@haposoft/cafekit 0.16.0 → 0.16.1
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/README.md +1 -1
- package/bin/__tests__/codex-hooks.test.js +125 -0
- package/bin/__tests__/develop-contract.test.js +43 -1
- package/bin/__tests__/grok-envelope.test.js +271 -0
- package/bin/__tests__/grok-platform.test.js +133 -0
- package/bin/__tests__/hook-payload.test.js +194 -0
- package/bin/__tests__/omp-bridge.test.js +168 -0
- package/bin/__tests__/omp-hooks.test.js +178 -0
- package/bin/__tests__/omp-runtime.test.js +150 -0
- package/bin/__tests__/runtime-dir.test.js +158 -0
- package/bin/__tests__/runtime-schema.test.js +5 -3
- package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
- package/bin/install.js +5 -5
- package/bin/lib/context.js +50 -3
- package/bin/lib/i18n.js +3 -30
- package/bin/phases/copy-payload.js +7 -0
- package/bin/phases/omp-runtime.js +140 -0
- package/bin/phases/root-config.js +3 -0
- package/bin/phases/select-platform.js +22 -1
- package/bin/phases/summary.js +0 -4
- package/package.json +1 -1
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
- package/src/claude/hooks/__tests__/state.test.js +2 -0
- package/src/claude/hooks/agent.cjs +12 -7
- package/src/claude/hooks/completion-authority.cjs +10 -4
- package/src/claude/hooks/docs-sync.cjs +2 -1
- package/src/claude/hooks/inspect-block.cjs +27 -7
- package/src/claude/hooks/lib/config.cjs +5 -2
- package/src/claude/hooks/lib/counter.cjs +7 -5
- package/src/claude/hooks/lib/hook-payload.cjs +160 -0
- package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
- package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
- package/src/claude/hooks/precompact.cjs +2 -1
- package/src/claude/hooks/privacy-block.cjs +4 -2
- package/src/claude/hooks/rules.cjs +4 -2
- package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
- package/src/claude/hooks/semantic-review-authority.cjs +6 -4
- package/src/claude/hooks/session.cjs +10 -8
- package/src/claude/hooks/spec-gate.cjs +18 -8
- package/src/claude/hooks/spec-state.cjs +9 -5
- package/src/claude/hooks/state.cjs +6 -4
- package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
- package/src/claude/hooks/usage.cjs +5 -4
- package/src/claude/migration-manifest.json +2 -0
- package/src/claude/rules/state-sync.md +7 -1
- package/src/claude/rules/workflow.md +2 -0
- package/src/claude/scripts/provenance.cjs +11 -4
- package/src/claude/scripts/spec-receipt.cjs +65 -4
- package/src/claude/skills/specs/SKILL.md +10 -11
- package/src/codex/hooks/spec-state.cjs +1 -1
- package/src/codex/rules/state-sync.md +7 -1
- package/src/omp/extensions/cafekit-bridge.mjs +233 -0
- package/src/omp/hooks/privacy-block.cjs +250 -0
- package/src/omp/runtime.json +33 -0
- package/bin/lib/rtk-run.js +0 -28
- package/bin/phases/setup-rtk.js +0 -159
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
const fs = require('fs');
|
|
15
15
|
const path = require('path');
|
|
16
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
16
17
|
|
|
17
18
|
function logCrash(error) {
|
|
18
19
|
try {
|
|
@@ -49,7 +50,8 @@ try {
|
|
|
49
50
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
50
51
|
if (!stdin) process.exit(0);
|
|
51
52
|
|
|
52
|
-
const
|
|
53
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
54
|
+
const payload = normalizeHookPayload(JSON.parse(stdin));
|
|
53
55
|
const cwd = payload.cwd || process.cwd();
|
|
54
56
|
|
|
55
57
|
let POLICY;
|
|
@@ -69,7 +71,7 @@ try {
|
|
|
69
71
|
// Read runtime configuration if exists
|
|
70
72
|
let runtime = {};
|
|
71
73
|
try {
|
|
72
|
-
const p =
|
|
74
|
+
const p = runtimePath(cwd, 'runtime.json');
|
|
73
75
|
if (fs.existsSync(p)) runtime = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
74
76
|
} catch { /* ignore */ }
|
|
75
77
|
|
|
@@ -77,7 +79,9 @@ try {
|
|
|
77
79
|
// (The Stop completion gate has its own toggle: spec.completion_gate.)
|
|
78
80
|
if (runtime.spec && runtime.spec.tollgate === false) process.exit(0);
|
|
79
81
|
|
|
80
|
-
|
|
82
|
+
// Same rule as spec-gate: an installed hook trusts its own location over the environment.
|
|
83
|
+
const installedRoot = path.basename(path.resolve(__dirname, '..')).startsWith('.') ? path.resolve(__dirname, '..', '..') : null;
|
|
84
|
+
const baseDir = installedRoot || process.env.PROJECT_ROOT || cwd;
|
|
81
85
|
const explicitFeature = payload.featureName || payload.feature || payload.explicitFeature || null;
|
|
82
86
|
const explicitPath = payload.specPath || payload.spec_path || payload.featurePath || null;
|
|
83
87
|
const resolveWorkflow = typeof RESOLVER.resolveWorkflowCandidate === 'function'
|
|
@@ -222,11 +226,11 @@ try {
|
|
|
222
226
|
lines.push('- Migration required: add `Specs-Contract: process-first-ready-v1` after the plan title before tasks can enter Next.');
|
|
223
227
|
}
|
|
224
228
|
lines.push('- Sync each flat task `Status:` only after verified work; task proof belongs in that task\'s inline `## Receipt`.');
|
|
225
|
-
lines.push(`- Workflow source: \`specs/${featureName}/plan.md\`; Stop
|
|
229
|
+
lines.push(`- Workflow source: \`specs/${featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime until the task file is committed, unchanged, and the tree outside the specs root is clean.`);
|
|
226
230
|
} else {
|
|
227
231
|
lines.push('- Sync `spec.json` + task Markdown status after verified work; task proof belongs in `receipts/<task-basename>.md`.');
|
|
228
232
|
lines.push(`- Create \`feature-receipt.md\` once after final integration proof${featureReceiptPresent ? ' (present)' : ' (not required before closeout)'}.`);
|
|
229
|
-
lines.push(`- Validate with \`node
|
|
233
|
+
lines.push(`- Validate with \`node ${runtimeDirName()}/scripts/validate-spec-output.cjs specs/${featureName}\`; hooks revalidate receipt bytes but never grant approval.`);
|
|
230
234
|
}
|
|
231
235
|
lines.push('');
|
|
232
236
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Stop → persist full session state and archive
|
|
13
13
|
* SubagentStop → append agent completion note to current state
|
|
14
14
|
*
|
|
15
|
-
* Storage:
|
|
15
|
+
* Storage: <runtime>/session-state/latest.md (+ archive/)
|
|
16
16
|
* Exit: 0 always (fail-open)
|
|
17
17
|
*/
|
|
18
18
|
|
|
@@ -23,6 +23,7 @@ try {
|
|
|
23
23
|
const crypto = require('crypto');
|
|
24
24
|
const { execSync } = require('child_process');
|
|
25
25
|
const { parseTranscript } = require('./lib/parser.cjs');
|
|
26
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
26
27
|
|
|
27
28
|
const EXPIRY_DAYS = 7;
|
|
28
29
|
const MAX_ARCHIVES = 5;
|
|
@@ -32,8 +33,8 @@ try {
|
|
|
32
33
|
|
|
33
34
|
function stateDir(cwd) {
|
|
34
35
|
try {
|
|
35
|
-
const local =
|
|
36
|
-
if (fs.existsSync(
|
|
36
|
+
const local = runtimePath(cwd, 'session-state');
|
|
37
|
+
if (fs.existsSync(runtimeDir(cwd))) {
|
|
37
38
|
if (!fs.existsSync(local)) fs.mkdirSync(local, { recursive: true });
|
|
38
39
|
return local;
|
|
39
40
|
}
|
|
@@ -224,7 +225,8 @@ try {
|
|
|
224
225
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
225
226
|
if (!stdin) process.exit(0);
|
|
226
227
|
|
|
227
|
-
const
|
|
228
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
229
|
+
const data = normalizeHookPayload(JSON.parse(stdin));
|
|
228
230
|
const event = data.hook_event_name || '';
|
|
229
231
|
const cwd = data.cwd || process.cwd();
|
|
230
232
|
const dir = stateDir(cwd);
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* 1. fail-open when spec-scaffold.cjs is absent — you cannot force a tool that
|
|
22
22
|
* is not installed, so a hook shipped without its script must not deadlock.
|
|
23
23
|
* 2. actionable block message — prints the exact scaffold command to run.
|
|
24
|
-
* 3. escape hatch — "spec": { "scaffold_guard": false } in
|
|
24
|
+
* 3. escape hatch — "spec": { "scaffold_guard": false } in <runtime>/runtime.json.
|
|
25
25
|
*
|
|
26
26
|
* Exit: 0 = allow, 2 = block.
|
|
27
27
|
*/
|
|
@@ -29,11 +29,33 @@
|
|
|
29
29
|
try {
|
|
30
30
|
const fs = require('fs');
|
|
31
31
|
const path = require('path');
|
|
32
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deny the call with a reason every host can read.
|
|
36
|
+
*
|
|
37
|
+
* The reason is the JSON `permissionDecisionReason`, which Claude Code reads natively and
|
|
38
|
+
* grok honours "regardless of exit code"; the same text also goes to stderr, because on
|
|
39
|
+
* an exit-2 denial that is the channel Claude Code feeds back, and grok reads only the
|
|
40
|
+
* first stderr line. Exit 2 stays so a host that reads neither still blocks.
|
|
41
|
+
*/
|
|
42
|
+
function denyWithReason(reason) {
|
|
43
|
+
process.stdout.write(JSON.stringify({
|
|
44
|
+
hookSpecificOutput: {
|
|
45
|
+
hookEventName: 'PreToolUse',
|
|
46
|
+
permissionDecision: 'deny',
|
|
47
|
+
permissionDecisionReason: reason
|
|
48
|
+
}
|
|
49
|
+
}) + '\n');
|
|
50
|
+
process.stderr.write(reason + '\n');
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
32
53
|
|
|
33
54
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
34
55
|
if (!stdin) process.exit(0);
|
|
35
56
|
|
|
36
|
-
const
|
|
57
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
58
|
+
const data = normalizeHookPayload(JSON.parse(stdin));
|
|
37
59
|
const toolName = data.tool_name || '';
|
|
38
60
|
const toolInput = data.tool_input || {};
|
|
39
61
|
const cwd = data.cwd || process.cwd();
|
|
@@ -50,17 +72,17 @@ try {
|
|
|
50
72
|
const isTaskFile = /(^|\/)specs\/[^/]+\/tasks\/task-[^/]+\.md$/.test(norm);
|
|
51
73
|
if (!isTaskFile) process.exit(0);
|
|
52
74
|
|
|
53
|
-
// Valve 3: explicit escape hatch via
|
|
75
|
+
// Valve 3: explicit escape hatch via <runtime>/runtime.json (fail-closed: a
|
|
54
76
|
// missing/broken runtime.json keeps the guard ON).
|
|
55
77
|
let runtime = {};
|
|
56
78
|
try {
|
|
57
|
-
const rp =
|
|
79
|
+
const rp = runtimePath(cwd, 'runtime.json');
|
|
58
80
|
if (fs.existsSync(rp)) runtime = JSON.parse(fs.readFileSync(rp, 'utf8'));
|
|
59
81
|
} catch { /* malformed runtime.json -> keep guard on */ }
|
|
60
82
|
if (runtime.spec && runtime.spec.scaffold_guard === false) process.exit(0);
|
|
61
83
|
|
|
62
84
|
// Valve 1: fail-open if the scaffold script is not installed next to this hook
|
|
63
|
-
// (hook lives in
|
|
85
|
+
// (hook lives in <runtime>/hooks/, script in <runtime>/scripts/). Forcing a tool
|
|
64
86
|
// that does not exist would deadlock task creation.
|
|
65
87
|
const scaffold = path.join(__dirname, '..', 'scripts', 'spec-scaffold.cjs');
|
|
66
88
|
if (!fs.existsSync(scaffold)) process.exit(0);
|
|
@@ -71,14 +93,13 @@ try {
|
|
|
71
93
|
// simply disables the guard itself. The hatch stays functional for humans.
|
|
72
94
|
const m = norm.match(/(^|\/)specs\/([^/]+)\/tasks\//);
|
|
73
95
|
const feature = m ? m[2] : '<feature>';
|
|
74
|
-
|
|
96
|
+
denyWithReason(
|
|
75
97
|
`TASK SCAFFOLD REQUIRED: task files must be generated, not hand-written.\n` +
|
|
76
98
|
`Blocked Write: ${filePath}\n\n` +
|
|
77
99
|
`Generate the stub(s), then Edit-fill the {{...}} placeholders:\n` +
|
|
78
|
-
` node
|
|
100
|
+
` node ${runtimeDirName()}/scripts/spec-scaffold.cjs ${feature} --tasks "R0-01-slug,R1-01-slug,..." --tasks-only\n` +
|
|
79
101
|
`Then use Edit (not Write) on each tasks/task-*.md stub.`
|
|
80
102
|
);
|
|
81
|
-
process.exit(2);
|
|
82
103
|
|
|
83
104
|
} catch (e) {
|
|
84
105
|
// Never let a hook crash block the user — log and fail-open.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Features:
|
|
18
18
|
* - Cross-platform credential retrieval (macOS Keychain, file-based)
|
|
19
19
|
* - Throttled API calls: 1 min (prompt) / 5 min (tool use)
|
|
20
|
-
* - Disable: set "usage": { "enabled": false } in
|
|
20
|
+
* - Disable: set "usage": { "enabled": false } in <runtime>/runtime.json
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
// Crash wrapper
|
|
@@ -27,11 +27,12 @@ try {
|
|
|
27
27
|
const os = require("os");
|
|
28
28
|
const { execSync } = require("child_process");
|
|
29
29
|
|
|
30
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
30
31
|
function readRuntime(cwd) {
|
|
31
32
|
try {
|
|
32
|
-
const
|
|
33
|
-
if (fs.existsSync(
|
|
34
|
-
return JSON.parse(fs.readFileSync(
|
|
33
|
+
const rp = runtimePath(cwd, 'runtime.json');
|
|
34
|
+
if (fs.existsSync(rp)) {
|
|
35
|
+
return JSON.parse(fs.readFileSync(rp, 'utf-8'));
|
|
35
36
|
}
|
|
36
37
|
} catch { /* fail-open */ }
|
|
37
38
|
return {};
|
|
@@ -148,6 +148,8 @@
|
|
|
148
148
|
"hooks/lib/context.cjs",
|
|
149
149
|
"hooks/lib/detect.cjs",
|
|
150
150
|
"hooks/lib/hook-state-dir.cjs",
|
|
151
|
+
"hooks/lib/runtime-dir.cjs",
|
|
152
|
+
"hooks/lib/hook-payload.cjs",
|
|
151
153
|
"hooks/lib/privacy-command-analysis.cjs",
|
|
152
154
|
"hooks/lib/secret-keywords.cjs",
|
|
153
155
|
"hooks/lib/runtime-path-safety.cjs"
|
|
@@ -30,7 +30,13 @@ state transition.
|
|
|
30
30
|
Set `Status: done` only after the inline Receipt passes the
|
|
31
31
|
`validateCanonicalReceipt` contract: exact command, `Exit: 0`,
|
|
32
32
|
`Verification: PASS`, runtime-derived Base and Head values, and a non-empty
|
|
33
|
-
fenced block of current command output.
|
|
33
|
+
fenced block of current command output.
|
|
34
|
+
|
|
35
|
+
A done task keeps that binding while its file differs from its committed bytes,
|
|
36
|
+
and is validated on structure alone once the file is committed, unchanged, and the
|
|
37
|
+
tree outside the specs root is clean. The gate detects drift, not invention: a valid
|
|
38
|
+
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
39
|
+
that the command was executed. Missing, stale, contradictory,
|
|
34
40
|
placeholder, copied, failure, or zero-test evidence leaves the task unfinished.
|
|
35
41
|
|
|
36
42
|
Synchronization never fabricates commands, output, timestamps, completed
|
|
@@ -56,6 +56,8 @@ Use the CafeKit loop: **Understand -> Plan -> Execute -> Verify -> Sync**.
|
|
|
56
56
|
- A canonical Receipt contains the exact command, `Exit: 0`,
|
|
57
57
|
`Verification: PASS`, runtime-derived Base and Head values, and non-empty
|
|
58
58
|
fenced current output. Never invent, copy, or infer missing proof.
|
|
59
|
+
- When the binding to Base and Head applies, and what the gate does and does not
|
|
60
|
+
detect, are stated once in `state-sync.md`.
|
|
59
61
|
- Re-read each edited task and reconcile its plan row, dependencies, acceptance
|
|
60
62
|
mapping, status, and receipt without rewriting unrelated bytes.
|
|
61
63
|
- Run docs checkpoint when a completed task affects public docs or architecture docs.
|
|
@@ -149,15 +149,21 @@ function parseStatus(bytes) {
|
|
|
149
149
|
return entries;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// Generated runtime state, not source evidence. Shared with the receipt binding
|
|
153
|
+
// selector so the gate's own cache writes cannot be read as a dirty worktree.
|
|
154
|
+
const RUNTIME_STATE_ROOTS = Object.freeze([
|
|
155
|
+
'.git', 'node_modules',
|
|
156
|
+
'.claude/hooks/.logs', '.codex/hooks/.logs', '.omp/hooks/.logs',
|
|
157
|
+
'.claude/.logs', '.codex/.logs', '.omp/.logs',
|
|
158
|
+
'.claude/runtime.json', '.codex/runtime.json', '.omp/runtime.json',
|
|
159
|
+
]);
|
|
160
|
+
|
|
152
161
|
function excluded(root, absolute, specsRoot) {
|
|
153
162
|
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
154
163
|
const specsRelative = path.relative(root, specsRoot);
|
|
155
164
|
// These roots are generated runtime state, not source evidence. The selected
|
|
156
165
|
// canonical specs root is excluded above to avoid receipt/state recursion.
|
|
157
|
-
const roots =
|
|
158
|
-
'.git', 'node_modules', '.claude/hooks/.logs', '.codex/hooks/.logs',
|
|
159
|
-
'.claude/.logs', '.codex/.logs', '.claude/runtime.json', '.codex/runtime.json',
|
|
160
|
-
];
|
|
166
|
+
const roots = RUNTIME_STATE_ROOTS;
|
|
161
167
|
if (relative === specsRelative || relative.startsWith(`${specsRelative}/`)) return true;
|
|
162
168
|
return roots.some((entry) => relative === entry || relative.startsWith(`${entry}/`));
|
|
163
169
|
}
|
|
@@ -315,6 +321,7 @@ function runCli(argv = process.argv.slice(2)) {
|
|
|
315
321
|
if (require.main === module) process.exitCode = runCli();
|
|
316
322
|
|
|
317
323
|
module.exports = {
|
|
324
|
+
RUNTIME_STATE_ROOTS,
|
|
318
325
|
CONTEXT_SCHEMA_VERSION,
|
|
319
326
|
PROVENANCE_MODE,
|
|
320
327
|
ProvenanceError,
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
6
7
|
const { annotatedMarkdownLines } = require('./spec-resolver.cjs');
|
|
8
|
+
const { RUNTIME_STATE_ROOTS } = require('./provenance.cjs');
|
|
7
9
|
|
|
8
10
|
const EVIDENCE_HEADINGS = new Set([
|
|
9
11
|
'Evidence',
|
|
@@ -177,11 +179,67 @@ function identity(body) {
|
|
|
177
179
|
return crypto.createHash('sha256').update(JSON.stringify({ fields, artifacts })).digest('hex');
|
|
178
180
|
}
|
|
179
181
|
|
|
180
|
-
function
|
|
182
|
+
function tryGitBytes(root, args) {
|
|
183
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
|
|
184
|
+
if (result.error || result.status !== 0) return null;
|
|
185
|
+
return result.stdout;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A receipt is bound to live Base/Head while it is being written or altered.
|
|
189
|
+
// Once its task file matches the committed bytes and nothing outside the specs
|
|
190
|
+
// root is uncommitted, later commits must not reopen it: rebinding every done
|
|
191
|
+
// receipt on every Stop made each unrelated commit a refresh of all of them.
|
|
192
|
+
// Every state that cannot be read as committed-and-clean keeps the binding,
|
|
193
|
+
// including an unborn HEAD, a staged-only file, and any git failure. The git
|
|
194
|
+
// calls pin the project root; a working-directory-relative status query run
|
|
195
|
+
// from inside the specs root reports a dirty tree as clean.
|
|
196
|
+
function receiptBindingMode(featureDir, taskPath, taskBytes, runtimeContext) {
|
|
197
|
+
const root = runtimeContext && typeof runtimeContext.project_root === 'string' ? runtimeContext.project_root : null;
|
|
198
|
+
const specsRoot = runtimeContext && typeof runtimeContext.specs_root === 'string' ? runtimeContext.specs_root : null;
|
|
199
|
+
if (!root || !specsRoot || !Buffer.isBuffer(taskBytes)) return 'binding';
|
|
200
|
+
let canonicalFeature;
|
|
201
|
+
try { canonicalFeature = fs.realpathSync(featureDir); } catch { return 'binding'; }
|
|
202
|
+
const toPosix = (value) => value.split(path.sep).join('/');
|
|
203
|
+
const taskRelative = toPosix(path.relative(root, path.join(canonicalFeature, taskPath)));
|
|
204
|
+
const specsRelative = toPosix(path.relative(root, specsRoot));
|
|
205
|
+
const escapes = (value) => !value || value === '..' || value.startsWith('../') || path.isAbsolute(value);
|
|
206
|
+
if (escapes(taskRelative) || escapes(specsRelative)) return 'binding';
|
|
207
|
+
// `--filters` applies the same smudge/eol conversion the worktree copy has,
|
|
208
|
+
// so a checkout that converts line endings can still reach structure mode.
|
|
209
|
+
const committed = tryGitBytes(root, ['cat-file', '--filters', `HEAD:${taskRelative}`]);
|
|
210
|
+
if (committed === null || !committed.equals(taskBytes)) return 'binding';
|
|
211
|
+
// The same roots the provenance manifest calls generated runtime state rather
|
|
212
|
+
// than source evidence. Without them the gate defeats itself: writing its own
|
|
213
|
+
// cache dirties the tree, so a project that tracks its runtime directory could
|
|
214
|
+
// never reach structure mode.
|
|
215
|
+
const outsideSpecs = ['--', '.', `:(exclude,literal)${specsRelative}`,
|
|
216
|
+
...RUNTIME_STATE_ROOTS.map((entry) => `:(exclude,literal)${entry}`)];
|
|
217
|
+
const status = tryGitBytes(root, ['status', '--porcelain', '--untracked-files=all', ...outsideSpecs]);
|
|
218
|
+
if (status === null || status.length !== 0) return 'binding';
|
|
219
|
+
// `git status` obeys skip-worktree and assume-unchanged, so a tracked file can
|
|
220
|
+
// be modified while the tree reports clean. Require every index entry outside
|
|
221
|
+
// the specs root to carry the plain cached tag `H`; any other tag, including
|
|
222
|
+
// `S` and the lowercase assume-unchanged tags, keeps the binding.
|
|
223
|
+
const indexTags = tryGitBytes(root, ['ls-files', '-v', ...outsideSpecs]);
|
|
224
|
+
if (indexTags === null) return 'binding';
|
|
225
|
+
const hidden = indexTags.toString('utf8').split('\n')
|
|
226
|
+
.filter((line) => line.trim() !== '')
|
|
227
|
+
.some((line) => line[0] !== 'H');
|
|
228
|
+
if (hidden) return 'binding';
|
|
229
|
+
return 'structure';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function canonicalFailures(body, task, runtimeContext, policy, { bindProvenance = true } = {}) {
|
|
181
233
|
if (!policy || typeof policy.validateCanonicalReceipt !== 'function') return ['validator_unavailable'];
|
|
182
234
|
const options = typeof policy.receiptValidatorOptions === 'function'
|
|
183
235
|
? policy.receiptValidatorOptions(task || {}, { runtimeContext, requireProvenanceBinding: true })
|
|
184
236
|
: { requireProvenanceBinding: true };
|
|
237
|
+
// Structure-only mode drops the live Base/Head comparison and nothing else.
|
|
238
|
+
// It is granted only while a trusted artifact root survived, because
|
|
239
|
+
// withholding the runtime context would also silence artifact byte checks.
|
|
240
|
+
if (!bindProvenance && typeof options.artifactRoot === 'string' && options.artifactRoot.trim() !== '') {
|
|
241
|
+
return policy.validateCanonicalReceipt(body, { ...options, expectedProvenance: null, requireProvenanceBinding: false });
|
|
242
|
+
}
|
|
185
243
|
return policy.validateCanonicalReceipt(body, options);
|
|
186
244
|
}
|
|
187
245
|
|
|
@@ -261,12 +319,15 @@ function checkWorkflowTaskReceipt(featureDir, taskPath, runtimeContext, policy)
|
|
|
261
319
|
const options = typeof policy?.receiptValidatorOptions === 'function'
|
|
262
320
|
? policy.receiptValidatorOptions({}, { runtimeContext, requireProvenanceBinding: true })
|
|
263
321
|
: { requireProvenanceBinding: true };
|
|
264
|
-
|
|
265
|
-
|
|
322
|
+
const requested = receiptBindingMode(featureDir, taskPath, taskFile.bytes, runtimeContext);
|
|
323
|
+
const artifactRootAvailable = typeof options.artifactRoot === 'string' && options.artifactRoot.trim() !== '';
|
|
324
|
+
const bindProvenance = !(requested === 'structure' && artifactRootAvailable);
|
|
325
|
+
failures.push(...canonicalFailures(canonicalBody, {}, runtimeContext, policy, { bindProvenance }));
|
|
326
|
+
failures.push(...canonicalFailures(body, {}, runtimeContext, policy, { bindProvenance })
|
|
266
327
|
.filter((failure) => ['placeholder', 'verification_state', 'exit_result'].includes(failure)));
|
|
267
328
|
failures.push(...workflowCommandFailures(taskText, canonicalBody));
|
|
268
329
|
if (!hasNonEmptyFencedBlock(body)) failures.push('command_output');
|
|
269
|
-
if (!options.expectedProvenance) failures.push('provenance');
|
|
330
|
+
if (bindProvenance && !options.expectedProvenance) failures.push('provenance');
|
|
270
331
|
}
|
|
271
332
|
return {
|
|
272
333
|
status: failures.length === 0 ? 'ok' : 'invalid',
|
|
@@ -124,17 +124,16 @@ it does not invent product approval, review independence, or runtime coverage.
|
|
|
124
124
|
|
|
125
125
|
## Machine boundary
|
|
126
126
|
|
|
127
|
-
The shared workflow resolver recognizes only a regular `plan.md` plus one or
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
Markdown look more authoritative.
|
|
127
|
+
The shared workflow resolver recognizes only a regular `plan.md` plus one or more
|
|
128
|
+
regular flat `task-*.md` files in one direct feature directory, and projects exact
|
|
129
|
+
`Status:` values without inferring blockers from prose. The Stop gate re-reads every
|
|
130
|
+
done Receipt, binding Base and Head to the live runtime until the task file is
|
|
131
|
+
committed, unchanged, and the tree outside the specs root is clean. It detects drift
|
|
132
|
+
between a receipt and the tree, not invention: a valid pair costs one command and no
|
|
133
|
+
verification run, so a receipt for a command that never ran satisfies it in either
|
|
134
|
+
mode. C3 is where a human weighs the evidence. These checks are a final safety net,
|
|
135
|
+
not a substitute for C1-C3 judgment. Do not add a new schema, approval field,
|
|
136
|
+
readiness bit, or review state to make the Markdown look more authoritative.
|
|
138
137
|
|
|
139
138
|
## Legacy compatibility
|
|
140
139
|
|
|
@@ -156,7 +156,7 @@ try {
|
|
|
156
156
|
}
|
|
157
157
|
lines.push(
|
|
158
158
|
'- Sync each flat task `Status:` only after verified work; task proof belongs in that task\'s inline `## Receipt`.',
|
|
159
|
-
`- Workflow source: \`specs/${active.featureName}/plan.md\`; Stop
|
|
159
|
+
`- Workflow source: \`specs/${active.featureName}/plan.md\`; Stop re-checks every done receipt, binding Base/Head to the runtime until the task file is committed, unchanged, and the tree outside the specs root is clean.`,
|
|
160
160
|
'- Hooks revalidate receipt bytes but never grant approval.'
|
|
161
161
|
);
|
|
162
162
|
} else {
|
|
@@ -30,7 +30,13 @@ state transition.
|
|
|
30
30
|
Set `Status: done` only after the inline Receipt passes the
|
|
31
31
|
`validateCanonicalReceipt` contract: exact command, `Exit: 0`,
|
|
32
32
|
`Verification: PASS`, runtime-derived Base and Head values, and a non-empty
|
|
33
|
-
fenced block of current command output.
|
|
33
|
+
fenced block of current command output.
|
|
34
|
+
|
|
35
|
+
A done task keeps that binding while its file differs from its committed bytes,
|
|
36
|
+
and is validated on structure alone once the file is committed, unchanged, and the
|
|
37
|
+
tree outside the specs root is clean. The gate detects drift, not invention: a valid
|
|
38
|
+
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
39
|
+
that the command was executed. Missing, stale, contradictory,
|
|
34
40
|
placeholder, copied, failure, or zero-test evidence leaves the task unfinished.
|
|
35
41
|
|
|
36
42
|
Synchronization never fabricates commands, output, timestamps, completed
|