@planu/cli 4.2.2 → 4.2.4

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 CHANGED
@@ -1,3 +1,19 @@
1
+ ## [4.2.4] - 2026-05-22
2
+
3
+ **Tarball SHA-256:** `963c0c81820ad002206299f102fdcae6635d96cdbe2e602e4f1dbb2d1b41e0c5`
4
+
5
+ ### Features
6
+ - feat(planu): require spec reviewer before approval
7
+
8
+
9
+ ## [4.2.3] - 2026-05-22
10
+
11
+ **Tarball SHA-256:** `6d23ef6f7bd12e2a94eb9984e272beb37cd0d9b54ad12170529f95ca870e46a4`
12
+
13
+ ### Features
14
+ - feat(planu): require implementation reviewer gate
15
+
16
+
1
17
  ## [4.2.2] - 2026-05-21
2
18
 
3
19
  **Tarball SHA-256:** `83193f54dc2fc5f461ef38b8bf2a9931d5d587ece50c77836942351e39b3c415`
@@ -31,13 +31,27 @@ export declare const SpecLockV1Schema: z.ZodObject<{
31
31
  }, z.core.$strip>;
32
32
  export declare const ReviewFeedbackV1Schema: z.ZodObject<{
33
33
  schema_version: z.ZodString;
34
+ specId: z.ZodString;
34
35
  verdict: z.ZodEnum<{
35
36
  approved: "approved";
36
37
  "changes-requested": "changes-requested";
37
38
  }>;
39
+ reviewer: z.ZodObject<{
40
+ kind: z.ZodEnum<{
41
+ human: "human";
42
+ "spec-review-agent": "spec-review-agent";
43
+ automation: "automation";
44
+ }>;
45
+ agent: z.ZodString;
46
+ verdict: z.ZodEnum<{
47
+ approved: "approved";
48
+ "changes-requested": "changes-requested";
49
+ }>;
50
+ }, z.core.$strip>;
38
51
  blockers: z.ZodArray<z.ZodString>;
39
52
  suggestions: z.ZodArray<z.ZodString>;
40
53
  body: z.ZodString;
54
+ reviewedAt: z.ZodISODateTime;
41
55
  }, z.core.$strip>;
42
56
  export declare const ImplementationReportV1Schema: z.ZodObject<{
43
57
  schema_version: z.ZodString;
@@ -57,6 +71,31 @@ export declare const ValidationReportV1Schema: z.ZodObject<{
57
71
  passed: z.ZodBoolean;
58
72
  reason: z.ZodOptional<z.ZodString>;
59
73
  }, z.core.$strip>>;
74
+ reviewer: z.ZodObject<{
75
+ kind: z.ZodEnum<{
76
+ human: "human";
77
+ automation: "automation";
78
+ "implementation-review-agent": "implementation-review-agent";
79
+ }>;
80
+ agent: z.ZodString;
81
+ verdict: z.ZodEnum<{
82
+ approved: "approved";
83
+ "changes-requested": "changes-requested";
84
+ }>;
85
+ }, z.core.$strip>;
86
+ specCompliance: z.ZodOptional<z.ZodObject<{
87
+ score: z.ZodNumber;
88
+ command: z.ZodString;
89
+ scenarios: z.ZodArray<z.ZodObject<{
90
+ title: z.ZodString;
91
+ verdict: z.ZodEnum<{
92
+ fail: "fail";
93
+ missing: "missing";
94
+ pass: "pass";
95
+ }>;
96
+ evidence: z.ZodArray<z.ZodString>;
97
+ }, z.core.$strip>>;
98
+ }, z.core.$strip>>;
60
99
  score: z.ZodOptional<z.ZodNumber>;
61
100
  completedAt: z.ZodISODateTime;
62
101
  }, z.core.$strip>;
@@ -90,13 +129,27 @@ export declare const ARTIFACT_SCHEMAS: {
90
129
  }, z.core.$strip>;
91
130
  readonly review_feedback: z.ZodObject<{
92
131
  schema_version: z.ZodString;
132
+ specId: z.ZodString;
93
133
  verdict: z.ZodEnum<{
94
134
  approved: "approved";
95
135
  "changes-requested": "changes-requested";
96
136
  }>;
137
+ reviewer: z.ZodObject<{
138
+ kind: z.ZodEnum<{
139
+ human: "human";
140
+ "spec-review-agent": "spec-review-agent";
141
+ automation: "automation";
142
+ }>;
143
+ agent: z.ZodString;
144
+ verdict: z.ZodEnum<{
145
+ approved: "approved";
146
+ "changes-requested": "changes-requested";
147
+ }>;
148
+ }, z.core.$strip>;
97
149
  blockers: z.ZodArray<z.ZodString>;
98
150
  suggestions: z.ZodArray<z.ZodString>;
99
151
  body: z.ZodString;
152
+ reviewedAt: z.ZodISODateTime;
100
153
  }, z.core.$strip>;
