peaks-loop 4.0.49 → 4.0.50

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 (119) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/audit-commands.js +1 -0
  5. package/dist/cli/commands/baseline-commands.js +163 -25
  6. package/dist/cli/commands/core/skill-command.js +53 -4
  7. package/dist/cli/commands/core/standards-command.d.ts +24 -0
  8. package/dist/cli/commands/core/standards-command.js +74 -0
  9. package/dist/cli/commands/hooks-commands.js +55 -38
  10. package/dist/cli/commands/share-commands.js +37 -11
  11. package/dist/cli/commands/web-commands.js +8 -1
  12. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +6 -0
  13. package/dist/cli/commands/workflow-lifecycle-commands.js +64 -3
  14. package/dist/services/adapter/adapter.d.ts +30 -0
  15. package/dist/services/adapter/auto-adapter.d.ts +13 -0
  16. package/dist/services/adapter/claude-adapter.js +12 -0
  17. package/dist/services/adapter/codex-adapter.d.ts +12 -0
  18. package/dist/services/adapter/codex-adapter.js +12 -0
  19. package/dist/services/adapter/copilot-adapter.d.ts +12 -0
  20. package/dist/services/adapter/copilot-adapter.js +12 -0
  21. package/dist/services/audit/backing-detector.d.ts +25 -7
  22. package/dist/services/audit/backing-detector.js +33 -17
  23. package/dist/services/audit/enforcer-liveness.d.ts +12 -0
  24. package/dist/services/audit/enforcer-liveness.js +100 -0
  25. package/dist/services/audit/enforcers/lint-catalog-governance.d.ts +23 -11
  26. package/dist/services/audit/enforcers/lint-catalog-governance.js +10 -14
  27. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.d.ts +5 -15
  28. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.js +94 -25
  29. package/dist/services/audit/enforcers/lint-style.d.ts +9 -1
  30. package/dist/services/audit/enforcers/lint-style.js +38 -2
  31. package/dist/services/audit/prose-ratio-calculator.d.ts +28 -17
  32. package/dist/services/audit/prose-ratio-calculator.js +25 -18
  33. package/dist/services/audit/red-line-catalog-p2-a.js +1 -1
  34. package/dist/services/audit/red-lines-service.js +51 -7
  35. package/dist/services/capability-audit-service/independent-checker.d.ts +15 -0
  36. package/dist/services/capability-audit-service/independent-checker.js +140 -0
  37. package/dist/services/capability-audit-service/index.d.ts +3 -1
  38. package/dist/services/capability-audit-service/index.js +1 -0
  39. package/dist/services/capability-audit-service/runner.d.ts +17 -13
  40. package/dist/services/capability-audit-service/runner.js +76 -15
  41. package/dist/services/capability-audit-service/types.d.ts +48 -0
  42. package/dist/services/capability-guard-runner/contracts/J01.js +21 -22
  43. package/dist/services/capability-guard-runner/contracts/J02.d.ts +1 -1
  44. package/dist/services/capability-guard-runner/contracts/J02.js +114 -28
  45. package/dist/services/capability-guard-runner/contracts/J03.d.ts +13 -0
  46. package/dist/services/capability-guard-runner/contracts/J03.js +72 -21
  47. package/dist/services/capability-guard-runner/contracts/J04.d.ts +6 -0
  48. package/dist/services/capability-guard-runner/contracts/J04.js +65 -32
  49. package/dist/services/capability-guard-runner/contracts/J05.js +118 -16
  50. package/dist/services/capability-guard-runner/contracts/J06.d.ts +14 -0
  51. package/dist/services/capability-guard-runner/contracts/J06.js +57 -39
  52. package/dist/services/capability-guard-runner/contracts/J07.d.ts +9 -0
  53. package/dist/services/capability-guard-runner/contracts/J07.js +76 -47
  54. package/dist/services/capability-guard-runner/contracts/J08.d.ts +11 -0
  55. package/dist/services/capability-guard-runner/contracts/J08.js +66 -39
  56. package/dist/services/capability-guard-runner/contracts/J09.d.ts +13 -0
  57. package/dist/services/capability-guard-runner/contracts/J09.js +95 -39
  58. package/dist/services/capability-guard-runner/contracts/J10.d.ts +12 -0
  59. package/dist/services/capability-guard-runner/contracts/J10.js +69 -35
  60. package/dist/services/capability-guard-runner/contracts/J11.d.ts +8 -0
  61. package/dist/services/capability-guard-runner/contracts/J11.js +73 -33
  62. package/dist/services/capability-guard-runner/contracts/J12.d.ts +12 -0
  63. package/dist/services/capability-guard-runner/contracts/J12.js +66 -30
  64. package/dist/services/capability-guard-runner/contracts/J13.d.ts +11 -0
  65. package/dist/services/capability-guard-runner/contracts/J13.js +62 -40
  66. package/dist/services/capability-guard-runner/contracts/J14.d.ts +11 -0
  67. package/dist/services/capability-guard-runner/contracts/J14.js +60 -31
  68. package/dist/services/capability-guard-runner/contracts/J15.d.ts +11 -0
  69. package/dist/services/capability-guard-runner/contracts/J15.js +70 -35
  70. package/dist/services/capability-guard-runner/contracts/_shared.d.ts +24 -0
  71. package/dist/services/capability-guard-runner/contracts/_shared.js +67 -0
  72. package/dist/services/capability-guard-runner/registry.d.ts +5 -0
  73. package/dist/services/capability-guard-runner/registry.js +140 -0
  74. package/dist/services/capability-guard-runner/runner.d.ts +26 -0
  75. package/dist/services/capability-guard-runner/runner.js +63 -6
  76. package/dist/services/code/auto-compact-modes.d.ts +13 -2
  77. package/dist/services/code/auto-compact-modes.js +20 -4
  78. package/dist/services/code/post-compact-detector.js +20 -11
  79. package/dist/services/code/step-08-gate.js +21 -6
  80. package/dist/services/config/config-safety.js +11 -9
  81. package/dist/services/final-review/pre-post-diff.js +10 -2
  82. package/dist/services/observability/observability-service.d.ts +1 -1
  83. package/dist/services/scan/api-diff-types.js +20 -2
  84. package/dist/services/security/safe-settings-path.js +19 -1
  85. package/dist/services/skill/skill-search-service.d.ts +3 -3
  86. package/dist/services/standards/loop-engineering-lint.d.ts +1 -1
  87. package/dist/services/standards/loop-engineering-lint.js +6 -0
  88. package/dist/services/web/daemon-registry.js +27 -2
  89. package/dist/services/workspace/claude-settings-template.d.ts +53 -37
  90. package/dist/services/workspace/claude-settings-template.js +105 -83
  91. package/dist/services/workspace/generated-artifacts-stamp.d.ts +119 -0
  92. package/dist/services/workspace/generated-artifacts-stamp.js +167 -0
  93. package/dist/services/workspace/workspace-claude-settings-materializer.d.ts +8 -0
  94. package/dist/services/workspace/workspace-claude-settings-materializer.js +38 -3
  95. package/dist/services/workspace/workspace-service.js +11 -1
  96. package/dist/shared/fs-utils.d.ts +26 -0
  97. package/dist/shared/fs-utils.js +35 -0
  98. package/package.json +9 -7
  99. package/scripts/copy-templates.mjs +0 -12
  100. package/scripts/install-skills.mjs +154 -53
  101. package/skills/bee/peaks-qa/SKILL.md +0 -1
  102. package/skills/bee/peaks-rd/SKILL.md +0 -1
  103. package/skills/peaks-code/SKILL.md +12 -10
  104. package/skills/peaks-code/references/periodic-checkpoint.md +2 -2
  105. package/skills/peaks-code/references/runbook.md +3 -0
  106. package/skills/peaks-code/references/session-overload-signal-index.md +4 -2
  107. package/skills/peaks-code/references/startup-sequence.md +2 -2
  108. package/skills/peaks-code/references/step-0-8-gate.md +1 -1
  109. package/skills/peaks-code/references/sub-agent-dispatch.md +19 -19
  110. package/dist/cli/commands/context-builder-commands.d.ts +0 -11
  111. package/dist/cli/commands/context-builder-commands.js +0 -85
  112. package/dist/services/hooks/write-gate.js +0 -111
  113. package/skills/bee/peaks-prd/references/command-migration.md +0 -3
  114. package/skills/bee/peaks-qa/references/command-migration.md +0 -3
  115. package/skills/bee/peaks-rd/references/command-migration.md +0 -3
  116. package/skills/bee/peaks-sc/references/command-migration.md +0 -3
  117. package/skills/bee/peaks-txt/references/command-migration.md +0 -3
  118. package/skills/bee/peaks-ui/references/command-migration.md +0 -3
  119. package/skills/peaks-code/references/command-migration.md +0 -3
