@planu/cli 4.11.7 → 4.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli/commands/serve.js +4 -0
  3. package/dist/config/license-plans.json +1 -0
  4. package/dist/engine/browser-validator.js +26 -21
  5. package/dist/engine/crash-shield/file-collector.d.ts +20 -3
  6. package/dist/engine/crash-shield/file-collector.js +137 -8
  7. package/dist/engine/crash-shield/index.d.ts +18 -1
  8. package/dist/engine/crash-shield/index.js +58 -17
  9. package/dist/engine/dogfooding/runtime-gap-detector.d.ts +3 -0
  10. package/dist/engine/dogfooding/runtime-gap-detector.js +386 -0
  11. package/dist/engine/figma/visual-qa.d.ts +2 -1
  12. package/dist/engine/figma/visual-qa.js +8 -7
  13. package/dist/engine/qa-gate.js +2 -1
  14. package/dist/engine/session-safeguard/checkpoint-runner.js +3 -7
  15. package/dist/engine/spec-state-machine/transition-spec.d.ts +16 -1
  16. package/dist/engine/spec-state-machine/transition-spec.js +19 -4
  17. package/dist/engine/triagier/classifier.d.ts +2 -2
  18. package/dist/engine/triagier/classifier.js +12 -15
  19. package/dist/index.js +12 -4
  20. package/dist/storage/approval-operation-lock.d.ts +10 -0
  21. package/dist/storage/approval-operation-lock.js +44 -0
  22. package/dist/storage/approval-store.d.ts +2 -0
  23. package/dist/storage/approval-store.js +9 -1
  24. package/dist/storage/spec-store.d.ts +29 -2
  25. package/dist/storage/spec-store.js +307 -7
  26. package/dist/tools/approval-handler.js +255 -124
  27. package/dist/tools/browser-validate-handler.js +17 -3
  28. package/dist/tools/dogfood-watch.d.ts +6 -0
  29. package/dist/tools/dogfood-watch.js +48 -0
  30. package/dist/tools/figma/visual-qa.js +2 -1
  31. package/dist/tools/tool-registry/core-tools.js +12 -0
  32. package/dist/tools/tool-registry/group-quality-compliance.js +12 -1
  33. package/dist/tools/update-status/file-sync.js +3 -2
  34. package/dist/tools/update-status/index.d.ts +2 -0
  35. package/dist/tools/update-status/index.js +1083 -821
  36. package/dist/tools/update-status/response-builder.js +11 -0
  37. package/dist/tools/update-status/side-effects.d.ts +16 -1
  38. package/dist/tools/update-status/side-effects.js +140 -0
  39. package/dist/tools/update-status/transition-guard.js +1 -1
  40. package/dist/tools/update-status-actions.d.ts +10 -2
  41. package/dist/tools/update-status-actions.js +166 -192
  42. package/dist/tools/update-status-convention-gate.d.ts +3 -1
  43. package/dist/tools/update-status-convention-gate.js +135 -7
  44. package/dist/types/browser-validator.d.ts +2 -0
  45. package/dist/types/dogfooding.d.ts +34 -0
  46. package/dist/types/dogfooding.js +2 -0
  47. package/dist/types/index.d.ts +1 -0
  48. package/dist/types/index.js +1 -0
  49. package/dist/types/spec/core.d.ts +28 -1
  50. package/package.json +25 -25
  51. package/planu-native.json +1 -1
  52. package/planu-plugin.json +1 -1
