@haposoft/cafekit 0.16.0-rc.7 → 0.16.0-rc.8

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.
@@ -4,10 +4,47 @@ const assert = require('node:assert/strict');
4
4
  const fs = require('node:fs');
5
5
  const os = require('node:os');
6
6
  const path = require('node:path');
7
+ const { createHash } = require('node:crypto');
7
8
  const { spawnSync } = require('node:child_process');
8
9
  const { test } = require('node:test');
9
10
 
10
11
  const PACKAGE_ROOT = path.resolve(__dirname, '../..');
12
+ const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
13
+ const OLD_FIXTURE_VERSION = '0.14.1';
14
+ const NO_INVENTION_ANCHOR = 'Before implementation handoff, apply the **no-invention gate**:';
15
+ const IMPLEMENTATION_READINESS_BOUNDARY_ROWS = [
16
+ ['API/CLI', 'entrypoint/route or command grammar; identity/auth; input/default/normalization; success output; error/status/exit; duplicate/retry/idempotency; compatibility'],
17
+ ['Schema', 'version; exact keys/nesting/types; required/optional; enum/format/bounds/cardinality; unknown-field behavior; compatibility/migration'],
18
+ ['State/concurrency', 'initial/terminal states; event + guard + effect + next + error; ordering; duplicate/retry; writer/lock acquire/contention/release; rollback/recovery'],
19
+ ['Filesystem/security', 'authoritative root; trusted/untrusted segment grammar; lexical + canonical containment and symlink policy; flags/mode; temp/rename/fsync; lock/stale reclaim; crash cleanup'],
20
+ ['Time/retention', 'clock source; unit/precision/timezone; endpoints and inclusion/comparator; anomaly behavior; expiry/purge/recovery'],
21
+ ['Integration/proof', 'caller; export/registration; packaging/config; native path/consumer; proof level (`source`/`installed`/`live`); observable failure oracle'],
22
+ ];
23
+ const IMPLEMENTATION_READINESS_CLAUSES = {
24
+ noInvention: 'Before implementation handoff, apply the **no-invention gate**: if two implementations conform to the packet text yet can produce different externally observable output, state, error, security, or compatibility behavior, surface the missing choice as an explicit C1 or C2 question and block handoff.',
25
+ materialDefinition: 'A boundary is material when the task creates, changes, or depends on it and a different choice changes an external observation, security, durable data, compatibility, or proof reachability. Require only the matching material row; omit nonmaterial categories.',
26
+ exactBoundaryChoices: 'For every required row, name each listed choice exactly; labels such as “JSON”, “local path”, “locked”, or “timestamped” alone remain unresolved.',
27
+ proofPlanLines: [
28
+ '- Command: `<exact runnable command>`',
29
+ '- Named probe: <existing concrete probe/test/hook ID; never only a suite label>',
30
+ '- Reachability: <real entrypoint/caller at each `source`/`installed`/`live` level; `UNKNOWN` if unexecuted>',
31
+ '- Oracle: <externally observable success or failure>',
32
+ '- Counterexample: <material alternative behavior that must make this proof fail>',
33
+ '- Artifacts: <required artifact path plus digest algorithm/comparison rule, or explicitly ephemeral with cleanup rule>',
34
+ ],
35
+ proofTrace: 'Trace `Command → Named probe → Reachability → Oracle`.',
36
+ namedProbeOwnership: 'Aggregate suites\nname the owning concrete probe.',
37
+ proofLevelSeparation: 'Proof at `source`/`installed`/`live` stays\nseparate; one level never promotes another.',
38
+ disposableTemplateControls: 'Run mutation or destructive\nnegative controls only on disposable copies under a verified temporary root,\nnever tracked worktree or canonical source bytes.',
39
+ disposableReviewControls: 'Run mutation or destructive negative controls only on disposable copies below a verified temporary root, never tracked worktree or canonical source bytes.',
40
+ failureSemantics: '`Crash` means abrupt unhandled termination before the claimed catch point; a catchable failure returns/raises an error or exits nonzero. Never use them interchangeably.',
41
+ privacyIdentifiers: 'Any privacy/security claim names the exact identifier surface at risk, such as an env var, header, path, token class, or field name; generic “sensitive data” is insufficient.',
42
+ freshReplay: 'After applying an accepted C2 finding, a fresh-context closure pass records and freshly replays its original counterexample after the repair under this exact review-log header:',
43
+ distinctRepairProof: '`Repaired at` cites the repair edit; `Proved at` must cite distinct evidence from the fresh replay, never the repair-edit citation.',
44
+ closureTransition: 'An accepted finding transitions `accepted → repaired → PASS|FAIL|UNKNOWN`.',
45
+ unknownBlocks: 'Only `PASS` closes it; `FAIL` remains open for the remaining paper-review round; `UNKNOWN` blocks implementation handoff.',
46
+ scopeReturnsToC1: 'A repair that adds user semantics or scope returns to C1.',
47
+ };
11
48
  const REQUIRED_PAYLOAD = [
12
49
  'bin/install.js',
13
50
  'src/claude/migration-manifest.json',
@@ -33,8 +70,14 @@ const FORBIDDEN_PAYLOAD = [
33
70
  /^src\/\.codex(?:\/|$)/,
34
71
  ];
35
72
  const RUNTIMES = {
36
- claude: { root: '.claude', rules: '.claude/hooks/rules.cjs' },
37
- codex: { root: '.codex', rules: '.codex/hooks/rules.cjs' },
73
+ claude: {
74
+ root: '.claude', rules: '.claude/hooks/rules.cjs', specs: '.claude/skills/specs',
75
+ manifest: '.claude/cafekit-manifest.json', templatesManifestPath: 'skills/specs/references/templates.md'
76
+ },
77
+ codex: {
78
+ root: '.codex', rules: '.codex/hooks/rules.cjs', specs: '.agents/skills/specs',
79
+ manifest: '.codex/cafekit-manifest.json', templatesManifestPath: '.agents/skills/specs/references/templates.md'
80
+ },
38
81
  };
39
82
 
40
83
  function npmPack(args, cwd) {
@@ -131,6 +174,434 @@ function runInstaller(installer, root, platforms, lang) {
131
174
  env: { ...process.env, HOME: path.join(root, 'home'), PATH: '/usr/bin:/bin' },
132
175
  });
133
176
  assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
177
+ return result;
178
+ }
179
+
180
+ function markdownTableUnderHeading(content, heading) {
181
+ const lines = markdownSectionUnderHeading(content, heading).split('\n');
182
+ const tableStart = lines.findIndex((line) => line.trim().startsWith('|'));
183
+ if (tableStart < 0) return [];
184
+ const rows = [];
185
+ for (let index = tableStart; index < lines.length && lines[index].trim().startsWith('|'); index += 1) {
186
+ const cells = lines[index].split('|').slice(1, -1).map((cell) => cell.trim());
187
+ if (cells.every((cell) => /^:?-+:?$/.test(cell))) continue;
188
+ rows.push(cells);
189
+ }
190
+ return rows;
191
+ }
192
+
193
+ function markdownSectionUnderHeading(content, heading) {
194
+ const lines = String(content).split('\n');
195
+ const headingIndex = lines.findIndex((line) => line.trim() === `## ${heading}`);
196
+ if (headingIndex < 0) return '';
197
+ const nextHeadingIndex = lines.findIndex(
198
+ (line, index) => index > headingIndex && /^##\s+/.test(line)
199
+ );
200
+ return lines.slice(headingIndex + 1, nextHeadingIndex < 0 ? undefined : nextHeadingIndex).join('\n');
201
+ }
202
+
203
+ function normalizeMarkdownWhitespace(content) {
204
+ return String(content).replace(/\s+/g, ' ').trim();
205
+ }
206
+
207
+ function markdownBetweenHeadings(content, startHeading, endHeading) {
208
+ const value = String(content);
209
+ const startMarker = `## ${startHeading}`;
210
+ const endMarker = `## ${endHeading}`;
211
+ const startIndex = value.indexOf(startMarker);
212
+ const endIndex = value.indexOf(endMarker, startIndex + startMarker.length);
213
+ if (startIndex < 0 || endIndex < 0) return '';
214
+ return value.slice(startIndex + startMarker.length, endIndex);
215
+ }
216
+
217
+ function implementationReadinessContractIssues(input) {
218
+ const keys = input && typeof input === 'object' && !Array.isArray(input)
219
+ ? Object.keys(input).sort()
220
+ : [];
221
+ if (keys.join(',') !== 'review,templates'
222
+ || typeof input.templates !== 'string' || typeof input.review !== 'string') {
223
+ throw new TypeError('implementation-readiness checker expects exactly templates and review UTF-8 strings');
224
+ }
225
+
226
+ const issues = new Set();
227
+ const authoring = markdownSectionUnderHeading(
228
+ input.templates,
229
+ 'No-invention and conditional boundary contracts'
230
+ );
231
+ if (!authoring.includes(IMPLEMENTATION_READINESS_CLAUSES.noInvention)) {
232
+ issues.add('no-invention');
233
+ }
234
+
235
+ const boundaryTable = markdownTableUnderHeading(
236
+ input.templates,
237
+ 'No-invention and conditional boundary contracts'
238
+ );
239
+ const expectedBoundaryTable = [
240
+ ['Boundary', 'Required contract when material'],
241
+ ...IMPLEMENTATION_READINESS_BOUNDARY_ROWS,
242
+ ];
243
+ if (!authoring.includes(IMPLEMENTATION_READINESS_CLAUSES.materialDefinition)
244
+ || !authoring.includes(IMPLEMENTATION_READINESS_CLAUSES.exactBoundaryChoices)
245
+ || IMPLEMENTATION_READINESS_BOUNDARY_ROWS.length !== 6
246
+ || boundaryTable.length !== expectedBoundaryTable.length
247
+ || JSON.stringify(boundaryTable) !== JSON.stringify(expectedBoundaryTable)) {
248
+ issues.add('boundary-contract');
249
+ }
250
+
251
+ const proof = markdownSectionUnderHeading(input.templates, 'Verification Plan');
252
+ const proofPlanLines = proof.split('\n').map((line) => line.trim()).filter((line) => line.startsWith('- '));
253
+ const normalizedProofContract = normalizeMarkdownWhitespace(markdownBetweenHeadings(
254
+ input.templates,
255
+ 'Verification Plan',
256
+ 'Canonical inline Receipt'
257
+ ));
258
+ const normalizedProofClauses = [
259
+ IMPLEMENTATION_READINESS_CLAUSES.proofTrace,
260
+ IMPLEMENTATION_READINESS_CLAUSES.namedProbeOwnership,
261
+ IMPLEMENTATION_READINESS_CLAUSES.proofLevelSeparation,
262
+ IMPLEMENTATION_READINESS_CLAUSES.disposableTemplateControls,
263
+ ].map(normalizeMarkdownWhitespace);
264
+ const reviewNegativeControls = normalizeMarkdownWhitespace(
265
+ markdownSectionUnderHeading(input.review, 'B2 — fresh-context red team')
266
+ );
267
+ if (JSON.stringify(proofPlanLines) !== JSON.stringify(IMPLEMENTATION_READINESS_CLAUSES.proofPlanLines)
268
+ || normalizedProofClauses.some((clause) => !normalizedProofContract.includes(clause))
269
+ || !reviewNegativeControls.includes(normalizeMarkdownWhitespace(
270
+ IMPLEMENTATION_READINESS_CLAUSES.disposableReviewControls
271
+ ))) {
272
+ issues.add('proof-chain');
273
+ }
274
+
275
+ const failureGuidance = markdownSectionUnderHeading(input.templates, 'Twelve edge-case dimensions');
276
+ if (!failureGuidance.includes(IMPLEMENTATION_READINESS_CLAUSES.failureSemantics)) {
277
+ issues.add('failure-semantics');
278
+ }
279
+
280
+ const evidenceRules = markdownSectionUnderHeading(input.review, 'B1 — evidence rule');
281
+ if (!evidenceRules.includes(IMPLEMENTATION_READINESS_CLAUSES.privacyIdentifiers)) {
282
+ issues.add('privacy-identifiers');
283
+ }
284
+
285
+ const closure = markdownSectionUnderHeading(input.review, 'Accepted-repair closure');
286
+ const normalizedClosure = normalizeMarkdownWhitespace(closure);
287
+ const closureHeader = markdownTableUnderHeading(input.review, 'Accepted-repair closure')[0] || [];
288
+ if (JSON.stringify(closureHeader) !== JSON.stringify([
289
+ 'ID', 'Decision', 'Original counterexample', 'Repaired at', 'Proved at', 'Replay', 'Closure',
290
+ ])
291
+ || !normalizedClosure.includes(normalizeMarkdownWhitespace(IMPLEMENTATION_READINESS_CLAUSES.freshReplay))
292
+ || !closure.includes(IMPLEMENTATION_READINESS_CLAUSES.distinctRepairProof)
293
+ || !closure.includes(IMPLEMENTATION_READINESS_CLAUSES.closureTransition)
294
+ || !closure.includes(IMPLEMENTATION_READINESS_CLAUSES.unknownBlocks)
295
+ || !closure.includes(IMPLEMENTATION_READINESS_CLAUSES.scopeReturnsToC1)) {
296
+ issues.add('repair-closure');
297
+ }
298
+
299
+ return [...issues].sort();
300
+ }
301
+
302
+ function installedSpecsReadinessIssues(project, installedRoot, expectedRelative) {
303
+ assert.equal(path.isAbsolute(project), true, 'project root must be absolute');
304
+ assert.equal(path.isAbsolute(installedRoot), true, 'installed Specs root must be absolute');
305
+ const canonicalProject = fs.realpathSync(project);
306
+ const canonicalInstalled = fs.realpathSync(installedRoot);
307
+ const relative = path.relative(canonicalProject, canonicalInstalled);
308
+ assert.ok(relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), 'installed Specs root must stay inside project');
309
+ assert.equal(canonicalInstalled, fs.realpathSync(path.join(canonicalProject, expectedRelative)), 'checker must use the native installed Specs root');
310
+ return implementationReadinessContractIssues({
311
+ templates: fs.readFileSync(path.join(canonicalInstalled, 'references/templates.md'), 'utf8'),
312
+ review: fs.readFileSync(path.join(canonicalInstalled, 'references/review.md'), 'utf8'),
313
+ });
314
+ }
315
+
316
+ function assertInstalledSpecsReadiness(project, platform, expected = []) {
317
+ const runtime = RUNTIMES[platform];
318
+ assert.ok(runtime, `unknown platform: ${platform}`);
319
+ const installedRoot = path.join(project, runtime.specs);
320
+ const issues = installedSpecsReadinessIssues(project, installedRoot, runtime.specs);
321
+ assert.deepEqual(issues, expected, `${platform} installed Specs readiness issues`);
322
+ return issues;
323
+ }
324
+
325
+ function sha256(content) {
326
+ return createHash('sha256').update(content).digest('hex');
327
+ }
328
+
329
+ function assertContainedRealpath(root, target, label) {
330
+ const canonicalRoot = fs.realpathSync(root);
331
+ const canonicalTarget = fs.realpathSync(target);
332
+ const relative = path.relative(canonicalRoot, canonicalTarget);
333
+ assert.ok(
334
+ relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative),
335
+ `${label} must stay inside ${canonicalRoot}`
336
+ );
337
+ return canonicalTarget;
338
+ }
339
+
340
+ function installedMutationContext(tempRoot, project, platform, packageRoot = PACKAGE_ROOT) {
341
+ const runtime = RUNTIMES[platform];
342
+ assert.ok(runtime, `unknown platform: ${platform}`);
343
+ const canonicalTempRoot = assertContainedRealpath(os.tmpdir(), tempRoot, `${platform} mkdtemp root`);
344
+ assert.match(path.basename(canonicalTempRoot), /^cafekit-package-matrix-/, `${platform} mutation root must be the verified matrix mkdtemp`);
345
+ const canonicalProject = assertContainedRealpath(canonicalTempRoot, project, `${platform} packed project`);
346
+ const nativeInstalledRoot = path.join(canonicalProject, runtime.specs);
347
+ const canonicalInstalledRoot = assertContainedRealpath(
348
+ canonicalProject,
349
+ nativeInstalledRoot,
350
+ `${platform} native installed Specs root`
351
+ );
352
+ const installedRelatives = {
353
+ templates: 'references/templates.md',
354
+ review: 'references/review.md',
355
+ };
356
+ const installedPaths = {
357
+ templates: path.join(nativeInstalledRoot, installedRelatives.templates),
358
+ review: path.join(nativeInstalledRoot, installedRelatives.review),
359
+ };
360
+ const sourcePaths = {
361
+ templates: path.join(packageRoot, 'src/claude/skills/specs/references/templates.md'),
362
+ review: path.join(packageRoot, 'src/claude/skills/specs/references/review.md'),
363
+ };
364
+ for (const source of Object.keys(sourcePaths)) sourcePaths[source] = fs.realpathSync(sourcePaths[source]);
365
+ const sourceHashes = Object.fromEntries(
366
+ Object.entries(sourcePaths).map(([source, sourcePath]) => [source, sha256(fs.readFileSync(sourcePath))])
367
+ );
368
+ const assertSourceHashes = (stage) => {
369
+ for (const source of Object.keys(sourcePaths)) {
370
+ assert.equal(
371
+ sha256(fs.readFileSync(sourcePaths[source])),
372
+ sourceHashes[source],
373
+ `${platform} PACKAGE_ROOT ${source} SHA changed ${stage}`
374
+ );
375
+ }
376
+ };
377
+ const assertSafeInstalledTarget = (source, stage) => {
378
+ assert.ok(Object.hasOwn(installedPaths, source), `${platform} unknown installed mutation source: ${source}`);
379
+ assertSourceHashes(`before ${stage}`);
380
+
381
+ const currentTempRoot = assertContainedRealpath(os.tmpdir(), tempRoot, `${platform} ${stage} mkdtemp root`);
382
+ assert.equal(currentTempRoot, canonicalTempRoot, `${platform} ${stage} mutation root changed`);
383
+ const currentProject = assertContainedRealpath(currentTempRoot, project, `${platform} ${stage} packed project`);
384
+ assert.equal(currentProject, canonicalProject, `${platform} ${stage} packed project changed`);
385
+ const currentInstalledRoot = assertContainedRealpath(
386
+ currentProject,
387
+ nativeInstalledRoot,
388
+ `${platform} ${stage} native installed Specs root`
389
+ );
390
+ assert.equal(currentInstalledRoot, canonicalInstalledRoot, `${platform} ${stage} native installed Specs root changed`);
391
+
392
+ const target = installedPaths[source];
393
+ const targetMetadata = fs.lstatSync(target);
394
+ assert.equal(
395
+ targetMetadata.isSymbolicLink(),
396
+ false,
397
+ `${platform} ${stage} target must be a regular non-symlink file`
398
+ );
399
+ assert.equal(targetMetadata.isFile(), true, `${platform} ${stage} target must be a regular non-symlink file`);
400
+ const canonicalTarget = assertContainedRealpath(
401
+ currentInstalledRoot,
402
+ target,
403
+ `${platform} ${stage} installed ${source} mutation target`
404
+ );
405
+ assert.equal(
406
+ assertContainedRealpath(currentProject, target, `${platform} ${stage} disposable project target`),
407
+ canonicalTarget
408
+ );
409
+ assert.equal(
410
+ canonicalTarget,
411
+ path.join(currentInstalledRoot, installedRelatives[source]),
412
+ `${platform} ${stage} target must remain at its exact native installed path`
413
+ );
414
+ assert.notEqual(canonicalTarget, sourcePaths[source], `${platform} ${stage} target must not be package source`);
415
+
416
+ const installedIdentity = fs.statSync(target);
417
+ const sourceIdentity = fs.statSync(sourcePaths[source]);
418
+ assert.ok(
419
+ installedIdentity.dev !== sourceIdentity.dev || installedIdentity.ino !== sourceIdentity.ino,
420
+ `${platform} ${stage} target must not share (dev, ino) with package source`
421
+ );
422
+ return target;
423
+ };
424
+ const writeInstalled = (source, content, stage) => {
425
+ const target = assertSafeInstalledTarget(source, stage);
426
+ fs.writeFileSync(target, content);
427
+ assertSourceHashes(`immediately after ${stage}`);
428
+ };
429
+ for (const source of Object.keys(installedPaths)) {
430
+ assertSafeInstalledTarget(source, `${source} context creation`);
431
+ }
432
+ return { installedPaths, assertSafeInstalledTarget, writeInstalled, assertSourceHashes };
433
+ }
434
+
435
+ test('installed mutation guard rejects post-context symlink and hardlink before source bytes change', () => {
436
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-package-matrix-'));
437
+ const trackedSources = {
438
+ templates: path.join(PACKAGE_ROOT, 'src/claude/skills/specs/references/templates.md'),
439
+ review: path.join(PACKAGE_ROOT, 'src/claude/skills/specs/references/review.md'),
440
+ };
441
+ const trackedHashes = Object.fromEntries(
442
+ Object.entries(trackedSources).map(([source, sourcePath]) => [source, sha256(fs.readFileSync(sourcePath))])
443
+ );
444
+ try {
445
+ for (const topology of ['symlink', 'hardlink']) {
446
+ const packageRoot = path.join(root, `${topology}-package-source`);
447
+ const project = path.join(root, `${topology}-project`);
448
+ const sourceDirectory = path.join(packageRoot, 'src/claude/skills/specs/references');
449
+ const installedDirectory = path.join(project, RUNTIMES.claude.specs, 'references');
450
+ fs.mkdirSync(sourceDirectory, { recursive: true });
451
+ fs.mkdirSync(installedDirectory, { recursive: true });
452
+ for (const file of ['templates.md', 'review.md']) {
453
+ fs.writeFileSync(path.join(sourceDirectory, file), `${topology} disposable source ${file}\n`);
454
+ fs.writeFileSync(path.join(installedDirectory, file), `${topology} disposable installed ${file}\n`);
455
+ }
456
+
457
+ const context = installedMutationContext(root, project, 'claude', packageRoot);
458
+ const sourcePath = path.join(sourceDirectory, 'templates.md');
459
+ const target = context.installedPaths.templates;
460
+ const sourceBytes = fs.readFileSync(sourcePath);
461
+ const sourceHash = sha256(sourceBytes);
462
+ fs.unlinkSync(target);
463
+ if (topology === 'symlink') {
464
+ fs.symlinkSync(sourcePath, target);
465
+ assert.equal(fs.lstatSync(target).isSymbolicLink(), true, 'symlink control must change target topology');
466
+ } else {
467
+ fs.linkSync(sourcePath, target);
468
+ const sourceIdentity = fs.statSync(sourcePath);
469
+ const installedIdentity = fs.statSync(target);
470
+ assert.deepEqual(
471
+ [installedIdentity.dev, installedIdentity.ino],
472
+ [sourceIdentity.dev, sourceIdentity.ino],
473
+ 'hardlink control must share source identity'
474
+ );
475
+ }
476
+
477
+ assert.throws(
478
+ () => context.writeInstalled('templates', 'forbidden installed mutation\n', `${topology} regression`),
479
+ topology === 'symlink' ? /regular non-symlink file/ : /must not share \(dev, ino\)/
480
+ );
481
+ assert.deepEqual(fs.readFileSync(sourcePath), sourceBytes, `${topology} rejection must preserve source bytes`);
482
+ assert.equal(sha256(fs.readFileSync(sourcePath)), sourceHash, `${topology} rejection must preserve source SHA`);
483
+ context.assertSourceHashes(`after rejected ${topology} write`);
484
+ }
485
+ for (const [source, sourcePath] of Object.entries(trackedSources)) {
486
+ assert.equal(
487
+ sha256(fs.readFileSync(sourcePath)),
488
+ trackedHashes[source],
489
+ `tracked PACKAGE_ROOT ${source} SHA must remain unchanged`
490
+ );
491
+ }
492
+ } finally {
493
+ fs.rmSync(root, { recursive: true, force: true });
494
+ }
495
+ });
496
+
497
+ function assertInstalledReadinessMutationsDetected(tempRoot, project, platform) {
498
+ const context = installedMutationContext(tempRoot, project, platform);
499
+ const baseline = Object.fromEntries(
500
+ Object.entries(context.installedPaths).map(([source, target]) => [source, fs.readFileSync(target, 'utf8')])
501
+ );
502
+ const apiBoundary = IMPLEMENTATION_READINESS_BOUNDARY_ROWS.find(([label]) => label === 'API/CLI');
503
+ assert.ok(apiBoundary, 'API/CLI boundary contract must exist');
504
+ const mutations = [
505
+ {
506
+ name: 'no-invention-blocking', issue: 'no-invention', source: 'templates',
507
+ from: IMPLEMENTATION_READINESS_CLAUSES.noInvention,
508
+ to: 'Before implementation handoff, note ambiguous choices without blocking handoff.',
509
+ },
510
+ {
511
+ name: 'api-success-and-error-semantics', issue: 'boundary-contract', source: 'templates',
512
+ from: `| ${apiBoundary[0]} | ${apiBoundary[1]} |`,
513
+ to: `| ${apiBoundary[0]} | ${apiBoundary[1].replace('success output; ', '').replace('error/status/exit; ', '')} |`,
514
+ },
515
+ {
516
+ name: 'concrete-named-probe', issue: 'proof-chain', source: 'templates',
517
+ from: IMPLEMENTATION_READINESS_CLAUSES.proofPlanLines[1],
518
+ to: '- Named probe: <suite label>',
519
+ },
520
+ {
521
+ name: 'artifact-path-and-digest-or-ephemeral', issue: 'proof-chain', source: 'templates',
522
+ from: IMPLEMENTATION_READINESS_CLAUSES.proofPlanLines[5],
523
+ to: '- Artifacts: <required artifact path, or none>',
524
+ },
525
+ {
526
+ name: 'distinct-repair-and-proof-evidence', issue: 'repair-closure', source: 'review',
527
+ from: IMPLEMENTATION_READINESS_CLAUSES.distinctRepairProof,
528
+ to: '`Repaired at` and `Proved at` may cite the same repair edit.',
529
+ },
530
+ {
531
+ name: 'crash-versus-catchable-failure', issue: 'failure-semantics', source: 'templates',
532
+ from: IMPLEMENTATION_READINESS_CLAUSES.failureSemantics,
533
+ to: 'Crash and catchable failure both mean an error occurred.',
534
+ },
535
+ {
536
+ name: 'privacy-identifier-surface', issue: 'privacy-identifiers', source: 'review',
537
+ from: IMPLEMENTATION_READINESS_CLAUSES.privacyIdentifiers,
538
+ to: 'Any privacy/security claim names the sensitive data at risk.',
539
+ },
540
+ ];
541
+ assertInstalledSpecsReadiness(project, platform);
542
+ for (const mutation of mutations) {
543
+ const anchorIndex = baseline[mutation.source].indexOf(mutation.from);
544
+ assert.notEqual(anchorIndex, -1, `${platform} ${mutation.name} mutation anchor must exist`);
545
+ assert.equal(
546
+ baseline[mutation.source].indexOf(mutation.from, anchorIndex + mutation.from.length),
547
+ -1,
548
+ `${platform} ${mutation.name} mutation anchor must be unique`
549
+ );
550
+ const weakened = `${baseline[mutation.source].slice(0, anchorIndex)}${mutation.to}${baseline[mutation.source].slice(anchorIndex + mutation.from.length)}`;
551
+ try {
552
+ context.writeInstalled(mutation.source, weakened, `${mutation.name} mutation`);
553
+ assertInstalledSpecsReadiness(project, platform, [mutation.issue]);
554
+ } finally {
555
+ context.writeInstalled(mutation.source, baseline[mutation.source], `${mutation.name} restoration`);
556
+ }
557
+ assertInstalledSpecsReadiness(project, platform);
558
+ }
559
+ }
560
+
561
+ function assertPristineUpgradeAndUserPreservation(installer, tempRoot, project, platform) {
562
+ const runtime = RUNTIMES[platform];
563
+ const context = installedMutationContext(tempRoot, project, platform);
564
+ const templatesPath = context.installedPaths.templates;
565
+ const manifestPath = path.join(project, runtime.manifest);
566
+ const metadataPath = path.join(project, runtime.root, 'cafekit.json');
567
+ const baseline = fs.readFileSync(templatesPath, 'utf8');
568
+ const weakened = baseline.replace(NO_INVENTION_ANCHOR, '');
569
+ assert.notEqual(weakened, baseline, `${platform} old fixture mutation must apply`);
570
+ try {
571
+ context.writeInstalled('templates', weakened, 'pristine-upgrade mutation');
572
+ const oldManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
573
+ assert.ok(oldManifest.files[runtime.templatesManifestPath], `${platform} templates ownership record missing`);
574
+ oldManifest.files[runtime.templatesManifestPath] = {
575
+ ...oldManifest.files[runtime.templatesManifestPath], sha256: sha256(weakened), version: OLD_FIXTURE_VERSION
576
+ };
577
+ writeJson(manifestPath, oldManifest);
578
+ const oldMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
579
+ oldMetadata.version = OLD_FIXTURE_VERSION;
580
+ writeJson(metadataPath, oldMetadata);
581
+
582
+ runInstaller(installer, project, [platform], null);
583
+ context.assertSourceHashes('immediately after pristine-upgrade installer');
584
+ context.assertSafeInstalledTarget('templates', 'after pristine-upgrade installer');
585
+ assert.equal(fs.readFileSync(templatesPath, 'utf8'), baseline, `${platform} pristine old gate must upgrade`);
586
+ assert.equal(JSON.parse(fs.readFileSync(metadataPath, 'utf8')).version, PACKAGE_VERSION);
587
+ const upgradedManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
588
+ assert.equal(upgradedManifest.files[runtime.templatesManifestPath].sha256, sha256(baseline));
589
+ assert.equal(upgradedManifest.files[runtime.templatesManifestPath].version, PACKAGE_VERSION);
590
+ assertInstalledSpecsReadiness(project, platform);
591
+
592
+ context.writeInstalled('templates', weakened, 'user-preservation mutation after installer');
593
+ const manifestBeforePreservedRerun = fs.readFileSync(manifestPath, 'utf8');
594
+ const preserved = runInstaller(installer, project, [platform], null);
595
+ context.assertSourceHashes('immediately after user-preservation installer');
596
+ context.assertSafeInstalledTarget('templates', 'after user-preservation installer');
597
+ assert.equal(fs.readFileSync(templatesPath, 'utf8'), weakened, `${platform} user edit must be preserved`);
598
+ assert.equal(fs.readFileSync(manifestPath, 'utf8'), manifestBeforePreservedRerun, `${platform} user-modified manifest must stay unchanged`);
599
+ assert.match(`${preserved.stdout}\n${preserved.stderr}`, /preserved \(user-modified\): Skill: specs/);
600
+ assertInstalledSpecsReadiness(project, platform, ['no-invention']);
601
+ } finally {
602
+ context.writeInstalled('templates', baseline, 'upgrade and user-preservation restoration');
603
+ }
604
+ assertInstalledSpecsReadiness(project, platform);
134
605
  }
