@planu/cli 4.7.3 → 4.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +6 -3
  3. package/dist/config/minimal-implementation-gate.json +12 -4
  4. package/dist/engine/cascade-hooks/core/append-releases.d.ts +2 -1
  5. package/dist/engine/cascade-hooks/core/append-releases.js +30 -3
  6. package/dist/engine/context-md-generator.js +5 -4
  7. package/dist/engine/evidence-gates/artifact-reader.js +15 -2
  8. package/dist/engine/evidence-gates/lifecycle-gate.js +14 -2
  9. package/dist/engine/generated-artifact-writer.d.ts +7 -0
  10. package/dist/engine/generated-artifact-writer.js +63 -0
  11. package/dist/engine/handoff-packager.js +6 -49
  12. package/dist/engine/implementation-contract/renderer.js +10 -0
  13. package/dist/engine/next-spec-resolver/session-writer.js +10 -6
  14. package/dist/engine/readiness-checker.js +13 -7
  15. package/dist/engine/session-context-generator.js +9 -5
  16. package/dist/engine/session-state/writer.js +33 -14
  17. package/dist/engine/spec-format/acceptance-criteria.d.ts +5 -0
  18. package/dist/engine/spec-format/acceptance-criteria.js +106 -0
  19. package/dist/engine/spec-migrator/strict-planu-cleanup.js +16 -0
  20. package/dist/engine/universal-rules/catalog.js +2 -0
  21. package/dist/engine/universal-rules/rules/planu-minimal-change.d.ts +3 -0
  22. package/dist/engine/universal-rules/rules/planu-minimal-change.js +44 -0
  23. package/dist/engine/universal-rules/rules/planu-release-policy.js +8 -5
  24. package/dist/native/lightweight-command-catalog.d.ts +1 -1
  25. package/dist/native/lightweight-command-catalog.js +1 -1
  26. package/dist/resources/process.js +3 -2
  27. package/dist/tools/challenge-spec/event-challenge-scenarios.js +10 -6
  28. package/dist/tools/git/hook-ops.js +1 -1
  29. package/dist/tools/init-project/per-client-files-writer.js +2 -1
  30. package/dist/tools/init-project/rules-generator.js +14 -0
  31. package/dist/tools/update-status/evidence-gate.js +5 -9
  32. package/dist/types/cascade-hooks.d.ts +5 -0
  33. package/dist/types/generated-artifacts.d.ts +13 -0
  34. package/dist/types/generated-artifacts.js +3 -0
  35. package/dist/types/spec-format.d.ts +6 -0
  36. package/package.json +16 -16
  37. package/planu-native.json +9 -30
  38. package/planu-plugin.json +7 -35
package/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## [4.7.5] - 2026-06-24
2
+
3
+ ### Features
4
+ - feat: add Planu minimal-change rule and guidance inspired by Ponytail
5
+
6
+ ### Bug Fixes
7
+ - fix: keep generated Planu session artifacts stable after lifecycle close
8
+
9
+ ### Chores
10
+ - chore(deps): refresh push-gate dev dependency knip
11
+
12
+
13
+ ## [4.7.4] - 2026-06-23
14
+
15
+ ### Bug Fixes
16
+ - fix: implement SPEC-1091-1094 tech debt bundle
17
+
18
+ ### Chores
19
+ - chore(deps): refresh push-gate dev dependencies
20
+
21
+
1
22
  ## [4.7.3] - 2026-06-19
2
23
 
3
24
  ### Features
@@ -4103,4 +4124,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
4103
4124
  - Mermaid diagram generation (architecture, sequence, state machine, ER, data flow)
4104
4125
  - Multi-language i18n (EN/ES/PT) for generated specs
4105
4126
  - Clean Architecture (hexagonal) — engine, tools, storage, types layers
