@ngockhoale/ukit 3.4.21 → 3.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/manifests/documentation.yaml +11 -0
  3. package/manifests/platform.full.yaml +42 -0
  4. package/package.json +1 -1
  5. package/src/core/docContracts.js +60 -6
  6. package/src/core/hookChainDoctor.js +39 -3
  7. package/template_project/.claude/.gitattributes +2 -0
  8. package/template_project/.claude/agents/feature-implementer.md +10 -3
  9. package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
  10. package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
  11. package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
  12. package/template_project/.claude/hooks/handoff-resume.sh +36 -11
  13. package/template_project/.claude/hooks/protect-files.sh +10 -2
  14. package/template_project/.claude/hooks/vision-router.sh +11 -1
  15. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
  16. package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
  17. package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
  18. package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
  19. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
  20. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +143 -6
  21. package/template_project/.claude/ukit/runtime/hook-process.mjs +120 -4
  22. package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
  23. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
  24. package/template_project/.omp/agents/feature-implementer.md +10 -3
  25. package/template_project/.omp/hooks/pre/ukit-bridge.js +12 -0
  26. package/template_project/docs/UKIT_INTERNALS.md +14 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.23 - 2026-10-08
6
+
7
+ **Core hook/gate reliability repairs** (C97; each backed by baseline RED, GREEN and independent different-model review).
8
+ - `stale-spec-check`: containment errors for external/traversal targets are caught at the checker boundary instead of crashing the hook chain.
9
+ - `hook-chain-runner`: bounded failure provenance and diagnostics while staying fail-closed.
10
+ - `docContracts`: `checkRenderVars` and `collectScannableDocs` prune isolated worktree checkouts; the walk skips `node_modules` and breaks symlink cycles by realpath (live-repo contract check 12.4s -> 0.3s).
11
+ - Handoff intent: exact command-suffix boundary, `handoff-implement` recognized, torn newest JSONL entry yields unknown instead of an older opposite intent; `handoff-create`-owned runs stay planning-only through Stop/resume.
12
+ - `protect-files`: backslash path spellings get the same protection as slash paths.
13
+ - `land-worktree`: complete base-relative copy-back (committed, staged, deleted, renamed, newline paths).
14
+ - `cleanup-task-worktree`: receipt-gated, recoverable cleanup with source/destination authority containment; no forced removal.
15
+ - Vision: already-pending unread image is idempotent; a corrupt pending marker is retained with a diagnostic.
16
+ - `output-compression`: a subagent's raw tool output no longer charges the parent's compact-pressure session (hard-cap gates unchanged).
17
+ - Not included: shell create-scope detection (TASK-C97-007) and decision-log lock reclaim (TASK-C97-013) failed final review and were reverted to the 3.4.22 behavior.
18
+ - Packed-artifact size cap raised 3.16 MB -> 3.17 MB.
19
+
20
+ ## 3.4.22 - 2026-10-07
21
+
22
+ **Windows: `.sh` hooks no longer deny every Edit/Write/Bash.**
23
+ - `.sh` hooks are now launched through Git for Windows bash: `UKIT_BASH_BIN` → `CLAUDE_CODE_GIT_BASH_PATH` →
24
+ default Git install paths → absolute `PATH` entries. WSL's `bash.exe` and a cwd `bash.exe` are never used.
25
+ - On Windows a `.sh` hook whose bash cannot be found or started is reported as `spawn-unavailable` and warned
26
+ instead of denying every Edit/Write/Bash. POSIX behaviour is unchanged; timeouts, real blocks, missing gate
27
+ files and `block-dangerous.sh` stay fail-closed.
28
+ - `ukit doctor` flags Windows hook launch failures.
29
+ - `*.sh` is pinned to LF (repo `.gitattributes` and a shipped `.claude/.gitattributes`) so a CRLF checkout cannot
30
+ break hook scripts. Troubleshooting added to `docs/INSTALL.md`.
31
+
5
32
  ## 3.4.21 - 2026-10-07
