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

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 (28) hide show
  1. package/bin/__tests__/change-firewall.semantic-firewall.test.js +2134 -0
  2. package/bin/__tests__/codex-hooks.test.js +136 -64
  3. package/bin/__tests__/develop-contract.test.js +55 -17
  4. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
  5. package/bin/__tests__/package-inventory.test.js +29 -11
  6. package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
  7. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
  8. package/bin/__tests__/specs-v2-execution-closeout.test.js +59 -12
  9. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +78 -14
  10. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +222 -42
  11. package/bin/__tests__/validator-grounding.test.js +103 -4
  12. package/package.json +5 -2
  13. package/src/claude/hooks/__tests__/completion-authority.test.js +31 -1
  14. package/src/claude/hooks/__tests__/privacy-block.test.js +111 -29
  15. package/src/claude/hooks/privacy-block.cjs +38 -14
  16. package/src/claude/scripts/change-firewall.cjs +1748 -0
  17. package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
  18. package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
  19. package/src/claude/scripts/spec-final-state.cjs +46 -0
  20. package/src/claude/scripts/spec-ground.cjs +46 -10
  21. package/src/claude/scripts/spec-readiness.cjs +324 -17
  22. package/src/claude/scripts/spec-scaffold.cjs +268 -28
  23. package/src/claude/scripts/validate-spec-output.cjs +262 -10
  24. package/src/claude/skills/specs/references/review.md +14 -2
  25. package/src/claude/skills/specs/templates/spec-state.json +14 -2
  26. package/src/codex/hooks/lib/privacy-state.cjs +23 -22
  27. package/src/codex/hooks/privacy-approval.cjs +7 -3
  28. package/src/codex/hooks/privacy-block.cjs +45 -22
@@ -128,7 +128,7 @@ function inHookFixture(run, options = {}) {
128
128
  }
129
129
  }
130
130
 
