@planu/cli 4.11.6 → 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 +16 -0
- package/dist/cli/commands/serve.js +4 -0
- package/dist/config/license-plans.json +1 -0
- package/dist/engine/actuals/git-analyzer.js +4 -4
- 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/diff-spec-generator.js +12 -5
- 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/git-safe-input.d.ts +6 -0
- package/dist/engine/git-safe-input.js +41 -0
- 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/code-impact-handler.js +4 -2
- 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 +1086 -812
- 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
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// tools/approval-handler.ts — Handlers for approval workflow tools (SPEC-283, SPEC-722)
|
|
2
2
|
import { hashProjectPath, projectDataDir } from '../storage/base-store.js';
|
|
3
3
|
import * as approvalStore from '../storage/approval-store.js';
|
|
4
|
-
|
|
5
|
-
import { getSpec, __internalSetStatus } from '../storage/spec-store.js';
|
|
4
|
+
import { getSpec } from '../storage/spec-store.js';
|
|
6
5
|
import { buildApprovalStatus } from '../engine/approval-workflow.js';
|
|
7
6
|
import { appendEvent } from '../engine/audit-trail/index.js';
|
|
8
7
|
import { join } from 'node:path';
|
|
9
8
|
import { issueReviewerToken, verifyReviewerToken } from '../engine/reviewer-tokens/index.js';
|
|
10
9
|
import { readPlanuConfig } from '../engine/planu-config-writer.js';
|
|
11
10
|
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { handleUpdateStatus } from './update-status/index.js';
|
|
12
|
+
import { withApprovalSpecLock } from '../storage/approval-operation-lock.js';
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// Path helpers
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
@@ -46,6 +47,13 @@ function buildTokenRejectionMessage(reason) {
|
|
|
46
47
|
' (c) Legacy mode (deprecated, v2.x only): set `legacyReviewMode: true` in planu.json. ' +
|
|
47
48
|
'This flag will be removed in v3. [DEPRECATION WARNING]');
|
|
48
49
|
}
|
|
50
|
+
function operationError(action, specId, error) {
|
|
51
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: 'text', text: `Failed to ${action} ${specId}: ${message}` }],
|
|
54
|
+
isError: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
49
57
|
// ---------------------------------------------------------------------------
|
|
50
58
|
// configure_approval_policy
|
|
51
59
|
// ---------------------------------------------------------------------------
|
|
@@ -103,8 +111,8 @@ export async function handleIssueReviewerToken(args) {
|
|
|
103
111
|
// ---------------------------------------------------------------------------
|
|
104
112
|
export async function handleApproveSpec(args) {
|
|
105
113
|
const projectId = hashProjectPath(args.projectPath);
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
114
|
+
const initialSpec = await getSpec(projectId, args.specId);
|
|
115
|
+
if (initialSpec === null) {
|
|
108
116
|
return {
|
|
109
117
|
content: [{ type: 'text', text: `Spec ${args.specId} not found.` }],
|
|
110
118
|
isError: true,
|
|
@@ -155,9 +163,6 @@ export async function handleApproveSpec(args) {
|
|
|
155
163
|
// Legacy mode: accept the string reviewer but warn loudly.
|
|
156
164
|
reviewerIdentity = legacyIdentity(args.reviewer);
|
|
157
165
|
}
|
|
158
|
-
// -------------------------------------------------------------------------
|
|
159
|
-
// Persist the approval record
|
|
160
|
-
// -------------------------------------------------------------------------
|
|
161
166
|
const record = {
|
|
162
167
|
specId: args.specId,
|
|
163
168
|
reviewer: args.reviewer,
|
|
@@ -167,56 +172,81 @@ export async function handleApproveSpec(args) {
|
|
|
167
172
|
...(args.role !== undefined ? { role: args.role } : {}),
|
|
168
173
|
...(args.comment !== undefined ? { comment: args.comment } : {}),
|
|
169
174
|
};
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
175
|
+
try {
|
|
176
|
+
return await withApprovalSpecLock(args.projectPath, args.specId, async () => {
|
|
177
|
+
const spec = await getSpec(projectId, args.specId);
|
|
178
|
+
if (spec === null) {
|
|
179
|
+
return {
|
|
180
|
+
content: [{ type: 'text', text: `Spec ${args.specId} not found.` }],
|
|
181
|
+
isError: true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (spec.status !== 'review') {
|
|
185
|
+
return {
|
|
186
|
+
content: [
|
|
187
|
+
{
|
|
188
|
+
type: 'text',
|
|
189
|
+
text: `Cannot approve ${args.specId} while status is "${spec.status}". Move it to review first.`,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
isError: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const records = await approvalStore.appendRecord(projectId, args.specId, record);
|
|
196
|
+
const policy = await approvalStore.getPolicy(projectId);
|
|
197
|
+
const status = buildApprovalStatus(args.specId, records, policy, spec.updatedAt);
|
|
198
|
+
let auditWarning;
|
|
199
|
+
try {
|
|
200
|
+
await appendEvent(auditLogPath(projectId), {
|
|
201
|
+
eventType: 'spec.approved',
|
|
202
|
+
specId: args.specId,
|
|
203
|
+
userId: args.reviewer,
|
|
204
|
+
action: 'approve_spec',
|
|
205
|
+
details: {
|
|
206
|
+
reviewer: args.reviewer,
|
|
207
|
+
reviewerIdentity,
|
|
208
|
+
role: args.role ?? null,
|
|
209
|
+
comment: args.comment ?? null,
|
|
210
|
+
approvalCount: status.approvalCount,
|
|
211
|
+
gateOpen: status.gateOpen,
|
|
212
|
+
tokenVerified: reviewerIdentity.verified,
|
|
213
|
+
legacyMode: reviewerIdentity.legacy,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
auditWarning = `Audit event could not be persisted: ${error instanceof Error ? error.message : String(error)}`;
|
|
219
|
+
}
|
|
220
|
+
const gateMsg = status.gateOpen
|
|
221
|
+
? ` Approval gate is now OPEN (${String(status.approvalCount)}/${String(policy?.requiredApprovals ?? status.approvalCount)}).`
|
|
222
|
+
: ` Gate still needs ${String(status.approvalsNeeded)} more approval(s).`;
|
|
223
|
+
const legacyWarning = reviewerIdentity.legacy
|
|
224
|
+
? '\n\n⚠ [DEPRECATION] legacyReviewMode is active. Token-based review will be mandatory in v3.'
|
|
225
|
+
: '';
|
|
226
|
+
return {
|
|
227
|
+
content: [
|
|
228
|
+
{
|
|
229
|
+
type: 'text',
|
|
230
|
+
text: `${args.reviewer} approved ${args.specId}.${gateMsg}${legacyWarning}`,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
structuredContent: {
|
|
234
|
+
record,
|
|
235
|
+
status,
|
|
236
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
return operationError('approve', args.specId, error);
|
|
243
|
+
}
|
|
207
244
|
}
|
|
208
245
|
// ---------------------------------------------------------------------------
|
|
209
246
|
// request_changes
|
|
210
247
|
// ---------------------------------------------------------------------------
|
|
211
248
|
export async function handleRequestChanges(args) {
|
|
212
249
|
const projectId = hashProjectPath(args.projectPath);
|
|
213
|
-
const spec = await getSpec(projectId, args.specId);
|
|
214
|
-
if (spec === null) {
|
|
215
|
-
return {
|
|
216
|
-
content: [{ type: 'text', text: `Spec ${args.specId} not found.` }],
|
|
217
|
-
isError: true,
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
250
|
const record = {
|
|
221
251
|
specId: args.specId,
|
|
222
252
|
reviewer: args.reviewer,
|
|
@@ -226,87 +256,188 @@ export async function handleRequestChanges(args) {
|
|
|
226
256
|
comment: args.comment,
|
|
227
257
|
...(args.role !== undefined ? { role: args.role } : {}),
|
|
228
258
|
};
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
259
|
+
try {
|
|
260
|
+
return await withApprovalSpecLock(args.projectPath, args.specId, async () => {
|
|
261
|
+
const spec = await getSpec(projectId, args.specId);
|
|
262
|
+
if (spec === null) {
|
|
263
|
+
return {
|
|
264
|
+
content: [{ type: 'text', text: `Spec ${args.specId} not found.` }],
|
|
265
|
+
isError: true,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const previousRecords = await approvalStore.getRecords(projectId, args.specId);
|
|
269
|
+
const statusResult = await handleUpdateStatus({
|
|
270
|
+
projectPath: args.projectPath,
|
|
271
|
+
specId: args.specId,
|
|
272
|
+
status: 'draft',
|
|
273
|
+
reviewNotes: `Changes requested by ${args.reviewer}: ${args.comment}`,
|
|
274
|
+
});
|
|
275
|
+
if (statusResult.isError) {
|
|
276
|
+
return statusResult;
|
|
277
|
+
}
|
|
278
|
+
let records;
|
|
279
|
+
try {
|
|
280
|
+
records = await approvalStore.replaceRecords(projectId, args.specId, [record]);
|
|
281
|
+
}
|
|
282
|
+
catch (replacementError) {
|
|
283
|
+
let approvalRecordsRestored;
|
|
284
|
+
let lifecycleRestored;
|
|
285
|
+
try {
|
|
286
|
+
await approvalStore.replaceRecords(projectId, args.specId, previousRecords);
|
|
287
|
+
approvalRecordsRestored = true;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
approvalRecordsRestored = false;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const rollbackResult = await handleUpdateStatus({
|
|
294
|
+
projectPath: args.projectPath,
|
|
295
|
+
specId: args.specId,
|
|
296
|
+
status: spec.status,
|
|
297
|
+
reviewNotes: `Automatic rollback after request_changes persistence failure for ${args.reviewer}.`,
|
|
298
|
+
});
|
|
299
|
+
lifecycleRestored = rollbackResult.isError !== true;
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
lifecycleRestored = false;
|
|
303
|
+
}
|
|
304
|
+
const replacementMessage = replacementError instanceof Error ? replacementError.message : String(replacementError);
|
|
305
|
+
let auditWarning;
|
|
306
|
+
try {
|
|
307
|
+
await appendEvent(auditLogPath(projectId), {
|
|
308
|
+
eventType: 'spec.status_changed',
|
|
309
|
+
specId: args.specId,
|
|
310
|
+
userId: args.reviewer,
|
|
311
|
+
action: 'request_changes_rolled_back',
|
|
312
|
+
details: {
|
|
313
|
+
reviewer: args.reviewer,
|
|
314
|
+
reviewerIdentity: record.reviewerIdentity,
|
|
315
|
+
role: args.role ?? null,
|
|
316
|
+
comment: args.comment,
|
|
317
|
+
changesRequested: true,
|
|
318
|
+
persistenceError: replacementMessage,
|
|
319
|
+
previousStatus: spec.status,
|
|
320
|
+
approvalRecordsRestored,
|
|
321
|
+
lifecycleRestored,
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch (auditError) {
|
|
326
|
+
auditWarning = `Audit event could not be persisted: ${auditError instanceof Error ? auditError.message : String(auditError)}`;
|
|
327
|
+
}
|
|
328
|
+
const fullyRestored = approvalRecordsRestored && lifecycleRestored;
|
|
329
|
+
return {
|
|
330
|
+
content: [
|
|
331
|
+
{
|
|
332
|
+
type: 'text',
|
|
333
|
+
text: fullyRestored
|
|
334
|
+
? `Request changes was not committed for ${args.specId}: ${replacementMessage}. Approval history and lifecycle were restored to "${spec.status}"; retry safely.`
|
|
335
|
+
: `Request changes failed for ${args.specId}: ${replacementMessage}. Automatic recovery was incomplete (approval records: ${approvalRecordsRestored ? 'restored' : 'not restored'}, lifecycle: ${lifecycleRestored ? 'restored' : 'not restored'}).`,
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
isError: true,
|
|
339
|
+
structuredContent: {
|
|
340
|
+
record,
|
|
341
|
+
previousStatus: spec.status,
|
|
342
|
+
approvalRecordsRestored,
|
|
343
|
+
lifecycleRestored,
|
|
344
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
let auditWarning;
|
|
349
|
+
try {
|
|
350
|
+
await appendEvent(auditLogPath(projectId), {
|
|
351
|
+
eventType: 'spec.status_changed',
|
|
352
|
+
specId: args.specId,
|
|
353
|
+
userId: args.reviewer,
|
|
354
|
+
action: 'request_changes',
|
|
355
|
+
details: {
|
|
356
|
+
reviewer: args.reviewer,
|
|
357
|
+
reviewerIdentity: record.reviewerIdentity,
|
|
358
|
+
role: args.role ?? null,
|
|
359
|
+
comment: args.comment,
|
|
360
|
+
changesRequested: true,
|
|
361
|
+
previousStatus: spec.status,
|
|
362
|
+
resultingStatus: 'draft',
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
auditWarning = `Audit event could not be persisted: ${error instanceof Error ? error.message : String(error)}`;
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
content: [
|
|
371
|
+
{
|
|
372
|
+
type: 'text',
|
|
373
|
+
text: `${args.reviewer} requested changes on ${args.specId}. ` +
|
|
374
|
+
`Spec moved back to draft. Comment: "${args.comment}"`,
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
structuredContent: {
|
|
378
|
+
record,
|
|
379
|
+
records,
|
|
380
|
+
specStatus: 'draft',
|
|
381
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
return operationError('request changes on', args.specId, error);
|
|
388
|
+
}
|
|
264
389
|
}
|
|
265
390
|
// ---------------------------------------------------------------------------
|
|
266
391
|
// approval_status
|
|
267
392
|
// ---------------------------------------------------------------------------
|
|
268
393
|
export async function handleApprovalStatus(args) {
|
|
269
394
|
const projectId = hashProjectPath(args.projectPath);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const reviewStartedAt = spec.status === 'review' ? spec.updatedAt : undefined;
|
|
283
|
-
const status = buildApprovalStatus(args.specId, records, policy, reviewStartedAt);
|
|
284
|
-
const lines = [
|
|
285
|
-
`Approval Status — ${args.specId}`,
|
|
286
|
-
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
|
|
287
|
-
`Approvals: ${String(status.approvalCount)} / ${String(policy?.requiredApprovals ?? '(no policy)')}`,
|
|
288
|
-
`Gate: ${status.gateOpen ? 'OPEN' : `BLOCKED — needs ${String(status.approvalsNeeded)} more`}`,
|
|
289
|
-
];
|
|
290
|
-
if (status.approvers.length > 0) {
|
|
291
|
-
lines.push(`Approved by: ${status.approvers.join(', ')}`);
|
|
292
|
-
}
|
|
293
|
-
if (policy !== null) {
|
|
294
|
-
lines.push(`SLA: ${String(policy.reviewSLAHours)}h`);
|
|
295
|
-
if (spec.status === 'review') {
|
|
296
|
-
lines.push(`In review: ${String(status.hoursInReview)}h`);
|
|
297
|
-
if (status.slaBreached) {
|
|
298
|
-
lines.push(`⚠ SLA BREACHED by ${String(status.slaBreachHours)}h` +
|
|
299
|
-
(policy.escalateTo !== undefined ? ` — escalate to ${policy.escalateTo}` : ''));
|
|
395
|
+
try {
|
|
396
|
+
return await withApprovalSpecLock(args.projectPath, args.specId, async () => {
|
|
397
|
+
const [spec, records, policy] = await Promise.all([
|
|
398
|
+
getSpec(projectId, args.specId),
|
|
399
|
+
approvalStore.getRecords(projectId, args.specId),
|
|
400
|
+
approvalStore.getPolicy(projectId),
|
|
401
|
+
]);
|
|
402
|
+
if (spec === null) {
|
|
403
|
+
return {
|
|
404
|
+
content: [{ type: 'text', text: `Spec ${args.specId} not found.` }],
|
|
405
|
+
isError: true,
|
|
406
|
+
};
|
|
300
407
|
}
|
|
301
|
-
|
|
408
|
+
const reviewStartedAt = spec.status === 'review' ? spec.updatedAt : undefined;
|
|
409
|
+
const status = buildApprovalStatus(args.specId, records, policy, reviewStartedAt);
|
|
410
|
+
const lines = [
|
|
411
|
+
`Approval Status — ${args.specId}`,
|
|
412
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
|
|
413
|
+
`Approvals: ${String(status.approvalCount)} / ${String(policy?.requiredApprovals ?? '(no policy)')}`,
|
|
414
|
+
`Gate: ${status.gateOpen ? 'OPEN' : `BLOCKED — needs ${String(status.approvalsNeeded)} more`}`,
|
|
415
|
+
];
|
|
416
|
+
if (status.approvers.length > 0) {
|
|
417
|
+
lines.push(`Approved by: ${status.approvers.join(', ')}`);
|
|
418
|
+
}
|
|
419
|
+
if (policy !== null) {
|
|
420
|
+
lines.push(`SLA: ${String(policy.reviewSLAHours)}h`);
|
|
421
|
+
if (spec.status === 'review') {
|
|
422
|
+
lines.push(`In review: ${String(status.hoursInReview)}h`);
|
|
423
|
+
if (status.slaBreached) {
|
|
424
|
+
lines.push(`⚠ SLA BREACHED by ${String(status.slaBreachHours)}h` +
|
|
425
|
+
(policy.escalateTo !== undefined ? ` — escalate to ${policy.escalateTo}` : ''));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const changeRequests = records.filter((record) => record.action === 'changes_requested');
|
|
430
|
+
if (changeRequests.length > 0) {
|
|
431
|
+
lines.push(`Change requests: ${String(changeRequests.length)}`);
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
435
|
+
structuredContent: { status },
|
|
436
|
+
};
|
|
437
|
+
});
|
|
302
438
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
lines.push(`Change requests: ${String(changeRequests.length)}`);
|
|
439
|
+
catch (error) {
|
|
440
|
+
return operationError('read approval status for', args.specId, error);
|
|
306
441
|
}
|
|
307
|
-
return {
|
|
308
|
-
content: [{ type: 'text', text: lines.join('\n') }],
|
|
309
|
-
structuredContent: { status },
|
|
310
|
-
};
|
|
311
442
|
}
|
|
312
443
|
//# sourceMappingURL=approval-handler.js.map
|
|
@@ -35,8 +35,13 @@ async function runPlaywrightTests(testFilePath) {
|
|
|
35
35
|
return parsePlaywrightOutput(stdout + stderr);
|
|
36
36
|
}
|
|
37
37
|
catch (err) {
|
|
38
|
-
const
|
|
39
|
-
|
|
38
|
+
const processError = err;
|
|
39
|
+
const output = [processError.stdout, processError.stderr, processError.message]
|
|
40
|
+
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
41
|
+
.join('\n')
|
|
42
|
+
.slice(0, 4_000);
|
|
43
|
+
const parsed = parsePlaywrightOutput(output);
|
|
44
|
+
return { ...parsed, failed: Math.max(1, parsed.failed) };
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
export async function handleValidateBrowser(args) {
|
|
@@ -58,6 +63,10 @@ export async function handleValidateBrowser(args) {
|
|
|
58
63
|
specContent = `- [ ] ${spec.title}`;
|
|
59
64
|
}
|
|
60
65
|
const assertions = extractUIAssertions(specContent);
|
|
66
|
+
const manualAssertionCount = assertions.filter((a) => a.type === 'generic' ||
|
|
67
|
+
((a.type === 'visibility' || a.type === 'form') && a.locator === undefined) ||
|
|
68
|
+
(a.type === 'text' && a.locator === undefined) ||
|
|
69
|
+
(a.type === 'navigation' && a.expected === undefined)).length;
|
|
61
70
|
const playwrightVersion = await checkPlaywrightAvailable();
|
|
62
71
|
const testContent = generatePlaywrightTest(assertions, specId, baseUrl);
|
|
63
72
|
const result = {
|
|
@@ -65,6 +74,7 @@ export async function handleValidateBrowser(args) {
|
|
|
65
74
|
baseUrl,
|
|
66
75
|
playwrightVersion,
|
|
67
76
|
assertions,
|
|
77
|
+
manualAssertionCount,
|
|
68
78
|
};
|
|
69
79
|
if (playwrightVersion) {
|
|
70
80
|
// Playwright is available — save temp file and run
|
|
@@ -80,12 +90,15 @@ export async function handleValidateBrowser(args) {
|
|
|
80
90
|
`Base URL: ${baseUrl}`,
|
|
81
91
|
`Playwright: ${playwrightVersion}`,
|
|
82
92
|
`Assertions extracted: ${String(assertions.length)}`,
|
|
93
|
+
`Assertions requiring manual grounding: ${String(manualAssertionCount)}`,
|
|
83
94
|
``,
|
|
84
95
|
`**Test Results:** ${summary}`,
|
|
85
96
|
``,
|
|
86
97
|
`Test file: \`${testFilePath}\``,
|
|
87
98
|
];
|
|
88
|
-
return toolResult(
|
|
99
|
+
return toolResult(testResults.failed === 0
|
|
100
|
+
? formatSuccess('Browser Validation Complete', lines.join('\n'))
|
|
101
|
+
: formatWarning('Browser Validation Failed', lines.join('\n')), result, testResults.failed > 0);
|
|
89
102
|
}
|
|
90
103
|
// Playwright not available — save test file and return instructions
|
|
91
104
|
const testFilePath = await saveTestFile(projectPath, specId, testContent);
|
|
@@ -100,6 +113,7 @@ export async function handleValidateBrowser(args) {
|
|
|
100
113
|
`Base URL: ${baseUrl}`,
|
|
101
114
|
`Playwright: not found`,
|
|
102
115
|
`Assertions extracted: ${String(assertions.length)}`,
|
|
116
|
+
`Assertions requiring manual grounding: ${String(manualAssertionCount)}`,
|
|
103
117
|
``,
|
|
104
118
|
`**Test file saved:** \`${testFilePath}\``,
|
|
105
119
|
``,
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
import { listSpecs } from '../storage/spec-store.js';
|
|
3
3
|
import { hashProjectPath } from '../storage/base-store.js';
|
|
4
4
|
import { analyzeCodeImpact, checkCodeChangeCompliance, suggestSpecUpdates, runGitDiffImpact, } from '../engine/code-impact-analyzer.js';
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { assertSafeGitRef } from '../engine/git-safe-input.js';
|
|
6
7
|
function getCommitDiff(commitHash, projectPath) {
|
|
8
|
+
assertSafeGitRef(commitHash, 'commit ref');
|
|
7
9
|
try {
|
|
8
|
-
return
|
|
10
|
+
return execFileSync('git', ['show', commitHash], {
|
|
9
11
|
cwd: projectPath,
|
|
10
12
|
encoding: 'utf-8',
|
|
11
13
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { analyzeRuntimeDogfooding } from '../engine/dogfooding/runtime-gap-detector.js';
|
|
2
|
+
export async function handleDogfoodWatch(input) {
|
|
3
|
+
const report = await analyzeRuntimeDogfooding(input.projectPath);
|
|
4
|
+
if (report.status === 'insufficient_evidence') {
|
|
5
|
+
return {
|
|
6
|
+
content: [
|
|
7
|
+
{
|
|
8
|
+
type: 'text',
|
|
9
|
+
text: [
|
|
10
|
+
'Insufficient local evidence to assess dogfooding gaps.',
|
|
11
|
+
'',
|
|
12
|
+
`Sources checked: ${report.analyzedSources.join(', ') || 'none'}`,
|
|
13
|
+
`Sources unavailable: ${report.unavailableSources.join(', ') || 'none'}`,
|
|
14
|
+
].join('\n'),
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
structuredContent: report,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (report.status === 'no_actionable_gaps') {
|
|
21
|
+
return {
|
|
22
|
+
content: [
|
|
23
|
+
{
|
|
24
|
+
type: 'text',
|
|
25
|
+
text: [
|
|
26
|
+
'No actionable dogfooding gaps detected.',
|
|
27
|
+
'',
|
|
28
|
+
`Sources checked: ${report.analyzedSources.join(', ') || 'none'}`,
|
|
29
|
+
].join('\n'),
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
structuredContent: report,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const lines = ['Dogfooding findings', ''];
|
|
36
|
+
for (const finding of report.findings) {
|
|
37
|
+
lines.push(`- [${finding.severity}] ${finding.title} (${String(finding.occurrences)} occurrence${finding.occurrences === 1 ? '' : 's'})`, ` Next action: ${finding.nextAction}`);
|
|
38
|
+
for (const evidence of finding.evidence) {
|
|
39
|
+
lines.push(` Evidence: ${evidence}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
lines.push('', `Sources checked: ${report.analyzedSources.join(', ') || 'none'}`);
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
45
|
+
structuredContent: report,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=dogfood-watch.js.map
|
|
@@ -22,7 +22,8 @@ export async function handleFigmaVisualDiffReport(input) {
|
|
|
22
22
|
for (const nodeId of spec.nodeIds) {
|
|
23
23
|
const figmaImageUrl = await exportFigmaFrameImage(parsed.fileKey, nodeId, config.accessToken);
|
|
24
24
|
const diffs = buildPlaceholderDiff('', figmaImageUrl);
|
|
25
|
-
const
|
|
25
|
+
const comparisonMode = diffs.length === 0 ? 'evidence-only' : 'pixel-diff';
|
|
26
|
+
const result = createVisualQAResult(spec.specId, nodeId, figmaImageUrl, '', diffs, comparisonMode);
|
|
26
27
|
results.push(result);
|
|
27
28
|
}
|
|
28
29
|
await saveFigmaVisualQA(input.projectPath, results);
|
|
@@ -38,6 +38,7 @@ import { IdeTargetEnum, IdeConfigScopeEnum } from '../schemas/ide-config.js';
|
|
|
38
38
|
import { handleReconcileSpecLiving } from '../reconcile-spec-living-handler.js';
|
|
39
39
|
import { handleSecurityReport } from '../security-report-handler.js';
|
|
40
40
|
import { handleFeedbackStatus, handleResolveFeedback, handleSubmitFeedback, handleSyncFeedback, handleTriageFeedback, } from '../feedback-handler.js';
|
|
41
|
+
import { handleDogfoodWatch } from '../dogfood-watch.js';
|
|
41
42
|
import { SecurityReportTimeRangeEnum, SecurityReportFormatEnum, } from '../schemas/runtime-security.js';
|
|
42
43
|
import { RenderSpecForProviderInputSchema, handleRenderSpecForProvider, } from '../render-spec-for-provider.js';
|
|
43
44
|
import { handleTriageRequest, TriageRequestInputSchema } from '../triage-request.js';
|
|
@@ -847,6 +848,17 @@ const coreToolsRegistry = [
|
|
|
847
848
|
wrap: 'tracked',
|
|
848
849
|
group: 'feedback',
|
|
849
850
|
},
|
|
851
|
+
{
|
|
852
|
+
name: 'dogfood_watch',
|
|
853
|
+
description: 'Analyze recent local Planu runtime evidence and surface actionable dogfooding gaps such as repeated tool failures, persisted version drift, guard fallback leakage, and stale local compatibility mirrors.',
|
|
854
|
+
schema: {
|
|
855
|
+
projectPath: z.string().min(1).max(4096).describe('Absolute path to the project root'),
|
|
856
|
+
},
|
|
857
|
+
handler: async (args) => handleDogfoodWatch(args),
|
|
858
|
+
annotations: { title: 'Dogfood Watch', readOnlyHint: true },
|
|
859
|
+
wrap: 'tracked',
|
|
860
|
+
group: 'feedback',
|
|
861
|
+
},
|
|
850
862
|
];
|
|
851
863
|
export default coreToolsRegistry;
|
|
852
864
|
//# sourceMappingURL=core-tools.js.map
|
|
@@ -1056,12 +1056,23 @@ export function registerQualityComplianceGroupTools(s) {
|
|
|
1056
1056
|
.describe('Spec ID to request changes on (e.g. SPEC-042).'),
|
|
1057
1057
|
reviewer: z
|
|
1058
1058
|
.string()
|
|
1059
|
+
.trim()
|
|
1060
|
+
.min(1)
|
|
1061
|
+
.max(500)
|
|
1059
1062
|
.describe('Name, email, or username of the reviewer requesting changes.'),
|
|
1060
1063
|
comment: z
|
|
1061
1064
|
.string()
|
|
1065
|
+
.trim()
|
|
1062
1066
|
.min(1)
|
|
1067
|
+
.max(10_000)
|
|
1063
1068
|
.describe('Required explanation of what needs to change before re-approval.'),
|
|
1064
|
-
role: z
|
|
1069
|
+
role: z
|
|
1070
|
+
.string()
|
|
1071
|
+
.trim()
|
|
1072
|
+
.min(1)
|
|
1073
|
+
.max(500)
|
|
1074
|
+
.optional()
|
|
1075
|
+
.describe('Role of the reviewer (e.g. "tech-lead", "qa").'),
|
|
1065
1076
|
},
|
|
1066
1077
|
annotations: { title: 'Request Changes', destructiveHint: true },
|
|
1067
1078
|
}, safeLicensed('request_changes', (args) => handleRequestChanges(args)));
|