@planu/cli 4.3.10 → 4.3.12

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,15 @@
1
+ ## [4.3.12] - 2026-05-25
2
+
3
+ ### Bug Fixes
4
+ - fix(release): avoid self-referential tarball sha
5
+
6
+
7
+ ## [4.3.11] - 2026-05-25
8
+
9
+ ### Bug Fixes
10
+ - fix: close critical Planu delivery specs
11
+
12
+
1
13
  ## [4.3.9] - 2026-05-25
2
14
 
3
15
  **Tarball SHA-256:** `a47146af1f2f8e695247fb6ee92cc8da8de00b2ab7967734a7faade7f3659aeb`
@@ -8,67 +8,100 @@ import { red, green } from '../colors.js';
8
8
  export const statusCommand = {
9
9
  name: 'status',
10
10
  description: 'Update the status of a spec',
11
- usage: 'planu status <specId> --set <status> [--project-id ID] | planu status batch --set <status> SPEC-001 SPEC-002',
11
+ usage: 'planu status <specId> --set <status> [--project-id ID] [--model-tier-used TIER --model-id ID --context-hash HASH --handoff-path PATH --reviewed-by ID --arbitrated-by ID] | planu status batch --set <status> SPEC-001 SPEC-002',
12
12
  async run(args, flags) {
13
- const { values, positionals } = parseArgs({
14
- args,
15
- options: {
16
- set: { type: 'string', short: 's' },
17
- 'project-id': { type: 'string' },
18
- notes: { type: 'string', short: 'n' },
19
- },
20
- strict: false,
21
- allowPositionals: true,
22
- });
13
+ const { values, positionals } = parseStatusArgs(args);
23
14
  const isBatch = positionals[0] === 'batch';
24
15
  const specId = positionals[0];
25
16
  if (!specId) {
26
- process.stderr.write(`${red('Error:')} Spec ID is required.\nUsage: ${statusCommand.usage}\n`);
17
+ writeUsageError('Spec ID is required.');
27
18
  process.exitCode = 1;
28
19
  return;
29
20
  }
30
21
  const status = values.set;
31
22
  if (!status) {
32
- process.stderr.write(`${red('Error:')} --set <status> is required.\nUsage: ${statusCommand.usage}\n`);
23
+ writeUsageError('--set <status> is required.');
33
24
  process.exitCode = 1;
34
25
  return;
35
26
  }
36
- const projectId = values['project-id'] ?? detectProjectId();
37
27
  if (isBatch) {
38
- const result = await handleUpdateStatusBatch({
39
- specIds: positionals.slice(1),
40
- projectId,
41
- status: status,
42
- reviewNotes: values.notes ?? undefined,
43
- });
44
- if (result.isError) {
45
- process.stderr.write(`${red(formatToolResult(result, flags))}\n`);
46
- process.exitCode = 1;
47
- return;
48
- }
49
- process.stdout.write(formatToolResult(result, flags) + '\n');
28
+ await runBatchStatus(positionals.slice(1), values, status, flags);
50
29
  return;
51
30
  }
52
- const result = await handleUpdateStatus({
53
- specId,
54
- projectId,
55
- status: status,
56
- reviewNotes: values.notes ?? undefined,
57
- });
58
- if (result.isError) {
59
- process.stderr.write(`${red(formatToolResult(result, flags))}\n`);
60
- process.exitCode = 1;
61
- return;
62
- }
63
- const output = formatToolResult(result, flags);
64
- if (flags?.quiet) {
65
- if (output) {
66
- process.stdout.write(output + '\n');
67
- }
68
- }
69
- else {
70
- process.stdout.write(`${green('Status updated!')} ${output}\n`);
71
- }
31
+ await runSingleStatus(specId, values, status, flags);
72
32
  },
73
33
  };