131
- test('Codex privacy approval is exact, session-bound, and one-time', () => {
131
+ test('Codex privacy approval is exact, session-bound, and reusable within its window', () => {
132
132
  inHookFixture((root, hooks) => {
133
133
  const nested = path.join(root, 'packages', 'app');
134
134
  fs.mkdirSync(nested, { recursive: true });
@@ -173,31 +173,36 @@ test('Codex privacy approval is exact, session-bound, and one-time', () => {
173
173
  });
174
174
  assert.match(wrongSession.stdout, /rejected/);
175
175
 
176
+ // A pasted prompt carries the terminal's surrounding whitespace. The
177
+ // exactness that matters is the request id, not the padding.
176
178
  const padded = runHook(approve, nested, {
177
179
  cwd: nested,
178
180
  session_id: 'session-a',
179
181
  hook_event_name: 'UserPromptSubmit',
180
182
  prompt: ` ${prompt}\n`
181
183
  });
182
- assert.equal(padded.stdout, '', 'whitespace-padded prompt must not issue a token');
183
-
184
- const approved = runHook(approve, nested, {
185
- cwd: nested,
186
- session_id: 'session-a',
187
- hook_event_name: 'UserPromptSubmit',
188
- prompt
189
- });
190
- assert.match(approved.stdout, /approved one retry/);
184
+ assert.match(padded.stdout, /User approved/, 'whitespace padding must not void the approval');
191
185
 
186
+ // The grant now covers repeated access to the same paths, and is not bound
187
+ // to the tool that first tripped the gate.
192
188
  const allowedOnce = runHook(block, nested, request);
193
189
  assert.equal(allowedOnce.stdout, '');
194
190
 
195
- const deniedAgain = runHook(block, nested, request);
196
- assert.equal(JSON.parse(deniedAgain.stdout).hookSpecificOutput.permissionDecision, 'deny');
191
+ const allowedAgain = runHook(block, nested, request);
192
+ assert.equal(allowedAgain.stdout, '', 'one approval covers a re-read');
197
193
 
194
+ const allowedOtherTool = runHook(block, nested, {
195
+ ...request,
196
+ tool_name: 'exec_command'
197
+ });
198
+ assert.equal(allowedOtherTool.stdout, '', 'grant is not bound to a tool name');
199
+
200
+ // A fresh session, so the single-path grant above cannot mask the exact-set
201
+ // matching under test.
198
202
  fs.writeFileSync(path.join(nested, 'credentials.json'), '{"token":"test"}\n');
199
203
  const multiRequest = {
200
204
  ...request,
205
+ session_id: 'session-c',
201
206
  tool_input: { command: 'cat .env credentials.json' }
202
207
  };
203
208
  const multiDenied = runHook(block, nested, multiRequest);
@@ -210,31 +215,65 @@ test('Codex privacy approval is exact, session-bound, and one-time', () => {
210
215
 
211
216
  const multiApproved = runHook(approve, nested, {
212
217
  cwd: nested,
213
- session_id: 'session-a',
218
+ session_id: 'session-c',
214
219
  hook_event_name: 'UserPromptSubmit',
215
220
  prompt: multiPrompt
216
221
  });
217
- assert.match(multiApproved.stdout, /approved one retry/);
222
+ assert.match(multiApproved.stdout, /User approved/);
218
223
 
219
- // A token for two paths cannot authorize a subset, and the failed subset
220
- // attempt must not consume the exact-set token.
221
- const subsetDenied = runHook(block, nested, request);
224
+ // A grant for two paths authorizes exactly that set: neither a subset nor a
225
+ // superset, in either order.
226
+ const subsetDenied = runHook(block, nested, { ...request, session_id: 'session-c' });
222
227
  assert.equal(JSON.parse(subsetDenied.stdout).hookSpecificOutput.permissionDecision, 'deny');
223
228
  fs.writeFileSync(path.join(nested, 'tokens.json'), '{"token":"test"}\n');
224
229
  const supersetDenied = runHook(block, nested, {
225
230
  ...request,
231
+ session_id: 'session-c',
226
232
  tool_input: { command: 'cat .env credentials.json tokens.json' }
227
233
  });
228
234
  assert.equal(JSON.parse(supersetDenied.stdout).hookSpecificOutput.permissionDecision, 'deny');
229
235
  const reordered = {
230
236
  ...request,
237
+ session_id: 'session-c',
231
238
  tool_input: { command: 'cat credentials.json .env' }
232
239
  };
233
240
  assert.equal(runHook(block, nested, reordered).stdout, '');
234
- assert.equal(
235
- JSON.parse(runHook(block, nested, multiRequest).stdout).hookSpecificOutput.permissionDecision,
236
- 'deny'
237
- );
241
+ assert.equal(runHook(block, nested, multiRequest).stdout, '', 'the set stays granted');
242
+
243
+ // Session binding survives the change: another session sees no grant.
244
+ const otherSession = runHook(block, nested, { ...request, session_id: 'session-d' });
245
+ assert.equal(JSON.parse(otherSession.stdout).hookSpecificOutput.permissionDecision, 'deny');
246
+ });
247
+ });
248
+
249
+ test('Codex privacy grant window is one hour and restarts at approval', () => {
250
+ inHookFixture((root, hooks) => {
251
+ const state = require(path.join(hooks, 'lib', 'privacy-state.cjs'));
252
+ const HOUR = 60 * 60 * 1000;
253
+ assert.equal(state.TTL_MS, HOUR, 'a five-minute window expired mid-conversation');
254
+
255
+ fs.writeFileSync(path.join(root, '.env'), 'SECRET=1\n');
256
+ const pending = state.createPending({
257
+ projectRoot: root,
258
+ sessionCwd: root,
259
+ sessionId: 'session-ttl',
260
+ filePaths: ['.env'],
261
+ });
262
+ assert.ok(pending.expiresAt - Date.now() > HOUR - 5000, 'pending must live an hour');
263
+
264
+ const approved = state.approvePending({
265
+ projectRoot: root,
266
+ sessionId: 'session-ttl',
267
+ requestId: pending.requestId,
268
+ });
269
+ assert.equal(approved.ok, true);
270
+
271
+ const stateDir = path.join(root, '.codex', 'hooks', '.privacy');
272
+ const tokenFile = fs.readdirSync(stateDir).find((name) => name.startsWith('token-'));
273
+ assert.ok(tokenFile, 'approval must write a grant');
274
+ const grant = JSON.parse(fs.readFileSync(path.join(stateDir, tokenFile), 'utf8'));
275
+ assert.ok(grant.expiresAt - Date.now() > HOUR - 5000, 'the grant window starts at approval');
276
+ assert.equal(grant.toolName, undefined, 'a grant is not bound to a tool name');
238
277
  });
239
278
  });
240
279
 
@@ -909,14 +948,14 @@ test('Codex adapter enforces declared task artifact SHA-256 with missing, invali
909
948
  }
910
949
  });
911
950
 
912
- test('Codex privacy denies obfuscated and session paths, allows benign', () => {
951
+ test('Codex privacy denies obfuscated secret reads, allows benign and runtime state', () => {
913
952
  inHookFixture((root, hooks) => {
914
953
  const block = path.join(hooks, 'privacy-block.cjs');
915
954
  const denied = [
916
955
  { command: "cat .e''nv", tool: 'exec_command' },
917
- { command: "cat .codex/sessions/test.json", tool: 'exec_command' },
918
- { command: "cat $HOME/.cafekit/completion-authority/hmac.key", tool: 'exec_command' },
919
- { command: "cat .codex/sessions/abc.json", tool: 'Bash' },
956
+ { command: 'cat ~/.cafekit/completion-authority/hmac.key', tool: 'exec_command' },
957
+ { command: 'cat ~/.ssh/id_rsa', tool: 'Bash' },
958
+ { command: 'docker compose --env-file .env up', tool: 'exec_command' },
920
959
  { command: "python3 -c \"open('.env').read()\"", tool: 'exec_command' },
921
960
  { command: "node -e \"fs.readFileSync('.env')\"", tool: 'Bash' },
922
961
  { command: "bash -c 'cat .e''nv'", tool: 'exec_command' },
@@ -926,10 +965,18 @@ test('Codex privacy denies obfuscated and session paths, allows benign', () => {
926
965
  const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
927
966
  assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', command);
928
967
  }
968
+ // Runtime state and transcripts are CafeKit's own bookkeeping, not secrets.
969
+ // Guarding them by substring also matched every source file and doc that
970
+ // merely spelled `sessions` or `session-state`, so the gate fired on the
971
+ // repo it was installed in. The one real secret there, hmac.key, is still
972
+ // covered above by the `.key` rule.
929
973
  const allowed = [
930
974
  { command: "echo hello", tool: 'exec_command' },
931
975
  { command: "cat README.md", tool: 'exec_command' },
932
976
  { command: "cat src/app.js", tool: 'Bash' },
977
+ { command: 'cat .codex/sessions/test.json', tool: 'exec_command' },
978
+ { command: 'cat .codex/session-state/latest.md', tool: 'Bash' },
979
+ { command: 'cat docs/sessions-guide.md', tool: 'exec_command' },
933
980
  ];
934
981
  for (const { command, tool } of allowed) {
935
982
  const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
@@ -938,45 +985,27 @@ test('Codex privacy denies obfuscated and session paths, allows benign', () => {
938
985
  });
939
986
  });
940
987
 
941
- test('Codex privacy denies active read expansion, preserves literal dollars and safe controls', () => {
988
+ test('Codex privacy denies sensitive names it can resolve, allows pure indirection', () => {
942
989
  inHookFixture((root, hooks) => {
943
990
  const block = path.join(hooks, 'privacy-block.cjs');
944
991
  const denied = [
945
- { command: "cat $FILE", tool: 'Bash' },
946
- { command: 'cat "$FILE"', tool: 'exec_command' },
947
- { command: "cat ${FILE}", tool: 'Bash' },
948
- { command: "cat $1", tool: 'exec_command' },
949
- { command: "cat $(echo .env)", tool: 'Bash' },
950
- { command: "cat `echo .env`", tool: 'Bash' },
951
- { command: "head $SECRET", tool: 'exec_command' },
952
- { command: "tail ${MY_FILE}", tool: 'Bash' },
953
- { command: "cat README.md $EXTRA", tool: 'Bash' },
954
- { command: "head README.md $UNRELATED", tool: 'exec_command' },
955
- { command: "echo ok; cat README.md $FILE", tool: 'Bash' },
956
- { command: "cat README.md | head $FILE", tool: 'exec_command' },
957
- { command: "bash -c 'cat $FILE'", tool: 'Bash' },
958
- { command: "echo $(cat $FILE)", tool: 'exec_command' },
959
- { command: 'cat README.md "$(printf safe)"', tool: 'Bash' },
960
- { command: 'cat README.md *', tool: 'Bash' },
961
- { command: 'cat README.md ~/README.md', tool: 'exec_command' },
962
- { command: 'cat README.md {README.md,LICENSE}', tool: 'Bash' },
963
992
  { command: 'cat .e\\\nnv', tool: 'Bash' },
964
993
  { command: "cat $'\\x2e\\x65\\x6e\\x76'", tool: 'exec_command' },
965
- { command: 'env -u UNUSED cat "$FILE"', tool: 'Bash' },
966
- { command: 'timeout 1 cat "$FILE"', tool: 'exec_command' },
967
- { command: 'nice -n 10 cat "$FILE"', tool: 'Bash' },
968
- { command: "eval 'cat \"$FILE\"'", tool: 'exec_command' },
969
- { command: "find . -exec sh -c 'cat \"$FILE\"' \\;", tool: 'Bash' },
970
- { command: 'source "$FILE"', tool: 'exec_command' },
971
- { command: 'xargs -a "$LIST" cat', tool: 'Bash' },
972
- { command: 'python3 -c \'open(os.environ["FILE"]).read()\'', tool: 'exec_command' },
973
- { command: 'node -e \'fs.readFileSync(process.env.FILE)\'', tool: 'Bash' },
974
- { command: 'cat < "$FILE"', tool: 'exec_command' },
994
+ { command: 'cat .env*', tool: 'Bash' },
995
+ { command: 'cat certs/*.pem', tool: 'exec_command' },
996
+ { command: 'cat ~/.aws/credentials', tool: 'Bash' },
997
+ { command: 'ssh -i ~/.ssh/id_ed25519 host', tool: 'exec_command' },
998
+ { command: 'curl --config ~/.netrc https://example.com', tool: 'Bash' },
999
+ { command: 'kubectl --kubeconfig ~/.kube/kubeconfig get pods', tool: 'exec_command' },
1000
+ { command: 'openssl rsa -in server.key', tool: 'Bash' },
975
1001
  ];
976
1002
  for (const { command, tool } of denied) {
977
1003
  const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
978
1004
  assert.equal(JSON.parse(res.stdout).hookSpecificOutput.permissionDecision, 'deny', `should deny ${command}`);
979
1005
  }
1006
+ // Accepted gap: the target of `$FILE` is unknowable without running the
1007
+ // shell, and every denial here costs a round trip through a one-shot
1008
+ // approval prompt. `less "$HOME/.env"` is the edge of that decision.
980
1009
  const allowed = [
981
1010
  { command: "echo $VAR", tool: 'exec_command' },
982
1011
  { command: 'echo "$HOME"', tool: 'Bash' },
@@ -995,11 +1024,37 @@ test('Codex privacy denies active read expansion, preserves literal dollars and
995
1024
  { command: 'echo not.env', tool: 'Bash' },
996
1025
  { command: 'echo .env', tool: 'exec_command' },
997
1026
  { command: "printf '%s\\n' .env", tool: 'Bash' },
1027
+ { command: 'git log -- .env', tool: 'Bash' },
998
1028
  { command: 'git status sessions.md', tool: 'exec_command' },
999
1029
  { command: 'python3 -c "print(os.environ[\'HOME\'])"', tool: 'Bash' },
1000
1030
  { command: 'head -n "$COUNT" README.md', tool: 'exec_command' },
1001
1031
  { command: 'grep .env README.md', tool: 'Bash' },
1002
1032
  { command: "sed -n '/.env/p' README.md", tool: 'exec_command' },
1033
+ { command: 'cat $FILE', tool: 'Bash' },
1034
+ { command: 'cat "$FILE"', tool: 'exec_command' },
1035
+ { command: 'cat ${FILE}', tool: 'Bash' },
1036
+ { command: 'cat $1', tool: 'exec_command' },
1037
+ { command: 'cat $(echo .env)', tool: 'Bash' },
1038
+ { command: 'cat `echo .env`', tool: 'Bash' },
1039
+ { command: 'head $SECRET', tool: 'exec_command' },
1040
+ { command: 'tail ${MY_FILE}', tool: 'Bash' },
1041
+ { command: 'less "$HOME/.env"', tool: 'Bash' },
1042
+ { command: 'cat README.md $EXTRA', tool: 'Bash' },
1043
+ { command: 'echo ok; cat README.md $FILE', tool: 'Bash' },
1044
+ { command: 'cat README.md | head $FILE', tool: 'exec_command' },
1045
+ { command: "bash -c 'cat $FILE'", tool: 'Bash' },
1046
+ { command: 'cat README.md *', tool: 'Bash' },
1047
+ { command: 'cat ~/.zshrc', tool: 'exec_command' },
1048
+ { command: 'du -sh */', tool: 'Bash' },
1049
+ { command: 'env -u UNUSED cat "$FILE"', tool: 'Bash' },
1050
+ { command: 'timeout 1 cat "$FILE"', tool: 'exec_command' },
1051
+ { command: "eval 'cat \"$FILE\"'", tool: 'exec_command' },
1052
+ { command: "find . -exec sh -c 'cat \"$FILE\"' \\;", tool: 'Bash' },
1053
+ { command: 'source "$FILE"', tool: 'exec_command' },
1054
+ { command: 'xargs -a "$LIST" cat', tool: 'Bash' },
1055
+ { command: 'python3 -c \'open(os.environ["FILE"]).read()\'', tool: 'exec_command' },
1056
+ { command: 'node -e \'fs.readFileSync(process.env.FILE)\'', tool: 'Bash' },
1057
+ { command: 'cat < "$FILE"', tool: 'exec_command' },
1003
1058
  ];
1004
1059
  for (const { command, tool } of allowed) {
1005
1060
  const res = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: tool, tool_input: { command } });
@@ -1007,11 +1062,24 @@ test('Codex privacy denies active read expansion, preserves literal dollars and
1007
1062
  }
1008
1063
  const assignmentOnly = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat README.md' } });
1009
1064
  assert.equal(assignmentOnly.stdout, '');
1010
- const assignmentRead = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat "$FILE"' } });
1065
+ const assignmentRead = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'FILE=.env; cat .env' } });
1011
1066
  assert.equal(JSON.parse(assignmentRead.stdout).hookSpecificOutput.permissionDecision, 'deny');
1012
1067
  });
1013
1068
  });
1014
1069
 
1070
+ test('Codex privacy reads an inert heredoc body as prose, not shell', () => {
1071
+ inHookFixture((root, hooks) => {
1072
+ const block = path.join(hooks, 'privacy-block.cjs');
1073
+ const inert = "gh pr create --body \"$(cat <<'BODY'\nRun `cat .env` to inspect config\nBODY\n)\"";
1074
+ const allowed = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: inert } });
1075
+ assert.equal(allowed.stdout, '', 'quoted delimiter expands nothing');
1076
+
1077
+ const expanding = 'bash -c "cat <<BODY\n$(cat .env)\nBODY"';
1078
+ const denied = runHook(block, root, { cwd: root, session_id: 's1', hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: expanding } });
1079
+ assert.equal(JSON.parse(denied.stdout).hookSpecificOutput.permissionDecision, 'deny', 'unquoted delimiter expands');
1080
+ });
1081
+ });
1082
+
1015
1083
  test('Claude and Codex privacy lexers agree on literals, delimiters, and quote expansion', () => {
1016
1084
  inHookFixture((root, hooks) => {
1017
1085
  const claude = path.join(PACKAGE_ROOT, 'src/claude/hooks/privacy-block.cjs');
@@ -1019,8 +1087,8 @@ test('Claude and Codex privacy lexers agree on literals, delimiters, and quote e
1019
1087
  const vectors = [
1020
1088
  ["cat .e''nv", true],
1021
1089
  ["python3 -c \"open('.env').read()\"", true],
1022
- ['cat README.md $FILE', true],
1023
- ['echo ok; cat README.md $FILE', true],
1090
+ ['cat README.md $FILE', false],
1091
+ ['echo ok; cat README.md $FILE', false],
1024
1092
  ["cat '$FILE'", false],
1025
1093
  ["echo '$(cat $FILE)'", false],
1026
1094
  ["cat README.md '*'", false],
@@ -1030,13 +1098,17 @@ test('Claude and Codex privacy lexers agree on literals, delimiters, and quote e
1030
1098
  ['echo .envoy', false],
1031
1099
  ['cat .e\\\nnv', true],
1032
1100
  ["cat $'\\x2e\\x65\\x6e\\x76'", true],
1033
- ['env -u UNUSED cat "$FILE"', true],
1034
- ['timeout 1 cat "$FILE"', true],
1035
- ["eval 'cat \"$FILE\"'", true],
1036
- ["find . -exec sh -c 'cat \"$FILE\"' \\;", true],
1037
- ['source "$FILE"', true],
1038
- ['xargs -a "$LIST" cat', true],
1039
- ['python3 -c \'open(os.environ["FILE"]).read()\'', true],
1101
+ ['env -u UNUSED cat "$FILE"', false],
1102
+ ['timeout 1 cat "$FILE"', false],
1103
+ ["eval 'cat \"$FILE\"'", false],
1104
+ ["find . -exec sh -c 'cat \"$FILE\"' \\;", false],
1105
+ ['source "$FILE"', false],
1106
+ ['xargs -a "$LIST" cat', false],
1107
+ ['python3 -c \'open(os.environ["FILE"]).read()\'', false],
1108
+ ['cat ~/.ssh/id_rsa', true],
1109
+ ['docker compose --env-file .env up', true],
1110
+ ['cat ~/.zshrc', false],
1111
+ ['git log -- .env', false],
1040
1112
  ['echo .env', false],
1041
1113
  ["printf '%s\\n' .env", false],
1042
1114
  ['git status sessions.md', false],
@@ -17,6 +17,7 @@ const VALIDATOR = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/validate-s
17
17
  const GROUNDER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-ground.cjs'));
18
18
  const SEMANTIC_AUTHORITY = require(path.join(PACKAGE_ROOT, 'src/claude/hooks/semantic-review-authority.cjs'));
19
19
  const READINESS = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-readiness.cjs'));
20
+ const AUTHORING_VALIDATOR = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-authoring-validation.cjs');
20
21
  const { copyClaudeTestRuntime } = require('./test-runtime-dependency-closure.cjs');
21
22
  const DEVELOP = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/SKILL.md');
22
23
  const SPECS = path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md');
@@ -183,23 +184,38 @@ The result contains the literal deleted state.
183
184
  ## Verification Definitions
184
185
  - **V1**: Criteria R1.1; Owner A-D-01; Decision refs D1, I1, C1; Method command \`node --test test/entry.test.js\`; Expected exit 0 and the service reports deleted state; Negative/failure a missing record returns not-found without deleted state; Reachability/grounding entrypoint \`src/entry.js\` via A-D-01.
185
186
  `);
186
- const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
187
- spec.authoring.requirements = 'validated';
188
- spec.authoring.design = 'validated';
189
- fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
187
+ const semantic = runNode(root, SCAFFOLD, ['ordinary', '--sync-semantic-model']);
188
+ assert.equal(semantic.status, 0, commandOutput(semantic));
189
+ const authoring = runNode(root, AUTHORING_VALIDATOR, [specDir]);
190
+ assert.equal(authoring.status, 0, commandOutput(authoring));
190
191
  initFixtureGit(root);
191
192
  return { root, specDir, specPath };
192
193
  }
193
194
 
194
195
  function ordinaryReview() {
195
- return {
196
- reviewed_criteria: ['R1.1'],
197
- counterexamples: [{
196
+ return canonicalPassReview(['R1.1'], [{
198
197
  criterion: 'R1.1', case_kind: 'failure',
199
198
  scenario: 'Deletion targets a record that does not exist in the current store.',
200
199
  expected: 'The service returns not-found and never reports the record as deleted.',
201
200
  decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
202
- }],
201
+ }]);
202
+ }
203
+
204
+ function canonicalPassReview(reviewedCriteria, counterexamples) {
205
+ return {
206
+ verdict: 'PASS',
207
+ findings: [],
208
+ unresolved_decisions: [],
209
+ graph_coverage: [
210
+ 'criterion_local', 'cross_criterion', 'runtime_path',
211
+ 'assumption_provenance', 'compatibility_migration',
212
+ ].map((surface) => ({
213
+ surface, covered: true,
214
+ notes: 'The review covers this semantic surface against the canonical model.',
215
+ })),
216
+ reviewed_criteria: reviewedCriteria,
217
+ counterexamples,
218
+ reviewer_evidence: null,
203
219
  };
204
220
  }
205
221
 
@@ -209,16 +225,12 @@ function promoteCanonicalModel(fixture) {
209
225
  }
210
226
 
211
227
  function makeCanonicalReady(fixture) {
212
- const spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
213
- spec.authoring.requirements = 'validated';
214
- spec.authoring.design = 'validated';
215
- fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
228
+ const authoring = runNode(fixture.root, AUTHORING_VALIDATOR, [fixture.specDir]);
229
+ assert.equal(authoring.status, 0, commandOutput(authoring));
216
230
  const result = READINESS.finalizeReadiness({
217
231
  specDir: fixture.specDir,
218
232
  projectRoot: fixture.root,
219
- reviewResult: {
220
- reviewed_criteria: ['R1.1', 'R1.2'],
221
- counterexamples: [{
233
+ reviewResult: canonicalPassReview(['R1.1', 'R1.2'], [{
222
234
  criterion: 'R1.1', case_kind: 'failure',
223
235
  scenario: 'The service receives invalid input without its required discriminator.',
224
236
  expected: 'The service returns rejected state and never emits enabled true.',
@@ -228,8 +240,7 @@ function makeCanonicalReady(fixture) {
228
240
  scenario: 'The service reports enabled state without producing separate proof evidence.',
229
241
  expected: 'Verification fails because the independent proof artifact is absent.',
230
242
  decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
231
- }],
232
- },
243
+ }]),
233
244
  });
234
245
  return result.spec;
235
246
  }
@@ -875,6 +886,8 @@ test('auto authoring reaches readiness only after promotion, validation, groundi
875
886
  assert.ok(validation.errors.some((error) => error.includes('readiness requires validated')));
876
887
  assert.ok(validation.errors.some((error) => error.includes('requires completed semantic review')));
877
888
 
889
+ spec.ready_for_implementation = false;
890
+ fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
878
891
  spec = makeCanonicalReady(fixture);
879
892
  validation = VALIDATOR.validateSpec(fixture.specDir);
880
893
  assert.deepEqual(validation.errors, [], validation.errors.join('\n'));
@@ -1025,6 +1038,31 @@ test('done is a final-state request bound to current semantic model and digest',
1025
1038
  assert.equal(current.ok, true, current.reason);
1026
1039
  assert.equal(current.semanticDigest, spec.validation.semantic_review.semantic_digest);
1027
1040
 
1041
+ const failedReceipt = structuredClone(spec);
1042
+ failedReceipt.validation.semantic_review.verdict = 'FAIL';
1043
+ const receiptMutation = FINAL_STATE.validateCanonicalFinalState({
1044
+ policy: POLICY,
1045
+ projectRoot: fixture.root,
1046
+ candidate: { ...candidate, spec: failedReceipt },
1047
+ dependencies,
1048
+ });
1049
+ assert.equal(receiptMutation.ok, false);
1050
+ assert.match(receiptMutation.reason, /PASS CONTINUE/);
1051
+
1052
+ const mismatchedHistory = structuredClone(spec);
1053
+ const latest = mismatchedHistory.validation.semantic_review_history.entries[
1054
+ mismatchedHistory.validation.semantic_review_history.entries.length - 1
1055
+ ];
1056
+ latest.review_receipt_digest = `sha256:${'0'.repeat(64)}`;
1057
+ const digestMismatch = FINAL_STATE.validateCanonicalFinalState({
1058
+ policy: POLICY,
1059
+ projectRoot: fixture.root,
1060
+ candidate: { ...candidate, spec: mismatchedHistory },
1061
+ dependencies,
1062
+ });
1063
+ assert.equal(digestMismatch.ok, false);
1064
+ assert.match(digestMismatch.reason, /review_receipt_digest|canonical receipt/);
1065
+
1028
1066
  const requirementsPath = path.join(fixture.specDir, 'requirements.md');
1029
1067
  const requirements = fs.readFileSync(requirementsPath, 'utf8');
1030
1068
  fs.writeFileSync(requirementsPath, requirements.replace('invalid input shall return rejected state', 'invalid input shall return a deterministic rejected state'));