@planu/cli 5.3.2 → 5.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/config/runtime-policy.json +9 -0
  3. package/dist/engine/autopilot/action-registry.js +20 -2
  4. package/dist/engine/autopilot/complete-loop.js +4 -1
  5. package/dist/engine/constitution/sdd-rules-registry.js +8 -4
  6. package/dist/engine/drift/violation-resolver.js +30 -8
  7. package/dist/engine/evidence-gates/evidence-skeletons.js +9 -4
  8. package/dist/engine/execution/outbox-worker.d.ts +8 -0
  9. package/dist/engine/execution/outbox-worker.js +102 -31
  10. package/dist/engine/handoff-packager.js +10 -3
  11. package/dist/engine/implementation-contract/common.d.ts +13 -1
  12. package/dist/engine/implementation-contract/common.js +19 -3
  13. package/dist/engine/implementation-contract/evaluator.js +116 -35
  14. package/dist/engine/implementation-contract/renderer.js +62 -31
  15. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  16. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  17. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  18. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  19. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  20. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  21. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  22. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  23. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  24. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  25. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  26. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  27. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  28. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  29. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  30. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  31. package/dist/engine/runtime-policy.js +14 -0
  32. package/dist/engine/spec-format/unified-spec-builder.js +32 -8
  33. package/dist/engine/spec-generator/fallback-generator.js +5 -3
  34. package/dist/engine/spec-quality/generic-output-gate.d.ts +7 -1
  35. package/dist/engine/spec-quality/generic-output-gate.js +199 -8
  36. package/dist/engine/spec-quality-scorer.js +9 -5
  37. package/dist/engine/workers/handlers/auto-drift.js +22 -8
  38. package/dist/index.js +3 -1
  39. package/dist/storage/runtime-db.d.ts +23 -1
  40. package/dist/storage/runtime-db.js +79 -9
  41. package/dist/tools/create-spec/post-creation.js +7 -2
  42. package/dist/tools/create-spec/spec-created-outbox-consumer.d.ts +2 -0
  43. package/dist/tools/create-spec/spec-created-outbox-consumer.js +94 -6
  44. package/dist/tools/create-spec-helpers.d.ts +2 -2
  45. package/dist/tools/create-spec-helpers.js +2 -2
  46. package/dist/tools/create-spec.js +5 -0
  47. package/dist/tools/git/branch-ops.d.ts +7 -3
  48. package/dist/tools/git/branch-ops.js +68 -18
  49. package/dist/tools/update-status/evidence-gate.js +10 -17
  50. package/dist/tools/update-status/transition-guard.js +26 -14
  51. package/dist/tools/update-status-actions.js +4 -2
  52. package/dist/types/git.d.ts +4 -0
  53. package/dist/types/outbox-worker.d.ts +2 -0
  54. package/dist/types/runtime-policy.d.ts +9 -0
  55. package/dist/types/spec-quality.d.ts +6 -1
  56. package/package.json +9 -9
  57. package/planu-native.json +1 -1
  58. package/planu-plugin.json +1 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.2",
4
- "engineVersion": "5.3.2",
5
- "buildId": "1-5.3.2-x86_64-unknown-linux-musl",
3
+ "cliVersion": "5.3.4",
4
+ "engineVersion": "5.3.4",
5
+ "buildId": "1-5.3.4-x86_64-unknown-linux-musl",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "linux",
17
17
  "arch": "x64",
18
18
  "libc": "musl",