101
154
  readonly 'implementation-report': z.ZodObject<{
102
155
  schema_version: z.ZodString;
@@ -116,6 +169,31 @@ export declare const ARTIFACT_SCHEMAS: {
116
169
  passed: z.ZodBoolean;
117
170
  reason: z.ZodOptional<z.ZodString>;
118
171
  }, z.core.$strip>>;
172
+ reviewer: z.ZodObject<{
173
+ kind: z.ZodEnum<{
174
+ human: "human";
175
+ automation: "automation";
176
+ "implementation-review-agent": "implementation-review-agent";
177
+ }>;
178
+ agent: z.ZodString;
179
+ verdict: z.ZodEnum<{
180
+ approved: "approved";
181
+ "changes-requested": "changes-requested";
182
+ }>;
183
+ }, z.core.$strip>;
184
+ specCompliance: z.ZodOptional<z.ZodObject<{
185
+ score: z.ZodNumber;
186
+ command: z.ZodString;
187
+ scenarios: z.ZodArray<z.ZodObject<{
188
+ title: z.ZodString;
189
+ verdict: z.ZodEnum<{
190
+ fail: "fail";
191
+ missing: "missing";
192
+ pass: "pass";
193
+ }>;
194
+ evidence: z.ZodArray<z.ZodString>;
195
+ }, z.core.$strip>>;
196
+ }, z.core.$strip>>;
119
197
  score: z.ZodOptional<z.ZodNumber>;
120
198
  completedAt: z.ZodISODateTime;
121
199
  }, z.core.$strip>;
@@ -27,10 +27,17 @@ export const SpecLockV1Schema = z.object({
27
27
  });
28
28
  export const ReviewFeedbackV1Schema = z.object({
29
29
  schema_version: SchemaVersion,
30
+ specId: z.string(),
30
31
  verdict: z.enum(['approved', 'changes-requested']),
32
+ reviewer: z.object({
33
+ kind: z.enum(['spec-review-agent', 'human', 'automation']),
34
+ agent: z.string().min(1),
35
+ verdict: z.enum(['approved', 'changes-requested']),
36
+ }),
31
37
  blockers: z.array(z.string()),
32
38
  suggestions: z.array(z.string()),
33
39
  body: z.string(),
40
+ reviewedAt: z.iso.datetime(),
34
41
  });
35
42
  export const ImplementationReportV1Schema = z.object({
36
43
  schema_version: SchemaVersion,
@@ -50,6 +57,22 @@ export const ValidationReportV1Schema = z.object({
50
57
  passed: z.boolean(),
51
58
  reason: z.string().optional(),
52
59
  })),
60
+ reviewer: z.object({
61
+ kind: z.enum(['implementation-review-agent', 'human', 'automation']),
62
+ agent: z.string().min(1),
63
+ verdict: z.enum(['approved', 'changes-requested']),
64
+ }),
65
+ specCompliance: z
66
+ .object({
67
+ score: z.number().min(0).max(100),
68
+ command: z.string(),
69
+ scenarios: z.array(z.object({
70
+ title: z.string(),
71
+ verdict: z.enum(['pass', 'fail', 'missing']),
72
+ evidence: z.array(z.string()),
73
+ })),
74
+ })
75
+ .optional(),
53
76
  score: z.number().optional(),
54
77
  completedAt: z.iso.datetime(),
55
78
  });
