Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .claude/skills/dld-reindex/scripts/find-collisions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ if ! git -C "$PROJECT_ROOT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/nu
exit 1
fi

TAKEN=$(bash "$SCRIPT_DIR/list-taken-ids.sh" --base "$BASE")
# @decision(DL-025)
# Collision detection asks the narrow question: which claims conflict with
# mine? Decisions already in this branch's history are mine, however they got
# here — including via a PR this branch is stacked on. They stay taken for ID
# allocation, which is why the filter is requested here rather than baked into
# list-taken-ids.sh.
TAKEN=$(bash "$SCRIPT_DIR/list-taken-ids.sh" --base "$BASE" --exclude-contained)

LOCAL_ADDED=$(git -C "$PROJECT_ROOT" diff --name-only --diff-filter=A "$BASE"...HEAD -- "$RECORDS_DIR_REL" 2>/dev/null || true)

Expand Down
42 changes: 37 additions & 5 deletions .claude/skills/dld-reindex/scripts/list-taken-ids.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
#!/usr/bin/env bash
# Output the set of decision IDs taken on the base branch and (best-effort) open PRs.
# One DL-NNN per line, sorted unique.
# Usage: list-taken-ids.sh [--base <ref>]
# Usage: list-taken-ids.sh [--base <ref>] [--exclude-contained]
#
# --exclude-contained drops PRs whose head branch is already an ancestor of
# HEAD. Those IDs are still *taken* — they are in this branch's tree — so the
# flag is off by default and the plain output stays a faithful answer to "which
# IDs are claimed anywhere", which is what ID allocation needs. Callers asking
# the narrower question "which claims conflict with mine" opt in.
# Default base: origin/main
# Emits a stderr note when the open-PR scan is skipped (gh missing, repo not on GitHub,
# or gh not authenticated). Exits 0 in all those cases — the base-branch scan is the
Expand All @@ -13,9 +19,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../dld-common/scripts/common.sh"

BASE="origin/main"
EXCLUDE_CONTAINED=false
while [[ $# -gt 0 ]]; do
case "$1" in
--base) BASE="$2"; shift 2 ;;
--exclude-contained) EXCLUDE_CONTAINED=true; shift ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
Expand Down Expand Up @@ -48,12 +56,36 @@ PR_BASE="${BASE#origin/}"

# IDs in files touched by open PRs targeting this base. Scope to paths under
# the records dir so an unrelated PR touching e.g. notes/DL-007-meeting.md
# doesn't poison the taken set. A PR whose head is the current branch is not
# a collision: it holds this branch's own decisions.
# doesn't poison the taken set.
#
# @decision(DL-025)
# A PR whose head is this branch holds this branch's own decisions, so it is
# never a competing claim.
#
# With --exclude-contained, a PR this branch is stacked on is dropped too:
# its head is an ancestor of HEAD, so its decisions are already in this
# branch's tree. That is the normal DLD workflow — decisions land as one PR,
# implementation branches are cut from it — and without the filter every
# decision such a branch exists to implement reads as a collision. It is
# opt-in because those IDs remain taken for allocation purposes.
#
# When the head ref is absent locally, ancestry can't be established and the
# PR stays in the set: an unfetched branch is treated as foreign, so a
# missing fetch can't silently drop a real claim.
if [[ -z "$SKIP_REASON" ]]; then
CURRENT_BRANCH="$(git -C "$PROJECT_ROOT" branch --show-current 2>/dev/null || true)"
gh pr list --state open --base "$PR_BASE" --json files,headRefName --limit 100 \
--jq ".[] | select(.headRefName != \"$CURRENT_BRANCH\") | .files[].path" 2>/dev/null \
while IFS=$'\t' read -r head path; do
[[ -z "$head" || -z "$path" ]] && continue
[[ "$head" == "$CURRENT_BRANCH" ]] && continue
if [[ "$EXCLUDE_CONTAINED" == true ]] \
&& git -C "$PROJECT_ROOT" merge-base --is-ancestor "origin/$head" HEAD 2>/dev/null; then
continue
fi
printf '%s\n' "$path"
done < <(
gh pr list --state open --base "$PR_BASE" --json files,headRefName --limit 100 \
--jq '.[] | .headRefName as $h | .files[].path | [$h, .] | @tsv' 2>/dev/null || true
) \
| grep -E "^${RECORDS_DIR_REL}/" \
| grep -oE 'DL-[0-9]+' || true
fi
Expand Down
1 change: 1 addition & 0 deletions decisions/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