19
- "artifactSha256": "d44f03ecaed34427ca6ed682ce695f432d25eac3366fc5e779cfa78fe39d504b",
19
+ "artifactSha256": "84a3ad58be73c7b2c75a1b15252e557644c80bf66e6484781107300b6b0aec91",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.linux-x64-musl.node.sbom.json",
23
- "sha256": "86d8219cdb5f5205d458cfa205ef619f68997f7a414f04a3a30f53704650b488"
23
+ "sha256": "dd6d811714f3dbe94427c0e66399f779dd9d9c194af589ae143bd16dc929b31a"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "30226657c1e66411b6ff233c2ea82e47573d533a",
27
+ "sourceCommit": "7f78cd615083c4284cab36c05f11dc2ce76e5cbd",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "O6Ay8rgviCK8bLJJbkQAsX9msRJoCxB2jkgpY6+v+7oyVohsZ3hKly1kFnQ35WRbibF4l2TBchxZI7IlKbAgCg=="
36
+ "value": "DUxITC22qKLQyVQNvklBEPM4TRryjTl4v3QM6mz+4f46GEkcMEOJ+6K3kH4/U425xnQH3fKBuqtpvWpj9Er8Aw=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.2",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.4",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.2",
10
+ "version": "5.3.4",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "d44f03ecaed34427ca6ed682ce695f432d25eac3366fc5e779cfa78fe39d504b"
14
+ "content": "84a3ad58be73c7b2c75a1b15252e557644c80bf66e6484781107300b6b0aec91"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.2",
1109
+ "ref": "pkg:cargo/planu-core@5.3.4",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.2",
4
- "engineVersion": "5.3.2",
5
- "buildId": "1-5.3.2-aarch64-pc-windows-msvc",
3
+ "cliVersion": "5.3.4",
4
+ "engineVersion": "5.3.4",
5
+ "buildId": "1-5.3.4-aarch64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "arm64",
18
18
  "libc": null,
19
- "artifactSha256": "9a10e393e1b0aad7856de5973ea8efb018513bcb4e2c47ddb013be9bda7e3bda",
19
+ "artifactSha256": "d3f8633e9c7014a9163666c20dcd016f64b362e575ffb26afa9e8f51393cc8ba",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-arm64-msvc.node.sbom.json",
23
- "sha256": "38f38493f6013f50daff7808dcbbab81133b141bce61cba430e7a07fe9a89fc2"
23
+ "sha256": "cb8c0c1f6567d471967a019e14fd7834b359c8a423626129bb8b01d404df9f5d"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "30226657c1e66411b6ff233c2ea82e47573d533a",
27
+ "sourceCommit": "7f78cd615083c4284cab36c05f11dc2ce76e5cbd",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "ktHCgTSn2Br8NhGf6Pjz5fCP38a9sGluOqs617pwRXymMiUBu2GNjvyp7mLqk2gY/L8KAbzvF6FHoqEVDzb9AA=="
36
+ "value": "R9tg5e4kK1p1AOy5RPA/HvLo5GwymlNQsCDMiGePRFZVetj8GumU1QAjBxF+B/yYCvaMa0YmuKCRrbpI4raNBQ=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.2",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.4",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.2",
10
+ "version": "5.3.4",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "9a10e393e1b0aad7856de5973ea8efb018513bcb4e2c47ddb013be9bda7e3bda"
14
+ "content": "d3f8633e9c7014a9163666c20dcd016f64b362e575ffb26afa9e8f51393cc8ba"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.2",
1109
+ "ref": "pkg:cargo/planu-core@5.3.4",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.2",
4
- "engineVersion": "5.3.2",
5
- "buildId": "1-5.3.2-x86_64-pc-windows-msvc",
3
+ "cliVersion": "5.3.4",
4
+ "engineVersion": "5.3.4",
5
+ "buildId": "1-5.3.4-x86_64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "x64",
18
18
  "libc": null,