6
33
 
7
34
  **Hook runtime no longer burns CPU/disk on every tool call** (five measured bugs, C95).
@@ -707,6 +707,17 @@ entries:
707
707
  validation: [manual]
708
708
  archive_policy: never
709
709
  notes: non-.md entry — exists for FR-002 manifest consistency
710
+ - id: tpl-claude-gitattributes
711
+ path: template_project/.claude/.gitattributes
712
+ class: canonical
713
+ audience: [maintainer]
714
+ owner: product
715
+ source_of_truth: template_project/.claude/.gitattributes
716
+ merge_strategy: skip
717
+ load_policy: on-demand
718
+ validation: [manual]
719
+ archive_policy: never
720
+ notes: non-.md entry — keeps hook *.sh LF on Windows autocrlf checkouts; exists for FR-002 manifest consistency
710
721
 
711
722
  # ── repo mirrors (derived from template_project/) ───────────────────────────────
712
723
  - id: mirror-claude-dir
@@ -1511,6 +1511,19 @@ items:
1511
1511
  packs:
1512
1512
  - core
1513
1513
 
1514
+ # Ships `*.sh text eol=lf` inside .claude/ only (never the user's root .gitattributes) so
1515
+ # a Windows autocrlf checkout cannot turn hook scripts into CRLF that bash rejects.
1516
+ - id: claude-gitattributes
1517
+ type: config
1518
+ sourceTemplate: .claude/.gitattributes
1519
+ targetPath: .claude/.gitattributes
1520
+ requires: []
1521
+ mergeStrategy: skip
1522
+ variables: []
1523
+ enabledByDefault: true
1524
+ packs:
1525
+ - core
1526
+
1514
1527
  - id: hook-reset-compact-pressure
1515
1528
  type: hook
1516
1529
  sourceTemplate: .claude/hooks/reset-compact-pressure.sh
@@ -1640,6 +1653,35 @@ items:
1640
1653
  packs:
1641
1654
  - core
1642
1655
 
1656
+ # C97 TASK-C97-009 (MODULE04 FR-M4-004): the task-scoped landing helper that
1657
+ # replaces the unstaged-only cp loop in the fullstack/implement 3c samples.
1658
+ - id: ukit-index-land-worktree-script
1659
+ type: config
1660
+ sourceTemplate: .claude/ukit/index/land-worktree.mjs
1661
+ targetPath: .claude/ukit/index/land-worktree.mjs
1662
+ requires:
1663
+ - ukit-runtime-scripts
1664
+ mergeStrategy: overwrite_with_backup
1665
+ variables: []
1666
+ enabledByDefault: true
1667
+ packs:
1668
+ - core
1669
+
1670
+ # C97 TASK-C97-010 (MODULE04 FR-M4-003/003a): the receipt-gated recoverable
1671
+ # cleanup helper that replaces the force-remove/-D samples in the fullstack,
1672
+ # implement and clear command docs.
1673
+ - id: ukit-index-cleanup-task-worktree-script
1674
+ type: config
1675
+ sourceTemplate: .claude/ukit/index/cleanup-task-worktree.mjs
1676
+ targetPath: .claude/ukit/index/cleanup-task-worktree.mjs
1677
+ requires:
1678
+ - ukit-runtime-scripts
1679
+ mergeStrategy: overwrite_with_backup
1680
+ variables: []
1681
+ enabledByDefault: true
1682
+ packs:
1683
+ - core
1684
+
1643
1685
  - id: ukit-index-stale-spec-check-script
1644
1686
  type: config
1645
1687
  sourceTemplate: .claude/ukit/index/stale-spec-check.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.21",
3
+ "version": "3.4.23",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -92,10 +92,27 @@ async function isDir(absPath) {
92
92
  }
93
93
 
94
94
  // Recursive repo-relative file walk under rootDir/dirRel. `filter` selects
