@planu/cli 4.7.2 → 4.7.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 (50) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +6 -3
  3. package/dist/config/token-waste-autopilot.json +16 -0
  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/compact/compact-middleware.d.ts +2 -2
  7. package/dist/engine/compact/compact-middleware.js +68 -7
  8. package/dist/engine/context-artifacts/index.d.ts +2 -0
  9. package/dist/engine/context-artifacts/index.js +2 -0
  10. package/dist/engine/context-artifacts/store.d.ts +5 -0
  11. package/dist/engine/context-artifacts/store.js +176 -0
  12. package/dist/engine/evidence-gates/artifact-reader.js +15 -2
  13. package/dist/engine/evidence-gates/lifecycle-gate.js +14 -2
  14. package/dist/engine/handoff-packager.js +6 -49
  15. package/dist/engine/readiness-checker.js +13 -7
  16. package/dist/engine/spec-format/acceptance-criteria.d.ts +5 -0
  17. package/dist/engine/spec-format/acceptance-criteria.js +106 -0
  18. package/dist/engine/spec-migrator/strict-planu-cleanup.js +16 -0
  19. package/dist/engine/token-optimizer/content-aware-compactor.d.ts +4 -0
  20. package/dist/engine/token-optimizer/content-aware-compactor.js +230 -0
  21. package/dist/engine/token-optimizer/index.d.ts +1 -0
  22. package/dist/engine/token-optimizer/index.js +1 -0
  23. package/dist/engine/token-optimizer/output-filter.js +18 -2
  24. package/dist/engine/token-optimizer/policy-loader.js +12 -0
  25. package/dist/engine/token-optimizer/reporter.d.ts +4 -0
  26. package/dist/engine/token-optimizer/reporter.js +14 -1
  27. package/dist/engine/universal-rules/rules/planu-release-policy.js +8 -5
  28. package/dist/engine/web-fetcher/docs-fetcher.js +5 -1
  29. package/dist/native/lightweight-command-catalog.d.ts +1 -1
  30. package/dist/native/lightweight-command-catalog.js +1 -1
  31. package/dist/resources/process.js +3 -2
  32. package/dist/tools/challenge-spec/event-challenge-scenarios.js +10 -6
  33. package/dist/tools/git/hook-ops.js +1 -1
  34. package/dist/tools/init-project/per-client-files-writer.js +2 -1
  35. package/dist/tools/init-project/rules-generator.js +14 -0
  36. package/dist/tools/safe-handler.js +4 -1
  37. package/dist/tools/token-usage-handler.js +5 -3
  38. package/dist/tools/update-status/evidence-gate.js +5 -9
  39. package/dist/types/cascade-hooks.d.ts +5 -0
  40. package/dist/types/compact/compact-mode.d.ts +5 -0
  41. package/dist/types/context-artifacts.d.ts +96 -0
  42. package/dist/types/context-artifacts.js +2 -0
  43. package/dist/types/index.d.ts +1 -0
  44. package/dist/types/index.js +1 -0
  45. package/dist/types/spec-format.d.ts +6 -0
  46. package/dist/types/token-optimization.d.ts +2 -0
  47. package/dist/types/token-waste-autopilot.d.ts +15 -0
  48. package/package.json +18 -18
  49. package/planu-native.json +30 -9
  50. package/planu-plugin.json +35 -7
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [4.7.4] - 2026-06-23
2
+
3
+ ### Bug Fixes
4
+ - fix: implement SPEC-1091-1094 tech debt bundle
5
+
6
+ ### Chores
7
+ - chore(deps): refresh push-gate dev dependencies
8
+
9
+
10
+ ## [4.7.3] - 2026-06-19
11
+
12
+ ### Features
13
+ - feat: add reversible context compaction
14
+
15
+ ### Chores
16
+ - chore(deps): update patch and minor dependencies
17
+
18
+
1
19
  ## [4.7.2] - 2026-06-16
2
20
 
3
21
  ### Features
@@ -4094,4 +4112,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
4094
4112
  - Mermaid diagram generation (architecture, sequence, state machine, ER, data flow)
4095
4113
  - Multi-language i18n (EN/ES/PT) for generated specs
4096
4114
  - Clean Architecture (hexagonal) — engine, tools, storage, types layers