34
+ function parseStatusArgs(args) {
35
+ const parsed = parseArgs({
36
+ args,
37
+ options: {
38
+ set: { type: 'string', short: 's' },
39
+ 'project-id': { type: 'string' },
40
+ 'project-path': { type: 'string' },
41
+ notes: { type: 'string', short: 'n' },
42
+ 'model-id': { type: 'string' },
43
+ 'model-tier-used': { type: 'string' },
44
+ 'context-hash': { type: 'string' },
45
+ 'handoff-path': { type: 'string' },
46
+ 'handoff-artifact-id': { type: 'string' },
47
+ 'reviewed-by': { type: 'string' },
48
+ 'arbitrated-by': { type: 'string' },
49
+ reason: { type: 'string' },
50
+ force: { type: 'boolean' },
51
+ 'force-status': { type: 'boolean' },
52
+ 'force-status-reason': { type: 'string' },
53
+ },
54
+ strict: false,
55
+ allowPositionals: true,
56
+ });
57
+ return { values: parsed.values, positionals: parsed.positionals };
58
+ }
59
+ function writeUsageError(message) {
60
+ process.stderr.write(`${red('Error:')} ${message}\nUsage: ${statusCommand.usage}\n`);
61
+ }
62
+ async function runBatchStatus(specIds, values, status, flags) {
63
+ const result = await handleUpdateStatusBatch({
64
+ specIds,
65
+ projectId: values['project-id'] ?? detectProjectId(),
66
+ status: status,
67
+ reviewNotes: values.notes,
68
+ });
69
+ writeToolResult(result, flags, false);
70
+ }
71
+ async function runSingleStatus(specId, values, status, flags) {
72
+ const result = await handleUpdateStatus({
73
+ specId,
74
+ projectId: values['project-id'] ?? detectProjectId(),
75
+ projectPath: values['project-path'],
76
+ status: status,
77
+ reviewNotes: values.notes,
78
+ modelId: values['model-id'],
79
+ modelTierUsed: values['model-tier-used'],
80
+ contextHash: values['context-hash'],
81
+ handoffPath: values['handoff-path'],
82
+ handoffArtifactId: values['handoff-artifact-id'],
83
+ reviewedBy: values['reviewed-by'],
84
+ arbitratedBy: values['arbitrated-by'],
85
+ reason: values.reason,
86
+ force: values.force === true ? true : undefined,
87
+ forceStatus: values['force-status'] === true ? true : undefined,
88
+ forceStatusReason: values['force-status-reason'],
89
+ });
90
+ writeToolResult(result, flags, true);
91
+ }
92
+ function writeToolResult(result, flags, includeSuccessPrefix) {
93
+ if (result.isError) {
94
+ process.stderr.write(`${red(formatToolResult(result, flags))}\n`);
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+ const output = formatToolResult(result, flags);
99
+ if (flags?.quiet) {
100
+ if (output) {
101
+ process.stdout.write(`${output}\n`);
102
+ }
103
+ return;
104
+ }
105
+ process.stdout.write(includeSuccessPrefix ? `${green('Status updated!')} ${output}\n` : `${output}\n`);
106
+ }
74
107
  //# sourceMappingURL=status.js.map
@@ -0,0 +1,4 @@
1
+ export declare class ApiKeyResolver {
2
+ resolveAnthropicKey(projectPath: string): Promise<string | undefined>;
3
+ }
4
+ //# sourceMappingURL=api-key-resolver.d.ts.map
@@ -0,0 +1,31 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export class ApiKeyResolver {
4
+ async resolveAnthropicKey(projectPath) {
5
+ const envKey = process.env.PLANU_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY;
6
+ if (envKey?.trim()) {
7
+ return envKey.trim();
8
+ }
9
+ for (const file of [
10
+ join(projectPath, 'src/config/api-keys.json'),
11
+ join(projectPath, '.planu/api-keys.json'),
12
+ ]) {
13
+ const key = await readKeyFile(file);
14
+ if (key) {
15
+ return key;
16
+ }
17
+ }
18
+ return undefined;
19
+ }
20
+ }
21
+ async function readKeyFile(path) {
22
+ try {
23
+ const parsed = JSON.parse(await readFile(path, 'utf-8'));
24
+ const key = parsed.anthropic?.apiKey;
25
+ return key?.trim() ? key.trim() : undefined;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ //# sourceMappingURL=api-key-resolver.js.map
@@ -1,2 +1,5 @@
1
1
  export { FallbackGenerator } from './fallback-generator.js';
2
+ export { ApiKeyResolver } from './api-key-resolver.js';
3
+ export { OpusGenerator } from './opus-generator.js';
4
+ export { QualityValidator } from './quality-validator.js';
2
5
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,5 @@
1
1
  export { FallbackGenerator } from './fallback-generator.js';
2
+ export { ApiKeyResolver } from './api-key-resolver.js';
3
+ export { OpusGenerator } from './opus-generator.js';
4
+ export { QualityValidator } from './quality-validator.js';
2
5
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,12 @@
1
+ import type { OpusGeneratorDeps, SpecContentGenerator, SpecGenerationRequest, SpecGenerationResult } from '../../types/index.js';
2
+ export declare class OpusGenerator implements SpecContentGenerator {
3
+ private readonly model;
4
+ private readonly timeoutMs;
5
+ private readonly client;
6
+ private readonly fallback;
7
+ private readonly validator;
8
+ constructor(deps: OpusGeneratorDeps);
9
+ generate(request: SpecGenerationRequest): Promise<SpecGenerationResult>;
10
+ private fallbackWithReason;
11
+ }
12
+ //# sourceMappingURL=opus-generator.d.ts.map
@@ -0,0 +1,97 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import { FallbackGenerator } from './fallback-generator.js';
3
+ import { QualityValidator } from './quality-validator.js';
4
+ export class OpusGenerator {
5
+ model;
6
+ timeoutMs;
7
+ client;
8
+ fallback;
9
+ validator;
10
+ constructor(deps) {
11
+ this.model = deps.model ?? 'claude-opus-4-5';
12
+ this.timeoutMs = deps.timeoutMs ?? 60_000;
13
+ this.client = deps.client ?? new Anthropic({ apiKey: deps.apiKey }).messages;
14
+ this.fallback = deps.fallback ?? new FallbackGenerator();
15
+ this.validator = deps.validator ?? new QualityValidator();
16
+ }
17
+ async generate(request) {
18
+ const controller = new AbortController();
19
+ const timeout = setTimeout(() => {
20
+ controller.abort();
21
+ }, this.timeoutMs);
22
+ try {
23
+ const response = await this.client.create({
24
+ model: this.model,
25
+ max_tokens: 6000,
26
+ temperature: 0.2,
27
+ system: buildSystemPrompt(),
28
+ messages: [{ role: 'user', content: buildUserPrompt(request) }],
29
+ }, { signal: controller.signal });
30
+ const text = response.content
31
+ .filter((block) => block.type === 'text' && typeof block.text === 'string')
32
+ .map((block) => block.text)
33
+ .join('\n')
34
+ .trim();
35
+ if (response.content.some((block) => block.type === 'tool_use') || text.length === 0) {
36
+ return await this.fallbackWithReason(request, 'tool-use-leak');
37
+ }
38
+ const result = parseGeneratedText(text, this.model);
39
+ const validation = this.validator.validate(result);
40
+ if (validation.passed) {
41
+ return result;
42
+ }
43
+ return { ...result, qualityWarnings: validation.warnings };
44
+ }
45
+ catch {
46
+ return await this.fallbackWithReason(request, 'timeout-or-http-error');
47
+ }
48
+ finally {
49
+ clearTimeout(timeout);
50
+ }
51
+ }
52
+ async fallbackWithReason(request, reason) {
53
+ const fallback = await this.fallback.generate(request);
54
+ return {
55
+ ...fallback,
56
+ qualityWarnings: [
57
+ ...fallback.qualityWarnings,
58
+ `Opus generator fallback used: ${reason}. Configure PLANU_ANTHROPIC_API_KEY for Opus-quality create_spec generation.`,
59
+ ],
60
+ fallbackReason: reason,
61
+ };
62
+ }
63
+ }
64
+ function buildSystemPrompt() {
65
+ return [
66
+ 'You generate Planu unified spec.md content.',
67
+ 'Do not invoke any tools. Do not request tool use. Return markdown only.',
68
+ 'Include ## Problem, ## Acceptance Criteria, ## Technical, ## Files, and ## Verification.',
69
+ 'Acceptance criteria must include at least 3 GIVEN/WHEN/THEN items.',
70
+ 'Technical must include at least 2 TypeScript signatures relevant to the requested change.',
71
+ ].join('\n');
72
+ }
73
+ function buildUserPrompt(request) {
74
+ return [
75
+ `Title: ${request.title}`,
76
+ `Type: ${request.type}`,
77
+ `Scope: ${request.scope}`,
78
+ `Target: ${request.target}`,
79
+ request.projectContext?.language ? `Language: ${request.projectContext.language}` : '',
80
+ request.projectContext?.framework ? `Framework: ${request.projectContext.framework}` : '',
81
+ '',
82
+ request.description,
83
+ ]
84
+ .filter(Boolean)
85
+ .join('\n');
86
+ }
87
+ function parseGeneratedText(text, model) {
88
+ const technicalMatch = /\n## Technical\n([\s\S]*?)(?=\n## |\s*$)/.exec(`\n${text}`);
89
+ return {
90
+ specBody: text,
91
+ technicalSection: technicalMatch?.[1]?.trim() ?? '',
92
+ generatedWithModel: model,
93
+ generatedAt: new Date().toISOString(),
94
+ qualityWarnings: [],
95
+ };
96
+ }
97
+ //# sourceMappingURL=opus-generator.js.map
@@ -0,0 +1,5 @@
1
+ import type { SpecGenerationResult, SpecQualityValidation } from '../../types/index.js';
2
+ export declare class QualityValidator {
3
+ validate(result: SpecGenerationResult): SpecQualityValidation;
4
+ }
5
+ //# sourceMappingURL=quality-validator.d.ts.map
@@ -0,0 +1,22 @@
1
+ export class QualityValidator {
2
+ validate(result) {
3
+ const text = `${result.specBody}\n${result.technicalSection}`;
4
+ const warnings = [];
5
+ for (const heading of ['## Problem', '## Acceptance Criteria', '## Technical']) {
6
+ if (!text.includes(heading)) {
7
+ warnings.push(`Missing required generated section: ${heading}`);
8
+ }
9
+ }
10
+ const bddCount = (text.match(/\bGIVEN\b[\s\S]*?\bWHEN\b[\s\S]*?\bTHEN\b/gi) ?? []).length;
11
+ if (bddCount < 3) {
12
+ warnings.push('Generated spec should include at least 3 GIVEN/WHEN/THEN criteria.');
13
+ }
14
+ const signatureCount = (result.technicalSection.match(/\b(?:export\s+)?(?:interface|type|class|function)\s+\w+/g) ??
15
+ []).length;
16
+ if (signatureCount < 2) {
17
+ warnings.push('Generated technical section should include at least 2 TypeScript signatures.');
18
+ }
19
+ return { passed: warnings.length === 0, warnings };
20
+ }
21
+ }
22
+ //# sourceMappingURL=quality-validator.js.map
@@ -25,7 +25,15 @@ export const PLANU_CANONICAL_POLICY = {
25
25
  'planu/specs/*/implementation-brief.md',
26
26
  'planu/specs/*/risk-register.md',
27
27
  ],
28
- legacyMergeBeforeDeleteFiles: ['technical.md', 'plan.md', 'PLAN.md', 'progress.md'],
28
+ legacyMergeBeforeDeleteFiles: [
29
+ 'technical.md',
30
+ 'plan.md',
31
+ 'PLAN.md',
32
+ 'progress.md',
33
+ 'HU.md',
34
+ 'FICHA-TECNICA.md',
35
+ 'PROGRESS.md',
36
+ ],
29
37
  };
30
38
  const ROOT_FILE_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootFiles);
31
39
  const ROOT_DIR_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootDirs);
