@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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/codex-hooks.test.js +125 -0
  3. package/bin/__tests__/develop-contract.test.js +43 -1
  4. package/bin/__tests__/grok-envelope.test.js +271 -0
  5. package/bin/__tests__/grok-platform.test.js +133 -0
  6. package/bin/__tests__/hook-payload.test.js +194 -0
  7. package/bin/__tests__/omp-bridge.test.js +168 -0
  8. package/bin/__tests__/omp-hooks.test.js +178 -0
  9. package/bin/__tests__/omp-runtime.test.js +150 -0
  10. package/bin/__tests__/runtime-dir.test.js +158 -0
  11. package/bin/__tests__/runtime-schema.test.js +5 -3
  12. package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
  13. package/bin/install.js +5 -5
  14. package/bin/lib/context.js +50 -3
  15. package/bin/lib/i18n.js +3 -30
  16. package/bin/phases/copy-payload.js +7 -0
  17. package/bin/phases/omp-runtime.js +140 -0
  18. package/bin/phases/root-config.js +3 -0
  19. package/bin/phases/select-platform.js +22 -1
  20. package/bin/phases/summary.js +0 -4
  21. package/package.json +1 -1
  22. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
  23. package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
  24. package/src/claude/hooks/__tests__/state.test.js +2 -0
  25. package/src/claude/hooks/agent.cjs +12 -7
  26. package/src/claude/hooks/completion-authority.cjs +10 -4
  27. package/src/claude/hooks/docs-sync.cjs +2 -1
  28. package/src/claude/hooks/inspect-block.cjs +27 -7
  29. package/src/claude/hooks/lib/config.cjs +5 -2
  30. package/src/claude/hooks/lib/counter.cjs +7 -5
  31. package/src/claude/hooks/lib/hook-payload.cjs +160 -0
  32. package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
  33. package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
  34. package/src/claude/hooks/precompact.cjs +2 -1
  35. package/src/claude/hooks/privacy-block.cjs +4 -2
  36. package/src/claude/hooks/rules.cjs +4 -2
  37. package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
  38. package/src/claude/hooks/semantic-review-authority.cjs +6 -4
  39. package/src/claude/hooks/session.cjs +10 -8
  40. package/src/claude/hooks/spec-gate.cjs +18 -8
  41. package/src/claude/hooks/spec-state.cjs +9 -5
  42. package/src/claude/hooks/state.cjs +6 -4
  43. package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
  44. package/src/claude/hooks/usage.cjs +5 -4
  45. package/src/claude/migration-manifest.json +2 -0
  46. package/src/claude/rules/state-sync.md +7 -1
  47. package/src/claude/rules/workflow.md +2 -0
  48. package/src/claude/scripts/provenance.cjs +11 -4
  49. package/src/claude/scripts/spec-receipt.cjs +65 -4
  50. package/src/claude/skills/specs/SKILL.md +10 -11
  51. package/src/codex/hooks/spec-state.cjs +1 -1
  52. package/src/codex/rules/state-sync.md +7 -1
  53. package/src/omp/extensions/cafekit-bridge.mjs +233 -0
  54. package/src/omp/hooks/privacy-block.cjs +250 -0
  55. package/src/omp/runtime.json +33 -0
  56. package/bin/lib/rtk-run.js +0 -28
  57. 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 payload = JSON.parse(stdin);
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 = path.join(cwd, '.claude', 'runtime.json');
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
- const baseDir = process.env.PROJECT_ROOT || cwd;
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 revalidates every task currently marked done.`);
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 .claude/scripts/validate-spec-output.cjs specs/${featureName}\`; hooks revalidate receipt bytes but never grant approval.`);
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: .claude/session-state/latest.md (+ archive/)
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 = path.join(cwd, '.claude', 'session-state');
36
- if (fs.existsSync(path.join(cwd, '.claude'))) {
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 data = JSON.parse(stdin);
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 .claude/runtime.json.
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 data = JSON.parse(stdin);
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 .claude/runtime.json (fail-closed: a
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 = path.join(cwd, '.claude', 'runtime.json');
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 .claude/hooks/, script in .claude/scripts/). Forcing a tool
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
- console.log(
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 .claude/scripts/spec-scaffold.cjs ${feature} --tasks "R0-01-slug,R1-01-slug,..." --tasks-only\n` +
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 .claude/runtime.json
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 runtimePath = path.join(cwd, '.claude', 'runtime.json');
33
- if (fs.existsSync(runtimePath)) {
34
- return JSON.parse(fs.readFileSync(runtimePath, 'utf-8'));
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. Missing, stale, contradictory,
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 canonicalFailures(body, task, runtimeContext, policy) {
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
- failures.push(...canonicalFailures(canonicalBody, {}, runtimeContext, policy));
265
- failures.push(...canonicalFailures(body, {}, runtimeContext, policy)
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
- more regular flat `task-*.md` files inside one direct feature directory. The
129
- Stop gate revalidates every done task's inline Receipt and provenance. The old
130
- task-scaffold guard remains limited to the older nested layout; flat v3 tasks
131
- do not invoke that kernel path.
132
-
133
- The resolver projects exact `Status:` values; it does not infer blockers from prose.
134
-
135
- These checks are a final safety net, not a substitute for C1-C3 judgment. Do
136
- not add a new schema, approval field, readiness bit, or review state to make the
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 revalidates every task currently marked done.`,
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. Missing, stale, contradictory,
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