@@ -0,0 +1,386 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, realpath, stat } from 'node:fs/promises';
3
+ import { execFile } from 'node:child_process';
4
+ import { isAbsolute, join, relative, resolve } from 'node:path';
5
+ const REPEATED_SIGNAL_THRESHOLD = 2;
6
+ const GIT_TIMEOUT_MS = 1_500;
7
+ const GIT_MAX_BUFFER_BYTES = 64 * 1024;
8
+ const ARTIFACTS = [
9
+ { label: 'package.json', relativePath: 'package.json', maxBytes: 64 * 1024, json: true },
10
+ {
11
+ label: 'session-context',
12
+ relativePath: join('planu', 'session-context.md'),
13
+ maxBytes: 256 * 1024,
14
+ json: false,
15
+ },
16
+ {
17
+ label: 'session',
18
+ relativePath: join('planu', 'session.json'),
19
+ maxBytes: 256 * 1024,
20
+ json: true,
21
+ },
22
+ ];
23
+ const SIGNAL_RULES = [
24
+ {
25
+ signature: 'forced-lifecycle:force-status',
26
+ title: 'Repeated forceStatus lifecycle bypasses',
27
+ severity: 'high',
28
+ nextAction: 'create_spec',
29
+ patterns: [
30
+ /^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)update_status\b[^\n]{0,160}\bforceStatus\b\s*[:=]\s*true\b/gim,
31
+ /^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)forceStatus\b\s*[:=]\s*true\b[^\n]{0,160}\bupdate_status\b/gim,
32
+ /^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)forced status transition\b/gim,
33
+ ],
34
+ structuredBooleanKey: 'forceStatus',
35
+ },
36
+ {
37
+ signature: 'forced-lifecycle:force-approve',
38
+ title: 'Repeated forceApprove lifecycle bypasses',
39
+ severity: 'high',
40
+ nextAction: 'create_spec',
41
+ patterns: [
42
+ /^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)approve_spec\b[^\n]{0,160}\bforceApprove\b\s*[:=]\s*true\b/gim,
43
+ /^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)forceApprove\b\s*[:=]\s*true\b[^\n]{0,160}\bapprove_spec\b/gim,
44
+ /^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)force-approved lifecycle transition\b/gim,
45
+ ],
46
+ structuredBooleanKey: 'forceApprove',
47
+ },
48
+ {
49
+ signature: 'guard-fallback-visible',
50
+ title: 'Repeated guarded fallback behavior',
51
+ severity: 'medium',
52
+ nextAction: 'create_spec',
53
+ patterns: [/\bGUARD_FALLBACK\b/gi, /\bguard(?:ed)? fallback\b/gi],
54
+ },
55
+ {
56
+ signature: 'placeholder-output-visible',
57
+ title: 'Repeated placeholder-facing output',
58
+ severity: 'medium',
59
+ nextAction: 'create_spec',
60
+ patterns: [
61
+ /\/\/\s*TO[D]O\b/gi,
62
+ /\braw TO[D]O placeholder\b/gi,
63
+ /\bplaceholder(?:-facing)? output\b/gi,
64
+ /\bempty placeholder comparison\b/gi,
65
+ ],
66
+ },
67
+ {
68
+ signature: 'validate-runtime-mismatch',
69
+ title: 'Repeated validation and runtime mismatch',
70
+ severity: 'high',
71
+ nextAction: 'create_spec',
72
+ patterns: [
73
+ /\bvalidat(?:e|ed|ion)[^\n]{0,80}\bruntime (?:mismatch|failed|failure|error|timeout)\b/gi,
74
+ /\bruntime mismatch[^\n]{0,80}\bvalidat(?:e|ed|ion)\b/gi,
75
+ ],
76
+ },
77
+ {
78
+ signature: 'manual-recovery-loop',
79
+ title: 'Repeated manual recovery steps',
80
+ severity: 'medium',
81
+ nextAction: 'submit_feedback',
82
+ patterns: [
83
+ /\bmanual recovery\b/gi,
84
+ /\brepair_frontmatter_drift\b/gi,
85
+ /\breconcile_spec\b/gi,
86
+ /\bretry(?:ing)? update_status\b/gi,
87
+ /\brecoveryRequired\b/gi,
88
+ ],
89
+ },
90
+ ];
91
+ function isWithin(root, candidate) {
92
+ const rel = relative(root, candidate);
93
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
94
+ }
95
+ async function loadArtifact(projectRoot, artifact) {
96
+ const candidate = resolve(projectRoot, artifact.relativePath);
97
+ if (!isWithin(projectRoot, candidate)) {
98
+ return null;
99
+ }
100
+ let handle;
101
+ try {
102
+ const canonicalBeforeOpen = await realpath(candidate);
103
+ if (!isWithin(projectRoot, canonicalBeforeOpen)) {
104
+ return null;
105
+ }
106
+ handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW);
107
+ const handleStat = await handle.stat();
108
+ if (!handleStat.isFile() || handleStat.size > artifact.maxBytes) {
109
+ return null;
110
+ }
111
+ // Re-resolve after opening and verify the path still names the opened inode.
112
+ // This closes parent-symlink traversal and path-swap races without trusting
113
+ // the lexical project-relative path alone.
114
+ const canonicalAfterOpen = await realpath(candidate);
115
+ if (!isWithin(projectRoot, canonicalAfterOpen)) {
116
+ return null;
117
+ }
118
+ const pathStat = await stat(canonicalAfterOpen);
119
+ if (pathStat.dev !== handleStat.dev || pathStat.ino !== handleStat.ino) {
120
+ return null;
121
+ }
122
+ const content = await handle.readFile({ encoding: 'utf8' });
123
+ if (!artifact.json) {
124
+ return { label: artifact.label, content, json: null };
125
+ }
126
+ try {
127
+ return { label: artifact.label, content, json: JSON.parse(content) };
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ finally {
137
+ await handle?.close().catch(() => undefined);
138
+ }
139
+ }
140
+ function countMatches(content, patterns) {
141
+ let count = 0;
142
+ for (const pattern of patterns) {
143
+ pattern.lastIndex = 0;
144
+ count += [...content.matchAll(pattern)].length;
145
+ }
146
+ return count;
147
+ }
148
+ function countStructuredBoolean(value, key) {
149
+ if (Array.isArray(value)) {
150
+ return value.reduce((total, item) => total + countStructuredBoolean(item, key), 0);
151
+ }
152
+ if (typeof value !== 'object' || value === null) {
153
+ return 0;
154
+ }
155
+ let count = 0;
156
+ for (const [entryKey, entryValue] of Object.entries(value)) {
157
+ if (/^(?:docs?|documentation|examples?|schema|inputSchema)$/i.test(entryKey)) {
158
+ continue;
159
+ }
160
+ if (entryKey === key && entryValue === true) {
161
+ count += 1;
162
+ }
163
+ count += countStructuredBoolean(entryValue, key);
164
+ }
165
+ return count;
166
+ }
167
+ function addRepeatedSignals(runtimeArtifacts, findings) {
168
+ for (const rule of SIGNAL_RULES) {
169
+ const sourceCounts = runtimeArtifacts
170
+ .map((artifact) => ({
171
+ label: artifact.label,
172
+ count: countMatches(artifact.content, rule.patterns) +
173
+ (rule.structuredBooleanKey
174
+ ? countStructuredBoolean(artifact.json, rule.structuredBooleanKey)
175
+ : 0),
176
+ }))
177
+ .filter((entry) => entry.count > 0);
178
+ const occurrences = sourceCounts.reduce((total, entry) => total + entry.count, 0);
179
+ if (occurrences < REPEATED_SIGNAL_THRESHOLD) {
180
+ continue;
181
+ }
182
+ findings.set(rule.signature, {
183
+ signature: rule.signature,
184
+ title: rule.title,
185
+ severity: rule.severity,
186
+ nextAction: rule.nextAction,
187
+ occurrences,
188
+ evidence: [
189
+ `${String(occurrences)} occurrence(s) across ${String(sourceCounts.length)} allowlisted runtime artifact(s)`,
190
+ ],
191
+ });
192
+ }
193
+ }
194
+ function addRepeatedToolFailures(runtimeArtifacts, findings) {
195
+ const counts = new Map();
196
+ for (const artifact of runtimeArtifacts) {
197
+ for (const match of artifact.content.matchAll(/(?:^|[^a-z0-9_-])-\s*[✗x]\s+([a-z0-9_-]+)/gi)) {
198
+ const tool = match[1];
199
+ if (!tool) {
200
+ continue;
201
+ }
202
+ const current = counts.get(tool) ?? { occurrences: 0, sources: new Set() };
203
+ current.occurrences += 1;
204
+ current.sources.add(artifact.label);
205
+ counts.set(tool, current);
206
+ }
207
+ }
208
+ for (const [tool, aggregate] of counts) {
209
+ if (aggregate.occurrences < REPEATED_SIGNAL_THRESHOLD) {
210
+ continue;
211
+ }
212
+ findings.set(`repeated-tool-failure:${tool}`, {
213
+ signature: `repeated-tool-failure:${tool}`,
214
+ title: `Repeated ${tool} failures during dogfooding`,
215
+ severity: 'high',
216
+ nextAction: 'create_spec',
217
+ occurrences: aggregate.occurrences,
218
+ evidence: [
219
+ `${tool} failed ${String(aggregate.occurrences)} time(s) across ${String(aggregate.sources.size)} allowlisted runtime artifact(s)`,
220
+ ],
221
+ });
222
+ }
223
+ }
224
+ function packageVersion(artifact) {
225
+ if (!artifact || typeof artifact.json !== 'object' || artifact.json === null) {
226
+ return null;
227
+ }
228
+ const version = artifact.json.version;
229
+ return typeof version === 'string' ? version : null;
230
+ }
231
+ function sessionVersion(runtimeArtifacts) {
232
+ for (const artifact of runtimeArtifacts) {
233
+ const match = /\*\*Version\*\*:\s*([0-9]+\.[0-9]+\.[0-9]+)/.exec(artifact.content);
234
+ if (match?.[1]) {
235
+ return match[1];
236
+ }
237
+ if (typeof artifact.json === 'object' && artifact.json !== null) {
238
+ const version = artifact.json.version;
239
+ if (typeof version === 'string') {
240
+ return version;
241
+ }
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+ function runGit(projectRoot, args) {
247
+ return new Promise((resolveResult) => {
248
+ execFile('git', [...args], {
249
+ cwd: projectRoot,
250
+ encoding: 'utf8',
251
+ timeout: GIT_TIMEOUT_MS,
252
+ maxBuffer: GIT_MAX_BUFFER_BYTES,
253
+ windowsHide: true,
254
+ }, (error, stdout) => {
255
+ resolveResult(error ? null : stdout.trim());
256
+ });
257
+ });
258
+ }
259
+ async function detectBranchDrift(projectRoot, findings) {
260
+ const refs = [
261
+ 'refs/heads/main',
262
+ 'refs/remotes/origin/main',
263
+ 'refs/heads/develop',
264
+ 'refs/remotes/origin/develop',
265
+ 'refs/heads/release',
266
+ 'refs/remotes/origin/release',
267
+ ];
268
+ const values = await Promise.all(refs.map((ref) => runGit(projectRoot, ['rev-parse', '--verify', ref])));
269
+ const resolved = new Map(refs.map((ref, index) => [ref, values[index] ?? null]));
270
+ const remoteMainSha = resolved.get('refs/remotes/origin/main');
271
+ const localMainSha = resolved.get('refs/heads/main');
272
+ const mainSha = remoteMainSha ?? localMainSha;
273
+ if (!mainSha) {
274
+ return false;
275
+ }
276
+ for (const branch of ['develop', 'release']) {
277
+ const local = resolved.get(`refs/heads/${branch}`);
278
+ const remote = resolved.get(`refs/remotes/origin/${branch}`);
279
+ if (remoteMainSha && remote && remote !== remoteMainSha) {
280
+ const signature = `remote-mirror-stale:${branch}`;
281
+ findings.set(signature, {
282
+ signature,
283
+ title: `Remote ${branch} mirror is stale`,
284
+ severity: 'high',
285
+ nextAction: 'create_spec',
286
+ occurrences: 1,
287
+ evidence: [`origin/${branch} does not match authoritative origin/main`],
288
+ });
289
+ }
290
+ if (local && local !== mainSha) {
291
+ const signature = `local-mirror-stale:${branch}`;
292
+ findings.set(signature, {
293
+ signature,
294
+ title: `Local ${branch} mirror is stale`,
295
+ severity: 'medium',
296
+ nextAction: 'create_spec',
297
+ occurrences: 1,
298
+ evidence: [
299
+ `Local ${branch} does not match ${remoteMainSha ? 'authoritative origin/main' : 'local main'}`,
300
+ ],
301
+ });
302
+ }
303
+ else if (!local && remote) {
304
+ const signature = `local-mirror-missing:${branch}`;
305
+ findings.set(signature, {
306
+ signature,
307
+ title: `Local ${branch} mirror is missing`,
308
+ severity: 'medium',
309
+ nextAction: 'create_spec',
310
+ occurrences: 1,
311
+ evidence: [`Local ${branch} is missing while origin/${branch} exists`],
312
+ });
313
+ }
314
+ }
315
+ return true;
316
+ }
317
+ export async function analyzeRuntimeDogfooding(projectPath) {
318
+ let projectRoot;
319
+ try {
320
+ projectRoot = await realpath(resolve(projectPath));
321
+ }
322
+ catch {
323
+ return {
324
+ status: 'insufficient_evidence',
325
+ findings: [],
326
+ analyzedSources: [],
327
+ unavailableSources: ARTIFACTS.map((artifact) => artifact.label),
328
+ };
329
+ }
330
+ const loaded = await Promise.all(ARTIFACTS.map((artifact) => loadArtifact(projectRoot, artifact)));
331
+ const artifacts = loaded.filter((artifact) => artifact !== null);
332
+ const analyzedSources = artifacts.map((artifact) => artifact.label);
333
+ const unavailableSources = ARTIFACTS.filter((_, index) => loaded[index] === null).map((artifact) => artifact.label);
334
+ const runtimeArtifacts = artifacts.filter((artifact) => artifact.label === 'session-context' || artifact.label === 'session');
335
+ const findings = new Map();
336
+ addRepeatedSignals(runtimeArtifacts, findings);
337
+ addRepeatedToolFailures(runtimeArtifacts, findings);
338
+ const currentVersion = packageVersion(artifacts.find((item) => item.label === 'package.json'));
339
+ const persistedVersion = sessionVersion(runtimeArtifacts);
340
+ if (currentVersion && persistedVersion && currentVersion !== persistedVersion) {
341
+ findings.set('session-version-drift', {
342
+ signature: 'session-version-drift',
343
+ title: 'Persisted session version drifted from the current package version',
344
+ severity: 'medium',
345
+ nextAction: 'submit_feedback',
346
+ occurrences: 1,
347
+ evidence: ['Persisted session version does not match package.json'],
348
+ });
349
+ }
350
+ const gitEvidenceAvailable = await detectBranchDrift(projectRoot, findings);
351
+ if (gitEvidenceAvailable) {
352
+ analyzedSources.push('git-local-refs');
353
+ }
354
+ else {
355
+ unavailableSources.push('git-local-refs');
356
+ }
357
+ const normalizedFindings = [...findings.values()].sort((a, b) => {
358
+ const rank = { high: 0, medium: 1, low: 2 };
359
+ return rank[a.severity] - rank[b.severity] || a.title.localeCompare(b.title);
360
+ });
361
+ if (normalizedFindings.length > 0) {
362
+ return {
363
+ status: 'findings',
364
+ findings: normalizedFindings,
365
+ analyzedSources,
366
+ unavailableSources,
367
+ };
368
+ }
369
+ const hasCompleteRuntimeEvidence = runtimeArtifacts.some((artifact) => artifact.label === 'session-context') &&
370
+ runtimeArtifacts.some((artifact) => artifact.label === 'session');
371
+ if (!hasCompleteRuntimeEvidence || !gitEvidenceAvailable) {
372
+ return {
373
+ status: 'insufficient_evidence',
374
+ findings: [],
375
+ analyzedSources,
376
+ unavailableSources,
377
+ };
378
+ }
379
+ return {
380
+ status: 'no_actionable_gaps',
381
+ findings: [],
382
+ analyzedSources,
383
+ unavailableSources,
384
+ };
385
+ }
386
+ //# sourceMappingURL=runtime-gap-detector.js.map
@@ -12,6 +12,7 @@ export interface VisualQAResult {
12
12
  figmaImageUrl: string;
13
13
  screenshotUrl: string;
14
14
  comparedAt: string;
15
+ comparisonMode: 'pixel-diff' | 'evidence-only';
15
16
  diffs: VisualDiffEntry[];
16
17
  criticalCount: number;
17
18
  warningCount: number;
@@ -26,7 +27,7 @@ export interface VisualQASummary {
26
27
  validatedAt: string;
27
28
  }
28
29
  export declare function exportFigmaFrameImage(fileKey: string, nodeId: string, accessToken: string): Promise<string>;
29
- export declare function createVisualQAResult(specId: string, nodeId: string, figmaImageUrl: string, screenshotUrl: string, diffs: VisualDiffEntry[]): VisualQAResult;
30
+ export declare function createVisualQAResult(specId: string, nodeId: string, figmaImageUrl: string, screenshotUrl: string, diffs: VisualDiffEntry[], comparisonMode?: 'pixel-diff' | 'evidence-only'): VisualQAResult;
30
31
  export declare function buildPlaceholderDiff(_screenshotUrl: string, _figmaImageUrl: string): VisualDiffEntry[];
31
32
  export declare function formatVisualQAReport(results: VisualQAResult[]): string;
32
33
  export declare function buildSummary(results: VisualQAResult[], totalSpecs: number): VisualQASummary;
@@ -21,7 +21,7 @@ export async function exportFigmaFrameImage(fileKey, nodeId, accessToken) {
21
21
  // ---------------------------------------------------------------------------
22
22
  // createVisualQAResult — build a VisualQAResult from diffs
23
23
  // ---------------------------------------------------------------------------
24
- export function createVisualQAResult(specId, nodeId, figmaImageUrl, screenshotUrl, diffs) {
24
+ export function createVisualQAResult(specId, nodeId, figmaImageUrl, screenshotUrl, diffs, comparisonMode = 'pixel-diff') {
25
25
  const criticalCount = diffs.filter((d) => d.severity === 'critical').length;
26
26
  const warningCount = diffs.filter((d) => d.severity === 'warning').length;
27
27
  const infoCount = diffs.filter((d) => d.severity === 'info').length;
@@ -31,18 +31,18 @@ export function createVisualQAResult(specId, nodeId, figmaImageUrl, screenshotUr
31
31
  figmaImageUrl,
32
32
  screenshotUrl,
33
33
  comparedAt: new Date().toISOString(),
34
+ comparisonMode,
34
35
  diffs,
35
36
  criticalCount,
36
37
  warningCount,
37
38
  infoCount,
38
- passed: criticalCount === 0,
39
+ passed: comparisonMode === 'pixel-diff' && criticalCount === 0,
39
40
  };
40
41
  }
41
42
  // ---------------------------------------------------------------------------
42
43
  // buildPlaceholderDiff — placeholder for pixel-level image comparison
43
44
  // ---------------------------------------------------------------------------
44
45
  export function buildPlaceholderDiff(_screenshotUrl, _figmaImageUrl) {
45
- // TODO: Integrate with canvas/Playwright for pixel-level comparison
46
46
  return [];
47
47
  }
48
48
  // ---------------------------------------------------------------------------
@@ -55,7 +55,7 @@ export function formatVisualQAReport(results) {
55
55
  const header = '| Spec | Status | Critical | Warnings | Compared At |';
56
56
  const separator = '|------|--------|---------|---------|------------|';
57
57
  const rows = results.map((r) => {
58
- const status = r.passed ? '✅ Pass' : '❌ Fail';
58
+ const status = r.comparisonMode === 'evidence-only' ? '⚠ Evidence only' : r.passed ? '✅ Pass' : '❌ Fail';
59
59
  const date = r.comparedAt.slice(0, 10);
60
60
  return `| ${r.specId} | ${status} | ${r.criticalCount} | ${r.warningCount} | ${date} |`;
61
61
  });
@@ -65,11 +65,12 @@ export function formatVisualQAReport(results) {
65
65
  // buildSummary — aggregate summary across all results
66
66
  // ---------------------------------------------------------------------------
67
67
  export function buildSummary(results, totalSpecs) {
68
+ const validatedResults = results.filter((result) => result.comparisonMode === 'pixel-diff');
68
69
  return {
69
70
  totalSpecs,
70
- specsValidated: results.length,
71
- specsWithCritical: results.filter((r) => r.criticalCount > 0).length,
72
- specsNeverValidated: totalSpecs - results.length,
71
+ specsValidated: validatedResults.length,
72
+ specsWithCritical: validatedResults.filter((r) => r.criticalCount > 0).length,
73
+ specsNeverValidated: Math.max(0, totalSpecs - validatedResults.length),
73
74
  validatedAt: new Date().toISOString(),
74
75
  };
75
76
  }
@@ -5,6 +5,7 @@ import { readFileSync, statSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { resolveProjectCommandPlan } from '../tools/validate-runtime.js';
7
7
  const TIMEOUT_MS = 120_000;
8
+ const MAX_OUTPUT_BUFFER_BYTES = 64 * 1024 * 1024;
8
9
  const QA_FINGERPRINT_FILES = [
9
10
  'package.json',
10
11
  'pnpm-lock.yaml',
@@ -30,7 +31,7 @@ function runCheck(name, command, args, cwd) {
30
31
  cwd,
31
32
  timeout: TIMEOUT_MS,
32
33
  encoding: 'utf-8',
33
- maxBuffer: 2 * 1024 * 1024,
34
+ maxBuffer: MAX_OUTPUT_BUFFER_BYTES,
34
35
  });
35
36
  const durationMs = Date.now() - start;
36
37
  const passed = result.status === 0 && !result.error;
@@ -1,6 +1,5 @@
1
1
  import { detectUnpushedCommits } from './unpushed-detector.js';
2
2
  import { flushBuffer } from './learnings-buffer.js';
3
- import { checkSessionContextFreshness } from './session-context-freshness.js';
4
3
  import { safeAutopush } from './autopush.js';
5
4
  const WARN_UNPUSHED_HOURS = 2;
6
5
  /** Regenerate session-context.md (fire-and-forget, best-effort). */
@@ -44,12 +43,9 @@ function buildHumanSummary(sessionContextRefreshed, learningsFlushed, unpushed,
44
43
  /** Run a full session checkpoint: regen context, flush buffer, detect/push unpushed. */
45
44
  export async function runCheckpoint(options) {
46
45
  const { projectPath, dryRun = false, autopush = false, autopushBranches } = options;
47
- // Check freshness and regenerate if stale (or always refresh on explicit call)
48
- const freshness = await checkSessionContextFreshness(projectPath);
49
- let sessionContextRefreshed = false;
50
- if (freshness.stale && !dryRun) {
51
- sessionContextRefreshed = await regenerateSessionContext(projectPath);
52
- }
46
+ // This runner backs the explicit checkpoint tool, so refresh even when the
47
+ // file mtime is recent: package releases can change semantic state instantly.
48
+ const sessionContextRefreshed = dryRun ? false : await regenerateSessionContext(projectPath);
53
49
  // Flush learnings buffer
54
50
  let learningsFlushed = 0;
55
51
  if (!dryRun) {
@@ -12,9 +12,19 @@ export interface TransitionContext {
12
12
  viaSync?: boolean;
13
13
  /** Optional reason supplied by the LLM (forceStatus / reverse transition). */
14
14
  reason?: string;
15
+ /** Non-status fields committed atomically with the status mutation. */
16
+ updates?: Omit<Partial<Spec>, 'status'>;
17
+ /** Optional work scheduled after the durable transition boundary. */
18
+ pendingBackgroundActions?: readonly string[];
19
+ /** Snapshot used by lifecycle gates; the store rejects intervening mutations. */
20
+ expectedSpec?: Spec;
15
21
  }
16
22
  export interface TransitionRecord {
23
+ projectId: string;
24
+ specId: string;
17
25
  spec: Spec;
26
+ previousSpec: Spec;
27
+ transitionId: string;
18
28
  fromStatus: SpecStatus;
19
29
  toStatus: SpecStatus;
20
30
  trigger: TransitionTrigger;
@@ -29,7 +39,12 @@ export interface TransitionRecord {
29
39
  * is responsible for running all gates (DoD, validate, QA, security, etc.) BEFORE
30
40
  * invoking transitionSpec. transitionSpec never runs gates itself.
31
41
  *
32
- * Callable ONLY from `src/tools/update-status/index.ts`.
42
+ * Callable only from the lifecycle status orchestrator.
33
43
  */
34
44
  export declare function transitionSpec(projectId: string, specId: string, newStatus: SpecStatus, ctx: TransitionContext): Promise<TransitionRecord>;
45
+ /**
46
+ * Restore the exact pre-transition snapshot after paired persistence fails.
47
+ * The update_status caller must still hold the per-spec cross-process lock.
48
+ */
49
+ export declare function rollbackTransitionSpec(record: TransitionRecord): Promise<Spec>;
35
50
  //# sourceMappingURL=transition-spec.d.ts.map
@@ -12,23 +12,38 @@ import { specStore } from '../../storage/index.js';
12
12
  * is responsible for running all gates (DoD, validate, QA, security, etc.) BEFORE
13
13
  * invoking transitionSpec. transitionSpec never runs gates itself.
14
14
  *
15
- * Callable ONLY from `src/tools/update-status/index.ts`.
15
+ * Callable only from the lifecycle status orchestrator.
16
16
  */
17
17
  export async function transitionSpec(projectId, specId, newStatus, ctx) {
18
- const before = await specStore.getSpec(projectId, specId);
18
+ const before = ctx.expectedSpec ?? (await specStore.getSpecFresh(projectId, specId));
19
19
  if (!before) {
20
20
  throw new Error(`Spec ${specId} not found in project ${projectId}`);
21
21
  }
22
22
  // Use the internal-only write path that bypasses the status guard
23
- const after = await specStore.__internalSetStatus(projectId, specId, newStatus);
23
+ const after = await specStore.__internalSetStatus(projectId, specId, newStatus, ctx.updates, ctx.pendingBackgroundActions, { status: before.status, updatedAt: before.updatedAt });
24
+ const receipt = after.statusHistory?.at(-1);
25
+ if (!receipt?.transitionId) {
26
+ throw new Error(`Transition receipt was not persisted for ${specId}`);
27
+ }
24
28
  return {
29
+ projectId,
30
+ specId,
25
31
  spec: after,
32
+ previousSpec: before,
33
+ transitionId: receipt.transitionId,
26
34
  fromStatus: before.status,
27
35
  toStatus: newStatus,
28
36
  trigger: ctx.trigger,
29
37
  actor: ctx.actor,
30
38
  viaSync: ctx.viaSync ?? false,
31
- timestamp: new Date().toISOString(),
39
+ timestamp: receipt.changedAt,
32
40
  };
33
41
  }
42
+ /**
43
+ * Restore the exact pre-transition snapshot after paired persistence fails.
44
+ * The update_status caller must still hold the per-spec cross-process lock.
45
+ */
46
+ export async function rollbackTransitionSpec(record) {
47
+ return specStore.__internalRestoreSpecSnapshot(record.projectId, record.specId, record.previousSpec, record.spec);
48
+ }
34
49
  //# sourceMappingURL=transition-spec.js.map
@@ -19,8 +19,8 @@ interface HeuristicResult {
19
19
  export declare function heuristicClassify(description: string): HeuristicResult;
20
20
  /**
21
21
  * Classify the intent of a user request into a TriageKind.
22
- * Wraps the LLM call (or heuristic) in a cost guard (5s wall-clock).
23
- * On guard trip, returns a safe fallback (feature-spec, confidence 0.5, rationale GUARD_FALLBACK).
22
+ * Wraps the guarded classifier path in a cost guard (5s wall-clock).
23
+ * On guard trip, returns a safe deterministic fallback.
24
24
  */
25
25
  export declare function classifyIntent(input: TriageRequestInput): Promise<TriageResult>;
26
26
  export {};
@@ -1,8 +1,7 @@
1
- // engine/triagier/classifier.ts — SPEC-726: Haiku-backed intent classifier
1
+ // engine/triagier/classifier.ts — SPEC-726: intent classifier
2
2
  //
3
- // If an LLM provider is available, calls Haiku for classification.
4
- // Falls back to a deterministic heuristic classifier on timeout, error, or
5
- // when no provider is configured.
3
+ // Uses deterministic classification today.
4
+ // A model-backed classifier may be wired later without changing the public interface.
6
5
  import { runWithGuard } from './cost-guard.js';
7
6
  import { routeToNextStep } from './router.js';
8
7
  /**
@@ -67,16 +66,16 @@ export function heuristicClassify(description) {
67
66
  const GUARD_FALLBACK_RESULT = {
68
67
  kind: 'feature-spec',
69
68
  confidence: 0.5,
70
- rationale: 'GUARD_FALLBACK',
69
+ rationale: 'Deterministic fallback classification used because the guarded classifier path did not complete in time.',
71
70
  nextStep: { tool: 'elicit_requirements', mode: 'interactive' },
72
71
  };
73
72
  // ---------------------------------------------------------------------------
74
- // LLM-backed classifier (stub — uses heuristic until Haiku adapter is wired)
73
+ // Guarded classifier path (currently deterministic)
75
74
  // ---------------------------------------------------------------------------
76
75
  /**
77
- * Try to classify using an LLM provider (Haiku-class).
78
- * Currently implemented as a deterministic heuristic because no Haiku API
79
- * adapter is wired in src/engine/llm-providers/. When a Haiku transport is
76
+ * Try to classify through the guarded classifier path.
77
+ * Currently implemented as a deterministic heuristic because no model transport
78
+ * is wired in src/engine/llm-providers/. When a model-backed transport is
80
79
  * available, replace this stub with a real call. The interface is unchanged.
81
80
  *
82
81
  * Architectural note: the spec says "If LLM provider unavailable, use
@@ -85,9 +84,7 @@ const GUARD_FALLBACK_RESULT = {
85
84
  * correctly when a real LLM call is wired in.
86
85
  */
87
86
  // eslint-disable-next-line @typescript-eslint/require-await -- async signature required by cost-guard interface
88
- async function tryLlmClassify(input) {
89
- // TODO: Wire real Haiku call here when llm-providers has a text-generation adapter.
90
- // For now: deterministic heuristic (same result, no API cost).
87
+ async function tryGuardedClassify(input) {
91
88
  return heuristicClassify(input.description);
92
89
  }
93
90
  // ---------------------------------------------------------------------------
@@ -95,8 +92,8 @@ async function tryLlmClassify(input) {
95
92
  // ---------------------------------------------------------------------------
96
93
  /**
97
94
  * Classify the intent of a user request into a TriageKind.
98
- * Wraps the LLM call (or heuristic) in a cost guard (5s wall-clock).
99
- * On guard trip, returns a safe fallback (feature-spec, confidence 0.5, rationale GUARD_FALLBACK).
95
+ * Wraps the guarded classifier path in a cost guard (5s wall-clock).
96
+ * On guard trip, returns a safe deterministic fallback.
100
97
  */
101
98
  export async function classifyIntent(input) {
102
99
  // Respect any hint provided by the caller
@@ -109,7 +106,7 @@ export async function classifyIntent(input) {
109
106
  nextStep: routeToNextStep(hintKind),
110
107
  };
111
108
  }
112
- const guardResult = await runWithGuard(() => tryLlmClassify(input), {
109
+ const guardResult = await runWithGuard(() => tryGuardedClassify(input), {
113
110
  wallClockMs: 5_000,
114
111
  maxOutputTokens: 1_500,
115
112
  });