@planu/cli 3.9.6 → 3.9.8
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/dist/config/skill-templates/planu-context-assets.md +94 -0
- package/dist/engine/handoff-packager.js +151 -4
- package/dist/engine/sdd-model-routing.d.ts +16 -0
- package/dist/engine/sdd-model-routing.js +195 -0
- package/dist/engine/universal-rules/catalog.js +10 -0
- package/dist/engine/universal-rules/installer.js +9 -3
- package/dist/engine/universal-rules/rules/planu-approval-gates.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-approval-gates.js +41 -0
- package/dist/engine/universal-rules/rules/planu-bdd-criteria.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-bdd-criteria.js +45 -0
- package/dist/engine/universal-rules/rules/planu-english-specs.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-english-specs.js +36 -0
- package/dist/engine/universal-rules/rules/planu-release-policy.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-release-policy.js +38 -0
- package/dist/engine/universal-rules/rules/planu-sdd-model-routing.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-sdd-model-routing.js +51 -0
- package/dist/tools/init-project/host-assets-writer.d.ts +21 -0
- package/dist/tools/init-project/host-assets-writer.js +171 -0
- package/dist/tools/init-project/scaffold-writer.d.ts +8 -0
- package/dist/tools/init-project/scaffold-writer.js +122 -74
- package/dist/tools/package-handoff.js +76 -0
- package/dist/tools/update-status/file-sync.d.ts +1 -0
- package/dist/tools/update-status/file-sync.js +1 -0
- package/dist/tools/update-status/index.js +88 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/readiness.d.ts +10 -1
- package/dist/types/sdd-model-routing.d.ts +22 -0
- package/dist/types/sdd-model-routing.js +2 -0
- package/dist/types/spec/inputs.d.ts +23 -0
- package/package.json +7 -7
- package/dist/types/data/estimation.d.ts +0 -147
- package/dist/types/data/estimation.js +0 -2
- package/dist/types/data/index.d.ts +0 -5
- package/dist/types/data/index.js +0 -6
- package/dist/types/data/velocity.d.ts +0 -168
- package/dist/types/data/velocity.js +0 -4
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: planu-context-assets
|
|
3
|
+
description: Create project-specific Planu rules or skills from the current app context so recurring standards and workflows become reusable agent guidance instead of MCP tool bloat.
|
|
4
|
+
triggers:
|
|
5
|
+
- create project rules
|
|
6
|
+
- create project skills
|
|
7
|
+
- capture this workflow
|
|
8
|
+
- make this a rule
|
|
9
|
+
- make this a skill
|
|
10
|
+
- codify this pattern
|
|
11
|
+
- project context changed
|
|
12
|
+
- app conventions
|
|
13
|
+
version: 1.0.0
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# /planu-context-assets — Create Rules or Skills from App Context
|
|
17
|
+
|
|
18
|
+
## When to invoke
|
|
19
|
+
|
|
20
|
+
Use this skill when project context reveals a recurring convention, workflow, integration, domain rule, or implementation pattern that future agents should reuse.
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
|
|
24
|
+
- A design system rule is discovered while implementing UI.
|
|
25
|
+
- A release or deployment workflow becomes stable.
|
|
26
|
+
- A legacy cleanup pattern repeats across modules.
|
|
27
|
+
- A domain invariant must never be violated.
|
|
28
|
+
- A framework-specific project convention is discovered during analysis.
|
|
29
|
+
|
|
30
|
+
## Decision rule
|
|
31
|
+
|
|
32
|
+
Create a **rule** when the guidance is mandatory and should constrain every future agent:
|
|
33
|
+
|
|
34
|
+
- architecture boundaries
|
|
35
|
+
- spec quality gates
|
|
36
|
+
- release policy
|
|
37
|
+
- BDD/SDD invariants
|
|
38
|
+
- security or data handling rules
|
|
39
|
+
- "never do X" / "always do Y" project standards
|
|
40
|
+
|
|
41
|
+
Create a **skill** when the guidance is an optional workflow invoked for a specific task:
|
|
42
|
+
|
|
43
|
+
- release workflow
|
|
44
|
+
- Figma implementation workflow
|
|
45
|
+
- legacy characterization workflow
|
|
46
|
+
- domain bundle generation
|
|
47
|
+
- integration setup
|
|
48
|
+
- migration playbook
|
|
49
|
+
- debugging or audit procedure
|
|
50
|
+
|
|
51
|
+
Do not create either when the information is one-off, speculative, or already covered by an existing rule/skill.
|
|
52
|
+
|
|
53
|
+
## Workflow
|
|
54
|
+
|
|
55
|
+
1. Inspect the current context and existing project assets:
|
|
56
|
+
- `AGENTS.md`
|
|
57
|
+
- `CLAUDE.md`
|
|
58
|
+
- `.claude/rules/`
|
|
59
|
+
- `.claude/skills/`
|
|
60
|
+
- `.gemini/conventions.md`
|
|
61
|
+
- `.gemini/skills/`
|
|
62
|
+
- `.opencode/rules/`
|
|
63
|
+
- `.opencode/skills/`
|
|
64
|
+
2. Decide whether the new asset is a rule, a skill, or nothing.
|
|
65
|
+
3. Keep the asset small and operational:
|
|
66
|
+
- one purpose
|
|
67
|
+
- explicit trigger
|
|
68
|
+
- concrete steps or constraints
|
|
69
|
+
- verification command when applicable
|
|
70
|
+
4. Use Planu host-aware tools:
|
|
71
|
+
- `create_rule` for mandatory constraints
|
|
72
|
+
- `create_skill` for reusable workflows
|
|
73
|
+
5. Use `host: "auto"` unless the user explicitly targets a host.
|
|
74
|
+
6. Prefer updating an existing Planu-owned asset over creating a duplicate.
|
|
75
|
+
|
|
76
|
+
## Quality bar
|
|
77
|
+
|
|
78
|
+
- Rules must be enforceable and written as constraints.
|
|
79
|
+
- Skills must describe when to invoke, what to inspect, what to do, and what output to produce.
|
|
80
|
+
- Do not reference legacy MCP tools that are not part of the 14-tool public core.
|
|
81
|
+
- Do not create broad "misc" skills.
|
|
82
|
+
- Do not preserve dead workflows "just in case".
|
|
83
|
+
|
|
84
|
+
## Output
|
|
85
|
+
|
|
86
|
+
Report:
|
|
87
|
+
|
|
88
|
+
- asset type: rule or skill
|
|
89
|
+
- asset name
|
|
90
|
+
- host target
|
|
91
|
+
- path or file updated
|
|
92
|
+
- why it should exist
|
|
93
|
+
- when it should trigger
|
|
94
|
+
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// engine/handoff-packager.ts — Assembles handoff context package for AI agents (SPEC-039)
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
import { stripFrontmatter } from './frontmatter-parser.js';
|
|
4
6
|
// ── Parsing helpers ──────────────────────────────────────────────────────────
|
|
5
7
|
async function safeReadFile(path) {
|
|
@@ -41,11 +43,37 @@ function extractCriteria(huContent) {
|
|
|
41
43
|
if (!huContent) {
|
|
42
44
|
return [];
|
|
43
45
|
}
|
|
46
|
+
const bddBlocks = extractBddCriteria(huContent);
|
|
47
|
+
if (bddBlocks.length > 0) {
|
|
48
|
+
return bddBlocks;
|
|
49
|
+
}
|
|
44
50
|
return huContent
|
|
45
51
|
.split('\n')
|
|
46
52
|
.filter((line) => /^- \[[ x]\]/.test(line.trim()))
|
|
47
53
|
.map((line) => line.trim().replace(/^- \[[ x]\]\s*/, ''));
|
|
48
54
|
}
|
|
55
|
+
function extractBddCriteria(content) {
|
|
56
|
+
const criteria = [];
|
|
57
|
+
let current = [];
|
|
58
|
+
for (const rawLine of content.split('\n')) {
|
|
59
|
+
const trimmed = rawLine.trim().replace(/^-+\s*/, '');
|
|
60
|
+
if (/^(GIVEN|WHEN|THEN|AND)\b/i.test(trimmed)) {
|
|
61
|
+
if (/^GIVEN\b/i.test(trimmed) && current.length > 0) {
|
|
62
|
+
criteria.push(current.join(' / '));
|
|
63
|
+
current = [];
|
|
64
|
+
}
|
|
65
|
+
current.push(trimmed);
|
|
66
|
+
}
|
|
67
|
+
else if (current.length > 0 && trimmed === '') {
|
|
68
|
+
criteria.push(current.join(' / '));
|
|
69
|
+
current = [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (current.length > 0) {
|
|
73
|
+
criteria.push(current.join(' / '));
|
|
74
|
+
}
|
|
75
|
+
return criteria.filter((criterion) => /GIVEN\b/i.test(criterion) && /WHEN\b/i.test(criterion) && /THEN\b/i.test(criterion));
|
|
76
|
+
}
|
|
49
77
|
function extractFilesFromFicha(fichaContent) {
|
|
50
78
|
if (!fichaContent) {
|
|
51
79
|
return { toModify: [], toCreate: [] };
|
|
@@ -88,6 +116,35 @@ function extractFilesFromFicha(fichaContent) {
|
|
|
88
116
|
}
|
|
89
117
|
return { toModify, toCreate };
|
|
90
118
|
}
|
|
119
|
+
function extractBacktickedFiles(content) {
|
|
120
|
+
const files = new Set();
|
|
121
|
+
const matches = content.matchAll(/`([^`\n]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|yml|yaml|sh|css|scss|html))`/g);
|
|
122
|
+
for (const match of matches) {
|
|
123
|
+
const filePath = match[1]?.trim();
|
|
124
|
+
if (filePath && !filePath.includes(' ')) {
|
|
125
|
+
files.add(filePath);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...files];
|
|
129
|
+
}
|
|
130
|
+
function extractLinesByKeywords(content, keywords) {
|
|
131
|
+
return content
|
|
132
|
+
.split('\n')
|
|
133
|
+
.map((line) => line.trim().replace(/^[-*]\s*/, ''))
|
|
134
|
+
.filter((line) => line.length > 0 && keywords.test(line))
|
|
135
|
+
.slice(0, 12);
|
|
136
|
+
}
|
|
137
|
+
function extractOperationalSections(content, spec) {
|
|
138
|
+
return {
|
|
139
|
+
testPlan: extractLinesByKeywords(content, /\b(test|typecheck|lint|vitest|playwright|verification|validate|pnpm|npm)\b/i),
|
|
140
|
+
risks: extractLinesByKeywords(content, /\b(risk|edge|failure|security|drift|migration|breaking|compatibility|rollback)\b/i),
|
|
141
|
+
ownership: extractLinesByKeywords(content, /\b(owner|ownership|wave|agent|reviewer|arbiter|files?|responsible)\b/i),
|
|
142
|
+
currentState: `Spec ${spec.id} is ${spec.status}; implementation must follow the approved spec artifact, not chat history.`,
|
|
143
|
+
nextAction: spec.status === 'approved'
|
|
144
|
+
? 'Move to implementing with this handoff, then implement only the listed scope.'
|
|
145
|
+
: 'Review the current spec status and move through the required SDD gates before implementation.',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
91
148
|
function extractOutOfScope(huContent, spec) {
|
|
92
149
|
if (!huContent) {
|
|
93
150
|
return [
|
|
@@ -155,13 +212,24 @@ export async function packageHandoff(spec, knowledge) {
|
|
|
155
212
|
}
|
|
156
213
|
const objective = extractObjective(huContent, spec);
|
|
157
214
|
const criteria = extractCriteria(huContent);
|
|
158
|
-
const
|
|
215
|
+
const parsedFiles = extractFilesFromFicha(fichaContent);
|
|
216
|
+
const discoveredFiles = extractBacktickedFiles(`${huContent}\n${fichaContent}`);
|
|
217
|
+
const toModify = parsedFiles.toModify.length > 0 ? parsedFiles.toModify : discoveredFiles;
|
|
218
|
+
const toCreate = parsedFiles.toCreate;
|
|
159
219
|
const outOfScope = extractOutOfScope(huContent, spec);
|
|
160
220
|
const constraints = buildConstraints(knowledge);
|
|
221
|
+
const operational = extractOperationalSections(`${huContent}\n${fichaContent}`, spec);
|
|
161
222
|
if (criteria.length === 0) {
|
|
162
|
-
warnings.push('No acceptance criteria found
|
|
223
|
+
warnings.push('No acceptance criteria found with complete BDD structure — implementation handoff is blocked');
|
|
163
224
|
}
|
|
164
|
-
|
|
225
|
+
const blockers = [
|
|
226
|
+
...(criteria.length === 0 ? ['Missing BDD acceptance criteria'] : []),
|
|
227
|
+
...(toModify.length + toCreate.length === 0 ? ['Missing file ownership / target files'] : []),
|
|
228
|
+
...(operational.testPlan.length === 0 ? ['Missing test plan'] : []),
|
|
229
|
+
...(operational.risks.length === 0 ? ['Missing risk analysis'] : []),
|
|
230
|
+
...(operational.ownership.length === 0 ? ['Missing ownership'] : []),
|
|
231
|
+
];
|
|
232
|
+
const pkg = {
|
|
165
233
|
specId: spec.id,
|
|
166
234
|
generatedAt: new Date().toISOString(),
|
|
167
235
|
readinessScore: 0, // caller can populate with checkSpecReadiness result
|
|
@@ -169,9 +237,88 @@ export async function packageHandoff(spec, knowledge) {
|
|
|
169
237
|
criteria,
|
|
170
238
|
filesToModify: toModify,
|
|
171
239
|
filesToCreate: toCreate,
|
|
240
|
+
ownership: operational.ownership,
|
|
241
|
+
testPlan: operational.testPlan,
|
|
242
|
+
risks: operational.risks,
|
|
172
243
|
outOfScope,
|
|
244
|
+
currentState: operational.currentState,
|
|
245
|
+
nextAction: operational.nextAction,
|
|
173
246
|
constraints,
|
|
174
247
|
warnings,
|
|
248
|
+
blocked: blockers.length > 0,
|
|
249
|
+
blockers,
|
|
175
250
|
};
|
|
251
|
+
return persistHandoffIfPossible(pkg, knowledge);
|
|
252
|
+
}
|
|
253
|
+
function renderPersistedHandoff(pkg) {
|
|
254
|
+
return [
|
|
255
|
+
`# Implementation Handoff Package — ${pkg.specId}`,
|
|
256
|
+
'',
|
|
257
|
+
`Generated: ${pkg.generatedAt}`,
|
|
258
|
+
`Blocked: ${pkg.blocked ? 'yes' : 'no'}`,
|
|
259
|
+
'',
|
|
260
|
+
'## Objective',
|
|
261
|
+
pkg.objective,
|
|
262
|
+
'',
|
|
263
|
+
'## Acceptance Criteria (BDD)',
|
|
264
|
+
...pkg.criteria.map((criterion) => `- ${criterion}`),
|
|
265
|
+
'',
|
|
266
|
+
'## Files To Modify',
|
|
267
|
+
...pkg.filesToModify.map((filePath) => `- ${filePath}`),
|
|
268
|
+
'',
|
|
269
|
+
'## Files To Create',
|
|
270
|
+
...pkg.filesToCreate.map((filePath) => `- ${filePath}`),
|
|
271
|
+
'',
|
|
272
|
+
'## Ownership',
|
|
273
|
+
...pkg.ownership.map((item) => `- ${item}`),
|
|
274
|
+
'',
|
|
275
|
+
'## Test Plan',
|
|
276
|
+
...pkg.testPlan.map((item) => `- ${item}`),
|
|
277
|
+
'',
|
|
278
|
+
'## Risks',
|
|
279
|
+
...pkg.risks.map((item) => `- ${item}`),
|
|
280
|
+
'',
|
|
281
|
+
'## Out Of Scope',
|
|
282
|
+
...pkg.outOfScope.map((item) => `- ${item}`),
|
|
283
|
+
'',
|
|
284
|
+
'## Current State',
|
|
285
|
+
pkg.currentState,
|
|
286
|
+
'',
|
|
287
|
+
'## Next Action',
|
|
288
|
+
pkg.nextAction,
|
|
289
|
+
'',
|
|
290
|
+
'## Blockers',
|
|
291
|
+
...(pkg.blockers.length > 0 ? pkg.blockers.map((item) => `- ${item}`) : ['- None']),
|
|
292
|
+
'',
|
|
293
|
+
].join('\n');
|
|
294
|
+
}
|
|
295
|
+
async function persistHandoffIfPossible(pkg, knowledge) {
|
|
296
|
+
if (!knowledge.projectPath) {
|
|
297
|
+
return pkg;
|
|
298
|
+
}
|
|
299
|
+
const handoffDir = join(knowledge.projectPath, 'planu', 'handoffs');
|
|
300
|
+
const handoffPath = join(handoffDir, `${pkg.specId}.md`);
|
|
301
|
+
const sessionContextPath = join(knowledge.projectPath, 'planu', 'session-context.md');
|
|
302
|
+
const markdown = renderPersistedHandoff(pkg);
|
|
303
|
+
try {
|
|
304
|
+
await mkdir(handoffDir, { recursive: true });
|
|
305
|
+
await writeFile(handoffPath, markdown, 'utf-8');
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return {
|
|
309
|
+
...pkg,
|
|
310
|
+
warnings: [
|
|
311
|
+
...pkg.warnings,
|
|
312
|
+
'Could not persist handoff artifact — context gate will block implementing.',
|
|
313
|
+
],
|
|
314
|
+
blocked: true,
|
|
315
|
+
blockers: [...pkg.blockers, 'Handoff artifact could not be persisted'],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const contextContent = await readFile(sessionContextPath, 'utf-8').catch(() => '');
|
|
319
|
+
const contextHash = createHash('sha256')
|
|
320
|
+
.update(`${contextContent}\n---HANDOFF---\n${markdown}`, 'utf8')
|
|
321
|
+
.digest('hex');
|
|
322
|
+
return { ...pkg, handoffPath, contextHash };
|
|
176
323
|
}
|
|
177
324
|
//# sourceMappingURL=handoff-packager.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SpecStatus, UpdateStatusInput } from '../types/index.js';
|
|
2
|
+
import type { SddModelTier, SddLifecyclePhase, SddRoutingGateResult, TransitionEvidenceMeta } from '../types/sdd-model-routing.js';
|
|
3
|
+
export declare function requiredTierForPhase(phase: SddLifecyclePhase): SddModelTier;
|
|
4
|
+
export declare function phaseForTransition(status: SpecStatus): SddLifecyclePhase | null;
|
|
5
|
+
export declare function classifySddModelTier(modelId: string | undefined): SddModelTier | 'unknown';
|
|
6
|
+
export declare function hashContextPackage(input: {
|
|
7
|
+
projectPath: string;
|
|
8
|
+
handoffPath?: string;
|
|
9
|
+
}): Promise<string>;
|
|
10
|
+
export declare function checkSddModelRoutingGate(input: {
|
|
11
|
+
params: UpdateStatusInput;
|
|
12
|
+
status: SpecStatus;
|
|
13
|
+
projectPath?: string;
|
|
14
|
+
}): Promise<SddRoutingGateResult>;
|
|
15
|
+
export declare function buildTransitionEvidenceMeta(params: UpdateStatusInput): TransitionEvidenceMeta;
|
|
16
|
+
//# sourceMappingURL=sdd-model-routing.d.ts.map
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// engine/sdd-model-routing.ts — Hard gates for SDD model tiers and context continuity.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { classifyModelTier } from './model-tier-resolver.js';
|
|
6
|
+
const FORCE_REASON_MIN_LENGTH = 100;
|
|
7
|
+
export function requiredTierForPhase(phase) {
|
|
8
|
+
if (phase === 'analysis' || phase === 'arbitration') {
|
|
9
|
+
return 'max';
|
|
10
|
+
}
|
|
11
|
+
if (phase === 'review') {
|
|
12
|
+
return 'review';
|
|
13
|
+
}
|
|
14
|
+
return 'implementation';
|
|
15
|
+
}
|
|
16
|
+
export function phaseForTransition(status) {
|
|
17
|
+
if (status === 'approved') {
|
|
18
|
+
return 'analysis';
|
|
19
|
+
}
|
|
20
|
+
if (status === 'implementing') {
|
|
21
|
+
return 'implementation';
|
|
22
|
+
}
|
|
23
|
+
if (status === 'done') {
|
|
24
|
+
return 'arbitration';
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export function classifySddModelTier(modelId) {
|
|
29
|
+
if (!modelId) {
|
|
30
|
+
return 'unknown';
|
|
31
|
+
}
|
|
32
|
+
const id = modelId.toLowerCase();
|
|
33
|
+
if (/opus|ultra|o1|o3|gemini.*(?:ultra|pro)|gpt-5(?:[._-]?5)?(?!.*(?:mini|nano|small|flash))/.test(id)) {
|
|
34
|
+
return 'max';
|
|
35
|
+
}
|
|
36
|
+
const modelTier = classifyModelTier(modelId);
|
|
37
|
+
if (modelTier === 'haiku') {
|
|
38
|
+
return 'implementation';
|
|
39
|
+
}
|
|
40
|
+
if (modelTier === 'opus') {
|
|
41
|
+
return 'max';
|
|
42
|
+
}
|
|
43
|
+
return 'review';
|
|
44
|
+
}
|
|
45
|
+
function hasTierEvidence(params, required) {
|
|
46
|
+
if (params.modelTierUsed === required) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
const classified = classifySddModelTier(params.modelId);
|
|
50
|
+
if (required === 'max') {
|
|
51
|
+
return classified === 'max';
|
|
52
|
+
}
|
|
53
|
+
if (required === 'implementation') {
|
|
54
|
+
return classified === 'implementation';
|
|
55
|
+
}
|
|
56
|
+
return classified === 'review' || classified === 'max';
|
|
57
|
+
}
|
|
58
|
+
function forceReason(params) {
|
|
59
|
+
const reason = params.forceStatusReason ?? params.reason ?? '';
|
|
60
|
+
return reason.trim().length >= FORCE_REASON_MIN_LENGTH ? reason.trim() : null;
|
|
61
|
+
}
|
|
62
|
+
function canForce(params, status) {
|
|
63
|
+
const forceFlag = status === 'approved'
|
|
64
|
+
? params.forceApprove === true
|
|
65
|
+
: status === 'done'
|
|
66
|
+
? params.forceStatus === true || params.force === true
|
|
67
|
+
: params.force === true;
|
|
68
|
+
return forceFlag && forceReason(params) !== null;
|
|
69
|
+
}
|
|
70
|
+
function buildBlock(status, blockers, fixHint) {
|
|
71
|
+
return {
|
|
72
|
+
content: [
|
|
73
|
+
{
|
|
74
|
+
type: 'text',
|
|
75
|
+
text: JSON.stringify({
|
|
76
|
+
error: 'SDD_MODEL_ROUTING_GATE_FAILED',
|
|
77
|
+
status,
|
|
78
|
+
blockers,
|
|
79
|
+
fixHint,
|
|
80
|
+
}, null, 2),
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
isError: true,
|
|
84
|
+
structuredContent: {
|
|
85
|
+
error: 'SDD_MODEL_ROUTING_GATE_FAILED',
|
|
86
|
+
status,
|
|
87
|
+
blockers,
|
|
88
|
+
fixHint,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export async function hashContextPackage(input) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
const sessionContextPath = join(input.projectPath, 'planu', 'session-context.md');
|
|
95
|
+
parts.push(await readFile(sessionContextPath, 'utf-8'));
|
|
96
|
+
if (input.handoffPath) {
|
|
97
|
+
parts.push(await readFile(input.handoffPath, 'utf-8'));
|
|
98
|
+
}
|
|
99
|
+
return createHash('sha256').update(parts.join('\n---HANDOFF---\n'), 'utf8').digest('hex');
|
|
100
|
+
}
|
|
101
|
+
async function contextCanBeReconstructed(params) {
|
|
102
|
+
const blockers = [];
|
|
103
|
+
if (!params.projectPath) {
|
|
104
|
+
blockers.push('projectPath is required to reconstruct planu/session-context.md.');
|
|
105
|
+
return blockers;
|
|
106
|
+
}
|
|
107
|
+
const sessionContextPath = join(params.projectPath, 'planu', 'session-context.md');
|
|
108
|
+
try {
|
|
109
|
+
const raw = await readFile(sessionContextPath, 'utf-8');
|
|
110
|
+
if (raw.trim().length < 80) {
|
|
111
|
+
blockers.push('planu/session-context.md exists but is too small to reconstruct context.');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
blockers.push('planu/session-context.md is missing or unreadable.');
|
|
116
|
+
}
|
|
117
|
+
if (params.handoffPath) {
|
|
118
|
+
try {
|
|
119
|
+
const raw = await readFile(params.handoffPath, 'utf-8');
|
|
120
|
+
if (raw.trim().length < 200) {
|
|
121
|
+
blockers.push('handoffPath exists but is too small to be an operational package.');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
blockers.push('handoffPath is missing or unreadable.');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (!params.handoffArtifactId) {
|
|
129
|
+
blockers.push('handoffPath or handoffArtifactId is required.');
|
|
130
|
+
}
|
|
131
|
+
return blockers;
|
|
132
|
+
}
|
|
133
|
+
export async function checkSddModelRoutingGate(input) {
|
|
134
|
+
const { params, status, projectPath } = input;
|
|
135
|
+
const phase = phaseForTransition(status);
|
|
136
|
+
const gateResults = {};
|
|
137
|
+
const forcedReasons = [];
|
|
138
|
+
if (phase === null) {
|
|
139
|
+
return { blockResult: null, gateResults: { sddModelRouting: 'skip' }, forcedReasons };
|
|
140
|
+
}
|
|
141
|
+
const requiredTier = requiredTierForPhase(phase);
|
|
142
|
+
const blockers = [];
|
|
143
|
+
if (!hasTierEvidence(params, requiredTier)) {
|
|
144
|
+
blockers.push(`Transition to ${status} requires model tier "${requiredTier}" evidence. Received modelTierUsed="${params.modelTierUsed ?? 'missing'}", modelId="${params.modelId ?? 'missing'}".`);
|
|
145
|
+
}
|
|
146
|
+
if (status === 'implementing' || status === 'done') {
|
|
147
|
+
if (!params.contextHash) {
|
|
148
|
+
blockers.push('contextHash is required so the next agent can verify context continuity.');
|
|
149
|
+
}
|
|
150
|
+
blockers.push(...(await contextCanBeReconstructed({
|
|
151
|
+
projectPath,
|
|
152
|
+
handoffPath: params.handoffPath,
|
|
153
|
+
handoffArtifactId: params.handoffArtifactId,
|
|
154
|
+
})));
|
|
155
|
+
}
|
|
156
|
+
if (status === 'done') {
|
|
157
|
+
if (!params.reviewedBy || params.reviewedBy.trim().length === 0) {
|
|
158
|
+
blockers.push('reviewedBy is required before done.');
|
|
159
|
+
}
|
|
160
|
+
if (!params.arbitratedBy || params.arbitratedBy.trim().length === 0) {
|
|
161
|
+
blockers.push('arbitratedBy is required before done.');
|
|
162
|
+
}
|
|
163
|
+
if (params.reconcileRequired === true) {
|
|
164
|
+
blockers.push('reconcileRequired=true blocks done until reconcile_spec records the intentional drift.');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (blockers.length === 0) {
|
|
168
|
+
gateResults.sddModelRouting = 'pass';
|
|
169
|
+
return { blockResult: null, gateResults, forcedReasons };
|
|
170
|
+
}
|
|
171
|
+
if (canForce(params, status)) {
|
|
172
|
+
gateResults.sddModelRouting = 'forced';
|
|
173
|
+
forcedReasons.push(forceReason(params) ?? 'Forced SDD model/context gate');
|
|
174
|
+
return { blockResult: null, gateResults, forcedReasons };
|
|
175
|
+
}
|
|
176
|
+
gateResults.sddModelRouting = 'fail';
|
|
177
|
+
return {
|
|
178
|
+
blockResult: buildBlock(status, blockers, 'Provide modelTierUsed/modelId evidence plus package_handoff context evidence, or force with a >=100 character audited reason.'),
|
|
179
|
+
gateResults,
|
|
180
|
+
forcedReasons,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function buildTransitionEvidenceMeta(params) {
|
|
184
|
+
return {
|
|
185
|
+
...(params.modelTierUsed !== undefined && { modelTierUsed: params.modelTierUsed }),
|
|
186
|
+
...(params.modelId !== undefined && { modelId: params.modelId }),
|
|
187
|
+
...(params.contextHash !== undefined && { contextHash: params.contextHash }),
|
|
188
|
+
...(params.handoffPath !== undefined && { handoffPath: params.handoffPath }),
|
|
189
|
+
...(params.handoffArtifactId !== undefined && { handoffArtifactId: params.handoffArtifactId }),
|
|
190
|
+
...(params.reviewedBy !== undefined && { reviewedBy: params.reviewedBy }),
|
|
191
|
+
...(params.arbitratedBy !== undefined && { arbitratedBy: params.arbitratedBy }),
|
|
192
|
+
...(params.reconcileRequired !== undefined && { reconcileRequired: params.reconcileRequired }),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=sdd-model-routing.js.map
|
|
@@ -4,12 +4,22 @@ import { planuDogfoodBugsRule } from './rules/planu-dogfood-bugs.js';
|
|
|
4
4
|
import { planuWorkflowRule } from './rules/planu-workflow.js';
|
|
5
5
|
import { planuModesRule } from './rules/planu-modes.js';
|
|
6
6
|
import { agentTeamsRule } from './rules/agent-teams.js';
|
|
7
|
+
import { planuEnglishSpecsRule } from './rules/planu-english-specs.js';
|
|
8
|
+
import { planuBddCriteriaRule } from './rules/planu-bdd-criteria.js';
|
|
9
|
+
import { planuApprovalGatesRule } from './rules/planu-approval-gates.js';
|
|
10
|
+
import { planuReleasePolicyRule } from './rules/planu-release-policy.js';
|
|
11
|
+
import { planuSddModelRoutingRule } from './rules/planu-sdd-model-routing.js';
|
|
7
12
|
/**
|
|
8
13
|
* The full catalog of universal Planu rules.
|
|
9
14
|
* Order matters: rules are installed in catalog order.
|
|
10
15
|
*/
|
|
11
16
|
export const UNIVERSAL_RULES = [
|
|
12
17
|
planuWorkflowRule,
|
|
18
|
+
planuEnglishSpecsRule,
|
|
19
|
+
planuBddCriteriaRule,
|
|
20
|
+
planuApprovalGatesRule,
|
|
21
|
+
planuSddModelRoutingRule,
|
|
22
|
+
planuReleasePolicyRule,
|
|
13
23
|
planuModesRule,
|
|
14
24
|
agentTeamsRule,
|
|
15
25
|
planuDogfoodBugsRule,
|
|
@@ -6,8 +6,14 @@ import { UNIVERSAL_RULES } from './catalog.js';
|
|
|
6
6
|
import { writeRuleForHost } from './host-writer.js';
|
|
7
7
|
import { hashContent, readManifest, upsertManifestEntry } from './user-edit-detector.js';
|
|
8
8
|
/** Path of the manifest relative to projectPath. */
|
|
9
|
-
function manifestPath(projectPath) {
|
|
10
|
-
|
|
9
|
+
function manifestPath(projectPath, host) {
|
|
10
|
+
if (host === 'claude-code') {
|
|
11
|
+
return join(projectPath, '.claude', 'rules', '.planu-rules-manifest.json');
|
|
12
|
+
}
|
|
13
|
+
if (host === 'gemini') {
|
|
14
|
+
return join(projectPath, '.gemini', '.planu-rules-manifest.json');
|
|
15
|
+
}
|
|
16
|
+
return join(projectPath, '.planu', 'rules-manifest.codex.json');
|
|
11
17
|
}
|
|
12
18
|
/**
|
|
13
19
|
* Install all default-enabled universal rules applicable to `host`.
|
|
@@ -15,7 +21,7 @@ function manifestPath(projectPath) {
|
|
|
15
21
|
* Returns the list of rules that were written.
|
|
16
22
|
*/
|
|
17
23
|
export async function installUniversalRules(projectPath, host) {
|
|
18
|
-
const mPath = manifestPath(projectPath);
|
|
24
|
+
const mPath = manifestPath(projectPath, host);
|
|
19
25
|
const manifest = (await readManifest(mPath)) ?? { rules: [] };
|
|
20
26
|
const installed = [];
|
|
21
27
|
for (const rule of UNIVERSAL_RULES) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// engine/universal-rules/rules/planu-approval-gates.ts — Universal rule: approval and done gates
|
|
2
|
+
function buildBody() {
|
|
3
|
+
return `# Planu Approval and Done Gates
|
|
4
|
+
|
|
5
|
+
Auto-generated by \`init_project\`. Do not edit manually.
|
|
6
|
+
|
|
7
|
+
## Approval Gate
|
|
8
|
+
|
|
9
|
+
Before moving a spec to \`approved\`, run and pass:
|
|
10
|
+
|
|
11
|
+
1. \`challenge_spec\`
|
|
12
|
+
2. \`check_readiness\`
|
|
13
|
+
3. BDD criteria completeness
|
|
14
|
+
4. files-to-create / files-to-modify ownership
|
|
15
|
+
5. test plan and verification commands
|
|
16
|
+
|
|
17
|
+
If the user explicitly forces approval, record the reason in the audit trail and make the missing risk visible.
|
|
18
|
+
|
|
19
|
+
## Done Gate
|
|
20
|
+
|
|
21
|
+
Before moving a spec to \`done\`, run \`validate\`.
|
|
22
|
+
|
|
23
|
+
If implementation intentionally diverged from the approved spec, run \`reconcile_spec\` first and make the divergence explicit before marking done.
|
|
24
|
+
|
|
25
|
+
## Hard Blocks
|
|
26
|
+
|
|
27
|
+
- Do not approve specs with placeholders.
|
|
28
|
+
- Do not mark done without validation evidence.
|
|
29
|
+
- Do not hide intentional drift; reconcile it.
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
export const planuApprovalGatesRule = {
|
|
33
|
+
id: 'planu-approval-gates',
|
|
34
|
+
name: 'Planu Approval Gates',
|
|
35
|
+
description: 'Requires challenge/readiness before approval and validate/reconcile before done.',
|
|
36
|
+
category: 'safety',
|
|
37
|
+
applicableHosts: ['all'],
|
|
38
|
+
defaultEnabled: true,
|
|
39
|
+
buildContent: (_host) => buildBody(),
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=planu-approval-gates.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// engine/universal-rules/rules/planu-bdd-criteria.ts — Universal rule: BDD acceptance criteria
|
|
2
|
+
function buildBody() {
|
|
3
|
+
return `# Planu BDD Acceptance Criteria
|
|
4
|
+
|
|
5
|
+
Auto-generated by \`init_project\`. Do not edit manually.
|
|
6
|
+
|
|
7
|
+
## Rule
|
|
8
|
+
|
|
9
|
+
Every acceptance criterion must be executable enough for an implementation agent and a validation gate.
|
|
10
|
+
|
|
11
|
+
Required format:
|
|
12
|
+
|
|
13
|
+
\`\`\`gherkin
|
|
14
|
+
GIVEN <initial state or context>
|
|
15
|
+
WHEN <action or event>
|
|
16
|
+
THEN <observable result>
|
|
17
|
+
AND <additional observable constraint>
|
|
18
|
+
\`\`\`
|
|
19
|
+
|
|
20
|
+
## Required Detail
|
|
21
|
+
|
|
22
|
+
Each criterion must identify:
|
|
23
|
+
|
|
24
|
+
- files or modules that are expected to change when known
|
|
25
|
+
- user-visible behavior or API behavior
|
|
26
|
+
- test expectation or verification command
|
|
27
|
+
- ownership when the work spans more than one wave or agent
|
|
28
|
+
|
|
29
|
+
## Hard Blocks
|
|
30
|
+
|
|
31
|
+
- No loose checklist-only acceptance criteria.
|
|
32
|
+
- No vague outcomes such as "works correctly", "improves UX", or "handles errors" without observable behavior.
|
|
33
|
+
- No approval when criteria are missing \`GIVEN\`, \`WHEN\`, or \`THEN\`.
|
|
34
|
+
`;
|
|
35
|
+
}
|
|
36
|
+
export const planuBddCriteriaRule = {
|
|
37
|
+
id: 'planu-bdd-criteria',
|
|
38
|
+
name: 'Planu BDD Criteria',
|
|
39
|
+
description: 'Requires complete GIVEN/WHEN/THEN acceptance criteria.',
|
|
40
|
+
category: 'quality',
|
|
41
|
+
applicableHosts: ['all'],
|
|
42
|
+
defaultEnabled: true,
|
|
43
|
+
buildContent: (_host) => buildBody(),
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=planu-bdd-criteria.js.map
|