@ngockhoale/ukit 3.4.67 → 3.4.68

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 (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/manifests/documentation.yaml +33 -0
  3. package/manifests/instructionRules.yaml +10 -0
  4. package/manifests/platform.full.yaml +67 -0
  5. package/package.json +1 -1
  6. package/src/core/applyPlan.js +111 -2
  7. package/src/core/buildPlan.js +3 -1
  8. package/src/core/diffPlan.js +49 -1
  9. package/src/core/executionContracts.js +49 -8
  10. package/src/core/metadata.js +45 -0
  11. package/src/core/runInstallPipeline.js +30 -2
  12. package/src/core/uninstall.js +106 -2
  13. package/src/manifest/validateManifest.js +25 -1
  14. package/template_project/.claude/agents/code-reviewer.md +22 -9
  15. package/template_project/.claude/agents/handoff-planner.md +97 -8
  16. package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
  17. package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
  18. package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
  19. package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
  21. package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
  22. package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
  23. package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
  24. package/template_project/.claude/hooks/handoff-resume.sh +141 -1
  25. package/template_project/.claude/hooks/record-execution.mjs +140 -9
  26. package/template_project/.claude/hooks/skill-router.sh +173 -8
  27. package/template_project/.claude/settings.json +2 -2
  28. package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
  29. package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
  30. package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
  31. package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
  32. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  33. package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
  34. package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
  35. package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
  36. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
  37. package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
  38. package/template_project/.claude/ukit/index/route-task.mjs +180 -11
  39. package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
  40. package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
  41. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
  42. package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
  43. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  44. package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
  46. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
  47. package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
  48. package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
  49. package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
  50. package/template_project/.codex/README.md +25 -0
  51. package/template_project/.omp/RULES.md +22 -22
  52. package/template_project/.omp/agents/code-reviewer.md +22 -9
  53. package/template_project/.omp/agents/handoff-planner.md +102 -12
  54. package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
  55. package/template_project/AGENTS.md +5 -1
  56. package/template_project/CLAUDE.md +1 -1
  57. package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
  58. package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
  59. package/template_project/instructions/core.md +5 -1
  60. package/template_project/instructions/layout.yaml +2 -0
  61. package/template_project/instructions/overlays/omp-rules.md +22 -22
  62. package/template_project/ukit/storage/config.json +1 -1
@@ -22,6 +22,8 @@ Dispositions (exactly one per row):
22
22
 
23
23
  Prohibitions are confirmed exclusions: a source "must not" / "never" rule is recorded as a confirmed exclusion with its rationale. If it is enforceable in scope, it is a `feasible` or `adapted` row whose acceptance includes a referenced negative test (a test that proves the forbidden behaviour is absent). No new taxonomy is needed for this.
24
24
 
25
+ `deferred` is never a catch-all: material the source itself marks as proposal, example, or rejected (and explicit non-goals) is recorded as `NOTES.md` entries with that kind (`proposal`/`example`/`rejected` — see Source-only notes), never squeezed into `deferred`. `deferred` means intentionally-later work and always names a queue entry; forcing non-work into it queues backlog that no cycle should ever drain.
26
+
25
27
  The `10` limit is a dispatch and context window, not a classification. Queued drafts carry no runnable rows; their INDEX rows are `queued_blueprint`.
26
28
 
27
29
  Footer (required, last lines of the file):
@@ -86,6 +88,14 @@ A blueprint without the line keeps the legacy fold-in behaviour. The contract is
86
88
 
87
89
  Before chunking, persist the original verbatim: `${OUTPUT_ROOT}SOURCE.md` holds the original path, the `sha256` of the original file's bytes (not of this wrapper or any metadata header), and an unmodified copy of those bytes. For chat or pasted input with no file, record the durable verbatim message origin (message id or transcript path plus the exact text) in place of the path. Gatherers and planners cite `SOURCE.md` lines; no model paraphrase replaces it. The reviewer starts its chain from `SOURCE.md` and re-hashes the original bytes.
88
90
 
91
+ Write it with the deterministic helper as ONE simple command, literal paths, no `cd`, no compound — never a hand-built wrapper:
92
+
93
+ ```
94
+ node .claude/ukit/index/source-snapshot.mjs <original-path> docs/AI_HANDOFF/SOURCE.md
95
+ ```
96
+
97
+ (under a queued root, pass that root's SOURCE.md path). It records the origin path, the sha256 and a byte-identical copy, refuses to overwrite an existing snapshot, and `--check <snapshot> [original]` re-hashes the copy for the reviewer. If it cannot run (denial, budget), `PLAN.md` and `LEDGER.md` must state `source snapshot MISSING` explicitly — never cite only a mutable path as the source.
98
+
89
99
  ## Source-only notes
90
100
 
91
101
  File: `${OUTPUT_ROOT}NOTES.md` (the semantic record is mandatory: every classification, exclusion, and rationale is recorded durably, and this file is the default home for the non-task items; an equivalent name is acceptable, and the file is written only when such items exist). Non-requirement material that a ledger row cannot carry. Each entry names its source (`doc §… L…`, or the human's words) and one kind:
@@ -28,7 +28,7 @@ This skill is **planning-only**. A create session that loads it edits docs and t
28
28
  ### Phase A — Gather (lite tier)
29
29
 
30
30
  1. Read the document header and table of contents; unreadable or empty source → write ledger row `R-000 | blocker` naming the missing input, create no tasks, stop.
31
- 1a. **Source snapshot** — before chunking, write `${OUTPUT_ROOT}SOURCE.md` with the original path (or the durable verbatim message origin for chat input), the `sha256` of the original bytes, and a verbatim copy of the full source text (see [REFERENCE.md](./REFERENCE.md#source-snapshot)). Chunks and citations point into this copy, never into a paraphrase.
31
+ 1a. **Source snapshot** — before chunking, write `${OUTPUT_ROOT}SOURCE.md` with the original path (or the durable verbatim message origin for chat input), the `sha256` of the original bytes, and a verbatim copy of the full source text (see [REFERENCE.md](./REFERENCE.md#source-snapshot)). Use the helper, ONE simple command, literal paths: `node .claude/ukit/index/source-snapshot.mjs <original-path> docs/AI_HANDOFF/SOURCE.md`; if it cannot run (denial, budget), state `source snapshot MISSING` explicitly in PLAN/LEDGER. Chunks and citations point into this copy, never into a paraphrase.
32
32
  2. Split into chunks of at most 800 lines; one bounded gatherer per chunk extracts candidate requirements as `Source` (section/line) + normalized text. Dedupe by normalized text, keep the first source; contradictory requirements become a `blocker` row.
33
33
  3. **Feasibility evidence** — per requirement, inspect the CURRENT source index-first (`query-index.mjs` / `resolve-context.mjs`, then read the code that would change) plus `git log`, `git diff` and `git status` of the touched areas. Record each finding in the ledger row as evidence (file:line or commit) using the block in [REFERENCE.md](./REFERENCE.md#feasibility-evidence). Gatherers collect; the smart tier decides.
34
34
  4. **Related-project research (default on, bounded)** — per requirement cluster search the internet / GitHub for related projects and prior art (how others solved it, what to adopt or avoid) via `.claude/skills/research/SKILL.md`, under the sanitized-query and untrusted-source rules in [REFERENCE.md](./REFERENCE.md#research-rules). Feed the findings into each row's feasibility evidence. Skip only when no sanitized query is possible or no research tool exists, and record the skip reason in the ledger notes.
@@ -37,7 +37,7 @@ Tier (lite gatherers): on Claude Code start a read-only `general-purpose` subage
37
37
 
38
38
  ### Phase B — Decide, ledger, plan (smart tier)
39
39
 
40
- 1. Use the evidence to assign every extracted requirement exactly one disposition: `feasible`, `adapted`, `deferred`, or `blocker`. `adapted` lists 2-3 alternatives with the chosen one marked; `deferred` names its queue entry and a justification; `blocker` names the missing prerequisite or owner conflict.
40
+ 1. Use the evidence to assign every extracted requirement exactly one disposition: `feasible`, `adapted`, `deferred`, or `blocker`. `adapted` lists 2-3 alternatives with the chosen one marked; `deferred` (intentionally later) names its queue entry and a justification; `blocker` names the missing prerequisite or owner conflict. Source-marked proposal/example/rejected items are `NOTES.md` entries with that kind, never `deferred`.
41
41
  2. Only `feasible`/`adapted` rows map to executable tasks, and each maps to an executable acceptance criterion and a test or verification command. `deferred` rows need a justification and a queue entry, not a task. `blocker` rows need the missing prerequisite or owner decision named, not a task. Rows the source marks as proposal, example, or rejected are not promoted to tasks: they stay as classified NOTES entries with their rationale.
42
42
  3. Write `${OUTPUT_ROOT}LEDGER.md` with the footer `Coverage: N/N rows dispositioned` and the sentence "disposition coverage is not implementation coverage". Never claim "100% implemented".
43
43
  4. **Candidate plans** — draft 2-3 candidate whole-plans (different decomposition, risk, ordering), score them on the rubric in [REFERENCE.md](./REFERENCE.md#candidate-plan-rubric), pick the best, and record the choice plus rejected alternatives in `PLAN.md`.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+ import fs from 'node:fs';
4
+ import { allocateTaskWorktree } from '../runtime/worktree-allocation.mjs';
5
+
6
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
7
+ const [root, taskId, ...extra] = process.argv.slice(2);
8
+ if (!root || !taskId || extra.length) {
9
+ console.error('Usage: node allocate-task-worktree.mjs <project-root> <TASK-id>');
10
+ process.exitCode = 2;
11
+ } else {
12
+ try { console.log(JSON.stringify(await allocateTaskWorktree(root, taskId), null, 2)); }
13
+ catch (error) { console.error(`Allocation refused: ${error.message}`); process.exitCode = 2; }
14
+ }
15
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // amend-guard.mjs — CR-10: overwriting PLAN.md / SPEC.md / LEDGER.md (or any planned file)
3
+ // requires a preserved copy under `amendments/rev-<NN>/<same relative path>` first.
4
+ //
5
+ // A valid copy must be: a regular, non-empty file at the SAME relative subpath as the file
6
+ // being overwritten (queued/foo/PLAN.md needs amendments/rev-NN/queued/foo/PLAN.md, never a
7
+ // root copy), byte-identical to the current file (the copy is the prior version, taken
8
+ // right before the rewrite), and not a symlink. A brand-new target (absent on disk) needs
9
+ // no copy, but a queued/<slug> directory that already exists is a collision and needs one.
10
+ //
11
+ // Mechanical scope: this checker verifies the COPY exists before a rewrite. It is invoked
12
+ // by the create/planner instructions before composing a revised plan (prose enforcement —
13
+ // hosts have no Write-time hook for docs paths).
14
+ //
15
+ // Usage:
16
+ // node .claude/ukit/index/amend-guard.mjs <handoff-root> <FILE.md> [FILE.md ...]
17
+ //
18
+ // Output:
19
+ // `AMEND_GUARD: ok` when every named file is either brand-new or has a valid copy;
20
+ // otherwise one `overwrite blocked: <FILE> has no amendments/rev-<NN> copy — copy the prior
21
+ // version to the next unused rev dir first` line per blocked file. Exit 0 = safe, 1 = blocked, 2 = usage.
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import process from 'node:process';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ function isDirectRun() {
29
+ try {
30
+ return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ const GUARDED = new Set(['PLAN.md', 'SPEC.md', 'LEDGER.md', 'INDEX.md', 'NOTES.md', 'SOURCE.md']);
37
+
38
+ function lstatSafe(file) {
39
+ try {
40
+ return fs.lstatSync(file);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ // A valid preserved copy: same relative subpath under some rev-NN, regular file (no symlink),
47
+ // non-empty, and — when `currentBuf` is given — byte-identical to the file it protects.
48
+ // `currentBuf === null` accepts any valid copy (used for a queued/<slug> collision check).
49
+ function hasValidCopy(root, rel, currentBuf) {
50
+ const amendmentsDir = path.join(root, 'amendments');
51
+ let entries;
52
+ try {
53
+ entries = fs.readdirSync(amendmentsDir);
54
+ } catch {
55
+ return false;
56
+ }
57
+ return entries
58
+ .filter((e) => /^rev-\d+$/.test(e))
59
+ .some((rev) => {
60
+ const copy = path.join(amendmentsDir, rev, rel);
61
+ const st = lstatSafe(copy);
62
+ if (!st || !st.isFile() || st.size === 0) return false;
63
+ if (currentBuf === null) return true;
64
+ try {
65
+ return fs.readFileSync(copy).equals(currentBuf);
66
+ } catch {
67
+ return false;
68
+ }
69
+ });
70
+ }
71
+
72
+ function main() {
73
+ const root = process.argv[2];
74
+ const files = process.argv.slice(3);
75
+ if (!root || files.length === 0) {
76
+ console.log('AMEND_GUARD: usage error — expected `<root> <FILE.md> [FILE.md ...]`');
77
+ process.exit(2);
78
+ }
79
+ const blocked = [];
80
+ for (const rel of files) {
81
+ const base = path.basename(rel);
82
+ if (!GUARDED.has(base)) {
83
+ console.log(`AMEND_GUARD: usage error — ${base} is not a guarded planning file (${[...GUARDED].join(', ')})`);
84
+ process.exit(2);
85
+ }
86
+ const target = path.join(root, rel);
87
+ const st = lstatSafe(target);
88
+ if (!st) {
89
+ // Brand-new file: nothing is overwritten — unless its queued/<slug> unit already exists.
90
+ const unitDir = path.dirname(target);
91
+ const isQueuedUnit = path.basename(path.dirname(unitDir)) === 'queued';
92
+ const unitExists = isQueuedUnit && fs.existsSync(unitDir) && fs.readdirSync(unitDir).length > 0;
93
+ if (unitExists && !hasValidCopy(root, rel, null)) blocked.push(rel);
94
+ continue;
95
+ }
96
+ if (!st.isFile()) {
97
+ blocked.push(rel);
98
+ continue;
99
+ }
100
+ if (!hasValidCopy(root, rel, fs.readFileSync(target))) {
101
+ blocked.push(rel);
102
+ }
103
+ }
104
+ if (blocked.length > 0) {
105
+ for (const rel of blocked) {
106
+ console.log(`overwrite blocked: ${rel} has no amendments/rev-<NN> copy — copy the prior version to the next unused rev dir first`);
107
+ }
108
+ process.exit(1);
109
+ }
110
+ console.log('AMEND_GUARD: ok');
111
+ process.exit(0);
112
+ }
113
+
114
+ if (isDirectRun()) {
115
+ main();
116
+ }
@@ -161,7 +161,7 @@ const sha256File = (p) => crypto.createHash('sha256').update(fs.readFileSync(p))
161
161
  const RECEIPT_KEYS = [
162
162
  'schemaVersion', 'taskId', 'baseCommit', 'sourceRoot', 'destinationRoot',
163
163
  'sourceBranch', 'sourceHead', 'destinationHeadBefore', 'sourceSnapshotTree',
164
- 'resultTree', 'patchSha256', 'scopeSha256', 'changedPaths', 'createdAt', 'status',
164
+ 'resultTree', 'patchSha256', 'scopeSha256', 'changedPaths', 'createdAt', 'status', 'adoption',
165
165
  ];
166
166
  const APPROVAL_KEYS = [
167
167
  'schemaVersion', 'taskId', 'receiptSha256', 'verdict', 'executorModel',
@@ -46,6 +46,8 @@ import os from 'node:os';
46
46
  import path from 'node:path';
47
47
  import crypto from 'node:crypto';
48
48
  import { spawnSync } from 'node:child_process';
49
+ import { pathToFileURL, fileURLToPath } from 'node:url';
50
+ import { reconcileTaskRecord } from '../runtime/task-record-reconciliation.mjs';
49
51
 
50
52
  const GIT_TIMEOUT_MS = (() => {
51
53
  const raw = Number(process.env.UKIT_LAND_GIT_TIMEOUT_MS);
@@ -57,12 +59,32 @@ const USAGE = `land-worktree.mjs — task-scoped base-relative worktree landing
57
59
 
58
60
  Usage:
59
61
  node land-worktree.mjs --scope <json-path> --receipt <new-json-path>
62
+ node land-worktree.mjs --adopt-applied --scope <json-path> --receipt <new-json-path>
63
+ node land-worktree.mjs --reconcile-metadata <proof-json> --scope <json-path> --receipt <new-json-path>
60
64
  node land-worktree.mjs --help
61
65
 
62
66
  Scope JSON (schemaVersion 1): taskId, baseCommit, sourceRoot, destinationRoot,
63
67
  sourceHead, destinationHead, targetPaths[].
64
68
  The receipt path must be a NEW file outside the source worktree and outside the
65
- target set. exit 0 landed/no-op; exit 2 refusal/validation; exit 1 runtime error.`;
69
+ target set. Landing is refused (worker-not-terminated) while any delegated worker claim exists
70
+ for the task in the destination claims store (claims-unreadable if the store cannot be read); the
71
+ check and the landing mutation run under the same claims lock, so no claim transition can interleave.
72
+
73
+ --adopt-applied reconciles a landing whose bytes are ALREADY committed in the destination HEAD
74
+ without a receipt: nothing is applied; every delta path's blob+mode in the destination HEAD must equal
75
+ the source snapshot (else adopt-mismatch), and the receipt is explicitly marked adoption
76
+ "already-applied". Same worker-claim fence, scope and cleanliness checks as a normal landing.
77
+
78
+ --reconcile-metadata is a separate, no-apply history comparison. Proof schemaVersion:1 holds
79
+ records[{path,status?,additions[{text,evidence}]}] and evidence[{id,kind:"commit",commit,path,sha256}
80
+ or {id,kind:"file",path,sha256}]. Commit evidence must be retained destination history; file evidence
81
+ is a caller-pinned audit artifact, not independent semantic approval. Only this task record can have
82
+ evidenced blocked-status/append-only review or recovery progression; requirements/AC/dependencies
83
+ and existing verdict bytes remain exact. Audit provenance before supplying proof. Other delta paths
84
+ must be byte-identical. Receipt status is reconciled, NEVER landed, completion or cleanup authority.
85
+ Both modes retain the full scope; do not omit changed paths to make equality pass.
86
+
87
+ exit 0 landed/no-op/reconciled; exit 2 refusal/validation; exit 1 runtime error.`;
66
88
 
67
89
  // --- bounded process helpers ------------------------------------------------
68
90
 
@@ -105,12 +127,14 @@ function emit(status, taskId, reason, changedPaths, receiptPath) {
105
127
  function refuse(taskId, reason, diagnostic) {
106
128
  if (diagnostic) process.stderr.write(`land-worktree: ${reason}: ${diagnostic}\n`);
107
129
  emit('refused', taskId, reason, [], null);
130
+ if (inClaimsLock) throw new LandExit(2);
108
131
  process.exit(2);
109
132
  }
110
133
 
111
134
  function runtimeError(taskId, reason, diagnostic) {
112
135
  if (diagnostic) process.stderr.write(`land-worktree: ${reason}: ${diagnostic}\n`);
113
136
  emit('error', taskId, reason, [], null);
137
+ if (inClaimsLock) throw new LandExit(1);
114
138
  process.exit(1);
115
139
  }
116
140
 
@@ -119,11 +143,21 @@ function runtimeError(taskId, reason, diagnostic) {
119
143
  const argv = process.argv.slice(2);
120
144
  let scopePath = null;
121
145
  let receiptPath = null;
146
+ let adoptApplied = false;
147
+ let reconciliationPath = null;
148
+ let reconciliationPreflight = false;
122
149
  for (let i = 0; i < argv.length; i += 1) {
123
150
  const a = argv[i];
124
151
  if (a === '--help' || a === '-h') {
125
152
  process.stdout.write(`${USAGE}\n`);
126
153
  process.exit(0);
154
+ } else if (a === '--preflight') {
155
+ reconciliationPreflight = true;
156
+ } else if (a === '--reconcile-metadata') {
157
+ reconciliationPath = argv[++i];
158
+ if (!reconciliationPath) { process.stderr.write('land-worktree: missing reconciliation proof\n'); process.exit(2); }
159
+ } else if (a === '--adopt-applied') {
160
+ adoptApplied = true;
127
161
  } else if (a === '--scope') {
128
162
  scopePath = argv[++i];
129
163
  if (scopePath === undefined) { process.stderr.write('land-worktree: missing value for --scope\n'); process.exit(2); }
@@ -135,11 +169,45 @@ for (let i = 0; i < argv.length; i += 1) {
135
169
  process.exit(2);
136
170
  }
137
171
  }
172
+ if (reconciliationPreflight && !reconciliationPath) {
173
+ process.stderr.write('land-worktree: --preflight requires --reconcile-metadata\n');
174
+ process.exit(2);
175
+ }
138
176
  if (!scopePath || !receiptPath) {
139
177
  process.stderr.write(`land-worktree: --scope and --receipt are both required\n${USAGE}\n`);
140
178
  process.exit(2);
141
179
  }
142
180
 
181
+ // Stale-worker landing authority. The trusted source is the hook-written claims store in the
182
+ // destination project (never caller-supplied flags): while ANY delegated worker claim for the task
183
+ // exists -- active, or expired (lease expiry is not proof the worker ended) -- landing is refused
184
+ // (worker-not-terminated). An unreadable store or unloadable runtime refuses too (fail closed).
185
+ // The check runs INSIDE the claims lock and the lock is held through apply+verify+receipt, so a
186
+ // claim transition (grant/takeover/release) cannot interleave with the landing mutation.
187
+ let inClaimsLock = false;
188
+ class LandExit extends Error { constructor(code) { super('land-exit'); this.exitCode = code; } }
189
+ const RUNTIME_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'runtime');
190
+ const LAND_LOCK_BUDGET_MS = 5000;
191
+
192
+ async function barrier(name) {
193
+ const dir = process.env.UKIT_LAND_BARRIER_DIR; // deterministic race-test hook; inert when unset
194
+ if (!dir) return;
195
+ try { fs.writeFileSync(path.join(dir, `${name}.reached`), ''); } catch { return; } // bad dir == unset
196
+ const go = path.join(dir, `${name}.go`);
197
+ const end = Date.now() + 30000;
198
+ while (!fs.existsSync(go) && Date.now() < end) await new Promise((r) => setTimeout(r, 10));
199
+ }
200
+
201
+ async function workerAuthorityClear(taskId, root) {
202
+ try {
203
+ const mod = await import(pathToFileURL(path.join(RUNTIME_DIR, 'resumable-run.mjs')).href);
204
+ const r = await mod.readTaskClaim(root, taskId);
205
+ if (r.unreadable) return 'claims-unreadable';
206
+ if (r.claimed && String(r.claim.owner || '').startsWith('deleg:')) return 'worker-not-terminated';
207
+ return null;
208
+ } catch { return 'claims-unreadable'; }
209
+ }
210
+
143
211
  // --- scope validation --------------------------------------------------------
144
212
 
145
213
  const SCOPE_KEYS = [
@@ -221,7 +289,7 @@ const isInsideOrEqual = (child, parent) => {
221
289
 
222
290
  // --- main --------------------------------------------------------------------
223
291
 
224
- function main() {
292
+ async function main() {
225
293
  const scope = readScope();
226
294
  const { taskId, targetPaths } = scope;
227
295
  const targets = new Set(targetPaths);
@@ -361,11 +429,142 @@ function main() {
361
429
  const patchSha256 = crypto.createHash('sha256').update(patchBuf).digest('hex');
362
430
  const scopeSha256 = crypto.createHash('sha256').update(fs.readFileSync(scopePath)).digest('hex');
363
431
 
432
+ // Everything from here to the receipt runs under the claims lock with the authority checked
433
+ // inside it (fence + mutation are one critical section; see workerAuthorityClear).
434
+ await barrier('pre-lock');
435
+ const lockMod = await import(pathToFileURL(path.join(RUNTIME_DIR, 'async-lock.mjs')).href).catch(() => null);
436
+ const claimsMod = await import(pathToFileURL(path.join(RUNTIME_DIR, 'resumable-run.mjs')).href).catch(() => null);
437
+ if (!lockMod || !claimsMod) refuse(taskId, 'claims-unreadable', 'claims runtime unavailable');
438
+ let exitCode = 0;
439
+ try {
440
+ fs.mkdirSync(path.dirname(claimsMod.taskClaimsPath(destinationRoot)), { recursive: true });
441
+ const outcome = await lockMod.withAsyncLock(claimsMod.taskClaimsPath(destinationRoot), { deadlineMs: LAND_LOCK_BUDGET_MS }, async () => {
442
+ inClaimsLock = true;
443
+ try {
444
+ const blocked = await workerAuthorityClear(taskId, destinationRoot);
445
+ if (blocked) refuse(taskId, blocked, 'a delegated worker claim exists for this task');
446
+ await barrier('in-lock');
447
+ landLocked();
448
+ } finally { inClaimsLock = false; }
449
+ });
450
+ if (!outcome.ok) refuse(taskId, 'claims-busy', 'could not take the claims lock');
451
+ } catch (e) {
452
+ if (!(e instanceof LandExit)) throw e;
453
+ exitCode = e.exitCode;
454
+ }
455
+ process.exit(exitCode);
456
+
457
+ function reconcileLocked() {
458
+ const stable = () => {
459
+ if (gitText(sourceRoot, ['rev-parse', 'HEAD']).stdout.trim() !== srcHead
460
+ || gitText(destinationRoot, ['rev-parse', 'HEAD']).stdout.trim() !== dstHead) refuse(taskId, 'head-drift', 'HEAD moved during reconciliation');
461
+ checkDestinationClean(destinationRoot, targetPaths, taskId);
462
+ if (computeWorktreeTree(sourceRoot) !== snapshotTree) refuse(taskId, 'source-drift', 'source snapshot moved during reconciliation');
463
+ };
464
+ stable();
465
+ if (adoptApplied) refuse(taskId, 'invalid-proof', 'reconciliation and adoption are separate operations');
466
+ let proof;
467
+ try { proof = JSON.parse(fs.readFileSync(reconciliationPath, 'utf8')); } catch { refuse(taskId, 'invalid-proof', 'proof unreadable'); }
468
+ if (proof?.schemaVersion !== 1 || !Array.isArray(proof.records) || !Array.isArray(proof.evidence)) refuse(taskId, 'invalid-proof', 'invalid proof schema');
469
+ const evidence = new Map();
470
+ const evidenceRefs = [];
471
+ for (const e of proof.evidence) {
472
+ if (!e || typeof e.id !== 'string' || evidence.has(e.id) || !/^[a-f0-9]{64}$/.test(e.sha256 || '')) refuse(taskId, 'invalid-proof', 'invalid evidence identity');
473
+ let bytes;
474
+ if (e.kind === 'commit') {
475
+ if (!/^[a-f0-9]{40}$/.test(e.commit || '') || !targetPaths.includes(e.path)
476
+ || gitText(destinationRoot, ['merge-base', '--is-ancestor', e.commit, dstHead]).status !== 0) refuse(taskId, 'invalid-proof', 'evidence commit is not retained destination history');
477
+ const r = gitBytes(destinationRoot, ['show', `${e.commit}:${e.path}`]);
478
+ if (r.status !== 0) refuse(taskId, 'invalid-proof', 'evidence blob unavailable');
479
+ bytes = r.stdout;
480
+ } else if (e.kind === 'file') {
481
+ // File evidence is explicitly scoped to the pinned proof's audit directory.
482
+ // This authenticates declared bytes, not the narrative's semantic provenance.
483
+ try {
484
+ const auditDir = path.dirname(fs.realpathSync(reconciliationPath));
485
+ const evidencePath = fs.realpathSync(e.path);
486
+ if (path.dirname(evidencePath) !== auditDir || !fs.statSync(evidencePath).isFile()) refuse(taskId, 'invalid-proof', 'file evidence must be colocated with the proof in its audit directory');
487
+ bytes = fs.readFileSync(evidencePath);
488
+ } catch (e) {
489
+ if (e instanceof LandExit) throw e;
490
+ refuse(taskId, 'invalid-proof', 'evidence file unavailable');
491
+ }
492
+ } else refuse(taskId, 'invalid-proof', 'unknown evidence kind');
493
+ if (crypto.createHash('sha256').update(bytes).digest('hex') !== e.sha256) refuse(taskId, 'invalid-proof', 'evidence hash mismatch');
494
+ evidence.set(e.id, bytes.toString('utf8'));
495
+ evidenceRefs.push(e);
496
+ }
497
+ const records = new Map();
498
+ for (const r of proof.records) {
499
+ if (r?.path !== `docs/AI_HANDOFF/tasks/${taskId}.md` || records.has(r.path)) refuse(taskId, 'invalid-proof', 'only this exact task record may have a history progression');
500
+ records.set(r.path, r);
501
+ }
502
+ // The scope must describe the WHOLE delta, never omit a mismatching record.
503
+ if (targetPaths.length !== deltaPaths.length || targetPaths.some((p) => !deltaPaths.includes(p))) refuse(taskId, 'scope-drift', 'reconciliation scope must equal the complete source delta');
504
+ const rows = [];
505
+ for (const p of deltaPaths) {
506
+ const entry = (root, tree) => {
507
+ const r = gitText(root, ['ls-tree', '-z', tree, '--', p]);
508
+ if (r.status !== 0) refuse(taskId, 'reconcile-unreadable', 'tree entry unavailable');
509
+ const m = r.stdout.match(/^(\d+) (\w+) ([a-f0-9]+)\t/);
510
+ return m ? { mode: m[1], type: m[2], blob: m[3] } : null;
511
+ };
512
+ const b = entry(sourceRoot, base), s = entry(sourceRoot, snapshotTree), d = entry(destinationRoot, dstHead);
513
+ const row = { path: p, base: b, source: s, destination: d, result: d, kind: 'byte-identical' };
514
+ if (JSON.stringify(s) !== JSON.stringify(d)) {
515
+ const r = records.get(p);
516
+ if (!r || !s || !d || s.mode !== '100644' || d.mode !== s.mode || s.type !== 'blob' || d.type !== 'blob') refuse(taskId, 'reconcile-mismatch', `unexplained mismatch at ${JSON.stringify(p)}`);
517
+ const text = (oid) => {
518
+ const result = gitBytes(sourceRoot, ['cat-file', 'blob', oid]);
519
+ if (result.status !== 0) refuse(taskId, 'reconcile-unreadable', 'blob unavailable');
520
+ const decoded = result.stdout.toString('utf8');
521
+ if (!Buffer.from(decoded).equals(result.stdout)) refuse(taskId, 'reconcile-unreadable', 'task record is not UTF-8');
522
+ return decoded;
523
+ };
524
+ try { row.progression = reconcileTaskRecord(text(s.blob), text(d.blob), r, evidence); }
525
+ catch (e) { refuse(taskId, 'reconcile-mismatch', e.message); }
526
+ row.kind = 'history-progression';
527
+ records.delete(p);
528
+ }
529
+ rows.push(row);
530
+ }
531
+ if (records.size) refuse(taskId, 'invalid-proof', 'unused metadata proof');
532
+ stable();
533
+ const receipt = { schemaVersion: 1, receiptKind: 'metadata-reconciliation', status: 'reconciled', taskId,
534
+ baseCommit: base, sourceHead: srcHead, destinationHead: dstHead, sourceSnapshotTree: snapshotTree,
535
+ sourceRoot, destinationRoot, scopeSha256, proofSha256: crypto.createHash('sha256').update(fs.readFileSync(reconciliationPath)).digest('hex'),
536
+ paths: rows, evidence: evidenceRefs, completion: false, cleanupEligible: false, createdAt: new Date().toISOString() };
537
+ if (reconciliationPreflight) {
538
+ process.stdout.write(`${JSON.stringify({ ...receipt, status: 'reconciliation-preflight', receiptPath: null })}\n`);
539
+ return;
540
+ }
541
+ try {
542
+ fs.mkdirSync(path.dirname(receiptAbs), { recursive: true });
543
+ fs.writeFileSync(receiptAbs, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx' });
544
+ } catch (e) { runtimeError(taskId, 'receipt-write-failed', e.code || e.message); }
545
+ emit('reconciled', taskId, 'history-preserved', deltaPaths, receiptAbs);
546
+ }
547
+
548
+ function landLocked() {
549
+ if (reconciliationPath) {
550
+ reconcileLocked();
551
+ return;
552
+ }
364
553
  // A verified empty delta is a landed no-op: identity check only, no apply.
554
+ if (adoptApplied) {
555
+ for (const p of deltaPaths) {
556
+ if (treeEntry(sourceRoot, snapshotTree, p) !== treeEntry(destinationRoot, 'HEAD', p)) {
557
+ refuse(taskId, 'adopt-mismatch', `destination HEAD does not already hold the source snapshot at ${JSON.stringify(p)}`);
558
+ }
559
+ }
560
+ writeReceipt(receiptAbs, taskId, base, sourceRoot, destinationRoot, srcHead, dstHead, snapshotTree, computeWorktreeTree(destinationRoot), patchSha256, scopeSha256, deltaPaths);
561
+ emit('landed', taskId, 'adopted', deltaPaths, receiptAbs);
562
+ return;
563
+ }
365
564
  if (deltaPaths.length === 0) {
366
565
  writeReceipt(receiptAbs, taskId, base, sourceRoot, destinationRoot, srcHead, dstHead, snapshotTree, snapshotTree, patchSha256, scopeSha256, deltaPaths);
367
566
  emit('landed', taskId, 'no-op', deltaPaths, receiptAbs);
368
- process.exit(0);
567
+ return;
369
568
  }
370
569
 
371
570
  const tmpPatch = path.join(os.tmpdir(), `ukit-land-${process.pid}-${crypto.randomBytes(6).toString('hex')}.patch`);
@@ -408,7 +607,7 @@ function main() {
408
607
 
409
608
  writeReceipt(receiptAbs, taskId, base, sourceRoot, destinationRoot, srcHead, dstHead, snapshotTree, dstTree, patchSha256, scopeSha256, deltaPaths);
410
609
  emit('landed', taskId, 'ok', deltaPaths, receiptAbs);
411
- process.exit(0);
610
+ }
412
611
  }
413
612
 
414
613
  // --- helpers -----------------------------------------------------------------
@@ -436,7 +635,9 @@ function computeWorktreeTree(root) {
436
635
  const idx = path.join(os.tmpdir(), `ukit-land-${process.pid}-${crypto.randomBytes(6).toString('hex')}.index`);
437
636
  try {
438
637
  const env = { GIT_INDEX_FILE: idx };
439
- for (const args of [['read-tree', 'HEAD'], ['add', '-A']]) {
638
+ // The claims lock this landing holds lives in the destination; it is never landed content. It is dropped from the temp index afterwards: an exclude pathspec makes `git add` fail when
639
+ // the destination .gitignore ignores that path.
640
+ for (const args of [['read-tree', 'HEAD'], ['add', '-A', '--', '.'], ['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', '.ukit/storage/runs/claims.json.lock']]) {
440
641
  const r = gitText(root, args, env);
441
642
  if (r.status !== 0) runtimeError(null, 'verify-failed', r.stderr.split('\n')[0]);
442
643
  }
@@ -483,6 +684,7 @@ function writeReceipt(absPath, taskId, base, sourceRoot, destinationRoot, srcHea
483
684
  changedPaths: changedPaths.slice(),
484
685
  createdAt: new Date().toISOString(),
485
686
  status: 'landed',
687
+ ...(adoptApplied ? { adoption: 'already-applied' } : {}),
486
688
  };
487
689
  try {
488
690
  fs.mkdirSync(path.dirname(absPath), { recursive: true });
@@ -495,31 +697,51 @@ function writeReceipt(absPath, taskId, base, sourceRoot, destinationRoot, srcHea
495
697
  }
496
698
 
497
699
  function checkDestinationClean(destinationRoot, targetPaths, taskId) {
498
- const st = gitText(destinationRoot, ['status', '--porcelain', '-z']);
700
+ // --untracked-files=all enumerates every untracked file individually (no
701
+ // whole-directory collapse), so each tolerated entry is an exact path that
702
+ // verify's delta check can exclude precisely.
703
+ const st = gitText(destinationRoot, ['status', '--porcelain', '-z', '--untracked-files=all']);
499
704
  if (st.status !== 0) refuse(taskId, 'destination-unreadable', 'could not read the destination status');
500
705
  const entries = parsePorcelainZ(st.stdout);
501
- // Protected set: every target path and every ancestor directory of a target.
502
- const protectedPaths = new Set();
503
- for (const t of targetPaths) {
504
- protectedPaths.add(t);
505
- const parts = t.split('/');
506
- for (let i = 1; i < parts.length; i += 1) protectedPaths.add(parts.slice(0, i).join('/'));
507
- }
508
- const collides = (p) => protectedPaths.has(p) || [...protectedPaths].some((d) => p.startsWith(`${d}/`));
706
+ // Refuse a TRUE overlap with a target only: the target path itself, untracked
707
+ // content under a target path (a directory occupying the target's name), or
708
+ // an untracked file occupying a needed ancestor directory of a target. An
709
+ // entry that merely shares an ancestor directory with a target (e.g.
710
+ // "docs/AI_HANDOFF/RUN.md" beside "docs/AI_HANDOFF/tasks/TASK-001.md") is
711
+ // unrelated and tolerated.
712
+ const collides = (p) => targetPaths.some((t) => t === p || p.startsWith(`${t}/`) || t.startsWith(`${p}/`));
509
713
  const tolerated = new Set();
714
+ const dirty = [];
510
715
 
511
716
  for (const { xy, path: p } of entries) {
512
717
  if (xy === '??') {
513
- // Untracked: refuse only a target-path collision; unrelated files tolerated.
514
718
  if (collides(p)) refuse(taskId, 'destination-collision', `untracked file collides with a target path: ${JSON.stringify(p)}`);
515
- // `-z` disables core.quotePath, but git still prints a directory entry with
516
- // a trailing slash — normalize so verify's delta set matches.
517
- tolerated.add(p.endsWith('/') ? p.slice(0, -1) : p);
719
+ // An untracked nested repository / linked worktree (e.g. .worktrees/task-001) is
720
+ // reported by status as "dir/" but recorded by `git add -A` as the gitlink "dir";
721
+ // tolerate the gitlink spelling too so verify does not call it an unexpected landing.
722
+ tolerated.add(p);
723
+ if (p.length > 1 && p.endsWith('/')) tolerated.add(p.slice(0, -1));
518
724
  } else {
519
- // Any tracked modification or staged change anywhere refuses.
520
- refuse(taskId, 'destination-dirty', `tracked modification at ${JSON.stringify(p)}`);
725
+ // Any tracked modification or staged change anywhere refuses (FR-M4-002:
726
+ // "tracked-clean destination" — a patch applied over unrelated tracked
727
+ // edits would bake them into the verify snapshot and receipt resultTree).
728
+ dirty.push(p);
521
729
  }
522
730
  }
731
+ if (dirty.length > 0) {
732
+ // Actionable refusal: name every dirty path (bounded) and the supported
733
+ // recovery — the owner commits or stashes those paths, or the coordinator
734
+ // lands into a clean destination worktree after checkpointing landed paths.
735
+ const listed = dirty.slice(0, 8).map((p) => JSON.stringify(p)).join(', ');
736
+ const more = dirty.length > 8 ? ` (+${dirty.length - 8} more)` : '';
737
+ refuse(
738
+ taskId,
739
+ 'destination-dirty',
740
+ `tracked modification(s) at ${listed}${more} — the destination must be tracked-clean`
741
+ + ' (no uncommitted or staged tracked change anywhere). Recovery: the owner'
742
+ + ' commits or stashes these paths, or land into a clean destination worktree.',
743
+ );
744
+ }
523
745
  return tolerated;
524
746
  }
525
747
 
@@ -540,4 +762,4 @@ function parsePorcelainZ(raw) {
540
762
  return out;
541
763
  }
542
764
 
543
- main();
765
+ await main();