@ngockhoale/ukit 3.4.22 → 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.
- package/CHANGELOG.md +15 -0
- package/manifests/platform.full.yaml +29 -0
- package/package.json +1 -1
- package/src/core/docContracts.js +60 -6
- package/template_project/.claude/agents/feature-implementer.md +10 -3
- package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
- package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
- package/template_project/.claude/hooks/handoff-resume.sh +36 -11
- package/template_project/.claude/hooks/protect-files.sh +10 -2
- package/template_project/.claude/hooks/vision-router.sh +11 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
- package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
- package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +60 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
- package/template_project/.omp/agents/feature-implementer.md +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
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
|
+
|
|
5
20
|
## 3.4.22 - 2026-10-07
|
|
6
21
|
|
|
7
22
|
**Windows: `.sh` hooks no longer deny every Edit/Write/Bash.**
|
|
@@ -1653,6 +1653,35 @@ items:
|
|
|
1653
1653
|
packs:
|
|
1654
1654
|
- core
|
|
1655
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
|
+
|
|
1656
1685
|
- id: ukit-index-stale-spec-check-script
|
|
1657
1686
|
type: config
|
|
1658
1687
|
sourceTemplate: .claude/ukit/index/stale-spec-check.mjs
|
package/package.json
CHANGED
package/src/core/docContracts.js
CHANGED
|
@@ -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.
|
|
96
|
-
|
|
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())
|
|
114
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
143
|
-
base
|
|
144
|
-
**copy-back is
|
|
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 —
|
|
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
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
#
|
|
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
|
|
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 —
|
|
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
|
-
#
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
git -C "$WORKTREE"
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
|
552
|
-
git branch | grep handoff # must be
|
|
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
|
|
748
|
-
git branch | grep handoff # must be
|
|
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
|
-
|
|
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
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
792
|
+
Re-run the assertions after cleanup to confirm the same named-retention state.
|
|
760
793
|
|
|
761
794
|
---
|
|
762
795
|
|
|
@@ -172,49 +172,64 @@ second time is what blows up the orchestrator's context window and kills the run
|
|
|
172
172
|
|
|
173
173
|
> **For the human operator, on a tool with no agent support (Codex) — not an instruction to the model:** open each task in a separate session with the code model.
|
|
174
174
|
|
|
175
|
-
### 3c — Orchestrator:
|
|
175
|
+
### 3c — Orchestrator: land each task's work (task-scoped, receipt-backed)
|
|
176
|
+
|
|
177
|
+
Landing uses the shared helper, never an ad-hoc cp loop. `land-worktree.mjs` snapshots
|
|
178
|
+
the source worktree's FINAL content relative to the immutable `$BASE` — committed
|
|
179
|
+
milestone, staged, unstaged and nonignored untracked changes, with deletions, rename
|
|
180
|
+
origins and binary / executable / symlink semantics — and applies that exact delta to the
|
|
181
|
+
destination. It refuses (exit 2, no mutation, no receipt) on a dirty destination, a
|
|
182
|
+
target-path untracked collision, an out-of-scope change or identity drift.
|
|
176
183
|
|
|
177
184
|
For each task that reported back:
|
|
178
185
|
|
|
179
186
|
**PASS + EXECUTOR_MODEL present:**
|
|
180
187
|
```bash
|
|
188
|
+
TASK="TASK-xxx"
|
|
181
189
|
WORKTREE=".worktrees/task-xxx"
|
|
182
190
|
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
git -C "$WORKTREE"
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
191
|
+
# Scope file: exact task-approved target paths (both rename endpoints). Written
|
|
192
|
+
# outside the source worktree AND outside the target set.
|
|
193
|
+
cat > ".worktrees/$TASK-scope.json" <<JSON
|
|
194
|
+
{
|
|
195
|
+
"schemaVersion": 1,
|
|
196
|
+
"taskId": "$TASK",
|
|
197
|
+
"baseCommit": "$BASE",
|
|
198
|
+
"sourceRoot": "$(git -C "$WORKTREE" rev-parse --show-toplevel)",
|
|
199
|
+
"destinationRoot": "$(git rev-parse --show-toplevel)",
|
|
200
|
+
"sourceHead": "$(git -C "$WORKTREE" rev-parse HEAD)",
|
|
201
|
+
"destinationHead": "$(git rev-parse HEAD)",
|
|
202
|
+
"targetPaths": ["<each approved path>"]
|
|
203
|
+
}
|
|
204
|
+
JSON
|
|
205
|
+
|
|
206
|
+
node .claude/ukit/index/land-worktree.mjs \
|
|
207
|
+
--scope ".worktrees/$TASK-scope.json" \
|
|
208
|
+
--receipt "docs/AI_HANDOFF/benchmark/$TASK-landing.json"
|
|
209
|
+
# exit 0 → landed; exit 2 → refused (untouched); exit 1 → apply/receipt failure.
|
|
210
|
+
# After each exit-0 landing, checkpoint-commit the landed paths (receipt stays outside the target set)
|
|
211
|
+
# BEFORE landing the next task: the helper refuses a destination with tracked modifications
|
|
212
|
+
# (destination-dirty), so uncommitted landed work would block every later task in the wave.
|
|
213
|
+
git add -- <landed paths> && git commit -m "checkpoint: $TASK landed"
|
|
198
214
|
|
|
199
215
|
# update INDEX.md: status → pending_review
|
|
200
216
|
```
|
|
201
217
|
|
|
202
218
|
**FAIL:**
|
|
203
219
|
```bash
|
|
204
|
-
|
|
205
|
-
git branch -D handoff/task-xxx
|
|
220
|
+
# No landing for a FAIL report — retain the worktree and branch as recovery evidence.
|
|
206
221
|
# update INDEX.md: status → blocked
|
|
207
222
|
```
|
|
208
223
|
|
|
209
224
|
**EXECUTOR_MODEL missing:**
|
|
210
225
|
```bash
|
|
211
|
-
|
|
212
|
-
git branch -D handoff/task-xxx
|
|
226
|
+
# No model attestation → no landing authority. Retain the worktree and branch.
|
|
213
227
|
# update INDEX.md: status → needs_executor_report
|
|
214
228
|
```
|
|
215
229
|
|
|
216
230
|
**Orchestrator writes INDEX.md** — agents never touch INDEX directly.
|
|
217
|
-
|
|
231
|
+
Worktree/branch removal is a separate, receipt-gated step (`cleanup-task-worktree.mjs`) —
|
|
232
|
+
never an unconditional forced removal or branch deletion sitting next to the landing.
|
|
218
233
|
|
|
219
234
|
### 3d — Wave boundary: commit + context checkpoint
|
|
220
235
|
|
|
@@ -246,12 +261,18 @@ Then create the next wave's worktrees from `$BASE` and repeat 3a–3c.
|
|
|
246
261
|
All waves complete. Verify:
|
|
247
262
|
|
|
248
263
|
```bash
|
|
249
|
-
git worktree list # must
|
|
250
|
-
git branch | grep handoff # must be
|
|
264
|
+
git worktree list # every remaining .worktrees/* entry must be named in the retained-reason report
|
|
265
|
+
git branch | grep handoff # every handoff/* branch must be named in the retained-reason report
|
|
251
266
|
git log --oneline -n 10 # one commit per wave
|
|
252
267
|
git status # clean, or only trailing edits from the last wave
|
|
253
268
|
```
|
|
254
269
|
|
|
270
|
+
Every remaining `.worktrees/*` entry and every `handoff/*` branch must be named in a
|
|
271
|
+
**retained-reason report**: receipt missing, not approved, FAIL, missing model, dirty vs
|
|
272
|
+
snapshot, unmerged history. Unexplained leftovers fail the gate; nothing is removed by force.
|
|
273
|
+
An eligible task is retired only through the shared, receipt-gated helper
|
|
274
|
+
(`node .claude/ukit/index/cleanup-task-worktree.mjs --receipt <json> --approval <json> --apply`).
|
|
275
|
+
|
|
255
276
|
All changes are committed as **one commit per wave on `$BASE`**, nothing pushed. No branches
|
|
256
277
|
or worktrees remain. Set `Phase: done` in `docs/AI_HANDOFF/RUN.md`.
|
|
257
278
|
|
|
@@ -196,11 +196,21 @@ async function emitOrdinaryResume(payload, source) {
|
|
|
196
196
|
// The session's CURRENT handoff intent — structured transcript evidence, the
|
|
197
197
|
// same shared module the Stop gate uses. A session whose latest request is
|
|
198
198
|
// planning (`/ukit:handoff-create`) must never be pushed into implementing a
|
|
199
|
-
// stale RUN.md, compact or not. `
|
|
200
|
-
// module absent)
|
|
199
|
+
// stale RUN.md, compact or not. `intent` is null when the read was unknown (no
|
|
200
|
+
// transcript path, unreadable, module absent), which the planning-cursor branch
|
|
201
|
+
// below treats the same as create — a NONTERMINAL `Command: handoff-create`
|
|
202
|
+
// cursor is the planner's file, so an unknown read resolves toward planning.
|
|
201
203
|
const { intent } = await readHandoffIntent(
|
|
202
204
|
typeof payload.transcript_path === 'string' ? payload.transcript_path : null,
|
|
203
205
|
);
|
|
206
|
+
// A NONTERMINAL planning cursor is the one RUN.md the resume lane treats as
|
|
207
|
+
// planning rather than implementation (TASK-C97-006, FR-M2-003): its `Command`
|
|
208
|
+
// says a planner owns the file, so any latest intent that is NOT positively
|
|
209
|
+
// implementation (create, known-null, unknown) gets the planning note. Every
|
|
210
|
+
// other command — actual fullstack/implement/review — keeps the unchanged
|
|
211
|
+
// contract, and an unknown intent there still resolves toward the conservative
|
|
212
|
+
// resume, so a lost read never strands a real run.
|
|
213
|
+
const planningCursor = field('Command').trim().toLowerCase() === 'handoff-create';
|
|
204
214
|
|
|
205
215
|
// A brand-new session (startup / clear) did not start this run and may be about
|
|
206
216
|
// something else entirely (e.g. planning). Tell it the run exists, but do NOT
|
|
@@ -216,17 +226,32 @@ async function emitOrdinaryResume(payload, source) {
|
|
|
216
226
|
process.exit(0);
|
|
217
227
|
}
|
|
218
228
|
|
|
219
|
-
// Planning
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
229
|
+
// Planning outranks a live cursor: the user asked this session to plan (or the
|
|
230
|
+
// cursor itself is the planner's `handoff-create` RUN.md with no positive
|
|
231
|
+
// implementation intent), so report the paused run and let the planning
|
|
232
|
+
// continue. This is the one place an automatic resume must yield —
|
|
233
|
+
// /ukit:handoff-create is planning-only by contract, and /ukit:handoff-fullstack
|
|
234
|
+
// remains the explicit way to resume.
|
|
235
|
+
if (intent === 'create' || (planningCursor && intent !== 'fullstack')) {
|
|
236
|
+
// The reason is stated honestly either way — the session's own request is
|
|
237
|
+
// planning (create), or the cursor itself is the planner's `handoff-create`
|
|
238
|
+
// RUN.md with no positive implementation intent. Neither fabricates ownership.
|
|
239
|
+
const why = intent === 'create'
|
|
240
|
+
? [
|
|
241
|
+
"but this session's current request",
|
|
242
|
+
'is planning (/ukit:handoff-create). Stop gate and resume will not force the run here —',
|
|
243
|
+
'finish the planning first. To resume implementation explicitly, run /ukit:handoff-fullstack;',
|
|
244
|
+
'to discard the run, /ukit:handoff-clear.',
|
|
245
|
+
]
|
|
246
|
+
: [
|
|
247
|
+
`and its cursor is planning-only (Command: ${field('Command')}). Neither the Stop gate nor this`,
|
|
248
|
+
'resume will force implementation here. To resume implementation explicitly, run',
|
|
249
|
+
'/ukit:handoff-fullstack; to discard the run, /ukit:handoff-clear.',
|
|
250
|
+
];
|
|
224
251
|
process.stdout.write([
|
|
225
252
|
'UKit note: docs/AI_HANDOFF/RUN.md has an unfinished handoff-fullstack run '
|
|
226
|
-
+ `(Phase: ${phase}; Next: ${next || 'not recorded'})
|
|
227
|
-
|
|
228
|
-
'finish the planning first. To resume implementation explicitly, run /ukit:handoff-fullstack;',
|
|
229
|
-
'to discard the run, /ukit:handoff-clear.',
|
|
253
|
+
+ `(Phase: ${phase}; Next: ${next || 'not recorded'}),`,
|
|
254
|
+
...why,
|
|
230
255
|
].join('\n') + '\n');
|
|
231
256
|
process.exit(0);
|
|
232
257
|
}
|
|
@@ -102,7 +102,15 @@ if [ -z "$FILE_PATH" ]; then
|
|
|
102
102
|
exit 0
|
|
103
103
|
fi
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
# TASK-C97-008 (FR-M3-002): match backslash / Windows-shaped spellings the same
|
|
106
|
+
# as forward-slash ones. Normalize separators for LEXICAL basename/component
|
|
107
|
+
# matching ONLY — no filesystem resolution, no path writes, and no native
|
|
108
|
+
# Windows claim. `${var//pat/rep}` is a bash builtin, so this holds with or
|
|
109
|
+
# without jq. The original path stays in the stderr diagnostic; stdout keeps the
|
|
110
|
+
# generic reason and never exposes it.
|
|
111
|
+
MATCH_PATH="${FILE_PATH//\\//}"
|
|
112
|
+
|
|
113
|
+
BASENAME=$(basename "$MATCH_PATH")
|
|
106
114
|
|
|
107
115
|
# Allow .env.example explicitly
|
|
108
116
|
if [[ "$BASENAME" == ".env.example" ]]; then
|
|
@@ -112,7 +120,7 @@ fi
|
|
|
112
120
|
# Match exact sensitive filenames and directory components, never arbitrary substrings:
|
|
113
121
|
# `.env.d.ts` and `app.envrc` are source files, not secrets. The old `*".env"*`
|
|
114
122
|
# check blocked both, which could appear as a mysterious stopped edit.
|
|
115
|
-
PATH_SLASHED="/${
|
|
123
|
+
PATH_SLASHED="/${MATCH_PATH#/}"
|
|
116
124
|
PROTECTED_MATCH=""
|
|
117
125
|
case "$BASENAME" in
|
|
118
126
|
.env|.env.local|.env.production|package-lock.json|yarn.lock)
|
|
@@ -326,6 +326,13 @@ async function readTextSafe(filePath) {
|
|
|
326
326
|
const pendingMarkers = Array.isArray(extractorJson?.pendingMarkers)
|
|
327
327
|
? extractorJson.pendingMarkers
|
|
328
328
|
: [];
|
|
329
|
+
// Additive (SPEC FR-M5-001 §7/§8): a currently-named input whose marker already
|
|
330
|
+
// existed is NOT newly armed, so it must not drive the NEW hint. It is still a
|
|
331
|
+
// KNOWN local ref, so it must not fall through to the unresolved-path advisory
|
|
332
|
+
// either. Absent (older extractor) degrades to [], i.e. today's behavior.
|
|
333
|
+
const alreadyPending = Array.isArray(extractorJson?.alreadyPending)
|
|
334
|
+
? extractorJson.alreadyPending
|
|
335
|
+
: [];
|
|
329
336
|
const analyzed = Array.isArray(extractorJson?.alreadyAnalyzed) ? extractorJson.alreadyAnalyzed : [];
|
|
330
337
|
const sessionId = typeof extractorJson?.sessionId === 'string' && extractorJson.sessionId
|
|
331
338
|
? extractorJson.sessionId
|
|
@@ -334,9 +341,12 @@ async function readTextSafe(filePath) {
|
|
|
334
341
|
const armedRefs = new Set(
|
|
335
342
|
pendingMarkers.filter((m) => typeof m?.ref === 'string').map((m) => m.ref)
|
|
336
343
|
);
|
|
344
|
+
const pendingRefs = new Set(
|
|
345
|
+
alreadyPending.filter((m) => typeof m?.ref === 'string').map((m) => m.ref)
|
|
346
|
+
);
|
|
337
347
|
const analyzedRefs = new Set(analyzed.filter((a) => typeof a?.ref === 'string').map((a) => a.ref));
|
|
338
348
|
const unresolvedLocal = localMatches.filter(
|
|
339
|
-
(m) => !armedRefs.has(m.trim()) && !analyzedRefs.has(m.trim())
|
|
349
|
+
(m) => !armedRefs.has(m.trim()) && !pendingRefs.has(m.trim()) && !analyzedRefs.has(m.trim())
|
|
340
350
|
);
|
|
341
351
|
const cases = [];
|
|
342
352
|
if (pendingMarkers.some((m) => !m?.source)) cases.push('pasted image');
|