@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.
- package/CHANGELOG.md +19 -1
- package/README.md +6 -3
- package/dist/config/token-waste-autopilot.json +16 -0
- package/dist/engine/cascade-hooks/core/append-releases.d.ts +2 -1
- package/dist/engine/cascade-hooks/core/append-releases.js +30 -3
- package/dist/engine/compact/compact-middleware.d.ts +2 -2
- package/dist/engine/compact/compact-middleware.js +68 -7
- package/dist/engine/context-artifacts/index.d.ts +2 -0
- package/dist/engine/context-artifacts/index.js +2 -0
- package/dist/engine/context-artifacts/store.d.ts +5 -0
- package/dist/engine/context-artifacts/store.js +176 -0
- package/dist/engine/evidence-gates/artifact-reader.js +15 -2
- package/dist/engine/evidence-gates/lifecycle-gate.js +14 -2
- package/dist/engine/handoff-packager.js +6 -49
- package/dist/engine/readiness-checker.js +13 -7
- package/dist/engine/spec-format/acceptance-criteria.d.ts +5 -0
- package/dist/engine/spec-format/acceptance-criteria.js +106 -0
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +16 -0
- package/dist/engine/token-optimizer/content-aware-compactor.d.ts +4 -0
- package/dist/engine/token-optimizer/content-aware-compactor.js +230 -0
- package/dist/engine/token-optimizer/index.d.ts +1 -0
- package/dist/engine/token-optimizer/index.js +1 -0
- package/dist/engine/token-optimizer/output-filter.js +18 -2
- package/dist/engine/token-optimizer/policy-loader.js +12 -0
- package/dist/engine/token-optimizer/reporter.d.ts +4 -0
- package/dist/engine/token-optimizer/reporter.js +14 -1
- package/dist/engine/universal-rules/rules/planu-release-policy.js +8 -5
- package/dist/engine/web-fetcher/docs-fetcher.js +5 -1
- package/dist/native/lightweight-command-catalog.d.ts +1 -1
- package/dist/native/lightweight-command-catalog.js +1 -1
- package/dist/resources/process.js +3 -2
- package/dist/tools/challenge-spec/event-challenge-scenarios.js +10 -6
- package/dist/tools/git/hook-ops.js +1 -1
- package/dist/tools/init-project/per-client-files-writer.js +2 -1
- package/dist/tools/init-project/rules-generator.js +14 -0
- package/dist/tools/safe-handler.js +4 -1
- package/dist/tools/token-usage-handler.js +5 -3
- package/dist/tools/update-status/evidence-gate.js +5 -9
- package/dist/types/cascade-hooks.d.ts +5 -0
- package/dist/types/compact/compact-mode.d.ts +5 -0
- package/dist/types/context-artifacts.d.ts +96 -0
- package/dist/types/context-artifacts.js +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/spec-format.d.ts +6 -0
- package/dist/types/token-optimization.d.ts +2 -0
- package/dist/types/token-waste-autopilot.d.ts +15 -0
- package/package.json +18 -18
- package/planu-native.json +30 -9
- 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
|
[](https://www.npmjs.com/package/@planu/cli)
|
|
6
|
-

|
|
7
7
|

|
|
8
8
|

|
|
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 >=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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,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(
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
//
|
|
409
|
-
|
|
410
|
-
const
|
|
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(
|
|
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,
|
|
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
|