| ID | Title | Status | Tags |
|----|-------|--------|------|
| DL-025 | Collision detection ignores decisions this branch already contains | accepted | dld-reindex, guards |
| DL-024 | Deepen dld-core: unified Exec, lifecycle ops, startRun, CompletionTracker, bounds, paths, DispatchGuard | proposed | dld-run, dld-core, pi-package, opencode |
| DL-023 | Bounded re-delivery and inline state-machine instructions in the dispatch prompt | accepted | opencode, dld-run, loop |
| DL-022 | Create extensions/dld-core: a TypeScript-native library with a function-shaped API | accepted | dld-core, architecture, typescript |
Expand Down
48 changes: 48 additions & 0 deletions decisions/records/DL-025.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
id: DL-025
title: "Collision detection ignores decisions this branch already contains"
timestamp: 2026-08-31T10:11:09Z
status: accepted
supersedes: []
amends: []
tags: [dld-reindex,guards]
references: []
---

## Context

`guard-preconditions.sh` refuses to start a run when a decision ID on this branch collides with one already taken on the base branch or in an open PR. The taken set comes from `list-taken-ids.sh`, which scans the base branch's records and the files of open PRs targeting that base.

The open-PR scan excludes exactly one PR: the one whose head branch equals the current branch. That is too narrow. Stacking is the normal DLD workflow — decisions are recorded and pushed as one PR, then implementation happens on branches cut from it. Those branches contain the decisions PR's commits, so every decision they are meant to implement is reported as a collision, and the run cannot start.

Observed in practice: an implementation branch cut from an open decisions PR could not start a run. The base branch held no decision records at all, yet every decision on the branch was reported as colliding with it, with `/dld-reindex` demanded as the remedy even though nothing needed renaming.

## Decision

A decision this branch already contains is not a collision. `find-collisions.sh` asks `list-taken-ids.sh` for the taken set with `--exclude-contained`, which drops open PRs whose head branch is an ancestor of HEAD, in addition to the existing same-name check.

The flag is opt-in, and the filter belongs at the call site rather than in the reporting script. Those IDs remain taken for allocation — they exist in this branch's tree — so the default output still answers "which IDs are claimed anywhere", which is what `plan-renames.sh` needs when choosing free IDs for renamed records. Collision detection asks the narrower question, "which claims conflict with mine", and only that caller opts in.

Ancestry is tested with `git merge-base --is-ancestor origin/<head> HEAD`. When the head ref is not present locally, ancestry cannot be established and the PR's IDs stay in the set — an unfetched branch is treated as foreign, which keeps the check conservative in the direction that preserves safety.

The same-name check stays, and stays unconditional. A branch that has diverged from its remote (force-push pending) fails the ancestry test while still being its own PR.

## Rationale

A collision means two different decisions claim one ID. When this branch contains the commit that introduced the record, there is only one decision and one claimant: the ID is this branch's own. Reporting it as a collision inverts the guard's purpose, and the prescribed remedy (`/dld-reindex`) would renumber records that nothing else conflicts with.

Ancestry is the precise test for "already mine". Comparing IDs alone cannot distinguish a stacked branch from an unrelated one, and comparing branch names only covers the single-branch case.

Keeping the filter out of `list-taken-ids.sh` by default preserves that script's meaning. "Taken" and "conflicting" are different questions: an ID introduced by a PR this branch is stacked on is not a conflict, but it is certainly taken, and an allocator that treats it as free hands out an ID that already exists in the tree.

Falling back to "taken" when the head ref is missing keeps a missing fetch from silently disabling the guard. The cost is a false positive that a `git fetch` resolves; the alternative cost is a real collision reaching a run.

## Consequences

Stacked implementation branches can start runs against the decisions they are stacked on — the workflow DLD prescribes. Runs are no longer blocked by their own decisions.

The guard still catches the case it exists for: an unrelated branch or PR that has independently claimed the same ID.

`list-taken-ids.sh` keeps its documented contract, so calling it directly — which the reindex SKILL suggests for debugging — still reports every claimed ID.