4106
- - 10,857 tests with ≥95% coverage
4127
+ - 10,857 tests with ≥95% coverage
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  **The high-performance SDD stack for AI coding agents.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@planu/cli?color=blue&label=npm)](https://www.npmjs.com/package/@planu/cli)
6
- ![Node](https://img.shields.io/badge/node-%3E%3D22.0.0-green)
6
+ ![Node](https://img.shields.io/badge/node-%3E%3D24.0.0-green)
7
7
  ![Rust](https://img.shields.io/badge/core-rust-orange)
8
8
  ![License](https://img.shields.io/badge/license-MIT-blue)
9
9
 
@@ -41,7 +41,7 @@ Add to your MCP client config (Claude Desktop: `~/Library/Application Support/Cl
41
41
  }
42
42
  ```
43
43
 
44
- **Requirements:** Node.js >= 22. The native Rust core is bundled and auto-detected for macOS (Intel/M1), Linux, and Windows.
44
+ **Requirements:** Node.js >= 24. The native Rust core is bundled and auto-detected for macOS (Intel/M1), Linux, and Windows.
45
45
 
46
46
  ---
47
47
 
@@ -112,7 +112,10 @@ data/ # Local storage (gitignored)
112
112
  pnpm install # Install dependencies
113
113
  pnpm build # Compile TS + Build Rust bridge
114
114
  pnpm dev # Watch mode
115
- pnpm test # Run 31,000+ tests
115
+ pnpm validate # Canonical local validation contract
116
+ pnpm check:strict # Extended local quality gates
117
+ pnpm test # Run the full test suite
118
+ pnpm release:local # Local-first release flow when shipping
116
119
  ```
117
120
 
118
121
  ---
@@ -31,7 +31,7 @@
31
31
  "tag": "stdlib",
32
32
  "severity": "warning",
33
33
  "confidence": "medium",
34
- "patterns": ["custom parser", "manual parser", "hand rolled parser"],
34
+ "patterns": ["custom parser", "manual parser", "hand rolled parser", "hand-rolled stdlib"],
35
35
  "replacementGuidance": "Use an existing parser from the runtime or current dependency set before writing parsing code."
36
36
  },
37
37
  {
@@ -39,7 +39,7 @@
39
39
  "tag": "native",
40
40
  "severity": "warning",
41
41
  "confidence": "high",
42
- "patterns": ["new public mcp tool", "new command", "new cli command"],
42
+ "patterns": ["new public mcp tool", "new command", "new cli command", "native platform feature"],
43
43
  "replacementGuidance": "Prefer existing Planu SDD surfaces unless the spec proves a new public surface is required."
44
44
  },
45
45
  {
@@ -55,7 +55,7 @@
55
55
  "tag": "yagni",
56
56
  "severity": "blocker",
57
57
  "confidence": "high",
58
- "patterns": ["future-proof", "just in case", "might need later", "eventually support"],
58
+ "patterns": ["future-proof", "just in case", "might need later", "eventually support", "speculative config"],
59
59
  "replacementGuidance": "Remove speculative scope unless it is directly required by an acceptance criterion.",
60
60
  "blockWhenRiskAtLeast": "medium"
61
61
  },
@@ -64,7 +64,15 @@
64
64
  "tag": "shrink",
65
65
  "severity": "warning",
66
66
  "confidence": "medium",
67
- "patterns": ["framework", "orchestration layer", "plugin architecture", "abstract factory"],
67
+ "patterns": [
68
+ "framework",
69
+ "orchestration layer",
70
+ "plugin architecture",
71
+ "abstract factory",
72
+ "wrapper that only delegates",
73
+ "delegate-only wrapper",
74
+ "shrinkable logic"
75
+ ],
68
76
  "replacementGuidance": "Shrink the design to the smallest correct module/function that satisfies the spec."
69
77
  },
70
78
  {
@@ -1,3 +1,4 @@
1
- import type { CascadeContext, CoreActionResult } from '../../../types/cascade-hooks.js';
1
+ import type { CascadeContext, CoreActionResult, PendingReleaseEntry } from '../../../types/cascade-hooks.js';
2
+ export declare function normalizePendingReleaseEntries(value: unknown, now?: Date): PendingReleaseEntry[];
2
3
  export declare function appendReleasesPending(ctx: CascadeContext): Promise<CoreActionResult>;
3
4
  //# sourceMappingURL=append-releases.d.ts.map
@@ -5,6 +5,35 @@ import { join } from 'node:path';
5
5
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
6
6
  import { specStore } from '../../../storage/index.js';
7
7
  const CORE_ACTION_BUDGET_MS = 2000;
8
+ const PENDING_ENTRY_MAX_AGE_DAYS = 30;
9
+ function isValidPendingReleaseEntry(value) {
10
+ return (typeof value === 'object' &&
11
+ value !== null &&
12
+ typeof value.specId === 'string' &&
13
+ typeof value.title === 'string' &&
14
+ typeof value.completedAt === 'string');
15
+ }
16
+ function isRecentPendingEntry(entry, now = new Date()) {
17
+ const completedAt = new Date(entry.completedAt);
18
+ if (Number.isNaN(completedAt.getTime())) {
19
+ return false;
20
+ }
21
+ const ageMs = now.getTime() - completedAt.getTime();
22
+ return ageMs <= PENDING_ENTRY_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
23
+ }
24
+ export function normalizePendingReleaseEntries(value, now = new Date()) {
25
+ if (!Array.isArray(value)) {
26
+ return [];
27
+ }
28
+ const deduped = new Map();
29
+ for (const entry of value) {
30
+ if (!isValidPendingReleaseEntry(entry) || !isRecentPendingEntry(entry, now)) {
31
+ continue;
32
+ }
33
+ deduped.set(entry.specId, entry);
34
+ }
35
+ return [...deduped.values()];
36
+ }
8
37
  async function actuallyAppendReleasesPending(ctx, opts) {
9
38
  const { projectPath, projectId, specId } = ctx;
10
39
  if (!projectPath || ctx.newStatus !== 'done') {
@@ -26,9 +55,7 @@ async function actuallyAppendReleasesPending(ctx, opts) {
26
55
  try {
27
56
  const raw = await readFile(pendingPath, 'utf-8');
28
57
  const parsed = JSON.parse(raw);
29
- if (Array.isArray(parsed)) {
30
- pendingList = parsed;
31
- }
58
+ pendingList = normalizePendingReleaseEntries(parsed);
32
59
  }
33
60
  catch {
34
61
  /* file doesn't exist yet — start fresh */
@@ -1,8 +1,9 @@
1
1
  // engine/context-md-generator.ts — SPEC-635: Live ≤800-token context briefing for any LLM
2
- import { writeFile, mkdir, readFile } from 'node:fs/promises';
2
+ import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { specStore } from '../storage/index.js';
5
5
  import { stripFrontmatter } from './frontmatter-parser.js';
6
+ import { stableWriteGeneratedFile, stripSessionMarkdownVolatileFields, } from './generated-artifact-writer.js';
6
7
  const MAX_TOKENS = 800;
7
8
  const CHARS_PER_TOKEN = 4; // rough estimate
8
9
  const MAX_CHARS = MAX_TOKENS * CHARS_PER_TOKEN;
@@ -92,9 +93,9 @@ export async function generateContextMd(projectPath, projectId) {
92
93
  }
93
94
  }
94
95
  const content = truncate(lines.join('\n'), MAX_CHARS);
95
- const planuDir = join(projectPath, 'planu');
96
- await mkdir(planuDir, { recursive: true });
97
- await writeFile(join(planuDir, 'context.md'), content + '\n', 'utf-8');
96
+ await stableWriteGeneratedFile(join(projectPath, 'planu', 'context.md'), content, {
97
+ equivalence: stripSessionMarkdownVolatileFields,
98
+ });
98
99
  }
99
100
  catch {
100
101
  /* best-effort — never blocks status transition */
@@ -49,6 +49,19 @@ const ContractValidationSchema = z.object({
49
49
  reportPath: z.string().optional(),
50
50
  summary: z.string().optional(),
51
51
  });
52
+ const ARTIFACT_HINTS = {
53
+ 'Discovery evidence': 'Expected discovery.json with { version: 1, rules: string[], examples: [{ rule, example }], openQuestions: [{ question, status, resolution? }], outOfScope: string[], glossary: [{ term, meaning }] }.',
54
+ 'Task plan evidence': 'Expected task-plan.json with { version: 1, tasks: [{ id, title, acceptanceCriteria: string[], status: "pending"|"doing"|"done" }] }. Acceptance criteria may be full text or stable IDs such as AC1 when they map to the canonical criteria order.',
55
+ 'Traceability matrix evidence': 'Expected traceability-matrix.json with { version: 1, rows: [{ acceptanceCriterion, scenario?, testEvidence?, contractEvidence?, manualEvidence?, changedFiles: string[], validationEvidence?, reviewerEvidence? }] }.',
56
+ 'contract-validation-api.json': 'Expected filename contract-validation-api.json with { version: 1, kind: "api", passed: boolean, reportPath?, summary? }.',
57
+ 'contract-validation-graphql.json': 'Expected filename contract-validation-graphql.json with { version: 1, kind: "graphql", passed: boolean, reportPath?, summary? }.',
58
+ 'contract-validation-event.json': 'Expected filename contract-validation-event.json with { version: 1, kind: "event", passed: boolean, reportPath?, summary? }.',
59
+ 'contract-validation-ui.json': 'Expected filename contract-validation-ui.json with { version: 1, kind: "ui", passed: boolean, reportPath?, summary? }.',
60
+ 'contract-validation-mcp.json': 'Expected filename contract-validation-mcp.json with { version: 1, kind: "mcp", passed: boolean, reportPath?, summary? }.',
61
+ };
62
+ function artifactHint(label) {
63
+ return ARTIFACT_HINTS[label] ?? '';
64
+ }
52
65
  function handoffEvidencePath(projectId, specId, filename) {
53
66
  return join(projectDataDir(projectId), 'handoffs', specId, filename);
54
67
  }
@@ -78,13 +91,13 @@ async function readOptional(args) {
78
91
  }
79
92
  const parsed = args.schema.safeParse(found.value);
80
93
  if (!parsed.success) {
81
- args.invalidArtifacts.push(`${args.label} is invalid at ${found.path}: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`);
94
+ args.invalidArtifacts.push(`${args.label} is invalid at ${found.path}: ${parsed.error.issues.map((issue) => issue.message).join('; ')} ${artifactHint(args.label)}`.trim());
82
95
  return undefined;
83
96
  }
84
97
  return parsed.data;
85
98
  }
86
99
  catch (err) {
87
- args.invalidArtifacts.push(`${args.label} is unreadable: ${err instanceof Error ? err.message : String(err)}`);
100
+ args.invalidArtifacts.push(`${args.label} is unreadable: ${err instanceof Error ? err.message : String(err)} ${artifactHint(args.label)}`.trim());
88
101
  return undefined;
89
102
  }
90
103
  }
@@ -11,10 +11,22 @@ function isNonTrivial(spec) {
11
11
  function normalizeCriterion(value) {
12
12
  return value
13
13
  .replace(/^-\s*\[[ x]\]\s*/i, '')
14
+ .replace(/^(AC\d+)\s*[:.)-]?\s*/i, '')
14
15
  .replace(/\s+/g, ' ')
15
16
  .trim()
16
17
  .toLowerCase();
17
18
  }
19
+ function criterionAliases(value, index) {
20
+ const aliases = new Set([
21
+ normalizeCriterion(value),
22
+ normalizeCriterion(`AC${String(index + 1)}`),
23
+ ]);
24
+ const explicit = /^(AC\d+)\b/i.exec(value)?.[1];
25
+ if (explicit) {
26
+ aliases.add(normalizeCriterion(explicit));
27
+ }
28
+ return [...aliases].filter((alias) => alias.length > 0);
29
+ }
18
30
  function hasText(value) {
19
31
  return value !== undefined && value.trim().length > 0;
20
32
  }
@@ -62,7 +74,7 @@ export function checkTaskPlanGate(spec, criteria, artifacts) {
62
74
  ];
63
75
  }
64
76
  const covered = new Set(artifacts.taskPlan.tasks.flatMap((task) => task.acceptanceCriteria.map(normalizeCriterion)));
65
- const uncovered = criteria.filter((criterion) => !covered.has(normalizeCriterion(criterion)));
77
+ const uncovered = criteria.filter((criterion, index) => !criterionAliases(criterion, index).some((alias) => covered.has(alias)));
66
78
  if (uncovered.length === 0) {
67
79
  return [];
68
80
  }
@@ -90,7 +102,7 @@ export function checkDoneEvidenceGate(spec, criteria, artifacts) {
90
102
  }
91
103
  else {
92
104
  const covered = new Set(matrix.rows.map((row) => normalizeCriterion(row.acceptanceCriterion)));
93
- const uncovered = criteria.filter((criterion) => !covered.has(normalizeCriterion(criterion)));
105
+ const uncovered = criteria.filter((criterion, index) => !criterionAliases(criterion, index).some((alias) => covered.has(alias)));
94
106
  if (uncovered.length > 0) {
95
107
  issues.push({
96
108
  code: 'traceability_uncovered_criteria',
@@ -0,0 +1,7 @@
1
+ import type { StableWriteOptions, StableWriteResult } from '../types/generated-artifacts.js';
2
+ export declare function withFinalNewline(content: string): string;
3
+ export declare function stripSessionMarkdownVolatileFields(content: string): string;
4
+ export declare function stableJsonStringify(value: unknown): string;
5
+ export declare function stripRootUpdatedAt(content: string): string;
6
+ export declare function stableWriteGeneratedFile(filePath: string, content: string, options?: StableWriteOptions): Promise<StableWriteResult>;
7
+ //# sourceMappingURL=generated-artifact-writer.d.ts.map
@@ -0,0 +1,63 @@
1
+ // engine/generated-artifact-writer.ts — stable writes for generated Planu artifacts.
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ export function withFinalNewline(content) {
5
+ return content.replace(/\s*$/u, '\n');
6
+ }
7
+ export function stripSessionMarkdownVolatileFields(content) {
8
+ return withFinalNewline(content)
9
+ .replace(/^# Planu Context — \d{4}-\d{2}-\d{2}$/mu, '# Planu Context — <date>')
10
+ .replace(/^> Last updated: .+$/mu, '> Last updated: <timestamp>');
11
+ }
12
+ export function stableJsonStringify(value) {
13
+ return `${JSON.stringify(value, null, 2)}\n`;
14
+ }
15
+ export function stripRootUpdatedAt(content) {
16
+ try {
17
+ const parsed = JSON.parse(content);
18
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
19
+ const clone = { ...parsed };
20
+ delete clone.updatedAt;
21
+ return stableJsonStringify(clone);
22
+ }
23
+ }
24
+ catch {
25
+ // Fall back to exact normalized text comparison for invalid existing JSON.
26
+ }
27
+ return withFinalNewline(content);
28
+ }
29
+ export async function stableWriteGeneratedFile(filePath, content, options = {}) {
30
+ const next = withFinalNewline(content);
31
+ let existing;
32
+ if (options.existingContent !== undefined) {
33
+ if (options.existingContent === null) {
34
+ await mkdir(dirname(filePath), { recursive: true });
35
+ await writeFile(filePath, next, 'utf-8');
36
+ return { written: true, reason: 'missing' };
37
+ }
38
+ existing = options.existingContent;
39
+ }
40
+ else {
41
+ try {
42
+ existing = await readFile(filePath, 'utf-8');
43
+ }
44
+ catch {
45
+ await mkdir(dirname(filePath), { recursive: true });
46
+ await writeFile(filePath, next, 'utf-8');
47
+ return { written: true, reason: 'missing' };
48
+ }
49
+ }
50
+ if (existing === next) {
51
+ return { written: false, reason: 'unchanged' };
52
+ }
53
+ const existingHasStableNewline = existing.endsWith('\n') && !existing.endsWith('\n\n');
54
+ const equivalent = options.equivalence !== undefined &&
55
+ options.equivalence(existing) === options.equivalence(next);
56
+ if (equivalent && existingHasStableNewline) {
57
+ return { written: false, reason: 'unchanged' };
58
+ }
59
+ await mkdir(dirname(filePath), { recursive: true });
60
+ await writeFile(filePath, next, 'utf-8');
61
+ return { written: true, reason: 'changed' };
62
+ }
63
+ //# sourceMappingURL=generated-artifact-writer.js.map
@@ -4,19 +4,8 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { stripFrontmatter } from './frontmatter-parser.js';
6
6
  import { hashProjectPath, projectDataDir } from '../storage/base-store.js';
7
+ import { extractAcceptanceCriteriaTexts } from './spec-format/acceptance-criteria.js';
7
8
  // ── Parsing helpers ──────────────────────────────────────────────────────────
8
- async function safeReadFile(path) {
9
- if (!path) {
10
- return '';
11
- }
12
- try {
13
- const raw = await readFile(path, 'utf-8');
14
- return stripFrontmatter(raw);
15
- }
16
- catch {
17
- return '';
18
- }
19
- }
20
9
  function extractObjective(huContent, spec) {
21
10
  if (!huContent) {
22
11
  return spec.title;
@@ -40,41 +29,6 @@ function extractObjective(huContent, spec) {
40
29
  }
41
30
  return spec.title;
42
31
  }
43
- function extractCriteria(huContent) {
44
- if (!huContent) {
45
- return [];
46
- }
47
- const bddBlocks = extractBddCriteria(huContent);
48
- if (bddBlocks.length > 0) {
49
- return bddBlocks;
50
- }
51
- return huContent
52
- .split('\n')
53
- .filter((line) => /^- \[[ x]\]/.test(line.trim()))
54
- .map((line) => line.trim().replace(/^- \[[ x]\]\s*/, ''));
55
- }
56
- function extractBddCriteria(content) {
57
- const criteria = [];
58
- let current = [];
59
- for (const rawLine of content.split('\n')) {
60
- const trimmed = rawLine.trim().replace(/^-+\s*/, '');
61
- if (/^(GIVEN|WHEN|THEN|AND)\b/i.test(trimmed)) {
62
- if (/^GIVEN\b/i.test(trimmed) && current.length > 0) {
63
- criteria.push(current.join(' / '));
64
- current = [];
65
- }
66
- current.push(trimmed);
67
- }
68
- else if (current.length > 0 && trimmed === '') {
69
- criteria.push(current.join(' / '));
70
- current = [];
71
- }
72
- }
73
- if (current.length > 0) {
74
- criteria.push(current.join(' / '));
75
- }
76
- return criteria.filter((criterion) => /GIVEN\b/i.test(criterion) && /WHEN\b/i.test(criterion) && /THEN\b/i.test(criterion));
77
- }
78
32
  function extractFilesFromFicha(fichaContent) {
79
33
  if (!fichaContent) {
80
34
  return { toModify: [], toCreate: [] };
@@ -203,13 +157,16 @@ function buildConstraints(knowledge) {
203
157
  // ── Public API ───────────────────────────────────────────────────────────────
204
158
  export async function packageHandoff(spec, knowledge) {
205
159
  const warnings = [];
206
- const huContent = await safeReadFile(spec.specPath);
160
+ const rawSpecContent = spec.specPath
161
+ ? await readFile(spec.specPath, 'utf-8').catch(() => '')
162
+ : '';
163
+ const huContent = rawSpecContent ? stripFrontmatter(rawSpecContent) : '';
207
164
  const fichaContent = huContent;
208
165
  if (!huContent) {
209
166
  warnings.push('spec.md not found — using minimal package. Run create_spec to generate it.');
210
167
  }
211
168
  const objective = extractObjective(huContent, spec);
212
- const criteria = extractCriteria(huContent);
169
+ const criteria = extractAcceptanceCriteriaTexts(rawSpecContent);
213
170
  const parsedFiles = extractFilesFromFicha(fichaContent);
214
171
  const discoveredFiles = extractBacktickedFiles(`${huContent}\n${fichaContent}`);
215
172
  const toModify = parsedFiles.toModify.length > 0 ? parsedFiles.toModify : discoveredFiles;
@@ -21,6 +21,9 @@ export function buildImplementationContractSection(input) {
21
21
  '### File-Level Work Plan',
22
22
  ...renderFilePlan(input.files),
23
23
  '',
24
+ '### Minimal Change Guidance',
25
+ ...renderMinimalChangeGuidance(),
26
+ '',
24
27
  '### Acceptance-To-Verification Map',
25
28
  ...criteria.map((criterion, index) => renderVerificationRow(criterion.text, index + 1, testFiles, verificationCommands)),
26
29
  '',
@@ -45,6 +48,13 @@ export function appendImplementationContractIfMissing(specBody, input) {
45
48
  }
46
49
  return `${specBody.trimEnd()}\n\n${buildImplementationContractSection(input)}`;
47
50
  }
51
+ function renderMinimalChangeGuidance() {
52
+ return [
53
+ '- Before implementing, check whether the work is unnecessary, already exists in this codebase, is covered by stdlib, is covered by the native platform, or is covered by an already-installed dependency.',
54
+ '- Add new code only after those checks, and keep the diff to the minimum that satisfies the accepted criteria.',
55
+ '- Do not use minimality to remove security, trust-boundary validation, data-loss handling, accessibility, explicit requirements, or required validation.',
56
+ ];
57
+ }
48
58
  function fallbackCriteria() {
49
59
  return [
50
60
  {
@@ -1,7 +1,8 @@
1
1
  // @crash-shield-ignore-file — config/cache reader for Planu-controlled JSON; writer is this codebase, shape guaranteed by build/seed.
2
2
  // engine/next-spec-resolver/session-writer.ts — SPEC-686: Best-effort session.json writer
3
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
- import { join, dirname } from 'node:path';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { stableJsonStringify, stableWriteGeneratedFile, stripRootUpdatedAt, } from '../generated-artifact-writer.js';
5
6
  // ---------------------------------------------------------------------------
6
7
  // Constants
7
8
  // ---------------------------------------------------------------------------
@@ -68,11 +69,11 @@ export async function writeOrchestrationPlan(projectPath, plan) {
68
69
  export async function patchSessionJson(projectPath, patch) {
69
70
  try {
70
71
  const filePath = join(projectPath, 'planu', 'session.json');
71
- await mkdir(dirname(filePath), { recursive: true });
72
+ let existingContent = null;
72
73
  let current = { activeSpecs: [], updatedAt: '' };
73
74
  try {
74
- const raw = await readFile(filePath, 'utf-8');
75
- current = JSON.parse(raw);
75
+ existingContent = await readFile(filePath, 'utf-8');
76
+ current = JSON.parse(existingContent);
76
77
  }
77
78
  catch {
78
79
  /* start fresh */
@@ -82,7 +83,10 @@ export async function patchSessionJson(projectPath, patch) {
82
83
  ...patch,
83
84
  updatedAt: new Date().toISOString(),
84
85
  };
85
- await writeFile(filePath, JSON.stringify(updated, null, 2), 'utf-8');
86
+ await stableWriteGeneratedFile(filePath, stableJsonStringify(updated), {
87
+ equivalence: stripRootUpdatedAt,
88
+ existingContent,
89
+ });
86
90
  }
87
91
  catch {
88
92
  /* best-effort — never throw */
@@ -6,6 +6,7 @@ import { loadReadinessConfig } from './readiness-config-loader.js';
6
6
  import { runEarsGate } from './ears-gate.js';
7
7
  import { findScenariosWithoutTests, parseFrontmatterScenarios, } from './validator/spec-compliance-runner.js';
8
8
  import { evaluateImplementationContract } from './implementation-contract/index.js';
9
+ import { extractNormalizedAcceptanceCriteria } from './spec-format/acceptance-criteria.js';
9
10
  // ── SPEC-784: Technical section quality constants ─────────────────────────────
10
11
  const TECHNICAL_MIN_CHARS = 500;
11
12
  // Detects "See technical.md", "See spec.md", or "See `<file>` technical.md" patterns
@@ -344,7 +345,8 @@ function checkSpecificityGate(spec, criteriaLines, fichaContent) {
344
345
  */
345
346
  export function checkReadinessInternal(body) {
346
347
  const bodyContent = stripFrontmatter(body);
347
- const criteriaLines = extractCriteriaLines(bodyContent);
348
+ const normalizedCriteria = extractNormalizedAcceptanceCriteria(body);
349
+ const criteriaLines = normalizedCriteria.map((criterion) => criterion.text);
348
350
  const scenarioCount = criteriaLines.length === 0 ? countFrontmatterScenarios(body) : 0;
349
351
  const criteriaCount = criteriaLines.length > 0 ? criteriaLines.length : scenarioCount;
350
352
  const config = loadReadinessConfig();
@@ -402,19 +404,23 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
402
404
  const huRaw = await readHuRaw(spec);
403
405
  const huContent = stripFrontmatter(huRaw);
404
406
  const fichaContent = await readFichaContent(spec);
405
- const criteriaLines = extractCriteriaLines(huContent);
407
+ const normalizedCriteria = extractNormalizedAcceptanceCriteria(huRaw);
408
+ const canonicalCriteriaLines = normalizedCriteria.map((criterion) => criterion.text);
409
+ const usesFrontmatterAsCanonical = normalizedCriteria.length > 0 &&
410
+ normalizedCriteria.every((criterion) => criterion.source === 'frontmatter');
406
411
  // When a spec uses BDD format, acceptance criteria are stored as `scenarios:` in
407
412
  // the YAML frontmatter (multi-line YAML that the simple KV parser cannot handle).
408
- // If no criteria were found in the body, fall back to counting frontmatter scenarios.
409
- const scenarioCriteria = criteriaLines.length === 0 ? frontmatterScenarioCriteria(huRaw) : [];
410
- const effectiveCriteriaLines = scenarioCriteria.length > 0 ? scenarioCriteria : criteriaLines;
413
+ // When those scenarios are canonical, keep their linked tests in the readiness
414
+ // evidence model so specificity gates still see exact test paths and line refs.
415
+ const scenarioCriteria = usesFrontmatterAsCanonical ? frontmatterScenarioCriteria(huRaw) : [];
416
+ const effectiveCriteriaLines = scenarioCriteria.length > 0 ? scenarioCriteria : canonicalCriteriaLines;
411
417
  const hu = scoreHuCompleteness(spec);
412
418
  const criteria = scoreCriteria(effectiveCriteriaLines, vagueWords);
413
419
  const files = scoreFilesIdentified(spec, fichaContent);
414
420
  const deps = scoreDependencies(spec);
415
421
  const totalScore = hu.points + criteria.points + files.points + deps.points;
416
422
  // SPEC-631: EARS gate — hard-block for vague modal verbs ("should/may/could/might") in criteria
417
- const earsBlockers = runEarsGate(criteriaLines);
423
+ const earsBlockers = runEarsGate(effectiveCriteriaLines);
418
424
  const allBlockers = [
419
425
  ...hu.blockers,
420
426
  ...criteria.blockers,
@@ -424,7 +430,7 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
424
430
  ];
425
431
  const allWarnings = [...hu.warnings, ...criteria.warnings, ...files.warnings, ...deps.warnings];
426
432
  if (mode === 'strict' && spec.scope !== 'trivial' && spec.difficulty >= 3) {
427
- for (const issue of evaluateImplementationContract(huContent, effectiveCriteriaLines)) {
433
+ for (const issue of evaluateImplementationContract(huContent, canonicalCriteriaLines)) {
428
434
  allBlockers.push(`${issue.code}: ${issue.message}`);
429
435
  }
430
436
  }
@@ -1,8 +1,9 @@
1
1
  // engine/session-context-generator.ts — SPEC-496: Auto-generate portable session-context.md
2
2
  // Writes planu/session-context.md so any LLM (Cursor, Windsurf, Aider…) can pick up project state.
3
- import { writeFile, readFile, mkdir } from 'node:fs/promises';
3
+ import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { specStore, lessonsStore } from '../storage/index.js';
6
+ import { stableWriteGeneratedFile, stripSessionMarkdownVolatileFields, } from './generated-artifact-writer.js';
6
7
  /** Reads the `version` field from the project's package.json, or returns 'unknown'. */
7
8
  async function readProjectVersion(projectPath) {
8
9
  try {
@@ -128,10 +129,13 @@ export async function generateSessionContext(projectPath, projectId) {
128
129
  lessonsContent: buildLessonsSection(lessons),
129
130
  generatedAt: new Date().toISOString(),
130
131
  });
131
- const planuDir = join(projectPath, 'planu');
132
- await mkdir(planuDir, { recursive: true });
133
- const contextPath = join(planuDir, 'session-context.md');
134
- await writeFile(contextPath, content, 'utf-8');
132
+ const contextPath = join(projectPath, 'planu', 'session-context.md');
133
+ const writeResult = await stableWriteGeneratedFile(contextPath, content, {
134
+ equivalence: stripSessionMarkdownVolatileFields,
135
+ });
136
+ if (!writeResult.written) {
137
+ return;
138
+ }
135
139
  if (process.env.PLANU_ENABLE_AUTOCOMMIT !== 'true') {
136
140
  return;
137
141
  }