@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.4

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 (98) hide show
  1. package/bin/__tests__/codex-hooks.test.js +794 -21
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +1838 -112
  4. package/bin/__tests__/installer-safety.test.js +82 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +799 -0
  6. package/bin/__tests__/package-inventory.test.js +841 -10
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +2469 -71
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +153 -202
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
  22. package/src/claude/hooks/completion-authority-check.cjs +56 -0
  23. package/src/claude/hooks/completion-authority-state.cjs +527 -0
  24. package/src/claude/hooks/completion-authority.cjs +154 -0
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/session.cjs +33 -19
  31. package/src/claude/hooks/spec-gate.cjs +181 -139
  32. package/src/claude/hooks/spec-state.cjs +112 -47
  33. package/src/claude/migration-manifest.json +15 -2
  34. package/src/claude/rules/state-sync.md +52 -21
  35. package/src/claude/rules/workflow.md +17 -9
  36. package/src/claude/scripts/provenance.cjs +315 -0
  37. package/src/claude/scripts/spec-final-state.cjs +244 -0
  38. package/src/claude/scripts/spec-ground.cjs +535 -173
  39. package/src/claude/scripts/spec-readiness.cjs +118 -0
  40. package/src/claude/scripts/spec-receipt.cjs +162 -0
  41. package/src/claude/scripts/spec-resolver.cjs +501 -0
  42. package/src/claude/scripts/spec-scaffold.cjs +1945 -55
  43. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  44. package/src/claude/scripts/validate-spec-output.cjs +3204 -262
  45. package/src/claude/scripts/workflow-policy.cjs +1888 -203
  46. package/src/claude/settings/settings.json +12 -0
  47. package/src/claude/skills/code-review/SKILL.md +94 -66
  48. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  49. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  50. package/src/claude/skills/develop/SKILL.md +138 -283
  51. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  52. package/src/claude/skills/develop/references/quality-gate.md +123 -148
  53. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  54. package/src/claude/skills/specs/SKILL.md +192 -397
  55. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  56. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  57. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  58. package/src/claude/skills/specs/references/review.md +200 -260
  59. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  60. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  61. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  62. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  63. package/src/claude/skills/specs/rules/design-review.md +42 -97
  64. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  65. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  66. package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
  67. package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
  68. package/src/claude/skills/specs/templates/design.md +49 -263
  69. package/src/claude/skills/specs/templates/requirements.md +26 -36
  70. package/src/claude/skills/specs/templates/research.md +9 -98
  71. package/src/claude/skills/specs/templates/spec-state.json +26 -67
  72. package/src/claude/skills/specs/templates/task.md +30 -82
  73. package/src/claude/skills/sync/SKILL.md +45 -15
  74. package/src/claude/skills/sync/references/sync-protocols.md +34 -16
  75. package/src/claude/skills/test/SKILL.md +151 -167
  76. package/src/codex/AGENTS.md +6 -0
  77. package/src/codex/hooks/completion-authority-check.cjs +63 -0
  78. package/src/codex/hooks/completion-authority-state.cjs +527 -0
  79. package/src/codex/hooks/completion-authority.cjs +106 -0
  80. package/src/codex/hooks/lib/hook-context.cjs +58 -4
  81. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  82. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  83. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  84. package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
  85. package/src/codex/hooks/lib/spec-utils.cjs +104 -27
  86. package/src/codex/hooks/privacy-approval.cjs +1 -1
  87. package/src/codex/hooks/privacy-block.cjs +67 -11
  88. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  89. package/src/codex/hooks/session.cjs +2 -0
  90. package/src/codex/hooks/spec-gate.cjs +151 -24
  91. package/src/codex/hooks/spec-state.cjs +65 -12
  92. package/src/codex/hooks/state.cjs +2 -0
  93. package/src/codex/hooks.json +16 -1
  94. package/src/codex/rules/state-sync.md +47 -27
  95. package/src/common/AGENTS.md +1 -1
  96. package/src/opencode/AGENTS.md +4 -2
  97. package/src/opencode/plugins/spec-gate.ts +296 -122
  98. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -1,31 +1,62 @@
