@planu/cli 4.7.3 → 4.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -1
- package/README.md +6 -3
- package/dist/config/minimal-implementation-gate.json +12 -4
- 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/context-md-generator.js +5 -4
- package/dist/engine/evidence-gates/artifact-reader.js +15 -2
- package/dist/engine/evidence-gates/lifecycle-gate.js +14 -2
- package/dist/engine/generated-artifact-writer.d.ts +7 -0
- package/dist/engine/generated-artifact-writer.js +63 -0
- package/dist/engine/handoff-packager.js +6 -49
- package/dist/engine/implementation-contract/renderer.js +10 -0
- package/dist/engine/next-spec-resolver/session-writer.js +10 -6
- package/dist/engine/readiness-checker.js +13 -7
- package/dist/engine/session-context-generator.js +9 -5
- package/dist/engine/session-state/writer.js +33 -14
- 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/universal-rules/catalog.js +2 -0
- package/dist/engine/universal-rules/rules/planu-minimal-change.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-minimal-change.js +44 -0
- package/dist/engine/universal-rules/rules/planu-release-policy.js +8 -5
- 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/update-status/evidence-gate.js +5 -9
- package/dist/types/cascade-hooks.d.ts +5 -0
- package/dist/types/generated-artifacts.d.ts +13 -0
- package/dist/types/generated-artifacts.js +3 -0
- package/dist/types/spec-format.d.ts +6 -0
- package/package.json +16 -16
- package/planu-native.json +9 -30
- package/planu-plugin.json +7 -35
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## [4.7.5] - 2026-06-24
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
- feat: add Planu minimal-change rule and guidance inspired by Ponytail
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
- fix: keep generated Planu session artifacts stable after lifecycle close
|
|
8
|
+
|
|
9
|
+
### Chores
|
|
10
|
+
- chore(deps): refresh push-gate dev dependency knip
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## [4.7.4] - 2026-06-23
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
- fix: implement SPEC-1091-1094 tech debt bundle
|
|
17
|
+
|
|
18
|
+
### Chores
|
|
19
|
+
- chore(deps): refresh push-gate dev dependencies
|
|
20
|
+
|
|
21
|
+
|
|
1
22
|
## [4.7.3] - 2026-06-19
|
|
2
23
|
|
|
3
24
|
### Features
|
|
@@ -4103,4 +4124,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
|
|
|
4103
4124
|
- Mermaid diagram generation (architecture, sequence, state machine, ER, data flow)
|
|
4104
4125
|
- Multi-language i18n (EN/ES/PT) for generated specs
|
|
4105
4126
|
- Clean Architecture (hexagonal) — engine, tools, storage, types layers
|
|
4106
|
-
- 10,857 tests with ≥95% coverage
|
|
4127
|
+
- 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
|
---
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"tag": "stdlib",
|
|
32
32
|
"severity": "warning",
|
|
33
33
|
"confidence": "medium",
|
|
34
|
-
"patterns": ["custom parser", "manual parser", "hand rolled parser"],
|
|
34
|
+
"patterns": ["custom parser", "manual parser", "hand rolled parser", "hand-rolled stdlib"],
|
|
35
35
|
"replacementGuidance": "Use an existing parser from the runtime or current dependency set before writing parsing code."
|
|
36
36
|
},
|
|
37
37
|
{
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"tag": "native",
|
|
40
40
|
"severity": "warning",
|
|
41
41
|
"confidence": "high",
|
|
42
|
-
"patterns": ["new public mcp tool", "new command", "new cli command"],
|
|
42
|
+
"patterns": ["new public mcp tool", "new command", "new cli command", "native platform feature"],
|
|
43
43
|
"replacementGuidance": "Prefer existing Planu SDD surfaces unless the spec proves a new public surface is required."
|
|
44
44
|
},
|
|
45
45
|
{
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"tag": "yagni",
|
|
56
56
|
"severity": "blocker",
|
|
57
57
|
"confidence": "high",
|
|
58
|
-
"patterns": ["future-proof", "just in case", "might need later", "eventually support"],
|
|
58
|
+
"patterns": ["future-proof", "just in case", "might need later", "eventually support", "speculative config"],
|
|
59
59
|
"replacementGuidance": "Remove speculative scope unless it is directly required by an acceptance criterion.",
|
|
60
60
|
"blockWhenRiskAtLeast": "medium"
|
|
61
61
|
},
|
|
@@ -64,7 +64,15 @@
|
|
|
64
64
|
"tag": "shrink",
|
|
65
65
|
"severity": "warning",
|
|
66
66
|
"confidence": "medium",
|
|
67
|
-
"patterns": [
|
|
67
|
+
"patterns": [
|
|
68
|
+
"framework",
|
|
69
|
+
"orchestration layer",
|
|
70
|
+
"plugin architecture",
|
|
71
|
+
"abstract factory",
|
|
72
|
+
"wrapper that only delegates",
|
|
73
|
+
"delegate-only wrapper",
|
|
74
|
+
"shrinkable logic"
|
|
75
|
+
],
|
|
68
76
|
"replacementGuidance": "Shrink the design to the smallest correct module/function that satisfies the spec."
|
|
69
77
|
},
|
|
70
78
|
{
|
|
@@ -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,8 +1,9 @@
|
|
|
1
1
|
// engine/context-md-generator.ts — SPEC-635: Live ≤800-token context briefing for any LLM
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { specStore } from '../storage/index.js';
|
|
5
5
|
import { stripFrontmatter } from './frontmatter-parser.js';
|
|
6
|
+
import { stableWriteGeneratedFile, stripSessionMarkdownVolatileFields, } from './generated-artifact-writer.js';
|
|
6
7
|
const MAX_TOKENS = 800;
|
|
7
8
|
const CHARS_PER_TOKEN = 4; // rough estimate
|
|
8
9
|
const MAX_CHARS = MAX_TOKENS * CHARS_PER_TOKEN;
|
|
@@ -92,9 +93,9 @@ export async function generateContextMd(projectPath, projectId) {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
const content = truncate(lines.join('\n'), MAX_CHARS);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
await stableWriteGeneratedFile(join(projectPath, 'planu', 'context.md'), content, {
|
|
97
|
+
equivalence: stripSessionMarkdownVolatileFields,
|
|
98
|
+
});
|
|
98
99
|
}
|
|
99
100
|
catch {
|
|
100
101
|
/* best-effort — never blocks status transition */
|
|
@@ -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',
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { StableWriteOptions, StableWriteResult } from '../types/generated-artifacts.js';
|
|
2
|
+
export declare function withFinalNewline(content: string): string;
|
|
3
|
+
export declare function stripSessionMarkdownVolatileFields(content: string): string;
|
|
4
|
+
export declare function stableJsonStringify(value: unknown): string;
|
|
5
|
+
export declare function stripRootUpdatedAt(content: string): string;
|
|
6
|
+
export declare function stableWriteGeneratedFile(filePath: string, content: string, options?: StableWriteOptions): Promise<StableWriteResult>;
|
|
7
|
+
//# sourceMappingURL=generated-artifact-writer.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// engine/generated-artifact-writer.ts — stable writes for generated Planu artifacts.
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
export function withFinalNewline(content) {
|
|
5
|
+
return content.replace(/\s*$/u, '\n');
|
|
6
|
+
}
|
|
7
|
+
export function stripSessionMarkdownVolatileFields(content) {
|
|
8
|
+
return withFinalNewline(content)
|
|
9
|
+
.replace(/^# Planu Context — \d{4}-\d{2}-\d{2}$/mu, '# Planu Context — <date>')
|
|
10
|
+
.replace(/^> Last updated: .+$/mu, '> Last updated: <timestamp>');
|
|
11
|
+
}
|
|
12
|
+
export function stableJsonStringify(value) {
|
|
13
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
14
|
+
}
|
|
15
|
+
export function stripRootUpdatedAt(content) {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(content);
|
|
18
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
19
|
+
const clone = { ...parsed };
|
|
20
|
+
delete clone.updatedAt;
|
|
21
|
+
return stableJsonStringify(clone);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Fall back to exact normalized text comparison for invalid existing JSON.
|
|
26
|
+
}
|
|
27
|
+
return withFinalNewline(content);
|
|
28
|
+
}
|
|
29
|
+
export async function stableWriteGeneratedFile(filePath, content, options = {}) {
|
|
30
|
+
const next = withFinalNewline(content);
|
|
31
|
+
let existing;
|
|
32
|
+
if (options.existingContent !== undefined) {
|
|
33
|
+
if (options.existingContent === null) {
|
|
34
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
35
|
+
await writeFile(filePath, next, 'utf-8');
|
|
36
|
+
return { written: true, reason: 'missing' };
|
|
37
|
+
}
|
|
38
|
+
existing = options.existingContent;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
try {
|
|
42
|
+
existing = await readFile(filePath, 'utf-8');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
46
|
+
await writeFile(filePath, next, 'utf-8');
|
|
47
|
+
return { written: true, reason: 'missing' };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (existing === next) {
|
|
51
|
+
return { written: false, reason: 'unchanged' };
|
|
52
|
+
}
|
|
53
|
+
const existingHasStableNewline = existing.endsWith('\n') && !existing.endsWith('\n\n');
|
|
54
|
+
const equivalent = options.equivalence !== undefined &&
|
|
55
|
+
options.equivalence(existing) === options.equivalence(next);
|
|
56
|
+
if (equivalent && existingHasStableNewline) {
|
|
57
|
+
return { written: false, reason: 'unchanged' };
|
|
58
|
+
}
|
|
59
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
60
|
+
await writeFile(filePath, next, 'utf-8');
|
|
61
|
+
return { written: true, reason: 'changed' };
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=generated-artifact-writer.js.map
|
|
@@ -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;
|
|
@@ -21,6 +21,9 @@ export function buildImplementationContractSection(input) {
|
|
|
21
21
|
'### File-Level Work Plan',
|
|
22
22
|
...renderFilePlan(input.files),
|
|
23
23
|
'',
|
|
24
|
+
'### Minimal Change Guidance',
|
|
25
|
+
...renderMinimalChangeGuidance(),
|
|
26
|
+
'',
|
|
24
27
|
'### Acceptance-To-Verification Map',
|
|
25
28
|
...criteria.map((criterion, index) => renderVerificationRow(criterion.text, index + 1, testFiles, verificationCommands)),
|
|
26
29
|
'',
|
|
@@ -45,6 +48,13 @@ export function appendImplementationContractIfMissing(specBody, input) {
|
|
|
45
48
|
}
|
|
46
49
|
return `${specBody.trimEnd()}\n\n${buildImplementationContractSection(input)}`;
|
|
47
50
|
}
|
|
51
|
+
function renderMinimalChangeGuidance() {
|
|
52
|
+
return [
|
|
53
|
+
'- Before implementing, check whether the work is unnecessary, already exists in this codebase, is covered by stdlib, is covered by the native platform, or is covered by an already-installed dependency.',
|
|
54
|
+
'- Add new code only after those checks, and keep the diff to the minimum that satisfies the accepted criteria.',
|
|
55
|
+
'- Do not use minimality to remove security, trust-boundary validation, data-loss handling, accessibility, explicit requirements, or required validation.',
|
|
56
|
+
];
|
|
57
|
+
}
|
|
48
58
|
function fallbackCriteria() {
|
|
49
59
|
return [
|
|
50
60
|
{
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @crash-shield-ignore-file — config/cache reader for Planu-controlled JSON; writer is this codebase, shape guaranteed by build/seed.
|
|
2
2
|
// engine/next-spec-resolver/session-writer.ts — SPEC-686: Best-effort session.json writer
|
|
3
|
-
import { readFile
|
|
4
|
-
import { join
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { stableJsonStringify, stableWriteGeneratedFile, stripRootUpdatedAt, } from '../generated-artifact-writer.js';
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Constants
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -68,11 +69,11 @@ export async function writeOrchestrationPlan(projectPath, plan) {
|
|
|
68
69
|
export async function patchSessionJson(projectPath, patch) {
|
|
69
70
|
try {
|
|
70
71
|
const filePath = join(projectPath, 'planu', 'session.json');
|
|
71
|
-
|
|
72
|
+
let existingContent = null;
|
|
72
73
|
let current = { activeSpecs: [], updatedAt: '' };
|
|
73
74
|
try {
|
|
74
|
-
|
|
75
|
-
current = JSON.parse(
|
|
75
|
+
existingContent = await readFile(filePath, 'utf-8');
|
|
76
|
+
current = JSON.parse(existingContent);
|
|
76
77
|
}
|
|
77
78
|
catch {
|
|
78
79
|
/* start fresh */
|
|
@@ -82,7 +83,10 @@ export async function patchSessionJson(projectPath, patch) {
|
|
|
82
83
|
...patch,
|
|
83
84
|
updatedAt: new Date().toISOString(),
|
|
84
85
|
};
|
|
85
|
-
await
|
|
86
|
+
await stableWriteGeneratedFile(filePath, stableJsonStringify(updated), {
|
|
87
|
+
equivalence: stripRootUpdatedAt,
|
|
88
|
+
existingContent,
|
|
89
|
+
});
|
|
86
90
|
}
|
|
87
91
|
catch {
|
|
88
92
|
/* best-effort — never throw */
|
|
@@ -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
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// engine/session-context-generator.ts — SPEC-496: Auto-generate portable session-context.md
|
|
2
2
|
// Writes planu/session-context.md so any LLM (Cursor, Windsurf, Aider…) can pick up project state.
|
|
3
|
-
import {
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { specStore, lessonsStore } from '../storage/index.js';
|
|
6
|
+
import { stableWriteGeneratedFile, stripSessionMarkdownVolatileFields, } from './generated-artifact-writer.js';
|
|
6
7
|
/** Reads the `version` field from the project's package.json, or returns 'unknown'. */
|
|
7
8
|
async function readProjectVersion(projectPath) {
|
|
8
9
|
try {
|
|
@@ -128,10 +129,13 @@ export async function generateSessionContext(projectPath, projectId) {
|
|
|
128
129
|
lessonsContent: buildLessonsSection(lessons),
|
|
129
130
|
generatedAt: new Date().toISOString(),
|
|
130
131
|
});
|
|
131
|
-
const
|
|
132
|
-
await
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
const contextPath = join(projectPath, 'planu', 'session-context.md');
|
|
133
|
+
const writeResult = await stableWriteGeneratedFile(contextPath, content, {
|
|
134
|
+
equivalence: stripSessionMarkdownVolatileFields,
|
|
135
|
+
});
|
|
136
|
+
if (!writeResult.written) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
135
139
|
if (process.env.PLANU_ENABLE_AUTOCOMMIT !== 'true') {
|
|
136
140
|
return;
|
|
137
141
|
}
|