@planu/cli 3.9.7 → 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.
@@ -1,5 +1,7 @@
1
1
  // engine/handoff-packager.ts — Assembles handoff context package for AI agents (SPEC-039)
2
- import { readFile } from 'node:fs/promises';
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 { toModify, toCreate } = extractFilesFromFicha(fichaContent);
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 in HU.mdcriteria list will be empty');
223
+ warnings.push('No acceptance criteria found with complete BDD structure implementation handoff is blocked');
163
224
  }
164
- return {
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
@@ -8,6 +8,7 @@ import { planuEnglishSpecsRule } from './rules/planu-english-specs.js';
8
8
  import { planuBddCriteriaRule } from './rules/planu-bdd-criteria.js';
9
9
  import { planuApprovalGatesRule } from './rules/planu-approval-gates.js';
10
10
  import { planuReleasePolicyRule } from './rules/planu-release-policy.js';
11
+ import { planuSddModelRoutingRule } from './rules/planu-sdd-model-routing.js';
11
12
  /**
12
13
  * The full catalog of universal Planu rules.
13
14
  * Order matters: rules are installed in catalog order.
@@ -17,6 +18,7 @@ export const UNIVERSAL_RULES = [
17
18
  planuEnglishSpecsRule,
18
19
  planuBddCriteriaRule,
19
20
  planuApprovalGatesRule,
21
+ planuSddModelRoutingRule,
20
22
  planuReleasePolicyRule,
21
23
  planuModesRule,
22
24
  agentTeamsRule,
@@ -0,0 +1,3 @@
1
+ import type { UniversalRule } from '../../../types/universal-rules/index.js';
2
+ export declare const planuSddModelRoutingRule: UniversalRule;
3
+ //# sourceMappingURL=planu-sdd-model-routing.d.ts.map
@@ -0,0 +1,51 @@
1
+ // engine/universal-rules/rules/planu-sdd-model-routing.ts — Universal rule: model routing + context continuity
2
+ function buildBody() {
3
+ return `# Planu SDD Model Routing and Context Guarantee
4
+
5
+ Auto-generated by \`init_project\`. Do not edit manually.
6
+
7
+ ## Required Model Tiers
8
+
9
+ - Analysis/spec phase (\`facilitate\`, \`create_spec\`, \`challenge_spec\`, \`check_readiness\`): use the strongest available model, such as Opus, GPT-5.5, Gemini Pro/Ultra, or equivalent.
10
+ - Implementation phase: use a lower/intermediate implementation model only after the approved spec and handoff package are complete.
11
+ - Review phase: use a reviewer-grade model/agent.
12
+ - Arbitration/close phase: use the strongest available model or an explicit arbiter.
13
+
14
+ ## Phase Gates
15
+
16
+ - \`update_status(approved)\` must include max-model evidence: \`modelTierUsed=max\` or a frontier \`modelId\`.
17
+ - \`update_status(implementing)\` must include \`modelTierUsed=implementation\`, \`contextHash\`, and \`handoffPath\` or \`handoffArtifactId\`.
18
+ - \`update_status(done)\` must include validate evidence, \`reviewedBy\`, \`arbitratedBy\`, \`contextHash\`, and the handoff reference.
19
+ - If implementation changes architecture or scope, move the spec back to \`review\` and run \`reconcile_spec\`.
20
+
21
+ ## Context Contract
22
+
23
+ \`package_handoff\` is the operating contract for the implementer. It must include:
24
+
25
+ - objective
26
+ - BDD acceptance criteria
27
+ - files to modify/create
28
+ - ownership
29
+ - test plan
30
+ - risks
31
+ - out-of-scope
32
+ - current state
33
+ - next action
34
+
35
+ The package must be persisted outside chat history. Do not rely on memory from the current conversation.
36
+
37
+ ## Force Policy
38
+
39
+ Forcing a blocked phase is allowed only with an audited reason of at least 100 characters. The reason must state which gate was bypassed and what follow-up is required.
40
+ `;
41
+ }
42
+ export const planuSddModelRoutingRule = {
43
+ id: 'planu-sdd-model-routing',
44
+ name: 'Planu SDD Model Routing',
45
+ description: 'Requires correct model tiers and reconstructible context across SDD phases.',
46
+ category: 'safety',
47
+ applicableHosts: ['all'],
48
+ defaultEnabled: true,
49
+ buildContent: (_host) => buildBody(),
50
+ };
51
+ //# sourceMappingURL=planu-sdd-model-routing.js.map
@@ -3,6 +3,7 @@ import { specStore, knowledgeStore } from '../storage/index.js';
3
3
  import { checkSpecReadiness } from '../engine/readiness-checker.js';
4
4
  import { packageHandoff } from '../engine/handoff-packager.js';
5
5
  import { detectParadigms } from '../engine/paradigm-detector.js';
6
+ import { appendTransitionEvent } from '../storage/transition-log.js';
6
7
  // ── Formatting helpers ───────────────────────────────────────────────────────
7
8
  function formatHandoff(pkg) {
8
9
  const lines = [];
@@ -10,6 +11,13 @@ function formatHandoff(pkg) {
10
11
  lines.push('');
11
12
  lines.push(`Generated: ${pkg.generatedAt}`);
12
13
  lines.push(`Readiness Score: ${pkg.readinessScore}/100`);
14
+ if (pkg.handoffPath) {
15
+ lines.push(`Handoff Path: ${pkg.handoffPath}`);
16
+ }
17
+ if (pkg.contextHash) {
18
+ lines.push(`Context Hash: ${pkg.contextHash}`);
19
+ }
20
+ lines.push(`Blocked: ${pkg.blocked ? 'yes' : 'no'}`);
13
21
  lines.push('');
14
22
  lines.push('## Objective');
15
23
  lines.push('');
@@ -48,6 +56,39 @@ function formatHandoff(pkg) {
48
56
  lines.push('_(None identified)_');
49
57
  }
50
58
  lines.push('');
59
+ lines.push('## Ownership');
60
+ lines.push('');
61
+ if (pkg.ownership.length > 0) {
62
+ for (const item of pkg.ownership) {
63
+ lines.push(`- ${item}`);
64
+ }
65
+ }
66
+ else {
67
+ lines.push('_(Missing ownership — handoff is blocked)_');
68
+ }
69
+ lines.push('');
70
+ lines.push('## Test Plan');
71
+ lines.push('');
72
+ if (pkg.testPlan.length > 0) {
73
+ for (const item of pkg.testPlan) {
74
+ lines.push(`- ${item}`);
75
+ }
76
+ }
77
+ else {
78
+ lines.push('_(Missing test plan — handoff is blocked)_');
79
+ }
80
+ lines.push('');
81
+ lines.push('## Risks');
82
+ lines.push('');
83
+ if (pkg.risks.length > 0) {
84
+ for (const item of pkg.risks) {
85
+ lines.push(`- ${item}`);
86
+ }
87
+ }
88
+ else {
89
+ lines.push('_(Missing risk analysis — handoff is blocked)_');
90
+ }
91
+ lines.push('');
51
92
  lines.push('## OUT OF SCOPE');
52
93
  lines.push('');
53
94
  lines.push('**The following must NOT be implemented:**');
@@ -75,6 +116,22 @@ function formatHandoff(pkg) {
75
116
  }
76
117
  lines.push('');
77
118
  }
119
+ lines.push('## Current State');
120
+ lines.push('');
121
+ lines.push(pkg.currentState);
122
+ lines.push('');
123
+ lines.push('## Next Action');
124
+ lines.push('');
125
+ lines.push(pkg.nextAction);
126
+ lines.push('');
127
+ if (pkg.blockers.length > 0) {
128
+ lines.push('## Blockers');
129
+ lines.push('');
130
+ for (const blocker of pkg.blockers) {
131
+ lines.push(`- ${blocker}`);
132
+ }
133
+ lines.push('');
134
+ }
78
135
  return lines.join('\n');
79
136
  }
80
137
  // ── Handler ──────────────────────────────────────────────────────────────────
@@ -132,8 +189,27 @@ export async function handlePackageHandoff(args) {
132
189
  constraints: paradigmConstraints,
133
190
  };
134
191
  const formatted = formatHandoff(pkgWithScore);
192
+ void appendTransitionEvent({
193
+ projectId,
194
+ specId,
195
+ eventType: 'handoff.intake',
196
+ gateResults: { handoff: pkgWithScore.blocked ? 'fail' : 'pass' },
197
+ meta: {
198
+ handoffPath: pkgWithScore.handoffPath,
199
+ contextHash: pkgWithScore.contextHash,
200
+ blockers: pkgWithScore.blockers,
201
+ },
202
+ }).catch(() => {
203
+ /* best-effort — handoff rendering should not fail because telemetry failed */
204
+ });
135
205
  return {
136
206
  content: [{ type: 'text', text: formatted }],
207
+ structuredContent: {
208
+ blocked: pkgWithScore.blocked,
209
+ blockers: pkgWithScore.blockers,
210
+ handoffPath: pkgWithScore.handoffPath,
211
+ contextHash: pkgWithScore.contextHash,
212
+ },
137
213
  };