1
- # Tollgate Protocol (State Sync)
1
+ # Specs v2.1 state synchronization
2
2
 
3
- ## Single Source of Truth
3
+ ## Authority layers
4
4
 
5
- In any Spec-driven workflow (`specs`), the state of the project is physically persisted in **two layers**:
6
- 1. **Machine Layer (`spec.json`)**: Tracks phase, status, overall completion, and per-task machine state via `task_registry`.
7
- 2. **Human Layer (`tasks/task-*.md`)**: Checkboxes indicating granular execution progress.
5
+ `spec.json` is machine semantic authority. Requirements, design, conditional
6
+ research, and conditional task Markdown are human projections. Host state may
7
+ record authority or integrity, but it never supplies product semantics.
8
8
 
9
- ## The Sync-back Rule (Mandatory)
9
+ Planning depth and assurance level remain independent. The artifact router runs
10
+ before discovery: None persists no spec; Compact and Full persist the bounded
11
+ core; research and tasks remain absent unless their semantic triggers exist.
10
12
 
11
- Whenever an agent finishes a task or blocks due to an issue, it **MUST NOT** simply respond with "Done" or "Blocked" in chat.
12
- Before returning control to the user or orchestrator, the agent **MUST**:
13
+ ## Canonical lifecycle
13
14
 
14
- ### On Success:
15
- 1. Update `spec.json`: Modify `current_phase` if moving forward, ensure `status` accurately reflects progress, keep `task_files` synchronized with the real files on disk, and update the corresponding `task_registry` entry (`status`, `blocker`, `started_at`, `completed_at`, `last_updated_at`).
16
- 2. Edit `task-R*.md`: Change `Status` only after real verification has passed (build/test/runtime/artifact). Then check `[x]` the sub-task boxes and relevant completion criteria.
17
- 3. Call `TaskUpdate` if Claude Tasks are active, setting the status to "completed" only after the physical files were updated.
15
+ The feature lifecycle is exactly `in_progress`, `paused`, `blocked`, or `done`.
16
+ Authoring state is independently `draft`, `validated`, or `absent`. Do not emit
17
+ legacy lifecycle aliases in a new 2.1 artifact.
18
18
 
19
- ### On Block/Failure (>3 retries):
20
- 1. Update `spec.json`: Set `"status": "blocked"` and fill out the `"blocker"` string with the root cause.
21
- 2. Update the corresponding `task_registry` entry to `blocked`, persist the blocker reason, and stamp `last_updated_at`.
22
- 3. Edit `task-R*.md`: Change `Status: pending` (or `in_progress`) to `Status: blocked` with a note.
23
- 4. Alert the orchestrator or user via `AskUserQuestion` or explicit warning.
19
+ Technical readiness is not lifecycle completion. `ready_for_implementation`
20
+ may change only through the installed machine finalization path after every
21
+ routed artifact, projection, semantic review, grounding check, and deterministic
22
+ gate passes. An author must not directly declare readiness, authority, approval,
23
+ review capability, execution proof, or closeout.
24
24
 
25
- **Canonical state values:** New specs MUST use `status: "in_progress"` for active work. Legacy `in-progress` may be read for compatibility, but must not be emitted in new files.
25
+ ## Projection sync
26
26
 
27
- **Golden Rule:** If the current phase changes, or a task completes, the agent must update the physical files. Never mark a task completed before there is execution proof, and never let `task_registry` disagree with the matching markdown task file. The context is intentionally NOT persisted in the chat to save tokens. An injected Hook (`spec-state.cjs`) constantly enforces and validates this state.
27
+ After any semantic Markdown edit:
28
28
 
29
- ### Completion gate (Stop)
29
+ 1. invoke the explicit installed machine semantic-sync step to promote `semantic_model`;
30
+ 2. regenerate or reconcile every affected Markdown projection;
31
+ 3. verify requirements, decisions, anchors, task registry, typed boundaries,
32
+ and verification refs round-trip without loss; and
33
+ 4. rerun the applicable deterministic validation and grounding checks.
30
34
 