@@ -0,0 +1,14 @@
1
+ import type { Spec } from '../../types/index.js';
2
+ import type { ReviewFeedbackV1 } from '../../types/handoff-artifacts.js';
3
+ export declare function writeSpecReviewFeedback(input: {
4
+ projectId: string;
5
+ specId: string;
6
+ spec: Spec;
7
+ }): Promise<{
8
+ written: boolean;
9
+ path?: string;
10
+ sha?: string;
11
+ payload: ReviewFeedbackV1;
12
+ error?: string;
13
+ }>;
14
+ //# sourceMappingURL=spec-review-writer.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { appendArtifact } from '../handoff-artifacts/io.js';
2
+ export async function writeSpecReviewFeedback(input) {
3
+ const blockers = buildSpecReviewBlockers(input.spec);
4
+ const verdict = blockers.length === 0 ? 'approved' : 'changes-requested';
5
+ const payload = {
6
+ schema_version: '1.0.0',
7
+ specId: input.specId,
8
+ verdict,
9
+ reviewer: {
10
+ kind: 'spec-review-agent',
11
+ agent: 'planu-spec-reviewer',
12
+ verdict,
13
+ },
14
+ blockers,
15
+ suggestions: blockers.length === 0
16
+ ? ['Proceed to approval gates; keep implementation reviewer separate after coding.']
17
+ : [
18
+ 'Resolve blockers, rerun challenge/readiness checks, then move the spec back to review.',
19
+ ],
20
+ body: blockers.length === 0
21
+ ? `Spec ${input.specId} reviewed by planu-spec-reviewer and approved for approval gates.`
22
+ : `Spec ${input.specId} reviewed by planu-spec-reviewer with requested changes.`,
23
+ reviewedAt: new Date().toISOString(),
24
+ };
25
+ try {
26
+ const artifact = await appendArtifact({
27
+ projectId: input.projectId,
28
+ specId: input.specId,
29
+ kind: 'review_feedback',
30
+ payload,
31
+ });
32
+ return { written: true, path: artifact.path, sha: artifact.sha, payload };
33
+ }
34
+ catch (err) {
35
+ return {
36
+ written: false,
37
+ payload,
38
+ error: err instanceof Error ? err.message : String(err),
39
+ };
40
+ }
41
+ }
42
+ function buildSpecReviewBlockers(spec) {
43
+ const blockers = [];
44
+ if (!spec.title || spec.title.trim().length === 0) {
45
+ blockers.push('Spec title is missing.');
46
+ }
47
+ return blockers;
48
+ }
49
+ //# sourceMappingURL=spec-review-writer.js.map
@@ -0,0 +1,21 @@
1
+ import type { Spec } from '../../types/index.js';
2
+ import type { ValidationReportV1 } from '../../types/handoff-artifacts.js';
3
+ export declare function writeImplementationReviewReport(input: {
4
+ projectId: string;
5
+ specId: string;
6
+ spec: Spec;
7
+ projectPath: string;
8
+ score: number | null;
9
+ lintPassed?: boolean;
10
+ conventionRegression?: boolean;
11
+ }): Promise<{
12
+ written: boolean;
13
+ path?: string;
14
+ sha?: string;
15
+ error?: string;
16
+ reviewer: ValidationReportV1['reviewer'];
17
+ passed: boolean;
18
+ gates: ValidationReportV1['gates'];
19
+ specCompliance: NonNullable<ValidationReportV1['specCompliance']>;
20
+ }>;
21
+ //# sourceMappingURL=validation-report-writer.d.ts.map
@@ -0,0 +1,106 @@
1
+ import { appendArtifact } from '../handoff-artifacts/io.js';
2
+ import { runSpecCompliance, } from './spec-compliance-runner.js';
3
+ export async function writeImplementationReviewReport(input) {
4
+ const specCompliance = await runSpecCompliance(input.spec, input.projectPath);
5
+ const gates = buildGates({
6
+ score: input.score,
7
+ lintPassed: input.lintPassed ?? true,
8
+ conventionRegression: input.conventionRegression ?? false,
9
+ specCompliance,
10
+ });
11
+ const passed = gates.every((gate) => gate.passed);
12
+ const reviewer = {
13
+ kind: 'implementation-review-agent',
14
+ agent: 'planu-implementation-reviewer',
15
+ verdict: passed ? 'approved' : 'changes-requested',
16
+ };
17
+ const reportCompliance = toValidationReportCompliance(specCompliance);
18
+ const payload = {
19
+ schema_version: '1.0.0',
20
+ specId: input.specId,
21
+ passed,
22
+ gates,
23
+ reviewer,
24
+ specCompliance: reportCompliance,
25
+ score: input.score ?? undefined,
26
+ completedAt: new Date().toISOString(),
27
+ };
28
+ try {
29
+ const artifact = await appendArtifact({
30
+ projectId: input.projectId,
31
+ specId: input.specId,
32
+ kind: 'validation-report',
33
+ payload,
34
+ });
35
+ return {
36
+ written: true,
37
+ path: artifact.path,
38
+ sha: artifact.sha,
39
+ reviewer,
40
+ passed,
41
+ gates,
42
+ specCompliance: reportCompliance,
43
+ };
44
+ }
45
+ catch (err) {
46
+ return {
47
+ written: false,
48
+ error: err instanceof Error ? err.message : String(err),
49
+ reviewer,
50
+ passed,
51
+ gates,
52
+ specCompliance: reportCompliance,
53
+ };
54
+ }
55
+ }
56
+ function buildGates(args) {
57
+ return [
58
+ {
59
+ name: 'validate-score',
60
+ passed: args.score === 100,
61
+ reason: args.score === 100
62
+ ? undefined
63
+ : `Expected validation score 100 before done, got ${String(args.score ?? 'unscored')}.`,
64
+ },
65
+ {
66
+ name: 'lint',
67
+ passed: args.lintPassed,
68
+ reason: args.lintPassed ? undefined : 'Configured lint command failed.',
69
+ },
70
+ {
71
+ name: 'convention-regression',
72
+ passed: !args.conventionRegression,
73
+ reason: args.conventionRegression ? 'Convention baseline regression detected.' : undefined,
74
+ },
75
+ buildSpecComplianceGate(args.specCompliance),
76
+ ];
77
+ }
78
+ function buildSpecComplianceGate(result) {
79
+ if (result.perScenario.length === 0) {
80
+ return {
81
+ name: 'spec-compliance',
82
+ passed: true,
83
+ reason: 'No executable scenarios declared; spec-compliance runner skipped.',
84
+ };
85
+ }
86
+ const passed = result.dimensionScore === 100;
87
+ return {
88
+ name: 'spec-compliance',
89
+ passed,
90
+ reason: passed
91
+ ? undefined
92
+ : `Expected all executable scenarios to pass, got ${String(result.dimensionScore)}.`,
93
+ };
94
+ }
95
+ function toValidationReportCompliance(result) {
96
+ return {
97
+ score: result.dimensionScore,
98
+ command: result.command,
99
+ scenarios: result.perScenario.map((scenario) => ({
100
+ title: scenario.title,
101
+ verdict: scenario.verdict,
102
+ evidence: scenario.evidence,
103
+ })),
104
+ };
105
+ }
106
+ //# sourceMappingURL=validation-report-writer.js.map
@@ -45,7 +45,10 @@ export type ValidateGateResult = {
45
45
  * @param forceStatusReason Why — required and ≥100 chars when forceStatus=true.
46
46
  * @param budgetMs Max milliseconds to wait for validate. Default: 9000.
47
47
  */
48
- export declare function runValidateGate(spec: Spec, projectPath: string, forceStatus: boolean, forceStatusReason: string | undefined, budgetMs?: number): Promise<ValidateGateResult>;
48
+ export declare function runValidateGate(spec: Spec, projectPath: string, forceStatus: boolean, forceStatusReason: string | undefined, budgetMs?: number, reviewContext?: {
49
+ projectId: string;
50
+ specId: string;
51
+ }): Promise<ValidateGateResult>;
49
52
  /**
50
53
  * Checks the DoD gate before transitioning to 'done'.
51
54
  * Returns an error ToolResult if blocked (unless force=true), null if passed.
@@ -71,11 +74,14 @@ export interface DoneGateResult {
71
74
  }
72
75
  /**
73
76
  * SPEC-725: Check the validation-report artifact gate before marking done.
74
- * If a validation-report.json exists and payload.passed === false, block transition.
75
- * If the artifact is absent, allow (backward compat).
76
- * Never throws — always best-effort.
77
+ * SPEC-1050: Fail closed. A done transition requires a fresh validation-report
78
+ * with reviewer evidence and all validation gates passing.
77
79
  */
78
80
  export declare function checkValidationReportGate(specId: string, projectId: string, force: boolean | undefined): Promise<ToolResult | null>;
81
+ /** SPEC-1051: Write spec-review evidence when a spec enters review. */
82
+ export declare function writeSpecReviewArtifact(spec: Spec, specId: string, projectId: string): Promise<ToolResult | null>;
83
+ /** SPEC-1051: Approval requires a dedicated spec reviewer artifact. */
84
+ export declare function checkSpecReviewGate(specId: string, projectId: string, _forceApprove: boolean | undefined): Promise<ToolResult | null>;
79
85
  /**
80
86
  * SPEC-335: Combined done-gates runner — DoD + security.
81
87
  * When force=true, gates are not blocking but failing items are recorded in
@@ -8,6 +8,8 @@ import { getComplianceGateConfig } from '../../storage/compliance-gate-config-st
8
8
  import { readJson, writeJson, projectDataDir } from '../../storage/base-store.js';
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import { withEscalation } from '../../engine/escalator/with-escalation.js';
11
+ import { writeImplementationReviewReport } from '../../engine/validator/validation-report-writer.js';
12
+ import { writeSpecReviewFeedback } from '../../engine/validator/spec-review-writer.js';
11
13
  /**
12
14
  * SPEC-721 / SPEC-222 Trigger 1: Run validate engine before marking done.
13
15
  *
@@ -24,7 +26,7 @@ import { withEscalation } from '../../engine/escalator/with-escalation.js';
24
26
  * @param forceStatusReason Why — required and ≥100 chars when forceStatus=true.
25
27
  * @param budgetMs Max milliseconds to wait for validate. Default: 9000.
26
28
  */
27
- export async function runValidateGate(spec, projectPath, forceStatus, forceStatusReason, budgetMs = 9_000) {
29
+ export async function runValidateGate(spec, projectPath, forceStatus, forceStatusReason, budgetMs = 9_000, reviewContext) {
28
30
  // ---- Path A: explicit force bypass ----------------------------------------
29
31
  if (forceStatus) {
30
32
  if (!forceStatusReason || forceStatusReason.trim().length < 100) {
@@ -96,13 +98,29 @@ export async function runValidateGate(spec, projectPath, forceStatus, forceStatu
96
98
  };
97
99
  }
98
100
  // ---- Normal score check — scoreValue is guaranteed number here (null/undefined filtered above) ---
99
- if (scoreValue < 70) {
101
+ if (scoreValue < 100) {
102
+ if (reviewContext) {
103
+ await writeImplementationReviewReport({
104
+ ...reviewContext,
105
+ spec,
106
+ projectPath,
107
+ score: scoreValue,
108
+ });
109
+ }
100
110
  return {
101
111
  blocked: true,
102
112
  score: scoreValue,
103
113
  reason: 'validate_score_below_threshold',
104
114
  };
105
115
  }
116
+ if (reviewContext) {
117
+ await writeImplementationReviewReport({
118
+ ...reviewContext,
119
+ spec,
120
+ projectPath,
121
+ score: scoreValue,
122
+ });
123
+ }
106
124
  return { blocked: false, score: scoreValue, forced: false };
107
125
  }
108
126
  /**
@@ -243,9 +261,8 @@ async function recordForceDoneBypass(specId, projectId, failingItems, warningIte
243
261
  }
244
262
  /**
245
263
  * SPEC-725: Check the validation-report artifact gate before marking done.
246
- * If a validation-report.json exists and payload.passed === false, block transition.
247
- * If the artifact is absent, allow (backward compat).
248
- * Never throws — always best-effort.
264
+ * SPEC-1050: Fail closed. A done transition requires a fresh validation-report
265
+ * with reviewer evidence and all validation gates passing.
249
266
  */
250
267
  export async function checkValidationReportGate(specId, projectId, force) {
251
268
  if (force) {
@@ -254,45 +271,168 @@ export async function checkValidationReportGate(specId, projectId, force) {
254
271
  try {
255
272
  const { readArtifact } = await import('../../engine/handoff-artifacts/io.js');
256
273
  const result = await readArtifact({ projectId, specId, kind: 'validation-report' });
257
- // If not found (ARTIFACT_NOT_FOUND), allow — backward compat
258
274
  if (!result.ok) {
259
275
  const firstErr = result.errors[0];
260
276
  if (firstErr?.code === 'ARTIFACT_NOT_FOUND') {
261
- return null;
277
+ return validationReportGateError({
278
+ specId,
279
+ error: 'validation_report_missing',
280
+ message: 'No validation-report artifact exists for this spec. Run validate to generate implementation-review evidence before marking done.',
281
+ gates: [],
282
+ fixHint: 'Run validate for this spec, fix any failing gates, then retry update_status(done). Use force:true only with an audited reason.',
283
+ });
262
284
  }
263
- // Other errors (malformed file) — best-effort: allow
264
- return null;
285
+ return validationReportGateError({
286
+ specId,
287
+ error: 'validation_report_invalid',
288
+ message: 'The validation-report artifact is malformed or uses an obsolete schema. Re-run validate so Planu can generate reviewer evidence.',
289
+ gates: [],
290
+ fixHint: 'Re-run validate for this spec. The report must include reviewer evidence and passing gates.',
291
+ });
265
292
  }
266
293
  if (!result.payload.passed) {
267
- const artifactPath = `external Planu project data: handoffs/${specId}/validation-report.json`;
268
- return {
269
- content: [
270
- {
271
- type: 'text',
272
- text: JSON.stringify({
273
- error: 'validation_report_failed',
274
- message: `Validation report at ${artifactPath} has passed: false — fix all failing gates before marking done.`,
275
- artifactPath,
276
- gates: result.payload.gates,
277
- fixHint: 'Fix all failing gates and re-run validation, then retry update_status(done). Use force:true to bypass.',
278
- }, null, 2),
279
- },
280
- ],
281
- isError: true,
282
- structuredContent: {
283
- error: 'validation_report_failed',
284
- code: 422,
285
- context: { specId, artifactPath, gates: result.payload.gates },
286
- fixHint: 'Fix failing gates and re-run validation. Use force:true to bypass.',
287
- },
288
- };
294
+ return validationReportGateError({
295
+ specId,
296
+ error: 'validation_report_failed',
297
+ message: 'Validation report has passed:false — fix all failing gates before marking done.',
298
+ gates: result.payload.gates,
299
+ fixHint: 'Fix failing gates and re-run validate before marking done.',
300
+ });
301
+ }
302
+ if (result.payload.reviewer.verdict !== 'approved' ||
303
+ result.payload.reviewer.agent.trim().length === 0) {
304
+ return validationReportGateError({
305
+ specId,
306
+ error: 'validation_report_reviewer_not_approved',
307
+ message: 'The implementation reviewer did not approve this spec. Fix the requested changes and re-run validate.',
308
+ gates: result.payload.gates,
309
+ fixHint: 'Fix reviewer findings and re-run validate before marking done.',
310
+ });
289
311
  }
290
312
  return null;
291
313
  }
292
- catch {
293
- /* best-effort — never block transition */
314
+ catch (err) {
315
+ return validationReportGateError({
316
+ specId,
317
+ error: 'validation_report_unreadable',
318
+ message: `Could not read validation-report artifact: ${err instanceof Error ? err.message : String(err)}`,
319
+ gates: [],
320
+ fixHint: 'Re-run validate for this spec, then retry update_status(done).',
321
+ });
322
+ }
323
+ }
324
+ /** SPEC-1051: Write spec-review evidence when a spec enters review. */
325
+ export async function writeSpecReviewArtifact(spec, specId, projectId) {
326
+ const result = await writeSpecReviewFeedback({ projectId, specId, spec });
327
+ if (result.written) {
328
+ return null;
329
+ }
330
+ return specReviewGateError({
331
+ specId,
332
+ error: 'spec_review_write_failed',
333
+ message: `Could not write spec review feedback: ${result.error ?? 'unknown error'}`,
334
+ blockers: result.payload.blockers,
335
+ fixHint: 'Fix the artifact store issue, then retry update_status(review).',
336
+ });
337
+ }
338
+ /** SPEC-1051: Approval requires a dedicated spec reviewer artifact. */
339
+ export async function checkSpecReviewGate(specId, projectId, _forceApprove) {
340
+ try {
341
+ const { readArtifact } = await import('../../engine/handoff-artifacts/io.js');
342
+ const result = await readArtifact({ projectId, specId, kind: 'review_feedback' });
343
+ if (!result.ok) {
344
+ const firstErr = result.errors[0];
345
+ return specReviewGateError({
346
+ specId,
347
+ error: firstErr?.code === 'ARTIFACT_NOT_FOUND' ? 'spec_review_missing' : 'spec_review_invalid',
348
+ message: firstErr?.code === 'ARTIFACT_NOT_FOUND'
349
+ ? 'No spec review feedback exists for this spec. Move it to review so planu-spec-reviewer can review it before approval.'
350
+ : 'Spec review feedback is malformed or uses an obsolete schema. Re-run update_status(review).',
351
+ blockers: [],
352
+ fixHint: firstErr?.code === 'ARTIFACT_NOT_FOUND'
353
+ ? 'Run update_status(review), resolve any review blockers, then retry update_status(approved).'
354
+ : 'Re-run update_status(review) to regenerate reviewer evidence, then retry approval.',
355
+ });
356
+ }
357
+ const review = result.payload;
358
+ if (review.verdict !== 'approved' || review.reviewer.verdict !== 'approved') {
359
+ return specReviewGateError({
360
+ specId,
361
+ error: 'spec_review_changes_requested',
362
+ message: 'Spec reviewer requested changes. Approval is blocked until the review passes.',
363
+ blockers: review.blockers,
364
+ fixHint: 'Resolve the spec review blockers, rerun update_status(review), then retry approval.',
365
+ });
366
+ }
367
+ if (review.reviewer.kind !== 'spec-review-agent' ||
368
+ review.reviewer.agent !== 'planu-spec-reviewer') {
369
+ return specReviewGateError({
370
+ specId,
371
+ error: 'spec_review_wrong_reviewer',
372
+ message: 'Approval requires a dedicated spec reviewer. The implementation reviewer cannot approve the spec plan.',
373
+ blockers: [`Reviewer was ${review.reviewer.agent}. Expected planu-spec-reviewer.`],
374
+ fixHint: 'Run update_status(review) so planu-spec-reviewer writes a fresh review artifact.',
375
+ });
376
+ }
294
377
  return null;
295
378
  }
379
+ catch (err) {
380
+ return specReviewGateError({
381
+ specId,
382
+ error: 'spec_review_unreadable',
383
+ message: `Could not read spec review feedback: ${err instanceof Error ? err.message : String(err)}`,
384
+ blockers: [],
385
+ fixHint: 'Re-run update_status(review), then retry update_status(approved).',
386
+ });
387
+ }
388
+ }
389
+ function validationReportGateError(args) {
390
+ const artifactPath = `external Planu project data: handoffs/${args.specId}/validation-report.json`;
391
+ return {
392
+ content: [
393
+ {
394
+ type: 'text',
395
+ text: JSON.stringify({
396
+ error: args.error,
397
+ message: args.message,
398
+ artifactPath,
399
+ gates: args.gates,
400
+ fixHint: args.fixHint,
401
+ }, null, 2),
402
+ },
403
+ ],
404
+ isError: true,
405
+ structuredContent: {
406
+ error: args.error,
407
+ code: 422,
408
+ context: { specId: args.specId, artifactPath, gates: args.gates },
409
+ fixHint: args.fixHint,
410
+ },
411
+ };
412
+ }
413
+ function specReviewGateError(args) {
414
+ const artifactPath = `external Planu project data: handoffs/${args.specId}/review_feedback.md`;
415
+ return {
416
+ content: [
417
+ {
418
+ type: 'text',
419
+ text: JSON.stringify({
420
+ error: args.error,
421
+ message: args.message,
422
+ artifactPath,
423
+ blockers: args.blockers,
424
+ fixHint: args.fixHint,
425
+ }, null, 2),
426
+ },
427
+ ],
428
+ isError: true,
429
+ structuredContent: {
430
+ error: args.error,
431
+ code: 422,
432
+ context: { specId: args.specId, artifactPath, blockers: args.blockers },
433
+ fixHint: args.fixHint,
434
+ },
435
+ };
296
436
  }
297
437
  /**
298
438
  * SPEC-335: Combined done-gates runner — DoD + security.
@@ -309,11 +449,6 @@ export async function checkDoneGates(spec, specId, projectId, projectPath, force
309
449
  if (secError) {
310
450
  return { blocked: secError, forcedBypassWarning: null };
311
451
  }
312
- // SPEC-725: Check validation-report artifact gate
313
- const validationReportError = await checkValidationReportGate(specId, projectId, false);
314
- if (validationReportError) {
315
- return { blocked: validationReportError, forcedBypassWarning: null };
316
- }
317
452
  return { blocked: null, forcedBypassWarning: null };
318
453
  }
319
454
  // force=true: run DoD in audit mode — collect results but do not block
@@ -13,7 +13,7 @@ import { checkApprovedDepGate } from '../../engine/dep-guard/index.js';
13
13
  import { checkApprovalGate } from '../../engine/approval-workflow.js';
14
14
  import * as approvalStore from '../../storage/approval-store.js';
15
15
  import { isLocked, getLock } from '../../storage/spec-lock-store.js';
16
- import { runValidateGate, checkDoneGates, checkComplianceGate, checkQaGate, checkApprovedFormatGate, } from './dod-gates.js';
16
+ import { runValidateGate, checkDoneGates, checkComplianceGate, checkQaGate, checkApprovedFormatGate, checkValidationReportGate, checkSpecReviewGate, writeSpecReviewArtifact, } from './dod-gates.js';
17
17
  import { buildStatusResponse, buildValidateBlockedResponse, buildDryRunResponse, } from './response-builder.js';
18
18
  import { getSuggestedMode } from './mode-hints.js';
19
19
  import { autoFillActuals, createDefaultActuals } from '../../engine/actuals-estimator.js';
@@ -398,6 +398,18 @@ export async function handleUpdateStatus(params, server) {
398
398
  if (challengeGate) {
399
399
  return challengeGate;
400
400
  }
401
+ if (newStatus === 'review') {
402
+ const specReviewWriteError = await writeSpecReviewArtifact(spec, specId, projectId);
403
+ if (specReviewWriteError) {
404
+ return specReviewWriteError;
405
+ }
406
+ }
407
+ if (newStatus === 'approved') {
408
+ const specReviewError = await checkSpecReviewGate(specId, projectId, params.forceApprove);
409
+ if (specReviewError) {
410
+ return specReviewError;
411
+ }
412
+ }
401
413
  // SPEC-595: Elicit confirmation for destructive status transitions (→done with forceStatus)
402
414
  const elicitResult = await runForceStatusElicitation(server, specId, newStatus, params.forceStatus);
403
415
  if (elicitResult) {
@@ -477,7 +489,7 @@ export async function handleUpdateStatus(params, server) {
477
489
  // SPEC-721: timeout lives inside runValidateGate (Promise.race) — do NOT wrap with
478
490
  // withToolTimeout here, which would silently convert timeout into blocked:false (fail-open).
479
491
  newStatus === 'done'
480
- ? runValidateGate(spec, effectiveGatePath ?? '', params.forceStatus ?? false, params.forceStatusReason, 9_000)
492
+ ? runValidateGate(spec, effectiveGatePath ?? '', params.forceStatus ?? false, params.forceStatusReason, 9_000, { projectId, specId })
481
493
  : Promise.resolve(null),
482
494
  // Crash shield: only on 'done', skipped if rate-limited (SPEC-628)
483
495
  newStatus === 'done' && effectiveGatePath && !crashShieldSkipReason
@@ -507,6 +519,12 @@ export async function handleUpdateStatus(params, server) {
507
519
  };
508
520
  }
509
521
  }
522
+ if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
523
+ const validationReportError = await checkValidationReportGate(specId, projectId, false);
524
+ if (validationReportError) {
525
+ return validationReportError;
526
+ }
527
+ }
510
528
  // Process crash shield result — record run timestamp on success (SPEC-628)
511
529
  /* c8 ignore next */
512
530
  if (crashRisksReport !== null) {
@@ -1033,7 +1051,7 @@ export async function handleUpdateStatus(params, server) {
1033
1051
  result.cascadeFastResults = cascadeRunResult.fastHookResults;
1034
1052
  }
1035
1053
  // SPEC-772 Scenario 4: surface validate failure from cascade as explicit warning
1036
- const autopilotValidateWarning = validateScore !== null && validateScore < 70
1054
+ const autopilotValidateWarning = validateScore !== null && validateScore < 100
1037
1055
  ? `Validate score ${String(validateScore)}/100 — below threshold. Check workspace_alerts for cascade details.`
1038
1056
  : null;
1039
1057
  if (autopilotValidateWarning) {
@@ -6,7 +6,7 @@ import { buildUpdateStatusSummary } from '../../engine/human-summary.js';
6
6
  // SPEC-721: Validate gate blocked response builder
7
7
  // ---------------------------------------------------------------------------
8
8
  const REASON_TO_FIX_HINT = {
9
- validate_score_below_threshold: 'Fix failing acceptance criteria, then retry. Or use forceStatus:true + forceStatusReason (≥100 chars).',
9
+ validate_score_below_threshold: 'Reach 100/100 validation coverage for the spec, then retry. Or use forceStatus:true + forceStatusReason (≥100 chars).',
10
10
  validate_gate_no_criteria: 'Add `scenarios:` to the spec frontmatter (BDD format), or use check_readiness to inject ' +
11
11
  'criteria, then retry. Or use forceStatus:true + forceStatusReason (≥100 chars).',
12
12
  validate_gate_crash: 'The validate engine crashed. Inspect logs, fix the spec or engine, and retry. ' +
@@ -14,6 +14,7 @@ import { validateContractCompliance } from '../engine/test-scaffold-generator.js
14
14
  import { parseConventions, scanConventions } from '../engine/convention-scanner/index.js';
15
15
  import { validateScopeCompliance } from '../engine/scope-boundaries/index.js';
16
16
  import { compareWithBaseline } from '../storage/convention-baseline.js';
17
+ import { writeImplementationReviewReport } from '../engine/validator/validation-report-writer.js';
17
18
  // Re-export for external use (SPEC-018)
18
19
  export { validateContractCompliance };
19
20
  /** Build the fallback interactiveQuestion for validation failures. */
@@ -130,6 +131,16 @@ export async function handleValidate(args, server) {
130
131
  }
131
132
  // 7. Lint check (best-effort — non-blocking)
132
133
  const lintCheck = runLintCheck(projectPath, knowledge.lintCommand ?? null);
134
+ // SPEC-1050: Generate a mandatory implementation-review artifact.
135
+ const validationReport = await writeImplementationReviewReport({
136
+ projectId,
137
+ specId,
138
+ spec,
139
+ projectPath,
140
+ score: result.score,
141
+ lintPassed: lintCheck.passed,
142
+ conventionRegression: regressionDetected,
143
+ });
133
144
  // 8. Build output
134
145
  const output = {
135
146
  specId,
@@ -204,6 +215,7 @@ export async function handleValidate(args, server) {
204
215
  })),
205
216
  },
206
217
  lintCheck,
218
+ validationReport,
207
219
  };
208
220
  // SPEC-612: Scope boundary validation — warn if impl files match outOfScope items
209
221
  if (spec.outOfScope !== undefined && spec.outOfScope.length > 0) {
@@ -26,10 +26,17 @@ export interface SpecLockV1 {
26
26
  /** Reviewer → Planner: structured feedback from code review */
27
27
  export interface ReviewFeedbackV1 {
28
28
  schema_version: string;
29
+ specId: string;
29
30
  verdict: 'approved' | 'changes-requested';
31
+ reviewer: {
32
+ kind: 'spec-review-agent' | 'human' | 'automation';
33
+ agent: string;
34
+ verdict: 'approved' | 'changes-requested';
35
+ };
30
36
  blockers: string[];
31
37
  suggestions: string[];
32
38
  body: string;
39
+ reviewedAt: string;
33
40
  }
34
41
  /** Implementer → Validator: result of implementation run */
35
42
  export interface ImplementationReportV1 {
@@ -51,6 +58,20 @@ export interface ValidationReportV1 {
51
58
  passed: boolean;
52
59
  reason?: string;
53
60
  }[];
61
+ reviewer: {
62
+ kind: 'implementation-review-agent' | 'human' | 'automation';
63
+ agent: string;
64
+ verdict: 'approved' | 'changes-requested';
65
+ };
66
+ specCompliance?: {
67
+ score: number;
68
+ command: string;
69
+ scenarios: {
70
+ title: string;
71
+ verdict: 'pass' | 'fail' | 'missing';
72
+ evidence: string[];
73
+ }[];
74
+ };
54
75
  score?: number;
55
76
  completedAt: string;
56
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.2.2",
3
+ "version": "4.2.4",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,14 +32,14 @@
32
32
  "packageName": "@planu/core"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@planu/core-darwin-arm64": "4.2.2",
36
- "@planu/core-darwin-x64": "4.2.2",
37
- "@planu/core-linux-arm64-gnu": "4.2.2",
38
- "@planu/core-linux-arm64-musl": "4.2.2",
39
- "@planu/core-linux-x64-gnu": "4.2.2",
40
- "@planu/core-linux-x64-musl": "4.2.2",
41
- "@planu/core-win32-arm64-msvc": "4.2.2",
42
- "@planu/core-win32-x64-msvc": "4.2.2"
35
+ "@planu/core-darwin-arm64": "4.2.4",
36
+ "@planu/core-darwin-x64": "4.2.4",
37
+ "@planu/core-linux-arm64-gnu": "4.2.4",
38
+ "@planu/core-linux-arm64-musl": "4.2.4",
39
+ "@planu/core-linux-x64-gnu": "4.2.4",
40
+ "@planu/core-linux-x64-musl": "4.2.4",
41
+ "@planu/core-win32-arm64-msvc": "4.2.4",
42
+ "@planu/core-win32-x64-msvc": "4.2.4"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=24.0.0"
@@ -189,7 +189,7 @@
189
189
  "happy-dom": "^20.9.0",
190
190
  "husky": "^9.1.7",
191
191
  "javascript-obfuscator": "^5.4.3",
192
- "knip": "^6.14.1",
192
+ "knip": "^6.14.2",
193
193
  "lint-staged": "^17.0.5",
194
194
  "madge": "^8.0.0",
195
195
  "prettier": "^3.8.3",