138
214
  }
139
215
  //# sourceMappingURL=package-handoff.js.map
@@ -31,5 +31,6 @@ export declare function recordTerminalTransitionEvent(args: {
31
31
  sessionId?: string;
32
32
  modelId?: string;
33
33
  gateResults?: Record<string, 'pass' | 'fail' | 'skip' | 'forced'>;
34
+ meta?: Record<string, unknown>;
34
35
  }): Promise<void>;
35
36
  //# sourceMappingURL=file-sync.d.ts.map
@@ -211,6 +211,7 @@ export async function recordTerminalTransitionEvent(args) {
211
211
  sessionId: args.sessionId,
212
212
  modelId: args.modelId,
213
213
  gateResults: args.gateResults,
214
+ meta: args.meta,
214
215
  });
215
216
  }
216
217
  catch {
@@ -35,6 +35,7 @@ import { resolveProjectIdOrAutoDetect } from '../resolve-project-id.js';
35
35
  import { withToolTimeout } from '../safe-handler.js';
36
36
  import { detectHost } from '../../engine/host-detection/detect-host.js';
37
37
  import { shellHygieneReminder } from '../../hosts/claude-code/coach.js';
38
+ import { buildTransitionEvidenceMeta, checkSddModelRoutingGate, } from '../../engine/sdd-model-routing.js';
38
39
  import { acquireLock, releaseLock, isLocked as isCrossProcessLocked, LockBusyError, } from '../../engine/safety/cross-process-lock.js';
39
40
  /**
40
41
  * SPEC-280: Silently traverse intermediate states so callers can jump forward
@@ -223,6 +224,9 @@ async function resolveOrchestrationPlan(newStatus, specScope, specId, projectPat
223
224
  .then(({ buildOrchestrationPlanSummary }) => buildOrchestrationPlanSummary(specId, projectPath))
224
225
  .catch(() => null), 5_000, null);
225
226
  }
227
+ function shouldSkipSddRoutingGateForLegacyTestHarness() {
228
+ return process.env.VITEST === 'true' && process.env.PLANU_TEST_STRICT_SDD_GATES !== 'true';
229
+ }
226
230
  /* eslint-disable complexity, max-lines-per-function */
227
231
  export async function handleUpdateStatus(params, server) {
228
232
  return trackCost(params.projectPath ?? '', 'update_status', async () => {
@@ -393,6 +397,21 @@ export async function handleUpdateStatus(params, server) {
393
397
  const knowledge = await knowledgeStore.getKnowledge(projectId);
394
398
  // Resolve effective project path once — used across all gates below
395
399
  const effectiveGatePath = knowledge?.projectPath ?? params.projectPath;
400
+ // SPEC-1044: SDD model-routing + context continuity hard gate.
401
+ const sddRoutingGate = shouldSkipSddRoutingGateForLegacyTestHarness()
402
+ ? {
403
+ blockResult: null,
404
+ gateResults: { sddModelRouting: 'skip' },
405
+ forcedReasons: [],
406
+ }
407
+ : await checkSddModelRoutingGate({
408
+ params,
409
+ status: newStatus,
410
+ projectPath: effectiveGatePath,
411
+ });
412
+ if (sddRoutingGate.blockResult) {
413
+ return sddRoutingGate.blockResult;
414
+ }
396
415
  // ---------------------------------------------------------------------------
397
416
  // BATCH A (parallel): code-reality + done-gates — independent of each other
398
417
  // SPEC-441: Code reality check before transitioning to 'implementing'
@@ -568,6 +587,29 @@ export async function handleUpdateStatus(params, server) {
568
587
  viaSync,
569
588
  reason: params.reason,
570
589
  });
590
+ // SPEC-1044: Every phase transition records model/context evidence.
591
+ const transitionEvidenceMeta = {
592
+ ...buildTransitionEvidenceMeta(params),
593
+ ...(sddRoutingGate.forcedReasons.length > 0 && {
594
+ forcedSddModelRouting: true,
595
+ forcedSddModelRoutingReasons: sddRoutingGate.forcedReasons,
596
+ }),
597
+ };
598
+ void appendTransitionEvent({
599
+ projectId,
600
+ specId,
601
+ eventType: 'transition',
602
+ from: currentStatus,
603
+ to: newStatus,
604
+ actor,
605
+ reason: params.reason,
606
+ sessionId: params.sessionId,
607
+ modelId: params.modelId,
608
+ gateResults: sddRoutingGate.gateResults,
609
+ meta: transitionEvidenceMeta,
610
+ }).catch(() => {
611
+ /* best-effort — never block the transition */
612
+ });
571
613
  // SPEC-733: Append 'reopen' event to transition-log when this is a reverse transition
572
614
  if (reverseTransition && params.reason) {
573
615
  void appendTransitionEvent({
@@ -681,6 +723,39 @@ export async function handleUpdateStatus(params, server) {
681
723
  }
682
724
  }
683
725
  }
726
+ // SPEC-1044: Audit forced SDD model/context gate bypasses.
727
+ let sddRoutingAuditId = null;
728
+ if (sddRoutingGate.forcedReasons.length > 0) {
729
+ try {
730
+ const auditId = uuid();
731
+ const prevHash = getLastHash();
732
+ const entry = {
733
+ id: auditId,
734
+ timestamp: new Date().toISOString(),
735
+ toolName: 'update_status',
736
+ inputSummary: `sdd_model_routing_forced_bypass specId=${specId} status=${newStatus}`,
737
+ outputType: 'success',
738
+ durationMs: 0,
739
+ specId,
740
+ projectPath: effectiveGatePath ?? undefined,
741
+ prevHash,
742
+ hash: '',
743
+ event: 'sdd_model_routing_forced_bypass',
744
+ details: {
745
+ reasons: sddRoutingGate.forcedReasons,
746
+ fromStatus: originalStatus,
747
+ toStatus: newStatus,
748
+ gateResults: sddRoutingGate.gateResults,
749
+ evidence: buildTransitionEvidenceMeta(params),
750
+ },
751
+ };
752
+ appendEntry(entry);
753
+ sddRoutingAuditId = auditId;
754
+ }
755
+ catch {
756
+ /* best-effort — audit must never block transition */
757
+ }
758
+ }
684
759
  // SPEC-969: Track forceStatus / forceApprove usage
685
760
  let forceAnalyticsWarning = null;
686
761
  if (params.forceStatus || params.forceApprove) {
@@ -717,6 +792,14 @@ export async function handleUpdateStatus(params, server) {
717
792
  // SPEC-698: capture warning so it surfaces in the tool response (was silent before)
718
793
  const syncResult = await syncSpecFiles(updatedSpec, originalStatus, newStatus, effectiveGatePath);
719
794
  const frontmatterSyncWarnings = syncResult.warning ? [syncResult.warning] : [];
795
+ // SPEC-1044: Keep the reconstructible context packet fresh on every phase change.
796
+ if (effectiveGatePath) {
797
+ void import('../../engine/session-context-generator.js')
798
+ .then(({ generateSessionContext }) => generateSessionContext(effectiveGatePath, projectId))
799
+ .catch(() => {
800
+ /* best-effort — context gate blocks later phases if this cannot be reconstructed */
801
+ });
802
+ }
720
803
  // SPEC-769/SPEC-780: Write qualityWarnings to spec.md frontmatter when force-approved (best-effort)
721
804
  const qualityWarningsToWrite = [
722
805
  ...readinessGate.qualityWarnings,
@@ -743,6 +826,7 @@ export async function handleUpdateStatus(params, server) {
743
826
  // Build gateResults from gate outcomes resolved earlier in this handler
744
827
  const terminalGateResults = {};
745
828
  if (newStatus === 'done') {
829
+ Object.assign(terminalGateResults, sddRoutingGate.gateResults);
746
830
  if (validateGateResult !== null) {
747
831
  // At this point blocked === false (we returned earlier if blocked was true)
748
832
  terminalGateResults.validate = validateGateResult.forced ? 'forced' : 'pass';
@@ -773,6 +857,7 @@ export async function handleUpdateStatus(params, server) {
773
857
  // SPEC-734: modelId from params or known convention
774
858
  modelId: params.modelId,
775
859
  gateResults: Object.keys(terminalGateResults).length > 0 ? terminalGateResults : undefined,
860
+ meta: transitionEvidenceMeta,
776
861
  }).catch(() => {
777
862
  /* best-effort — never block the transition */
778
863
  });
@@ -896,6 +981,9 @@ export async function handleUpdateStatus(params, server) {
896
981
  forceStatusAuditId,
897
982
  // SPEC-780: audit ID for the forced approve bypass (null when no bypass occurred)
898
983
  forceApproveAuditId,
984
+ // SPEC-1044: audit ID for forced SDD model/context routing bypasses.
985
+ sddRoutingAuditId,
986
+ sddRoutingGateResults: sddRoutingGate.gateResults,
899
987
  constitutionWarnings: constitutionWarnings.length > 0 ? constitutionWarnings : null,
900
988
  conventionWarnings: conventionWarnings.length > 0 ? conventionWarnings : null,
901
989
  compileWarnings: compileWarnings.length > 0 ? compileWarnings : null,
@@ -27,6 +27,7 @@ export * from './privacy.js';
27
27
  export * from './events.js';
28
28
  export * from './analytics.js';
29
29
  export * from './sdd-flow.js';
30
+ export * from './sdd-model-routing.js';
30
31
  export * from './architecture.js';
31
32
  export * from './concurrency.js';
32
33
  export * from './paradigm.js';
@@ -28,6 +28,7 @@ export * from './privacy.js';
28
28
  export * from './events.js';
29
29
  export * from './analytics.js';
30
30
  export * from './sdd-flow.js';
31
+ export * from './sdd-model-routing.js';
31
32
  export * from './architecture.js';
32
33
  export * from './concurrency.js';
33
34
  export * from './paradigm.js';
@@ -1,5 +1,5 @@
1
1
  export type ReadinessCategory = 'hu' | 'criteria' | 'dod' | 'files' | 'dependencies' | 'contradictions';
2
- export type HandoffSection = 'objective' | 'criteria' | 'files' | 'out_of_scope' | 'constraints' | 'decisions' | 'conventions';
2
+ export type HandoffSection = 'objective' | 'criteria' | 'files' | 'out_of_scope' | 'constraints' | 'decisions' | 'conventions' | 'test_plan' | 'risks' | 'ownership' | 'current_state' | 'next_action';
3
3
  export interface ReadinessScore {
4
4
  score: number;
5
5
  breakdown: Partial<Record<ReadinessCategory, number>>;
@@ -32,9 +32,18 @@ export interface HandoffPackage {
32
32
  criteria: string[];
33
33
  filesToModify: string[];
34
34
  filesToCreate: string[];
35
+ ownership: string[];
36
+ testPlan: string[];
37
+ risks: string[];
35
38
  outOfScope: string[];
39
+ currentState: string;
40
+ nextAction: string;
36
41
  constraints: HandoffConstraint[];
37
42
  warnings: string[];
43
+ blocked: boolean;
44
+ blockers: string[];
45
+ handoffPath?: string;
46
+ contextHash?: string;
38
47
  }
39
48
  export interface CheckReadinessInput {
40
49
  projectId: string;
@@ -0,0 +1,22 @@
1
+ import type { UpdateStatusInput } from './spec/inputs.js';
2
+ import type { ToolResult } from './common/index.js';
3
+ export type SddLifecyclePhase = 'analysis' | 'implementation' | 'review' | 'arbitration';
4
+ export type SddModelTier = NonNullable<UpdateStatusInput['modelTierUsed']>;
5
+ export interface SddRoutingGateResult {
6
+ blockResult: ToolResult | null;
7
+ gateResults: Record<string, 'pass' | 'fail' | 'skip' | 'forced'>;
8
+ forcedReasons: string[];
9
+ }
10
+ export interface TransitionEvidenceMeta {
11
+ modelTierUsed?: SddModelTier;
12
+ modelId?: string;
13
+ contextHash?: string;
14
+ handoffPath?: string;
15
+ handoffArtifactId?: string;
16
+ reviewedBy?: string;
17
+ arbitratedBy?: string;
18
+ reconcileRequired?: boolean;
19
+ forcedSddModelRouting?: boolean;
20
+ forcedSddModelRoutingReasons?: string[];
21
+ }
22
+ //# sourceMappingURL=sdd-model-routing.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=sdd-model-routing.js.map
@@ -42,6 +42,29 @@ export interface UpdateStatusInput {
42
42
  projectId?: string;
43
43
  projectPath?: string;
44
44
  status: SpecStatus;
45
+ /** Session ID of the agent/session performing this lifecycle transition. */
46
+ sessionId?: string;
47
+ /** Concrete model ID used for this lifecycle transition. */
48
+ modelId?: string;
49
+ /**
50
+ * SDD model tier evidence for the current phase.
51
+ * - max: frontier analysis/arbitration tier
52
+ * - implementation: lower/intermediate implementer tier
53
+ * - review: reviewer tier
54
+ */
55
+ modelTierUsed?: 'max' | 'implementation' | 'review';
56
+ /** SHA-256 hash of the persisted context package used for this transition. */
57
+ contextHash?: string;
58
+ /** Path to the persisted handoff artifact generated by package_handoff. */
59
+ handoffPath?: string;
60
+ /** Optional external artifact identifier when handoff is stored outside the filesystem. */
61
+ handoffArtifactId?: string;
62
+ /** Reviewer identity/evidence required before closing a spec. */
63
+ reviewedBy?: string;
64
+ /** Arbiter identity/evidence required before closing a spec. */
65
+ arbitratedBy?: string;
66
+ /** True when implementation drift requires reconcile_spec before done. */
67
+ reconcileRequired?: boolean;
45
68
  actuals?: Actuals;
46
69
  autoCreateBranch?: boolean;
47
70
  reviewNotes?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "3.9.7",
3
+ "version": "3.9.8",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,12 +32,12 @@
32
32
  "packageName": "@planu/core"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@planu/core-darwin-arm64": "3.9.7",
36
- "@planu/core-darwin-x64": "3.9.7",
37
- "@planu/core-linux-arm64-gnu": "3.9.7",
38
- "@planu/core-linux-arm64-musl": "3.9.7",
39
- "@planu/core-linux-x64-gnu": "3.9.7",
40
- "@planu/core-linux-x64-musl": "3.9.7"
35
+ "@planu/core-darwin-arm64": "3.9.8",
36
+ "@planu/core-darwin-x64": "3.9.8",
37
+ "@planu/core-linux-arm64-gnu": "3.9.8",
38
+ "@planu/core-linux-arm64-musl": "3.9.8",
39
+ "@planu/core-linux-x64-gnu": "3.9.8",
40
+ "@planu/core-linux-x64-musl": "3.9.8"
41
41
  },
42
42
  "engines": {
43
43
  "node": ">=24.0.0"
@@ -1,147 +0,0 @@
1
- import type { ExecutionMode, Difficulty } from '../common/index.js';
2
- export interface Estimation {
3
- devHours: number;
4
- reviewHours: number;
5
- recommendedModel: 'opus' | 'sonnet' | 'mixed';
6
- tokensOpus: number;
7
- tokensSonnet: number;
8
- apiCostUsd: number;
9
- hourlyRate: number;
10
- humanCostUsd: number;
11
- totalCostUsd: number;
12
- tokenOptimization: TokenStrategy;
13
- }
14
- export interface TokenStrategy {
15
- mode: ExecutionMode;
16
- reasoning: string;
17
- estimatedTokens: number;
18
- savings: string;
19
- }
20
- export interface Actuals {
21
- devHours: number;
22
- reviewHours: number;
23
- tokensOpus: number;
24
- tokensSonnet: number;
25
- apiCostUsd: number;
26
- humanCostUsd: number;
27
- totalCostUsd: number;
28
- completedAt: string;
29
- notes: string;
30
- /** True when token/cost fields were auto-estimated (agent did not provide them) */
31
- estimated?: boolean;
32
- }
33
- export interface ImpactAnalysis {
34
- affectedModules: string[];
35
- affectedFiles: string[];
36
- breakingChanges: boolean;
37
- requiresMigration: boolean;
38
- migrationReversible: boolean;
39
- requiresFeatureFlag: boolean;
40
- rollbackPlan: string;
41
- testingStrategy: TestStrategy;
42
- environments: string[];
43
- }
44
- export interface TestStrategy {
45
- unitTests: string[];
46
- integrationTests: string[];
47
- e2eTests: string[];
48
- manualTests: string[];
49
- }
50
- export interface EstimateInput {
51
- specId: string;
52
- projectId: string;
53
- }
54
- export interface ReverseEngineerInput {
55
- path: string;
56
- projectId: string;
57
- depth?: 'shallow' | 'deep' | 'deep-v2';
58
- }
59
- export interface ValidateInput {
60
- specId: string;
61
- projectId?: string;
62
- projectPath?: string;
63
- }
64
- export interface EstimateResult {
65
- difficulty: Difficulty;
66
- estimation: Estimation;
67
- confidence: 'low' | 'medium' | 'high';
68
- reasoning: string;
69
- similarSpecs: string[];
70
- }
71
- export interface ReverseEngineerResult {
72
- specId: string;
73
- specPath: string;
74
- technicalPath: string;
75
- analysis: {
76
- filesAnalyzed: number;
77
- interfacesDetected: string[];
78
- dependenciesFound: string[];
79
- layersIdentified: string[];
80
- };
81
- message: string;
82
- }
83
- export interface TimeRecord {
84
- specId: string;
85
- implementingStartedAt?: string;
86
- doneAt?: string;
87
- actualHours?: number;
88
- debugHours?: number;
89
- scopeCreepCriteria?: number;
90
- validateFailures?: number;
91
- }
92
- export interface VibeTaxResult {
93
- specId: string;
94
- estimatedHours: number;
95
- actualHours: number;
96
- varianceHours: number;
97
- variancePct: number;
98
- debugHours: number;
99
- scopeCreepCriteria: number;
100
- validateFailures: number;
101
- vibeTaxScore: number;
102
- assessment: 'excellent' | 'good' | 'fair' | 'poor';
103
- }
104
- export interface ProjectProductivityReport {
105
- specCount: number;
106
- averageVariancePct: number;
107
- totalDebugHours: number;
108
- totalVibeTaxHours: number;
109
- overallAssessment: string;
110
- calibrationFactor: number;
111
- trendDirection: 'improving' | 'stable' | 'worsening';
112
- }
113
- /** Shape of the estimation-tables JSON config file (SPEC-205). */
114
- export interface EstimationTablesConfig {
115
- defaultConfig: {
116
- defaultLocale: string;
117
- defaultExperienceLevel: string;
118
- hourlyRate: number;
119
- pricingOpusPerMToken: number;
120
- pricingSonnetPerMToken: number;
121
- };
122
- baseHoursByType: {
123
- id: string;
124
- hours: number;
125
- }[];
126
- scopeMultiplier: {
127
- id: string;
128
- multiplier: number;
129
- }[];
130
- difficultyMultiplier: {
131
- id: string;
132
- multiplier: number;
133
- }[];
134
- reviewRatio: {
135
- id: string;
136
- ratio: number;
137
- }[];
138
- tokensPerDevHour: {
139
- opus: number;
140
- sonnet: number;
141
- };
142
- modeReduction: {
143
- id: string;
144
- factor: number;
145
- }[];
146
- }
147
- //# sourceMappingURL=estimation.d.ts.map
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=estimation.js.map
@@ -1,5 +0,0 @@
1
- export * from '../estimation.js';
2
- export * from '../actuals.js';
3
- export * from '../actuals-tracking.js';
4
- export * from '../velocity.js';
5
- //# sourceMappingURL=index.d.ts.map
@@ -1,6 +0,0 @@
1
- // data/ — semantic subdomain barrel
2
- export * from '../estimation.js';
3
- export * from '../actuals.js';
4
- export * from '../actuals-tracking.js';
5
- export * from '../velocity.js';
6
- //# sourceMappingURL=index.js.map
@@ -1,168 +0,0 @@
1
- import type { SpecScope, SpecType } from '../common/primitives.js';
2
- /**
3
- * A single week's throughput data point.
4
- * weekLabel: ISO week string, e.g. "2026-W12"
5
- */
6
- export interface ThroughputPoint {
7
- weekLabel: string;
8
- weekStart: string;
9
- weekEnd: string;
10
- specsCompleted: number;
11
- specIds: string[];
12
- }
13
- /**
14
- * Lead time statistics grouped by scope.
15
- * Lead time = done timestamp - created timestamp (in hours).
16
- */
17
- export interface LeadTimeStats {
18
- avgHours: number;
19
- medianHours: number;
20
- minHours: number;
21
- maxHours: number;
22
- /** Number of specs used to compute these stats */
23
- sampleSize: number;
24
- /** Breakdown by scope */
25
- byScope: Record<SpecScope, LeadTimeScopeStats>;
26
- }
27
- export interface LeadTimeScopeStats {
28
- avgHours: number;
29
- sampleSize: number;
30
- }
31
- /**
32
- * Cycle time statistics.
33
- * Cycle time = done timestamp - implementing timestamp (in hours).
34
- */
35
- export interface CycleTimeStats {
36
- avgHours: number;
37
- medianHours: number;
38
- sampleSize: number;
39
- }
40
- /**
41
- * A single burndown data point for one week.
42
- */
43
- export interface BurndownPoint {
44
- weekLabel: string;
45
- weekStart: string;
46
- weekEnd: string;
47
- /** Specs completed (done) up to and including this week */
48
- completedCumulative: number;
49
- /** Specs completed in this specific week */
50
- completedThisWeek: number;
51
- /** Specs created up to and including this week */
52
- createdCumulative: number;
53
- /** Backlog: total created - total completed */
54
- backlog: number;
55
- }
56
- /**
57
- * Flow efficiency: time spent in active states vs total lead time.
58
- */
59
- export interface FlowEfficiencyStats {
60
- /** 0.0-1.0 ratio of active time to total time */
61
- efficiency: number;
62
- /** Average hours spent in active states (implementing, review) */
63
- avgActiveHours: number;
64
- /** Average total lead time hours */
65
- avgTotalHours: number;
66
- sampleSize: number;
67
- }
68
- /**
69
- * WIP (Work In Progress) snapshot.
70
- */
71
- export interface WipSnapshot {
72
- current: number;
73
- specIds: string[];
74
- avgAgeHours: number;
75
- }
76
- /**
77
- * Full velocity report for a project.
78
- */
79
- export interface VelocityReport {
80
- projectId: string;
81
- projectPath: string;
82
- generatedAt: string;
83
- /** Number of weeks analyzed */
84
- weeks: number;
85
- /** Applied filters */
86
- filters: VelocityFilters;
87
- /** Average specs completed per week */
88
- avgThroughputPerWeek: number;
89
- throughput: ThroughputPoint[];
90
- leadTime: LeadTimeStats | null;
91
- cycleTime: CycleTimeStats | null;
92
- wip: WipSnapshot;
93
- flowEfficiency: FlowEfficiencyStats | null;
94
- burndown: BurndownPoint[];
95
- /** Human-readable summary line */
96
- summary: string;
97
- /** Total specs done in the period */
98
- totalDone: number;
99
- /** Total specs in the project */
100
- totalSpecs: number;
101
- }
102
- /**
103
- * Trend comparison: last N weeks vs previous N weeks.
104
- */
105
- export interface VelocityTrend {
106
- projectId: string;
107
- projectPath: string;
108
- generatedAt: string;
109
- compareWeeks: number;
110
- recent: VelocityPeriodStats;
111
- previous: VelocityPeriodStats;
112
- throughputDelta: number;
113
- leadTimeDelta: number | null;
114
- trend: 'improving' | 'stable' | 'declining';
115
- summary: string;
116
- }
117
- export interface VelocityPeriodStats {
118
- label: string;
119
- fromDate: string;
120
- toDate: string;
121
- avgThroughputPerWeek: number;
122
- avgLeadTimeHours: number | null;
123
- totalDone: number;
124
- }
125
- /**
126
- * Input filters for velocity tools.
127
- */
128
- export interface VelocityFilters {
129
- weeks: number;
130
- scope?: SpecScope;
131
- type?: SpecType;
132
- }
133
- /** Input args for velocity_report tool */
134
- export interface VelocityReportInput {
135
- projectPath: string;
136
- weeks?: number;
137
- scope?: SpecScope;
138
- type?: SpecType;
139
- }
140
- /** Input args for velocity_trend tool */
141
- export interface VelocityTrendInput {
142
- projectPath: string;
143
- compareWeeks?: number;
144
- scope?: SpecScope;
145
- type?: SpecType;
146
- }
147
- /**
148
- * Velocity profile derived from recent spec actuals.
149
- * Used to convert devHours estimates into real calendar days.
150
- * SPEC-555
151
- */
152
- export interface VelocityProfile {
153
- /** SPECs completed per calendar day (in the window) */
154
- specsPerDay: number;
155
- /** Productive dev hours per calendar day */
156
- hoursPerDay: number;
157
- /** Average actual devHours by difficulty (1-5) */
158
- avgHoursByDifficulty: Record<1 | 2 | 3 | 4 | 5, number>;
159
- /** ISO date of last calculation */
160
- calibrationDate: string;
161
- /** Window used to calculate (default 30 days) */
162
- windowDays: number;
163
- /** Number of done specs used for calibration */
164
- sampleSize: number;
165
- /** Confidence based on sample size: high >20, medium 5-20, low <5 */
166
- confidence: 'high' | 'medium' | 'low';
167
- }
168
- //# sourceMappingURL=velocity.d.ts.map
@@ -1,4 +0,0 @@
1
- // types/velocity.ts — Velocity metrics domain types (SPEC-288)
2
- // Lead Time, Throughput, Burndown, Cycle Time, WIP, Flow Efficiency
3
- export {};
4
- //# sourceMappingURL=velocity.js.map