@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
@@ -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
- // eslint-disable-next-line no-restricted-imports -- approval-handler is a SPEC-722 migration bridge; __internalSetStatus use is intentional
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 spec = await getSpec(projectId, args.specId);
107
- if (spec === null) {
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
- const records = await approvalStore.appendRecord(projectId, args.specId, record);
171
- const policy = await approvalStore.getPolicy(projectId);
172
- const status = buildApprovalStatus(args.specId, records, policy, undefined);
173
- // Audit trail fire-and-forget
174
- appendEvent(auditLogPath(projectId), {
175
- eventType: 'spec.approved',
176
- specId: args.specId,
177
- userId: args.reviewer,
178
- action: 'approve_spec',
179
- details: {
180
- reviewer: args.reviewer,
181
- reviewerIdentity,
182
- role: args.role ?? null,
183
- comment: args.comment ?? null,
184
- approvalCount: status.approvalCount,
185
- gateOpen: status.gateOpen,
186
- tokenVerified: reviewerIdentity.verified,
187
- legacyMode: reviewerIdentity.legacy,
188
- },
189
- }).catch(() => {
190
- // audit failure must never block the tool
191
- });
192
- const gateMsg = status.gateOpen
193
- ? ` Approval gate is now OPEN (${String(status.approvalCount)}/${String(policy?.requiredApprovals ?? status.approvalCount)}).`
194
- : ` Gate still needs ${String(status.approvalsNeeded)} more approval(s).`;
195
- const legacyWarning = reviewerIdentity.legacy
196
- ? '\n\n⚠ [DEPRECATION] legacyReviewMode is active. Token-based review will be mandatory in v3.'
197
- : '';
198
- return {
199
- content: [
200
- {
201
- type: 'text',
202
- text: `${args.reviewer} approved ${args.specId}.${gateMsg}${legacyWarning}`,
203
- },
204
- ],
205
- structuredContent: { record, status },
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
- // Append the changes_requested record and clear prior approvals for this spec
230
- await approvalStore.appendRecord(projectId, args.specId, record);
231
- await approvalStore.clearRecords(projectId, args.specId);
232
- // Re-append only the changes_requested record so the history is preserved
233
- const records = await approvalStore.appendRecord(projectId, args.specId, record);
234
- // Move spec back to draft
235
- // TODO(SPEC-722): Migrate to handleUpdateStatus with reviewer identity token.
236
- // Using __internalSetStatus as a temporary bridge until SPEC-722 lands.
237
- await __internalSetStatus(projectId, args.specId, 'draft');
238
- // Audit trail fire-and-forget
239
- appendEvent(auditLogPath(projectId), {
240
- eventType: 'spec.status_changed',
241
- specId: args.specId,
242
- userId: args.reviewer,
243
- action: 'request_changes',
244
- details: {
245
- reviewer: args.reviewer,
246
- role: args.role ?? null,
247
- comment: args.comment,
248
- changesRequested: true,
249
- },
250
- }).catch(() => {
251
- // audit failure must never block the tool
252
- });
253
- void records;
254
- return {
255
- content: [
256
- {
257
- type: 'text',
258
- text: `${args.reviewer} requested changes on ${args.specId}. ` +
259
- `Spec moved back to draft. Comment: "${args.comment}"`,
260
- },
261
- ],
262
- structuredContent: { record, specStatus: 'draft' },
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
- const [spec, records, policy] = await Promise.all([
271
- getSpec(projectId, args.specId),
272
- approvalStore.getRecords(projectId, args.specId),
273
- approvalStore.getPolicy(projectId),
274
- ]);
275
- if (spec === null) {
276
- return {
277
- content: [{ type: 'text', text: `Spec ${args.specId} not found.` }],
278
- isError: true,
279
- };
280
- }
281
- // Determine when spec entered review (use updatedAt as proxy if in review)
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
- const changeRequests = records.filter((r) => r.action === 'changes_requested');
304
- if (changeRequests.length > 0) {
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 output = err instanceof Error ? err.message : String(err);
39
- return parsePlaywrightOutput(output);
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(formatSuccess('Browser Validation Complete', lines.join('\n')), result);
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
  ``,
@@ -0,0 +1,6 @@
1
+ import type { ToolResult } from '../types/index.js';
2
+ export interface DogfoodWatchInput {
3
+ projectPath: string;
4
+ }
5
+ export declare function handleDogfoodWatch(input: DogfoodWatchInput): Promise<ToolResult>;
6
+ //# sourceMappingURL=dogfood-watch.d.ts.map
@@ -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 result = createVisualQAResult(spec.specId, nodeId, figmaImageUrl, '', diffs);
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.string().optional().describe('Role of the reviewer (e.g. "tech-lead", "qa").'),
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)));
@@ -3,10 +3,11 @@ import { metricsStore } from '../../storage/index.js';
3
3
  import { addLesson, getLessons } from '../../storage/lessons-store.js';
4
4
  import { dispatchFeedbackEvent } from '../learn.js';
5
5
  import { autoReconcileOnDone } from '../update-status-reconcile.js';
6
- import { readFile, writeFile } from 'node:fs/promises';
6
+ import { readFile } from 'node:fs/promises';
7
7
  import { parseFrontmatter } from '../../engine/frontmatter-parser.js';
8
8
  import { computeFrontmatterSha } from '../../engine/frontmatter-sha/index.js';
9
9
  import { appendTransitionEvent } from '../../storage/transition-log.js';
10
+ import { atomicWriteFile } from '../../engine/safety/atomic-write-file.js';
10
11
  export async function recordDoneMetrics(projectId, specId, spec, actuals, projectPath) {
11
12
  if (actuals.devHours + actuals.reviewHours === 0) {
12
13
  return;
@@ -110,7 +111,7 @@ export async function syncSpecFiles(updatedSpec, _currentStatus, newStatus, proj
110
111
  newStatus,
111
112
  specId: updatedSpec.id,
112
113
  });
113
- await writeFile(updatedSpec.specPath, updatedContent, 'utf-8');
114
+ await atomicWriteFile(updatedSpec.specPath, updatedContent);
114
115
  // SPEC-544: Auto-stage spec.md after writing so it's included in the user's next commit
115
116
  // SPEC-575: Auto-commit staged planu/ docs (idempotent, safe-fail)
116
117
  // Commit message must reflect the actual transition — 'mark-done' is only
@@ -1,4 +1,6 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { UpdateStatusInput, ToolResult } from '../../types/index.js';
3
+ /** Recover durable post-commit work for every registered project after server startup. */
4
+ export declare function recoverPendingPostCommitTasksAtStartup(): Promise<void>;
3
5
  export declare function handleUpdateStatus(params: UpdateStatusInput, server?: McpServer): Promise<ToolResult>;
4
6
  //# sourceMappingURL=index.d.ts.map