@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.
- package/CHANGELOG.md +16 -0
- package/README.md +2 -2
- package/dist/engine/actuals/progress-parser.js +4 -1
- package/dist/engine/ai-integration/gemini/gemini-md-generator.js +7 -0
- package/dist/engine/detectors/idp-detector.js +1 -1
- package/dist/engine/evidence-gates/lifecycle-gate.js +8 -4
- package/dist/engine/evidence-index/index-builder.js +6 -3
- package/dist/engine/github/quality-dimensions.js +4 -1
- package/dist/engine/hooks/event-bus.js +1 -1
- package/dist/engine/lifecycle-hints.js +5 -5
- package/dist/engine/outbound-webhook-dispatcher.d.ts +2 -2
- package/dist/engine/outbound-webhook-dispatcher.js +8 -6
- package/dist/engine/qa-gate.d.ts +1 -0
- package/dist/engine/qa-gate.js +69 -3
- package/dist/engine/spec-repair.js +1 -1
- package/dist/engine/token-optimizer/response-cache.js +4 -1
- package/dist/engine/validator/deep-code-checker.js +4 -1
- package/dist/engine/validator.js +1 -0
- package/dist/engine/web-fetcher/registry-auto-discovery.d.ts +2 -2
- package/dist/engine/web-fetcher/registry-auto-discovery.js +9 -5
- package/dist/storage/path-resolver.js +0 -5
- package/dist/storage/qa-gate-store.js +1 -1
- package/dist/tools/challenge-spec.js +0 -4
- package/dist/tools/create-spec/auto-pipeline.js +40 -16
- package/dist/tools/create-spec.js +4 -14
- package/dist/tools/init-project/agents-md-writer.js +2 -2
- package/dist/tools/init-project/helpers.js +1 -1
- package/dist/tools/init-project/planu-workflow-generator.js +2 -2
- package/dist/tools/resolve-project-path.js +12 -1
- package/dist/tools/update-status/index.js +11 -25
- package/dist/tools/update-status/qa-gate.d.ts +0 -1
- package/dist/tools/update-status/qa-gate.js +16 -3
- package/dist/tools/update-status/transition-guard.js +0 -8
- package/dist/tools/update-status-convention-gate.js +16 -7
- package/dist/tools/validate.js +1 -7
- package/dist/types/docs.d.ts +4 -0
- package/dist/types/outbound-webhook.d.ts +4 -0
- package/dist/types/qa-gate.d.ts +2 -0
- package/package.json +9 -9
- package/planu-native.json +1 -1
- 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`
|
|
61
|
-
- `update_status(done)`
|
|
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 =
|
|
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
|
|
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 = /^(
|
|
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(/^
|
|
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([
|
|
37
|
-
|
|
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' :
|
|
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 = /^(
|
|
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(/^
|
|
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 =
|
|
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
|
|
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:
|
|
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:
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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 =
|
|
49
|
-
await
|
|
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
|
package/dist/engine/qa-gate.d.ts
CHANGED
|
@@ -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
|
package/dist/engine/qa-gate.js
CHANGED
|
@@ -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(
|
|
105
|
+
checks.push(runRequestedCheck('typecheck', resolveRequestedQaCommand(projectPath, 'typecheck'), projectPath));
|
|
41
106
|
if (coverageThreshold === null) {
|
|
42
|
-
checks.push(
|
|
107
|
+
checks.push(runRequestedCheck('test', resolveRequestedQaCommand(projectPath, 'test'), projectPath));
|
|
43
108
|
}
|
|
44
109
|
else {
|
|
45
|
-
checks.push(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
129
|
+
const escaped = escapeRegex(pattern);
|
|
127
130
|
return new RegExp(escaped, 'g');
|
|
128
131
|
}
|
|
129
132
|
catch {
|
package/dist/engine/validator.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
//
|
|
1295
|
-
//
|
|
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
|
-
-
|
|
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)
|
|
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 =
|
|
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 =
|
|
78
|
-
const escapedClose =
|
|
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,
|
|
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
|
-
?
|
|
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
|
-
|
|
591
|
-
validationReportGate
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
24
|
+
return { output: mergeCommandOutput(stdout, stderr), ok: true, timedOut: false };
|
|
25
25
|
}
|
|
26
26
|
catch (err) {
|
|
27
27
|
const e = err;
|
|
28
|
-
const raw =
|
|
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);
|
package/dist/tools/validate.js
CHANGED
|
@@ -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,
|
package/dist/types/docs.d.ts
CHANGED
|
@@ -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/dist/types/qa-gate.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
38
|
-
"@planu/core-darwin-x64": "4.11.
|
|
39
|
-
"@planu/core-linux-arm64-gnu": "4.11.
|
|
40
|
-
"@planu/core-linux-arm64-musl": "4.11.
|
|
41
|
-
"@planu/core-linux-x64-gnu": "4.11.
|
|
42
|
-
"@planu/core-linux-x64-musl": "4.11.
|
|
43
|
-
"@planu/core-win32-arm64-msvc": "4.11.
|
|
44
|
-
"@planu/core-win32-x64-msvc": "4.11.
|
|
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
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.
|
|
5
|
+
"version": "4.11.6",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": [
|
|
8
8
|
"npx",
|