19
- "artifactSha256": "4a41383ea29c9dd64a668143e206b4af1859ba6f35b79fdeb7ea8f812113811c",
19
+ "artifactSha256": "a1ffe9cf1c0fa69ad29543b70dc3e6af72993f4ac3b770b5ed8f1794b5082020",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-x64-msvc.node.sbom.json",
23
- "sha256": "f96dbf4fd6d9cab1aa9b90057325829de42e018dfa2bf4220c78c9bbfab0f9c5"
23
+ "sha256": "76c83934b4a0fb50e59919c6d27723a395f5387af0c467674f5eeb0bb328a3a3"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "30226657c1e66411b6ff233c2ea82e47573d533a",
27
+ "sourceCommit": "7f78cd615083c4284cab36c05f11dc2ce76e5cbd",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "vZ49hewESnK5idVecQPyBFUD+GPcbfa0ihzAKkxuqBSsyVEWNFGaGU4c/rzvXD6D3SKnApFEM3vZ9D2uCfSjDA=="
36
+ "value": "/yNziQ2dBYLR9Qg13plc/CanSa9RXWtBP8E8TDNsJYuZ+jLV1dnHksyG0zlVrGw/guegmsNe/LRC6fREdCFMCA=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.2",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.4",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.2",
10
+ "version": "5.3.4",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "4a41383ea29c9dd64a668143e206b4af1859ba6f35b79fdeb7ea8f812113811c"
14
+ "content": "a1ffe9cf1c0fa69ad29543b70dc3e6af72993f4ac3b770b5ed8f1794b5082020"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.2",
1109
+ "ref": "pkg:cargo/planu-core@5.3.4",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -100,6 +100,20 @@ const runtimePolicySchema = z
100
100
  minimumHeartbeatMs: z.number().int().positive(),
101
101
  })
102
102
  .strict(),
103
+ outbox: z
104
+ .object({
105
+ sliceRecordBudget: z.number().int().positive(),
106
+ sliceTimeBudgetMs: z.number().int().positive(),
107
+ backoffBaseMs: z.number().int().positive(),
108
+ backoffCapMs: z.number().int().positive(),
109
+ maxAttempts: z.number().int().positive(),
110
+ diagnosticMaxLength: z.number().int().positive(),
111
+ continuationDelayMs: z.number().int().positive(),
112
+ })
113
+ .strict()
114
+ .refine((value) => value.backoffBaseMs <= value.backoffCapMs, {
115
+ message: 'backoffBaseMs must not exceed backoffCapMs',
116
+ }),
103
117
  })
104
118
  .strict();
105
119
  const require = createRequire(import.meta.url);