4097
- - 10,857 tests with ≥95% coverage
4115
+ - 10,857 tests with ≥95% coverage
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  **The high-performance SDD stack for AI coding agents.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@planu/cli?color=blue&label=npm)](https://www.npmjs.com/package/@planu/cli)
6
- ![Node](https://img.shields.io/badge/node-%3E%3D22.0.0-green)
6
+ ![Node](https://img.shields.io/badge/node-%3E%3D24.0.0-green)
7
7
  ![Rust](https://img.shields.io/badge/core-rust-orange)
8
8
  ![License](https://img.shields.io/badge/license-MIT-blue)
9
9
 
@@ -41,7 +41,7 @@ Add to your MCP client config (Claude Desktop: `~/Library/Application Support/Cl
41
41
  }
42
42
  ```
43
43
 
44
- **Requirements:** Node.js >= 22. The native Rust core is bundled and auto-detected for macOS (Intel/M1), Linux, and Windows.
44
+ **Requirements:** Node.js >= 24. The native Rust core is bundled and auto-detected for macOS (Intel/M1), Linux, and Windows.
45
45
 
46
46
  ---
47
47
 
@@ -112,7 +112,10 @@ data/ # Local storage (gitignored)
112
112
  pnpm install # Install dependencies
113
113
  pnpm build # Compile TS + Build Rust bridge
114
114
  pnpm dev # Watch mode
115
- pnpm test # Run 31,000+ tests
115
+ pnpm validate # Canonical local validation contract
116
+ pnpm check:strict # Extended local quality gates
117
+ pnpm test # Run the full test suite
118
+ pnpm release:local # Local-first release flow when shipping
116
119
  ```
117
120
 
118
121
  ---
@@ -17,6 +17,22 @@
17
17
  "generic": { "maxLines": 60 }
18
18
  }
19
19
  },
20
+ "contextArtifacts": {
21
+ "enabled": true,
22
+ "ttlMs": 86400000,
23
+ "minTokens": 200
24
+ },
25
+ "contentCompaction": {
26
+ "strategies": {
27
+ "json": { "maxLines": 60, "maxSnippetChars": 240 },
28
+ "test-log": { "maxLines": 80, "keepFailures": true, "maxSnippetChars": 240 },
29
+ "runtime-log": { "maxLines": 60, "keepFailures": true, "uniqueOnly": true, "maxSnippetChars": 240 },
30
+ "search-results": { "maxLines": 80, "uniqueOnly": true, "maxSnippetChars": 180 },
31
+ "code": { "maxLines": 120, "maxSnippetChars": 240 },
32
+ "spec-or-handoff": { "maxLines": 120, "maxSnippetChars": 240 },
33
+ "generic-text": { "maxLines": 60, "maxSnippetChars": 240 }
34
+ }
35
+ },
20
36
  "tools": {
21
37
  "groups": {
22
38
  "spec": ["create_spec", "check_readiness", "challenge_spec", "update_status"],
@@ -1,3 +1,4 @@
1
- import type { CascadeContext, CoreActionResult } from '../../../types/cascade-hooks.js';
1
+ import type { CascadeContext, CoreActionResult, PendingReleaseEntry } from '../../../types/cascade-hooks.js';
2
+ export declare function normalizePendingReleaseEntries(value: unknown, now?: Date): PendingReleaseEntry[];
2
3
  export declare function appendReleasesPending(ctx: CascadeContext): Promise<CoreActionResult>;
3
4
  //# sourceMappingURL=append-releases.d.ts.map
@@ -5,6 +5,35 @@ import { join } from 'node:path';
5
5
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
6
6
  import { specStore } from '../../../storage/index.js';
7
7
  const CORE_ACTION_BUDGET_MS = 2000;
8
+ const PENDING_ENTRY_MAX_AGE_DAYS = 30;
9
+ function isValidPendingReleaseEntry(value) {
10
+ return (typeof value === 'object' &&
11
+ value !== null &&
12
+ typeof value.specId === 'string' &&
13
+ typeof value.title === 'string' &&
14
+ typeof value.completedAt === 'string');
15
+ }
16
+ function isRecentPendingEntry(entry, now = new Date()) {
17
+ const completedAt = new Date(entry.completedAt);
18
+ if (Number.isNaN(completedAt.getTime())) {
19
+ return false;
20
+ }
21
+ const ageMs = now.getTime() - completedAt.getTime();
22
+ return ageMs <= PENDING_ENTRY_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
23
+ }
24
+ export function normalizePendingReleaseEntries(value, now = new Date()) {
25
+ if (!Array.isArray(value)) {
26
+ return [];
27
+ }
28
+ const deduped = new Map();
29
+ for (const entry of value) {
30
+ if (!isValidPendingReleaseEntry(entry) || !isRecentPendingEntry(entry, now)) {
31
+ continue;
32
+ }
33
+ deduped.set(entry.specId, entry);
34
+ }
35
+ return [...deduped.values()];
36
+ }
8
37
  async function actuallyAppendReleasesPending(ctx, opts) {
9
38
  const { projectPath, projectId, specId } = ctx;
10
39
  if (!projectPath || ctx.newStatus !== 'done') {
@@ -26,9 +55,7 @@ async function actuallyAppendReleasesPending(ctx, opts) {
26
55
  try {
27
56
  const raw = await readFile(pendingPath, 'utf-8');
28
57
  const parsed = JSON.parse(raw);
29
- if (Array.isArray(parsed)) {
30
- pendingList = parsed;
31
- }
58
+ pendingList = normalizePendingReleaseEntries(parsed);
32
59
  }
33
60
  catch {
34
61
  /* file doesn't exist yet — start fresh */
@@ -1,10 +1,10 @@
1
1
  import type { ToolResult } from '../../types/index.js';
2
- import type { CompactDecision } from '../../types/compact/compact-mode.js';
2
+ import type { CompactDecision, CompactModeOptions } from '../../types/compact/compact-mode.js';
3
3
  /**
4
4
  * Apply compact mode formatting to a ToolResult.
5
5
  * - Truncates text content blocks to tokenBudget
6
6
  * - Preserves structuredContent (essential data: status, scores, blockers)
7
7
  * - Adds _meta.compactMode and _meta.contextUsed
8
8
  */
9
- export declare function applyCompactMode(result: ToolResult, decision: CompactDecision): ToolResult;
9
+ export declare function applyCompactMode(result: ToolResult, decision: CompactDecision, options?: CompactModeOptions): ToolResult;
10
10
  //# sourceMappingURL=compact-middleware.d.ts.map
@@ -1,6 +1,47 @@
1
1
  // engine/compact/compact-middleware.ts — SPEC-922: Apply compact mode to tool responses
2
+ import { compactContentAware } from '../token-optimizer/content-aware-compactor.js';
2
3
  /** Approximate chars per token for English text. */
3
4
  const CHARS_PER_TOKEN = 4;
5
+ function defaultCompactPolicy(tokenBudget) {
6
+ return {
7
+ contextArtifacts: {
8
+ enabled: true,
9
+ ttlMs: 24 * 60 * 60 * 1000,
10
+ minTokens: tokenBudget,
11
+ },
12
+ contentCompaction: {
13
+ strategies: {
14
+ json: { maxLines: tokenBudget },
15
+ 'test-log': { maxLines: tokenBudget },
16
+ 'runtime-log': { maxLines: tokenBudget },
17
+ 'search-results': { maxLines: tokenBudget },
18
+ code: { maxLines: tokenBudget },
19
+ 'spec-or-handoff': { maxLines: tokenBudget },
20
+ 'generic-text': { maxLines: tokenBudget },
21
+ },
22
+ },
23
+ redaction: {
24
+ maxSnippetChars: 240,
25
+ redactPatterns: ['secret', 'token', 'password', 'api_key'],
26
+ },
27
+ };
28
+ }
29
+ function withCompactionMeta(result, compaction) {
30
+ if (compaction === undefined) {
31
+ return result;
32
+ }
33
+ return {
34
+ ...result,
35
+ structuredContent: {
36
+ ...(result.structuredContent ?? {}),
37
+ compaction,
38
+ },
39
+ _meta: {
40
+ ...(typeof result._meta === 'object' && result._meta !== null ? result._meta : {}),
41
+ compaction,
42
+ },
43
+ };
44
+ }
4
45
  /** Truncate a single text block to a token budget. */
5
46
  function truncateBlock(text, tokenBudget) {
6
47
  const maxChars = tokenBudget * CHARS_PER_TOKEN;
@@ -17,7 +58,7 @@ function truncateBlock(text, tokenBudget) {
17
58
  * - Preserves structuredContent (essential data: status, scores, blockers)
18
59
  * - Adds _meta.compactMode and _meta.contextUsed
19
60
  */
20
- export function applyCompactMode(result, decision) {
61
+ export function applyCompactMode(result, decision, options = {}) {
21
62
  if (decision.mode !== 'compact') {
22
63
  // Still add _meta even in verbose mode if contextUsed was provided
23
64
  if (decision.contextUsed !== undefined) {
@@ -32,11 +73,31 @@ export function applyCompactMode(result, decision) {
32
73
  }
33
74
  return result;
34
75
  }
35
- const truncatedContent = result.content.map((block) => ({
36
- ...block,
37
- text: truncateBlock(block.text, decision.tokenBudget),
38
- }));
39
- return {
76
+ let firstCompaction;
77
+ const truncatedContent = result.content.map((block) => {
78
+ if (options.projectPath !== undefined) {
79
+ const compacted = compactContentAware({
80
+ text: block.text,
81
+ policy: defaultCompactPolicy(decision.tokenBudget),
82
+ projectPath: options.projectPath,
83
+ sourcePath: options.sourcePath,
84
+ flow: options.flow,
85
+ kind: options.flow,
86
+ });
87
+ firstCompaction ??= compacted.artifact;
88
+ return {
89
+ ...block,
90
+ text: compacted.text.length < block.text.length
91
+ ? `${compacted.text}\n\nArtifact: ${compacted.artifact?.artifactRef ?? 'not stored'}`
92
+ : truncateBlock(block.text, decision.tokenBudget),
93
+ };
94
+ }
95
+ return {
96
+ ...block,
97
+ text: truncateBlock(block.text, decision.tokenBudget),
98
+ };
99
+ });
100
+ return withCompactionMeta({
40
101
  ...result,
41
102
  content: truncatedContent,
42
103
  _meta: {
@@ -45,6 +106,6 @@ export function applyCompactMode(result, decision) {
45
106
  contextUsed: decision.contextUsed,
46
107
  tokenBudget: decision.tokenBudget,
47
108
  },
48
- };
109
+ }, firstCompaction);
49
110
  }
50
111
  //# sourceMappingURL=compact-middleware.js.map
@@ -0,0 +1,2 @@
1
+ export { getContextArtifactStats, retrieveContextArtifact, storeContextArtifact } from './store.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { getContextArtifactStats, retrieveContextArtifact, storeContextArtifact } from './store.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,5 @@
1
+ import type { ContextArtifactStats, RetrieveContextArtifactResult, StoreContextArtifactInput, StoreContextArtifactResult } from '../../types/context-artifacts.js';
2
+ export declare function storeContextArtifact(input: StoreContextArtifactInput): StoreContextArtifactResult;
3
+ export declare function retrieveContextArtifact(projectPath: string, ref: string): RetrieveContextArtifactResult;
4
+ export declare function getContextArtifactStats(projectPath: string): ContextArtifactStats;
5
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1,176 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { getSensitivePathRefusal, shouldBypassLeanMode, } from '../context-intelligence/compression-guards.js';
5
+ import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
6
+ const REF_PREFIX = 'ctx_';
7
+ const REF_PATTERN = /^ctx_[a-f0-9]{32}$/;
8
+ const FORBIDDEN_METADATA_KEY = /(?:secret|token|password|credential|api[_-]?key|private[_-]?key)/i;
9
+ function artifactDir(projectPath) {
10
+ return join(projectDataDir(hashProjectPath(projectPath)), 'context-artifacts');
11
+ }
12
+ function artifactPath(projectPath, ref) {
13
+ if (!REF_PATTERN.test(ref)) {
14
+ return null;
15
+ }
16
+ return join(artifactDir(projectPath), `${ref}.json`);
17
+ }
18
+ function sha256(value) {
19
+ return createHash('sha256').update(value, 'utf8').digest('hex');
20
+ }
21
+ function safeMetadata(metadata) {
22
+ const safe = {};
23
+ for (const [key, value] of Object.entries(metadata ?? {})) {
24
+ if (FORBIDDEN_METADATA_KEY.test(key)) {
25
+ continue;
26
+ }
27
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
28
+ safe[key] =
29
+ typeof value === 'string' && FORBIDDEN_METADATA_KEY.test(value) ? '[redacted]' : value;
30
+ }
31
+ }
32
+ return safe;
33
+ }
34
+ function compactionMetadata(artifact) {
35
+ return {
36
+ artifactRef: artifact.ref,
37
+ originalTokens: artifact.originalTokens,
38
+ compactTokens: artifact.compactTokens,
39
+ tokensSaved: artifact.tokensSaved,
40
+ strategy: artifact.strategy,
41
+ contentType: artifact.contentType,
42
+ expiresAt: artifact.expiresAt,
43
+ retrievalHint: `Use artifactRef ${artifact.ref} to retrieve the full local output before ${artifact.expiresAt}.`,
44
+ };
45
+ }
46
+ function readArtifactFile(path) {
47
+ try {
48
+ return JSON.parse(readFileSync(path, 'utf-8'));
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ function writeArtifactFile(path, artifact) {
55
+ writeFileSync(path, JSON.stringify(artifact, null, 2), 'utf-8');
56
+ }
57
+ export function storeContextArtifact(input) {
58
+ if (input.sourcePath) {
59
+ const refusal = getSensitivePathRefusal(input.sourcePath);
60
+ if (refusal !== null) {
61
+ return { stored: false, refusedReason: refusal };
62
+ }
63
+ }
64
+ if (input.flow && shouldBypassLeanMode(input.flow)) {
65
+ return {
66
+ stored: false,
67
+ refusedReason: `Refusing recoverable artifact for sensitive flow ${input.flow}`,
68
+ };
69
+ }
70
+ const contentHash = sha256(input.originalContent);
71
+ const ref = `${REF_PREFIX}${contentHash.slice(0, 32)}`;
72
+ const path = artifactPath(input.projectPath, ref);
73
+ if (path === null) {
74
+ return { stored: false, refusedReason: 'Invalid generated artifact ref' };
75
+ }
76
+ const now = Date.now();
77
+ const createdAt = new Date(now).toISOString();
78
+ const expiresAt = new Date(now + input.ttlMs).toISOString();
79
+ const tokensSaved = Math.max(0, input.originalTokens - input.compactTokens);
80
+ const previous = existsSync(path) ? readArtifactFile(path) : null;
81
+ const artifact = {
82
+ ref,
83
+ contentHash,
84
+ createdAt: previous?.createdAt ?? createdAt,
85
+ expiresAt,
86
+ ttlMs: input.ttlMs,
87
+ projectId: hashProjectPath(input.projectPath),
88
+ contentType: input.contentType,
89
+ strategy: input.strategy,
90
+ originalTokens: input.originalTokens,
91
+ compactTokens: input.compactTokens,
92
+ tokensSaved,
93
+ retrievalCount: previous?.retrievalCount ?? 0,
94
+ metadata: safeMetadata(input.metadata),
95
+ originalContent: input.originalContent,
96
+ compactContent: input.compactContent,
97
+ };
98
+ mkdirSync(artifactDir(input.projectPath), { recursive: true });
99
+ writeArtifactFile(path, artifact);
100
+ return { stored: true, artifact, metadata: compactionMetadata(artifact) };
101
+ }
102
+ export function retrieveContextArtifact(projectPath, ref) {
103
+ const path = artifactPath(projectPath, ref);
104
+ if (path === null) {
105
+ return {
106
+ found: false,
107
+ reason: 'invalid-ref',
108
+ hint: 'Artifact refs are opaque ctx_<hash> identifiers.',
109
+ };
110
+ }
111
+ if (!existsSync(path)) {
112
+ return {
113
+ found: false,
114
+ reason: 'missing',
115
+ hint: 'The artifact is missing or belongs to a different project.',
116
+ };
117
+ }
118
+ const artifact = readArtifactFile(path);
119
+ if (artifact === null) {
120
+ return {
121
+ found: false,
122
+ reason: 'corrupt',
123
+ hint: 'The artifact payload is corrupt and cannot be recovered.',
124
+ };
125
+ }
126
+ if (artifact.projectId !== hashProjectPath(projectPath)) {
127
+ return {
128
+ found: false,
129
+ reason: 'unauthorized',
130
+ hint: 'The artifact does not belong to this project scope.',
131
+ };
132
+ }
133
+ if (Date.parse(artifact.expiresAt) <= Date.now()) {
134
+ return {
135
+ found: false,
136
+ reason: 'expired',
137
+ hint: 'The artifact expired; rerun the source operation if exact output is needed.',
138
+ };
139
+ }
140
+ const updated = { ...artifact, retrievalCount: artifact.retrievalCount + 1 };
141
+ writeArtifactFile(path, updated);
142
+ return {
143
+ found: true,
144
+ artifact: updated,
145
+ hint: 'Full artifact recovered from local Planu storage.',
146
+ };
147
+ }
148
+ export function getContextArtifactStats(projectPath) {
149
+ const dir = artifactDir(projectPath);
150
+ const stats = {
151
+ artifactCount: 0,
152
+ totalOriginalTokens: 0,
153
+ totalCompactTokens: 0,
154
+ totalTokensSaved: 0,
155
+ retrievalCount: 0,
156
+ };
157
+ if (!existsSync(dir)) {
158
+ return stats;
159
+ }
160
+ for (const entry of readdirSync(dir)) {
161
+ if (!entry.endsWith('.json')) {
162
+ continue;
163
+ }
164
+ const artifact = readArtifactFile(join(dir, entry));
165
+ if (artifact === null || Date.parse(artifact.expiresAt) <= Date.now()) {
166
+ continue;
167
+ }
168
+ stats.artifactCount += 1;
169
+ stats.totalOriginalTokens += artifact.originalTokens;
170
+ stats.totalCompactTokens += artifact.compactTokens;
171
+ stats.totalTokensSaved += artifact.tokensSaved;
172
+ stats.retrievalCount += artifact.retrievalCount;
173
+ }
174
+ return stats;
175
+ }
176
+ //# sourceMappingURL=store.js.map
@@ -49,6 +49,19 @@ const ContractValidationSchema = z.object({
49
49
  reportPath: z.string().optional(),
50
50
  summary: z.string().optional(),
51
51
  });
52
+ const ARTIFACT_HINTS = {
53
+ 'Discovery evidence': 'Expected discovery.json with { version: 1, rules: string[], examples: [{ rule, example }], openQuestions: [{ question, status, resolution? }], outOfScope: string[], glossary: [{ term, meaning }] }.',
54
+ 'Task plan evidence': 'Expected task-plan.json with { version: 1, tasks: [{ id, title, acceptanceCriteria: string[], status: "pending"|"doing"|"done" }] }. Acceptance criteria may be full text or stable IDs such as AC1 when they map to the canonical criteria order.',
55
+ 'Traceability matrix evidence': 'Expected traceability-matrix.json with { version: 1, rows: [{ acceptanceCriterion, scenario?, testEvidence?, contractEvidence?, manualEvidence?, changedFiles: string[], validationEvidence?, reviewerEvidence? }] }.',
56
+ 'contract-validation-api.json': 'Expected filename contract-validation-api.json with { version: 1, kind: "api", passed: boolean, reportPath?, summary? }.',
57
+ 'contract-validation-graphql.json': 'Expected filename contract-validation-graphql.json with { version: 1, kind: "graphql", passed: boolean, reportPath?, summary? }.',
58
+ 'contract-validation-event.json': 'Expected filename contract-validation-event.json with { version: 1, kind: "event", passed: boolean, reportPath?, summary? }.',
59
+ 'contract-validation-ui.json': 'Expected filename contract-validation-ui.json with { version: 1, kind: "ui", passed: boolean, reportPath?, summary? }.',
60
+ 'contract-validation-mcp.json': 'Expected filename contract-validation-mcp.json with { version: 1, kind: "mcp", passed: boolean, reportPath?, summary? }.',
61
+ };
62
+ function artifactHint(label) {
63
+ return ARTIFACT_HINTS[label] ?? '';
64
+ }
52
65
  function handoffEvidencePath(projectId, specId, filename) {
53
66
  return join(projectDataDir(projectId), 'handoffs', specId, filename);
54
67
  }
@@ -78,13 +91,13 @@ async function readOptional(args) {
78
91
  }
79
92
  const parsed = args.schema.safeParse(found.value);
80
93
  if (!parsed.success) {
81
- args.invalidArtifacts.push(`${args.label} is invalid at ${found.path}: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`);
94
+ args.invalidArtifacts.push(`${args.label} is invalid at ${found.path}: ${parsed.error.issues.map((issue) => issue.message).join('; ')} ${artifactHint(args.label)}`.trim());
82
95
  return undefined;
83
96
  }
84
97
  return parsed.data;
85
98
  }
86
99
  catch (err) {
87
- args.invalidArtifacts.push(`${args.label} is unreadable: ${err instanceof Error ? err.message : String(err)}`);
100
+ args.invalidArtifacts.push(`${args.label} is unreadable: ${err instanceof Error ? err.message : String(err)} ${artifactHint(args.label)}`.trim());
88
101
  return undefined;
89
102
  }
90
103
  }
@@ -11,10 +11,22 @@ function isNonTrivial(spec) {
11
11
  function normalizeCriterion(value) {
12
12
  return value
13
13
  .replace(/^-\s*\[[ x]\]\s*/i, '')
14
+ .replace(/^(AC\d+)\s*[:.)-]?\s*/i, '')
14
15
  .replace(/\s+/g, ' ')
15
16
  .trim()
16
17
  .toLowerCase();
17
18
  }
19
+ function criterionAliases(value, index) {
20
+ const aliases = new Set([
21
+ normalizeCriterion(value),
22
+ normalizeCriterion(`AC${String(index + 1)}`),
23
+ ]);
24
+ const explicit = /^(AC\d+)\b/i.exec(value)?.[1];
25
+ if (explicit) {
26
+ aliases.add(normalizeCriterion(explicit));
27
+ }
28
+ return [...aliases].filter((alias) => alias.length > 0);
29
+ }
18
30
  function hasText(value) {
19
31
  return value !== undefined && value.trim().length > 0;
20
32
  }
@@ -62,7 +74,7 @@ export function checkTaskPlanGate(spec, criteria, artifacts) {
62
74
  ];
63
75
  }
64
76
  const covered = new Set(artifacts.taskPlan.tasks.flatMap((task) => task.acceptanceCriteria.map(normalizeCriterion)));
65
- const uncovered = criteria.filter((criterion) => !covered.has(normalizeCriterion(criterion)));
77
+ const uncovered = criteria.filter((criterion, index) => !criterionAliases(criterion, index).some((alias) => covered.has(alias)));
66
78
  if (uncovered.length === 0) {
67
79
  return [];
68
80
  }
@@ -90,7 +102,7 @@ export function checkDoneEvidenceGate(spec, criteria, artifacts) {
90
102
  }
91
103
  else {
92
104
  const covered = new Set(matrix.rows.map((row) => normalizeCriterion(row.acceptanceCriterion)));
93
- const uncovered = criteria.filter((criterion) => !covered.has(normalizeCriterion(criterion)));
105
+ const uncovered = criteria.filter((criterion, index) => !criterionAliases(criterion, index).some((alias) => covered.has(alias)));
94
106
  if (uncovered.length > 0) {
95
107
  issues.push({
96
108
  code: 'traceability_uncovered_criteria',
@@ -4,19 +4,8 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { stripFrontmatter } from './frontmatter-parser.js';
6
6
  import { hashProjectPath, projectDataDir } from '../storage/base-store.js';
7
+ import { extractAcceptanceCriteriaTexts } from './spec-format/acceptance-criteria.js';
7
8
  // ── Parsing helpers ──────────────────────────────────────────────────────────
8
- async function safeReadFile(path) {
9
- if (!path) {
10
- return '';
11
- }
12
- try {
13
- const raw = await readFile(path, 'utf-8');
14
- return stripFrontmatter(raw);
15
- }
16
- catch {
17
- return '';
18
- }
19
- }
20
9
  function extractObjective(huContent, spec) {
21
10
  if (!huContent) {
22
11
  return spec.title;
@@ -40,41 +29,6 @@ function extractObjective(huContent, spec) {
40
29
  }
41
30
  return spec.title;
42
31
  }
43
- function extractCriteria(huContent) {
44
- if (!huContent) {
45
- return [];
46
- }
47
- const bddBlocks = extractBddCriteria(huContent);
48
- if (bddBlocks.length > 0) {
49
- return bddBlocks;
50
- }
51
- return huContent
52
- .split('\n')
53
- .filter((line) => /^- \[[ x]\]/.test(line.trim()))
54
- .map((line) => line.trim().replace(/^- \[[ x]\]\s*/, ''));
55
- }
56
- function extractBddCriteria(content) {
57
- const criteria = [];
58
- let current = [];
59
- for (const rawLine of content.split('\n')) {
60
- const trimmed = rawLine.trim().replace(/^-+\s*/, '');
61
- if (/^(GIVEN|WHEN|THEN|AND)\b/i.test(trimmed)) {
62
- if (/^GIVEN\b/i.test(trimmed) && current.length > 0) {
63
- criteria.push(current.join(' / '));
64
- current = [];
65
- }
66
- current.push(trimmed);
67
- }
68
- else if (current.length > 0 && trimmed === '') {
69
- criteria.push(current.join(' / '));
70
- current = [];
71
- }
72
- }
73
- if (current.length > 0) {
74
- criteria.push(current.join(' / '));
75
- }
76
- return criteria.filter((criterion) => /GIVEN\b/i.test(criterion) && /WHEN\b/i.test(criterion) && /THEN\b/i.test(criterion));
77
- }
78
32
  function extractFilesFromFicha(fichaContent) {
79
33
  if (!fichaContent) {
80
34
  return { toModify: [], toCreate: [] };
@@ -203,13 +157,16 @@ function buildConstraints(knowledge) {
203
157
  // ── Public API ───────────────────────────────────────────────────────────────
204
158
  export async function packageHandoff(spec, knowledge) {
205
159
  const warnings = [];
206
- const huContent = await safeReadFile(spec.specPath);
160
+ const rawSpecContent = spec.specPath
161
+ ? await readFile(spec.specPath, 'utf-8').catch(() => '')
162
+ : '';
163
+ const huContent = rawSpecContent ? stripFrontmatter(rawSpecContent) : '';
207
164
  const fichaContent = huContent;
208
165
  if (!huContent) {
209
166
  warnings.push('spec.md not found — using minimal package. Run create_spec to generate it.');
210
167
  }
211
168
  const objective = extractObjective(huContent, spec);
212
- const criteria = extractCriteria(huContent);
169
+ const criteria = extractAcceptanceCriteriaTexts(rawSpecContent);
213
170
  const parsedFiles = extractFilesFromFicha(fichaContent);
214
171
  const discoveredFiles = extractBacktickedFiles(`${huContent}\n${fichaContent}`);
215
172
  const toModify = parsedFiles.toModify.length > 0 ? parsedFiles.toModify : discoveredFiles;
@@ -6,6 +6,7 @@ import { loadReadinessConfig } from './readiness-config-loader.js';
6
6
  import { runEarsGate } from './ears-gate.js';
7
7
  import { findScenariosWithoutTests, parseFrontmatterScenarios, } from './validator/spec-compliance-runner.js';
8
8
  import { evaluateImplementationContract } from './implementation-contract/index.js';
9
+ import { extractNormalizedAcceptanceCriteria } from './spec-format/acceptance-criteria.js';
9
10
  // ── SPEC-784: Technical section quality constants ─────────────────────────────
10
11
  const TECHNICAL_MIN_CHARS = 500;
11
12
  // Detects "See technical.md", "See spec.md", or "See `<file>` technical.md" patterns
@@ -344,7 +345,8 @@ function checkSpecificityGate(spec, criteriaLines, fichaContent) {
344
345
  */
345
346
  export function checkReadinessInternal(body) {
346
347
  const bodyContent = stripFrontmatter(body);
347
- const criteriaLines = extractCriteriaLines(bodyContent);
348
+ const normalizedCriteria = extractNormalizedAcceptanceCriteria(body);
349
+ const criteriaLines = normalizedCriteria.map((criterion) => criterion.text);
348
350
  const scenarioCount = criteriaLines.length === 0 ? countFrontmatterScenarios(body) : 0;
349
351
  const criteriaCount = criteriaLines.length > 0 ? criteriaLines.length : scenarioCount;
350
352
  const config = loadReadinessConfig();
@@ -402,19 +404,23 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
402
404
  const huRaw = await readHuRaw(spec);
403
405
  const huContent = stripFrontmatter(huRaw);
404
406
  const fichaContent = await readFichaContent(spec);
405
- const criteriaLines = extractCriteriaLines(huContent);
407
+ const normalizedCriteria = extractNormalizedAcceptanceCriteria(huRaw);
408
+ const canonicalCriteriaLines = normalizedCriteria.map((criterion) => criterion.text);
409
+ const usesFrontmatterAsCanonical = normalizedCriteria.length > 0 &&
410
+ normalizedCriteria.every((criterion) => criterion.source === 'frontmatter');
406
411
  // When a spec uses BDD format, acceptance criteria are stored as `scenarios:` in
407
412
  // the YAML frontmatter (multi-line YAML that the simple KV parser cannot handle).
408
- // If no criteria were found in the body, fall back to counting frontmatter scenarios.
409
- const scenarioCriteria = criteriaLines.length === 0 ? frontmatterScenarioCriteria(huRaw) : [];
410
- const effectiveCriteriaLines = scenarioCriteria.length > 0 ? scenarioCriteria : criteriaLines;
413
+ // When those scenarios are canonical, keep their linked tests in the readiness
414
+ // evidence model so specificity gates still see exact test paths and line refs.
415
+ const scenarioCriteria = usesFrontmatterAsCanonical ? frontmatterScenarioCriteria(huRaw) : [];
416
+ const effectiveCriteriaLines = scenarioCriteria.length > 0 ? scenarioCriteria : canonicalCriteriaLines;
411
417
  const hu = scoreHuCompleteness(spec);
412
418
  const criteria = scoreCriteria(effectiveCriteriaLines, vagueWords);
413
419
  const files = scoreFilesIdentified(spec, fichaContent);
414
420
  const deps = scoreDependencies(spec);
415
421
  const totalScore = hu.points + criteria.points + files.points + deps.points;
416
422
  // SPEC-631: EARS gate — hard-block for vague modal verbs ("should/may/could/might") in criteria
417
- const earsBlockers = runEarsGate(criteriaLines);
423
+ const earsBlockers = runEarsGate(effectiveCriteriaLines);
418
424
  const allBlockers = [
419
425
  ...hu.blockers,
420
426
  ...criteria.blockers,
@@ -424,7 +430,7 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
424
430
  ];
425
431
  const allWarnings = [...hu.warnings, ...criteria.warnings, ...files.warnings, ...deps.warnings];
426
432
  if (mode === 'strict' && spec.scope !== 'trivial' && spec.difficulty >= 3) {
427
- for (const issue of evaluateImplementationContract(huContent, effectiveCriteriaLines)) {
433
+ for (const issue of evaluateImplementationContract(huContent, canonicalCriteriaLines)) {
428
434
  allBlockers.push(`${issue.code}: ${issue.message}`);
429
435
  }
430
436
  }
@@ -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