Not covered: a decision cherry-picked onto a sibling branch, where the same record exists in two places with no ancestry between them. That still reports as a collision, correctly at the ID level but arguably unhelpfully. Blob-identity comparison would resolve it and is deliberately left out until it is seen in practice.
17 changes: 17 additions & 0 deletions extensions/opencode-dld-run/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "opencode-dld-run",
"version": "0.9.0",
"private": true,
"type": "module",
"description": "OpenCode plugin for dld-run: the goal-loop driver and its TUI surfaces.",
"exports": {
".": "./server.ts",
"./tui": "./tui.tsx"
},
"peerDependencies": {
"@opencode-ai/plugin": "beta",
"@opentui/core": ">=0.5.8",
"@opentui/solid": ">=0.5.8",
"solid-js": ">=1.9.0"
}
}
175 changes: 175 additions & 0 deletions scripts/install-opencode-plugin.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
# Install the dld-run OpenCode plugin into a project.
#
# Usage:
# bash /path/to/dld-kit/scripts/install-opencode-plugin.sh [project-dir]
#
# Defaults to the current directory. Symlinks the plugins back to this
# checkout rather than copying them, because bun resolves a symlinked
# module to its real path before resolving that module's imports. That
# gives three things for free:
#
# - ../dld-core/*.ts resolves inside dld-kit, not inside the project
# - packageRoot()'s existence walk finds dld-kit unaided, so no source
# patching is needed (the patching approach silently rotted when the
# function it rewrote changed shape)
# - node_modules resolve from dld-kit
#
# Edits in dld-kit take effect the next time OpenCode reloads the plugin.

set -euo pipefail

DLD_KIT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT="${1:-$PWD}"

cd "$PROJECT"

if [[ ! -d .git ]]; then
echo "Error: $PROJECT is not a git repository." >&2
exit 1
fi

PLUGIN_SRC="$DLD_KIT/extensions/opencode-dld-run"
SERVER_SRC="$PLUGIN_SRC/server.ts"
TUI_SRC="$PLUGIN_SRC/tui.tsx"
MANIFEST_SRC="$PLUGIN_SRC/package.json"
GUARD_SRC="$DLD_KIT/skills/dld-run/scripts/guard-preconditions.sh"

for f in "$SERVER_SRC" "$TUI_SRC" "$MANIFEST_SRC" "$GUARD_SRC"; do
[[ -f "$f" ]] || { echo "Error: missing $f — is $DLD_KIT a dld-kit checkout?" >&2; exit 1; }
done

# The symlinked plugins resolve @opencode-ai/plugin and the TUI peers from
# dld-kit's node_modules, so dld-kit needs its own install.
if [[ ! -d "$DLD_KIT/node_modules/@opencode-ai/plugin" ]]; then
echo "Installing dld-kit dependencies..."
(cd "$DLD_KIT" && bun install >/dev/null 2>&1)
fi

PLUGIN_DIR=".opencode/plugins"
PKG_DIR="$PLUGIN_DIR/dld-run"

# A previous version of this installer linked $PKG_DIR straight at dld-kit's
# extension directory. Writing into a path that is a symlink to a directory
# resolves through it, so removing and rewriting "the plugin's files" would
# delete and overwrite dld-kit's own sources. Drop the link itself first, and
# only then create a real directory.
[[ -L "$PKG_DIR" ]] && rm -f "$PKG_DIR"
[[ -L "$PLUGIN_DIR" ]] && rm -f "$PLUGIN_DIR"

mkdir -p "$PKG_DIR"

