@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.
- package/bin/__tests__/codex-hooks.test.js +794 -21
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +1838 -112
- package/bin/__tests__/installer-safety.test.js +82 -29
- package/bin/__tests__/opencode-spec-gate.test.js +799 -0
- package/bin/__tests__/package-inventory.test.js +841 -10
- package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2469 -71
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +153 -202
- package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
- package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
- package/src/claude/hooks/completion-authority-check.cjs +56 -0
- package/src/claude/hooks/completion-authority-state.cjs +527 -0
- package/src/claude/hooks/completion-authority.cjs +154 -0
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +77 -30
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +181 -139
- package/src/claude/hooks/spec-state.cjs +112 -47
- package/src/claude/migration-manifest.json +15 -2
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-final-state.cjs +244 -0
- package/src/claude/scripts/spec-ground.cjs +535 -173
- package/src/claude/scripts/spec-readiness.cjs +118 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +501 -0
- package/src/claude/scripts/spec-scaffold.cjs +1945 -55
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3204 -262
- package/src/claude/scripts/workflow-policy.cjs +1888 -203
- package/src/claude/settings/settings.json +12 -0
- package/src/claude/skills/code-review/SKILL.md +94 -66
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +138 -283
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +123 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +192 -397
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +200 -260
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +26 -67
- package/src/claude/skills/specs/templates/task.md +30 -82
- package/src/claude/skills/sync/SKILL.md +45 -15
- package/src/claude/skills/sync/references/sync-protocols.md +34 -16
- package/src/claude/skills/test/SKILL.md +151 -167
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +63 -0
- package/src/codex/hooks/completion-authority-state.cjs +527 -0
- package/src/codex/hooks/completion-authority.cjs +106 -0
- package/src/codex/hooks/lib/hook-context.cjs +58 -4
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +38 -16
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
- package/src/codex/hooks/lib/spec-utils.cjs +104 -27
- package/src/codex/hooks/privacy-approval.cjs +1 -1
- package/src/codex/hooks/privacy-block.cjs +67 -11
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +151 -24
- package/src/codex/hooks/spec-state.cjs +65 -12
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +16 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -1,31 +1,62 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Specs v2.1 state synchronization
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Authority layers
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
Before returning control to the user or orchestrator, the agent **MUST**:
|
|
13
|
+
## Canonical lifecycle
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
25
|
+
## Projection sync
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
After any semantic Markdown edit:
|
|
28
28
|
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
- `
|
|
19
|
-
- `
|
|
20
|
-
- `
|
|
21
|
-
- `
|
|
22
|
-
- `
|
|
23
|
-
- `
|
|
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 `
|
|
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
|
|
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
|
+
};
|