135
606
 
136
607
  function createProvenanceFixture(root) {
@@ -592,10 +1063,11 @@ test('entry accepts only the boolean true discriminator', () => {
592
1063
  }
593
1064
 
594
1065
  function taskProjection({ id, title, ownedPath, criterion, verificationRef, role, artifact = false }) {
1066
+ const artifactPath = `artifacts/${id}.json`;
595
1067
  const anchors = typedAnchorTable([
596
1068
  { id: `A-${id}-01`, type: 'file', target: ownedPath, role: 'owner', access: 'write', action: 'modify' },
597
1069
  ...(artifact ? [{
598
- id: `A-${id}-02`, type: 'artifact', target: `artifacts/${id}.json`,
1070
+ id: `A-${id}-02`, type: 'artifact', target: artifactPath,
599
1071
  role: 'verifier', access: 'write', action: 'create',
600
1072
  }] : []),
601
1073
  {
@@ -636,7 +1108,7 @@ ${anchors}
636
1108
  - **Verification ref:** ${verificationRef}
637
1109
  - **Task role:** ${role}
638
1110
  - **Command:** \`node --test test/entry.test.js\`
639
- - **Expected:** Exit code 0 and the criterion-specific state is observed.
1111
+ - **Expected:** Exit code 0 and the criterion-specific state is observed.${artifact ? ` The artifact \`${artifactPath}\` must exist, and SHA-256 over its current bytes must match the recorded digest.` : ''}
640
1112
  - **Negative path:** Invalid input returns the named rejected state.
641
1113
  - **Reachability:** \`src/entry.js\` is reached by the installed test command.
642
1114
  `;
@@ -647,7 +1119,7 @@ function createTaskFixture(paths, root, feature) {
647
1119
  const fixture = materializeSpecState(paths, root, feature);
648
1120
  fs.writeFileSync(path.join(fixture.featureDir, 'requirements.md'), requirementsProjection({ proof: true }));
649
1121
  fs.writeFileSync(path.join(fixture.featureDir, 'design.md'), designProjection({
650
- expected: 'exit 0, status enabled, and verifier artifact `artifacts/R1-02.json`',
1122
+ expected: 'exit 0, status enabled, and verifier artifact `artifacts/R1-02.json` whose SHA-256 over current bytes matches the recorded digest',
651
1123
  taskful: true,
652
1124
  }));
653
1125
  const taskOne = 'tasks/task-R1-01-implement.md';
@@ -684,7 +1156,7 @@ function createTaskFixture(paths, root, feature) {
684
1156
  }, {
685
1157
  criterion: 'R1.2', case_kind: 'failure',
686
1158
  scenario: 'The verification command completes without producing its proof artifact.',
687
- expected: 'The verification criterion remains unsatisfied until the artifact exists.',
1159
+ expected: 'The verification criterion remains unsatisfied until `artifacts/R1-02.json` exists and its SHA-256 over current bytes matches the recorded digest.',
688
1160
  decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
689
1161
  }]);
690
1162
  return fixture;
@@ -858,6 +1330,11 @@ test('packed tarball installer matrix proves locale, transforms, paths, and reru
858
1330
  assertHookLanguage(project, platform, lang, `${project}-${matrixCase.name}-${lang || 'none'}`);
859
1331
  assertInstalledScripts(project, platform);
860
1332
  assertTransforms(project, platform);
1333
+ assertInstalledSpecsReadiness(project, platform);
1334
+ if (matrixCase.platforms.length === 1 && lang === null) {
1335
+ assertInstalledReadinessMutationsDetected(root, project, platform);
1336
+ assertPristineUpgradeAndUserPreservation(installer, root, project, platform);
1337
+ }
861
1338
  }
862
1339
  if (matrixCase.platforms.includes('claude') && matrixCase.platforms.includes('codex')) {
863
1340
  assertCombinedInstructionIsolation(project);
@@ -871,6 +1348,7 @@ test('packed tarball installer matrix proves locale, transforms, paths, and reru
871
1348
  const changed = [...new Set([...Object.keys(before), ...Object.keys(after)])]
872
1349
  .filter((file) => !before[file] || !after[file] || !before[file].equals(after[file]));
873
1350
  assert.deepEqual(changed, [], `${matrixCase.name} rerun must be byte-idempotent`);
1351
+ for (const platform of matrixCase.platforms) assertInstalledSpecsReadiness(project, platform);
874
1352
  assertCombinedInstructionIsolation(project);
875
1353
  assert.match(fs.readFileSync(path.join(project, 'AGENTS.md'), 'utf8'), /User matrix note\nKeep this exact\./);
876
1354
  assert.match(fs.readFileSync(path.join(project, 'CLAUDE.md'), 'utf8'), /User Claude matrix note\nKeep this exact\./);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haposoft/cafekit",
3
- "version": "0.16.0-rc.7",
3
+ "version": "0.16.0-rc.8",
4
4
  "description": "Native spec-driven workflows for Claude Code and Codex CLI. Bundles CafeKit skills, runtime hooks, agents, and installer scaffolding.",
5
5
  "author": "Haposoft <nghialt@haposoft.com>",
6
6
  "license": "MIT",
@@ -36,7 +36,7 @@ repeat scope objections later unless new evidence invalidates the decision.
36
36
 
37
37
  ### 3. Author the flat packet
38
38
 
39
- Read `skills/specs/references/templates.md`, then create:
39
+ Read `skills/specs/references/templates.md`, then create its marked flat packet:
40
40
 
41
41
  ```text
42
42
  specs/<feature>/plan.md
@@ -50,8 +50,8 @@ Verification Plan. Every criterion maps to at least one task and one proof.
50
50
  Use EARS sentences for observable acceptance behavior.
51
51
 
52
52
  Do not create implementation files, receipts, approval records, generated
53
- registries, readiness claims, or empty supporting documents. Leave every new
54
- task `Status: pending` and its `## Receipt` empty.
53
+ registries, readiness claims, or empty supporting documents. Keep every new
54
+ task `Status: blocked` while C2 is open, and keep its `## Receipt` empty.
55
55
 
56
56
  ### 4. Review from fresh context
57
57
 
@@ -62,14 +62,20 @@ repair for every finding.
62
62
 
63
63
  Deduplicate and cap findings at 15. Open C2 so the user can accept, reject, or
64
64
  revise each one. Apply only accepted changes and run the full consistency sweep
65
- after every edit. Stop after two paper rounds; later findings need runtime
66
- evidence.
65
+ after every edit. Then derive every task state, not only the first candidate:
66
+ `pending` means semantically ready for the dependency-aware queue. Keep a task
67
+ `blocked` while a C1/C2 decision, accepted finding, or `UNKNOWN` closure remains
68
+ open. A named task dependency alone does not make it blocked; write dependencies
69
+ as exact flat task basenames and let the resolver derive the next pending task.
70
+ Move `blocked` to `pending` only when current evidence closes every non-dependency
71
+ blocker. Stop after two paper rounds; later findings need runtime evidence.
67
72
 
68
73
  ### 5. Hand off without dispatch
69
74
 
70
75
  Report the created files, C1 decision, accepted C2 findings, remaining
71
- uncertainties, task order, and first unblocked task. Do not start Develop or
72
- claim implementation readiness. The user chooses when execution begins.
76
+ uncertainties, every task status, and the first pending task.
77
+ Do not start Develop or treat dispatchability as user authorization. The user
78
+ chooses when execution begins.
73
79
 
74
80
  ## Authoring constraints
75
81
 
@@ -91,6 +97,6 @@ Status: DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
91
97
  Files: <plan and task paths>
92
98
  C1: <decision>
93
99
  C2: <accepted/rejected/revised counts>
94
- Next: <first unblocked task or blocker>
100
+ Next: <first `Status: pending` task or blocker>
95
101
  Unresolved questions: <none or concise list>
96
102
  ```