@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
@@ -1,8 +1,9 @@
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/session-state/writer.ts — SPEC-633: Write session.json universal state file
3
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import { readFile, mkdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { execSync } from 'node:child_process';
6
+ import { stableJsonStringify, stableWriteGeneratedFile, stripRootUpdatedAt, } from '../generated-artifact-writer.js';
6
7
  const STATE_DIR = 'state';
7
8
  const ROOT_SESSION = 'session.json';
8
9
  function stateDir(projectPath) {
@@ -69,12 +70,15 @@ export async function writeSpecSessionState(projectPath, specId, specTitle, last
69
70
  resumePrompt: resumePrompt.slice(0, 300),
70
71
  updatedAt: new Date().toISOString(),
71
72
  };
72
- await writeFile(specFile(projectPath, specId), JSON.stringify(state, null, 2), 'utf-8');
73
+ await stableWriteGeneratedFile(specFile(projectPath, specId), stableJsonStringify(state), {
74
+ equivalence: stripRootUpdatedAt,
75
+ });
73
76
  // Update root session.json index
77
+ let existingIndexContent = null;
74
78
  let index = { activeSpecs: [], updatedAt: '' };
75
79
  try {
76
- const raw = await readFile(rootFile(projectPath), 'utf-8');
77
- index = JSON.parse(raw);
80
+ existingIndexContent = await readFile(rootFile(projectPath), 'utf-8');
81
+ index = JSON.parse(existingIndexContent);
78
82
  }
79
83
  catch {
80
84
  /* start fresh */
@@ -83,7 +87,10 @@ export async function writeSpecSessionState(projectPath, specId, specTitle, last
83
87
  index.activeSpecs.push(specId);
84
88
  }
85
89
  index.updatedAt = new Date().toISOString();
86
- await writeFile(rootFile(projectPath), JSON.stringify(index, null, 2), 'utf-8');
90
+ await stableWriteGeneratedFile(rootFile(projectPath), stableJsonStringify(index), {
91
+ equivalence: stripRootUpdatedAt,
92
+ existingContent: existingIndexContent,
93
+ });
87
94
  // Auto-commit planu/ changes so session.json is never left unstaged
88
95
  void (async () => {
89
96
  try {
@@ -127,10 +134,11 @@ export async function updateEpicProgressInSession(projectPath, specs) {
127
134
  return;
128
135
  }
129
136
  const filePath = rootFile(projectPath);
137
+ let existingContent = null;
130
138
  let index = { activeSpecs: [], updatedAt: '' };
131
139
  try {
132
- const raw = await readFile(filePath, 'utf-8');
133
- index = JSON.parse(raw);
140
+ existingContent = await readFile(filePath, 'utf-8');
141
+ index = JSON.parse(existingContent);
134
142
  }
135
143
  catch {
136
144
  /* start fresh */
@@ -141,7 +149,10 @@ export async function updateEpicProgressInSession(projectPath, specs) {
141
149
  index.activeSpecs = index.activeSpecs.filter((specId) => !terminalSpecIds.has(specId));
142
150
  index.epicProgress = epicProgress;
143
151
  index.updatedAt = new Date().toISOString();
144
- await writeFile(filePath, JSON.stringify(index, null, 2), 'utf-8');
152
+ await stableWriteGeneratedFile(filePath, stableJsonStringify(index), {
153
+ equivalence: stripRootUpdatedAt,
154
+ existingContent,
155
+ });
145
156
  // Auto-commit planu/ changes so session.json is never left unstaged
146
157
  void (async () => {
147
158
  try {
@@ -166,10 +177,11 @@ export async function patchSessionJson(projectPath, patch) {
166
177
  try {
167
178
  const filePath = rootFile(projectPath);
168
179
  await mkdir(stateDir(projectPath), { recursive: true });
180
+ let existingContent = null;
169
181
  let index = { activeSpecs: [], updatedAt: '' };
170
182
  try {
171
- const raw = await readFile(filePath, 'utf-8');
172
- index = JSON.parse(raw);
183
+ existingContent = await readFile(filePath, 'utf-8');
184
+ index = JSON.parse(existingContent);
173
185
  }
174
186
  catch {
175
187
  /* start fresh */
@@ -179,7 +191,10 @@ export async function patchSessionJson(projectPath, patch) {
179
191
  ...patch,
180
192
  updatedAt: new Date().toISOString(),
181
193
  };
182
- await writeFile(filePath, JSON.stringify(updated, null, 2), 'utf-8');
194
+ await stableWriteGeneratedFile(filePath, stableJsonStringify(updated), {
195
+ equivalence: stripRootUpdatedAt,
196
+ existingContent,
197
+ });
183
198
  // Auto-commit planu/ changes so session.json is never left unstaged
184
199
  void (async () => {
185
200
  try {
@@ -200,11 +215,15 @@ export async function patchSessionJson(projectPath, patch) {
200
215
  */
201
216
  export async function removeSpecFromSession(projectPath, specId) {
202
217
  try {
203
- const raw = await readFile(rootFile(projectPath), 'utf-8');
204
- const index = JSON.parse(raw);
218
+ const filePath = rootFile(projectPath);
219
+ const existingContent = await readFile(filePath, 'utf-8');
220
+ const index = JSON.parse(existingContent);
205
221
  index.activeSpecs = index.activeSpecs.filter((id) => id !== specId);
206
222
  index.updatedAt = new Date().toISOString();
207
- await writeFile(rootFile(projectPath), JSON.stringify(index, null, 2), 'utf-8');
223
+ await stableWriteGeneratedFile(filePath, stableJsonStringify(index), {
224
+ equivalence: stripRootUpdatedAt,
225
+ existingContent,
226
+ });
208
227
  // Auto-commit planu/ changes so session.json is never left unstaged
209
228
  void (async () => {
210
229
  try {
@@ -0,0 +1,5 @@
1
+ import type { NormalizedAcceptanceCriterion } from '../../types/spec-format.js';
2
+ export declare function extractNormalizedAcceptanceCriteria(raw: string): NormalizedAcceptanceCriterion[];
3
+ export declare function extractAcceptanceCriteriaTexts(raw: string): string[];
4
+ export declare function normalizeAcceptanceCriterionToken(value: string): string;
5
+ //# sourceMappingURL=acceptance-criteria.d.ts.map
@@ -0,0 +1,106 @@
1
+ import { parseFrontmatterScenarios } from '../validator/spec-compliance-runner.js';
2
+ import { stripFrontmatter } from '../frontmatter-parser.js';
3
+ import { extractListItems, extractSection } from '../validator/extractors.js';
4
+ function normalizeCriterionValue(value) {
5
+ return value
6
+ .replace(/^-\s*\[[ x]\]\s*/i, '')
7
+ .replace(/[`*_()[\]:]/g, ' ')
8
+ .replace(/\s+/g, ' ')
9
+ .trim()
10
+ .toLowerCase();
11
+ }
12
+ function stripCriterionIdPrefix(value) {
13
+ return value.replace(/^(AC\d+)\s*[:.)-]?\s*/i, '').trim();
14
+ }
15
+ function detectCriterionId(value, index) {
16
+ const explicit = /^(AC\d+)\b/i.exec(value)?.[1];
17
+ return (explicit ?? `AC${String(index + 1)}`).toUpperCase();
18
+ }
19
+ function extractSectionCriteria(section) {
20
+ const listItems = extractListItems(section);
21
+ if (listItems.length > 0) {
22
+ return listItems;
23
+ }
24
+ const lines = section.split('\n').map((line) => line.trim());
25
+ if (lines.length === 0) {
26
+ return [];
27
+ }
28
+ const bddBlocks = [];
29
+ let current = [];
30
+ for (const line of lines) {
31
+ if (line.length === 0) {
32
+ if (current.length > 0) {
33
+ bddBlocks.push(current.join(' '));
34
+ current = [];
35
+ }
36
+ continue;
37
+ }
38
+ if (/^(GIVEN|WHEN|THEN|AND|DADO|CUANDO|ENTONCES|Y)\b/i.test(line)) {
39
+ if (/^(GIVEN|DADO)\b/i.test(line) && current.length > 0) {
40
+ bddBlocks.push(current.join(' '));
41
+ current = [];
42
+ }
43
+ current.push(line.replace(/^[-*+]\s*/, ''));
44
+ continue;
45
+ }
46
+ if (current.length > 0) {
47
+ current.push(line);
48
+ }
49
+ }
50
+ if (current.length > 0) {
51
+ bddBlocks.push(current.join(' '));
52
+ }
53
+ return bddBlocks;
54
+ }
55
+ function extractBodyCriteria(raw) {
56
+ const body = stripFrontmatter(raw);
57
+ const acceptanceCriteria = extractSection(body, 'acceptance criteria', 'criterios de aceptaci');
58
+ if (acceptanceCriteria) {
59
+ const sectionCriteria = extractSectionCriteria(acceptanceCriteria);
60
+ if (sectionCriteria.length > 0) {
61
+ return sectionCriteria;
62
+ }
63
+ }
64
+ return body
65
+ .split('\n')
66
+ .map((line) => line.trim())
67
+ .filter((line) => /^-\s*\[[ x]\]/i.test(line) || /^- .*\bgiven\b.*\bwhen\b.*\bthen\b/i.test(line))
68
+ .map((line) => line.replace(/^-\s*\[[ x]\]\s*/i, '').trim());
69
+ }
70
+ function buildCriterion(text, source, index) {
71
+ const trimmed = text.trim();
72
+ if (trimmed.length === 0) {
73
+ return null;
74
+ }
75
+ const id = detectCriterionId(trimmed, index);
76
+ const withoutId = stripCriterionIdPrefix(trimmed);
77
+ const aliases = new Set([
78
+ normalizeCriterionValue(trimmed),
79
+ normalizeCriterionValue(withoutId),
80
+ normalizeCriterionValue(id),
81
+ ]);
82
+ return {
83
+ id,
84
+ text: withoutId,
85
+ source,
86
+ aliases: [...aliases].filter((alias) => alias.length > 0),
87
+ };
88
+ }
89
+ export function extractNormalizedAcceptanceCriteria(raw) {
90
+ const bodyCriteria = extractBodyCriteria(raw)
91
+ .map((criterion, index) => buildCriterion(criterion, 'body', index))
92
+ .filter((criterion) => criterion !== null);
93
+ if (bodyCriteria.length > 0) {
94
+ return bodyCriteria;
95
+ }
96
+ return parseFrontmatterScenarios(raw)
97
+ .map((scenario, index) => buildCriterion(scenario.title, 'frontmatter', index))
98
+ .filter((criterion) => criterion !== null);
99
+ }
100
+ export function extractAcceptanceCriteriaTexts(raw) {
101
+ return extractNormalizedAcceptanceCriteria(raw).map((criterion) => criterion.text);
102
+ }
103
+ export function normalizeAcceptanceCriterionToken(value) {
104
+ return normalizeCriterionValue(stripCriterionIdPrefix(value));
105
+ }
106
+ //# sourceMappingURL=acceptance-criteria.js.map
@@ -133,6 +133,13 @@ async function walkSpecDirectory(projectPath, specDir, result) {
133
133
  }
134
134
  }
135
135
  }
136
+ async function shouldDeleteOrphanSpecDir(specDir) {
137
+ const entries = await readdir(specDir).catch(() => []);
138
+ if (entries.includes('spec.md')) {
139
+ return false;
140
+ }
141
+ return !entries.some((entry) => mustMergeBeforeDeleteSpecFile(entry));
142
+ }
136
143
  export async function runStrictPlanuCleanup(projectPath) {
137
144
  const result = { deleted: [], merged: [], gitignoreUpdated: false };
138
145
  const planuDir = join(projectPath, 'planu');
@@ -170,6 +177,11 @@ export async function runStrictPlanuCleanup(projectPath) {
170
177
  result.deleted.push(relative(projectPath, full));
171
178
  continue;
172
179
  }
180
+ if (await shouldDeleteOrphanSpecDir(full)) {
181
+ await removePath(projectPath, full);
182
+ result.deleted.push(relative(projectPath, full));
183
+ continue;
184
+ }
173
185
  await walkSpecDirectory(projectPath, full, result);
174
186
  }
175
187
  result.gitignoreUpdated = await updateGitignore(projectPath);
@@ -210,6 +222,10 @@ export async function validateStrictPlanuLayout(projectPath, options = {}) {
210
222
  offenders.push(relative(projectPath, full));
211
223
  continue;
212
224
  }
225
+ if (await shouldDeleteOrphanSpecDir(full)) {
226
+ offenders.push(relative(projectPath, full));
227
+ continue;
228
+ }
213
229
  for (const entry of await readdir(full).catch(() => [])) {
214
230
  const entryPath = join(full, entry);
215
231
  const isDir = await pathIsDirectory(entryPath);
@@ -9,6 +9,7 @@ import { planuBddCriteriaRule } from './rules/planu-bdd-criteria.js';
9
9
  import { planuApprovalGatesRule } from './rules/planu-approval-gates.js';
10
10
  import { planuReleasePolicyRule } from './rules/planu-release-policy.js';
11
11
  import { planuSddModelRoutingRule } from './rules/planu-sdd-model-routing.js';
12
+ import { planuMinimalChangeRule } from './rules/planu-minimal-change.js';
12
13
  /**
13
14
  * The full catalog of universal Planu rules.
14
15
  * Order matters: rules are installed in catalog order.
@@ -19,6 +20,7 @@ export const UNIVERSAL_RULES = [
19
20
  planuBddCriteriaRule,
20
21
  planuApprovalGatesRule,
21
22
  planuSddModelRoutingRule,
23
+ planuMinimalChangeRule,
22
24
  planuReleasePolicyRule,
23
25
  planuModesRule,
24
26
  agentTeamsRule,
@@ -0,0 +1,3 @@
1
+ import type { UniversalRule } from '../../../types/universal-rules/index.js';
2
+ export declare const planuMinimalChangeRule: UniversalRule;
3
+ //# sourceMappingURL=planu-minimal-change.d.ts.map
@@ -0,0 +1,44 @@
1
+ // engine/universal-rules/rules/planu-minimal-change.ts
2
+ // Universal rule: minimal-change implementation discipline.
3
+ function buildBody() {
4
+ return `# Planu Minimal Change
5
+
6
+ Use the smallest correct change after reading the task and the code path it touches.
7
+
8
+ Before writing code, stop at the first rung that holds:
9
+
10
+ 1. Does this need to be built at all? If not, do not build it.
11
+ 2. Does this codebase already have the helper, utility, convention, or pattern? Reuse it.
12
+ 3. Does the standard library already do this? Use it.
13
+ 4. Does the native platform already cover it? Use it.
14
+ 5. Does an already-installed dependency solve it? Use it.
15
+ 6. Can this be one clear line? Use one clear line.
16
+ 7. Only then, write the minimum code that satisfies the approved spec.
17
+
18
+ The ladder runs after understanding, not instead of understanding. Read the relevant files, trace the real flow, and fix shared root causes rather than patching one visible symptom per caller.
19
+
20
+ Prefer deletion over addition, boring over clever, and fewer touched files over broader refactors. Do not add abstractions, dependencies, configuration, feature flags, compatibility paths, or future-proofing unless the approved spec explicitly requires them.
21
+
22
+ Minimal does not mean unsafe. Never remove or weaken:
23
+
24
+ - security checks
25
+ - trust-boundary validation
26
+ - data-loss error handling
27
+ - accessibility
28
+ - explicit user requirements
29
+ - required tests or validation commands
30
+ - SDD approval, evidence, review, or done gates
31
+
32
+ If a deliberate shortcut has a known ceiling, record the ceiling and upgrade trigger as structured Planu debt evidence instead of leaving a vague comment.
33
+ `;
34
+ }
35
+ export const planuMinimalChangeRule = {
36
+ id: 'planu-minimal-change',
37
+ name: 'Planu Minimal Change',
38
+ description: 'Smallest-correct-change ladder with explicit safety boundaries.',
39
+ category: 'quality',
40
+ applicableHosts: ['all'],
41
+ defaultEnabled: true,
42
+ buildContent: (_host) => buildBody(),
43
+ };
44
+ //# sourceMappingURL=planu-minimal-change.js.map
@@ -6,23 +6,26 @@ Auto-generated by \`init_project\`. Do not edit manually.
6
6
 
7
7
  ## Rule
8
8
 
9
- When implementation changes are complete and validated, always address release explicitly:
9
+ When implementation changes are complete and validated, always address release explicitly with a local-first flow:
10
10
 
11
- - create the release when the user has already requested shipping or publishing
12
- - otherwise ask before publishing, tagging, or pushing externally visible release artifacts
11
+ - canonical local gates: \`pnpm validate\` then \`pnpm check:strict\`
12
+ - canonical local release command: \`pnpm release:local\`
13
+ - publish externally only when the user has explicitly requested shipping/publishing
14
+ - use \`main\` as the default release branch; treat \`develop\` as optional gitflow compatibility only when the repository actively uses it
13
15
 
14
16
  ## Required Release Checklist
15
17
 
16
- - tests and validation commands passed
18
+ - local validation commands passed
17
19
  - changelog or release notes updated when applicable
18
20
  - package version and git tag stay aligned
19
- - main/develop branch state is reconciled when the repository uses both
21
+ - release metadata under \`planu/releases/pending.json\` contains only genuinely pending entries
20
22
  - published package smoke check is run after release when applicable
21
23
 
22
24
  ## Hard Blocks
23
25
 
24
26
  - Do not silently skip release discussion after completed product/runtime changes.
25
27
  - Do not publish without validation evidence.
28
+ - Do not let hosted CI, branch protection, or release bots redefine the canonical local release contract.
26
29
  - Do not leave release notes promising capabilities that are not in the public runtime.
27
30
  `;
28
31
  }
@@ -47,7 +47,7 @@ export declare const LIGHTWEIGHT_COMMANDS: readonly [{
47
47
  }, {
48
48
  readonly id: "planu.release.check";
49
49
  readonly title: "Check release readiness";
50
- readonly description: "Check local branch cleanliness and main/develop/release sync readiness.";
50
+ readonly description: "Check local-first release readiness, branch cleanliness, and optional gitflow drift.";
51
51
  readonly invocation: "planu release check";
52
52
  readonly hosts: readonly ["codex", "claude-code"];
53
53
  readonly requiresMcp: false;
@@ -54,7 +54,7 @@ export const LIGHTWEIGHT_COMMANDS = [
54
54
  {
55
55
  id: 'planu.release.check',
56
56
  title: 'Check release readiness',
57
- description: 'Check local branch cleanliness and main/develop/release sync readiness.',
57
+ description: 'Check local-first release readiness, branch cleanliness, and optional gitflow drift.',
58
58
  invocation: 'planu release check',
59
59
  hosts: ['codex', 'claude-code'],
60
60
  requiresMcp: false,
@@ -93,8 +93,9 @@ function getSddProcess() {
93
93
  id: 'documenting',
94
94
  name: '8. Document & Ship',
95
95
  tools: ['generate_docs', 'generate_docs_site', 'integrate_pm', 'learn_pattern'],
96
- description: 'Generate documentation. Sync with project management tools. ' +
97
- 'Learn patterns for future estimations.',
96
+ description: 'Generate documentation, run the local-first release contract ' +
97
+ '(`pnpm validate`, `pnpm check:strict`, `pnpm release:local` when shipping), ' +
98
+ 'sync with project management tools, and learn patterns for future estimations.',
98
99
  },
99
100
  ],
100
101
  specLifecycle: ['draft', 'review', 'approved', 'implementing', 'done'],
@@ -6,12 +6,8 @@ import { detectBrokers, getBrokerConfig, requiresIdempotence, } from '../../engi
6
6
  * Returns true when the spec or project has event-driven characteristics.
7
7
  */
8
8
  export function isEventDrivenSpec(spec, specContent, knowledge) {
9
- const brokers = detectBrokers(knowledge.stack);
10
- if (brokers.length > 0 && brokers[0] !== 'unknown') {
11
- return true;
12
- }
13
9
  const lower = `${spec.title} ${spec.tags.join(' ')} ${specContent}`.toLowerCase();
14
- return (lower.includes('event') ||
10
+ const specSignals = lower.includes('event') ||
15
11
  lower.includes('kafka') ||
16
12
  lower.includes('rabbitmq') ||
17
13
  lower.includes('pubsub') ||
@@ -21,7 +17,15 @@ export function isEventDrivenSpec(spec, specContent, knowledge) {
21
17
  lower.includes('sns') ||
22
18
  lower.includes('message') ||
23
19
  lower.includes('consumer') ||
24
- lower.includes('producer'));
20
+ lower.includes('producer');
21
+ if (spec.target === 'frontend' && !specSignals) {
22
+ return false;
23
+ }
24
+ const brokers = detectBrokers(knowledge.stack);
25
+ if (specSignals) {
26
+ return true;
27
+ }
28
+ return brokers.length > 0 && brokers[0] !== 'unknown' && spec.target !== 'frontend';
25
29
  }
26
30
  /**
27
31
  * Generate event-driven failure scenarios for challenge_spec.
@@ -265,7 +265,7 @@ export async function handleSetupHooks(projectId, config) {
265
265
  /* v8 ignore start */
266
266
  const protectedBranches = mergedConfig.protectedBranches ?? DEFAULT_PROTECTED_BRANCHES;
267
267
  const stalenessThreshold = mergedConfig.stalenessThreshold ?? 50;
268
- const baseBranch = mergedConfig.baseBranch ?? 'develop';
268
+ const baseBranch = mergedConfig.baseBranch ?? 'main';
269
269
  /* v8 ignore end */
270
270
  const preCommitScript = buildPreCommitScript(
271
271
  /* v8 ignore next */ mergedConfig.requireSpecInCommit ?? true, stalenessThreshold, baseBranch);
@@ -63,6 +63,7 @@ Planu enforces Spec Driven Development — spec first, then implement, then vali
63
63
 
64
64
  - NEVER write production code before spec is \`approved\`
65
65
  - NEVER skip \`validate\` after implementation
66
+ - Specs stay \`spec.md\`-only. Do not recreate standalone \`technical.md\` or \`progress.md\`.
66
67
  `;
67
68
  const CODEX_NATIVE_SKILL_CONTENT = `# planu-native — Planu Lightweight Native Surface
68
69
 
@@ -79,7 +80,7 @@ Use Planu's lightweight CLI surface for common SDD checks without loading the fu
79
80
  - \`planu spec list\` — list specs
80
81
  - \`planu spec validate SPEC-001\` — validate one spec
81
82
  - \`planu audit debt\` — read-only technical debt audit
82
- - \`planu release check\` — local release readiness check
83
+ - \`planu release check\` — local-first release readiness check
83
84
 
84
85
  ## Escalate to Full MCP
85
86
 
@@ -20,6 +20,19 @@ Auto-generated by \`init_project\`. Do not edit manually.
20
20
 
21
21
  Flow: \`facilitate → create_spec → challenge_spec → check_readiness → approve → implement → validate → done\`
22
22
 
23
+ ## Evidence Gates
24
+
25
+ For non-trivial specs, Planu blocks lifecycle transitions unless evidence is present:
26
+
27
+ | Transition | Required evidence |
28
+ |------------|-------------------|
29
+ | \`approved\` | Discovery evidence: rules, examples, open questions, out-of-scope, glossary |
30
+ | \`implementing\` | Task plan mapped to acceptance criteria |
31
+ | \`done\` | Traceability matrix covering every acceptance criterion |
32
+ | \`done\` for API/UI/event/MCP work | Passing contract validation evidence |
33
+
34
+ Evidence lives in the external Planu handoff store, not under \`planu/specs/<spec>/\`. Spec folders remain \`spec.md\`-only.
35
+
23
36
  ## When to use \`facilitate\`
24
37
 
25
38
  Any new feature, bug fix, refactor, integration, or task > 5 min.
@@ -51,6 +64,7 @@ Any new feature, bug fix, refactor, integration, or task > 5 min.
51
64
  - NEVER write code before spec is \`approved\`
52
65
  - NEVER skip \`validate\` after implementation
53
66
  - NEVER create design docs outside \`planu/specs/\`
67
+ - NEVER recreate legacy \`technical.md\`, \`progress.md\`, or \`HU.md\` files for new specs
54
68
 
55
69
  ## Model Selection (MANDATORY)
56
70
 
@@ -1,12 +1,12 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { isAbsolute, join } from 'node:path';
3
- import { extractCriteria, extractListItems, extractSection, } from '../../engine/validator/extractors.js';
4
- import { stripFrontmatter } from '../../engine/frontmatter-parser.js';
3
+ import { extractCriteria } from '../../engine/validator/extractors.js';
5
4
  import { readEvidenceArtifacts } from '../../engine/evidence-gates/artifact-reader.js';
6
5
  import { checkLifecycleEvidenceGate } from '../../engine/evidence-gates/lifecycle-gate.js';
7
6
  import { buildSpecEvidenceIndex } from '../../engine/evidence-index/index-builder.js';
8
7
  import { checkDoneDriftContract } from '../../engine/evidence-index/done-drift.js';
9
8
  import { parseTechnicalReferenceGroundingRecords } from '../../engine/spec-grounding/contract.js';
9
+ import { extractAcceptanceCriteriaTexts } from '../../engine/spec-format/acceptance-criteria.js';
10
10
  /** SPEC-1054: BDD/SDD lifecycle evidence gate. */
11
11
  export async function checkLifecycleEvidenceTransitionGate(args) {
12
12
  const [criteriaResult, artifacts] = await Promise.all([
@@ -54,13 +54,9 @@ async function extractLifecycleGateCriteriaAndBody(spec, projectPath) {
54
54
  const specPath = isAbsolute(spec.specPath) || !projectPath ? spec.specPath : join(projectPath, spec.specPath);
55
55
  try {
56
56
  const raw = await readFile(specPath, 'utf-8');
57
- const body = stripFrontmatter(raw);
58
- const acceptanceCriteria = extractSection(body, 'acceptance criteria', 'criterios de aceptaci');
59
- if (acceptanceCriteria) {
60
- const items = extractListItems(acceptanceCriteria);
61
- if (items.length > 0) {
62
- return { criteria: [...new Set(items)], body: raw };
63
- }
57
+ const criteria = extractAcceptanceCriteriaTexts(raw);
58
+ if (criteria.length > 0) {
59
+ return { criteria: [...new Set(criteria)], body: raw };
64
60
  }
65
61
  }
66
62
  catch {
@@ -35,6 +35,11 @@ export type CoreActionResult = {
35
35
  ok: false;
36
36
  error: string;
37
37
  };
38
+ export interface PendingReleaseEntry {
39
+ specId: string;
40
+ title: string;
41
+ completedAt: string;
42
+ }
38
43
  /** SPEC-772: Result of a single fast hook execution. */
39
44
  export interface FastHookResult {
40
45
  hookId: string;
@@ -0,0 +1,13 @@
1
+ export interface StableWriteResult {
2
+ written: boolean;
3
+ reason: 'changed' | 'missing' | 'unchanged';
4
+ }
5
+ export interface StableWriteOptions {
6
+ /**
7
+ * Normalize content for semantic comparison. The original content is still
8
+ * written when formatting differs, but volatile-only differences can be skipped.
9
+ */
10
+ equivalence?: (content: string) => string;
11
+ existingContent?: string | null;
12
+ }
13
+ //# sourceMappingURL=generated-artifacts.d.ts.map
@@ -0,0 +1,3 @@
1
+ // types/generated-artifacts.ts — stable generated artifact write contracts.
2
+ export {};
3
+ //# sourceMappingURL=generated-artifacts.js.map
@@ -27,6 +27,12 @@ export interface BddScenario {
27
27
  line?: number;
28
28
  }[];
29
29
  }
30
+ export interface NormalizedAcceptanceCriterion {
31
+ id: string;
32
+ text: string;
33
+ source: 'body' | 'frontmatter';
34
+ aliases: string[];
35
+ }
30
36
  /** Input for lean spec generation. */
31
37
  export interface LeanSpecInput {
32
38
  spec: Spec;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.7.3",
3
+ "version": "4.7.5",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -34,14 +34,14 @@
34
34
  "packageName": "@planu/core"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@planu/core-darwin-arm64": "4.7.3",
38
- "@planu/core-darwin-x64": "4.7.3",
39
- "@planu/core-linux-arm64-gnu": "4.7.3",
40
- "@planu/core-linux-arm64-musl": "4.7.3",
41
- "@planu/core-linux-x64-gnu": "4.7.3",
42
- "@planu/core-linux-x64-musl": "4.7.3",
43
- "@planu/core-win32-arm64-msvc": "4.7.3",
44
- "@planu/core-win32-x64-msvc": "4.7.3"
37
+ "@planu/core-darwin-arm64": "4.7.5",
38
+ "@planu/core-darwin-x64": "4.7.5",
39
+ "@planu/core-linux-arm64-gnu": "4.7.5",
40
+ "@planu/core-linux-arm64-musl": "4.7.5",
41
+ "@planu/core-linux-x64-gnu": "4.7.5",
42
+ "@planu/core-linux-x64-musl": "4.7.5",
43
+ "@planu/core-win32-arm64-msvc": "4.7.5",
44
+ "@planu/core-win32-x64-msvc": "4.7.5"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.0.0"
@@ -168,8 +168,8 @@
168
168
  }
169
169
  },
170
170
  "devDependencies": {
171
- "@commitlint/cli": "^21.0.2",
172
- "@commitlint/config-conventional": "^21.0.2",
171
+ "@commitlint/cli": "^21.1.0",
172
+ "@commitlint/config-conventional": "^21.1.0",
173
173
  "@eslint/js": "^10.0.1",
174
174
  "@napi-rs/cli": "^3.7.2",
175
175
  "@secretlint/secretlint-rule-no-homedir": "^13.0.2",
@@ -183,7 +183,7 @@
183
183
  "@stryker-mutator/core": "^9.6.1",
184
184
  "@stryker-mutator/vitest-runner": "^9.6.1",
185
185
  "@supabase/supabase-js": "^2.108.2",
186
- "@types/node": "^25.9.3",
186
+ "@types/node": "^26.0.0",
187
187
  "@vitejs/plugin-vue": "^6.0.7",
188
188
  "@vitest/coverage-v8": "^4.1.9",
189
189
  "@vue/test-utils": "^2.4.11",
@@ -194,8 +194,8 @@
194
194
  "happy-dom": "^20.10.6",
195
195
  "husky": "^9.1.7",
196
196
  "javascript-obfuscator": "^5.4.3",
197
- "knip": "^6.17.1",
198
- "lint-staged": "^17.0.7",
197
+ "knip": "^6.20.0",
198
+ "lint-staged": "^17.0.8",
199
199
  "madge": "^8.0.0",
200
200
  "prettier": "^3.8.4",
201
201
  "secretlint": "^13.0.2",
@@ -203,8 +203,8 @@
203
203
  "tsc-alias": "^1.8.17",
204
204
  "type-coverage": "^2.29.7",
205
205
  "typescript": "^6.0.3",
206
- "typescript-eslint": "^8.61.1",
207
- "vite": "^8.0.16",
206
+ "typescript-eslint": "^8.62.0",
207
+ "vite": "^8.1.0",
208
208
  "vitest": "^4.1.9",
209
209
  "vue": "^3.5.38"
210
210
  }