@planu/cli 4.2.3 → 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,11 @@
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
+
1
9
  ## [4.2.3] - 2026-05-22
2
10
 
3
11
  **Tarball SHA-256:** `6d23ef6f7bd12e2a94eb9984e272beb37cd0d9b54ad12170529f95ca870e46a4`
@@ -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;
@@ -60,8 +74,8 @@ export declare const ValidationReportV1Schema: z.ZodObject<{
60
74
  reviewer: z.ZodObject<{
61
75
  kind: z.ZodEnum<{
62
76
  human: "human";
63
- "implementation-review-agent": "implementation-review-agent";
64
77
  automation: "automation";
78
+ "implementation-review-agent": "implementation-review-agent";
65
79
  }>;
66
80
  agent: z.ZodString;
67
81
  verdict: z.ZodEnum<{
@@ -115,13 +129,27 @@ export declare const ARTIFACT_SCHEMAS: {
115
129
  }, z.core.$strip>;
116
130
  readonly review_feedback: z.ZodObject<{
117
131
  schema_version: z.ZodString;
132
+ specId: z.ZodString;
118
133
  verdict: z.ZodEnum<{
119
134
  approved: "approved";
120
135
  "changes-requested": "changes-requested";
121
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>;
122
149
  blockers: z.ZodArray<z.ZodString>;
123
150
  suggestions: z.ZodArray<z.ZodString>;
124
151
  body: z.ZodString;
152
+ reviewedAt: z.ZodISODateTime;
125
153
  }, z.core.$strip>;
126
154
  readonly 'implementation-report': z.ZodObject<{
127
155
  schema_version: z.ZodString;
@@ -144,8 +172,8 @@ export declare const ARTIFACT_SCHEMAS: {
144
172
  reviewer: z.ZodObject<{
145
173
  kind: z.ZodEnum<{
146
174
  human: "human";
147
- "implementation-review-agent": "implementation-review-agent";
148
175
  automation: "automation";
176
+ "implementation-review-agent": "implementation-review-agent";
149
177
  }>;
150
178
  agent: z.ZodString;
151
179
  verdict: z.ZodEnum<{
@@ -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,
@@ -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
@@ -78,6 +78,10 @@ export interface DoneGateResult {
78
78
  * with reviewer evidence and all validation gates passing.
79
79
  */
80
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>;
81
85
  /**
82
86
  * SPEC-335: Combined done-gates runner — DoD + security.
83
87
  * When force=true, gates are not blocking but failing items are recorded in
@@ -9,6 +9,7 @@ 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
11
  import { writeImplementationReviewReport } from '../../engine/validator/validation-report-writer.js';
12
+ import { writeSpecReviewFeedback } from '../../engine/validator/spec-review-writer.js';
12
13
  /**
13
14
  * SPEC-721 / SPEC-222 Trigger 1: Run validate engine before marking done.
14
15
  *
@@ -320,6 +321,71 @@ export async function checkValidationReportGate(specId, projectId, force) {
320
321
  });
321
322
  }
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
+ }
377
+ return null;
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
+ }
323
389
  function validationReportGateError(args) {
324
390
  const artifactPath = `external Planu project data: handoffs/${args.specId}/validation-report.json`;
325
391
  return {
@@ -344,6 +410,30 @@ function validationReportGateError(args) {
344
410
  },
345
411
  };
346
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
+ };
436
+ }
347
437
  /**
348
438
  * SPEC-335: Combined done-gates runner — DoD + security.
349
439
  * When force=true, gates are not blocking but failing items are recorded in
@@ -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, checkValidationReportGate, } 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) {
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.2.3",
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.3",
36
- "@planu/core-darwin-x64": "4.2.3",
37
- "@planu/core-linux-arm64-gnu": "4.2.3",
38
- "@planu/core-linux-arm64-musl": "4.2.3",
39
- "@planu/core-linux-x64-gnu": "4.2.3",
40
- "@planu/core-linux-x64-musl": "4.2.3",
41
- "@planu/core-win32-arm64-msvc": "4.2.3",
42
- "@planu/core-win32-x64-msvc": "4.2.3"
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"