# Belt and braces for the same class of mistake: never write into anything
# that resolves inside the dld-kit checkout.
PKG_REAL="$(cd "$PKG_DIR" && pwd -P)"
KIT_REAL="$(cd "$DLD_KIT" && pwd -P)"
case "$PKG_REAL/" in
"$KIT_REAL"/*)
echo "Error: $PKG_DIR resolves to $PKG_REAL, inside the dld-kit checkout." >&2
echo "Refusing to write there — remove that path and re-run." >&2
exit 1
;;
esac

# Only a package exposing a "./tui" export gets its CLI plugin loaded, so the
# plugin is installed as a package directory rather than as loose files: a
# stray tui.tsx under plugins/ is never discovered, which is why the run had
# no UI surfaces.
#
# The directory and its files are real, not symlinks. A symlinked plugin
# *directory* is skipped by directory discovery (a symlink is not a directory
# to readdir), even though a symlinked loose file is picked up. The files are
# one-line re-exports of the modules in dld-kit, so the code still has exactly
# one home and edits there take effect on reload.
rm -rf .opencode/dld-core "$PLUGIN_DIR/tui"
rm -f "$PLUGIN_DIR/dld-run.ts" "$PKG_DIR/package.json" "$PKG_DIR/index.ts" "$PKG_DIR/server.ts" "$PKG_DIR/tui.tsx"

# The server entrypoint must be index.ts. A plugin directory is resolved by
# its index file, not by package.json "main" or "exports" — pointing those at
# server.ts produced "configured plugin directory has no index entrypoint"
# and the plugin was dropped from the location's plugin set entirely, while
# still logging a "loading plugin" line.
cat > "$PKG_DIR/package.json" <<'MANIFEST'
{
"name": "opencode-dld-run",
"version": "0.9.0",
"private": true,
"type": "module",
"main": "./index.ts",
"exports": {
".": "./index.ts",
"./tui": "./tui.tsx"
}
}
MANIFEST

printf 'export { default } from "%s";\n' "$SERVER_SRC" > "$PKG_DIR/index.ts"
printf 'export { default } from "%s";\n' "$TUI_SRC" > "$PKG_DIR/tui.tsx"

# Belt and braces: also register the package explicitly. Auto-discovery of
# .opencode/plugins/ is documented, but an explicit entry is what the docs
# guarantee for "configured plugins that expose a TUI component are loaded
# automatically by the CLI". Paths resolve relative to the config file.
CONFIG=".opencode/opencode.json"
if [[ -f "$CONFIG" ]]; then
if command -v jq >/dev/null 2>&1; then
if ! jq -e '(.plugins // []) | index("./plugins/dld-run")' "$CONFIG" >/dev/null 2>&1; then
tmp="$(mktemp)"
jq '.plugins = ((.plugins // []) + ["./plugins/dld-run"] | unique)' "$CONFIG" > "$tmp" && mv "$tmp" "$CONFIG"
echo " registered ./plugins/dld-run in $CONFIG"
fi
else
echo " note: jq not found — add \"./plugins/dld-run\" to \"plugins\" in $CONFIG yourself" >&2
fi
else
cat > "$CONFIG" <<'CONFIGJSON'
{
"$schema": "https://opencode.ai/config.json",
"plugins": ["./plugins/dld-run"]
}
CONFIGJSON
echo " wrote $CONFIG registering ./plugins/dld-run"
fi

# Verify rather than assume: load the plugin the way OpenCode will, then ask
# dld-core where it thinks its scripts live. A silent misresolution here is
# what made the previous installer's failure so hard to diagnose.
echo "Verifying the installed package loads..."
bun -e "
const server = await import('$PWD/$PKG_DIR/index.ts');
const tui = await import('$PWD/$PKG_DIR/tui.tsx');
for (const [name, mod] of [['server', server], ['tui', tui]]) {
const def = mod.default;
if (typeof def?.id !== 'string' || typeof def?.setup !== 'function') {
throw new Error(name + ' export is not a { id, setup } plugin definition');
}
}
" >/dev/null || {
echo "Error: the symlinked plugin failed to load." >&2
exit 1
}

RESOLVED="$(bun -e "
import { packageRoot, missingScripts } from '$DLD_KIT/extensions/dld-core/paths.ts';
const missing = missingScripts();
console.log(packageRoot());
console.log(missing.length ? 'MISSING: ' + missing.join(', ') : 'all scripts present');
")"
echo "$RESOLVED" | sed 's/^/ /'

case "$RESOLVED" in
"$DLD_KIT"*) ;;
*) echo "Error: packageRoot resolved outside dld-kit — the plugin would not find its scripts." >&2; exit 1 ;;
esac
case "$RESOLVED" in
*MISSING:*) echo "Error: required scripts are missing from $DLD_KIT." >&2; exit 1 ;;
esac

echo "dld-run plugin installed:"
echo " package: $PROJECT/$PKG_DIR (re-exports $PLUGIN_SRC)"
echo " . -> index.ts (server runtime)"
echo " ./tui -> tui.tsx (CLI surfaces)"
echo
echo "Plugins load in OpenCode's background service, not the TUI. If a running"
echo "session does not pick this up, restart the service:"
echo " pkill -f 'opencode2.*serve --service' && opencode2 -c"
8 changes: 7 additions & 1 deletion skills/dld-reindex/scripts/find-collisions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ if ! git -C "$PROJECT_ROOT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/nu
exit 1
fi

TAKEN=$(bash "$SCRIPT_DIR/list-taken-ids.sh" --base "$BASE")
# @decision(DL-025)
# Collision detection asks the narrow question: which claims conflict with
# mine? Decisions already in this branch's history are mine, however they got
# here — including via a PR this branch is stacked on. They stay taken for ID
# allocation, which is why the filter is requested here rather than baked into
# list-taken-ids.sh.
TAKEN=$(bash "$SCRIPT_DIR/list-taken-ids.sh" --base "$BASE" --exclude-contained)

LOCAL_ADDED=$(git -C "$PROJECT_ROOT" diff --name-only --diff-filter=A "$BASE"...HEAD -- "$RECORDS_DIR_REL" 2>/dev/null || true)

Expand Down
Loading
Loading