@@ -1,43 +1,61 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- // v2: existence is the primary check; the single fragment is best-effort.
4
- // v1 required 3 strict fragments (deepestGate / resume / etc.) and was too strict for
5
- // 4.0.8 — the actual resume service uses camelCase variants and lives under session/.
6
- const CANDIDATE_FILES = [
7
- 'src/services/resume/resume-service.ts',
8
- 'src/services/resume/resume-types.ts',
9
- 'src/services/session/session-resume-service.ts',
10
- 'src/services/session/session-checkpoint-service.ts'
11
- ];
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { classifyResume } from '../../skill/resume-detector.js';
5
+ import { combineProbes, fail, missingSourceFiles, pass, probe, requireBaselineRow } from './_shared.js';
6
+ const SESSION_ID = '2026-09-15-guard-j06';
7
+ function write(sessionDir, relative, body) {
8
+ const abs = join(sessionDir, relative);
9
+ mkdirSync(dirname(abs), { recursive: true });
10
+ writeFileSync(abs, body);
11
+ }
12
+ function request(body) {
13
+ return `# request\n\n## Status\n\n- state: ${body}\n`;
14
+ }
15
+ /**
16
+ * Behavioural probe for "the resume option always identifies the DEEPEST
17
+ * completed gate".
18
+ *
19
+ * The load-bearing part is that the SAME fixture is walked from shallow to
20
+ * deep: with only a PRD request the classifier must stop at `rd-planning`;
21
+ * the moment a deeper RD `qa-handoff` request appears (with its review
22
+ * artifacts) the point must move to `qa-validation`; adding the txt handoff
23
+ * must end the workflow. A classifier that returned a constant point, or that
24
+ * looked at file existence alone, cannot track that progression.
25
+ *
26
+ * The old check asserted that a file named `session-resume-service.ts` exists
27
+ * and contains the word "resume" — which the filename already guarantees.
28
+ */
12
29
  export async function runJ06Contract(ctx) {
13
- const existing = CANDIDATE_FILES.filter((f) => existsSync(join(ctx.projectRoot, f)));
14
- if (existing.length === 0) {
15
- return {
16
- journeyId: 'J06',
17
- contract: 'workflow-trace',
18
- status: 'fail',
19
- diff: { before: 'a resume service file exists', after: 'none of the candidates found', reason: 'J06#1 broken: no resume service file is present' },
20
- artifactPath: 'src/services/resume/'
21
- };
30
+ const row = requireBaselineRow(ctx);
31
+ const missing = missingSourceFiles(ctx, row);
32
+ const root = mkdtempSync(join(tmpdir(), 'cbl-J06-'));
33
+ const peaksRoot = join(root, '_runtime');
34
+ const sessionDir = join(peaksRoot, SESSION_ID);
35
+ mkdirSync(sessionDir, { recursive: true });
36
+ try {
37
+ write(sessionDir, 'prd/requests/001-shallow.md', request('handed-off'));
38
+ const shallow = classifyResume(SESSION_ID, peaksRoot);
39
+ // `ridOf()` strips the `NNN-` index prefix, so the review artifacts are
40
+ // keyed by `deeper`, not `001-deeper`.
41
+ write(sessionDir, 'rd/requests/001-deeper.md', request('qa-handoff'));
42
+ write(sessionDir, 'rd/code-review-deeper.md', '# code review\n');
43
+ write(sessionDir, 'audit/security-deeper.md', '# security\n');
44
+ const deeper = classifyResume(SESSION_ID, peaksRoot);
45
+ write(sessionDir, 'txt/handoff.md', '# handoff\n');
46
+ const done = classifyResume(SESSION_ID, peaksRoot);
47
+ const result = combineProbes([
48
+ probe(missing.length === 0, `baseline sourceFiles present (${row.sourceFiles.length})`),
49
+ probe(shallow.kind === 'resume' && shallow.point === 'rd-planning', `shallow fixture resumes at rd-planning (saw kind=${shallow.kind} point=${String(shallow.point)})`),
50
+ probe(deeper.kind === 'resume' && deeper.point === 'qa-validation', `adding an RD qa-handoff request moves the point DEEPER to qa-validation (saw kind=${deeper.kind} point=${String(deeper.point)})`),
51
+ probe(done.kind === 'complete', `adding txt/handoff.md marks the workflow complete (saw kind=${done.kind} point=${String(done.point)})`)
52
+ ]);
53
+ const artifact = row.sourceFiles[0] ?? 'src/services/skill/resume-detector.ts';
54
+ if (result.ok)
55
+ return pass(ctx, artifact);
56
+ return fail(ctx, artifact, 'resume identifies the deepest completed gate (rd-planning → qa-validation → complete)', result.detail, 'J06 invariant broken: resume no longer tracks the deepest completed gate');
22
57
  }
23
- // Soft check: at least one existing file mentions "resume".
24
- const mentionsResume = existing.some((f) => {
25
- const src = readFileSync(join(ctx.projectRoot, f), 'utf8');
26
- return src.toLowerCase().includes('resume');
27
- });
28
- if (!mentionsResume) {
29
- return {
30
- journeyId: 'J06',
31
- contract: 'workflow-trace',
32
- status: 'fail',
33
- diff: { before: 'resume service files mention "resume"', after: 'none mention resume', reason: 'J06#1 broken: resume service files do not reference the resume concept' },
34
- artifactPath: existing[0]
35
- };
58
+ finally {
59
+ rmSync(root, { recursive: true, force: true });
36
60
  }
37
- return {
38
- journeyId: 'J06',
39
- contract: 'workflow-trace',
40
- status: 'pass',
41
- artifactPath: existing[0]
42
- };
43
61
  }
@@ -1,2 +1,11 @@
1
1
  import type { GuardContext, GuardRunResult } from '../types.js';
2
+ /**
3
+ * Behavioural probe of the (mtime, sha256) gate.
4
+ *
5
+ * A cache entry is written for a passing test, then the file's CONTENT is
6
+ * changed while its mtime is restored to the original value. If the gate ever
7
+ * degraded to an mtime-only (or filename-only) check, the cache would report a
8
+ * hit for a file whose bytes changed — exactly the fake-green the invariant
9
+ * forbids. The mtime-only and status gates are probed the same way.
10
+ */
2
11
  export declare function runJ07Contract(ctx: GuardContext): Promise<GuardRunResult>;
@@ -1,54 +1,83 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- // v2: existence-of-CLI + source-contains-vitest, not actual invocation.
5
- // v1 failed with `spawn vitest ENOENT` because the test runner spawns a child vitest
6
- // that needs `pnpm`/PATH configured; the test fixture doesn't have that. A dry-run
7
- // probe is enough to prove the `peaks test` subcommand is registered and the runner
8
- // is wired to vitest, without actually executing a vitest child process.
9
- const TEST_COMMAND_FILES = [
10
- 'src/cli/commands/test-commands.ts',
11
- 'src/services/test/',
12
- 'src/services/test-runner/'
13
- ];
14
- export async function runJ07Contract(ctx) {
15
- // 1. CLI probe: `peaks test --help` must produce non-empty output.
16
- let helpOk = false;
4
+ import { TEST_CACHE_DIR, isCacheable, mtimeOfFile, readTestCache, recordTestResult, sha256OfFile, testCacheDir, writeTestCache } from '../../test-cache/test-cache-service.js';
5
+ import { combineProbes, fail, missingSourceFiles, pass, probe, requireBaselineRow } from './_shared.js';
6
+ const TEST_NAME = 'fingerprint probe';
7
+ /** Highest vitest major the frozen baseline allows ("MUST NOT be locked at 5.x"). */
8
+ const MAX_VITEST_MAJOR = 4;
9
+ function vitestMajor(projectRoot) {
17
10
  try {
18
- const bin = join(ctx.projectRoot, 'bin', 'peaks.js');
19
- const out = execFileSync('node', [bin, 'test', '--help'], {
20
- cwd: ctx.projectRoot,
21
- env: { ...process.env, PEAKS_CALLER_ID: `guard-J07-${ctx.sessionId}` },
22
- stdio: ['ignore', 'pipe', 'pipe'],
23
- windowsHide: true
24
- }).toString('utf8');
25
- helpOk = out.length > 0 && !out.includes('unknown command');
11
+ const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
12
+ const raw = pkg.devDependencies?.['vitest'] ?? pkg.dependencies?.['vitest'];
13
+ if (typeof raw !== 'string')
14
+ return null;
15
+ const major = /(\d+)/.exec(raw);
16
+ return major === null ? null : Number(major[1]);
26
17
  }
27
18
  catch {
28
- helpOk = false;
19
+ return null;
20
+ }
21
+ }
22
+ /**
23
+ * Behavioural probe of the (mtime, sha256) gate.
24
+ *
25
+ * A cache entry is written for a passing test, then the file's CONTENT is
26
+ * changed while its mtime is restored to the original value. If the gate ever
27
+ * degraded to an mtime-only (or filename-only) check, the cache would report a
28
+ * hit for a file whose bytes changed — exactly the fake-green the invariant
29
+ * forbids. The mtime-only and status gates are probed the same way.
30
+ */
31
+ export async function runJ07Contract(ctx) {
32
+ const row = requireBaselineRow(ctx);
33
+ const missing = missingSourceFiles(ctx, row);
34
+ const root = mkdtempSync(join(tmpdir(), 'cbl-J07-'));
35
+ const file = join(root, 'probe.test.ts');
36
+ try {
37
+ writeFileSync(file, 'export const a = 1;\n');
38
+ recordTestResult(root, file, 'vitest', {
39
+ testName: TEST_NAME,
40
+ status: 'passed',
41
+ durationMs: 1,
42
+ lastRun: new Date().toISOString()
43
+ });
44
+ const baseline = isCacheable(root, file, TEST_NAME);
45
+ const cached = readTestCache(root, file);
46
+ // Gate 1, isolated: the file's bytes change but the recorded mtime is
47
+ // up-to-date, so ONLY the sha check can catch it. If the gate degraded to
48
+ // an mtime-only check this reports a hit for a file nobody verified.
49
+ writeFileSync(file, 'export const a = 2;\n');
50
+ writeTestCache(root, { ...cached, fileMtime: mtimeOfFile(file) });
51
+ const afterSilentEdit = isCacheable(root, file, TEST_NAME);
52
+ // Gate 2, isolated: the sha is up-to-date but the recorded mtime is stale.
53
+ writeTestCache(root, { ...cached, fileSha256: sha256OfFile(file), fileMtime: cached.fileMtime - 5_000 });
54
+ const afterTouch = isCacheable(root, file, TEST_NAME);
55
+ // A non-passing status must never be served as a hit.
56
+ recordTestResult(root, file, 'vitest', {
57
+ testName: 'skipped probe',
58
+ status: 'skipped',
59
+ durationMs: 0,
60
+ lastRun: new Date().toISOString()
61
+ });
62
+ const skipped = isCacheable(root, file, 'skipped probe');
63
+ const unknown = isCacheable(root, file, 'never recorded');
64
+ const major = vitestMajor(ctx.projectRoot);
65
+ const result = combineProbes([
66
+ probe(missing.length === 0, `baseline sourceFiles present (${row.sourceFiles.length})`),
67
+ probe(baseline.hit, `an unchanged passing test is a cache hit (reason=${String(baseline.reason)})`),
68
+ probe(!afterSilentEdit.hit && afterSilentEdit.reason === 'sha-changed', `a same-mtime content edit is NOT a hit (hit=${String(afterSilentEdit.hit)} reason=${String(afterSilentEdit.reason)})`),
69
+ probe(!afterTouch.hit && afterTouch.reason === 'mtime-changed', `a touched file is NOT a hit (hit=${String(afterTouch.hit)} reason=${String(afterTouch.reason)})`),
70
+ probe(!skipped.hit && skipped.reason === 'previous-skipped', `a previously skipped test is NOT a hit (hit=${String(skipped.hit)} reason=${String(skipped.reason)})`),
71
+ probe(!unknown.hit && unknown.reason === 'no-cache', `an unrecorded test is NOT a hit (reason=${String(unknown.reason)})`),
72
+ probe(testCacheDir(ctx.projectRoot).replace(/\\/g, '/').endsWith(`.peaks/_runtime/${TEST_CACHE_DIR}`), `the cache writes under .peaks/_runtime/${TEST_CACHE_DIR} (got ${testCacheDir(ctx.projectRoot)})`),
73
+ probe(major !== null && major <= MAX_VITEST_MAJOR, `vitest major is <= ${String(MAX_VITEST_MAJOR)} (saw ${String(major)})`)
74
+ ]);
75
+ const artifact = row.sourceFiles[1] ?? 'src/services/test-cache/test-cache-service.ts';
76
+ if (result.ok)
77
+ return pass(ctx, artifact);
78
+ return fail(ctx, artifact, 'the per-test fingerprint cache only serves a hit for a file whose mtime AND sha256 are unchanged and whose last status passed', result.detail, 'J07 invariant broken: the fingerprint cache can return passed for an unverified file');
29
79
  }
30
- // 2. Source probe: at least one test command file references vitest.
31
- const sourceMentionsVitest = TEST_COMMAND_FILES.some((f) => {
32
- const abs = join(ctx.projectRoot, f);
33
- if (!existsSync(abs))
34
- return false;
35
- if (existsSync(abs) && readFileSync(abs, 'utf8').includes('vitest'))
36
- return true;
37
- return false;
38
- });
39
- // 3. package.json probe: vitest is in dependencies.
40
- const pkgPath = join(ctx.projectRoot, 'package.json');
41
- let pkgHasVitest = false;
42
- if (existsSync(pkgPath)) {
43
- const pkg = readFileSync(pkgPath, 'utf8');
44
- pkgHasVitest = /"vitest"\s*:/i.test(pkg);
80
+ finally {
81
+ rmSync(root, { recursive: true, force: true });
45
82
  }
46
- const ok = helpOk && sourceMentionsVitest && pkgHasVitest;
47
- return {
48
- journeyId: 'J07',
49
- contract: 'cli-output-golden',
50
- status: ok ? 'pass' : 'fail',
51
- ...(ok ? {} : { diff: { before: 'peaks test --help works, test commands reference vitest, package.json has vitest', after: `helpOk=${helpOk} sourceMentionsVitest=${sourceMentionsVitest} pkgHasVitest=${pkgHasVitest}`, reason: 'J07#1 broken: peaks test does not delegate to vitest' } }),
52
- artifactPath: 'src/cli/commands/test-commands.ts'
53
- };
54
83
  }
@@ -1,2 +1,13 @@
1
1
  import type { GuardContext, GuardRunResult } from '../types.js';
2
+ /**
3
+ * Behavioural probe of the crystallization gate.
4
+ *
5
+ * Before this the contract only asserted that one of three candidate files
6
+ * exists and mentions "crystalliz"/"sediment"/"promot" — the file it found is
7
+ * named `crystallization-service.ts`, so the check restated its own filename.
8
+ *
9
+ * Here the real gate is driven with a completed and an incomplete task state:
10
+ * a task whose `gates_passed` is false must be refused at the Zod boundary AND
11
+ * again at the service boundary.
12
+ */
2
13
  export declare function runJ08Contract(ctx: GuardContext): Promise<GuardRunResult>;
@@ -1,43 +1,70 @@
1
- // src/services/capability-guard-runner/contracts/J08.ts
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- // v2: existence is the primary check; a single soft fragment is best-effort.
5
- // v1 required 3 strict fragments (regressionSkeptic/independentEval/promote) that
6
- // do not all appear in the actual 4.0.8 crystallization service.
7
- const CANDIDATE_FILES = [
8
- 'src/services/crystallization/crystallization-service.ts',
9
- 'src/services/asset-crystallize/crystallization-service.ts',
10
- 'src/services/evolution/evolution-service.ts'
11
- ];
1
+ import Database from 'better-sqlite3';
2
+ import { CrystallizationIntegrityError, CrystallizationService, CrystallizationTaskStateSchema } from '../../crystallization/crystallization-service.js';
3
+ import { combineProbes, fail, missingSourceFiles, pass, probe, requireBaselineRow } from './_shared.js';
4
+ /** Test doubles: `assertReady` never reaches any of them. */
5
+ function probeService() {
6
+ const noop = () => undefined;
7
+ const opts = {
8
+ loopReleaseSchema: { parse: (input) => input },
9
+ loopBeeRelationSchema: { parse: (input) => input, omit: () => ({ parse: (input) => input }) },
10
+ insertLoopRelease: noop,
11
+ insertLoopBeeRelation: noop
12
+ };
13
+ return new CrystallizationService(new Database(':memory:'), opts);
14
+ }
15
+ /**
16
+ * Behavioural probe of the crystallization gate.
17
+ *
18
+ * Before this the contract only asserted that one of three candidate files
19
+ * exists and mentions "crystalliz"/"sediment"/"promot" — the file it found is
20
+ * named `crystallization-service.ts`, so the check restated its own filename.
21
+ *
22
+ * Here the real gate is driven with a completed and an incomplete task state:
23
+ * a task whose `gates_passed` is false must be refused at the Zod boundary AND
24
+ * again at the service boundary.
25
+ */
12
26
  export async function runJ08Contract(ctx) {
13
- const existing = CANDIDATE_FILES.filter((f) => existsSync(join(ctx.projectRoot, f)));
14
- if (existing.length === 0) {
15
- return {
16
- journeyId: 'J08',
17
- contract: 'asset-roundtrip',
18
- status: 'fail',
19
- diff: { before: 'a crystallization/evolution service exists', after: 'no candidate found', reason: 'J08#1 broken: no sediment/promotion service is present' },
20
- artifactPath: 'src/services/crystallization/'
21
- };
27
+ const row = requireBaselineRow(ctx);
28
+ const missing = missingSourceFiles(ctx, row);
29
+ const ready = { task_id: 'guard-J08', task_status: 'completed', gates_passed: true, evidence_collected: true };
30
+ const schemaAcceptsReady = CrystallizationTaskStateSchema.safeParse(ready).success;
31
+ const schemaRejectsGatesFalse = !CrystallizationTaskStateSchema.safeParse({ ...ready, gates_passed: false }).success;
32
+ const schemaRejectsNotCompleted = !CrystallizationTaskStateSchema.safeParse({ ...ready, task_status: 'in_progress' }).success;
33
+ const schemaRejectsNoEvidence = !CrystallizationTaskStateSchema.safeParse({ ...ready, evidence_collected: false }).success;
34
+ const svc = probeService();
35
+ let serviceAcceptsReady = false;
36
+ try {
37
+ serviceAcceptsReady = svc.assertReady(ready).task_id === 'guard-J08';
22
38
  }
23
- // Soft check: at least one existing file mentions crystallization, sediment, or promotion.
24
- const primary = existing.some((f) => {
25
- const src = readFileSync(join(ctx.projectRoot, f), 'utf8').toLowerCase();
26
- return src.includes('crystallization') || src.includes('sediment') || src.includes('promot');
27
- });
28
- if (!primary) {
29
- return {
30
- journeyId: 'J08',
31
- contract: 'asset-roundtrip',
32
- status: 'fail',
33
- diff: { before: 'crystallization service references sediment/promotion', after: 'no service mentions any of them', reason: 'J08#1 broken: crystallization service is not wired to sediment' },
34
- artifactPath: existing[0]
35
- };
39
+ catch {
40
+ serviceAcceptsReady = false;
36
41
  }
37
- return {
38
- journeyId: 'J08',
39
- contract: 'asset-roundtrip',
40
- status: 'pass',
41
- artifactPath: existing[0]
42
- };
42
+ const refused = [];
43
+ const badStates = [
44
+ ['gates_passed=false', { ...ready, gates_passed: false }],
45
+ ['task_status=in_progress', { ...ready, task_status: 'in_progress' }],
46
+ ['evidence_collected=false', { ...ready, evidence_collected: false }]
47
+ ];
48
+ for (const [label, task] of badStates) {
49
+ try {
50
+ svc.assertReady(task);
51
+ }
52
+ catch (e) {
53
+ if (e instanceof CrystallizationIntegrityError)
54
+ refused.push(label);
55
+ }
56
+ }
57
+ const result = combineProbes([
58
+ probe(missing.length === 0, `baseline sourceFiles present (${row.sourceFiles.length})`),
59
+ probe(schemaAcceptsReady, 'a completed, gate-passed, evidenced task passes the schema'),
60
+ probe(schemaRejectsGatesFalse, 'gates_passed=false is refused by the schema'),
61
+ probe(schemaRejectsNotCompleted, 'task_status!=completed is refused by the schema'),
62
+ probe(schemaRejectsNoEvidence, 'evidence_collected=false is refused by the schema'),
63
+ probe(serviceAcceptsReady, 'the service accepts a ready task'),
64
+ probe(refused.length === 3, `the service refuses every non-passing gate state with CrystallizationIntegrityError (refused: ${refused.join(', ') || 'none'})`)
65
+ ]);
66
+ const artifact = row.sourceFiles[0] ?? 'src/services/crystallization/crystallization-service.ts';
67
+ if (result.ok)
68
+ return pass(ctx, artifact);
69
+ return fail(ctx, artifact, 'crystallization refuses any task whose gates are not in a passing state', result.detail, 'J08 invariant broken: crystallization no longer enforces the pre-run gate');
43
70
  }
@@ -1,2 +1,15 @@
1
1
  import type { GuardContext, GuardRunResult } from '../types.js';
2
+ /**
3
+ * Behavioural probe of the SOP id guard and the lint-before-register ordering.
4
+ *
5
+ * The previous version listed five SOP files and passed if any of them
6
+ * mentioned "sop", "gate" or "register" — every file in the list is named
7
+ * `sop-*.ts`, so the check could not fail.
8
+ *
9
+ * Here the real `initSop` / `registerSop` path runs against a tmp project:
10
+ * path-traversal and reserved ids must be refused, and a manifest that fails
11
+ * lint must stop the registration BEFORE `registry.json` is written. The
12
+ * positive control (a clean manifest DOES register) is what makes the negative
13
+ * assertion mean something.
14
+ */
2
15
  export declare function runJ09Contract(ctx: GuardContext): Promise<GuardRunResult>;
@@ -1,44 +1,100 @@
1
- // src/services/capability-guard-runner/contracts/J09.ts
2
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- // v2: existence + soft fragment. v1 required 3 strict fragments (gate/checkable/register)
5
- // that don't all appear in every SOP service file in 4.0.8.
6
- const CANDIDATE_FILES = [
7
- 'src/services/sop/sop-service.ts',
8
- 'src/services/sop/sop-register.ts',
9
- 'src/services/sop/sop-check-service.ts',
10
- 'src/services/sop/sop-registry-service.ts',
11
- 'src/services/sop/sop-types.ts'
12
- ];
4
+ import { initSop } from '../../sop/sop-service.js';
5
+ import { SOP_ID_PATTERN } from '../../sop/sop-types.js';
6
+ import { registerSop, SopRegisterError } from '../../sop/sop-registry-service.js';
7
+ import { combineProbes, fail, missingSourceFiles, pass, probe, requireBaselineRow } from './_shared.js';
8
+ const VALID_ID = 'guard-demo';
9
+ function registryPath(projectRoot) {
10
+ return join(projectRoot, '.peaks', 'sops', 'registry.json');
11
+ }
12
+ /**
13
+ * Behavioural probe of the SOP id guard and the lint-before-register ordering.
14
+ *
15
+ * The previous version listed five SOP files and passed if any of them
16
+ * mentioned "sop", "gate" or "register" — every file in the list is named
17
+ * `sop-*.ts`, so the check could not fail.
18
+ *
19
+ * Here the real `initSop` / `registerSop` path runs against a tmp project:
20
+ * path-traversal and reserved ids must be refused, and a manifest that fails
21
+ * lint must stop the registration BEFORE `registry.json` is written. The
22
+ * positive control (a clean manifest DOES register) is what makes the negative
23
+ * assertion mean something.
24
+ */
13
25
  export async function runJ09Contract(ctx) {
14
- const existing = CANDIDATE_FILES.filter((f) => existsSync(join(ctx.projectRoot, f)));
15
- if (existing.length === 0) {
16
- return {
17
- journeyId: 'J09',
18
- contract: 'sop-register',
19
- status: 'fail',
20
- diff: { before: 'a SOP service file exists', after: 'no candidate found', reason: 'J09#1 broken: no SOP service is present' },
21
- artifactPath: 'src/services/sop/'
22
- };
26
+ const row = requireBaselineRow(ctx);
27
+ const missing = missingSourceFiles(ctx, row);
28
+ const root = mkdtempSync(join(tmpdir(), 'cbl-J09-'));
29
+ try {
30
+ const patternRejects = ['a.b', 'a/b', '../escape', 'Bad-Id', ''].every((id) => !SOP_ID_PATTERN.test(id));
31
+ const patternAccepts = ['demo', 'my-sop', 'a', 'sop2'].every((id) => SOP_ID_PATTERN.test(id));
32
+ const reservedRefused = [];
33
+ for (const id of ['peaks-x', 'peaks']) {
34
+ try {
35
+ await initSop({ id, projectRoot: root, apply: true });
36
+ }
37
+ catch (e) {
38
+ if (/reserved/i.test(e.message))
39
+ reservedRefused.push(id);
40
+ }
41
+ }
42
+ let traversalRefused = false;
43
+ try {
44
+ await initSop({ id: '../escape', projectRoot: root, apply: true });
45
+ }
46
+ catch (e) {
47
+ traversalRefused = /invalid sop id|pattern|kebab/i.test(e.message);
48
+ }
49
+ // Positive control: a real SOP initialises and registers cleanly.
50
+ let cleanRegisters = false;
51
+ let registerError = '';
52
+ try {
53
+ await initSop({ id: VALID_ID, projectRoot: root, apply: true });
54
+ const reg = await registerSop({ id: VALID_ID, projectRoot: root });
55
+ cleanRegisters = reg.applied === true && existsSync(registryPath(root));
56
+ }
57
+ catch (e) {
58
+ registerError = `${e.name}: ${e.message.slice(0, 140)}`;
59
+ }
60
+ // Negative control: break the manifest so lint must fail, then register.
61
+ let dirtyRefused = false;
62
+ let dirtyCode = 'none';
63
+ rmSync(registryPath(root), { force: true });
64
+ const manifestPath = join(root, '.peaks', 'sops', VALID_ID, 'sop.json');
65
+ if (existsSync(manifestPath)) {
66
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
67
+ manifest['id'] = 'not-the-directory-id';
68
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
69
+ }
70
+ else {
71
+ mkdirSync(join(root, '.peaks', 'sops', VALID_ID), { recursive: true });
72
+ writeFileSync(manifestPath, JSON.stringify({ id: 'not-the-directory-id', name: 'x', phases: ['p'], gates: [] }));
73
+ }
74
+ try {
75
+ await registerSop({ id: VALID_ID, projectRoot: root });
76
+ }
77
+ catch (e) {
78
+ dirtyCode = e instanceof SopRegisterError ? e.code : e.name;
79
+ dirtyRefused = dirtyCode === 'SOP_INVALID';
80
+ }
81
+ const wroteRegistryDespiteLintFailure = existsSync(registryPath(root));
82
+ const result = combineProbes([
83
+ probe(missing.length === 0, `baseline sourceFiles present (${row.sourceFiles.length})`),
84
+ probe(patternRejects, 'SOP_ID_PATTERN rejects dots, slashes, traversal and empty ids'),
85
+ probe(patternAccepts, 'SOP_ID_PATTERN accepts lowercase kebab ids'),
86
+ probe(reservedRefused.length === 2, `reserved ids are refused (refused: ${reservedRefused.join(',') || 'none'})`),
87
+ probe(traversalRefused, "initSop refuses '../escape'"),
88
+ probe(cleanRegisters, `a clean SOP registers and writes registry.json${registerError ? ` [${registerError}]` : ''}`),
89
+ probe(dirtyRefused, `a manifest that fails lint is refused with SOP_INVALID (saw ${dirtyCode})`),
90
+ probe(!wroteRegistryDespiteLintFailure, 'lint failure halts registration before registry.json is written')
91
+ ]);
92
+ const artifact = row.sourceFiles[0] ?? 'src/services/sop/sop-service.ts';
93
+ if (result.ok)
94
+ return pass(ctx, artifact);
95
+ return fail(ctx, artifact, 'SOP ids are pattern-guarded and lintSop() gates registerSop()', result.detail, 'J09 invariant broken: the SOP id guard or the lint-before-register ordering no longer holds');
23
96
  }
24
- // Soft check: at least one existing file mentions sop, gate, or register.
25
- const primary = existing.some((f) => {
26
- const src = readFileSync(join(ctx.projectRoot, f), 'utf8').toLowerCase();
27
- return src.includes('sop') || src.includes('gate') || src.includes('register');
28
- });
29
- if (!primary) {
30
- return {
31
- journeyId: 'J09',
32
- contract: 'sop-register',
33
- status: 'fail',
34
- diff: { before: 'SOP service references sop/gate/register', after: 'no service mentions any of them', reason: 'J09#1 broken: SOP service is not wired to gate-able registration' },
35
- artifactPath: existing[0]
36
- };
97
+ finally {
98
+ rmSync(root, { recursive: true, force: true });
37
99
  }
38
- return {
39
- journeyId: 'J09',
40
- contract: 'sop-register',
41
- status: 'pass',
42
- artifactPath: existing[0]
43
- };
44
100
  }
@@ -1,2 +1,14 @@
1
1
  import type { GuardContext, GuardRunResult } from '../types.js';
2
+ /**
3
+ * Behavioural probe of "a plan is produced before anything is written, so the
4
+ * diff is reviewable".
5
+ *
6
+ * The previous version listed four hook/IDE files and passed if any of them
7
+ * contained the word "hook", "install", "ide" or "adapter" — `hooks-commands.ts`
8
+ * satisfies that from its own filename.
9
+ *
10
+ * Here `planHookInstall` runs against a fresh tmp project and the settings file
11
+ * must NOT appear; `applyHookInstall` then writes it, and a second apply must
12
+ * be a no-op that does not duplicate the entry.
13
+ */
2
14
  export declare function runJ10Contract(ctx: GuardContext): Promise<GuardRunResult>;