95
- // files by extension etc. Never throws (unreadable dirs are skipped).
96
- async function walkFiles(rootDir, dirRel, filter = () => true) {
95
+ // files by extension etc. `pruneDir(rel)` optionally excludes a directory
96
+ // subtree BEFORE descent (default: never prune — unrelated consumers keep the
97
+ // full walk). `dirIs` carries a per-top-level-walk set of descended directory
98
+ // `fs.realpath`s; because `fs.stat` follows symlinks, a symlink cycle (e.g.
99
+ // the live repo's `node_modules/node_modules -> node_modules`, or an
100
+ // exploration worktree symlinked at the repo root) would otherwise grow
101
+ // unbounded — the realpath guard makes every real directory traverse at most
102
+ // once. A directory named `node_modules` is never doc scope, so it is skipped
103
+ // by basename before its installed-tree contents are walked. Never throws
104
+ // (unreadable dirs are skipped).
105
+ async function walkFiles(rootDir, dirRel, filter = () => true, pruneDir = null, dirIs = null) {
106
+ const dirs = dirIs || new Set();
97
107
  const out = [];
98
108
  const dirAbs = dirRel ? path.join(rootDir, dirRel) : rootDir;
109
+ // Seed the starting directory so a child symlink pointing back at it (or at
110
+ // any ancestor of this walk) is recognized as a cycle immediately.
111
+ try {
112
+ dirs.add(await fs.realpath(dirAbs));
113
+ } catch {
114
+ /* realpath unavailable — descend without the cycle seed */
115
+ }
99
116
  let names;
100
117
  try {
101
118
  names = await fs.readdir(dirAbs);
@@ -110,12 +127,36 @@ async function walkFiles(rootDir, dirRel, filter = () => true) {
110
127
  } catch {
111
128
  continue; // dangling symlink / unreadable
112
129
  }
113
- if (st.isDirectory()) out.push(...(await walkFiles(rootDir, rel, filter)));
114
- else if (filter(name, rel)) out.push(rel);
130
+ if (st.isDirectory()) {
131
+ if (pruneDir && pruneDir(rel)) continue;
132
+ if (name === 'node_modules') continue; // never doc scope — skip the installed tree
133
+ let real;
134
+ try {
135
+ real = await fs.realpath(path.join(rootDir, rel));
136
+ } catch {
137
+ continue; // unreadable — nothing to descend
138
+ }
139
+ if (dirs.has(real)) continue; // symlink cycle / real directory already walked
140
+ dirs.add(real);
141
+ out.push(...(await walkFiles(rootDir, rel, filter, pruneDir, dirs)));
142
+ } else if (filter(name, rel)) out.push(rel);
115
143
  }
116
144
  return out;
117
145
  }
118
146
 
147
+ // SPEC FR-004 (TASK-C97-003): true when `rel` is inside an isolated checkout
148
+ // worktree subtree — `.claude/worktrees/<agent>/` or `.worktrees/<task>/`.
149
+ // Exact path-COMPONENT comparison (not a string prefix), so ordinary lookalike
150
+ // directories such as `.claude/worktrees-not-isolated/` stay in scope.
151
+ function isIsolatedCheckoutSubtree(rel) {
152
+ const parts = rel.split('/');
153
+ for (let i = 0; i < parts.length; i++) {
154
+ if (parts[i] === '.worktrees') return true;
155
+ if (parts[i] === '.claude' && parts[i + 1] === 'worktrees') return true;
156
+ }
157
+ return false;
158
+ }
159
+
119
160
  function makeCheck(id, label, severity, extra) {
120
161
  return { id, label, severity, passed: false, ...extra };
121
162
  }
@@ -314,8 +355,17 @@ async function checkRenderVars(rootDir, registry) {
314
355
  if (typeof e.path !== 'string' || e.class !== 'derived' || e.path.startsWith('template_project/')) continue;
315
356
  // Only .md doc surfaces are render targets — scripts/components/templates
316
357
  // inside derived dirs legitimately use {{ }} as their own placeholders.
358
+ // FR-004: descent prunes exact isolated-checkout subtrees BEFORE walking
359
+ // (render-only prune; raw nested CHANGELOG/docs placeholders belong to
360
+ // exploration worktrees, not the current checkout). All other .claude
361
+ // content, lookalike dirs and root instructions stay enforced.
317
362
  if (e.path.endsWith('/')) {
318
- for (const f of await walkFiles(rootDir, e.path.slice(0, -1), (n) => n.endsWith('.md'))) {
363
+ for (const f of await walkFiles(
364
+ rootDir,
365
+ e.path.slice(0, -1),
366
+ (n) => n.endsWith('.md'),
367
+ isIsolatedCheckoutSubtree,
368
+ )) {
319
369
  targets.add(f);
320
370
  }
321
371
  } else if (e.path.endsWith('.md')) {
@@ -444,7 +494,11 @@ export async function collectScannableDocs(rootDir, registry) {
444
494
  if (typeof e.path === 'string' && e.path.endsWith('/')) dirs.add(e.path.slice(0, -1));
445
495
  }
446
496
  for (const dirRel of dirs) {
447
- for (const f of await walkFiles(rootDir, dirRel, (n) => n.endsWith('.md'))) {
497
+ // FR-004 (TASK-C97-018): prune isolated-checkout subtrees before descent —
498
+ // exploration worktrees are not part of the current checkout, and their
499
+ // own node_modules symlink would otherwise drag the whole installed tree
500
+ // into the walk. `walkFiles` additionally breaks symlink cycles by realpath.
501
+ for (const f of await walkFiles(rootDir, dirRel, (n) => n.endsWith('.md'), isIsolatedCheckoutSubtree)) {
448
502
  files.add(f);
449
503
  }
450
504
  }
@@ -24,12 +24,31 @@ const FAILURE_OUTCOMES = new Set([
24
24
  'output-overflow',
25
25
  'error',
26
26
  'signal',
27
+ 'spawn-unavailable',
27
28
  ]);
28
29
 
29
30
  // The pre-O6 remedy, kept verbatim: a failure with no gate evidence must read
30
31
  // exactly as it did before escalation existed (SPEC §FR-012).
32
+ // SPEC §FR-007: launch failures only arise on win32 (the runner emits
33
+ // `spawn-unavailable` from its bash-launcher path), so there is one remedy.
34
+ const LAUNCH_REMEDY = 'Windows cannot execute .sh hooks directly — install Git for Windows so C:\\Program Files\\Git\\bin\\bash.exe exists, or set UKIT_BASH_BIN to the full path of bash.exe, then restart the session.';
35
+
36
+ // Legacy pre-fix win32 rows recorded a failed .sh launch as a plain `error`
37
+ // that finished almost instantly.
38
+ const LEGACY_LAUNCH_FAILURE_MAX_MS = 10;
39
+
31
40
  const BASE_REMEDY = 'Inspect recent rows in .ukit/storage/cache/hook-latency/ for the failing scriptName/failureKind and fix or widen the gate budget.';
32
41
 
42
+ function isLaunchFailure(entry, platform) {
43
+ if (!entry || typeof entry !== 'object') return false;
44
+ if (typeof entry.scriptName !== 'string' || !entry.scriptName.toLowerCase().endsWith('.sh')) return false;
45
+ if (entry.failureKind === 'spawn-unavailable') return true;
46
+ return platform === 'win32'
47
+ && entry.failureKind === 'error'
48
+ && typeof entry.elapsedMs === 'number'
49
+ && entry.elapsedMs < LEGACY_LAUNCH_FAILURE_MAX_MS;
50
+ }
51
+
33
52
  function telemetryDirFor(projectRoot) {
34
53
  return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
35
54
  }
@@ -78,7 +97,7 @@ async function tailLines(filePath, size) {
78
97
  }
79
98
  }
80
99
 
81
- export async function inspectHookChainHealth({ projectRoot, now = Date.now() } = {}) {
100
+ export async function inspectHookChainHealth({ projectRoot, now = Date.now(), platform = process.platform } = {}) {
82
101
  const label = 'hook-chain health';
83
102
 
84
103
  // Never-fatal posture extends to malformed input: a missing projectRoot is
@@ -106,6 +125,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
106
125
  // evidence shapes that prove it, collected in the same pass that counts
107
126
  // outcomes so no second read of the rows is needed.
108
127
  const gateFailures = new Set();
128
+ const launchFailures = new Set();
109
129
  let budgetExhaustedChains = 0;
110
130
 
111
131
  for (const file of files) {
@@ -130,6 +150,14 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
130
150
  const outcome = typeof row.outcome === 'string' && row.outcome.length > 0 ? row.outcome : 'ok';
131
151
  counts[outcome] = (counts[outcome] ?? 0) + 1;
132
152
 
153
+ // .sh launch failures are collected from EVERY row (an exit-code row can
154
+ // still carry one); they are a different signal from a gate that ran and failed.
155
+ if (Array.isArray(row.scripts)) {
156
+ for (const entry of row.scripts) {
157
+ if (isLaunchFailure(entry, platform)) launchFailures.add(entry.scriptName);
158
+ }
159
+ }
160
+
133
161
  // Escalation evidence is only ever read off a FAILING row — a gate that
134
162
  // ran fine is not evidence of anything, and SPEC §FR-012 scopes the
135
163
  // budget signal to a non-ok row ("a non-ok row ... escalates only when
@@ -155,6 +183,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
155
183
  if (!entry || typeof entry !== 'object') continue;
156
184
  if (entry.failClosed !== true) continue;
157
185
  if (typeof entry.failureKind !== 'string' || entry.failureKind === 'ok') continue;
186
+ if (entry.failureKind === 'spawn-unavailable') continue; // reported as a launch failure
158
187
  gateFailures.add(typeof entry.scriptName === 'string' ? entry.scriptName : 'unknown');
159
188
  }
160
189
  }
@@ -177,7 +206,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
177
206
  .filter((key) => FAILURE_OUTCOMES.has(key) && counts[key] > 0)
178
207
  .sort();
179
208
 
180
- if (failureKinds.length === 0) {
209
+ if (failureKinds.length === 0 && launchFailures.size === 0) {
181
210
  return {
182
211
  label,
183
212
  passed: true,
@@ -190,7 +219,9 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
190
219
  }
191
220
 
192
221
  const breakdown = failureKinds.map((kind) => `${kind}=${counts[kind]}`).join(', ');
193
- const baseDetail = `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`;
222
+ const baseDetail = failureKinds.length > 0
223
+ ? `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`
224
+ : `${counts.rowsScanned} chain row(s) scanned`;
194
225
 
195
226
  // O6 (SPEC §FR-012): escalate only when a failure actually landed ON a gate —
196
227
  // a gate that merely ran is not evidence. The escalation is purely additive:
@@ -207,6 +238,11 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
207
238
  `A fail-closed gate actually failed (${gateList.join(', ')}) — that is a real budget or path problem, not a slow advisory hook; widen the failing gate's budget or fix that step.`,
208
239
  );
209
240
  }
241
+ const launchList = [...launchFailures].sort();
242
+ if (launchList.length > 0) {
243
+ escalations.push(`.sh hook(s) could not be launched: ${launchList.join(', ')}`);
244
+ advices.push(LAUNCH_REMEDY);
245
+ }
210
246
  if (budgetExhaustedChains > 0) {
211
247
  escalations.push(`${budgetExhaustedChains} chain row(s) exhausted the total budget`);
212
248
  advices.push(
@@ -0,0 +1,2 @@
1
+ # Hook scripts must stay LF: a CRLF checkout makes bash fail on "\r".
2
+ *.sh text eol=lf
@@ -139,9 +139,16 @@ mode only; daily flow does not get a Progress section.
139
139
  reports `undeclared-drift`.
140
140
  - **Milestone commit.** The commit lives on your worktree branch only (e.g.
141
141
  `handoff/task-xxx`). Use a `-m "milestone: <name>"` message. Never commit outside your
142
- worktree, never push. The orchestrator's copy-back step (`git diff --name-only` against
143
- base) diffs the working tree, so intermediate commits on the worktree branch are free —
144
- **copy-back is unaffected** by milestone commits.
142
+ worktree, never push. The orchestrator's copy-back step lands the branch's final
143
+ base-relative snapshot (`land-worktree.mjs` diffs the working tree against the immutable
144
+ base), so intermediate commits on the worktree branch are free — **copy-back is
145
+ unaffected** by milestone commits.
146
+ - **Final clean milestone commit (required before PASS).** End your run with one final
147
+ milestone commit so the worktree's snapshot equals its committed tree — nothing staged,
148
+ nothing unstaged, no nonignored untracked file left behind. A Git-clean snapshot is what
149
+ lets a later receipt-gated cleanup remove the worktree without `--force`; a tree dirty
150
+ against the snapshot is retained with a named reason, never forced. Do not report PASS on
151
+ a dirty worktree.
145
152
  - **Resume.** When a previous run aborted, you will be respawned. Read `## Progress`,
146
153
  jump straight to the last entry whose `last-green:` is not `none`, and continue from
147
154
  there. Do NOT re-plan, do NOT restart from RED, do NOT re-write tests that already
@@ -9,21 +9,34 @@
9
9
 
10
10
  ---
11
11
 
12
- ## Step 1 — Force-remove all handoff worktrees and branches
12
+ ## Step 1 — Receipt-gated cleanup of handoff worktrees and branches
13
+
14
+ **Clearing is not licence to destroy recovery evidence.** Never force-remove a worktree and
15
+ never force-delete a branch — a stuck or abandoned task's unreported work is exactly what
16
+ someone may need to recover. Use the shared, receipt-gated helper: it removes
17
+ **only** the one task named by a verified landing receipt whose result was independently
18
+ Approved and whose source still matches the receipt snapshot and is Git-clean; everything
19
+ else is retained with a named reason.
20
+
21
+ Every remaining `.worktrees/*` entry and `handoff/*` branch must be named in a
22
+ **retained-reason report** (receipt missing, not approved, FAIL, missing model, dirty vs
23
+ snapshot, unmerged history). Unexplained leftovers fail the gate; nothing is removed by force.
13
24
 
14
25
  ```bash
15
- # Remove task worktrees
16
- git worktree list | grep '.worktrees/task-' | awk '{print $1}' | while read wt; do
17
- name=$(basename "$wt")
18
- git worktree remove "$wt" --force 2>/dev/null
19
- git branch -D "handoff/$name" 2>/dev/null
20
- done
21
- git worktree prune
22
-
23
- # Remove integration branch if still exists
24
- git branch -D handoff/integration 2>/dev/null || true
26
+ # For each task worktree, run the receipt-gated cleanup (dry-run first, then --apply):
27
+ node .claude/ukit/index/cleanup-task-worktree.mjs \
28
+ --receipt <receipt-json> --approval <approval-json> # dry-run: reports eligible/retained
29
+ node .claude/ukit/index/cleanup-task-worktree.mjs \
30
+ --receipt <receipt-json> --approval <approval-json> --apply # removes only that one verified task
31
+
32
+ # A worktree with NO verified receipt/approval is RETAINED with its reason — list it:
33
+ git worktree list # every .worktrees/* entry must be named in the retained-reason report
34
+ git branch | grep handoff # every handoff/* branch must be named in the retained-reason report
25
35
  ```
26
36
 
37
+ An unmerged `handoff/integration` branch is retained the same way: report it with its reason
38
+ (`unmerged history`), never delete it with `-D`.
39
+
27
40
  ## Step 2 — Unstage any pending changes
28
41
 
29
42
  ```bash
@@ -68,7 +81,9 @@ files behind, or the next cycle resumes from stale bytes.
68
81
  ## Step 5 — Report
69
82
 
70
83
  ```
71
- Cleared: X worktrees removed, Y branches deleted, staged changes unstaged.
84
+ Cleared: X receipt-gated tasks removed, staged changes unstaged.
85
+ Retained with reason: <list each surviving worktree/branch and why — receipt missing,
86
+ not approved, FAIL, missing model, dirty vs snapshot, unmerged history>.
72
87
  Handoff docs reset. Ready for new cycle.
73
88
  ```
74
89
 
@@ -462,43 +462,65 @@ them back a second time is what blows up the orchestrator's context window and k
462
462
  run mid-pipeline.
463
463
  ```
464
464
 
465
- **3c — Copy changes back + delete worktrees** (orchestrator, after each task reports):
465
+ **3c — Land each task's work + hand off worktree state** (orchestrator, after each task reports):
466
+
467
+ Landing is **task-scoped, base-relative and receipt-backed** — never an ad-hoc cp loop.
468
+ `land-worktree.mjs` snapshots the source worktree's FINAL content against the immutable
469
+ `$BASE` (committed milestone + staged + unstaged + nonignored untracked), so committed and
470
+ staged work is never dropped, deletions and rename origins are applied, and binary /
471
+ executable / symlink semantics and unusual filenames (space / newline / literal backslash)
472
+ survive. The helper refuses on any destination conflict, scope drift or identity drift
473
+ **before mutating anything** and writes no receipt on refusal.
466
474
 
467
475
  For **PASS + EXECUTOR_MODEL present:**
468
476
  ```bash
477
+ TASK="TASK-xxx"
469
478
  WORKTREE=".worktrees/task-xxx"
470
479
 
471
- # Copy modified tracked files to main working tree
472
- git -C "$WORKTREE" diff --name-only | while read f; do
473
- mkdir -p "$(dirname "$f")"
474
- cp "$WORKTREE/$f" "$f"
475
- done
476
-
477
- # Copy new untracked files
478
- git -C "$WORKTREE" ls-files --others --exclude-standard | while read f; do
479
- mkdir -p "$(dirname "$f")"
480
- cp "$WORKTREE/$f" "$f"
481
- done
482
-
483
- # Delete worktree and branch immediately
484
- git worktree remove "$WORKTREE" --force
485
- git branch -D handoff/task-xxx
480
+ # Scope = the exact task-approved target paths (both rename endpoints).
481
+ # Written outside the source worktree AND outside the target set.
482
+ cat > ".worktrees/$TASK-scope.json" <<JSON
483
+ {
484
+ "schemaVersion": 1,
485
+ "taskId": "$TASK",
486
+ "baseCommit": "$(git rev-parse $BASE)",
487
+ "sourceRoot": "$(git -C "$WORKTREE" rev-parse --show-toplevel)",
488
+ "destinationRoot": "$(git rev-parse --show-toplevel)",
489
+ "sourceHead": "$(git -C "$WORKTREE" rev-parse HEAD)",
490
+ "destinationHead": "$(git rev-parse HEAD)",
491
+ "targetPaths": ["<each approved path>"]
492
+ }
493
+ JSON
494
+
495
+ node .claude/ukit/index/land-worktree.mjs \
496
+ --scope ".worktrees/$TASK-scope.json" \
497
+ --receipt "docs/AI_HANDOFF/benchmark/$TASK-landing.json"
498
+ # exit 0 → landed (receipt written); exit 2 → refused, both repos untouched;
499
+ # exit 1 → apply/receipt failure, source retained, never a success claim.
500
+ # After each exit-0 landing, checkpoint-commit the landed paths (receipt stays outside the target set)
501
+ # BEFORE landing the next task: the helper refuses a destination with tracked modifications
502
+ # (destination-dirty), so uncommitted landed work would block every later task in the wave.
503
+ git add -- <landed paths> && git commit -m "checkpoint: $TASK landed"
504
+
505
+ # update INDEX.md: status → pending_review
486
506
  ```
487
507
 
488
508
  For **FAIL:**
489
509
  ```bash
490
- git worktree remove .worktrees/task-xxx --force
491
- git branch -D handoff/task-xxx
510
+ # Landing is refused by contract (no EXECUTOR_REPORT). Retain the worktree and branch
511
+ # as recovery evidence and mark it in INDEX.md:
512
+ # status → blocked, reason "FAIL"
492
513
  ```
493
514
 
494
515
  For **EXECUTOR_MODEL missing:**
495
516
  ```bash
496
- git worktree remove .worktrees/task-xxx --force
497
- git branch -D handoff/task-xxx
498
- # mark needs_executor_report in INDEX
517
+ # No model attestation → no landing authority. Retain the worktree and branch:
518
+ # status → needs_executor_report
499
519
  ```
500
520
 
501
- Worktrees are **always deleted immediately** — no exceptions.
521
+ A refused or retained worktree keeps its source recovery evidence. Removal is a separate,
522
+ receipt-gated step (`cleanup-task-worktree.mjs`); it is never an unconditional forced
523
+ removal or branch deletion sitting on the same page as the landing.
502
524
 
503
525
  **3d — Wave boundary: commit + context checkpoint** (orchestrator, after every wave)
504
526
 
@@ -546,12 +568,15 @@ logs used to cost. That difference is what makes a multi-wave cycle finish in on
546
568
  - EXECUTOR_MODEL missing → `needs_executor_report`
547
569
  Then **verify the write** (read back the updated status column).
548
570
 
549
- 2. Verify cleanup:
571
+ 2. Verify cleanup with the **retained-reason report** — not an only-main assertion:
550
572
  ```bash
551
- git worktree list # must show only main worktree
552
- git branch | grep handoff # must be empty
573
+ git worktree list # every remaining .worktrees/* entry must be named in the retained-reason report
574
+ git branch | grep handoff # every handoff/* branch must be named in the retained-reason report
553
575
  git diff --stat # summary of all uncommitted changes
554
576
  ```
577
+ A **retained-reason report** names every surviving worktree/branch and why it stays
578
+ (receipt missing, not approved, FAIL, missing model, dirty vs snapshot, unmerged
579
+ history). Unexplained leftovers fail the gate; nothing is removed by force.
555
580
 
556
581
  ---
557
582
 
@@ -744,19 +769,27 @@ start, and emit the Final Report below.
744
769
  Run at the very end of every cycle, regardless of outcome:
745
770
 
746
771
  ```bash
747
- git worktree list # must show ONLY main worktree
748
- git branch | grep handoff # must be EMPTY
772
+ git worktree list # every remaining .worktrees/* entry must be named in the retained-reason report
773
+ git branch | grep handoff # every handoff/* branch must be named in the retained-reason report
749
774
  git status # clean (fully approved + pushed) or expected partial state
750
775
  ```
751
776
 
752
- If any lingering worktrees or handoff branches found, force-remove them:
777
+ Every remaining `.worktrees/*` entry and every `handoff/*` branch must be named in a
778
+ **retained-reason report**: receipt missing, not approved, FAIL, missing model, dirty vs
779
+ snapshot, unmerged history. Unexplained leftovers fail the gate.
780
+
781
+ If a lingering worktree carries a verified, independently approved receipt whose source
782
+ still matches the snapshot and is Git-clean, retire it through the shared helper — never by
783
+ forcing it:
784
+
753
785
  ```bash
754
- # For each lingering worktree/branch:
755
- git worktree remove .worktrees/<name> --force
756
- git branch -D handoff/<name>
786
+ # For each lingering task worktree with a verified receipt + approval:
787
+ node .claude/ukit/index/cleanup-task-worktree.mjs \
788
+ --receipt <receipt-json> --approval <approval-json> --apply
789
+ # Anything without that authority is RETAINED and reported with its reason.
757
790
  ```
758
791
 
759
- Re-run assertions after removal to confirm clean state.
792
+ Re-run the assertions after cleanup to confirm the same named-retention state.
760
793
 
761
794
  ---
762
795