@planu/cli 5.7.4 → 5.7.6

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 (208) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +3 -3
  3. package/dist/.planu-build.json +1 -1
  4. package/dist/cli/commands/package-handoff.js +30 -5
  5. package/dist/config/compliance-profiles.json +36 -3
  6. package/dist/config/environment-schema.json +21 -0
  7. package/dist/config/hook-templates/planu-session-safeguard.sh +22 -15
  8. package/dist/config/official-sdd-tools.d.ts +1 -1
  9. package/dist/config/official-sdd-tools.js +11 -24
  10. package/dist/config/registries/hosts/codex.json +14 -30
  11. package/dist/config/registries/hosts/opencode.json +1 -6
  12. package/dist/config/registries/legacy-guidance.json +26 -0
  13. package/dist/config/server-instructions.js +0 -21
  14. package/dist/config/skill-templates/planu-context-assets.md +3 -6
  15. package/dist/config/skill-templates/planu-implement.md +2 -2
  16. package/dist/config/skill-templates/planu-multi-teammate-review.md +1 -1
  17. package/dist/config/skill-templates/planu-release.md +2 -7
  18. package/dist/config/skill-templates/planu-validate.md +3 -3
  19. package/dist/config/spec-templates/crud-rest-api/template.json +5 -1
  20. package/dist/config/spec-templates/file-upload-s3/template.json +5 -1
  21. package/dist/config/spec-templates/stripe-payments/template.json +5 -1
  22. package/dist/config/spec-templates/webhook-system/template.json +5 -1
  23. package/dist/config/subagent-templates/planu-challenger.md +4 -9
  24. package/dist/config/subagent-templates/planu-spec-implementer.md +2 -4
  25. package/dist/config/subagent-templates/planu-validator.md +5 -8
  26. package/dist/config/tool-groups.json +6 -144
  27. package/dist/engine/ambiguity-scorer.js +40 -1
  28. package/dist/engine/autopilot/bootstrap.js +1 -1
  29. package/dist/engine/code-impact-analyzer.js +2 -6
  30. package/dist/engine/context-orchestrator/index.js +2 -2
  31. package/dist/engine/convention-scanner/codebase-scanner.js +2 -2
  32. package/dist/engine/dogfooding/runtime-gap-detector.js +2 -2
  33. package/dist/engine/evidence-gates/artifact-reader.d.ts +1 -0
  34. package/dist/engine/evidence-gates/artifact-reader.js +1 -1
  35. package/dist/engine/evidence-gates/evidence-autofill.d.ts +13 -5
  36. package/dist/engine/evidence-gates/evidence-autofill.js +106 -43
  37. package/dist/engine/evidence-gates/lifecycle-gate.js +7 -4
  38. package/dist/engine/evidence-index/index-builder.js +21 -4
  39. package/dist/engine/execution/job-runtime.js +1 -1
  40. package/dist/engine/execution/package-handoff-job-executor.d.ts +22 -0
  41. package/dist/engine/execution/package-handoff-job-executor.js +490 -0
  42. package/dist/engine/execution/validate-job-executor.js +20 -8
  43. package/dist/engine/git/exec-git.d.ts +4 -0
  44. package/dist/engine/git/exec-git.js +14 -0
  45. package/dist/engine/handoff-artifacts/implementation-review-reader.js +15 -2
  46. package/dist/engine/handoff-artifacts/schemas.d.ts +2 -0
  47. package/dist/engine/handoff-artifacts/schemas.js +1 -0
  48. package/dist/engine/handoff-format.d.ts +36 -0
  49. package/dist/engine/handoff-format.js +314 -0
  50. package/dist/engine/handoff-packager.d.ts +5 -3
  51. package/dist/engine/handoff-packager.js +89 -32
  52. package/dist/engine/host-tool-filter.js +20 -49
  53. package/dist/engine/human-summary.js +14 -3
  54. package/dist/engine/implementation-contract/common.d.ts +8 -3
  55. package/dist/engine/implementation-contract/common.js +9 -6
  56. package/dist/engine/implementation-contract/evaluator.js +4 -6
  57. package/dist/engine/implementation-contract/renderer.js +18 -23
  58. package/dist/engine/project-health-checker.js +2 -2
  59. package/dist/engine/readiness-checker.js +27 -0
  60. package/dist/engine/reconcile/apply-changes.js +4 -0
  61. package/dist/engine/reconcile/propagate-mirrors.d.ts +3 -0
  62. package/dist/engine/reconcile/propagate-mirrors.js +311 -0
  63. package/dist/engine/self-healing/healer.js +12 -5
  64. package/dist/engine/self-healing/strategies/lint-fix.d.ts +1 -1
  65. package/dist/engine/self-healing/strategies/lint-fix.js +4 -1
  66. package/dist/engine/self-healing/strategies/test-fix.d.ts +4 -3
  67. package/dist/engine/self-healing/strategies/test-fix.js +32 -14
  68. package/dist/engine/self-healing/strategies/typescript-fix.d.ts +1 -1
  69. package/dist/engine/self-healing/strategies/typescript-fix.js +28 -10
  70. package/dist/engine/session/checkpoint-writer.js +8 -4
  71. package/dist/engine/session/session-tracker.d.ts +5 -16
  72. package/dist/engine/session/session-tracker.js +29 -26
  73. package/dist/engine/session-state/writer.js +3 -11
  74. package/dist/engine/skill-generator/workflow-skill-generator.d.ts +4 -2
  75. package/dist/engine/skill-generator/workflow-skill-generator.js +113 -38
  76. package/dist/engine/spec-format/bdd-parser.d.ts +1 -11
  77. package/dist/engine/spec-format/bdd-parser.js +40 -11
  78. package/dist/engine/spec-format/lean-spec-generator.js +30 -10
  79. package/dist/engine/spec-format/retired-scaffold.d.ts +3 -0
  80. package/dist/engine/spec-format/retired-scaffold.js +5 -0
  81. package/dist/engine/spec-format/technical-md-populator.d.ts +1 -0
  82. package/dist/engine/spec-format/technical-md-populator.js +27 -0
  83. package/dist/engine/spec-format/unified-spec-builder.d.ts +1 -0
  84. package/dist/engine/spec-format/unified-spec-builder.js +30 -20
  85. package/dist/engine/spec-grounding/contract.js +115 -1
  86. package/dist/engine/spec-migrator/fold-technical.js +60 -19
  87. package/dist/engine/spec-migrator/placeholder-technical.d.ts +2 -0
  88. package/dist/engine/spec-migrator/placeholder-technical.js +18 -0
  89. package/dist/engine/spec-quality/generic-output-gate.js +37 -3
  90. package/dist/engine/technical-enricher/index.js +4 -1
  91. package/dist/engine/type-safety-gate.js +3 -11
  92. package/dist/engine/universal-rules/catalog.js +0 -2
  93. package/dist/engine/universal-rules/rules/planu-workflow.js +1 -1
  94. package/dist/engine/validation/durable-validation.d.ts +3 -2
  95. package/dist/engine/validation/durable-validation.js +106 -40
  96. package/dist/engine/validation/validation-freshness.d.ts +3 -2
  97. package/dist/engine/validation/validation-freshness.js +22 -16
  98. package/dist/engine/validation/validation-worktree.js +34 -0
  99. package/dist/hosts/claude-code/ux/mcp-prompts.js +8 -8
  100. package/dist/index.js +3 -17
  101. package/dist/resources/process.js +38 -65
  102. package/dist/storage/session-state-store.js +2 -0
  103. package/dist/tools/challenge-spec/scenarios-utils.js +2 -2
  104. package/dist/tools/configure-checkpoint-policy.js +3 -3
  105. package/dist/tools/create-spec-helpers.js +2 -5
  106. package/dist/tools/create-spec.js +21 -4
  107. package/dist/tools/facilitate.js +3 -6
  108. package/dist/tools/git/branch-ops.js +16 -4
  109. package/dist/tools/github-release-handler.js +1 -20
  110. package/dist/tools/heal-spec-docs.js +1 -13
  111. package/dist/tools/init-project/agents-md-writer.js +6 -6
  112. package/dist/tools/init-project/claude-md-generator.d.ts +0 -1
  113. package/dist/tools/init-project/claude-md-generator.js +0 -35
  114. package/dist/tools/init-project/handler.js +24 -49
  115. package/dist/tools/init-project/legacy-guidance-scanner.d.ts +12 -0
  116. package/dist/tools/init-project/legacy-guidance-scanner.js +176 -0
  117. package/dist/tools/init-project/per-client-files-writer.js +5 -5
  118. package/dist/tools/init-project/planu-workflow-generator.js +2 -13
  119. package/dist/tools/init-project/portable-index-reconciler.d.ts +21 -1
  120. package/dist/tools/init-project/portable-index-reconciler.js +66 -0
  121. package/dist/tools/init-project/rules-generator.d.ts +0 -11
  122. package/dist/tools/init-project/rules-generator.js +4 -85
  123. package/dist/tools/init-project/scaffold-writer.d.ts +0 -1
  124. package/dist/tools/init-project/scaffold-writer.js +1 -12
  125. package/dist/tools/jobs/handlers.d.ts +0 -6
  126. package/dist/tools/jobs/handlers.js +13 -40
  127. package/dist/tools/list-specs.js +6 -21
  128. package/dist/tools/package-handoff.d.ts +3 -2
  129. package/dist/tools/package-handoff.js +153 -427
  130. package/dist/tools/reconcile-session-safeguard-hook.js +22 -15
  131. package/dist/tools/register-sdd-tools.js +0 -23
  132. package/dist/tools/register-spec-tools/core-spec-tools.js +18 -27
  133. package/dist/tools/schemas/index.d.ts +1 -1
  134. package/dist/tools/schemas/index.js +1 -1
  135. package/dist/tools/schemas/output-schemas.d.ts +1 -18
  136. package/dist/tools/schemas/output-schemas.js +1 -11
  137. package/dist/tools/schemas/package-handoff-output-schema.d.ts +40 -0
  138. package/dist/tools/schemas/package-handoff-output-schema.js +68 -0
  139. package/dist/tools/schemas/validate-output-schema.d.ts +1 -2
  140. package/dist/tools/schemas/validate-output-schema.js +3 -2
  141. package/dist/tools/session-checkpoint.js +2 -2
  142. package/dist/tools/skill-registry/index.d.ts +0 -1
  143. package/dist/tools/skill-registry/index.js +0 -1
  144. package/dist/tools/sync-spec-state-handler.d.ts +2 -2
  145. package/dist/tools/sync-spec-state-handler.js +23 -3
  146. package/dist/tools/tool-registry/core-tools.js +1 -140
  147. package/dist/tools/tool-registry/group-infra.js +2 -39
  148. package/dist/tools/tool-registry/group-quality-compliance.js +9 -1
  149. package/dist/tools/update-status/batch.js +17 -0
  150. package/dist/tools/update-status/dod-gates.js +14 -1
  151. package/dist/tools/update-status/done-receipt-verifier.d.ts +22 -0
  152. package/dist/tools/update-status/done-receipt-verifier.js +56 -26
  153. package/dist/tools/update-status/evidence-gate.js +10 -21
  154. package/dist/tools/update-status/file-sync.d.ts +1 -0
  155. package/dist/tools/update-status/file-sync.js +6 -0
  156. package/dist/tools/update-status/index.d.ts +6 -0
  157. package/dist/tools/update-status/index.js +39 -44
  158. package/dist/tools/update-status-actions.js +2 -7
  159. package/dist/tools/validate.js +34 -28
  160. package/dist/transports/oauth-validator.js +12 -1
  161. package/dist/transports/transport-factory.d.ts +2 -1
  162. package/dist/transports/transport-factory.js +19 -0
  163. package/dist/types/common/primitives.d.ts +2 -0
  164. package/dist/types/durable-job.d.ts +45 -0
  165. package/dist/types/durable-validation.d.ts +1 -1
  166. package/dist/types/evidence-autofill.d.ts +13 -1
  167. package/dist/types/handoff-artifacts.d.ts +2 -1
  168. package/dist/types/index.d.ts +1 -0
  169. package/dist/types/index.js +1 -0
  170. package/dist/types/legacy-guidance.d.ts +20 -0
  171. package/dist/types/legacy-guidance.js +3 -0
  172. package/dist/types/readiness.d.ts +8 -1
  173. package/dist/types/reconcile.d.ts +6 -0
  174. package/dist/types/skill-registry.d.ts +26 -1
  175. package/dist/types/spec/core.d.ts +5 -0
  176. package/dist/types/spec/inputs.d.ts +2 -2
  177. package/dist/types/spec-format.d.ts +11 -1
  178. package/dist/types/spec-grounding.d.ts +12 -0
  179. package/dist/types/ssr-migration.d.ts +1 -1
  180. package/dist/types/transport.d.ts +1 -0
  181. package/dist/types/validation-receipt.d.ts +27 -0
  182. package/package.json +3 -2
  183. package/planu-plugin.json +13 -26
  184. package/src/i18n/messages/en.json +1 -1
  185. package/src/i18n/messages/es.json +1 -1
  186. package/src/i18n/messages/pt.json +1 -1
  187. package/dist/engine/skill-generator/conventions-hasher.d.ts +0 -7
  188. package/dist/engine/skill-generator/conventions-hasher.js +0 -24
  189. package/dist/engine/universal-rules/rules/agent-teams.d.ts +0 -3
  190. package/dist/engine/universal-rules/rules/agent-teams.js +0 -63
  191. package/dist/tools/clarify-requirements/multiple-choice.d.ts +0 -30
  192. package/dist/tools/clarify-requirements/multiple-choice.js +0 -306
  193. package/dist/tools/clarify-requirements/questions-context.d.ts +0 -9
  194. package/dist/tools/clarify-requirements/questions-context.js +0 -74
  195. package/dist/tools/clarify-requirements/questions.d.ts +0 -16
  196. package/dist/tools/clarify-requirements/questions.js +0 -96
  197. package/dist/tools/clarify-requirements.d.ts +0 -4
  198. package/dist/tools/clarify-requirements.js +0 -314
  199. package/dist/tools/code-graph-handler.d.ts +0 -6
  200. package/dist/tools/code-graph-handler.js +0 -72
  201. package/dist/tools/create-rule.d.ts +0 -4
  202. package/dist/tools/create-rule.js +0 -107
  203. package/dist/tools/feedback-handler.d.ts +0 -7
  204. package/dist/tools/feedback-handler.js +0 -150
  205. package/dist/tools/semantic-search-handler.d.ts +0 -7
  206. package/dist/tools/semantic-search-handler.js +0 -71
  207. package/dist/tools/skill-registry/search.d.ts +0 -11
  208. package/dist/tools/skill-registry/search.js +0 -159