31
- Marking a task `done` is now machine-gated on the **Stop** hook (`spec-gate.cjs`), not only reminded on each prompt. When a turn ends with *newly-done* tasks, the gate requires a verification receipt in each task markdown: `Status: done`, `## Evidence` (legacy heading aliases still parse) with real proof (no `{{...}}` placeholders; at least one fenced command block or PASS/FAIL line), and a non-empty `task_registry[path].completed_at`. First adoption (no cache yet) seeds history without blocking. Escape hatch for humans: `"spec": { "completion_gate": false }` in `.claude/runtime.json` (missing key keeps the gate ON).
35
+ For Claude Code, the isolated promotion is
36
+ `node .claude/scripts/spec-scaffold.cjs <feature> --sync-semantic-model`.
37
+
38
+ Task Markdown exists only when typed ownership, dependency, transition, proof,
39
+ or parallel topology requires it. Its status and dependencies project the
40
+ matching `task_registry` entry. Its Verification Plan is prospective and never
41
+ contains an execution verdict or proof claim. Topology comes only from typed
42
+ `coordination.boundaries`, never from legacy trigger fields, priority markers,
43
+ related-file lists, or prose.
44
+
45
+ ## Lifecycle updates
46
+
47
+ - Starting or continuing authored work keeps the feature `in_progress`.
48
+ - A deliberate resumable stop uses `paused` with the exact remaining decision.
49
+ - An external or semantic blocker uses `blocked` with a concrete cause.
50
+ - Task state changes update `task_registry` and the matching task projection
51
+ together, but only from observed work and verification.
52
+ - Feature `done` is reserved for final-state authority after implementation
53
+ closeout; authoring or readiness cannot produce it.
54
+
55
+ State synchronization never fabricates commands, PASS results, timestamps,
56
+ receipts, completed work, reviewer identity, or user approval. When execution
57
+ evidence is absent, preserve the unfinished state and report the missing proof.
58
+ Never hand-author the `semantic_model` shape or treat an unsynchronized Markdown
59
+ edit as machine authority.
60
+
61
+ Claude-native plan/task state may mirror physical state when available, but it
62
+ is never a substitute for `spec.json` or authority to advance the lifecycle.
@@ -13,14 +13,16 @@ Use the CafeKit loop: **Understand -> Plan -> Execute -> Verify -> Sync**.
13
13
  ## 2. Plan
14
14
 
15
15
  - For non-trivial features, use `/specs` to create or validate the spec.
16
- - For approved specs, work one task file at a time.
16
+ - For technically ready specs, start implementation only after a new explicit Develop invocation and work one task file at a time.
17
17
  - Extract from the active task:
18
- - `Context`
19
- - `Steps`
20
- - `Requirements`
21
- - `Related Files`
22
- - `Completion Criteria`
23
- - `Evidence`
18
+ - `Status`
19
+ - `Outcome`
20
+ - `Scope`
21
+ - `Anchors and Ownership`
22
+ - `Changes`
23
+ - `Acceptance`
24
+ - `Dependencies`
25
+ - `Verification Plan`
24
26
  - If these are missing or too vague to verify, route back to spec correction.
25
27
 
26
28
  ## 3. Execute
@@ -32,7 +34,7 @@ Use the CafeKit loop: **Understand -> Plan -> Execute -> Verify -> Sync**.
32
34
 
33
35
  ## 4. Verify
34
36
 
35
- - Run exact commands from `Evidence` first.
37
+ - Run exact commands from `Verification Plan` first.
36
38
  - Then run repo-level lint/test/build as needed for confidence.
37
39
  - Use only fresh verification from the current run when claiming completion.
38
40
  - `PRECHECK_FAIL` outranks `NO_TESTS`.
@@ -42,7 +44,13 @@ Use the CafeKit loop: **Understand -> Plan -> Execute -> Verify -> Sync**.
42
44
  ## 5. Sync
43
45
 
44
46
  - Mark task state only after implementation, tests/evidence, and review pass.
