@planu/cli 4.11.4 → 4.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +2 -2
  3. package/dist/engine/actuals/progress-parser.js +4 -1
  4. package/dist/engine/ai-integration/gemini/gemini-md-generator.js +7 -0
  5. package/dist/engine/detectors/idp-detector.js +1 -1
  6. package/dist/engine/evidence-gates/lifecycle-gate.js +8 -4
  7. package/dist/engine/evidence-index/index-builder.js +6 -3
  8. package/dist/engine/github/quality-dimensions.js +4 -1
  9. package/dist/engine/hooks/event-bus.js +1 -1
  10. package/dist/engine/lifecycle-hints.js +5 -5
  11. package/dist/engine/outbound-webhook-dispatcher.d.ts +2 -2
  12. package/dist/engine/outbound-webhook-dispatcher.js +8 -6
  13. package/dist/engine/qa-gate.d.ts +1 -0
  14. package/dist/engine/qa-gate.js +69 -3
  15. package/dist/engine/spec-repair.js +1 -1
  16. package/dist/engine/token-optimizer/response-cache.js +4 -1
  17. package/dist/engine/validator/deep-code-checker.js +4 -1
  18. package/dist/engine/validator.js +1 -0
  19. package/dist/engine/web-fetcher/registry-auto-discovery.d.ts +2 -2
  20. package/dist/engine/web-fetcher/registry-auto-discovery.js +9 -5
  21. package/dist/storage/path-resolver.js +0 -5
  22. package/dist/storage/qa-gate-store.js +1 -1
  23. package/dist/tools/challenge-spec.js +0 -4
  24. package/dist/tools/create-spec/auto-pipeline.js +40 -16
  25. package/dist/tools/create-spec.js +4 -14
  26. package/dist/tools/init-project/agents-md-writer.js +2 -2
  27. package/dist/tools/init-project/helpers.js +1 -1
  28. package/dist/tools/init-project/planu-workflow-generator.js +2 -2
  29. package/dist/tools/resolve-project-path.js +12 -1
  30. package/dist/tools/update-status/index.js +11 -25
  31. package/dist/tools/update-status/qa-gate.d.ts +0 -1
  32. package/dist/tools/update-status/qa-gate.js +16 -3
  33. package/dist/tools/update-status/transition-guard.js +0 -8
  34. package/dist/tools/update-status-convention-gate.js +16 -7
  35. package/dist/tools/validate.js +1 -7
  36. package/dist/types/docs.d.ts +4 -0
  37. package/dist/types/outbound-webhook.d.ts +4 -0
  38. package/dist/types/qa-gate.d.ts +2 -0
  39. package/package.json +9 -9
  40. package/planu-native.json +1 -1
  41. package/planu-plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [4.11.6] - 2026-07-18
2
+
3
+ ### Bug Fixes
4
+ - fix: tighten SDD gates and release readiness
5
+ - fix: allow routine dependency drift during pre-push
6
+
7
+ ### Chores
8
+ - chore: finalize weekly debt spec state
9
+
10
+
11
+ ## [4.11.5] - 2026-07-18
12
+
13
+ ### Bug Fixes
14
+ - fix: close weekly technical debt backlog
15
+
16
+
1
17
  ## [4.11.4] - 2026-07-18
2
18
 
3
19
  ### Bug Fixes
