@planu/cli 4.11.3 → 4.11.5

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,16 @@
1
+ ## [4.11.5] - 2026-07-18
2
+
3
+ ### Bug Fixes
4
+ - fix: close weekly technical debt backlog
5
+
6
+
7
+ ## [4.11.4] - 2026-07-18
8
+
9
+ ### Bug Fixes
10
+ - fix(planu): unblock challenge gate and record debt specs
11
+ - fix(release): harden local recovery environment
12
+
13
+
1
14
  ## [4.11.3] - 2026-07-10
2
15
 
3
16
  ### Bug Fixes
@@ -58,12 +58,19 @@ function buildSddSection(options) {
58
58
  '',
59
59
  '### Architecture Rules',
60
60
  '',
61
+ '- Primary implementation stays in `src/`; Rust is an optional acceleration layer, not the default destination for every feature',
61
62
  '- `types/` → no imports from other layers',
62
63
  '- `engine/` → imports only from `types/`',
63
64
  '- `storage/` → imports only from `types/`',
64
65
  '- `tools/` → imports from `engine/` + `storage/` + `types/`',
65
66
  '- Cross-layer violations → ESLint error',
66
67
  '',
68
+ '### Release Rules',
69
+ '',
70
+ '- `main` is the authoritative release branch',
71
+ '- `develop` and `release` are optional mirrors that must not drift from `main`',
72
+ '- Run `pnpm check` and `pnpm test` before local release publishing',
73
+ '',
67
74
  PLANU_SECTION_END,
68
75
  ].join('\n');
69
76
  }
@@ -6,6 +6,8 @@ const FILLER_PATTERNS = [
6
6
  /\bhandle errors\b/i,
7
7
  /\bto be determined\b/i,
8
8
  ];
