@planu/cli 4.11.7 → 4.11.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/CHANGELOG.md +10 -0
- package/dist/cli/commands/serve.js +4 -0
- package/dist/config/license-plans.json +1 -0
- package/dist/engine/browser-validator.js +26 -21
- package/dist/engine/crash-shield/file-collector.d.ts +20 -3
- package/dist/engine/crash-shield/file-collector.js +137 -8
- package/dist/engine/crash-shield/index.d.ts +18 -1
- package/dist/engine/crash-shield/index.js +58 -17
- package/dist/engine/dogfooding/runtime-gap-detector.d.ts +3 -0
- package/dist/engine/dogfooding/runtime-gap-detector.js +386 -0
- package/dist/engine/figma/visual-qa.d.ts +2 -1
- package/dist/engine/figma/visual-qa.js +8 -7
- package/dist/engine/qa-gate.js +2 -1
- package/dist/engine/spec-state-machine/transition-spec.d.ts +16 -1
- package/dist/engine/spec-state-machine/transition-spec.js +19 -4
- package/dist/engine/triagier/classifier.d.ts +2 -2
- package/dist/engine/triagier/classifier.js +12 -15
- package/dist/index.js +12 -4
- package/dist/storage/approval-operation-lock.d.ts +10 -0
- package/dist/storage/approval-operation-lock.js +44 -0
- package/dist/storage/approval-store.d.ts +2 -0
- package/dist/storage/approval-store.js +9 -1
- package/dist/storage/spec-store.d.ts +29 -2
- package/dist/storage/spec-store.js +307 -7
- package/dist/tools/approval-handler.js +255 -124
- package/dist/tools/browser-validate-handler.js +17 -3
- package/dist/tools/dogfood-watch.d.ts +6 -0
- package/dist/tools/dogfood-watch.js +48 -0
- package/dist/tools/figma/visual-qa.js +2 -1
- package/dist/tools/tool-registry/core-tools.js +12 -0
- package/dist/tools/tool-registry/group-quality-compliance.js +12 -1
- package/dist/tools/update-status/file-sync.js +3 -2
- package/dist/tools/update-status/index.d.ts +2 -0
- package/dist/tools/update-status/index.js +1083 -821
- package/dist/tools/update-status/response-builder.js +11 -0
- package/dist/tools/update-status/side-effects.d.ts +16 -1
- package/dist/tools/update-status/side-effects.js +140 -0
- package/dist/tools/update-status/transition-guard.js +1 -1
- package/dist/tools/update-status-actions.d.ts +10 -2
- package/dist/tools/update-status-actions.js +166 -192
- package/dist/tools/update-status-convention-gate.d.ts +3 -1
- package/dist/tools/update-status-convention-gate.js +135 -7
- package/dist/types/browser-validator.d.ts +2 -0
- package/dist/types/dogfooding.d.ts +34 -0
- package/dist/types/dogfooding.js +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/spec/core.d.ts +28 -1
- package/package.json +25 -25
- package/planu-native.json +1 -1
- 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:
|
|
71
|
-
specsWithCritical:
|
|
72
|
-
specsNeverValidated: totalSpecs -
|
|
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
|
}
|
package/dist/engine/qa-gate.js
CHANGED
|
@@ -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:
|
|
34
|
+
maxBuffer: MAX_OUTPUT_BUFFER_BYTES,
|
|
34
35
|
});
|
|
35
36
|
const durationMs = Date.now() - start;
|
|
36
37
|
const passed = result.status === 0 && !result.error;
|
|
@@ -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
|
|
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
|
|
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.
|
|
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:
|
|
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
|
|
23
|
-
* On guard trip, returns a safe 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:
|
|
1
|
+
// engine/triagier/classifier.ts — SPEC-726: intent classifier
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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: '
|
|
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
|
-
//
|
|
73
|
+
// Guarded classifier path (currently deterministic)
|
|
75
74
|
// ---------------------------------------------------------------------------
|
|
76
75
|
/**
|
|
77
|
-
* Try to classify
|
|
78
|
-
* Currently implemented as a deterministic heuristic because no
|
|
79
|
-
*
|
|
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
|
|
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
|
|
99
|
-
* On guard trip, returns a safe 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(() =>
|
|
109
|
+
const guardResult = await runWithGuard(() => tryGuardedClassify(input), {
|
|
113
110
|
wallClockMs: 5_000,
|
|
114
111
|
maxOutputTokens: 1_500,
|
|
115
112
|
});
|
package/dist/index.js
CHANGED
|
@@ -57,16 +57,26 @@ function createMcpServer() {
|
|
|
57
57
|
}
|
|
58
58
|
// Primary server instance — created immediately at module load with minimal deps.
|
|
59
59
|
const server = createMcpServer();
|
|
60
|
+
function scheduleStartupStateRecovery(startupSync, recoverPendingPostCommitTasksAtStartup) {
|
|
61
|
+
void startupSync()
|
|
62
|
+
.catch((err) => {
|
|
63
|
+
console.error('[Planu] Startup spec state sync failed:', err);
|
|
64
|
+
})
|
|
65
|
+
.then(() => recoverPendingPostCommitTasksAtStartup().catch((err) => {
|
|
66
|
+
console.error('[Planu] Startup post-commit recovery failed:', err);
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
60
69
|
// Loads post-handshake modules after the MCP handshake has completed.
|
|
61
70
|
// Separated from main() to keep main() within the 80-line function budget.
|
|
62
71
|
async function setupPostHandshake(handshakeGate, officialToolNames) {
|
|
63
72
|
// Wait for initialize + notifications/initialized before firing any notifications.
|
|
64
73
|
await handshakeGate;
|
|
65
74
|
const envKey = process.env.SDD_LICENSE_KEY;
|
|
66
|
-
const [{ bootstrapAutopilotHandlers }, { registerAgentSquadHandlers }, { startupSync }, { checkForUpdates }, { setConnectedClient }, { handleActivateLicense }, { GroupManager }, { setGroupManager },] = await Promise.all([
|
|
75
|
+
const [{ bootstrapAutopilotHandlers }, { registerAgentSquadHandlers }, { startupSync }, { recoverPendingPostCommitTasksAtStartup }, { checkForUpdates }, { setConnectedClient }, { handleActivateLicense }, { GroupManager }, { setGroupManager },] = await Promise.all([
|
|
67
76
|
import('./engine/autopilot/bootstrap.js'),
|
|
68
77
|
import('./tools/register-agent-squad-tools.js'),
|
|
69
78
|
import('./tools/sync-spec-state-handler.js'),
|
|
79
|
+
import('./tools/update-status/index.js'),
|
|
70
80
|
import('./engine/update-notifier.js'),
|
|
71
81
|
import('./engine/llm-runtime/index.js'),
|
|
72
82
|
import('./tools/activate-license.js'),
|
|
@@ -104,9 +114,7 @@ async function setupPostHandshake(handshakeGate, officialToolNames) {
|
|
|
104
114
|
}
|
|
105
115
|
})();
|
|
106
116
|
// SPEC-334: Sync planu/ YAML frontmatter status → data/ store (non-blocking)
|
|
107
|
-
startupSync
|
|
108
|
-
console.error('[Planu] Startup spec state sync failed:', err);
|
|
109
|
-
});
|
|
117
|
+
scheduleStartupStateRecovery(startupSync, recoverPendingPostCommitTasksAtStartup);
|
|
110
118
|
// Auto-activate license from environment variable if present.
|
|
111
119
|
if (envKey) {
|
|
112
120
|
const { licenseStore: ls } = await import('./storage/index.js');
|