@@ -1,4 +1,20 @@
1
1
  // engine/spec-format/bdd-parser.ts — BDD/Gherkin parser and emitter (SPEC-481)
2
+ import { consumePathRunPreservingCase } from './technical-md-populator.js';
3
+ const TEST_MARKER_RE = /\bTESTS?\s*:/gi;
4
+ export function extractInlineTestPaths(text) {
5
+ const paths = [];
6
+ const seen = new Set();
7
+ for (const marker of text.matchAll(TEST_MARKER_RE)) {
8
+ const start = marker.index + marker[0].length;
9
+ for (const path of consumePathRunPreservingCase(text.slice(start))) {
10
+ if (!seen.has(path)) {
11
+ seen.add(path);
12
+ paths.push(path);
13
+ }
14
+ }
15
+ }
16
+ return paths;
17
+ }
2
18
  const KEYWORD_MAP = {
3
19
  given: 'given',
4
20
  when: 'when',
@@ -17,22 +33,37 @@ const KEYWORD_MAP = {
17
33
  * And ...
18
34
  * But ...
19
35
  */
36
+ const THEN_WORD_RE = /\bTHEN\b/i;
37
+ const WHEN_WORD_RE = /\bWHEN\b/i;
38
+ function hasThenSignal(scenario) {
39
+ return scenario.steps.some((step) => step.keyword === 'then' || THEN_WORD_RE.test(step.text));
40
+ }
41
+ function isSingleLineGivenWhenThenText(text) {
42
+ return WHEN_WORD_RE.test(text) && THEN_WORD_RE.test(text);
43
+ }
44
+ function opensHeaderlessScenario(rawKeyword, text) {
45
+ return rawKeyword === 'given' || isSingleLineGivenWhenThenText(text);
46
+ }
20
47
  export function parseBddScenarios(markdown) {
21
48
  const scenarios = [];
22
49
  const lines = markdown.split('\n');
23
50
  let current = null;
24
51
  // Tracks whether the current scenario was auto-created (no Scenario: header)
25
52
  let currentIsHeaderless = false;
53
+ const closeCurrent = () => {
54
+ if (current && hasThenSignal(current)) {
55
+ scenarios.push(current);
56
+ }
57
+ current = null;
58
+ currentIsHeaderless = false;
59
+ };
26
60
  for (const raw of lines) {
27
61
  const line = raw.trim();
28
62
  // Detect scenario header
29
63
  const scenarioMatch = /^scenario:\s*(.+)$/i.exec(line);
30
64
  if (scenarioMatch) {
31
- if (current) {
32
- scenarios.push(current);
33
- }
65
+ closeCurrent();
34
66
  current = { title: (scenarioMatch[1] ?? '').trim(), steps: [], done: false };
35
- currentIsHeaderless = false;
36
67
  continue;
37
68
  }
38
69
  // Detect step keywords
@@ -47,12 +78,12 @@ export function parseBddScenarios(markdown) {
47
78
  // steps. This handles both multiple headerless blocks and headerless blocks following
48
79
  // an explicit Scenario: header block.
49
80
  if (rawKeyword === 'given' && current !== null && current.steps.length > 0) {
50
- scenarios.push(current);
51
- current = null;
52
- currentIsHeaderless = false;
81
+ closeCurrent();
53
82
  }
54
- // Auto-create a scenario when a step is found without a preceding Scenario header
55
83
  if (!current) {
84
+ if (!opensHeaderlessScenario(rawKeyword, text)) {
85
+ continue;
86
+ }
56
87
  current = { title: text, steps: [], done: false };
57
88
  currentIsHeaderless = true;
58
89
  }
@@ -62,9 +93,7 @@ export function parseBddScenarios(markdown) {
62
93
  current.title = text;
63
94
  }
64
95
  }
65
- if (current) {
66
- scenarios.push(current);
67
- }
96
+ closeCurrent();
68
97
  return scenarios;
69
98
  }
70
99
  /**
@@ -1,6 +1,6 @@
1
1
  // engine/spec-format/lean-spec-generator.ts — Generates lean spec.md with YAML frontmatter (SPEC-461)
2
2
  // Output: ~30-50 lines. No generic sections, no OWASP, no STRIDE, no resilience criteria.
3
- import { parseBddScenarios, renderBddScenariosYaml, convertCheckboxToBdd } from './bdd-parser.js';
3
+ import { parseBddScenarios, renderBddScenariosYaml, convertCheckboxToBdd, extractInlineTestPaths, } from './bdd-parser.js';
4
4
  import { decideMetadataSerialization, } from './metadata-value-policy.js';
5
5
  import { renderGroundingFrontmatter, renderTechnicalReferenceGroundingFrontmatter, } from '../spec-grounding/contract.js';
6
6
  /** Strip a redundant spec-ID prefix from spec titles.
@@ -117,15 +117,21 @@ function buildBddLines(description, extraCriteria, criteriaOverride, scenarioTes
117
117
  done: false,
118
118
  });
119
119
  }
120
+ appendUncoveredCheckboxCriteria(scenarios, description);
120
121
  }
121
- if (scenarioTestPaths.length > 0) {
122
- scenarios = scenarios.map((scenario) => ({
123
- ...scenario,
124
- tests: scenario.tests && scenario.tests.length > 0
125
- ? scenario.tests
126
- : scenarioTestPaths.map((path) => ({ path })),
127
- }));
128
- }
122
+ scenarios = scenarios.map((scenario) => {
123
+ const inlinePaths = extractInlineTestPaths([scenario.title, ...scenario.steps.map((step) => step.text)].join('\n'));
124
+ if (inlinePaths.length > 0) {
125
+ return { ...scenario, tests: inlinePaths.map((path) => ({ path })) };
126
+ }
127
+ if (scenario.tests && scenario.tests.length > 0) {
128
+ return scenario;
129
+ }
130
+ if (scenarioTestPaths.length > 0) {
131
+ return { ...scenario, tests: scenarioTestPaths.map((path) => ({ path })) };
132
+ }
133
+ return scenario;
134
+ });
129
135
  return renderBddScenariosYaml(scenarios);
130
136
  }
131
137
  /** Extract acceptance criteria explicitly present in the description.
@@ -166,7 +172,7 @@ function extractCheckboxCriteria(description) {
166
172
  function extractGivenWhenThenCriteria(description) {
167
173
  const criteria = [];
168
174
  // Single-line: GIVEN ... WHEN ... THEN ...
169
- const singleLineRegex = /^[ \t]*(?:[-*>]\s*|\d+[.)]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
175
+ const singleLineRegex = /^[ \t]*(?:[-*+>]\s*|\d+[.)]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
170
176
  let match = singleLineRegex.exec(description);
171
177
  while (match) {
172
178
  const thenText = match[1]?.trim() ?? '';
@@ -214,4 +220,18 @@ function extractPlainListCriteria(description) {
214
220
  function escapeYaml(value) {
215
221
  return value.replace(/"/g, '\\"');
216
222
  }
223
+ function normalizeForDedupe(value) {
224
+ return value.trim().toLowerCase();
225
+ }
226
+ function appendUncoveredCheckboxCriteria(scenarios, description) {
227
+ const existingTexts = new Set(scenarios.flatMap((scenario) => [
228
+ normalizeForDedupe(scenario.title),
229
+ ...scenario.steps.map((step) => normalizeForDedupe(step.text)),
230
+ ]));
231
+ for (const criterion of extractCheckboxCriteria(description)) {
232
+ if (!existingTexts.has(normalizeForDedupe(criterion.text))) {
233
+ scenarios.push(...convertCheckboxToBdd([criterion]));
234
+ }
235
+ }
236
+ }
217
237
  //# sourceMappingURL=lean-spec-generator.js.map
@@ -0,0 +1,3 @@
1
+ export declare const RETIRED_SCAFFOLD_PREFIX = "GIVEN the requested change is implemented WHEN the behavior is exercised";
2
+ export declare function isRetiredScaffoldText(text: string): boolean;
3
+ //# sourceMappingURL=retired-scaffold.d.ts.map
@@ -0,0 +1,5 @@
1
+ export const RETIRED_SCAFFOLD_PREFIX = 'GIVEN the requested change is implemented WHEN the behavior is exercised';
2
+ export function isRetiredScaffoldText(text) {
3
+ return text.toLowerCase().includes(RETIRED_SCAFFOLD_PREFIX.toLowerCase());
4
+ }
5
+ //# sourceMappingURL=retired-scaffold.js.map
@@ -1,4 +1,5 @@
1
1
  import type { LeanFileEntry } from '../../types/index.js';
2
+ export declare function consumePathRunPreservingCase(remainder: string): string[];
2
3
  /**
3
4
  * Extract the path list declared immediately after each FILES:/FILE:/TEST:/TESTS: marker.
4
5
  * Returns null when no such marker is present (caller falls back to whole-text grounding).
@@ -137,6 +137,33 @@ function consumePathRun(remainder) {
137
137
  }
138
138
  return paths;
139
139
  }
140
+ export function consumePathRunPreservingCase(remainder) {
141
+ const paths = [];
142
+ const seen = new Set();
143
+ let cursor = remainder;
144
+ for (;;) {
145
+ cursor = cursor.replace(PATH_SEPARATOR_RE, '');
146
+ const tokenMatch = PATH_TOKEN_RE.exec(cursor);
147
+ if (tokenMatch?.[1] === undefined) {
148
+ break;
149
+ }
150
+ const raw = tokenMatch[1];
151
+ const endsWithTerminator = /[.;:]+$/.test(raw);
152
+ const trimmed = raw.replace(TRAILING_PUNCTUATION_RE, '');
153
+ if (!isPathShaped(trimmed)) {
154
+ break;
155
+ }
156
+ if (!seen.has(trimmed)) {
157
+ seen.add(trimmed);
158
+ paths.push(trimmed);
159
+ }
160
+ if (endsWithTerminator) {
161
+ break;
162
+ }
163
+ cursor = cursor.slice(tokenMatch[0].length);
164
+ }
165
+ return paths;
166
+ }
140
167
  /**
141
168
  * Extract the path list declared immediately after each FILES:/FILE:/TEST:/TESTS: marker.
142
169
  * Returns null when no such marker is present (caller falls back to whole-text grounding).
@@ -21,4 +21,5 @@ export declare function containsUnresolvedContractPlaceholder(rawText: string, o
21
21
  export declare function validateUnifiedSpecCandidate(candidate: string, options?: {
22
22
  groundedFilePaths?: Iterable<string>;
23
23
  }): UnifiedSpecCandidateValidation;
24
+ export declare function maskFencedAndQuotedText(body: string): string;
24
25
  //# sourceMappingURL=unified-spec-builder.d.ts.map
@@ -3,6 +3,7 @@
3
3
  // two-file spec.md + technical.md pair only to be merged on the next list_specs run.
4
4
  import { stripFrontmatter } from '../frontmatter-parser.js';
5
5
  import { extractAcceptanceCriteriaTexts } from './acceptance-criteria.js';
6
+ import { isRetiredScaffoldText } from './retired-scaffold.js';
6
7
  import { escapeRegex } from '../../core/shared/strings.js';
7
8
  const REQUIRED_SECTIONS = [
8
9
  'Problem',
@@ -24,7 +25,7 @@ export function buildCanonicalUnifiedSpecContent(input) {
24
25
  const goal = firstNonEmpty(explicitGoal, input.criteria[0]?.text, 'Needs decision: define the goal this spec must achieve.');
25
26
  const explicitTechnical = extractTopLevelSectionBody(source, 'Technical');
26
27
  const generatedNotes = extractTopLevelSectionBody(input.technicalBody, 'Implementation Notes');
27
- const technical = firstNonEmpty(explicitTechnical, generatedNotes, 'Implement only the grounded ownership and observable behavior declared by this contract.');
28
+ const technical = firstNonEmpty(explicitTechnical, generatedNotes, groundedTechnicalFallback(input.files, input.criteria));
28
29
  const acceptance = extractExecutableCriteria(source, input.criteria);
29
30
  const explicitOutOfScope = extractTopLevelSectionBody(source, 'Out of scope');
30
31
  const outOfScope = input.outOfScope.length > 0
@@ -224,8 +225,12 @@ export function validateUnifiedSpecCandidate(candidate, options = {}) {
224
225
  });
225
226
  }
226
227
  const acceptanceSection = extractTopLevelSectionBody(scannable, 'Acceptance Criteria');
227
- if (acceptanceSection === null ||
228
- !/\bGIVEN\b[\s\S]*\bWHEN\b[\s\S]*\bTHEN\b/i.test(acceptanceSection)) {
228
+ const acceptanceSectionForBddCheck = acceptanceSection
229
+ ?.split('\n')
230
+ .filter((line) => !isRetiredScaffoldText(line))
231
+ .join('\n');
232
+ if (!acceptanceSectionForBddCheck ||
233
+ !/\bGIVEN\b[\s\S]*\bWHEN\b[\s\S]*\bTHEN\b/i.test(acceptanceSectionForBddCheck)) {
229
234
  issues.push({
230
235
  code: 'MISSING_EXECUTABLE_CRITERIA',
231
236
  section: 'Acceptance Criteria',
@@ -248,7 +253,7 @@ export function validateUnifiedSpecCandidate(candidate, options = {}) {
248
253
  }
249
254
  return { valid: issues.length === 0, issues };
250
255
  }
251
- function maskFencedAndQuotedText(body) {
256
+ export function maskFencedAndQuotedText(body) {
252
257
  const lines = body.split('\n');
253
258
  let fence = null;
254
259
  return lines
@@ -305,27 +310,18 @@ function firstNonEmpty(...values) {
305
310
  return (values.find((value) => value !== undefined && value !== null && value.length > 0) ?? '');
306
311
  }
307
312
  function fallbackCriteriaBullets(criteria) {
308
- return criteria
309
- .map((criterion) => `- GIVEN the requested change is implemented WHEN the behavior is exercised THEN ${criterion.text}`)
310
- .join('\n');
313
+ return criteria.map((criterion) => `- ${criterion.text}`).join('\n');
311
314
  }
312
- // Same BDD step vocabulary `acceptance-criteria.ts` recognizes (English + Spanish).
313
- // A criterion already starting with one of these is a genuine BDD fragment
314
- // (a full GIVEN/WHEN/THEN block, or an AND continuation of one) — leave it as-is.
315
- const BDD_STEP_START = /^(GIVEN|WHEN|THEN|AND|DADO|CUANDO|ENTONCES|Y)\b/i;
316
315
  /**
317
- * Render one real criterion as an executable bullet. Criteria already
318
- * written in BDD form pass through untouched; plain-text criteria (e.g.
319
- * checkbox items) are wrapped in the same GIVEN/WHEN/THEN scaffold
320
- * `fallbackCriteriaBullets` uses, so every bullet stays BDD-executable
321
- * without ever discarding a real criterion.
316
+ * Render one real criterion as a bullet. Criteria already written in BDD form
317
+ * pass through untouched; plain-text criteria (e.g. checkbox items) render as
318
+ * plain bullets SPEC-1435 retires the constant GIVEN/WHEN/THEN scaffold that
319
+ * used to wrap them, since it satisfied `MISSING_EXECUTABLE_CRITERIA` without
320
+ * adding any spec-specific content.
322
321
  */
323
322
  function toExecutableBullet(text) {
324
323
  const cleaned = text.trim().replace(/[.\s]+$/, '.');
325
- if (BDD_STEP_START.test(cleaned)) {
326
- return `- ${cleaned}`;
327
- }
328
- return `- GIVEN the requested change is implemented WHEN the behavior is exercised THEN ${cleaned}`;
324
+ return `- ${cleaned}`;
329
325
  }
330
326
  /**
331
327
  * Criteria resolve through the canonical parser in `acceptance-criteria.ts`
@@ -339,6 +335,20 @@ function extractExecutableCriteria(source, criteria) {
339
335
  }
340
336
  return parsed.map(toExecutableBullet).join('\n');
341
337
  }
338
+ /**
339
+ * SPEC-1435: replaces the constant Technical fallback sentence. When neither an
340
+ * author-written `## Technical` body nor generated implementation notes exist, compose
341
+ * the section from the spec's own grounded material instead of a fixed sentence that
342
+ * never varies with the spec: the grounded file records when present, otherwise the
343
+ * criteria texts.
344
+ */
345
+ function groundedTechnicalFallback(files, criteria) {
346
+ const groundedFiles = [...files.create, ...files.modify, ...files.test];
347
+ if (groundedFiles.length > 0) {
348
+ return groundedFiles.map((entry) => `- \`${entry.path}\``).join('\n');
349
+ }
350
+ return criteria.map((criterion) => `- ${criterion.text}`).join('\n');
351
+ }
342
352
  function renderOwnedFiles(files) {
343
353
  const sections = [];
344
354
  for (const [title, entries] of [
@@ -1,4 +1,17 @@
1
+ import YAML from 'yaml';
1
2
  import { createCriterionIdentity, normalizeCriterionText } from '../criterion-identity.js';
3
+ const GROUNDING_SOURCES = [
4
+ 'user_input',
5
+ 'project_evidence',
6
+ 'documented_assumption',
7
+ 'ungrounded_advisory',
8
+ ];
9
+ const GROUNDING_CONFIDENCES = ['low', 'medium', 'high'];
10
+ const TECHNICAL_REFERENCE_SECTIONS = [
11
+ 'create',
12
+ 'modify',
13
+ 'test',
14
+ ];
2
15
  export { normalizeCriterionText } from '../criterion-identity.js';
3
16
  const GENERIC_CRITERION_PATTERNS = [
4
17
  /\bimplementation complete\b/i,
@@ -135,6 +148,107 @@ export function checkGroundedSpecContract(spec, content) {
135
148
  };
136
149
  }
137
150
  export function parseCriterionGroundingRecords(frontmatter) {
151
+ const outcome = parseFrontmatterYaml(frontmatter);
152
+ if (!outcome.parsed) {
153
+ return parseCriterionGroundingRecordsLegacy(frontmatter);
154
+ }
155
+ const grounding = readGroundingSection(outcome.document);
156
+ const entries = Array.isArray(grounding?.criteria) ? grounding.criteria : [];
157
+ const records = [];
158
+ for (const entry of entries) {
159
+ const record = toCriterionGroundingRecord(entry);
160
+ if (record) {
161
+ records.push(record);
162
+ }
163
+ }
164
+ return records;
165
+ }
166
+ export function parseTechnicalReferenceGroundingRecords(frontmatter) {
167
+ const outcome = parseFrontmatterYaml(frontmatter);
168
+ if (!outcome.parsed) {
169
+ return parseTechnicalReferenceGroundingRecordsLegacy(frontmatter);
170
+ }
171
+ const grounding = readGroundingSection(outcome.document);
172
+ const entries = grounding
173
+ ? Array.isArray(grounding.technicalReferences)
174
+ ? grounding.technicalReferences
175
+ : []
176
+ : Array.isArray(outcome.document?.technicalReferences)
177
+ ? outcome.document.technicalReferences
178
+ : [];
179
+ const records = [];
180
+ for (const entry of entries) {
181
+ const record = toTechnicalReferenceGroundingRecord(entry);
182
+ if (record) {
183
+ records.push(record);
184
+ }
185
+ }
186
+ return records;
187
+ }
188
+ function parseFrontmatterYaml(frontmatter) {
189
+ let value;
190
+ try {
191
+ value = YAML.parse(frontmatter);
192
+ }
193
+ catch {
194
+ return { parsed: false };
195
+ }
196
+ return { parsed: true, document: isRecord(value) ? value : undefined };
197
+ }
198
+ function readGroundingSection(document) {
199
+ const grounding = document?.grounding;
200
+ return isRecord(grounding) ? grounding : undefined;
201
+ }
202
+ function toCriterionGroundingRecord(entry) {
203
+ if (!isRecord(entry)) {
204
+ return null;
205
+ }
206
+ const text = typeof entry.text === 'string' && entry.text.length > 0 ? entry.text : null;
207
+ const source = toGroundingSource(entry.source);
208
+ const confidence = toGroundingConfidence(entry.confidence);
209
+ if (!text || !source || !confidence) {
210
+ return null;
211
+ }
212
+ return { text, source, evidence: toStringArray(entry.evidence), confidence };
213
+ }
214
+ function toTechnicalReferenceGroundingRecord(entry) {
215
+ if (!isRecord(entry)) {
216
+ return null;
217
+ }
218
+ const path = typeof entry.path === 'string' && entry.path.length > 0 ? entry.path : null;
219
+ const section = toTechnicalReferenceSection(entry.section);
220
+ const source = toGroundingSource(entry.source);
221
+ const confidence = toGroundingConfidence(entry.confidence);
222
+ if (!path || !section || !source || !confidence) {
223
+ return null;
224
+ }
225
+ return { path, section, source, evidence: toStringArray(entry.evidence), confidence };
226
+ }
227
+ function toGroundingSource(value) {
228
+ return typeof value === 'string' && GROUNDING_SOURCES.includes(value)
229
+ ? value
230
+ : null;
231
+ }
232
+ function toGroundingConfidence(value) {
233
+ return typeof value === 'string' && GROUNDING_CONFIDENCES.includes(value)
234
+ ? value
235
+ : null;
236
+ }
237
+ function toTechnicalReferenceSection(value) {
238
+ return typeof value === 'string' &&
239
+ TECHNICAL_REFERENCE_SECTIONS.includes(value)
240
+ ? value
241
+ : null;
242
+ }
243
+ function toStringArray(value) {
244
+ return Array.isArray(value)
245
+ ? value.filter((item) => typeof item === 'string')
246
+ : [];
247
+ }
248
+ function isRecord(value) {
249
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
250
+ }
251
+ function parseCriterionGroundingRecordsLegacy(frontmatter) {
138
252
  const records = [];
139
253
  const chunks = frontmatter.split(/\n\s{4}- text:\s*/).slice(1);
140
254
  for (const chunk of chunks) {
@@ -154,7 +268,7 @@ export function parseCriterionGroundingRecords(frontmatter) {
154
268
  }
155
269
  return records;
156
270
  }
157
- export function parseTechnicalReferenceGroundingRecords(frontmatter) {
271
+ function parseTechnicalReferenceGroundingRecordsLegacy(frontmatter) {
158
272
  const records = [];
159
273
  const chunks = frontmatter.split(/\n\s{4}- path:\s*/).slice(1);
160
274
  for (const chunk of chunks) {
@@ -1,10 +1,47 @@
1
1
  // engine/spec-migrator/fold-technical.ts — SPEC-752
2
2
  // Folds legacy technical.md body into spec.md's ## Technical section, then deletes technical.md.
3
3
  // Uses the same pattern as unified-migration.ts (SPEC-630).
4
- import { readFile } from 'node:fs/promises';
4
+ import { readFile, unlink } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { atomicWriteFile } from '../safety/atomic-write-file.js';
7
7
  import { safeUnlink } from './git-aware-fs.js';
8
+ import { isPlaceholderTechnical } from './placeholder-technical.js';
9
+ const FOLDED_HEADING = '### Folded from legacy technical.md';
10
+ /**
11
+ * Insert `techBody` under a `### Folded from legacy technical.md` subsection,
12
+ * placed right after the existing `## Technical` section (before the next
13
+ * top-level `## ` heading, or at the end of the document when none follows).
14
+ */
15
+ function foldIntoTechnicalSection(specContent, techBody) {
16
+ const headingMatch = /\n## Technical(\n|$)/.exec(specContent);
17
+ const insertion = `\n${FOLDED_HEADING}\n\n${techBody}\n`;
18
+ if (!headingMatch) {
19
+ return `${specContent.trimEnd()}\n\n## Technical\n${insertion}`;
20
+ }
21
+ const afterHeading = headingMatch.index + headingMatch[0].length;
22
+ const nextHeadingMatch = /\n## /.exec(specContent.slice(afterHeading));
23
+ if (!nextHeadingMatch) {
24
+ return `${specContent.trimEnd()}\n${insertion}`;
25
+ }
26
+ const insertAt = afterHeading + nextHeadingMatch.index;
27
+ return specContent.slice(0, insertAt) + insertion + specContent.slice(insertAt);
28
+ }
29
+ async function removeResidualTechnicalMd(techPath, projectPath) {
30
+ if (projectPath) {
31
+ // safeUnlink is already idempotent for a missing file (git rm --ignore-unmatch,
32
+ // unlink swallows ENOENT) and rethrows every other failure.
33
+ await safeUnlink(projectPath, techPath);
34
+ return;
35
+ }
36
+ try {
37
+ await unlink(techPath);
38
+ }
39
+ catch (err) {
40
+ if (err.code !== 'ENOENT') {
41
+ throw err;
42
+ }
43
+ }
44
+ }
8
45
  /**
9
46
  * Fold technical.md content into spec.md under a `## Technical` section.
10
47
  * Idempotent: returns already_unified if spec.md already has `## Technical`.
@@ -20,22 +57,32 @@ export async function foldTechnicalIntoSpec(specDir, projectPath) {
20
57
  const specContent = await readFile(specPath, 'utf-8');
21
58
  // Idempotency check: already has ## Technical section.
22
59
  // SPEC-1008: also delete any residual technical.md so the spec dir ends
23
- // up in the canonical single-file layout. Previously the early-return
24
- // left the legacy file behind forever.
60
+ // up in the canonical single-file layout.
61
+ // SPEC-1716: before deleting, read the residual divergent content is
62
+ // folded under a "Folded from legacy technical.md" subsection instead of
63
+ // silently dropped.
25
64
  if (/\n## Technical(\n|$)/.test(specContent)) {
65
+ let residualContent;
26
66
  try {
27
- if (projectPath) {
28
- await safeUnlink(projectPath, techPath);
29
- }
30
- else {
31
- const { unlink } = await import('node:fs/promises');
32
- await unlink(techPath);
33
- }
67
+ residualContent = await readFile(techPath, 'utf-8');
34
68
  }
35
69
  catch {
36
- /* technical.md already absent nothing to clean up */
70
+ return { ok: true, reason: 'already_unified' };
37
71
  }
38
- return { ok: true, reason: 'already_unified' };
72
+ const residualBody = residualContent.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
73
+ const alreadyContained = residualBody.length === 0 ||
74
+ specContent.includes(residualBody) ||
75
+ isPlaceholderTechnical(residualBody);
76
+ if (alreadyContained) {
77
+ await removeResidualTechnicalMd(techPath, projectPath);
78
+ return { ok: true, reason: 'already_unified' };
79
+ }
80
+ const unified = foldIntoTechnicalSection(specContent, residualBody);
81
+ const before = Buffer.byteLength(specContent, 'utf-8');
82
+ const after = Buffer.byteLength(unified, 'utf-8');
83
+ await atomicWriteFile(specPath, unified);
84
+ await removeResidualTechnicalMd(techPath, projectPath);
85
+ return { ok: true, reason: 'folded_residual', byteDelta: after - before };
39
86
  }
40
87
  // Try reading technical.md
41
88
  let techContent;
@@ -51,13 +98,7 @@ export async function foldTechnicalIntoSpec(specDir, projectPath) {
51
98
  const before = Buffer.byteLength(specContent, 'utf-8');
52
99
  const after = Buffer.byteLength(unified, 'utf-8');
53
100
  await atomicWriteFile(specPath, unified);
54
- if (projectPath) {
55
- await safeUnlink(projectPath, techPath);
56
- }
57
- else {
58
- const { unlink } = await import('node:fs/promises');
59
- await unlink(techPath);
60
- }
101
+ await removeResidualTechnicalMd(techPath, projectPath);
61
102
  return { ok: true, reason: 'ok', byteDelta: after - before };
62
103
  }
63
104
  catch (err) {
@@ -0,0 +1,2 @@
1
+ export declare function isPlaceholderTechnical(content: string): boolean;
2
+ //# sourceMappingURL=placeholder-technical.d.ts.map
@@ -0,0 +1,18 @@
1
+ // engine/spec-migrator/placeholder-technical.ts — SPEC-1716
2
+ // Shared placeholder-stub detector for technical.md bodies. Lives in engine/ so
3
+ // both engine (fold-technical.ts) and tools (heal-spec-docs.ts) can import it —
4
+ // engine/ must never import from tools/.
5
+ export function isPlaceholderTechnical(content) {
6
+ if (content.includes('(pending)')) {
7
+ return true;
8
+ }
9
+ if (/--[a-z]/.test(content)) {
10
+ return true;
11
+ }
12
+ if (content.includes('(to be determined)') &&
13
+ !/\.(ts|tsx|js|jsx|py|rb|go|java|cs)/.test(content)) {
14
+ return true;
15
+ }
16
+ return false;
17
+ }
18
+ //# sourceMappingURL=placeholder-technical.js.map
@@ -1,3 +1,5 @@
1
+ import { maskFencedAndQuotedText } from '../spec-format/unified-spec-builder.js';
2
+ import { RETIRED_SCAFFOLD_PREFIX } from '../spec-format/retired-scaffold.js';
1
3
  /** Bounds the search for criteria to the frontmatter block only (between the two `---`
2
4
  * delimiters), so criterion-shaped lines never leak in from the rendered body below it. */
3
5
  const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
@@ -21,7 +23,8 @@ const CRITERION_TEXT_RE = /^\s+- text:\s*"?(.+?)"?\s*$/gm;
21
23
  * the criterion-equivalent for `scenarios:`-format specs, which have no `- text:` entries. */
22
24
  const SCENARIO_TITLE_RE = /^\s+- title:\s*"?(.+?)"?\s*$/gm;
23
25
  const FILE_LINE_RE = /^-\s+(.+?)\s+\((pending|done)\)\s*$/gm;
24
- const H3_HEADING_RE = /^###[ \t]+(.+?)[ \t]*$/gm;
26
+ const SECTION_HEADING_RE = /^#{1,3}[ \t]+(.+?)[ \t]*$/gm;
27
+ const H3_HEADING_PREFIX_RE = /^###[ \t]/;
25
28
  const BULLET_LINE_RE = /^[-*][ \t]+(.+)$/;
26
29
  /** A bullet that, once a leading `Label:`, wrapping backticks, and a trailing `(status)`
27
30
  * marker are stripped, is just a file path: contains a `/` and ends in a short extension.
@@ -98,9 +101,40 @@ export function checkGenericSpecOutput(content) {
98
101
  ...checkCriteria(criteria),
99
102
  ...checkTechnicalReferences(content),
100
103
  ...checkInterpolatedSections(content, criteria),
104
+ ...checkRetiredFillerVocabulary(content),
101
105
  ];
102
106
  return { passed: issues.length === 0, issues };
103
107
  }
108
+ /**
109
+ * SPEC-1435: dedicated scan for the constant filler sentences the renderer and the
110
+ * unified spec builder used to emit whenever nothing was grounded. Those sentences
111
+ * satisfied structural checks (a non-empty subsection, an executable-criteria match)
112
+ * without carrying any spec-specific content, and no existing rule matched their exact
113
+ * wording. Scans the whole body — prose and bullets, any heading level — with fenced
114
+ * code blocks and quoted text masked the same way `unified-spec-builder.ts` masks them,
115
+ * so a legitimate example inside a fence or blockquote is never flagged.
116
+ */
117
+ const RETIRED_FILLER_SENTENCES = [
118
+ 'Implement only the grounded ownership and observable behavior declared by this contract.',
119
+ 'No repository-supported verification command was grounded for this candidate.',
120
+ 'No file ownership was grounded for this behavior.',
121
+ 'No repository-supported verification evidence was grounded for these criteria.',
122
+ RETIRED_SCAFFOLD_PREFIX,
123
+ ];
124
+ function checkRetiredFillerVocabulary(content) {
125
+ const scannable = maskFencedAndQuotedText(content);
126
+ const issues = [];
127
+ for (const sentence of RETIRED_FILLER_SENTENCES) {
128
+ if (scannable.includes(sentence)) {
129
+ issues.push({
130
+ kind: 'generic-technical-reference',
131
+ phrase: sentence,
132
+ reason: `retired filler text must not appear in a persisted spec: "${sentence}"`,
133
+ });
134
+ }
135
+ }
136
+ return issues;
137
+ }
104
138
  /**
105
139
  * SPEC-1406 (DEFECT 2, round 2 — detector rebuilt around CRITERION RESTATEMENT): the
106
140
  * previous prefix/suffix-length rule flagged `### File-Level Work Plan` (whose bullets
@@ -115,10 +149,10 @@ export function checkGenericSpecOutput(content) {
115
149
  */
116
150
  function checkInterpolatedSections(content, criteria) {
117
151
  const issues = [];
118
- const headingMatches = [...content.matchAll(H3_HEADING_RE)];
152
+ const headingMatches = [...content.matchAll(SECTION_HEADING_RE)];
119
153
  for (let i = 0; i < headingMatches.length; i += 1) {
120
154
  const heading = headingMatches[i];
121
- if (!heading?.[1]) {
155
+ if (!heading?.[1] || !H3_HEADING_PREFIX_RE.test(heading[0])) {
122
156
  continue;
123
157
  }
124
158
  const start = heading.index + heading[0].length;
@@ -65,7 +65,10 @@ async function runEnricherFromSpecSection(opts) {
65
65
  if (result.enriched && result.enrichedContent) {
66
66
  const updated = `${specRaw.slice(0, section.contentStart)}${result.enrichedContent}${specRaw.slice(section.end)}`;
67
67
  assertEnrichmentPreservesContract(specRaw, updated, 'review-enricher');
68
- await atomicWriteFile(physicalSpecPath, updated);
68
+ const relativeProposalPath = join('planu', '.runtime', 'enrichment-proposals', `${opts.specId}.md`);
69
+ const proposalPath = join(opts.projectPath, relativeProposalPath);
70
+ await atomicWriteFile(proposalPath, updated);
71
+ console.error(`Review enrichment proposal written to ${relativeProposalPath}`);
69
72
  }
70
73
  }
71
74
  //# sourceMappingURL=index.js.map