@@ -2,6 +2,7 @@
2
2
  // Closes the SPEC-630 residue: every new spec is born unified, never written as a
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
+ import { extractAcceptanceCriteriaTexts } from './acceptance-criteria.js';
5
6
  const REQUIRED_SECTIONS = [
6
7
  'Problem',
7
8
  'Goal',
@@ -254,18 +255,41 @@ function firstSentence(text) {
254
255
  .find((part) => part.length > 0 && !part.startsWith('#'));
255
256
  return sentence ?? null;
256
257
  }
257
- function extractExecutableCriteria(source, criteria) {
258
- const explicit = /\bGIVEN\b[\s\S]*?(?=(?:\n##|\bFILES:|\bFUNCTIONS:|\bTEST:|$))/i
259
- .exec(source)?.[0]
260
- ?.trim()
261
- .replace(/[.\s]+$/, '.');
262
- if (explicit) {
263
- return `- ${explicit}`;
264
- }
258
+ function fallbackCriteriaBullets(criteria) {
265
259
  return criteria
266
260
  .map((criterion) => `- GIVEN the requested change is implemented WHEN the behavior is exercised THEN ${criterion.text}`)
267
261
  .join('\n');
268
262
  }
263
+ // Same BDD step vocabulary `acceptance-criteria.ts` recognizes (English + Spanish).
264
+ // A criterion already starting with one of these is a genuine BDD fragment
265
+ // (a full GIVEN/WHEN/THEN block, or an AND continuation of one) — leave it as-is.
266
+ const BDD_STEP_START = /^(GIVEN|WHEN|THEN|AND|DADO|CUANDO|ENTONCES|Y)\b/i;
267
+ /**
268
+ * Render one real criterion as an executable bullet. Criteria already
269
+ * written in BDD form pass through untouched; plain-text criteria (e.g.
270
+ * checkbox items) are wrapped in the same GIVEN/WHEN/THEN scaffold
271
+ * `fallbackCriteriaBullets` uses, so every bullet stays BDD-executable
272
+ * without ever discarding a real criterion.
273
+ */
274
+ function toExecutableBullet(text) {
275
+ const cleaned = text.trim().replace(/[.\s]+$/, '.');
276
+ if (BDD_STEP_START.test(cleaned)) {
277
+ return `- ${cleaned}`;
278
+ }
279
+ return `- GIVEN the requested change is implemented WHEN the behavior is exercised THEN ${cleaned}`;
280
+ }
281
+ /**
282
+ * Criteria resolve through the canonical parser in `acceptance-criteria.ts`
283
+ * so the renderer and `readiness-checker.ts` can never disagree about how
284
+ * many criteria a spec has.
285
+ */
286
+ function extractExecutableCriteria(source, criteria) {
287
+ const parsed = extractAcceptanceCriteriaTexts(source);
288
+ if (parsed.length === 0) {
289
+ return fallbackCriteriaBullets(criteria);
290
+ }
291
+ return parsed.map(toExecutableBullet).join('\n');
292
+ }
269
293
  function renderOwnedFiles(files) {
270
294
  const sections = [];
271
295
  for (const [title, entries] of [
@@ -22,9 +22,11 @@ export class FallbackGenerator {
22
22
  method: 'deterministic',
23
23
  generatedAt: new Date().toISOString(),
24
24
  },
25
- qualityWarnings: [
26
- 'Fallback generator used. Source description was preserved; missing decisions require explicit user input.',
27
- ],
25
+ // SPEC-1406 (DEFECT 4, round 2): this generator is the only one wired into
26
+ // production (create-spec.ts:1430), so this warning fired unconditionally on
27
+ // every spec ever created — a warning with 100% incidence and no gate consumer
28
+ // carries no signal. Only qualityWarnings from actual quality issues remain.
29
+ qualityWarnings: [],
28
30
  fallbackReason: 'No external model generator is configured for create_spec.',
29
31
  });
30
32
  }
@@ -1,3 +1,9 @@
1
- import type { GenericSpecOutputGateResult } from '../../types/index.js';
1
+ import type { GenericSpecOutputGateResult, GenericSpecOutputIssueKind } from '../../types/index.js';
2
+ /**
3
+ * SPEC-1406 (DEFECT 2 fix): the constant kind reused to mark structural-interpolation
4
+ * findings so callers can tell them apart from the other generic-output issue kinds and
5
+ * decide, per call site, whether that finding blocks or only warns.
6
+ */
7
+ export declare const STRUCTURAL_INTERPOLATION_KIND: GenericSpecOutputIssueKind;
2
8
  export declare function checkGenericSpecOutput(content: string): GenericSpecOutputGateResult;
3
9
  //# sourceMappingURL=generic-output-gate.d.ts.map
@@ -1,6 +1,45 @@
1
- const CRITERIA_SECTION_RE = /^criteria:\n([\s\S]*?)(?=^[a-zA-Z_][\w-]*:|\n---|\s*$)/m;
1
+ /** Bounds the search for criteria to the frontmatter block only (between the two `---`
2
+ * delimiters), so criterion-shaped lines never leak in from the rendered body below it. */
3
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
4
+ /**
5
+ * SPEC-1406 (DEFECT 1, round 4 — detector was inert on real specs): matches a criterion's
6
+ * `- text: "..."` line regardless of nesting depth, so it covers BOTH shapes this generator
7
+ * renders (`lean-spec-generator.ts`): the top-level checkbox `criteria:` list AND the BDD
8
+ * `grounding: criteria:` list nested under `grounding:` (see
9
+ * `contract.ts:renderGroundingFrontmatter`, ` - text: "..."`). The previous version only
10
+ * looked inside a slice bounded by a single-line lookahead that matched line-end (`\s*$`
11
+ * under `/m`) after the FIRST criterion, so multi-criterion checkbox specs (e.g. SPEC-001,
12
+ * 6 criteria) only ever contributed their first entry — and it never ran at all against
13
+ * `scenarios:`-shaped (BDD) specs, which have no top-level `criteria:` key, so criteria was
14
+ * always `[]` there and `isInterpolatedTemplate`'s `criteria.length === 0` guard made every
15
+ * BDD spec's filler unflaggable by construction. Scanning the whole frontmatter block with
16
+ * this regex (plus SCENARIO_TITLE_RE below) fixes both defects in one pass — no truncating
17
+ * section boundary is needed once matching isn't anchored to a single top-level key.
18
+ */
2
19
  const CRITERION_TEXT_RE = /^\s+- text:\s*"?(.+?)"?\s*$/gm;
20
+ /** Matches a BDD scenario's `- title: "..."` line (`bdd-parser.ts:renderBddScenariosYaml`) —
21
+ * the criterion-equivalent for `scenarios:`-format specs, which have no `- text:` entries. */
22
+ const SCENARIO_TITLE_RE = /^\s+- title:\s*"?(.+?)"?\s*$/gm;
3
23
  const FILE_LINE_RE = /^-\s+(.+?)\s+\((pending|done)\)\s*$/gm;
24
+ const H3_HEADING_RE = /^###[ \t]+(.+?)[ \t]*$/gm;
25
+ const BULLET_LINE_RE = /^[-*][ \t]+(.+)$/;
26
+ /** A bullet that, once a leading `Label:`, wrapping backticks, and a trailing `(status)`
27
+ * marker are stripped, is just a file path: contains a `/` and ends in a short extension.
28
+ * File/artifact lists (`Files`, `Modify`, `Create`, `Test`, `File-Level Work Plan`, or any
29
+ * future heading with this shape) are legitimately parallel — the interpolation detector
30
+ * must not run on them. Detected structurally so no heading name needs hardcoding. */
31
+ const BULLET_LABEL_PREFIX_RE = /^[\w-]+:\s*/;
32
+ const STATUS_SUFFIX_RE = /\s*\([\w .-]+\)$/;
33
+ const PATH_TOKEN_RE = /^[\w./-]+\/[\w.-]+\.[a-zA-Z0-9]{1,10}$/;
34
+ /** Minimum normalized length before a variable substring can count as "restating" a
35
+ * criterion — guards against trivial short-string collisions. */
36
+ const CRITERION_RESTATEMENT_MIN_LENGTH = 8;
37
+ /**
38
+ * SPEC-1406 (DEFECT 2 fix): the constant kind reused to mark structural-interpolation
39
+ * findings so callers can tell them apart from the other generic-output issue kinds and
40
+ * decide, per call site, whether that finding blocks or only warns.
41
+ */
42
+ export const STRUCTURAL_INTERPOLATION_KIND = 'structural-interpolation';
4
43
  const GENERIC_CRITERION_RULES = [
5
44
  {
6
45
  pattern: /\bimplementation (is )?complete\b/i,
@@ -50,24 +89,176 @@ const PLACEHOLDER_REFERENCE_RULES = [
50
89
  },
51
90
  ];
52
91
  export function checkGenericSpecOutput(content) {
92
+ const criteria = frontmatterCriteria(content);
53
93
  const issues = [
54
- ...checkCriteria(frontmatterCriteria(content)),
94
+ ...checkCriteria(criteria),
55
95
  ...checkTechnicalReferences(content),
96
+ ...checkInterpolatedSections(content, criteria),
56
97
  ];
57
98
  return { passed: issues.length === 0, issues };
58
99
  }
100
+ /**
101
+ * SPEC-1406 (DEFECT 2, round 2 — detector rebuilt around CRITERION RESTATEMENT): the
102
+ * previous prefix/suffix-length rule flagged `### File-Level Work Plan` (whose bullets
103
+ * legitimately share a `- Modify: \`...\` (pending)` frame around a file path) and missed
104
+ * the actual filler this spec exists to kill (`- AC1 failure: ...`, `- Expected behavior
105
+ * AC1: ...`), because the framing on those templates is interrupted by a digit
106
+ * (`AC1` vs `AC2`) that a plain character-by-character prefix scan treats as a mismatch.
107
+ * The real discriminator isn't frame length — it's whether the varying middle of each
108
+ * bullet is itself a restatement of a declared acceptance criterion. Digit runs are
109
+ * treated as mutually equal while locating the shared frame, then the extracted middle
110
+ * is compared against every criterion.
111
+ */
112
+ function checkInterpolatedSections(content, criteria) {
113
+ const issues = [];
114
+ const headingMatches = [...content.matchAll(H3_HEADING_RE)];
115
+ for (let i = 0; i < headingMatches.length; i += 1) {
116
+ const heading = headingMatches[i];
117
+ if (!heading?.[1]) {
118
+ continue;
119
+ }
120
+ const start = heading.index + heading[0].length;
121
+ const end = headingMatches[i + 1]?.index ?? content.length;
122
+ const bullets = content
123
+ .slice(start, end)
124
+ .split('\n')
125
+ .map((line) => BULLET_LINE_RE.exec(line.trim())?.[1])
126
+ .filter((line) => Boolean(line));
127
+ if (isFileListSection(bullets)) {
128
+ continue;
129
+ }
130
+ if (isInterpolatedTemplate(bullets, criteria)) {
131
+ issues.push({
132
+ kind: STRUCTURAL_INTERPOLATION_KIND,
133
+ phrase: heading[1],
134
+ reason: `subsection "${heading[1]}" restates each criterion into the same template with no new information`,
135
+ });
136
+ }
137
+ }
138
+ return issues;
139
+ }
140
+ /**
141
+ * SPEC-1406 (DEFECT 1, round 3 — file-list false positive): a section whose bullets are
142
+ * mostly file paths (`### Modify`, `### Files`, `### Create`, `### Test`, `### File-Level
143
+ * Work Plan`, ...) is an allowlisted shape, not prose — parallel paths under a shared
144
+ * directory/extension carry no information loss, even when a path also happens to appear
145
+ * inside a criterion's `FILES:` marker. Detected structurally (allowlist, not a heading
146
+ * name denylist) so an unanticipated heading defaults to safe.
147
+ */
148
+ function isFileListSection(bullets) {
149
+ if (bullets.length === 0) {
150
+ return false;
151
+ }
152
+ const pathLikeCount = bullets.filter(isPathLikeBullet).length;
153
+ return pathLikeCount > bullets.length / 2;
154
+ }
155
+ function isPathLikeBullet(bullet) {
156
+ const stripped = bullet
157
+ .trim()
158
+ .replace(BULLET_LABEL_PREFIX_RE, '')
159
+ .replace(STATUS_SUFFIX_RE, '')
160
+ .trim()
161
+ .replace(/^`|`$/g, '');
162
+ return PATH_TOKEN_RE.test(stripped);
163
+ }
164
+ function isInterpolatedTemplate(bullets, criteria) {
165
+ if (bullets.length < 2 || criteria.length === 0) {
166
+ return false;
167
+ }
168
+ const first = bullets[0] ?? '';
169
+ const prefix = commonPrefixLength(bullets);
170
+ const suffix = commonSuffixLength(bullets, prefix);
171
+ if (prefix + suffix >= first.length) {
172
+ // Digit-tolerant framing consumed the entire bullet: the bullets are identical
173
+ // except for an index digit (e.g. `AC1` vs `AC2`) with no other variable content.
174
+ // The digit itself isn't the restated criterion — the digit-stripped body is.
175
+ return bullets.every((bullet) => restatesCriterion(stripDigits(bullet), criteria));
176
+ }
177
+ return bullets.every((bullet) => {
178
+ const variable = bullet.slice(prefix, bullet.length - suffix);
179
+ return restatesCriterion(variable, criteria);
180
+ });
181
+ }
182
+ function stripDigits(value) {
183
+ return value.replace(/\d+/g, '');
184
+ }
185
+ function restatesCriterion(variable, criteria) {
186
+ const normalizedVariable = normalizeForRestatementMatch(variable);
187
+ if (normalizedVariable.length < CRITERION_RESTATEMENT_MIN_LENGTH) {
188
+ return false;
189
+ }
190
+ return criteria.some((criterion) => {
191
+ const normalizedCriterion = normalizeForRestatementMatch(criterion);
192
+ if (normalizedCriterion.length < CRITERION_RESTATEMENT_MIN_LENGTH) {
193
+ return false;
194
+ }
195
+ return (normalizedVariable.includes(normalizedCriterion) ||
196
+ normalizedCriterion.includes(normalizedVariable));
197
+ });
198
+ }
199
+ function normalizeForRestatementMatch(value) {
200
+ return value
201
+ .replace(/[`*_"]/g, '')
202
+ .replace(/\s+/g, ' ')
203
+ .trim()
204
+ .toLowerCase();
205
+ }
206
+ /** Digit runs are treated as mutually equal so a fixed template interrupted only by an
207
+ * `AC1`/`AC2`-style index still resolves to its true shared frame. */
208
+ function charsMatch(a, b) {
209
+ if (a === undefined || b === undefined) {
210
+ return false;
211
+ }
212
+ return a === b || (isDigit(a) && isDigit(b));
213
+ }
214
+ function isDigit(ch) {
215
+ return ch >= '0' && ch <= '9';
216
+ }
217
+ function commonPrefixLength(values) {
218
+ const first = values[0] ?? '';
219
+ let length = first.length;
220
+ for (const value of values.slice(1)) {
221
+ let i = 0;
222
+ while (i < length && i < value.length && charsMatch(value[i], first[i])) {
223
+ i += 1;
224
+ }
225
+ length = Math.min(length, i);
226
+ }
227
+ return length;
228
+ }
229
+ function commonSuffixLength(values, limit) {
230
+ const first = values[0] ?? '';
231
+ let length = first.length - limit;
232
+ for (const value of values.slice(1)) {
233
+ let i = 0;
234
+ while (i < length &&
235
+ i < value.length - limit &&
236
+ charsMatch(value[value.length - 1 - i], first[first.length - 1 - i])) {
237
+ i += 1;
238
+ }
239
+ length = Math.min(length, i);
240
+ }
241
+ return Math.max(length, 0);
242
+ }
59
243
  function frontmatterCriteria(content) {
60
- const criteriaSection = CRITERIA_SECTION_RE.exec(content)?.[1] ?? '';
61
- const criteria = [];
62
- let match = CRITERION_TEXT_RE.exec(criteriaSection);
244
+ const frontmatter = FRONTMATTER_RE.exec(content)?.[1] ?? '';
245
+ return [
246
+ ...collectMatches(frontmatter, CRITERION_TEXT_RE),
247
+ ...collectMatches(frontmatter, SCENARIO_TITLE_RE),
248
+ ];
249
+ }
250
+ function collectMatches(text, pattern) {
251
+ const re = new RegExp(pattern.source, pattern.flags);
252
+ const values = [];
253
+ let match = re.exec(text);
63
254
  while (match) {
64
255
  const phrase = match[1]?.replace(/\\"/g, '"').trim() ?? '';
65
256
  if (phrase.length > 0) {
66
- criteria.push(phrase);
257
+ values.push(phrase);
67
258
  }
68
- match = CRITERION_TEXT_RE.exec(criteriaSection);
259
+ match = re.exec(text);
69
260
  }
70
- return criteria;
261
+ return values;
71
262
  }
72
263
  function checkCriteria(criteria) {
73
264
  const issues = [];
@@ -1,6 +1,7 @@
1
1
  // engine/spec-quality-scorer.ts — Spec quality scoring logic (SPEC-314)
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { readSpecTechnicalSection } from './spec-format/read-technical-section.js';
4
+ import { extractAcceptanceCriteriaTexts } from './spec-format/acceptance-criteria.js';
4
5
  import { stripFrontmatter } from './frontmatter-parser.js';
5
6
  // ── Constants ────────────────────────────────────────────────────────────────
6
7
  const MAX_DIMENSION_SCORE = 25;
@@ -67,11 +68,14 @@ async function readSpecContent(spec) {
67
68
  async function readTechnicalContent(spec) {
68
69
  return readSpecTechnicalSection(spec);
69
70
  }
71
+ // Delegates to the canonical parser (SPEC-1410) instead of matching checkbox
72
+ // lines only, which returned [] for every GIVEN/WHEN/THEN spec. Deliberately
73
+ // does NOT reuse `readiness-checker.extractCriteriaLines`: that helper returns
74
+ // every non-empty line in the section (including WHEN/THEN/FILES: lines),
75
+ // which would inflate the denominator that scoreTestability/scoreAmbiguity
76
+ // divide by.
70
77
  function extractCriteriaLines(content) {
71
- return content
72
- .split('\n')
73
- .filter((line) => /^- \[[ x]\]/.test(line.trim()))
74
- .map((line) => line.trim());
78
+ return extractAcceptanceCriteriaTexts(content);
75
79
  }
76
80
  // ── Dimension scorers ────────────────────────────────────────────────────────
77
81
  function scoreCompleteness(spec, content) {
@@ -107,7 +111,7 @@ function scoreCompleteness(spec, content) {
107
111
  }
108
112
  else {
109
113
  issues.push('No acceptance criteria found');
110
- recommendations.push('Add acceptance criteria in checkbox format (- [ ] ...)');
114
+ recommendations.push('Add acceptance criteria in GIVEN/WHEN/THEN format');
111
115
  }
112
116
  // Scope set to non-default (5 pts) — cast to handle partial specs at runtime
113
117
  const scope = spec.scope;
@@ -154,14 +154,6 @@ async function buildDriftFindings(projectDir, effectiveChanged, activeSpecs, pro
154
154
  driftedFiles,
155
155
  cachedAt: new Date().toISOString(),
156
156
  });
157
- // Wire drift:detected event so autopilot trigger-rules can react
158
- emitAutopilotEvent({
159
- name: 'drift:detected',
160
- projectPath: projectDir,
161
- projectId,
162
- specId: spec.id,
163
- payload: { driftScore, driftLevel: driftScore >= 0.7 ? 'high' : 'warning' },
164
- });
165
157
  findings.push({
166
158
  severity: driftScore >= 0.7 ? 'high' : 'warning',
167
159
  file: '',
@@ -171,6 +163,28 @@ async function buildDriftFindings(projectDir, effectiveChanged, activeSpecs, pro
171
163
  });
172
164
  }
173
165
  }
166
+ if (driftScore >= threshold) {
167
+ // Wire drift:detected event so autopilot trigger-rules can react.
168
+ // Emitted once per project run — driftScore is project-wide, not per-spec,
169
+ // so emitting inside the loop would fire N identical events for N specs.
170
+ //
171
+ // MUST run after the cache-persistence loop above: emitAutopilotEvent
172
+ // dispatches handlers in a microtask (event-bus.ts), and the enabled
173
+ // rule drift-detected:resolve_drift_violations calls resolveDriftViolations,
174
+ // which loads the drift cache as its first action. Emitting before the
175
+ // cache entries are persisted lets that microtask race ahead and read an
176
+ // empty cache, silently resolving nothing.
177
+ emitAutopilotEvent({
178
+ name: 'drift:detected',
179
+ projectPath: projectDir,
180
+ projectId,
181
+ payload: {
182
+ driftScore,
183
+ driftLevel: driftScore >= 0.7 ? 'high' : 'warning',
184
+ specIds: activeSpecs.map((s) => s.id),
185
+ },
186
+ });
187
+ }
174
188
  return findings;
175
189
  }
176
190
  // ---------------------------------------------------------------------------
package/dist/index.js CHANGED
@@ -126,7 +126,9 @@ async function setupPostHandshake(handshakeGate, officialToolNames) {
126
126
  })();
127
127
  // SPEC-334: Sync planu/ YAML frontmatter status → data/ store (non-blocking)
128
128
  scheduleStartupStateRecovery(startupSync, recoverPendingPostCommitTasksAtStartup);
129
- await startSpecCreatedOutboxConsumer().catch((err) => {
129
+ // SPEC-1271: bounded, fair, durable spec.created outbox recovery — fire-and-forget so a
130
+ // large or poisoned backlog never delays post-handshake completion (mirrors serve.ts).
131
+ void startSpecCreatedOutboxConsumer().catch((err) => {
130
132
  console.error('[Planu] Startup spec.created outbox recovery failed:', err);
131
133
  });
132
134
  try {