@@ -22,7 +22,12 @@ async function gitRmCached(projectPath, relPath) {
22
22
  return;
23
23
  }
24
24
  try {
25
- await execFileAsync('git', ['rm', '--cached', '--quiet', '--ignore-unmatch', relPath], {
25
+ const args = ['rm', '--cached', '--quiet', '--ignore-unmatch'];
26
+ if (await pathIsDirectory(join(projectPath, relPath))) {
27
+ args.push('-r');
28
+ }
29
+ args.push(relPath);
30
+ await execFileAsync('git', args, {
26
31
  cwd: projectPath,
27
32
  timeout: 5_000,
28
33
  });
@@ -51,7 +56,11 @@ async function mergeLegacySpecFile(projectPath, specDir, fileName) {
51
56
  readFile(legacyPath, 'utf-8'),
52
57
  ]);
53
58
  const body = stripFrontmatter(legacyContent);
54
- const section = fileName === 'progress.md' ? 'Progress' : fileName === 'technical.md' ? 'Technical' : 'Files';
59
+ const section = fileName === 'progress.md' || fileName === 'PROGRESS.md'
60
+ ? 'Progress'
61
+ : fileName === 'technical.md' || fileName === 'FICHA-TECNICA.md'
62
+ ? 'Technical'
63
+ : 'Files';
55
64
  const merged = appendSectionIfMissing(specContent, section, body);
56
65
  if (merged !== specContent) {
57
66
  await atomicWriteFile(specPath, merged, {
@@ -19,7 +19,7 @@ import { generateLeanSpecContent } from '../engine/spec-format/lean-spec-generat
19
19
  import { generateLeanTechnicalContent } from '../engine/spec-format/lean-technical-generator.js';
20
20
  import { buildUnifiedSpecContent } from '../engine/spec-format/unified-spec-builder.js';
21
21
  import { validateEnglishOnlySpecText } from '../engine/spec-language/english-only.js';
22
- import { FallbackGenerator } from '../engine/spec-generator/index.js';
22
+ import { ApiKeyResolver, FallbackGenerator, OpusGenerator, } from '../engine/spec-generator/index.js';
23
23
  import { analyzeProjectForSpec, getEmptyAutopilotResult, } from './create-spec/autopilot-analyzer.js';
24
24
  import { AutopilotSummaryCollector } from '../engine/autopilot/summary-collector.js';
25
25
  import { trackCost } from '../engine/cost-tracking/operation-tracker.js';
@@ -535,7 +535,10 @@ export async function handleCreateSpec(inputParams, server) {
535
535
  .filter(Boolean)
536
536
  .join('\n')
537
537
  : '';
538
- const specGenerator = new FallbackGenerator();
538
+ const anthropicKey = await new ApiKeyResolver().resolveAnthropicKey(params.projectPath ?? '');
539
+ const specGenerator = anthropicKey !== undefined
540
+ ? new OpusGenerator({ apiKey: anthropicKey })
541
+ : new FallbackGenerator();
539
542
  const generatedSpec = await measureStep('generateSpecBody', () => specGenerator.generate({
540
543
  title: spec.title,
541
544
  description: `${description}${contractNote}`,
@@ -17,4 +17,21 @@ export interface CliCommand {
17
17
  /** Execute the command with parsed args */
18
18
  run(args: string[], flags?: GlobalFlags): Promise<void>;
19
19
  }
20
+ export interface StatusCommandValues {
21
+ set?: string;
22
+ 'project-id'?: string;
23
+ 'project-path'?: string;
24
+ notes?: string;
25
+ 'model-id'?: string;
26
+ 'model-tier-used'?: string;
27
+ 'context-hash'?: string;
28
+ 'handoff-path'?: string;
29
+ 'handoff-artifact-id'?: string;
30
+ 'reviewed-by'?: string;
31
+ 'arbitrated-by'?: string;
32
+ reason?: string;
33
+ force?: boolean;
34
+ 'force-status'?: boolean;
35
+ 'force-status-reason'?: string;
36
+ }
20
37
  //# sourceMappingURL=cli.d.ts.map
@@ -23,4 +23,42 @@ export interface SpecGenerationResult {
23
23
  export interface SpecContentGenerator {
24
24
  generate(request: SpecGenerationRequest): Promise<SpecGenerationResult>;
25
25
  }
26
+ export interface SpecQualityValidation {
27
+ passed: boolean;
28
+ warnings: string[];
29
+ }
30
+ export interface SpecGeneratorMessagesClient {
31
+ create(body: {
32
+ model: string;
33
+ max_tokens: number;
34
+ temperature: number;
35
+ system: string;
36
+ messages: {
37
+ role: 'user';
38
+ content: string;
39
+ }[];
40
+ }, options?: {
41
+ signal?: AbortSignal;
42
+ }): Promise<{
43
+ content: {
44
+ type: string;
45
+ text?: string;
46
+ }[];
47
+ }>;
48
+ }
49
+ export interface OpusGeneratorDeps {
50
+ apiKey: string;
51
+ model?: string;
52
+ timeoutMs?: number;
53
+ client?: SpecGeneratorMessagesClient;
54
+ fallback?: SpecContentGenerator;
55
+ validator?: {
56
+ validate(result: SpecGenerationResult): SpecQualityValidation;
57
+ };
58
+ }
59
+ export interface ApiKeyConfig {
60
+ anthropic?: {
61
+ apiKey?: string;
62
+ };
63
+ }
26
64
  //# sourceMappingURL=spec-generator.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.3.10",
3
+ "version": "4.3.12",
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.3.10",
36
- "@planu/core-darwin-x64": "4.3.10",
37
- "@planu/core-linux-arm64-gnu": "4.3.10",
38
- "@planu/core-linux-arm64-musl": "4.3.10",
39
- "@planu/core-linux-x64-gnu": "4.3.10",
40
- "@planu/core-linux-x64-musl": "4.3.10",
41
- "@planu/core-win32-arm64-msvc": "4.3.10",
42
- "@planu/core-win32-x64-msvc": "4.3.10"
35
+ "@planu/core-darwin-arm64": "4.3.12",
36
+ "@planu/core-darwin-x64": "4.3.12",
37
+ "@planu/core-linux-arm64-gnu": "4.3.12",
38
+ "@planu/core-linux-arm64-musl": "4.3.12",
39
+ "@planu/core-linux-x64-gnu": "4.3.12",
40
+ "@planu/core-linux-x64-musl": "4.3.12",
41
+ "@planu/core-win32-arm64-msvc": "4.3.12",
42
+ "@planu/core-win32-x64-msvc": "4.3.12"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=24.0.0"
@@ -127,6 +127,7 @@
127
127
  ],
128
128
  "license": "SEE LICENSE IN LICENSE",
129
129
  "dependencies": {
130
+ "@anthropic-ai/sdk": "^0.98.0",
130
131
  "@modelcontextprotocol/sdk": "^1.29.0",
131
132
  "glob": "^13.0.6",
132
133
  "yaml": "^2.9.0",
@@ -178,7 +179,7 @@
178
179
  "@semantic-release/release-notes-generator": "^14.1.1",
179
180
  "@stryker-mutator/core": "^9.6.1",
180
181
  "@stryker-mutator/vitest-runner": "^9.6.1",
181
- "@supabase/supabase-js": "^2.106.1",
182
+ "@supabase/supabase-js": "^2.106.2",
182
183
  "@types/node": "^25.9.1",
183
184
  "@vitejs/plugin-vue": "^6.0.7",
184
185
  "@vitest/coverage-v8": "^4.1.7",