9
+ const MANUAL_VERIFICATION_STEP_RE = /\b(given|when|then|step|observe|click|run)\b/i;
10
+ const CONCRETE_MANUAL_EVIDENCE_RE = /`[^`]+`|\b(?:src|tests|docs|scripts|website|planu)\/[\w./-]+/i;
9
11
  export function evaluateImplementationContract(specBody, acceptanceCriteria) {
10
12
  const contract = extractTopLevelSection(specBody, IMPLEMENTATION_CONTRACT_SECTION);
11
13
  if (contract.trim().length === 0) {
@@ -62,7 +64,8 @@ export function evaluateImplementationContract(specBody, acceptanceCriteria) {
62
64
  }
63
65
  }
64
66
  if (/\bmanual verification\b/i.test(map) &&
65
- !/\b(given|when|then|step|observe|click|run)\b/i.test(map)) {
67
+ !MANUAL_VERIFICATION_STEP_RE.test(map) &&
68
+ !CONCRETE_MANUAL_EVIDENCE_RE.test(map)) {
66
69
  issues.push({
67
70
  code: 'contract_manual_verification_vague',
68
71
  message: 'Manual verification in the Implementation Contract must include exact observable steps.',
@@ -1,4 +1,4 @@
1
- import type { OutboundWebhookPayload, OutboundWebhookEventType, OutboundWebhookConfig, OutboundWebhookDelivery } from '../types/index.js';
2
- export declare function dispatchToWebhooks(webhooks: readonly OutboundWebhookConfig[], payload: OutboundWebhookPayload, onDelivery: (delivery: OutboundWebhookDelivery) => Promise<void>): Promise<void>;
1
+ import type { OutboundWebhookPayload, OutboundWebhookEventType, OutboundWebhookConfig, OutboundWebhookDelivery, WebhookDispatchOptions } from '../types/index.js';
2
+ export declare function dispatchToWebhooks(webhooks: readonly OutboundWebhookConfig[], payload: OutboundWebhookPayload, onDelivery: (delivery: OutboundWebhookDelivery) => Promise<void>, options?: WebhookDispatchOptions): Promise<void>;
3
3
  export declare function buildSpecLifecyclePayload(event: OutboundWebhookEventType, specId: string, specTitle: string, status: string, projectPath: string): OutboundWebhookPayload;
4
4
  //# sourceMappingURL=outbound-webhook-dispatcher.d.ts.map
@@ -40,13 +40,15 @@ async function attemptDelivery(url, body, signature) {
40
40
  // Retry logic: 3 attempts with exponential backoff (1s, 5s, 30s)
41
41
  // ---------------------------------------------------------------------------
42
42
  const RETRY_DELAYS_MS = [1_000, 5_000, 30_000];
43
- async function deliverWithRetry(webhook, body, payload, onDelivery) {
43
+ async function deliverWithRetry(webhook, body, payload, onDelivery, options) {
44
44
  let lastResult = null;
45
45
  const signature = signPayload(webhook.secret, body);
46
- for (let attempt = 0; attempt < 3; attempt++) {
46
+ const retryDelays = options.retryDelaysMs ?? RETRY_DELAYS_MS;
47
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
48
+ for (let attempt = 0; attempt < retryDelays.length + 1; attempt++) {
47
49
  if (attempt > 0) {
48
- const delay = RETRY_DELAYS_MS[attempt - 1] ?? 1_000;
49
- await new Promise((resolve) => setTimeout(resolve, delay));
50
+ const delay = retryDelays[attempt - 1] ?? 0;
51
+ await sleep(delay);
50
52
  }
51
53
  lastResult = await attemptDelivery(webhook.url, body, signature);
52
54
  if (lastResult.success) {
@@ -71,12 +73,12 @@ async function deliverWithRetry(webhook, body, payload, onDelivery) {
71
73
  // ---------------------------------------------------------------------------
72
74
  // Core dispatch (pure — no storage deps)
73
75
  // ---------------------------------------------------------------------------
74
- export async function dispatchToWebhooks(webhooks, payload, onDelivery) {
76
+ export async function dispatchToWebhooks(webhooks, payload, onDelivery, options = {}) {
75
77
  if (webhooks.length === 0) {
76
78
  return;
77
79
  }
78
80
  const body = JSON.stringify(payload);
79
- await Promise.allSettled(webhooks.map((webhook) => deliverWithRetry(webhook, body, payload, onDelivery)));
81
+ await Promise.allSettled(webhooks.map((webhook) => deliverWithRetry(webhook, body, payload, onDelivery, options)));
80
82
  }
81
83
  // ---------------------------------------------------------------------------
82
84
  // Helper to build spec lifecycle payloads
@@ -1,4 +1,4 @@
1
- import type { DiscoveredDocsEntry, DocsRegistryHealth } from '../../types/index.js';
1
+ import type { DiscoveredDocsEntry, DocsRegistryHealth, DocsDiscoveryOptions } from '../../types/index.js';
2
2
  /**
3
3
  * Verify that a URL is alive with a HEAD request (timeout 3s).
4
4
  * Returns true if status is 200, 301, or 302.
@@ -9,7 +9,7 @@ export declare function validateRegistryUrl(url: string): Promise<boolean>;
9
9
  * Respects a 1 req/s rate limit per registry via simple delay.
10
10
  * Returns null if not found anywhere.
11
11
  */
12
- export declare function discoverDocsUrl(tech: string): Promise<DiscoveredDocsEntry | null>;
12
+ export declare function discoverDocsUrl(tech: string, options?: DocsDiscoveryOptions): Promise<DiscoveredDocsEntry | null>;
13
13
  /**
14
14
  * Batch validate all entries in the docs registry.
15
15
  * Categorizes each entry as healthy (<=2s), slow (2-5s), or broken (timeout/error).
@@ -113,10 +113,14 @@ export async function validateRegistryUrl(url) {
113
113
  * Respects a 1 req/s rate limit per registry via simple delay.
114
114
  * Returns null if not found anywhere.
115
115
  */
116
- export async function discoverDocsUrl(tech) {
117
- const tryWithDelay = async (fn) => {
116
+ export async function discoverDocsUrl(tech, options = {}) {
117
+ const rateLimitDelayMs = options.rateLimitDelayMs ?? RATE_LIMIT_DELAY_MS;
118
+ const sleepFn = options.sleep ?? sleep;
119
+ const tryWithDelay = async (fn, shouldDelay) => {
118
120
  const result = await fn();
119
- await sleep(RATE_LIMIT_DELAY_MS);
121
+ if (shouldDelay && rateLimitDelayMs > 0) {
122
+ await sleepFn(rateLimitDelayMs);
123
+ }
120
124
  return result;
121
125
  };
122
126
  const registries = [
@@ -124,8 +128,8 @@ export async function discoverDocsUrl(tech) {
124
128
  { source: 'pypi', fn: () => tryPypi(tech) },
125
129
  { source: 'crates', fn: () => tryCrates(tech) },
126
130
  ];
127
- for (const { source, fn } of registries) {
128
- const url = await tryWithDelay(fn);
131
+ for (const [index, { source, fn }] of registries.entries()) {
132
+ const url = await tryWithDelay(fn, index < registries.length - 1);
129
133
  if (url) {
130
134
  const verified = await validateRegistryUrl(url);
131
135
  return {
@@ -1,5 +1,7 @@
1
1
  import type { ChallengeReport, ChallengeResolutionEvidence, FailureScenario } from '../../types/index.js';
2
2
  export declare const MINIMUM_RESOLVED_CHALLENGES = 3;
3
+ export declare function requiredResolvedChallenges(totalScenarios: number): number;
4
+ export declare function parseChallengeResolutionEvidence(specContent: string, scenarios: readonly FailureScenario[], runAt: string): ChallengeResolutionEvidence[];
3
5
  /** Count only evidence tied to findings persisted by the same challenge report. */
4
6
  export declare function getResolvedChallengeEvidence(report: ChallengeReport | undefined): ChallengeResolutionEvidence[];
5
7
  /** Build a report whose resolution state is derived exclusively from explicit evidence. */
@@ -8,6 +10,7 @@ export declare function buildChallengeReport(input: {
8
10
  focusAreas: string[];
9
11
  overallRisk: ChallengeReport['overallRisk'];
10
12
  previousReport?: ChallengeReport;
13
+ explicitResolutionEvidence?: ChallengeResolutionEvidence[];
11
14
  runAt?: string;
12
15
  }): ChallengeReport;
13
16
  //# sourceMappingURL=challenge-report.d.ts.map
@@ -1,4 +1,7 @@
1
1
  export const MINIMUM_RESOLVED_CHALLENGES = 3;
2
+ export function requiredResolvedChallenges(totalScenarios) {
3
+ return Math.min(MINIMUM_RESOLVED_CHALLENGES, Math.max(0, totalScenarios));
4
+ }
2
5
  function isExplicitResolutionEvidence(value) {
3
6
  if (typeof value !== 'object' || value === null) {
4
7
  return false;
@@ -12,16 +15,72 @@ function isExplicitResolutionEvidence(value) {
12
15
  typeof candidate.resolvedAt === 'string' &&
13
16
  candidate.resolvedAt.trim().length > 0);
14
17
  }
15
- function uniqueMatchingEvidence(report, scenarioNames) {
16
- const evidence = Array.isArray(report?.resolutionEvidence) ? report.resolutionEvidence : [];
18
+ function uniqueMatchingEvidence(evidence, scenarioNames) {
17
19
  const matched = new Map();
18
- for (const item of evidence) {
20
+ for (const item of evidence ?? []) {
19
21
  if (isExplicitResolutionEvidence(item) && scenarioNames.has(item.scenario)) {
20
22
  matched.set(item.scenario, item);
21
23
  }
22
24
  }
23
25
  return [...matched.values()];
24
26
  }
27
+ function inferResolution(text) {
28
+ return /\b(accepted|accept risk|accepted risk|known risk)\b/i.test(text)
29
+ ? 'accepted'
30
+ : 'mitigated';
31
+ }
32
+ function extractChallengeResolutionSection(specContent) {
33
+ const normalized = specContent.replace(/\r\n/g, '\n');
34
+ const headingRe = /^###\s+Challenge Resolution[ \t]*$/m;
35
+ const match = headingRe.exec(normalized);
36
+ if (!match) {
37
+ return '';
38
+ }
39
+ const start = match.index + match[0].length;
40
+ const nextRe = /^###\s+\S/gm;
41
+ nextRe.lastIndex = start;
42
+ const next = nextRe.exec(normalized);
43
+ return normalized.slice(start, next ? next.index : normalized.length).trim();
44
+ }
45
+ export function parseChallengeResolutionEvidence(specContent, scenarios, runAt) {
46
+ const section = extractChallengeResolutionSection(specContent);
47
+ if (section.length === 0 || scenarios.length === 0) {
48
+ return [];
49
+ }
50
+ if (scenarios.length === 1) {
51
+ const [singleScenario] = scenarios;
52
+ if (!singleScenario) {
53
+ return [];
54
+ }
55
+ return [
56
+ {
57
+ scenario: singleScenario.scenario,
58
+ resolution: inferResolution(section),
59
+ evidence: section.replace(/\s+/g, ' ').trim(),
60
+ resolvedAt: runAt,
61
+ },
62
+ ];
63
+ }
64
+ const bullets = section
65
+ .split('\n')
66
+ .map((line) => line.trim())
67
+ .filter((line) => /^-\s+/.test(line))
68
+ .map((line) => line.replace(/^-\s+/, '').trim());
69
+ const evidence = [];
70
+ for (const bullet of bullets) {
71
+ const scenario = scenarios.find((item) => bullet.includes(item.scenario));
72
+ if (!scenario) {
73
+ continue;
74
+ }
75
+ evidence.push({
76
+ scenario: scenario.scenario,
77
+ resolution: inferResolution(bullet),
78
+ evidence: bullet,
79
+ resolvedAt: runAt,
80
+ });
81
+ }
82
+ return evidence;
83
+ }
25
84
  /** Count only evidence tied to findings persisted by the same challenge report. */
26
85
  export function getResolvedChallengeEvidence(report) {
27
86
  if (!Array.isArray(report?.findings)) {
@@ -30,21 +89,27 @@ export function getResolvedChallengeEvidence(report) {
30
89
  const scenarioNames = new Set(report.findings
31
90
  .map((finding) => finding.scenario)
32
91
  .filter((scenario) => typeof scenario === 'string' && scenario.trim().length > 0));
33
- return uniqueMatchingEvidence(report, scenarioNames);
92
+ return uniqueMatchingEvidence(report.resolutionEvidence, scenarioNames);
34
93
  }
35
94
  /** Build a report whose resolution state is derived exclusively from explicit evidence. */
36
95
  export function buildChallengeReport(input) {
37
96
  const scenarioNames = new Set(input.scenarios.map((scenario) => scenario.scenario));
38
- const resolutionEvidence = uniqueMatchingEvidence(input.previousReport, scenarioNames);
97
+ const resolutionEvidence = uniqueMatchingEvidence([
98
+ ...(Array.isArray(input.previousReport?.resolutionEvidence)
99
+ ? input.previousReport.resolutionEvidence
100
+ : []),
101
+ ...(Array.isArray(input.explicitResolutionEvidence) ? input.explicitResolutionEvidence : []),
102
+ ], scenarioNames);
39
103
  const resolvedScenarios = new Set(resolutionEvidence.map((item) => item.scenario));
40
104
  const addressedCount = resolvedScenarios.size;
105
+ const requiredCount = requiredResolvedChallenges(input.scenarios.length);
41
106
  return {
42
107
  runAt: input.runAt ?? new Date().toISOString(),
43
108
  totalScenarios: input.scenarios.length,
44
109
  addressedCount,
45
110
  focusAreas: input.focusAreas,
46
111
  overallRisk: input.overallRisk,
47
- passed: addressedCount >= MINIMUM_RESOLVED_CHALLENGES,
112
+ passed: addressedCount >= requiredCount,
48
113
  findings: input.scenarios.map((scenario) => ({
49
114
  scenario: scenario.scenario,
50
115
  resolved: resolvedScenarios.has(scenario.scenario),
@@ -18,7 +18,7 @@ import { calculateTokenBudget, injectBudgetIntoPrompt } from '../engine/token-bu
18
18
  import { analyzeMinimalImplementation, loadMinimalImplementationPolicy, } from '../engine/minimality/index.js';
19
19
  import { detectChallengeCapabilities, } from './challenge-spec/scenarios-utils.js';
20
20
  import { collectCapabilityScenarios } from './challenge-spec/scenario-collector.js';
21
- import { buildChallengeReport } from './challenge-spec/challenge-report.js';
21
+ import { buildChallengeReport, parseChallengeResolutionEvidence, } from './challenge-spec/challenge-report.js';
22
22
  const ALL_FOCUS_AREAS = [
23
23
  'failures',
24
24
  'concurrency',
@@ -194,11 +194,15 @@ export async function handleChallengeSpec(args, server) {
194
194
  scalabilityAssessment,
195
195
  overallRisk,
196
196
  };
197
+ const challengeRunAt = new Date().toISOString();
198
+ const explicitResolutionEvidence = parseChallengeResolutionEvidence(specContent, failureScenariosScored, challengeRunAt);
197
199
  const challengeReport = buildChallengeReport({
198
200
  scenarios: failureScenariosScored,
199
201
  focusAreas,
200
202
  overallRisk,
201
203
  previousReport: spec.challengeReport,
204
+ explicitResolutionEvidence,
205
+ runAt: challengeRunAt,
202
206
  });
203
207
  const rawHumanSummary = buildChallengeSpecSummary(prioritized, overallRisk);
204
208
  // SPEC-620: Inject token budget tag into the LLM-facing summary prompt
@@ -76,7 +76,7 @@ export declare function checkReadinessGate(spec: Spec, newStatus: SpecStatus, fo
76
76
  * or does not contain enough explicit resolution evidence.
77
77
  *
78
78
  * - Requires challengeReport to exist on the spec
79
- * - Requires at least 3 scenarios with explicit mitigation/risk-acceptance evidence
79
+ * - Requires explicit mitigation/risk-acceptance evidence for up to the first 3 findings
80
80
  * - High-risk specs (high/critical) require all 5 focus areas
81
81
  */
82
82
  export declare function checkChallengeGate(spec: Spec, newStatus: SpecStatus): ToolResult | null;
@@ -9,7 +9,7 @@ import { validateEnglishOnlySpecText } from '../../engine/spec-language/english-
9
9
  import { checkGroundedSpecContract } from '../../engine/spec-grounding/contract.js';
10
10
  import { checkGenericSpecOutput } from '../../engine/spec-quality/generic-output-gate.js';
11
11
  import { formatKeyValue } from '../output-formatter.js';
12
- import { getResolvedChallengeEvidence, MINIMUM_RESOLVED_CHALLENGES, } from '../challenge-spec/challenge-report.js';
12
+ import { getResolvedChallengeEvidence, requiredResolvedChallenges, } from '../challenge-spec/challenge-report.js';
13
13
  /**
14
14
  * Valid state transitions for spec lifecycle.
15
15
  * draft -> review -> approved -> implementing -> done
@@ -436,7 +436,7 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
436
436
  * or does not contain enough explicit resolution evidence.
437
437
  *
438
438
  * - Requires challengeReport to exist on the spec
439
- * - Requires at least 3 scenarios with explicit mitigation/risk-acceptance evidence
439
+ * - Requires explicit mitigation/risk-acceptance evidence for up to the first 3 findings
440
440
  * - High-risk specs (high/critical) require all 5 focus areas
441
441
  */
442
442
  export function checkChallengeGate(spec, newStatus) {
@@ -462,12 +462,16 @@ export function checkChallengeGate(spec, newStatus) {
462
462
  }
463
463
  const resolutionEvidence = getResolvedChallengeEvidence(report);
464
464
  const addressedCount = resolutionEvidence.length;
465
- if (addressedCount < MINIMUM_RESOLVED_CHALLENGES) {
465
+ const findingsCount = Array.isArray(report.findings)
466
+ ? report.findings.length
467
+ : report.totalScenarios;
468
+ const requiredCount = requiredResolvedChallenges(findingsCount);
469
+ if (addressedCount < requiredCount) {
466
470
  return {
467
471
  content: [
468
472
  {
469
473
  type: 'text',
470
- text: `Challenge gate blocked: only ${String(addressedCount)} scenario(s) have explicit resolution evidence (minimum ${String(MINIMUM_RESOLVED_CHALLENGES)} required). Record mitigation or accepted-risk evidence for discovered findings before retrying.`,
474
+ text: `Challenge gate blocked: only ${String(addressedCount)} scenario(s) have explicit resolution evidence (minimum ${String(requiredCount)} required). Record mitigation or accepted-risk evidence for discovered findings before retrying.`,
471
475
  },
472
476
  ],
473
477
  isError: true,
@@ -475,7 +479,7 @@ export function checkChallengeGate(spec, newStatus) {
475
479
  error: 'CHALLENGE_GATE_BLOCKED',
476
480
  code: 'INSUFFICIENT_CHALLENGES',
477
481
  addressedCount,
478
- requiredCount: MINIMUM_RESOLVED_CHALLENGES,
482
+ requiredCount,
479
483
  fixHint: `Resolve challenge_spec findings with concrete evidence, then retry update_status(specId="${spec.id}", status="review").`,
480
484
  },
481
485
  };
@@ -285,6 +285,10 @@ export interface DocsRegistryHealth {
285
285
  broken: string[];
286
286
  slow: string[];
287
287
  }
288
+ export interface DocsDiscoveryOptions {
289
+ rateLimitDelayMs?: number;
290
+ sleep?: (ms: number) => Promise<void>;
291
+ }
288
292
  /** Input for validate_docs_registry tool. */
289
293
  export interface ValidateDocsRegistryInput {
290
294
  projectPath: string;
@@ -48,4 +48,8 @@ export interface WebhookAttemptResult {
48
48
  readonly success: boolean;
49
49
  readonly error?: string;
50
50
  }
51
+ export interface WebhookDispatchOptions {
52
+ readonly retryDelaysMs?: readonly number[];
53
+ readonly sleep?: (ms: number) => Promise<void>;
54
+ }
51
55
  //# sourceMappingURL=outbound-webhook.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.11.3",
3
+ "version": "4.11.5",
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",
@@ -34,14 +34,14 @@
34
34
  "packageName": "@planu/core"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@planu/core-darwin-arm64": "4.11.3",
38
- "@planu/core-darwin-x64": "4.11.3",
39
- "@planu/core-linux-arm64-gnu": "4.11.3",
40
- "@planu/core-linux-arm64-musl": "4.11.3",
41
- "@planu/core-linux-x64-gnu": "4.11.3",
42
- "@planu/core-linux-x64-musl": "4.11.3",
43
- "@planu/core-win32-arm64-msvc": "4.11.3",
44
- "@planu/core-win32-x64-msvc": "4.11.3"
37
+ "@planu/core-darwin-arm64": "4.11.5",
38
+ "@planu/core-darwin-x64": "4.11.5",
39
+ "@planu/core-linux-arm64-gnu": "4.11.5",
40
+ "@planu/core-linux-arm64-musl": "4.11.5",
41
+ "@planu/core-linux-x64-gnu": "4.11.5",
42
+ "@planu/core-linux-x64-musl": "4.11.5",
43
+ "@planu/core-win32-arm64-msvc": "4.11.5",
44
+ "@planu/core-win32-x64-msvc": "4.11.5"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.0.0"
package/planu-native.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dev.planu.native",
3
3
  "displayName": "Planu Native Lightweight Surface",
4
- "version": "4.11.3",
4
+ "version": "4.11.5",
5
5
  "packageName": "@planu/cli",
6
6
  "modes": {
7
7
  "lightweight": {
package/planu-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dev.planu.cli",
3
3
  "displayName": "Planu — Spec Driven Development",
4
4
  "description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
5
- "version": "4.11.3",
5
+ "version": "4.11.5",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": [
8
8
  "npx",