@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
@@ -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: copy changes to main + IMMEDIATELY delete worktree
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
- # Copy modified tracked files to main working tree
184
- git -C "$WORKTREE" diff --name-only | while read f; do
185
- mkdir -p "$(dirname "$f")"
186
- cp "$WORKTREE/$f" "$f"
187
- done
188
-
189
- # Copy new untracked files
190
- git -C "$WORKTREE" ls-files --others --exclude-standard | while read f; do
191
- mkdir -p "$(dirname "$f")"
192
- cp "$WORKTREE/$f" "$f"
193
- done
194
-
195
- # Delete worktree and branch immediately
196
- git worktree remove "$WORKTREE" --force
197
- git branch -D handoff/task-xxx
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
- git worktree remove .worktrees/task-xxx --force
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
- git worktree remove .worktrees/task-xxx --force
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
- **Worktrees are always deleted immediately** — no exceptions.
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 show only main worktree
250
- git branch | grep handoff # must be empty
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. `known` false (no transcript path, unreadable,
200
- // module absent) keeps the existing resume behaviour.
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 intent outranks a live cursor: the user asked this session to plan,
220
- // so report the paused run and let the planning continue. This is the one place
221
- // an automatic resume must yield — /ukit:handoff-create is planning-only by
222
- // contract, and /ukit:handoff-fullstack remains the explicit way to resume.
223
- if (intent === 'create') {
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'}), but this session's current request`,
227
- 'is planning (/ukit:handoff-create). Stop gate and resume will not force the run here —',
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
- BASENAME=$(basename "$FILE_PATH")
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="/${FILE_PATH#/}"
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');
@@ -0,0 +1,418 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cleanup-task-worktree.mjs — receipt-gated, recoverable task cleanup
4
+ * (C97 TASK-C97-010 · MODULE04-SPEC FR-M4-003/003a · §7–§14).
5
+ *
6
+ * The shipped handoff samples removed task worktrees with
7
+ * `git worktree remove <wt> --force` plus `git branch -D "handoff/<name>"`, so a
8
+ * FAIL, an unreported task or a missing-model task lost its recovery evidence,
9
+ * and the final gate kept a global "only main worktree must remain" assertion
10
+ * that force-deleted anything left over. This helper replaces that with one
11
+ * narrow, fail-closed contract:
12
+ *
13
+ * node .claude/ukit/index/cleanup-task-worktree.mjs \
14
+ * --receipt <json-path> --approval <json-path> [--apply]
15
+ *
16
+ * Dry-run is the default and mutates nothing. `--apply` removes EXACTLY the one
17
+ * worktree named by a verified C97-009 landing receipt whose result has been
18
+ * independently Approved, and only when the source still matches the receipt
19
+ * snapshot, is Git-clean, unlocked and carries no active run. The worktree is
20
+ * removed WITHOUT `--force`; the branch is deleted only with safe `-d` when
21
+ * ancestry is provable, otherwise it is retained and reported. Unrelated,
22
+ * dirty, active, locked, stale or unapproved state is retained with a named
23
+ * reason — never destroyed.
24
+ *
25
+ * Authority (SPEC §7): the receipt is an attestation backed by a sha256, and
26
+ * the approval is a plain file; the receipt and approval paths must lie OUTSIDE
27
+ * the source worktree and outside the receipt's changed set, so an executor
28
+ * cannot self-author authority inside its own tree. The receipt is never reused
29
+ * for a different root or task.
30
+ *
31
+ * CLI: --help exits 0. stdout is one JSON object:
32
+ * { status: "retained" | "eligible" | "removed", taskId, reason, branchRetained }
33
+ * exit 0 dry-run / retained / removed; exit 2 invalid input or authority;
34
+ * exit 1 a Git runtime failure (a removal failure retains the branch). A
35
+ * retained result is never a successful removal.
36
+ */
37
+
38
+ import fs from 'node:fs';
39
+ import os from 'node:os';
40
+ import path from 'node:path';
41
+ import crypto from 'node:crypto';
42
+ import { spawnSync } from 'node:child_process';
43
+
44
+ const GIT_TIMEOUT_MS = (() => {
45
+ const raw = Number(process.env.UKIT_CLEANUP_GIT_TIMEOUT_MS);
46
+ return Number.isFinite(raw) && raw > 0 ? raw : 20000;
47
+ })();
48
+ const MAX_BUFFER = 64 * 1024 * 1024;
49
+
50
+ const USAGE = `cleanup-task-worktree.mjs — receipt-gated recoverable task cleanup
51
+
52
+ Usage:
53
+ node cleanup-task-worktree.mjs --receipt <json-path> --approval <json-path> [--apply]
54
+ node cleanup-task-worktree.mjs --help
55
+
56
+ Dry-run is the default (mutates nothing). --apply removes EXACTLY the one
57
+ worktree named by a verified C97-009 landing receipt whose result is Approved,
58
+ never with --force; an unmerged branch is retained. exit 0 dry-run/retained/
59
+ removed; exit 2 invalid input/authority; exit 1 Git runtime failure.`;
60
+
61
+ // --- bounded git -------------------------------------------------------------
62
+
63
+ function git(root, args) {
64
+ return spawnSync('git', ['-C', root, ...args], {
65
+ encoding: 'utf8',
66
+ timeout: GIT_TIMEOUT_MS,
67
+ killSignal: 'SIGKILL',
68
+ maxBuffer: MAX_BUFFER,
69
+ });
70
+ }
71
+
72
+ // --- exit contract -----------------------------------------------------------
73
+
74
+ function emit(status, taskId, reason, branchRetained) {
75
+ process.stdout.write(`${JSON.stringify({
76
+ status,
77
+ taskId: taskId ?? null,
78
+ reason,
79
+ branchRetained,
80
+ })}\n`);
81
+ }
82
+
83
+ // Invalid input or authority: the task is retained (never removed) and the
84
+ // process exits 2. A bounded diagnostic never includes file contents.
85
+ function refuse(taskId, reason, diagnostic) {
86
+ if (diagnostic) process.stderr.write(`cleanup-task-worktree: ${reason}: ${diagnostic}\n`);
87
+ emit('retained', taskId, reason, true);
88
+ process.exit(2);
89
+ }
90
+
91
+ // A named recovery state (not invalid authority): retained, exit 0.
92
+ function retain(taskId, reason) {
93
+ emit('retained', taskId, reason, true);
94
+ process.exit(0);
95
+ }
96
+
97
+ function runtimeError(taskId, reason, diagnostic) {
98
+ if (diagnostic) process.stderr.write(`cleanup-task-worktree: ${reason}: ${diagnostic}\n`);
99
+ emit('retained', taskId, reason, true);
100
+ process.exit(1);
101
+ }
102
+
103
+ // --- args --------------------------------------------------------------------
104
+
105
+ const argv = process.argv.slice(2);
106
+ let receiptPath = null;
107
+ let approvalPath = null;
108
+ let apply = false;
109
+ for (let i = 0; i < argv.length; i += 1) {
110
+ const a = argv[i];
111
+ if (a === '--help' || a === '-h') {
112
+ process.stdout.write(`${USAGE}\n`);
113
+ process.exit(0);
114
+ } else if (a === '--apply') {
115
+ apply = true;
116
+ } else if (a === '--receipt') {
117
+ receiptPath = argv[++i];
118
+ if (receiptPath === undefined) { process.stderr.write('cleanup-task-worktree: missing value for --receipt\n'); process.exit(2); }
119
+ } else if (a === '--approval') {
120
+ approvalPath = argv[++i];
121
+ if (approvalPath === undefined) { process.stderr.write('cleanup-task-worktree: missing value for --approval\n'); process.exit(2); }
122
+ } else {
123
+ process.stderr.write(`cleanup-task-worktree: unknown argument: ${a}\n${USAGE}\n`);
124
+ process.exit(2);
125
+ }
126
+ }
127
+ if (!receiptPath || !approvalPath) {
128
+ process.stderr.write(`cleanup-task-worktree: --receipt and --approval are both required\n${USAGE}\n`);
129
+ process.exit(2);
130
+ }
131
+
132
+ // --- path helpers ------------------------------------------------------------
133
+
134
+ const realPath = (p) => {
135
+ try { return fs.realpathSync(p); } catch { return path.resolve(p); }
136
+ };
137
+ // Resolve symlinks in the longest existing ancestor, then append the remaining
138
+ // segments (macOS /var → /private/var makes a naive resolve a different string).
139
+ function resolveReal(p) {
140
+ const abs = path.resolve(p);
141
+ const root = path.parse(abs).root;
142
+ const parts = abs.slice(root.length).split(path.sep).filter(Boolean);
143
+ let cur = root;
144
+ let i = 0;
145
+ for (; i < parts.length; i += 1) {
146
+ const next = path.join(cur, parts[i]);
147
+ if (fs.existsSync(next)) cur = next; else break;
148
+ }
149
+ return path.join(realPath(cur), ...parts.slice(i));
150
+ }
151
+ const isInsideOrEqual = (child, parent) => {
152
+ const c = resolveReal(child);
153
+ const p = realPath(parent);
154
+ return c === p || c.startsWith(p + path.sep);
155
+ };
156
+
157
+ const sha256File = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
158
+
159
+ // --- schema ------------------------------------------------------------------
160
+
161
+ const RECEIPT_KEYS = [
162
+ 'schemaVersion', 'taskId', 'baseCommit', 'sourceRoot', 'destinationRoot',
163
+ 'sourceBranch', 'sourceHead', 'destinationHeadBefore', 'sourceSnapshotTree',
164
+ 'resultTree', 'patchSha256', 'scopeSha256', 'changedPaths', 'createdAt', 'status',
165
+ ];
166
+ const APPROVAL_KEYS = [
167
+ 'schemaVersion', 'taskId', 'receiptSha256', 'verdict', 'executorModel',
168
+ 'reviewerModel', 'reviewedResultTree',
169
+ ];
170
+ const TASK_TOKEN = /^TASK-C97-\d{3}$/;
171
+ const OID = /^[0-9a-f]{40}$/;
172
+
173
+ function readJson(p, taskId, label) {
174
+ let raw;
175
+ try {
176
+ raw = fs.readFileSync(p, 'utf8');
177
+ } catch {
178
+ refuse(taskId, 'invalid-authority', `${label} file unreadable`);
179
+ }
180
+ try {
181
+ return JSON.parse(raw);
182
+ } catch {
183
+ refuse(taskId, 'invalid-authority', `${label} is not valid JSON`);
184
+ }
185
+ }
186
+
187
+ function validateReceipt(receipt) {
188
+ const taskId = receipt && typeof receipt === 'object' ? receipt.taskId : null;
189
+ if (receipt === null || typeof receipt !== 'object' || Array.isArray(receipt)) {
190
+ refuse(null, 'invalid-authority', 'receipt must be a JSON object');
191
+ }
192
+ for (const key of Object.keys(receipt)) {
193
+ if (!RECEIPT_KEYS.includes(key)) refuse(taskId, 'invalid-authority', `unknown receipt key: ${key}`);
194
+ }
195
+ if (receipt.schemaVersion !== 1) refuse(taskId, 'invalid-authority', 'receipt schemaVersion must be 1');
196
+ if (typeof receipt.taskId !== 'string' || !TASK_TOKEN.test(receipt.taskId)) {
197
+ refuse(taskId, 'invalid-authority', 'receipt taskId must be an exact TASK-C97-NNN token');
198
+ }
199
+ if (receipt.status !== 'landed') refuse(taskId, 'invalid-authority', 'receipt status must be "landed"');
200
+ for (const k of ['baseCommit', 'sourceRoot', 'destinationRoot', 'sourceHead', 'sourceSnapshotTree', 'resultTree', 'patchSha256', 'scopeSha256']) {
201
+ if (typeof receipt[k] !== 'string' || receipt[k].length === 0) {
202
+ refuse(taskId, 'invalid-authority', `receipt ${k} must be a nonempty string`);
203
+ }
204
+ }
205
+ for (const k of ['sourceSnapshotTree', 'resultTree']) {
206
+ if (!OID.test(receipt[k])) refuse(taskId, 'invalid-authority', `receipt ${k} must be a full tree OID`);
207
+ }
208
+ if (receipt.sourceBranch !== null && typeof receipt.sourceBranch !== 'string') {
209
+ refuse(taskId, 'invalid-authority', 'receipt sourceBranch must be a string or null');
210
+ }
211
+ if (!Array.isArray(receipt.changedPaths)) {
212
+ refuse(taskId, 'invalid-authority', 'receipt changedPaths must be an array');
213
+ }
214
+ for (const p of receipt.changedPaths) {
215
+ if (typeof p !== 'string' || p.length === 0) refuse(taskId, 'invalid-authority', 'receipt changedPaths entries must be nonempty strings');
216
+ }
217
+ return receipt.taskId;
218
+ }
219
+
220
+ function validateApproval(approval, receipt, receiptSha, taskId) {
221
+ if (approval === null || typeof approval !== 'object' || Array.isArray(approval)) {
222
+ refuse(taskId, 'invalid-authority', 'approval must be a JSON object');
223
+ }
224
+ for (const key of Object.keys(approval)) {
225
+ if (!APPROVAL_KEYS.includes(key)) refuse(taskId, 'invalid-authority', `unknown approval key: ${key}`);
226
+ }
227
+ if (approval.schemaVersion !== 1) refuse(taskId, 'invalid-authority', 'approval schemaVersion must be 1');
228
+ if (approval.taskId !== taskId) refuse(taskId, 'invalid-authority', 'approval taskId does not match the receipt');
229
+ if (approval.receiptSha256 !== receiptSha) refuse(taskId, 'invalid-authority', 'approval receiptSha256 does not match the receipt');
230
+ if (approval.verdict !== 'Approved') refuse(taskId, 'invalid-authority', 'approval verdict must be "Approved"');
231
+ if (typeof approval.executorModel !== 'string' || approval.executorModel.length === 0) {
232
+ refuse(taskId, 'invalid-authority', 'approval executorModel must be a nonempty string');
233
+ }
234
+ if (typeof approval.reviewerModel !== 'string' || approval.reviewerModel.length === 0) {
235
+ refuse(taskId, 'invalid-authority', 'approval reviewerModel must be a nonempty string');
236
+ }
237
+ if (approval.reviewerModel === approval.executorModel) {
238
+ refuse(taskId, 'invalid-authority', 'approval reviewerModel must differ from executorModel');
239
+ }
240
+ if (approval.reviewedResultTree !== receipt.resultTree) {
241
+ refuse(taskId, 'invalid-authority', 'approval reviewedResultTree does not match the receipt resultTree');
242
+ }
243
+ }
244
+
245
+ // --- state helpers -----------------------------------------------------------
246
+
247
+ function registeredWorktrees(root) {
248
+ const res = git(root, ['worktree', 'list', '--porcelain']);
249
+ if (res.status !== 0) runtimeError(null, 'git-failure', 'worktree list failed');
250
+ const out = [];
251
+ let cur = null;
252
+ for (const line of res.stdout.split('\n')) {
253
+ if (line.startsWith('worktree ')) {
254
+ if (cur) out.push(cur);
255
+ cur = { path: line.slice('worktree '.length), branch: null, locked: false };
256
+ } else if (cur) {
257
+ if (line.startsWith('branch ')) cur.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
258
+ else if (line.startsWith('locked')) cur.locked = true;
259
+ }
260
+ }
261
+ if (cur) out.push(cur);
262
+ return out;
263
+ }
264
+
265
+ function hasActiveRun(root) {
266
+ try {
267
+ const runsDir = path.join(root, '.ukit', 'storage', 'runs');
268
+ if (fs.statSync(runsDir).isDirectory() && fs.readdirSync(runsDir).some((f) => f.endsWith('.json'))) {
269
+ return true;
270
+ }
271
+ } catch { /* absent */ }
272
+ try {
273
+ const raw = fs.readFileSync(path.join(root, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
274
+ const m = raw.match(/^Phase:\s*(\S+)/m);
275
+ if (m && m[1] !== 'done' && m[1] !== 'blocked') return true;
276
+ } catch { /* no cursor */ }
277
+ return false;
278
+ }
279
+
280
+ // Snapshot a worktree's FINAL content via an isolated temp index (never the
281
+ // repository's real index) — the same definition the landing helper used.
282
+ function worktreeTree(root) {
283
+ const idx = path.join(os.tmpdir(), `ukit-cleanup-${process.pid}-${crypto.randomBytes(6).toString('hex')}.index`);
284
+ try {
285
+ const env = { ...process.env, GIT_INDEX_FILE: idx };
286
+ for (const args of [['read-tree', 'HEAD'], ['add', '-A']]) {
287
+ const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL', env, maxBuffer: MAX_BUFFER });
288
+ if (r.status !== 0) runtimeError(null, 'git-failure', r.stderr?.split('\n')[0] ?? 'snapshot failed');
289
+ }
290
+ const t = spawnSync('git', ['-C', root, 'write-tree'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL', env, maxBuffer: MAX_BUFFER });
291
+ if (t.status !== 0) runtimeError(null, 'git-failure', t.stderr?.split('\n')[0] ?? 'write-tree failed');
292
+ return t.stdout.trim();
293
+ } finally {
294
+ try { fs.rmSync(idx, { force: true }); } catch { /* absent */ }
295
+ }
296
+ }
297
+
298
+ // mode+object of `path` in `treeish` (null when the path is absent — a deletion).
299
+ function treeEntry(root, treeish, p) {
300
+ const r = git(root, ['ls-tree', '-z', treeish, '--', p]);
301
+ if (r.status !== 0 || !r.stdout) return null;
302
+ const row = r.stdout.split('\0').find((x) => x.length > 0);
303
+ if (!row) return null;
304
+ const [meta] = row.split('\t');
305
+ const [mode, , object] = meta.split(' ');
306
+ return `${mode}:${object}`;
307
+ }
308
+
309
+ // --- main --------------------------------------------------------------------
310
+
311
+ function main() {
312
+ const receipt = readJson(receiptPath, null, 'receipt');
313
+ const taskId = validateReceipt(receipt);
314
+ const receiptSha = sha256File(receiptPath);
315
+ const approval = readJson(approvalPath, taskId, 'approval');
316
+ validateApproval(approval, receipt, receiptSha, taskId);
317
+
318
+ // Authority locations (SPEC §7): outside the source worktree and outside the
319
+ // receipt's changed set. An executor must not self-author authority in its own
320
+ // tree or in a path the landing already touched.
321
+ const sourceRoot = realPath(receipt.sourceRoot);
322
+ // The receipt changedPaths are DESTINATION-relative (they name what the landing
323
+ // wrote into the destination). Resolve the containment against BOTH roots: an
324
+ // authority file sitting at a changed path inside the destination worktree is
325
+ // just as unsafe as one inside the source worktree, and resolving only against
326
+ // sourceRoot would silently miss it (review F1).
327
+ const dstRootForAuthority = realPath(receipt.destinationRoot);
328
+ // A path is inside `base` only when its base-relative form is not a PARENT
329
+ // escape. The boundary is the exact `..` segment (or a `../` prefix) — NOT any
330
+ // `..`-prefixed name, because `..hidden.txt` is a legal repo-relative filename
331
+ // and a naive `startsWith('..')` would treat it as outside the root and let an
332
+ // authority file at that changed path bypass the containment check (review F2).
333
+ // An absolute result (`path.relative` on different drives) is also outside.
334
+ const containedIn = (base, abs) => {
335
+ const rel = path.relative(base, abs).split(path.sep).join('/');
336
+ if (!rel || path.isAbsolute(rel) || rel === '..' || rel.startsWith('../')) return null;
337
+ return rel;
338
+ };
339
+ for (const [label, p] of [['receipt', receiptPath], ['approval', approvalPath]]) {
340
+ if (isInsideOrEqual(p, sourceRoot)) {
341
+ refuse(taskId, 'invalid-authority', `${label} path is inside the source worktree`);
342
+ }
343
+ const abs = resolveReal(p);
344
+ for (const base of [sourceRoot, dstRootForAuthority]) {
345
+ const rel = containedIn(base, abs);
346
+ if (!rel) continue;
347
+ for (const changed of receipt.changedPaths) {
348
+ if (rel === changed || rel.startsWith(`${changed}/`)) {
349
+ refuse(taskId, 'invalid-authority', `${label} path is inside the receipt changed set`);
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ if (!fs.existsSync(sourceRoot)) retain(taskId, 'source-missing');
356
+ const destinationRoot = realPath(receipt.destinationRoot);
357
+ if (!fs.existsSync(destinationRoot)) runtimeError(taskId, 'git-failure', 'destination root is not present');
358
+
359
+ // The source must be a registered, unlocked worktree of the same common repo.
360
+ const worktrees = registeredWorktrees(destinationRoot);
361
+ const entry = worktrees.find((w) => realPath(w.path) === sourceRoot);
362
+ if (!entry) refuse(taskId, 'invalid-authority', 'source root is not a registered worktree');
363
+ if (entry.locked) retain(taskId, 'locked');
364
+
365
+ // The task must not carry an in-flight run.
366
+ if (hasActiveRun(sourceRoot)) retain(taskId, 'active');
367
+
368
+ // Revalidate the exact source identity and content against the receipt.
369
+ const headRes = git(sourceRoot, ['rev-parse', 'HEAD']);
370
+ if (headRes.status !== 0) retain(taskId, 'source-head-unreadable');
371
+ if (headRes.stdout.trim() !== receipt.sourceHead) retain(taskId, 'source-head-changed');
372
+ const branchRes = git(sourceRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
373
+ const branch = branchRes.status === 0 ? branchRes.stdout.trim() : null;
374
+ if ((receipt.sourceBranch ?? null) !== branch) retain(taskId, 'source-branch-changed');
375
+
376
+ // "Dirty" = the source tree differs from the receipt snapshot (tracked, staged
377
+ // or nonignored untracked content). A snapshot-identical tree is Git-clean and
378
+ // `git worktree remove` succeeds without --force.
379
+ if (worktreeTree(sourceRoot) !== receipt.sourceSnapshotTree) retain(taskId, 'dirty-vs-snapshot');
380
+
381
+ // Destination path-scoped verification: for every receipt changedPath, the
382
+ // blob (or absence, for a deletion) in the destination HEAD must equal the
383
+ // receipt resultTree — the checkpoint commit may move HEAD, so no blind
384
+ // whole-tree equality.
385
+ for (const p of receipt.changedPaths) {
386
+ const expected = treeEntry(destinationRoot, receipt.resultTree, p);
387
+ const actual = treeEntry(destinationRoot, 'HEAD', p);
388
+ if (expected !== actual) retain(taskId, 'destination-path-changed');
389
+ }
390
+
391
+ if (!apply) {
392
+ emit('eligible', taskId, 'eligible', true);
393
+ process.exit(0);
394
+ }
395
+
396
+ // Remove the registered worktree WITHOUT --force. A refusal or failure (dirty
397
+ // by Git's own definition, locked, etc.) is a runtime failure that keeps the
398
+ // worktree and branch — never escalated to --force.
399
+ const rm = git(destinationRoot, ['worktree', 'remove', sourceRoot]);
400
+ if (rm.status !== 0) {
401
+ runtimeError(taskId, 'remove-failed', (rm.stderr || '').split('\n')[0] || 'git worktree remove failed');
402
+ }
403
+ if (fs.existsSync(sourceRoot)) {
404
+ runtimeError(taskId, 'remove-failed', 'worktree still present after removal');
405
+ }
406
+
407
+ // Delete the branch only with safe `-d` when ancestry is provable; an
408
+ // unmerged milestone branch is retained as recovery evidence (never -D).
409
+ let branchRetained = true;
410
+ if (branch) {
411
+ const del = git(destinationRoot, ['branch', '-d', branch]);
412
+ branchRetained = del.status !== 0;
413
+ }
414
+ emit('removed', taskId, branchRetained ? 'removed-branch-retained' : 'removed', branchRetained);
415
+ process.exit(0);
416
+ }
417
+
418
+ main();