package/README.md CHANGED
@@ -57,8 +57,8 @@ Every transition is gated by Definition of Ready (DoR) and Definition of Done (D
57
57
 
58
58
  Planu runs follow-up actions automatically after each step:
59
59
 
60
- - `create_spec` automatically runs `challenge_spec` + `check_readiness`.
61
- - `update_status(done)` automatically runs `validate` + scans crash risks + **freezes the spec**.
60
+ - `create_spec` runs a bounded post-create analysis and surfaces challenge/readiness signals when they complete in time.
61
+ - `update_status(done)` runs `validate`, writes implementation-review evidence, then scans crash risks + **freezes the spec** before finalizing.
62
62
  - `update_status(approved)` automatically snapshots the spec version.
63
63
 
64
64
  ---
@@ -1,3 +1,6 @@
1
+ function escapeRegex(input) {
2
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
1
4
  /**
2
5
  * Parses progress.md content and extracts any actual metrics found.
3
6
  *
@@ -45,7 +48,7 @@ export function parseProgressActuals(content) {
45
48
  * Returns undefined if the actual column is `-` or missing.
46
49
  */
47
50
  function parseMetricRow(content, metricName) {
48
- const escapedName = RegExp.escape(metricName);
51
+ const escapedName = escapeRegex(metricName);
49
52
  const pattern = new RegExp(`\\|\\s*${escapedName}[^|]*\\|[^|]*\\|\\s*([^|]+)\\|`, 'i');
50
53
  const match = pattern.exec(content);
51
54
  if (!match?.[1]) {
@@ -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
  }
@@ -121,6 +121,6 @@ function extractYamlField(content, fieldPath) {
121
121
  return value.replace(/^['"]|['"]$/g, '');
122
122
  }
123
123
  function escapeRegex(str) {
124
- return RegExp.escape(str);
124
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
125
125
  }
126
126
  //# sourceMappingURL=idp-detector.js.map
@@ -22,19 +22,23 @@ function isNonTrivial(spec) {
22
22
  }
23
23
  function normalizeCriterion(value) {
24
24
  const withoutCheckbox = value.replace(/^-\s*\[[ x]\]\s*/i, '').trim();
25
- const criterionOnly = /^(AC\d+)\s*[:.)-]?\s*$/i.exec(withoutCheckbox)?.[1];
25
+ const criterionOnly = /^([A-Z]{2}\d+)\s*[:.)-]?\s*$/i.exec(withoutCheckbox)?.[1];
26
26
  if (criterionOnly) {
27
27
  return criterionOnly.toLowerCase();
28
28
  }
29
29
  return withoutCheckbox
30
- .replace(/^AC\d+\s*[:.)-]?\s*/i, '')
30
+ .replace(/^[A-Z]{2}\d+\s*[:.)-]?\s*/i, '')
31
31
  .replace(/\s+/g, ' ')
32
32
  .trim()
33
33
  .toLowerCase();
34
34
  }
35
35
  function criterionAliases(value, index) {
36
- const aliases = new Set([normalizeCriterion(value), `ac${String(index + 1)}`]);
37
- const explicit = /^(AC\d+)\b/i.exec(value)?.[1];
36
+ const aliases = new Set([
37
+ normalizeCriterion(value),
38
+ `ac${String(index + 1)}`,
39
+ `ab${String(index + 1)}`,
40
+ ]);
41
+ const explicit = /^([A-Z]{2}\d+)\b/i.exec(value)?.[1];
38
42
  if (explicit) {
39
43
  aliases.add(normalizeCriterion(explicit));
40
44
  }
@@ -108,7 +108,10 @@ async function pathRecord(kind, value, source, args, fieldName) {
108
108
  .then(() => true)
109
109
  .catch(() => false);
110
110
  const missingReason = evidencePathReason(fieldName, `Missing path: ${value}`);
111
- return record(kind, value, exists ? 'valid' : 'stale', source, exists ? undefined : missingReason);
111
+ return record(kind, value, exists ? 'valid' : missingPathStatus(fieldName), source, exists ? undefined : missingReason);
112
+ }
113
+ function missingPathStatus(fieldName) {
114
+ return fieldName === 'changedFiles' ? 'missing' : 'stale';
112
115
  }
113
116
  function resolveSafeProjectPath(value, projectPath, fieldName) {
114
117
  if (value.trim().length === 0) {
@@ -156,12 +159,12 @@ function record(kind, value, status, source, reason) {
156
159
  }
157
160
  function normalizeCriterion(value) {
158
161
  const withoutCheckbox = value.replace(/^-\s*\[[ x]\]\s*/i, '').trim();
159
- const criterionOnly = /^(AC\d+)\s*[:.)-]?\s*$/i.exec(withoutCheckbox)?.[1];
162
+ const criterionOnly = /^([A-Z]{2}\d+)\s*[:.)-]?\s*$/i.exec(withoutCheckbox)?.[1];
160
163
  if (criterionOnly) {
161
164
  return criterionOnly.toLowerCase();
162
165
  }
163
166
  return withoutCheckbox
164
- .replace(/^AC\d+\s*[:.)-]?\s*/i, '')
167
+ .replace(/^[A-Z]{2}\d+\s*[:.)-]?\s*/i, '')
165
168
  .replace(/\s+/g, ' ')
166
169
  .trim()
167
170
  .toLowerCase();
@@ -1,6 +1,9 @@
1
1
  // engine/github/quality-dimensions.ts — Quality analysis dimensions (SPEC-309)
2
2
  // Duplication, complexity, and dead-code checks extracted from review-dimensions.ts
3
3
  import { parseDiffFiles } from './review-helpers.js';
4
+ function escapeRegex(input) {
5
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6
+ }
4
7
  // ---------------------------------------------------------------------------
5
8
  // Duplication dimension
6
9
  // ---------------------------------------------------------------------------
@@ -203,7 +206,7 @@ function runDeadCode(diff) {
203
206
  continue;
204
207
  }
205
208
  const withoutImport = allContent.replace(content, '');
206
- const escapedId = RegExp.escape(id);
209
+ const escapedId = escapeRegex(id);
207
210
  const occurrences = withoutImport.split(new RegExp(`\\b${escapedId}\\b`)).length - 1;
208
211
  if (occurrences === 0) {
209
212
  findings.push({
@@ -55,7 +55,7 @@ export class EventBus extends EventEmitter {
55
55
  * treated as literal glob-style strings (* and ? retain special meaning).
56
56
  */
57
57
  static escapeRegex(str) {
58
- return RegExp.escape(str);
58
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
59
59
  }
60
60
  /**
61
61
  * Get filtered event history.
@@ -10,9 +10,9 @@ const NEXT_ACTION_MAP = {
10
10
  },
11
11
  review: {
12
12
  step: 'Get approval',
13
- prompt: "Share this plan with your team or just tell me 'approved' to move forward",
13
+ prompt: 'Move the spec through review evidence first, then approve it once the plan reviewer passes',
14
14
  suggestedCommand: 'update_status(status="approved")',
15
- whenToDoIt: "When you're happy with the plan",
15
+ whenToDoIt: 'After review feedback is approved',
16
16
  },
17
17
  approved: {
18
18
  step: 'Start implementation',
@@ -22,9 +22,9 @@ const NEXT_ACTION_MAP = {
22
22
  },
23
23
  implementing: {
24
24
  step: 'Validate the implementation',
25
- prompt: 'Check the implementation against every acceptance criterion',
26
- suggestedCommand: 'validate(specId)',
27
- whenToDoIt: 'After all tests pass',
25
+ prompt: 'Run validate first, then mark the spec done once reviewer evidence is written',
26
+ suggestedCommand: 'validate(specId) → update_status(status="done")',
27
+ whenToDoIt: 'After all tests pass and the implementation reviewer approves',
28
28
  },
29
29
  };
30
30
  /**
@@ -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,5 +1,6 @@
1
1
  import type { QaGateResult } from '../types/qa-gate.js';
2
2
  import type { Spec } from '../types/spec/core.js';
3
3
  export declare function extractCoverageThreshold(spec: Spec): number | null;
4
+ export declare function computeQaGateFingerprint(spec: Spec, projectPath: string): string;
4
5
  export declare function runQaGate(spec: Spec, projectPath: string): Promise<QaGateResult>;
5
6
  //# sourceMappingURL=qa-gate.d.ts.map
@@ -1,6 +1,21 @@
1
1
  // engine/qa-gate.ts — SPEC-642
2
2
  import { spawnSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { readFileSync, statSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { resolveProjectCommandPlan } from '../tools/validate-runtime.js';
3
7
  const TIMEOUT_MS = 120_000;
8
+ const QA_FINGERPRINT_FILES = [
9
+ 'package.json',
10
+ 'pnpm-lock.yaml',
11
+ 'package-lock.json',
12
+ 'yarn.lock',
13
+ 'bun.lockb',
14
+ 'tsconfig.json',
15
+ 'vitest.config.ts',
16
+ 'vitest.config.mts',
17
+ 'vitest.config.js',
18
+ ];
4
19
  export function extractCoverageThreshold(spec) {
5
20
  const text = [spec.title, ...spec.tags].join(' ');
6
21
  const match = /coverage\s*[>=]+\s*(\d+)%/i.exec(text);
@@ -34,15 +49,65 @@ function runCheck(name, command, args, cwd) {
34
49
  errorMessage: result.error?.message,
35
50
  };
36
51
  }
52
+ function runRequestedCheck(name, requestedCommand, cwd) {
53
+ const plan = resolveProjectCommandPlan(cwd, requestedCommand);
54
+ return runCheck(name, plan.executable, plan.args, cwd);
55
+ }
56
+ function readProjectPackageManager(projectPath) {
57
+ try {
58
+ const raw = readFileSync(join(projectPath, 'package.json'), 'utf-8');
59
+ const parsed = JSON.parse(raw);
60
+ return typeof parsed.packageManager === 'string' ? parsed.packageManager : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function resolveRequestedQaCommand(projectPath, check) {
67
+ const manager = readProjectPackageManager(projectPath)?.split('@')[0] ?? 'pnpm';
68
+ if (check === 'typecheck') {
69
+ return `${manager} typecheck`;
70
+ }
71
+ if (check === 'test-coverage') {
72
+ return `${manager} test:coverage`;
73
+ }
74
+ return `${manager} test`;
75
+ }
76
+ function asStableString(value) {
77
+ return typeof value === 'string' ? value : '';
78
+ }
79
+ function asStableStringArray(value) {
80
+ return Array.isArray(value)
81
+ ? value.filter((item) => typeof item === 'string')
82
+ : [];
83
+ }
84
+ export function computeQaGateFingerprint(spec, projectPath) {
85
+ const hash = createHash('sha256');
86
+ hash.update(spec.id);
87
+ hash.update(asStableString(spec.updatedAt));
88
+ hash.update(asStableString(spec.title));
89
+ hash.update(asStableStringArray(spec.tags).join('|'));
90
+ for (const file of QA_FINGERPRINT_FILES) {
91
+ const fullPath = join(projectPath, file);
92
+ try {
93
+ const stats = statSync(fullPath);
94
+ hash.update(`${file}:${String(stats.mtimeMs)}:${String(stats.size)}`);
95
+ }
96
+ catch {
97
+ hash.update(`${file}:missing`);
98
+ }
99
+ }
100
+ return hash.digest('hex');
101
+ }
37
102
  export function runQaGate(spec, projectPath) {
38
103
  const coverageThreshold = extractCoverageThreshold(spec);
39
104
  const checks = [];
40
- checks.push(runCheck('typecheck', 'pnpm', ['typecheck'], projectPath));
105
+ checks.push(runRequestedCheck('typecheck', resolveRequestedQaCommand(projectPath, 'typecheck'), projectPath));
41
106
  if (coverageThreshold === null) {
42
- checks.push(runCheck('test', 'pnpm', ['test'], projectPath));
107
+ checks.push(runRequestedCheck('test', resolveRequestedQaCommand(projectPath, 'test'), projectPath));
43
108
  }
44
109
  else {
45
- checks.push(runCheck('test-coverage', 'pnpm', ['test:coverage'], projectPath));
110
+ checks.push(runRequestedCheck('test-coverage', resolveRequestedQaCommand(projectPath, 'test-coverage'), projectPath));
46
111
  }
47
112
  const passed = checks.every((c) => c.passed);
48
113
  return Promise.resolve({
@@ -51,6 +116,7 @@ export function runQaGate(spec, projectPath) {
51
116
  checks,
52
117
  ranAt: new Date().toISOString(),
53
118
  coverageThreshold,
119
+ fingerprint: computeQaGateFingerprint(spec, projectPath),
54
120
  });
55
121
  }
56
122
  //# sourceMappingURL=qa-gate.js.map
@@ -163,7 +163,7 @@ function extractFrontmatterArray(content, field) {
163
163
  if (!match?.[1]) {
164
164
  return [];
165
165
  }
166
- const escaped = RegExp.escape(field);
166
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
167
167
  const fieldRe = new RegExp(`^${escaped}:\\s*\\[(.*)\\]`, 'm');
168
168
  const fieldMatch = fieldRe.exec(match[1]);
169
169
  if (!fieldMatch?.[1]) {
@@ -7,6 +7,9 @@ const DEFAULT_CACHE_CONFIG = {
7
7
  maxPersistentSizeMb: 5,
8
8
  persistMinTtlMs: 60 * 60 * 1000, // 1 hour
9
9
  };
10
+ function escapeRegex(input) {
11
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12
+ }
10
13
  /**
11
14
  * In-memory LRU response cache with content-hash keys and TTL support.
12
15
  */
@@ -84,7 +87,7 @@ export class ResponseCache {
84
87
  }
85
88
  // Glob-like pattern: escape regex metacharacters, then restore glob wildcards.
86
89
  // This prevents ReDoS from user-supplied patterns with special regex chars.
87
- const escaped = RegExp.escape(keyOrPattern).replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
90
+ const escaped = escapeRegex(keyOrPattern).replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
88
91
  const regex = new RegExp(`^${escaped}$`);
89
92
  let count = 0;
90
93
  for (const key of [...this.entries.keys()]) {
@@ -4,6 +4,9 @@ import { readFile, stat } from 'node:fs/promises';
4
4
  import { join, relative } from 'node:path';
5
5
  import { glob } from 'glob';
6
6
  const DEFAULT_IGNORE = ['node_modules/**', 'dist/**', 'build/**', '.git/**', 'coverage/**'];
7
+ function escapeRegex(input) {
8
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
+ }
7
10
  // ─── Keyword classification ──────────────────────────────────────────────────
8
11
  /**
9
12
  * Classify criterion text into a check type using keyword heuristics.
@@ -123,7 +126,7 @@ function buildRegex(pattern) {
123
126
  }
124
127
  catch {
125
128
  try {
126
- const escaped = RegExp.escape(pattern);
129
+ const escaped = escapeRegex(pattern);
127
130
  return new RegExp(escaped, 'g');
128
131
  }
129
132
  catch {
@@ -17,6 +17,7 @@ export { buildHolisticReportFromFlatScore } from './validator/holistic-report.js
17
17
  function normalizeCriterionForEvidence(value) {
18
18
  return value
19
19
  .replace(/^-\s*\[[ xX]\]\s*/i, '')
20
+ .replace(/^[A-Z]{2}\d+\s*[:.)-]?\s*/i, '')
20
21
  .replace(/\s+/g, ' ')
21
22
  .trim()
22
23
  .toLowerCase();
@@ -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 {
@@ -119,11 +119,6 @@ export async function resolveProjectPath(input) {
119
119
  resolutionCache.set(cacheKey, result);
120
120
  return result;
121
121
  }
122
- if (gitRoot) {
123
- const result = { projectPath: gitRoot, resolvedFrom: 'git-root' };
124
- resolutionCache.set(cacheKey, result);
125
- return result;
126
- }
127
122
  // Cannot resolve — fall back to cwd
128
123
  const result = { projectPath: cwd, resolvedFrom: 'cwd-walk-planu' };
129
124
  resolutionCache.set(cacheKey, result);
@@ -8,7 +8,7 @@ function gatePath(projectPath, specId) {
8
8
  export async function saveGateState(projectPath, specId, result) {
9
9
  const path = gatePath(projectPath, specId);
10
10
  await mkdir(dirname(path), { recursive: true });
11
- const state = { specId, lastResult: result };
11
+ const state = { specId, lastResult: result, fingerprint: result.fingerprint };
12
12
  await writeFile(path, JSON.stringify(state, null, 2), 'utf-8');
13
13
  }
14
14
  export async function getGateState(projectPath, specId) {
@@ -271,10 +271,6 @@ export async function handleChallengeSpec(args, server) {
271
271
  }),
272
272
  },
273
273
  { type: 'text', text: humanSummary },
274
- {
275
- type: 'text',
276
- text: '⚡ INTERACTIVE — call AskUserQuestion now with the interactiveQuestions from structuredContent',
277
- },
278
274
  ],
279
275
  structuredContent: {
280
276
  ...analysisPayload,
@@ -8,9 +8,21 @@ import { generateFailureScenarios, generateSecurityScenarios, generateScaleScena
8
8
  import { readSpecContent, calculateOverallRisk } from '../challenge-spec-helpers.js';
9
9
  import { prioritizeScenarios, buildPrioritizedSummary, } from '../../engine/challenge-prioritizer.js';
10
10
  const AUTO_PIPELINE_TIMEOUT_MS = 10_000;
11
- async function runChallengePhase(spec, knowledge) {
11
+ function abortError(message) {
12
+ const error = new Error(message);
13
+ error.name = 'AbortError';
14
+ return error;
15
+ }
16
+ function throwIfAborted(signal) {
17
+ if (signal.aborted) {
18
+ throw abortError(signal.reason instanceof Error ? signal.reason.message : 'Auto-pipeline aborted');
19
+ }
20
+ }
21
+ async function runChallengePhase(spec, knowledge, signal) {
12
22
  try {
23
+ throwIfAborted(signal);
13
24
  const specContent = knowledge ? await readSpecContent(spec) : '';
25
+ throwIfAborted(signal);
14
26
  const failureScenarios = [
15
27
  ...(knowledge ? generateFailureScenarios(spec, specContent, knowledge) : []),
16
28
  ...(knowledge ? generateSecurityScenarios(spec, specContent, knowledge) : []),
@@ -34,12 +46,13 @@ async function runChallengePhase(spec, knowledge) {
34
46
  return null;
35
47
  }
36
48
  }
37
- async function runPipelineInternal(specId, projectId) {
49
+ async function runPipelineInternal(specId, projectId, signal) {
38
50
  // Load spec and knowledge (both required for challenge)
39
51
  const [spec, knowledge] = await Promise.all([
40
52
  specStore.getSpec(projectId, specId),
41
53
  knowledgeStore.getKnowledge(projectId),
42
54
  ]);
55
+ throwIfAborted(signal);
43
56
  if (!spec) {
44
57
  return {
45
58
  challengeSummary: null,
@@ -49,12 +62,14 @@ async function runPipelineInternal(specId, projectId) {
49
62
  };
50
63
  }
51
64
  // --- Challenge phase ---
52
- const challengeSummary = await runChallengePhase(spec, knowledge);
65
+ const challengeSummary = await runChallengePhase(spec, knowledge, signal);
53
66
  // --- Readiness phase ---
54
67
  let readinessScore = null;
55
68
  let readinessSuggestion = null;
56
69
  try {
70
+ throwIfAborted(signal);
57
71
  const report = await checkSpecReadiness(spec, 'lenient');
72
+ throwIfAborted(signal);
58
73
  readinessScore = report.score;
59
74
  if (report.score >= 80) {
60
75
  readinessSuggestion = 'Listo para review. Usa update_status(review) cuando quieras proceder.';
@@ -72,6 +87,7 @@ async function runPipelineInternal(specId, projectId) {
72
87
  // SPEC-501: AC testability gate — flag vague criteria, suggest EARS rewrites
73
88
  let acTestabilityWarning = null;
74
89
  try {
90
+ throwIfAborted(signal);
75
91
  acTestabilityWarning = await scoreSpecCriteria(spec);
76
92
  }
77
93
  catch {
@@ -81,8 +97,10 @@ async function runPipelineInternal(specId, projectId) {
81
97
  let relevantLessons = null;
82
98
  try {
83
99
  if (knowledge?.projectPath && spec.tags.length > 0) {
100
+ throwIfAborted(signal);
84
101
  const { getLessons } = await import('../../storage/lessons-store.js');
85
102
  const lessons = await getLessons(knowledge.projectPath, { tags: spec.tags, limit: 3 });
103
+ throwIfAborted(signal);
86
104
  if (lessons.length > 0) {
87
105
  relevantLessons = lessons.map((l) => `- ${l.title}: ${l.prevention}`);
88
106
  }
@@ -94,10 +112,13 @@ async function runPipelineInternal(specId, projectId) {
94
112
  // SPEC-664: Resolve npm package versions mentioned in spec text
95
113
  let resolvedVersions = null;
96
114
  try {
115
+ throwIfAborted(signal);
97
116
  const specBody = await readSpecContent(spec);
98
117
  if (specBody) {
118
+ throwIfAborted(signal);
99
119
  const { resolveSpecVersions } = await import('../../engine/version-resolver.js');
100
120
  resolvedVersions = await resolveSpecVersions(specBody, projectId);
121
+ throwIfAborted(signal);
101
122
  }
102
123
  }
103
124
  catch {
@@ -182,22 +203,22 @@ export function formatPipelineLines(result) {
182
203
  * Has a 10s total timeout. Never throws — errors are captured in the result.
183
204
  */
184
205
  export async function runAutoPostCreatePipeline(specId, projectId, _projectPath) {
185
- const timeoutPromise = new Promise((resolve) => {
186
- /* c8 ignore next */
187
- setTimeout(() => {
188
- /* c8 ignore next */
189
- resolve({
190
- challengeSummary: null,
191
- readinessScore: null,
192
- readinessSuggestion: null,
193
- error: 'Auto-pipeline timeout (10s)',
194
- });
195
- }, AUTO_PIPELINE_TIMEOUT_MS);
196
- });
206
+ const controller = new AbortController();
207
+ const timeout = setTimeout(() => {
208
+ controller.abort(abortError(`Auto-pipeline timeout (${String(AUTO_PIPELINE_TIMEOUT_MS / 1000)}s)`));
209
+ }, AUTO_PIPELINE_TIMEOUT_MS);
197
210
  try {
198
- return await Promise.race([runPipelineInternal(specId, projectId), timeoutPromise]);
211
+ return await runPipelineInternal(specId, projectId, controller.signal);
199
212
  }
200
213
  catch (err) {
214
+ if (err instanceof Error && err.name === 'AbortError') {
215
+ return {
216
+ challengeSummary: null,
217
+ readinessScore: null,
218
+ readinessSuggestion: null,
219
+ error: err.message,
220
+ };
221
+ }
201
222
  const message = err instanceof Error ? err.message : String(err);
202
223
  return {
203
224
  challengeSummary: null,
@@ -206,5 +227,8 @@ export async function runAutoPostCreatePipeline(specId, projectId, _projectPath)
206
227
  error: message,
207
228
  };
208
229
  }
230
+ finally {
231
+ clearTimeout(timeout);
232
+ }
209
233
  }
210
234
  //# sourceMappingURL=auto-pipeline.js.map
@@ -1290,20 +1290,10 @@ export async function handleCreateSpec(inputParams, server) {
1290
1290
  .catch(() => {
1291
1291
  /* best-effort */
1292
1292
  });
1293
- // Auto post-creation pipeline: challenge + readiness (SPEC-445)
1294
- // SPEC-713: Convert to fire-and-forget pipeline has its own 10s internal timeout
1295
- // but was blocking the MCP response. Now runs in background after response is built.
1296
- const pipelineResult = {
1297
- challengeSummary: null,
1298
- readinessScore: null,
1299
- readinessSuggestion: null,
1300
- error: 'pipeline deferred (SPEC-713 fire-and-forget)',
1301
- };
1302
- // Start pipeline in background — result will be lost after response, but spec is
1303
- // already persisted so this is a best-effort enrichment only.
1304
- void runAutoPostCreatePipeline(spec.id, projectId, knowledge?.projectPath ?? params.projectPath ?? '').catch(() => {
1305
- /* best-effort */
1306
- });
1293
+ // Auto post-creation pipeline: challenge + readiness (SPEC-445).
1294
+ // Keep it observable and bounded so users see the actual result instead of
1295
+ // background work that may finish invisibly after the tool already returned.
1296
+ const pipelineResult = await runAutoPostCreatePipeline(spec.id, projectId, knowledge?.projectPath ?? params.projectPath ?? '');
1307
1297
  // Build markdown response
1308
1298
  const lines = [
1309
1299
  `**SPEC-${spec.id}** — ${spec.title}`,
@@ -45,7 +45,7 @@ facilitate("what to build") → create_spec → challenge_spec → check_readine
45
45
  ## Session End
46
46
 
47
47
  - Call \`session_checkpoint\` to save context
48
- - Call \`update_status(done)\` if work is complete
48
+ - Run \`validate\`, then call \`update_status(done)\` when work is complete
49
49
 
50
50
  ## Quick Reference
51
51
 
@@ -82,7 +82,7 @@ Auto-generated by init_project.
82
82
 
83
83
  - New feature: facilitate("what to build")
84
84
  - Check status: planu_status
85
- - Mark done: update_status(done) then validate
85
+ - Mark done: validate, then update_status(done)
86
86
  `;
87
87
  async function writeIfMissing(path, content) {
88
88
  try {
@@ -228,7 +228,7 @@ const AGENT_RULE_FILES = [
228
228
  function injectOrReplaceSection(content, section, marker) {
229
229
  if (content.includes(marker)) {
230
230
  // Replace everything between (and including) the two marker lines
231
- const escaped = RegExp.escape(marker);
231
+ const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
232
232
  const re = new RegExp(`${escaped}[\\s\\S]*?${escaped}`, 'g');
233
233
  // Extract the new wrapped block from the section (it already contains the markers)
234
234
  const newBlock = section.includes(marker) ? section : `${marker}\n${section}\n${marker}`;
@@ -74,8 +74,8 @@ export function injectOrReplacePlanuBlock(existing, block, openMarker) {
74
74
  const closeMarker = openMarker.replace('<!-- ', '<!-- /');
75
75
  if (existing.includes(openMarker)) {
76
76
  // Replace everything from openMarker to closeMarker (inclusive)
77
- const escapedOpen = RegExp.escape(openMarker);
78
- const escapedClose = RegExp.escape(closeMarker);
77
+ const escapedOpen = openMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
78
+ const escapedClose = closeMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
79
79
  const re = new RegExp(`${escapedOpen}[\\s\\S]*?${escapedClose}`, 'g');
80
80
  return existing.replace(re, block);
81
81
  }
@@ -11,6 +11,8 @@
11
11
  // This module is intentionally side-effect-free: it reads the global registry
12
12
  // and the env var but never mutates any state.
13
13
  import { spawnSync } from 'node:child_process';
14
+ import { stat } from 'node:fs/promises';
15
+ import { join } from 'node:path';
14
16
  import { hashProjectPath } from '../storage/base-store.js';
15
17
  import { getProjects } from '../storage/global-projects-store.js';
16
18
  // ---------------------------------------------------------------------------
@@ -37,6 +39,15 @@ export function detectGitRoot() {
37
39
  return null;
38
40
  }
39
41
  }
42
+ async function hasPlanuDir(projectPath) {
43
+ try {
44
+ const stats = await stat(join(projectPath, 'planu'));
45
+ return stats.isDirectory();
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
40
51
  /**
41
52
  * Derive a human-readable project name from its absolute path.
42
53
  * Uses the last non-empty path segment (the directory name).
@@ -121,7 +132,7 @@ export async function resolveProjectPath(params) {
121
132
  }
122
133
  // 4. SPEC-509: git rev-parse --show-toplevel — detect from current working directory
123
134
  const gitRoot = detectGitRoot();
124
- if (gitRoot) {
135
+ if (gitRoot && (await hasPlanuDir(gitRoot))) {
125
136
  return {
126
137
  kind: 'resolved',
127
138
  projectId: hashProjectPath(gitRoot),
@@ -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, readApprovedValidationReportGate, checkSpecReviewGate, writeSpecReviewArtifact, } from './dod-gates.js';
16
+ import { runValidateGate, checkDoneGates, checkComplianceGate, checkQaGate, checkApprovedFormatGate, readApprovedValidationReportGate, checkSpecReviewGate, writeSpecReviewArtifact, } from './dod-gates.js';
17
17
  import { checkLifecycleEvidenceTransitionGate } from './evidence-gate.js';
18
18
  import { buildStatusResponse, buildValidateBlockedResponse, buildDryRunResponse, } from './response-builder.js';
19
19
  import { recordDoneMetrics, syncSpecFiles, tryReconcile, recordTerminalTransitionEvent, } from './file-sync.js';
@@ -529,29 +529,14 @@ export async function handleUpdateStatus(params, server) {
529
529
  let crashShieldSkipReason = null;
530
530
  let complianceGateResult = null;
531
531
  let validationReportGate = null;
532
- if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
533
- validationReportGate = await readApprovedValidationReportGate(specId, projectId, false);
534
- if (!validationReportGate.ok) {
535
- return validationReportGate.error;
536
- }
537
- }
538
532
  // SPEC-628: Rate-limit crash scan — check before entering the parallel batch
539
533
  crashShieldSkipReason = await checkCrashScanRateLimit(newStatus, effectiveGatePath ?? null);
540
- const approvedValidationReportScore = validationReportGate?.ok === true && validationReportGate.score === 100
541
- ? validationReportGate.score
542
- : null;
543
534
  const [validateGateResult, crashRisksReport, complianceResult] = await Promise.all([
544
535
  // Validate: only on 'done'.
545
536
  // SPEC-721: timeout lives inside runValidateGate (Promise.race) — do NOT wrap with
546
537
  // withToolTimeout here, which would silently convert timeout into blocked:false (fail-open).
547
538
  newStatus === 'done'
548
- ? approvedValidationReportScore !== null
549
- ? Promise.resolve({
550
- blocked: false,
551
- score: approvedValidationReportScore,
552
- forced: false,
553
- })
554
- : runValidateGate(spec, effectiveGatePath ?? '', params.forceStatus ?? false, params.forceStatusReason, 9_000, { projectId, specId })
539
+ ? runValidateGate(spec, effectiveGatePath ?? '', params.forceStatus ?? false, params.forceStatusReason, 9_000, { projectId, specId })
555
540
  : Promise.resolve(null),
556
541
  // Crash shield: only on 'done', skipped if rate-limited (SPEC-628)
557
542
  newStatus === 'done' && effectiveGatePath && !crashShieldSkipReason
@@ -582,16 +567,17 @@ export async function handleUpdateStatus(params, server) {
582
567
  };
583
568
  }
584
569
  else {
585
- validateScoreSource =
586
- approvedValidationReportScore !== null ? 'validation-report' : 'validateSpec';
570
+ validateScoreSource = 'validateSpec';
587
571
  }
588
572
  }
589
- if (newStatus === 'done' &&
590
- !(params.force ?? params.forceStatus ?? false) &&
591
- validationReportGate === null) {
592
- const validationReportError = await checkValidationReportGate(specId, projectId, false);
593
- if (validationReportError) {
594
- return validationReportError;
573
+ if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
574
+ validationReportGate = await readApprovedValidationReportGate(specId, projectId, false);
575
+ if (!validationReportGate.ok) {
576
+ return validationReportGate.error;
577
+ }
578
+ if (validationReportGate.score !== null) {
579
+ validateScore = validationReportGate.score;
580
+ validateScoreSource = 'validation-report';
595
581
  }
596
582
  }
597
583
  if (newStatus === 'done' &&
@@ -1,5 +1,4 @@
1
1
  import type { ToolResult } from '../../types/index.js';
2
2
  import type { Spec } from '../../types/spec/core.js';
3
- /** SPEC-642 / SPEC-1111: verify and persist local QA evidence before done. */
4
3
  export declare function checkQaGate(spec: Spec, projectPath: string | undefined, force: boolean): Promise<ToolResult | null>;
5
4
  //# sourceMappingURL=qa-gate.d.ts.map
@@ -1,23 +1,36 @@
1
1
  /** SPEC-642 / SPEC-1111: verify and persist local QA evidence before done. */
2
+ const QA_GATE_TTL_MS = 15 * 60 * 1000;
2
3
  export async function checkQaGate(spec, projectPath, force) {
3
4
  if (force || !projectPath) {
4
5
  return null;
5
6
  }
6
7
  const specId = spec.id;
7
8
  const { getGateState, saveGateState } = await import('../../storage/qa-gate-store.js');
9
+ const { computeQaGateFingerprint, runQaGate } = await import('../../engine/qa-gate.js');
10
+ const fingerprint = computeQaGateFingerprint(spec, projectPath);
8
11
  const state = await getGateState(projectPath, specId).catch(() => null);
9
- if (state?.lastResult?.passed === true) {
12
+ const ranAtMs = state?.lastResult?.ranAt ? Date.parse(state.lastResult.ranAt) : NaN;
13
+ const freshEnough = Number.isFinite(ranAtMs) && Date.now() - ranAtMs <= QA_GATE_TTL_MS;
14
+ if (state?.lastResult?.passed === true &&
15
+ state.lastResult.fingerprint === fingerprint &&
16
+ freshEnough) {
10
17
  return null;
11
18
  }
12
19
  if (await hasPassedExternalQaEvidence(spec, projectPath)) {
13
20
  return null;
14
21
  }
15
22
  let effectiveResult = state?.lastResult ?? null;
16
- if (effectiveResult === null) {
17
- const { runQaGate } = await import('../../engine/qa-gate.js');
23
+ const canReuseLocalQa = effectiveResult !== null &&
24
+ effectiveResult.fingerprint === fingerprint &&
25
+ freshEnough &&
26
+ effectiveResult.passed;
27
+ if (!canReuseLocalQa) {
18
28
  effectiveResult = await runQaGate(spec, projectPath);
19
29
  await saveGateState(projectPath, specId, effectiveResult);
20
30
  }
31
+ if (effectiveResult === null) {
32
+ return null;
33
+ }
21
34
  if (effectiveResult.passed) {
22
35
  return null;
23
36
  }
@@ -349,10 +349,6 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
349
349
  type: 'text',
350
350
  text: `Readiness gate: spec has 0 acceptance criteria — cannot approve an empty spec.`,
351
351
  },
352
- {
353
- type: 'text',
354
- text: '⚡ INTERACTIVE — call AskUserQuestion now with the interactiveQuestions from structuredContent',
355
- },
356
352
  ],
357
353
  structuredContent: {
358
354
  interactiveQuestions: [question],
@@ -419,10 +415,6 @@ export async function checkReadinessGate(spec, newStatus, forceApprove) {
419
415
  type: 'text',
420
416
  text: `Readiness gate: score ${String(score)}/100 — below 70 threshold. Warnings: ${warningsSummary}`,
421
417
  },
422
- {
423
- type: 'text',
424
- text: '⚡ INTERACTIVE — call AskUserQuestion now with the interactiveQuestions from structuredContent',
425
- },
426
418
  ],
427
419
  structuredContent: {
428
420
  interactiveQuestions: [question],
@@ -16,24 +16,33 @@ function isSafeCommand(cmd) {
16
16
  async function execGate(cmd, cwd, timeoutMs) {
17
17
  const [bin, ...args] = cmd.split(/\s+/);
18
18
  try {
19
- const { stdout } = await execFile(bin ?? 'sh', args, {
19
+ const { stdout, stderr } = await execFile(bin ?? 'sh', args, {
20
20
  cwd,
21
21
  timeout: timeoutMs,
22
22
  maxBuffer: 4 * 1024 * 1024,
23
23
  });
24
- return { output: stdout.trim(), ok: true, timedOut: false };
24
+ return { output: mergeCommandOutput(stdout, stderr), ok: true, timedOut: false };
25
25
  }
26
26
  catch (err) {
27
27
  const e = err;
28
- const raw = Buffer.isBuffer(e.stdout)
29
- ? e.stdout.toString('utf-8')
30
- : typeof e.stdout === 'string'
31
- ? e.stdout
32
- : '';
28
+ const raw = mergeCommandOutput(e.stdout, e.stderr);
33
29
  const timedOut = e.killed === true || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT';
34
30
  return { output: raw.trim(), ok: false, timedOut, errorMessage: e.message };
35
31
  }
36
32
  }
33
+ function mergeCommandOutput(stdout, stderr) {
34
+ const stdoutText = Buffer.isBuffer(stdout)
35
+ ? stdout.toString('utf-8')
36
+ : typeof stdout === 'string'
37
+ ? stdout
38
+ : '';
39
+ const stderrText = Buffer.isBuffer(stderr)
40
+ ? stderr.toString('utf-8')
41
+ : typeof stderr === 'string'
42
+ ? stderr
43
+ : '';
44
+ return [stdoutText.trim(), stderrText.trim()].filter((part) => part.length > 0).join('\n');
45
+ }
37
46
  function countLintIssues(output) {
38
47
  // Handle eslint, tsc, and generic "N problem(s)" patterns
39
48
  const problemMatch = /(\d+)\s+problem/i.exec(output);
@@ -326,13 +326,7 @@ export async function handleValidate(args, server) {
326
326
  if (outcome.mode === 'fallback') {
327
327
  return {
328
328
  phaseEvent: createBasicPhaseEvent('validate', 'completed', 100, 'validating'),
329
- content: [
330
- { type: 'text', text: compactText + graphText },
331
- {
332
- type: 'text',
333
- text: '⚡ INTERACTIVE — call AskUserQuestion now with the interactiveQuestions from structuredContent',
334
- },
335
- ],
329
+ content: [{ type: 'text', text: compactText + graphText }],
336
330
  structuredContent: {
337
331
  ...structuredBase,
338
332
  interactiveQuestions: outcome.interactiveQuestions,
@@ -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
@@ -14,9 +14,11 @@ export interface QaGateResult {
14
14
  checks: QaCheckResult[];
15
15
  ranAt: string;
16
16
  coverageThreshold: number | null;
17
+ fingerprint?: string;
17
18
  }
18
19
  export interface QaGateState {
19
20
  specId: string;
20
21
  lastResult: QaGateResult | null;
22
+ fingerprint?: string;
21
23
  }
22
24
  //# sourceMappingURL=qa-gate.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.11.4",
3
+ "version": "4.11.6",
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.4",
38
- "@planu/core-darwin-x64": "4.11.4",
39
- "@planu/core-linux-arm64-gnu": "4.11.4",
40
- "@planu/core-linux-arm64-musl": "4.11.4",
41
- "@planu/core-linux-x64-gnu": "4.11.4",
42
- "@planu/core-linux-x64-musl": "4.11.4",
43
- "@planu/core-win32-arm64-msvc": "4.11.4",
44
- "@planu/core-win32-x64-msvc": "4.11.4"
37
+ "@planu/core-darwin-arm64": "4.11.6",
38
+ "@planu/core-darwin-x64": "4.11.6",
39
+ "@planu/core-linux-arm64-gnu": "4.11.6",
40
+ "@planu/core-linux-arm64-musl": "4.11.6",
41
+ "@planu/core-linux-x64-gnu": "4.11.6",
42
+ "@planu/core-linux-x64-musl": "4.11.6",
43
+ "@planu/core-win32-arm64-msvc": "4.11.6",
44
+ "@planu/core-win32-x64-msvc": "4.11.6"
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.4",
4
+ "version": "4.11.6",
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.4",
5
+ "version": "4.11.6",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": [
8
8
  "npx",