@ngockhoale/ukit 3.4.22 → 3.4.24

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 (30) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/manifests/platform.full.yaml +29 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/telemetry.js +67 -4
  5. package/src/cli/index.js +1 -1
  6. package/src/core/docContracts.js +60 -6
  7. package/src/core/observability/adapters/routeAdapter.js +48 -9
  8. package/src/core/observability/analytics/usageReadiness.js +342 -0
  9. package/src/core/observability/privacy/allowlist.js +10 -1
  10. package/src/core/observability/schema/constants.js +13 -0
  11. package/src/core/observability/schema/registry.js +20 -0
  12. package/src/core/observability/schema/validate.js +45 -2
  13. package/src/core/observability/segments/readSegments.js +23 -1
  14. package/template_project/.claude/agents/feature-implementer.md +10 -3
  15. package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
  16. package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
  17. package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
  18. package/template_project/.claude/hooks/handoff-resume.sh +36 -11
  19. package/template_project/.claude/hooks/protect-files.sh +10 -2
  20. package/template_project/.claude/hooks/vision-router.sh +11 -1
  21. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
  22. package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
  23. package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
  24. package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
  25. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
  26. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +60 -0
  27. package/template_project/.claude/ukit/runtime/observability-emit.mjs +53 -2
  28. package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
  29. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
  30. package/template_project/.omp/agents/feature-implementer.md +10 -3
@@ -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();
@@ -673,43 +673,98 @@ function main() {
673
673
  }
674
674
  }
675
675
 
676
- const written = [];
677
- for (const img of selected) {
678
- const markerPath = path.join(markerDir, `pending-${img.sha}.json`);
679
- const marker = { sha: img.sha, mediaType: img.mediaType, ts: Date.now(), sessionId };
676
+ // Idempotent unread pending (SPEC FR-M5-001/002 §7):
677
+ // an existing pending-<sha>.json is UNREAD AUTHORITY — it is never overwritten,
678
+ // refreshed (no new ts), or reported as newly armed. Creation is EXCLUSIVE
679
+ // (`wx`), so a concurrent sweep cannot double-arm the same sha: the loser gets
680
+ // EEXIST and reports alreadyPending instead of NEW.
681
+ const newlyArmed = [];
682
+ const alreadyPending = [];
683
+ const pendingDiagnostics = [];
684
+
685
+ // Does the marker already exist? Never throws. An unreadable/corrupt body is
686
+ // retained byte-identically and surfaced as a bounded diagnostic — it can never
687
+ // be repaired into a fresh arm, and it never fabricates an analyzed receipt.
688
+ const inspectExistingMarker = (markerPath, sha) => {
680
689
  try {
681
- fs.writeFileSync(markerPath, JSON.stringify(marker));
682
- written.push({ sha: img.sha, path: markerPath, mediaType: img.mediaType, bytes: 0 });
690
+ fs.readFileSync(markerPath);
683
691
  } catch (err) {
684
- process.stderr.write(`extract-image: failed to write marker ${markerPath}: ${err.message}\n`);
692
+ if (err && err.code === 'ENOENT') return false;
693
+ // EACCES / EISDIR / anything else: the marker may well exist but we cannot
694
+ // verify it. Preserve the bytes, diagnose, and do NOT re-arm.
695
+ pendingDiagnostics.push({ sha, code: 'pending-marker-corrupt' });
696
+ process.stderr.write(`ukit-vision: pending marker corrupt sha=${sha} retained\n`);
697
+ return true;
698
+ }
699
+ // The bytes exist. A body that is not valid JSON is a half-write: retained and
700
+ // diagnosed, still not NEW (the sha identity comes from the current input).
701
+ let corrupt = false;
702
+ try {
703
+ JSON.parse(fs.readFileSync(markerPath, 'utf8'));
704
+ } catch {
705
+ corrupt = true;
706
+ }
707
+ if (corrupt) {
708
+ pendingDiagnostics.push({ sha, code: 'pending-marker-corrupt' });
709
+ process.stderr.write(`ukit-vision: pending marker corrupt sha=${sha} retained\n`);
710
+ }
711
+ return true;
712
+ };
713
+
714
+ const armMarker = (entry, marker) => {
715
+ const markerPath = path.join(markerDir, `pending-${entry.sha}.json`);
716
+ const receipt = { ...entry, path: markerPath, bytes: 0 };
717
+ if (inspectExistingMarker(markerPath, entry.sha)) {
718
+ alreadyPending.push(receipt);
719
+ return;
685
720
  }
686
- }
687
- for (const ref of refs) {
688
- const markerPath = path.join(markerDir, `pending-${ref.sha}.json`);
689
- const marker = {
690
- sha: ref.sha,
691
- mediaType: ref.mediaType,
692
- ts: Date.now(),
693
- sessionId,
694
- source: ref.source,
695
- ref: ref.value,
696
- };
697
721
  try {
698
- fs.writeFileSync(markerPath, JSON.stringify(marker));
699
- written.push({ sha: ref.sha, path: markerPath, mediaType: ref.mediaType, bytes: 0, source: ref.source, ref: ref.value });
722
+ fs.writeFileSync(markerPath, JSON.stringify(marker), { flag: 'wx' });
723
+ newlyArmed.push(receipt);
700
724
  } catch (err) {
725
+ if (err && err.code === 'EEXIST') {
726
+ // Lost the exclusive-create race (or a marker appeared between the read
727
+ // and the write): the winner owns the NEW report, we report alreadyPending.
728
+ alreadyPending.push(receipt);
729
+ return;
730
+ }
701
731
  process.stderr.write(`extract-image: failed to write marker ${markerPath}: ${err.message}\n`);
702
732
  }
733
+ };
734
+
735
+ for (const img of selected) {
736
+ armMarker(
737
+ { sha: img.sha, mediaType: img.mediaType },
738
+ { sha: img.sha, mediaType: img.mediaType, ts: Date.now(), sessionId },
739
+ );
740
+ }
741
+ for (const ref of refs) {
742
+ armMarker(
743
+ { sha: ref.sha, mediaType: ref.mediaType, source: ref.source, ref: ref.value },
744
+ {
745
+ sha: ref.sha,
746
+ mediaType: ref.mediaType,
747
+ ts: Date.now(),
748
+ sessionId,
749
+ source: ref.source,
750
+ ref: ref.value,
751
+ },
752
+ );
703
753
  }
704
754
  pruneMarkerDirs(outDir);
705
755
  // images[] invariant: marker receipts (bytes: 0, .json path) NEVER belong
706
756
  // here — they live in pendingMarkers[]. images[] is the materialized-only
707
757
  // shape; detect/mark-pending mode keeps it empty by definition.
758
+ // imageCount/pendingMarkers[] count NEWLY ARMED markers only; an input whose
759
+ // marker already existed is reported in the additive alreadyPending[] so the
760
+ // router can stay silent without the local ref reading as unresolved.
708
761
  printResult(opts, {
709
- imageCount: written.length,
762
+ imageCount: newlyArmed.length,
710
763
  sessionId,
711
764
  images: [],
712
- pendingMarkers: written,
765
+ pendingMarkers: newlyArmed,
766
+ alreadyPending,
767
+ pendingDiagnostics,
713
768
  alreadyAnalyzed,
714
769
  });
715
770
  return;