45
- - Write a verification receipt with commands run, outcomes, and artifact/runtime proof.
47
+ - Write task proof to `receipts/<task-basename>.md`; use legacy task-local
48
+ `## Evidence` only when consuming an old task format.
49
+ - Create `feature-receipt.md` only for final integration closeout.
50
+ - Treat `spec.json` as machine authority; task Markdown is a human projection.
51
+ - Derive task ordering, ownership, proof, and parallel eligibility only from
52
+ typed `coordination.boundaries`, never `(P)`, `task_triggers`, `Related Files`,
53
+ or prose markers.
46
54
  - Keep `spec.json.task_registry` and markdown task files aligned.
47
55
  - Run docs checkpoint when a completed task affects public docs or architecture docs.
48
56
 
@@ -0,0 +1,315 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const PROVENANCE_MODE = 'worktree-sha256-v1';
9
+ const CONTEXT_SCHEMA_VERSION = '1';
10
+ const trustedContexts = new WeakSet();
11
+
12
+ class ProvenanceError extends Error {
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.name = 'ProvenanceError';
16
+ this.code = code;
17
+ }
18
+ }
19
+
20
+ function fail(code, message) {
21
+ throw new ProvenanceError(code, message);
22
+ }
23
+
24
+ function valueOf(input, snake, camel) {
25
+ const hasSnake = Object.prototype.hasOwnProperty.call(input, snake);
26
+ const hasCamel = Object.prototype.hasOwnProperty.call(input, camel);
27
+ if (hasSnake && hasCamel && input[snake] !== input[camel]) {
28
+ fail('ambiguous_identity', `${snake} and ${camel} disagree`);
29
+ }
30
+ return hasSnake ? input[snake] : input[camel];
31
+ }
32
+
33
+ function text(value, field) {
34
+ if (typeof value !== 'string' || value.trim() !== value || value === '' || /[\0\r\n]/.test(value)) {
35
+ fail('malformed_input', `${field} must be a non-empty single-line string`);
36
+ }
37
+ return value;
38
+ }
39
+
40
+ function inside(root, candidate, field) {
41
+ const relative = path.relative(root, candidate);
42
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
43
+ fail('path_escape', `${field} escapes the project root`);
44
+ }
45
+ return relative;
46
+ }
47
+
48
+ function existingPath(value, field, kind, base = process.cwd()) {
49
+ const requested = text(value, field);
50
+ let canonical;
51
+ try { canonical = fs.realpathSync(path.resolve(base, requested)); } catch (error) {
52
+ fail('path_error', `${field} cannot be canonicalized (${error.code || error.message})`);
53
+ }
54
+ let stat;
55
+ try { stat = fs.lstatSync(canonical); } catch (error) {
56
+ fail('path_error', `${field} cannot be inspected (${error.code || error.message})`);
57
+ }
58
+ if ((kind === 'directory' && !stat.isDirectory()) || (kind === 'file' && !stat.isFile())) {
59
+ fail('malformed_input', `${field} is not a ${kind}`);
60
+ }
61
+ return canonical;
62
+ }
63
+
64
+ function runGit(root, args, label) {
65
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
66
+ if (result.error || result.status !== 0 || result.signal) {
67
+ const detail = result.error?.message || String(result.stderr || '').trim() || `exit ${result.status}`;
68
+ fail('git_command_failed', `${label} failed: ${detail}`);
69
+ }
70
+ if (!Buffer.isBuffer(result.stdout)) fail('malformed_git_output', `${label} did not return bytes`);
71
+ return result.stdout;
72
+ }
73
+
74
+ function decode(value, label) {
75
+ const decoded = value.toString('utf8');
76
+ if (!Buffer.from(decoded, 'utf8').equals(value)) fail('malformed_git_output', `${label} is not valid UTF-8`);
77
+ return decoded;
78
+ }
79
+
80
+ function oneLine(value, label) {
81
+ const output = decode(value, label);
82
+ if (!/^[^\0\r\n]+\n?$/.test(output)) fail('malformed_git_output', `${label} is not a single line`);
83
+ return output.replace(/\n$/, '');
84
+ }
85
+
86
+ function gitBase(root) {
87
+ const output = oneLine(runGit(root, ['rev-parse', 'HEAD'], 'git rev-parse HEAD')).toLowerCase();
88
+ if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(output)) fail('malformed_git_output', 'git rev-parse HEAD did not return a commit id');
89
+ return output;
90
+ }
91
+
92
+ function gitRoot(requested) {
93
+ const input = existingPath(requested, 'projectRoot', 'directory');
94
+ const reported = oneLine(runGit(input, ['rev-parse', '--show-toplevel'], 'git rev-parse --show-toplevel'));
95
+ const root = existingPath(reported, 'git project root', 'directory');
96
+ inside(root, input, 'projectRoot');
97
+ return root;
98
+ }
99
+
100
+ function splitNul(bytes, label) {
101
+ if (bytes.length && bytes[bytes.length - 1] !== 0) fail('malformed_git_output', `${label} is not NUL terminated`);
102
+ return bytes.length === 0 ? [] : bytes.subarray(0, -1).toString('binary').split('\0').map((entry) => {
103
+ const raw = Buffer.from(entry, 'binary');
104
+ return decode(raw, label);
105
+ });
106
+ }
107
+
108
+ function pathKey(root, relative, label) {
109
+ if (typeof relative !== 'string' || relative === '' || relative.includes('\0') || path.posix.isAbsolute(relative)) {
110
+ fail('malformed_git_output', `${label} contains an invalid path`);
111
+ }
112
+ const normalized = path.sep === '\\' ? relative.replaceAll('\\', '/') : relative;
113
+ if (normalized.split('/').includes('..')) fail('path_escape', `${label} escapes the project root`);
114
+ const absolute = path.resolve(root, ...normalized.split('/'));
115
+ inside(root, absolute, label);
116
+ return { relative: normalized, absolute };
117
+ }
118
+
119
+ function parseStatus(bytes) {
120
+ const values = splitNul(bytes, 'git status');
121
+ const entries = new Map();
122
+ for (let index = 0; index < values.length; index += 1) {
123
+ const value = values[index];
124
+ if (value.length < 4 || value[2] !== ' ') fail('malformed_git_output', 'git status emitted an invalid porcelain record');
125
+ const xy = value.slice(0, 2);
126
+ const first = value.slice(3);
127
+ if (xy.includes('R') || xy.includes('C')) {
128
+ const next = values[++index];
129
+ if (!next) fail('malformed_git_output', 'git status rename record is incomplete');
130
+ entries.set(first, { xy, role: 'rename-source', counterpart: next });
131
+ entries.set(next, { xy, role: 'rename-target', counterpart: first });
132
+ } else {
133
+ entries.set(first, { xy, role: xy === '??' || xy === '!!' ? 'untracked' : 'tracked' });
134
+ }
135
+ }
136
+ return entries;
137
+ }
138
+
139
+ function excluded(root, absolute, specsRoot) {
140
+ const relative = path.relative(root, absolute).split(path.sep).join('/');
141
+ const specsRelative = path.relative(root, specsRoot);
142
+ // These roots are generated runtime state, not source evidence. The selected
143
+ // canonical specs root is excluded above to avoid receipt/state recursion.
144
+ const roots = [
145
+ '.git', 'node_modules', '.claude/hooks/.logs', '.codex/hooks/.logs',
146
+ '.claude/.logs', '.codex/.logs', '.claude/runtime.json', '.codex/runtime.json',
147
+ ];
148
+ if (relative === specsRelative || relative.startsWith(`${specsRelative}/`)) return true;
149
+ return roots.some((entry) => relative === entry || relative.startsWith(`${entry}/`));
150
+ }
151
+
152
+ function manifest(root, specsRoot) {
153
+ const statuses = parseStatus(runGit(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], 'git status'));
154
+ const tracked = [
155
+ ...splitNul(runGit(root, ['ls-files', '-z', '--cached'], 'git ls-files'), 'git ls-files'),
156
+ ...splitNul(runGit(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], 'git ls-tree'), 'git ls-tree'),
157
+ ...statuses.keys(),
158
+ ];
159
+ const paths = [...new Set(tracked)].map((entry) => pathKey(root, entry, 'worktree path'))
160
+ .filter(({ absolute }) => !excluded(root, absolute, specsRoot))
161
+ .sort((left, right) => left.relative < right.relative ? -1 : left.relative > right.relative ? 1 : 0);
162
+ const unique = new Map(paths.map((entry) => [entry.relative, entry]));
163
+ const records = [];
164
+ for (const { relative, absolute } of unique.values()) {
165
+ let before;
166
+ try { before = fs.lstatSync(absolute); } catch (error) {
167
+ if (error.code === 'ENOENT') { records.push({ path: relative, type: 'deleted', mode: '0000', status: statuses.get(relative) || null, content: null }); continue; }
168
+ fail('path_error', `cannot inspect ${relative} (${error.code || error.message})`);
169
+ }
170
+ const mode = (before.mode & 0o7777).toString(8).padStart(4, '0');
171
+ let content;
172
+ let type = 'file';
173
+ if (before.isSymbolicLink()) { type = 'symlink'; content = Buffer.from(fs.readlinkSync(absolute), 'utf8'); }
174
+ else if (before.isFile()) { content = fs.readFileSync(absolute); }
175
+ else { type = before.isDirectory() ? 'directory' : 'special'; content = Buffer.alloc(0); }
176
+ let after;
177
+ try { after = fs.lstatSync(absolute); } catch { fail('worktree_race', `${relative} changed during provenance capture`); }
178
+ if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.mode !== after.mode) {
179
+ fail('worktree_race', `${relative} changed during provenance capture`);
180
+ }
181
+ records.push({ path: relative, type, mode, status: statuses.get(relative) || null, content: content.toString('base64') });
182
+ }
183
+ return { status: [...statuses.entries()], records };
184
+ }
185
+
186
+ function manifestDigest(snapshot) {
187
+ const hash = crypto.createHash('sha256');
188
+ hash.update('cafekit-provenance\0');
189
+ for (const record of snapshot.records) hash.update(`${JSON.stringify(record)}\n`);
190
+ return hash.digest('hex');
191
+ }
192
+
193
+ function identity(input) {
194
+ if (!input || typeof input !== 'object' || Array.isArray(input)) fail('malformed_input', 'runtime provenance input must be an object');
195
+ const root = gitRoot(valueOf(input, 'project_root', 'projectRoot'));
196
+ const specsRoot = existingPath(valueOf(input, 'specs_root', 'specsRoot'), 'specsRoot', 'directory', root);
197
+ inside(root, specsRoot, 'specsRoot');
198
+ if (specsRoot === root) fail('ambiguous_identity', 'specsRoot must be a distinct project subdirectory');
199
+ const specFile = existingPath(valueOf(input, 'spec_file', 'specFile'), 'specFile', 'file', root);
200
+ inside(specsRoot, specFile, 'specFile');
201
+ const feature = text(valueOf(input, 'feature_name', 'featureName'), 'feature_name');
202
+ if (feature.includes('/') || feature.includes('\\') || feature === '.' || feature === '..') fail('ambiguous_identity', 'feature_name must be one path segment');
203
+ const featureDir = existingPath(path.join(specsRoot, feature), 'feature directory', 'directory');
204
+ if (path.dirname(specFile) !== featureDir) fail('ambiguous_identity', 'feature_name does not identify the selected spec file');
205
+ const session = text(valueOf(input, 'runtime_session', 'runtimeSession'), 'runtime_session');
206
+ const requestedMode = valueOf(input, 'provenance_mode', 'provenanceMode');
207
+ const mode = requestedMode === undefined ? PROVENANCE_MODE : text(requestedMode, 'provenance_mode');
208
+ if (mode !== PROVENANCE_MODE) fail('malformed_input', `unsupported provenance mode: ${mode}`);
209
+ return { root, specsRoot, specFile, feature, session, mode };
210
+ }
211
+
212
+ function deriveRuntimeProvenance(input) {
213
+ const current = identity(input);
214
+ const base = gitBase(current.root);
215
+ const first = manifest(current.root, current.specsRoot);
216
+ const head = manifestDigest(first);
217
+ const second = manifest(current.root, current.specsRoot);
218
+ if (JSON.stringify(first) !== JSON.stringify(second) || gitBase(current.root) !== base) fail('worktree_race', 'checkout changed during provenance capture');
219
+ const stable = {
220
+ schema_version: CONTEXT_SCHEMA_VERSION,
221
+ project_root: current.root,
222
+ specs_root: current.specsRoot,
223
+ spec_file: current.specFile,
224
+ feature_name: current.feature,
225
+ runtime_session: current.session,
226
+ provenance_mode: current.mode,
227
+ Base: base,
228
+ Head: head,
229
+ };
230
+ const contextId = crypto.createHash('sha256').update(JSON.stringify(stable)).digest('hex');
231
+ const context = Object.freeze({
232
+ ...stable,
233
+ base,
234
+ head,
235
+ context_id: contextId,
236
+ contextId,
237
+ expected_provenance: Object.freeze({ base, head }),
238
+ });
239
+ trustedContexts.add(context);
240
+ return context;
241
+ }
242
+
243
+ function isTrustedRuntimeContext(value) {
244
+ return Boolean(value && typeof value === 'object' && trustedContexts.has(value));
245
+ }
246
+
247
+ function recomputeRuntimeProvenance(value) {
248
+ if (!isTrustedRuntimeContext(value)) fail('untrusted_context', 'runtime provenance context was not derived by the runtime helper');
249
+ return deriveRuntimeProvenance({
250
+ projectRoot: value.project_root,
251
+ specsRoot: value.specs_root,
252
+ specFile: value.spec_file,
253
+ featureName: value.feature_name,
254
+ runtimeSession: value.runtime_session,
255
+ provenanceMode: value.provenance_mode,
256
+ });
257
+ }
258
+
259
+ function createReceiptBinding(value) {
260
+ const context = recomputeRuntimeProvenance(value);
261
+ const binding = Object.freeze({
262
+ expectedProvenance: Object.freeze({ base: context.base, head: context.head }),
263
+ requireProvenanceBinding: true,
264
+ runtimeContext: context,
265
+ contextId: context.context_id,
266
+ });
267
+ return binding;
268
+ }
269
+
270
+ function parseArgs(argv) {
271
+ const result = { json: false };
272
+ for (let index = 0; index < argv.length; index += 1) {
273
+ const arg = argv[index];
274
+ if (arg === '--json') result.json = true;
275
+ else if (arg === '--project-root') result.projectRoot = argv[++index];
276
+ else if (arg === '--specs-root') result.specsRoot = argv[++index];
277
+ else if (arg === '--spec-file') result.specFile = argv[++index];
278
+ else if (arg === '--feature-name') result.featureName = argv[++index];
279
+ else if (arg === '--runtime-session' || arg === '--session') result.runtimeSession = argv[++index];
280
+ else if (arg === '--provenance-mode') result.provenanceMode = argv[++index];
281
+ else fail('malformed_input', `unknown option: ${arg}`);
282
+ }
283
+ return result;
284
+ }
285
+
286
+ function runCli(argv = process.argv.slice(2)) {
287
+ let options;
288
+ try {
289
+ options = parseArgs(argv);
290
+ const context = deriveRuntimeProvenance(options);
291
+ const output = { ok: true, context, Base: context.base, Head: context.head, context_id: context.context_id };
292
+ process.stdout.write(`${JSON.stringify(output)}\n`);
293
+ return 0;
294
+ } catch (error) {
295
+ const output = { ok: false, error: { code: error.code || 'provenance_failed', message: error.message } };
296
+ if (options?.json) process.stdout.write(`${JSON.stringify(output)}\n`);
297
+ else process.stderr.write(`${output.error.code}: ${output.error.message}\n`);
298
+ return 2;
299
+ }
300
+ }
301
+
302
+ if (require.main === module) process.exitCode = runCli();
303
+
304
+ module.exports = {
305
+ CONTEXT_SCHEMA_VERSION,
306
+ PROVENANCE_MODE,
307
+ ProvenanceError,
308
+ createReceiptBinding,
309
+ deriveRuntimeContext: deriveRuntimeProvenance,
310
+ deriveRuntimeProvenance,
311
+ isTrustedRuntimeContext,
312
+ recomputeRuntimeContext: recomputeRuntimeProvenance,
313
+ recomputeRuntimeProvenance,
314
+ runCli,
315
+ };