@planu/cli 5.3.3 → 5.3.4

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 (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/engine/autopilot/action-registry.js +20 -2
  3. package/dist/engine/constitution/sdd-rules-registry.js +8 -4
  4. package/dist/engine/drift/violation-resolver.js +30 -8
  5. package/dist/engine/evidence-gates/evidence-skeletons.js +9 -4
  6. package/dist/engine/handoff-packager.js +10 -3
  7. package/dist/engine/implementation-contract/common.d.ts +13 -1
  8. package/dist/engine/implementation-contract/common.js +19 -3
  9. package/dist/engine/implementation-contract/evaluator.js +116 -35
  10. package/dist/engine/implementation-contract/renderer.js +62 -31
  11. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  12. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  13. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  14. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  15. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  16. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  17. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  18. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  19. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  20. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  21. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  22. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  23. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  24. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  25. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  26. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  27. package/dist/engine/spec-format/unified-spec-builder.js +32 -8
  28. package/dist/engine/spec-generator/fallback-generator.js +5 -3
  29. package/dist/engine/spec-quality/generic-output-gate.d.ts +7 -1
  30. package/dist/engine/spec-quality/generic-output-gate.js +199 -8
  31. package/dist/engine/spec-quality-scorer.js +9 -5
  32. package/dist/engine/workers/handlers/auto-drift.js +22 -8
  33. package/dist/tools/create-spec.js +5 -0
  34. package/dist/tools/update-status/evidence-gate.js +10 -17
  35. package/dist/tools/update-status/transition-guard.js +26 -14
  36. package/dist/types/spec-quality.d.ts +6 -1
  37. package/package.json +9 -9
  38. package/planu-native.json +1 -1
  39. package/planu-plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## [5.3.4] - 2026-08-06
2
+
3
+ ### Bug Fixes
4
+ - fix(deps): override js-yaml to patched versions for CVE-2026-59870
5
+ - fix(spec-format): keep rendered criteria BDD-executable after canonical parsing
6
+ - fix(spec-format): resolve rendered criteria through the canonical parser (SPEC-1410)
7
+ - fix(spec-quality): render and count every acceptance criterion (SPEC-1410)
8
+ - fix(planu): point SPEC-1405 verification map at the real test filename
9
+ - fix(spec-quality): stop shipping template filler and self-certifying spec sections (SPEC-1406)
10
+ - fix(drift): make follow-up spec id deterministic per parent spec (SPEC-1405)
11
+ - fix(drift): emit one drift event per project instead of one per spec (SPEC-1405)
12
+
13
+ ### Chores
14
+ - chore(planu): SPEC-1405 and SPEC-1406 done with debate evidence
15
+ - chore(planu): SPEC-1405 and SPEC-1406 implementing with packaged handoffs
16
+ - chore(planu): approve SPEC-1405 and SPEC-1406 with debate evidence, file SPEC-1410..1411
17
+ - chore(planu): file SPEC-1401..1409 from deep flow audit
18
+ - chore(planu): SPEC-1396 done with debate evidence, file SPEC-1397..1400
19
+
20
+
1
21
  ## [5.3.3] - 2026-08-06
2
22
 
3
23
  ### Bug Fixes
@@ -219,7 +219,17 @@ const ACTION_HANDLERS = {
219
219
  resolve_drift_violations: wrapHandler('resolve_drift_violations', async (ctx) => {
220
220
  const { resolveDriftViolations } = await import('../drift/violation-resolver.js');
221
221
  const result = (await resolveDriftViolations(ctx.projectPath, ctx.projectId));
222
- const resolved = safeCount(result, 'resolvedCount');
222
+ // DriftResolutionResult exposes `resolved` as an array (see types/health.ts);
223
+ // safeCount() returns its length. There is no `resolvedCount` field.
224
+ const resolved = safeCount(result, 'resolved');
225
+ const manualReview = safeCount(result, 'manualReview');
226
+ if (manualReview > 0) {
227
+ return {
228
+ success: false,
229
+ summary: `Resolved ${resolved} drift violations, ${manualReview} need manual review`,
230
+ durationMs: 0,
231
+ };
232
+ }
223
233
  return {
224
234
  success: true,
225
235
  summary: `Resolved ${resolved} drift violations`,
@@ -228,13 +238,21 @@ const ACTION_HANDLERS = {
228
238
  }),
229
239
  log_lesson: wrapHandler('log_lesson', async (ctx) => {
230
240
  const { addLesson } = await import('../../storage/lessons-store.js');
241
+ // drift:detected events (auto-drift.ts) are project-wide and carry no
242
+ // ctx.specId; fall back to the payload's specIds so the lesson stays
243
+ // attributed to the drifted specs instead of going generic.
244
+ const payloadSpecIds = ctx.payload?.specIds;
245
+ const attributedSpecIds = Array.isArray(payloadSpecIds)
246
+ ? payloadSpecIds.filter((id) => typeof id === 'string')
247
+ : [];
248
+ const attribution = ctx.specId ?? (attributedSpecIds.length > 0 ? attributedSpecIds.join(', ') : undefined);
231
249
  const lesson = await addLesson(ctx.projectPath, {
232
250
  projectId: ctx.projectId,
233
251
  specId: ctx.specId,
234
252
  category: 'process',
235
253
  severity: 'low',
236
254
  title: 'Drift detected',
237
- description: `Spec drift was detected${ctx.specId !== undefined ? ` for ${ctx.specId}` : ''}.`,
255
+ description: `Spec drift was detected${attribution !== undefined ? ` for ${attribution}` : ''}.`,
238
256
  prevention: 'Review spec file alignment with implementation after each commit.',
239
257
  tags: ['drift', 'autopilot'],
240
258
  });
@@ -1,4 +1,4 @@
1
- import { checkGenericSpecOutput } from '../spec-quality/generic-output-gate.js';
1
+ import { checkGenericSpecOutput, STRUCTURAL_INTERPOLATION_KIND, } from '../spec-quality/generic-output-gate.js';
2
2
  import { checkGroundedSpecContract } from '../spec-grounding/contract.js';
3
3
  export const DEFAULT_SDD_CONSTITUTION_RULES = [
4
4
  {
@@ -81,7 +81,11 @@ export function evaluateSddConstitutionRules(args) {
81
81
  }
82
82
  if (rules.some((rule) => rule.id === 'sdd.no-generic-output')) {
83
83
  const generic = checkGenericSpecOutput(args.content);
84
- violations.push(...generic.issues.map((issue) => violation('sdd.no-generic-output', issue.reason, [issue.phrase], rules)));
84
+ violations.push(...generic.issues.map((issue) => violation('sdd.no-generic-output', issue.reason, [issue.phrase], rules,
85
+ // SPEC-1406: structural-interpolation findings are advisory here — the blocking
86
+ // enforcement point for this new detector is create_spec, not the constitution
87
+ // gate, so ~1100 already-persisted specs stay transitionable.
88
+ issue.kind === STRUCTURAL_INTERPOLATION_KIND ? 'advisory' : undefined)));
85
89
  }
86
90
  return violations;
87
91
  }
@@ -92,11 +96,11 @@ export function renderSddConstitutionRulesForHost() {
92
96
  ...enabled.map((rule) => `- ${rule.id} (${rule.level}): ${rule.description} Next action: ${rule.nextAction}`),
93
97
  ].join('\n');
94
98
  }
95
- function violation(ruleId, message, evidence, rules) {
99
+ function violation(ruleId, message, evidence, rules, levelOverride) {
96
100
  const rule = rules.find((candidate) => candidate.id === ruleId);
97
101
  return {
98
102
  ruleId,
99
- level: rule?.level ?? 'blocking',
103
+ level: levelOverride ?? rule?.level ?? 'blocking',
100
104
  message,
101
105
  evidence,
102
106
  nextAction: rule?.nextAction ?? 'Fix the violated SDD constitution rule.',
@@ -1,8 +1,9 @@
1
1
  // engine/drift/violation-resolver.ts — Semi-automatic drift violation resolution (SPEC-408)
2
- import { mkdir, writeFile, access } from 'node:fs/promises';
2
+ import { mkdir, access } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { specStore } from '../../storage/index.js';
5
5
  import { DriftCacheStore } from '../../storage/drift-cache-store.js';
6
+ import { atomicWriteFile } from '../safety/atomic-write-file.js';
6
7
  // ---------------------------------------------------------------------------
7
8
  // Helpers
8
9
  // ---------------------------------------------------------------------------
@@ -16,10 +17,22 @@ async function specDirExists(specDir) {
16
17
  }
17
18
  }
18
19
  async function createFollowUpSpec(projectPath, parentSpecId, reason, dryRun) {
19
- const timestamp = Date.now();
20
- const specId = `SPEC-followup-${parentSpecId.toLowerCase()}-${timestamp}`;
20
+ // SPEC-1405 (AC2): the id is deterministic per parent spec, not timestamp-keyed.
21
+ // A timestamped id made every drift pass create a brand-new follow-up directory,
22
+ // so repeated triggers spammed duplicates instead of converging on one record.
23
+ const specId = `SPEC-followup-${parentSpecId.toLowerCase()}`;
21
24
  if (!dryRun) {
22
25
  const specDir = join(projectPath, 'planu', 'specs', specId);
26
+ const specPath = join(specDir, 'spec.md');
27
+ // Already created by an earlier pass — leave it alone. Rewriting would clobber
28
+ // any edits made to the follow-up spec since it was raised.
29
+ try {
30
+ await access(specPath);
31
+ return specId;
32
+ }
33
+ catch {
34
+ // Not present yet — fall through and create it.
35
+ }
23
36
  await mkdir(specDir, { recursive: true });
24
37
  const now = new Date().toISOString();
25
38
  const specContent = [
@@ -42,7 +55,7 @@ async function createFollowUpSpec(projectPath, parentSpecId, reason, dryRun) {
42
55
  '- [ ] Run detect_drift to confirm score returns to 0',
43
56
  '',
44
57
  ].join('\n');
45
- await writeFile(join(specDir, 'spec.md'), specContent, 'utf-8');
58
+ await atomicWriteFile(specPath, specContent);
46
59
  }
47
60
  return specId;
48
61
  }
@@ -88,12 +101,21 @@ export async function resolveDriftViolations(projectPath, projectId, dryRun = fa
88
101
  continue;
89
102
  }
90
103
  if (spec.status === 'done') {
91
- // Code diverged from a completed spec — create follow-up
104
+ // Code diverged from a completed spec — create follow-up.
105
+ // The write is atomic (tmp + fsync + rename); if it throws (e.g. the
106
+ // target spec.md is frozen), count the violation as unresolved instead
107
+ // of letting the error escape into the fire-and-forget autopilot handler.
92
108
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
93
109
  const reason = `Drift detected (score: ${entry.driftScore.toFixed(2)}) in files: ${(entry.driftedFiles ?? []).join(', ') || 'unknown'}`;
94
- const followUpId = await createFollowUpSpec(projectPath, spec.id, reason, dryRun);
95
- followUpSpecsCreated.push(followUpId);
96
- resolved.push(buildDoneButDivergedResolution(spec.id));
110
+ try {
111
+ const followUpId = await createFollowUpSpec(projectPath, spec.id, reason, dryRun);
112
+ followUpSpecsCreated.push(followUpId);
113
+ resolved.push(buildDoneButDivergedResolution(spec.id));
114
+ }
115
+ catch (err) {
116
+ const msg = err instanceof Error ? err.message : String(err);
117
+ manualReview.push(`${spec.id}: follow-up spec write failed — ${msg}`);
118
+ }
97
119
  }
98
120
  else if (spec.status === 'implementing') {
99
121
  // In-progress spec with code ahead — annotate but don't block
@@ -1,7 +1,12 @@
1
- // evidence-skeletons.ts — SPEC-1356: auto-generate schema-valid discovery/task-plan skeletons
2
- // when the underlying handoff evidence file is missing, so lifecycle gates self-heal instead
3
- // of blocking on a manual authoring step. Skeletons still require review; they only unblock
4
- // the transition, they do not fabricate confidence.
1
+ // evidence-skeletons.ts — SPEC-1356: auto-generate a schema-valid task-plan skeleton when the
2
+ // underlying handoff evidence file is missing, so the `implementing` lifecycle gate self-heals
3
+ // instead of blocking on a manual authoring step.
4
+ //
5
+ // SPEC-1406 (DEFECT 3): `generateDiscoverySkeleton` is kept here (still exercised directly by
6
+ // its own tests) but is no longer wired into the `approved` gate's autofill path — writing a
7
+ // fabricated discovery skeleton made the blocking discovery-evidence gate self-certify. The
8
+ // `approved` transition now reports `discovery_missing` with the specific missing fields
9
+ // instead of silently manufacturing the evidence it was supposed to require.
5
10
  import { join } from 'node:path';
6
11
  import { extractListItems, extractSection } from '../spec-format/markdown-sections.js';
7
12
  import { parseCriterionGroundingRecords } from '../spec-grounding/contract.js';
@@ -444,12 +444,19 @@ function extractBacktickedFiles(content) {
444
444
  }
445
445
  return [...files].sort();
446
446
  }
447
+ /**
448
+ * SPEC-1406 (DEFECT 6): markdown heading lines (e.g. "### Edge Cases And Failure Modes")
449
+ * match keyword regexes like /\bedge\b/ just as readily as real bullet content, so headings
450
+ * used to leak into testPlan/risks/ownership as if they were grounded items. Skip headings
451
+ * and dedupe before truncating, so a criterion repeated under multiple sections — or the same
452
+ * heading matching two keyword sets — is not counted as distinct evidence twice.
453
+ */
447
454
  function extractLinesByKeywords(content, keywords) {
448
- return content
455
+ const lines = content
449
456
  .split('\n')
450
457
  .map((line) => line.trim().replace(/^[-*]\s*/, ''))
451
- .filter((line) => line.length > 0 && keywords.test(line))
452
- .slice(0, 12);
458
+ .filter((line) => line.length > 0 && !/^#{1,6}\s/.test(line) && keywords.test(line));
459
+ return [...new Set(lines)].slice(0, 12);
453
460
  }
454
461
  function extractOperationalSections(content, spec) {
455
462
  return {
@@ -1,5 +1,17 @@
1
1
  export declare const IMPLEMENTATION_CONTRACT_SECTION = "Implementation Contract";
2
- export declare const IMPLEMENTATION_CONTRACT_SUBSECTIONS: readonly ['User Outcome', 'Behavior Contract', 'File-Level Work Plan', 'Acceptance-To-Verification Map', 'Edge Cases And Failure Modes', 'Non-Goals And Forbidden Approaches', 'Verification Commands'];
2
+ /** Subsections every rendered contract must include omitting one is a defect. */
3
+ export declare const IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS: readonly ['User Outcome', 'File-Level Work Plan', 'Acceptance-To-Verification Map', 'Verification Commands'];
4
+ /**
5
+ * SPEC-1406: subsections that carry no grounded content are omitted from the rendered
6
+ * body rather than filled with restated-criterion filler. Their absence is not an
7
+ * evaluator defect — only their presence-but-empty is. This keeps the ~1100
8
+ * already-persisted specs that still carry these sections (with real, non-empty
9
+ * content) transitionable: `evaluateImplementationContract` only requires them when
10
+ * the rendered body chose to include the heading.
11
+ */
12
+ export declare const IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS: readonly ['Behavior Contract', 'Edge Cases And Failure Modes', 'Non-Goals And Forbidden Approaches'];
13
+ /** Union of required + optional subsections. Kept for callers that only need the full set. */
14
+ export declare const IMPLEMENTATION_CONTRACT_SUBSECTIONS: readonly ["User Outcome", "File-Level Work Plan", "Acceptance-To-Verification Map", "Verification Commands", "Behavior Contract", "Edge Cases And Failure Modes", "Non-Goals And Forbidden Approaches"];
3
15
  export declare function hasImplementationContract(specBody: string): boolean;
4
16
  export declare function extractTopLevelSection(body: string, sectionName: string): string;
5
17
  //# sourceMappingURL=common.d.ts.map
@@ -1,12 +1,28 @@
1
1
  export const IMPLEMENTATION_CONTRACT_SECTION = 'Implementation Contract';
2
- export const IMPLEMENTATION_CONTRACT_SUBSECTIONS = [
2
+ /** Subsections every rendered contract must include — omitting one is a defect. */
3
+ export const IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS = [
3
4
  'User Outcome',
4
- 'Behavior Contract',
5
5
  'File-Level Work Plan',
6
6
  'Acceptance-To-Verification Map',
7
+ 'Verification Commands',
8
+ ];
9
+ /**
10
+ * SPEC-1406: subsections that carry no grounded content are omitted from the rendered
11
+ * body rather than filled with restated-criterion filler. Their absence is not an
12
+ * evaluator defect — only their presence-but-empty is. This keeps the ~1100
13
+ * already-persisted specs that still carry these sections (with real, non-empty
14
+ * content) transitionable: `evaluateImplementationContract` only requires them when
15
+ * the rendered body chose to include the heading.
16
+ */
17
+ export const IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS = [
18
+ 'Behavior Contract',
7
19
  'Edge Cases And Failure Modes',
8
20
  'Non-Goals And Forbidden Approaches',
9
- 'Verification Commands',
21
+ ];
22
+ /** Union of required + optional subsections. Kept for callers that only need the full set. */
23
+ export const IMPLEMENTATION_CONTRACT_SUBSECTIONS = [
24
+ ...IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS,
25
+ ...IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS,
10
26
  ];
11
27
  export function hasImplementationContract(specBody) {
12
28
  return extractTopLevelSection(specBody, IMPLEMENTATION_CONTRACT_SECTION).trim().length > 0;
@@ -1,4 +1,4 @@
1
- import { extractTopLevelSection, IMPLEMENTATION_CONTRACT_SECTION, IMPLEMENTATION_CONTRACT_SUBSECTIONS, } from './common.js';
1
+ import { extractTopLevelSection, IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS, IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS, IMPLEMENTATION_CONTRACT_SECTION, } from './common.js';
2
2
  const FILLER_PATTERNS = [
3
3
  /\bTBD\b/i,
4
4
  /\bN\/A\b/i,
@@ -8,6 +8,19 @@ const FILLER_PATTERNS = [
8
8
  ];
9
9
  const MANUAL_VERIFICATION_STEP_RE = /\b(given|when|then|step|observe|click|run)\b/i;
10
10
  const CONCRETE_MANUAL_EVIDENCE_RE = /`[^`]+`|\b(?:src|tests|docs|scripts|website|planu)\/[\w./-]+/i;
11
+ const OPTIONAL_SUBSECTIONS = new Set(IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS);
12
+ /**
13
+ * SPEC-1406 (DEFECT 1, round 2 — FALSE EVIDENCE fix): the old CONCRETE_MANUAL_EVIDENCE_RE
14
+ * accepted ANY backticked text, so a row whose evidence was `` `not-a-path` `` produced zero
15
+ * issues. Verification-row evidence must actually look like a test path (a `tests/` segment,
16
+ * or `.test.`/`.spec.` in the filename), a runnable test/typecheck/lint command, or a
17
+ * backticked command/path that names a real file or script (contains `/` or a file
18
+ * extension) — a bare backticked word with neither is not evidence.
19
+ */
20
+ const TEST_EVIDENCE_RE = /tests?\/[\w./-]+\.\w+|\.(?:test|spec)\.\w+|\b(?:pnpm|npm|npx|yarn)\s+(?:run\s+)?[\w:-]*(?:test|typecheck|lint)[\w:-]*\b|\bvitest\b|\bjest\b|\bpytest\b|\bgo test\b|\bcargo test\b|`[^`]*(?:[\\/]|\.[a-zA-Z]{1,10}\b)[^`]*`/i;
21
+ /** The shared row rendered by renderSharedVerificationRow when there are fewer test files
22
+ * than criteria — it covers every criterion at once rather than one row each. */
23
+ const SHARED_VERIFICATION_ROW_RE = /^-\s*All criteria are verified by:/;
11
24
  export function evaluateImplementationContract(specBody, acceptanceCriteria) {
12
25
  const contract = extractTopLevelSection(specBody, IMPLEMENTATION_CONTRACT_SECTION);
13
26
  if (contract.trim().length === 0) {
@@ -19,56 +32,77 @@ export function evaluateImplementationContract(specBody, acceptanceCriteria) {
19
32
  ];
20
33
  }
21
34
  const issues = [];
22
- const sections = new Map(IMPLEMENTATION_CONTRACT_SUBSECTIONS.map((section) => [
23
- section,
24
- extractSubsection(contract, section),
25
- ]));
35
+ const allSubsections = [
36
+ ...IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS,
37
+ ...IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS,
38
+ ];
39
+ const sections = new Map(allSubsections.map((section) => [section, extractSubsection(contract, section)]));
26
40
  for (const [section, content] of sections.entries()) {
27
- if (content === null) {
41
+ issues.push(...evaluateSubsection(section, content));
42
+ }
43
+ const map = sections.get('Acceptance-To-Verification Map') ?? '';
44
+ const mapLines = map.split('\n').map((line) => line.trim());
45
+ const sharedRowCoversAll = mapLines.some((line) => SHARED_VERIFICATION_ROW_RE.test(line) && TEST_EVIDENCE_RE.test(line));
46
+ const useStrictIndex = hasCompleteAcLabeling(mapLines, acceptanceCriteria.length);
47
+ acceptanceCriteria.forEach((criterion, index) => {
48
+ if (sharedRowCoversAll) {
49
+ return;
50
+ }
51
+ if (!criterionHasVerification(criterion, index + 1, mapLines, useStrictIndex)) {
52
+ issues.push({
53
+ code: 'contract_unmapped_criterion',
54
+ message: `Acceptance criterion is not mapped to verification evidence: ${criterion}`,
55
+ });
56
+ }
57
+ });
58
+ if (/\bmanual verification\b/i.test(map) &&
59
+ !MANUAL_VERIFICATION_STEP_RE.test(map) &&
60
+ !CONCRETE_MANUAL_EVIDENCE_RE.test(map)) {
61
+ issues.push({
62
+ code: 'contract_manual_verification_vague',
63
+ message: 'Manual verification in the Implementation Contract must include exact observable steps.',
64
+ });
65
+ }
66
+ return issues;
67
+ }
68
+ /**
69
+ * SPEC-1406: optional subsections with no grounded content are omitted from the rendered
70
+ * body entirely — that is the intended fix, not a defect to flag.
71
+ */
72
+ function evaluateSubsection(section, content) {
73
+ const issues = [];
74
+ if (content === null) {
75
+ if (!OPTIONAL_SUBSECTIONS.has(section)) {
28
76
  issues.push({
29
77
  code: 'contract_subsection_missing',
30
78
  message: `Implementation Contract subsection "${section}" is missing.`,
31
79
  });
32
- continue;
33
80
  }
34
- const trimmed = content.trim();
35
- if (trimmed.length === 0) {
81
+ return issues;
82
+ }
83
+ const trimmed = content.trim();
84
+ if (trimmed.length === 0) {
85
+ if (!OPTIONAL_SUBSECTIONS.has(section)) {
36
86
  issues.push({
37
87
  code: 'contract_subsection_empty',
38
88
  message: `Implementation Contract subsection "${section}" is empty.`,
39
89
  });
40
90
  }
41
- for (const pattern of FILLER_PATTERNS) {
42
- if (pattern.test(trimmed)) {
43
- issues.push({
44
- code: 'contract_filler',
45
- message: `Implementation Contract subsection "${section}" contains filler text.`,
46
- });
47
- break;
48
- }
49
- }
50
- if (/\bNeeds decision:/i.test(trimmed)) {
51
- issues.push({
52
- code: 'contract_needs_decision',
53
- message: `Implementation Contract subsection "${section}" still has unresolved Needs decision items.`,
54
- });
55
- }
91
+ return issues;
56
92
  }
57
- const map = sections.get('Acceptance-To-Verification Map') ?? '';
58
- for (const criterion of acceptanceCriteria) {
59
- if (!criterionHasVerification(criterion, map)) {
93
+ for (const pattern of FILLER_PATTERNS) {
94
+ if (pattern.test(trimmed)) {
60
95
  issues.push({
61
- code: 'contract_unmapped_criterion',
62
- message: `Acceptance criterion is not mapped to verification evidence: ${criterion}`,
96
+ code: 'contract_filler',
97
+ message: `Implementation Contract subsection "${section}" contains filler text.`,
63
98
  });
99
+ break;
64
100
  }
65
101
  }
66
- if (/\bmanual verification\b/i.test(map) &&
67
- !MANUAL_VERIFICATION_STEP_RE.test(map) &&
68
- !CONCRETE_MANUAL_EVIDENCE_RE.test(map)) {
102
+ if (/\bNeeds decision:/i.test(trimmed)) {
69
103
  issues.push({
70
- code: 'contract_manual_verification_vague',
71
- message: 'Manual verification in the Implementation Contract must include exact observable steps.',
104
+ code: 'contract_needs_decision',
105
+ message: `Implementation Contract subsection "${section}" still has unresolved Needs decision items.`,
72
106
  });
73
107
  }
74
108
  return issues;
@@ -85,7 +119,54 @@ function extractSubsection(sectionBody, subsectionName) {
85
119
  const next = nextRe.exec(sectionBody);
86
120
  return sectionBody.slice(start, next ? next.index : sectionBody.length);
87
121
  }
88
- function criterionHasVerification(criterion, map) {
122
+ /**
123
+ * A map only qualifies for strict index-based matching when EVERY criterion 1..N has its
124
+ * own `AC{n}:` row — a partial or off-by-one labeling scheme (e.g. an extra `AB1:` row for
125
+ * a review-only criterion) is not the renderer's per-criterion output shape and must not
126
+ * be forced into it; fall back to the legacy whole-map match instead (see below).
127
+ */
128
+ function hasCompleteAcLabeling(mapLines, criteriaCount) {
129
+ if (criteriaCount === 0) {
130
+ return false;
131
+ }
132
+ for (let index = 1; index <= criteriaCount; index += 1) {
133
+ const rowRe = new RegExp(`^-\\s*AC${String(index)}:`);
134
+ if (!mapLines.some((line) => rowRe.test(line))) {
135
+ return false;
136
+ }
137
+ }
138
+ return true;
139
+ }
140
+ /**
141
+ * SPEC-1406 (DEFECT 1, round 2 — PREFIX COLLISION fix): the old lookup matched rows by
142
+ * `normalize(criterion).slice(0, 48)` and took the FIRST matching line — two criteria
143
+ * sharing the same first 48 normalized characters were both "certified" by AC1's row,
144
+ * so AC2 could be entirely unmapped and still pass. `AC{n}:` labels are only ever emitted
145
+ * by this renderer (renderPerCriterionRow), so when the map has a complete `AC1:`..`ACn:`
146
+ * sequence, match strictly on the labeled index instead of a truncated prefix.
147
+ *
148
+ * When the map does not have a complete AC-labeled sequence, it was authored by hand or by
149
+ * an older generator (bullet lists, GIVEN/WHEN/THEN prose, markdown tables, partial AC
150
+ * labeling — corpus-replay against all pre-SPEC-1406 persisted specs found no single
151
+ * positional or structural rule that covers every legacy shape without newly blocking specs
152
+ * that were previously readable). Falling back to the original whole-map prefix/keyword
153
+ * match preserves exact pre-fix behavior for that legacy corpus — the non-functional
154
+ * constraint that no already-persisted spec may gain a new blocker takes priority over
155
+ * tightening a shape this fix does not target.
156
+ */
157
+ function criterionHasVerification(criterion, index, mapLines, useStrictIndex) {
158
+ if (criterion.trim().length === 0) {
159
+ return true;
160
+ }
161
+ if (useStrictIndex) {
162
+ const rowRe = new RegExp(`^-\\s*AC${String(index)}:`);
163
+ const row = mapLines.find((line) => rowRe.test(line));
164
+ return row !== undefined && TEST_EVIDENCE_RE.test(row);
165
+ }
166
+ return legacyCriterionHasVerification(criterion, mapLines.join('\n'));
167
+ }
168
+ /** Pre-SPEC-1406 whole-map matcher, kept verbatim for maps with no `AC{n}:` labels. */
169
+ function legacyCriterionHasVerification(criterion, map) {
89
170
  const normalizedCriterion = normalize(criterion);
90
171
  if (normalizedCriterion.length === 0) {
91
172
  return true;
@@ -3,6 +3,9 @@ export function buildImplementationContractSection(input) {
3
3
  const criteria = input.criteria;
4
4
  const testFiles = input.files.test.map((file) => file.path);
5
5
  const verificationCommands = input.verificationCommands;
6
+ const verificationRows = renderVerificationRows(criteria, testFiles, verificationCommands);
7
+ const failureModes = renderFailureModes(criteria);
8
+ const nonGoals = renderNonGoals(input.outOfScope);
6
9
  const lines = [
7
10
  `## ${IMPLEMENTATION_CONTRACT_SECTION}`,
8
11
  '### User Outcome',
@@ -10,21 +13,16 @@ export function buildImplementationContractSection(input) {
10
13
  criteria[0]?.text ??
11
14
  'Needs decision: define the exact observable outcome this spec must deliver.',
12
15
  '',
13
- '### Behavior Contract',
14
- ...renderBehaviorContract(criteria),
15
- '',
16
16
  '### File-Level Work Plan',
17
17
  ...renderFilePlan(input.files, criteria.length > 0),
18
18
  '',
19
19
  '### Acceptance-To-Verification Map',
20
- ...criteria.map((criterion, index) => renderVerificationRow(criterion.text, index + 1, testFiles, verificationCommands)),
21
- '',
22
- '### Edge Cases And Failure Modes',
23
- ...renderFailureModes(criteria),
24
- '',
25
- '### Non-Goals And Forbidden Approaches',
26
- ...renderNonGoals(input.outOfScope),
20
+ ...verificationRows,
27
21
  '',
22
+ // SPEC-1406 (DEFECT 5): a subsection with no grounded content is omitted rather than
23
+ // filled with a restated-criterion placeholder — prefer omission over filler.
24
+ ...(failureModes.length > 0 ? ['### Edge Cases And Failure Modes', ...failureModes, ''] : []),
25
+ ...(nonGoals.length > 0 ? ['### Non-Goals And Forbidden Approaches', ...nonGoals, ''] : []),
28
26
  '### Verification Commands',
29
27
  ...(verificationCommands.length > 0
30
28
  ? verificationCommands.map((command) => `- \`${command}\``)
@@ -71,31 +69,64 @@ function renderFilePlan(files, hasGroundedBehavior) {
71
69
  ? ['- No file ownership was grounded for this behavior.']
72
70
  : ['- Needs decision: identify expected source and test files.'];
73
71
  }
74
- function renderBehaviorContract(criteria) {
75
- return criteria.length > 0
76
- ? criteria.map((criterion, index) => `- Expected behavior AC${String(index + 1)}: ${criterion.text}`)
77
- : ['- Needs decision: define at least one evidence-grounded acceptance criterion.'];
72
+ /**
73
+ * SPEC-1406 (DEFECT 1, round 2): renders the Acceptance-To-Verification Map body.
74
+ * There is no per-criterion test mapping anywhere in the pipeline (LeanCriterion is
75
+ * `{text, done}`, `files.test` is a flat list) positional `testFiles[index]` is only
76
+ * trustworthy when there are at least as many test files as criteria. When there are
77
+ * not enough test files to map one per criterion, emit ONE shared row naming every
78
+ * criterion's coverage instead of dropping rows (which used to make criteria 2..N
79
+ * invisible to the evaluator and promote them to false blockers).
80
+ */
81
+ function renderVerificationRows(criteria, testFiles, commands) {
82
+ if (criteria.length === 0) {
83
+ return ['- Needs decision: provide at least one acceptance criterion to verify.'];
84
+ }
85
+ if (testFiles.length >= criteria.length) {
86
+ const rows = [];
87
+ criteria.forEach((criterion, index) => {
88
+ const test = testFiles[index];
89
+ if (test !== undefined) {
90
+ rows.push(renderPerCriterionRow(criterion.text, index + 1, test, commands));
91
+ }
92
+ });
93
+ return rows;
94
+ }
95
+ if (testFiles.length === 0) {
96
+ return ['- No repository-supported verification evidence was grounded for these criteria.'];
97
+ }
98
+ return renderSharedVerificationRow(testFiles, commands);
99
+ }
100
+ function renderPerCriterionRow(criterion, index, test, commands) {
101
+ const command = commands[index - 1] ?? commands[0];
102
+ return command
103
+ ? `- AC${String(index)}: ${criterion} -> unit test \`${test}\` plus \`${command}\`.`
104
+ : `- AC${String(index)}: ${criterion} -> unit test \`${test}\`.`;
78
105
  }
79
- function renderVerificationRow(criterion, index, testFiles, commands) {
80
- const test = testFiles[0];
81
- if (test) {
82
- const command = commands[0];
83
- return command
84
- ? `- AC${String(index)}: ${criterion} -> unit test \`${test}\` plus \`${command}\`.`
85
- : `- AC${String(index)}: ${criterion} -> unit test \`${test}\`.`;
106
+ /**
107
+ * Emitted once when there are fewer declared test files than criteria — never repeat
108
+ * `commands[0]` per criterion (SPEC-1406 round 2 finding: N byte-identical rows).
109
+ */
110
+ function renderSharedVerificationRow(testFiles, commands) {
111
+ const fileList = testFiles.map((file) => `\`${file}\``).join(', ');
112
+ const lines = [`- All criteria are verified by: ${fileList}`];
113
+ const command = commands[0];
114
+ if (command) {
115
+ lines.push(`- Shared verification command: \`${command}\`.`);
86
116
  }
87
- return `- AC${String(index)}: ${criterion} -> verify the exact observable result declared by this criterion.`;
117
+ return lines;
88
118
  }
89
119
  function renderNonGoals(outOfScope) {
90
- return outOfScope.length > 0
91
- ? outOfScope.map((item) => `- ${item}`)
92
- : ['- No explicit non-goals were supplied.'];
120
+ return outOfScope.map((item) => `- ${item}`);
93
121
  }
94
- function renderFailureModes(criteria) {
95
- return criteria.length > 0
96
- ? criteria.map((criterion, index) => `- AC${String(index + 1)} failure: reject partial behavior that violates "${criterion.text}" and preserve the last valid observable state.`)
97
- : [
98
- '- Needs decision: identify criterion-specific failure modes from user input or project evidence.',
99
- ];
122
+ /**
123
+ * SPEC-1406 (DEFECT 5): there is no per-criterion failure-mode input — the previous
124
+ * implementation synthesized a constant restatement of the criterion text instead
125
+ * ("AC1 failure: reject partial behavior that violates \"<criterion>\"..."), which carries
126
+ * no information beyond the Acceptance-To-Verification Map. Prefer omission over filler:
127
+ * this subsection has nothing grounded to add, so it is always empty here.
128
+ */
129
+ function renderFailureModes(_criteria) {
130
+ return [];
100
131
  }
101
132
  //# sourceMappingURL=renderer.js.map
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.3",
4
- "engineVersion": "5.3.3",
5
- "buildId": "1-5.3.3-aarch64-apple-darwin",
3
+ "cliVersion": "5.3.4",
4
+ "engineVersion": "5.3.4",
5
+ "buildId": "1-5.3.4-aarch64-apple-darwin",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -17,15 +17,15 @@
17
17
  "platform": "darwin",
18
18
  "arch": "arm64",
19
19
  "libc": null,
20
- "artifactSha256": "936cf103c37c5622595eba08d503131657a4718d37b84d9355fe3dd8b63f313a",
20
+ "artifactSha256": "570521dc4b74de29cb0e40a5da5da31bb8a34851c7bbc632c40dfeba48400bfc",
21
21
  "sbom": {
22
22
  "format": "CycloneDX-1.5",
23
23
  "path": "planu-core.darwin-arm64.node.sbom.json",
24
- "sha256": "a3350e8b345415b6b7881cd0ed7b1fa09a86e21530f790e08f93e74c0bfc4384"
24
+ "sha256": "6f82916e52c2ae01a71b3d55b2c9c6ac607d4f9b153512d349cf8decc632b309"
25
25
  },
26
26
  "provenance": {
27
27
  "builder": "scripts/build-rust-local.sh",
28
- "sourceCommit": "30eda7a2ba11647e835b0079c526bed77f189c8e",
28
+ "sourceCommit": "7f78cd615083c4284cab36c05f11dc2ce76e5cbd",
29
29
  "sourceTreeDirty": false,
30
30
  "sourceDateEpoch": 1,
31
31
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -34,6 +34,6 @@
34
34
  "signature": {
35
35
  "algorithm": "Ed25519",
36
36
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
37
- "value": "bk6W0MZRvTB3XJtJPw0Oe9Brcs8vOa0ZyWNyx81gBZHTp1qRjQjyT+bWVWjMRl9ULBE8+Iu5Sd02wR0ZGZ3MCQ=="
37
+ "value": "VKMAa/cn3GxIH/tAv535J9ZCcXOT6tCmakcat6U6z9HyWkTb8fpBG9xYQoufyQ7GVt1GocbWIyyOVX2HV6JbCg=="
38
38
  }
39
39
  }