@planu/cli 4.11.9 → 4.12.2
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 +129 -22
- package/dist/engine/abortable-process-runner.d.ts +8 -0
- package/dist/engine/abortable-process-runner.js +107 -0
- package/dist/engine/cascade-hooks/core/append-releases.d.ts +1 -0
- package/dist/engine/cascade-hooks/core/append-releases.js +51 -14
- package/dist/engine/cascade-hooks/hooks/orchestration-plan.hook.js +16 -21
- package/dist/engine/ci-generator/yaml-builder.js +5 -1
- package/dist/engine/context-md-generator.js +3 -1
- package/dist/engine/criterion-identity.d.ts +6 -0
- package/dist/engine/criterion-identity.js +63 -0
- package/dist/engine/evidence-gates/lifecycle-gate.js +22 -44
- package/dist/engine/evidence-index/index-builder.js +7 -19
- package/dist/engine/frontmatter-parser.d.ts +5 -0
- package/dist/engine/frontmatter-parser.js +24 -0
- package/dist/engine/project-command-runtime.d.ts +5 -0
- package/dist/{tools/validate-runtime.js → engine/project-command-runtime.js} +8 -21
- package/dist/engine/project-graph/builder.js +1 -35
- package/dist/engine/project-graph/cache.js +37 -1
- package/dist/engine/qa-gate.d.ts +4 -1
- package/dist/engine/qa-gate.js +72 -59
- package/dist/engine/safety/cross-process-lock.d.ts +1 -6
- package/dist/engine/safety/cross-process-lock.js +18 -16
- package/dist/engine/safety/lock-scavenger.d.ts +9 -0
- package/dist/engine/safety/lock-scavenger.js +114 -0
- package/dist/engine/session-context-generator.js +24 -2
- package/dist/engine/session-safeguard/learnings-buffer.js +7 -2
- package/dist/engine/session-state/writer.js +15 -8
- package/dist/engine/skill-registry/installer.js +1 -1
- package/dist/engine/spec-format/acceptance-criteria.js +8 -26
- package/dist/engine/spec-grounding/contract.d.ts +3 -1
- package/dist/engine/spec-grounding/contract.js +39 -14
- package/dist/engine/universal-rules/installer.js +2 -2
- package/dist/engine/validation-evidence-digest.d.ts +4 -0
- package/dist/engine/validation-evidence-digest.js +83 -0
- package/dist/engine/validation-evidence-ledger.d.ts +30 -0
- package/dist/engine/validation-evidence-ledger.js +274 -0
- package/dist/engine/validation-impact-planner.d.ts +4 -0
- package/dist/engine/validation-impact-planner.js +71 -0
- package/dist/engine/validator/spec-compliance-runner.d.ts +1 -1
- package/dist/engine/validator/spec-compliance-runner.js +37 -40
- package/dist/engine/validator/validation-report-writer.d.ts +1 -0
- package/dist/engine/validator/validation-report-writer.js +1 -1
- package/dist/engine/validator.js +7 -11
- package/dist/storage/convention-baseline.js +5 -0
- package/dist/storage/session-state-store.js +13 -6
- package/dist/storage/spec-store.d.ts +5 -0
- package/dist/storage/spec-store.js +7 -0
- package/dist/tools/git/git-helpers.d.ts +5 -1
- package/dist/tools/git/git-helpers.js +3 -2
- package/dist/tools/init-project/conventions-writer.js +1 -1
- package/dist/tools/init-project/scaffold-writer.js +1 -1
- package/dist/tools/safe-handler.d.ts +5 -1
- package/dist/tools/safe-handler.js +73 -12
- package/dist/tools/status-handler.d.ts +1 -1
- package/dist/tools/status-handler.js +27 -66
- package/dist/tools/update-status/dod-gates.js +7 -31
- package/dist/tools/update-status/evidence-gate.js +2 -4
- package/dist/tools/update-status/force-done-audit.d.ts +2 -0
- package/dist/tools/update-status/force-done-audit.js +23 -0
- package/dist/tools/update-status/qa-gate.d.ts +1 -0
- package/dist/tools/update-status/qa-gate.js +7 -51
- package/dist/tools/update-status-convention-gate.js +132 -97
- package/dist/tools/validate-lint.d.ts +1 -1
- package/dist/tools/validate-lint.js +78 -165
- package/dist/tools/validate.js +3 -1
- package/dist/transports/http-transport.js +22 -8
- package/dist/transports/mcp-types.d.ts +2 -2
- package/dist/transports/transport-factory.js +110 -12
- package/dist/types/abortable-process.d.ts +26 -0
- package/dist/types/abortable-process.js +2 -0
- package/dist/types/criterion-identity.d.ts +12 -0
- package/dist/types/criterion-identity.js +2 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/qa-gate.d.ts +0 -5
- package/dist/types/spec-format.d.ts +2 -0
- package/dist/types/spec-lock-v2.d.ts +7 -0
- package/dist/types/validation-evidence.d.ts +62 -0
- package/dist/types/validation-evidence.js +2 -0
- package/package.json +43 -23
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
- package/dist/storage/qa-gate-store.d.ts +0 -4
- package/dist/storage/qa-gate-store.js +0 -24
- package/dist/tools/validate-runtime.d.ts +0 -14
- package/dist/types/data/estimation.d.ts +0 -147
- package/dist/types/data/estimation.js +0 -2
- package/dist/types/data/index.d.ts +0 -5
- package/dist/types/data/index.js +0 -6
- package/dist/types/data/velocity.d.ts +0 -168
- package/dist/types/data/velocity.js +0 -4
|
@@ -25,7 +25,7 @@ function buildConventionsContent(knowledge) {
|
|
|
25
25
|
}
|
|
26
26
|
lines.push('', '## Naming Conventions', '', '- Variables/functions: `camelCase`', '- Types/interfaces: `PascalCase`', '- Files: `kebab-case`', '- Constants: `SCREAMING_SNAKE_CASE`', '', '## File Structure', '');
|
|
27
27
|
if (knowledge.projectPath) {
|
|
28
|
-
lines.push(
|
|
28
|
+
lines.push('- Project root: repository root (`.`)');
|
|
29
29
|
}
|
|
30
30
|
lines.push('- Source: `src/`', '- Tests: `tests/` (mirrors `src/` structure)', '- Specs: `planu/specs/`', '', '## Code Style', '', '- Prefer explicit return types on all functions', '- No magic numbers — use named constants', '- Error handling: return `ToolResult` with `isError: true`, never throw from handlers', '', '## Spec Conventions', '', '- One spec per feature/bugfix', '- ACs must be testable: GIVEN/WHEN/THEN or measurable condition', '- spec.md always in English', '- Technical planning belongs in `spec.md` under `## Technical`; do not create `technical.md`');
|
|
31
31
|
return lines.join('\n') + '\n';
|
|
@@ -353,7 +353,7 @@ export async function runScaffoldWriter(projectPath, projectId, knowledge, recom
|
|
|
353
353
|
// Read the canonical template from src/config/hook-templates/
|
|
354
354
|
const templatePath = joinFn(
|
|
355
355
|
// Resolve relative to this file's compiled location in dist/ or src/
|
|
356
|
-
homedir(), '.claude', 'projects', '-Users-
|
|
356
|
+
homedir(), '.claude', 'projects', '-Users-maintainer-Documents-desarrollo-sdd-mcp-server');
|
|
357
357
|
void templatePath; // unused — we read from a fixed relative location
|
|
358
358
|
// Read template from its canonical src/ location
|
|
359
359
|
const { fileURLToPath } = await import('node:url');
|
|
@@ -11,6 +11,10 @@ export declare function isProjectInitialized(projectPath: string): boolean;
|
|
|
11
11
|
* tool calls reflect the new state.
|
|
12
12
|
*/
|
|
13
13
|
export declare function markProjectInitialized(projectPath: string): void;
|
|
14
|
+
/** Signal for the currently executing tool, including server deadline and client cancellation. */
|
|
15
|
+
export declare function getToolExecutionSignal(): AbortSignal | undefined;
|
|
16
|
+
/** Abort every in-flight tool before stdio shutdown closes the transport. */
|
|
17
|
+
export declare function abortActiveToolExecutions(reason?: string): void;
|
|
14
18
|
/**
|
|
15
19
|
* Races a promise against a hard deadline, resolving with `fallback` on timeout.
|
|
16
20
|
* Unlike withTimeout (which rejects), this always resolves — safe for non-critical gates
|
|
@@ -24,7 +28,7 @@ export declare function withToolTimeout<T>(p: Promise<T>, ms: number, fallback:
|
|
|
24
28
|
/**
|
|
25
29
|
* Wraps a tool handler so that any unhandled error is caught and returned as a
|
|
26
30
|
* graceful MCP error response, preventing server crashes. Also enforces a
|
|
27
|
-
* configurable execution timeout (default
|
|
31
|
+
* configurable execution timeout (default 55s, env PLANU_TOOL_TIMEOUT_MS).
|
|
28
32
|
*
|
|
29
33
|
* Usage — only for alwaysAllowed tools (activate_license, license_status, etc.):
|
|
30
34
|
* server.registerTool('my_tool', schema, safe(async (args) => { ... }));
|
|
@@ -26,6 +26,7 @@ import { checkBudgetEnforcement } from '../engine/cost-guardrails.js';
|
|
|
26
26
|
import { existsSync } from 'node:fs';
|
|
27
27
|
import { join } from 'node:path';
|
|
28
28
|
import { maybeWriteCheckpoint } from '../engine/session/checkpoint-writer.js';
|
|
29
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
29
30
|
// SPEC-922: Compact response auto-on
|
|
30
31
|
import { parseContextHeader } from '../engine/compact/header-parser.js';
|
|
31
32
|
import { evaluateCompactMode } from '../engine/compact/threshold-evaluator.js';
|
|
@@ -137,16 +138,19 @@ function injectDriftBanner(result, banner) {
|
|
|
137
138
|
* Checks `projectPath` and `path` fields for directory traversal attacks.
|
|
138
139
|
* Also triggers lazy auto-start for hooks and workers when a projectPath is present.
|
|
139
140
|
*/
|
|
140
|
-
function sanitizeArgs(args) {
|
|
141
|
+
function sanitizeArgs(args, toolName) {
|
|
141
142
|
if (args === null || typeof args !== 'object') {
|
|
142
143
|
return args;
|
|
143
144
|
}
|
|
144
145
|
const obj = { ...args };
|
|
145
146
|
if (typeof obj.projectPath === 'string') {
|
|
146
147
|
obj.projectPath = sanitizePath(obj.projectPath);
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
// planu_status is a read-only snapshot. Starting background services here made
|
|
149
|
+
// its latency depend on repository size and violated the MCP readOnlyHint.
|
|
150
|
+
if (toolName !== 'planu_status') {
|
|
151
|
+
ensureHooksStarted(obj.projectPath);
|
|
152
|
+
ensureWorkersStarted(obj.projectPath);
|
|
153
|
+
}
|
|
150
154
|
// Read-only check: track whether planu.json exists (no file writes)
|
|
151
155
|
ensureProjectKnown(obj.projectPath);
|
|
152
156
|
}
|
|
@@ -161,8 +165,20 @@ function sanitizeArgs(args) {
|
|
|
161
165
|
}
|
|
162
166
|
return obj;
|
|
163
167
|
}
|
|
164
|
-
/**
|
|
165
|
-
const DEFAULT_TIMEOUT_MS =
|
|
168
|
+
/** Keep the server deadline below the common 60s client deadline. */
|
|
169
|
+
const DEFAULT_TIMEOUT_MS = 55_000;
|
|
170
|
+
const toolAbortContext = new AsyncLocalStorage();
|
|
171
|
+
const activeToolControllers = new Set();
|
|
172
|
+
/** Signal for the currently executing tool, including server deadline and client cancellation. */
|
|
173
|
+
export function getToolExecutionSignal() {
|
|
174
|
+
return toolAbortContext.getStore();
|
|
175
|
+
}
|
|
176
|
+
/** Abort every in-flight tool before stdio shutdown closes the transport. */
|
|
177
|
+
export function abortActiveToolExecutions(reason = 'MCP transport closed') {
|
|
178
|
+
for (const controller of activeToolControllers) {
|
|
179
|
+
controller.abort(new Error(reason));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
166
182
|
/**
|
|
167
183
|
* Session ID generated once per process startup.
|
|
168
184
|
* Used to group all tool calls from a single MCP session in the token ledger.
|
|
@@ -180,18 +196,55 @@ function getTimeoutMs() {
|
|
|
180
196
|
* Races a handler promise against a timeout. Returns a timeout error ToolResult
|
|
181
197
|
* if the handler exceeds the configured duration.
|
|
182
198
|
*/
|
|
183
|
-
async function withTimeout(fn, ms) {
|
|
199
|
+
async function withTimeout(fn, ms, upstreamSignal) {
|
|
184
200
|
let timer;
|
|
201
|
+
const controller = new AbortController();
|
|
202
|
+
activeToolControllers.add(controller);
|
|
203
|
+
const abortFromUpstream = () => {
|
|
204
|
+
controller.abort(upstreamSignal?.reason ?? new Error('Tool request aborted'));
|
|
205
|
+
};
|
|
206
|
+
if (upstreamSignal?.aborted === true) {
|
|
207
|
+
abortFromUpstream();
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
upstreamSignal?.addEventListener('abort', abortFromUpstream, { once: true });
|
|
211
|
+
}
|
|
185
212
|
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
186
213
|
timer = setTimeout(() => {
|
|
187
|
-
|
|
214
|
+
const error = new Error(`Tool execution timed out after ${ms}ms`);
|
|
215
|
+
controller.abort(error);
|
|
216
|
+
reject(error);
|
|
188
217
|
}, ms);
|
|
189
218
|
});
|
|
219
|
+
const abortPromise = new Promise((_resolve, reject) => {
|
|
220
|
+
const rejectAbort = () => {
|
|
221
|
+
const reason = controller.signal.reason;
|
|
222
|
+
const error = reason instanceof Error
|
|
223
|
+
? reason
|
|
224
|
+
: new Error(typeof reason === 'string' ? reason : 'Tool request aborted');
|
|
225
|
+
reject(error);
|
|
226
|
+
};
|
|
227
|
+
if (controller.signal.aborted) {
|
|
228
|
+
rejectAbort();
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
controller.signal.addEventListener('abort', rejectAbort, { once: true });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
const handlerPromise = toolAbortContext.run(controller.signal, () => fn(controller.signal));
|
|
190
235
|
try {
|
|
191
|
-
return await Promise.race([
|
|
236
|
+
return await Promise.race([handlerPromise, timeoutPromise, abortPromise]);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (controller.signal.aborted) {
|
|
240
|
+
await handlerPromise.then(() => undefined, () => undefined);
|
|
241
|
+
}
|
|
242
|
+
throw error;
|
|
192
243
|
}
|
|
193
244
|
finally {
|
|
194
245
|
clearTimeout(timer);
|
|
246
|
+
upstreamSignal?.removeEventListener('abort', abortFromUpstream);
|
|
247
|
+
activeToolControllers.delete(controller);
|
|
195
248
|
}
|
|
196
249
|
}
|
|
197
250
|
/**
|
|
@@ -220,7 +273,7 @@ export function withToolTimeout(p, ms, fallback) {
|
|
|
220
273
|
/**
|
|
221
274
|
* Wraps a tool handler so that any unhandled error is caught and returned as a
|
|
222
275
|
* graceful MCP error response, preventing server crashes. Also enforces a
|
|
223
|
-
* configurable execution timeout (default
|
|
276
|
+
* configurable execution timeout (default 55s, env PLANU_TOOL_TIMEOUT_MS).
|
|
224
277
|
*
|
|
225
278
|
* Usage — only for alwaysAllowed tools (activate_license, license_status, etc.):
|
|
226
279
|
* server.registerTool('my_tool', schema, safe(async (args) => { ... }));
|
|
@@ -234,7 +287,7 @@ export function safe(handler) {
|
|
|
234
287
|
*/
|
|
235
288
|
function safeWithTelemetry(toolName, handler) {
|
|
236
289
|
return async (args, extra) => {
|
|
237
|
-
const safeArgs = sanitizeArgs(args);
|
|
290
|
+
const safeArgs = sanitizeArgs(args, toolName);
|
|
238
291
|
const projectPath = safeArgs !== null && typeof safeArgs === 'object'
|
|
239
292
|
? safeArgs.projectPath
|
|
240
293
|
: undefined;
|
|
@@ -259,7 +312,15 @@ function safeWithTelemetry(toolName, handler) {
|
|
|
259
312
|
recordVerboseDecision();
|
|
260
313
|
}
|
|
261
314
|
try {
|
|
262
|
-
const
|
|
315
|
+
const upstreamSignal = extra &&
|
|
316
|
+
typeof extra === 'object' &&
|
|
317
|
+
extra.signal instanceof AbortSignal
|
|
318
|
+
? extra.signal
|
|
319
|
+
: undefined;
|
|
320
|
+
const rawResult = (await withTimeout((signal) => handler(safeArgs, {
|
|
321
|
+
...(extra && typeof extra === 'object' ? extra : {}),
|
|
322
|
+
signal,
|
|
323
|
+
}), getTimeoutMs(), upstreamSignal));
|
|
263
324
|
// Defensive guard: belt-and-suspenders alongside withUsageTracking guard and noImplicitReturns.
|
|
264
325
|
const result = rawResult ?? {
|
|
265
326
|
content: [{ type: 'text', text: 'Internal error: tool handler returned no result' }],
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { ToolResult, PlanStatusInput } from '../types/index.js';
|
|
2
|
-
export declare function handlePlanStatus(args: PlanStatusInput): Promise<ToolResult>;
|
|
2
|
+
export declare function handlePlanStatus(args: PlanStatusInput, signal?: AbortSignal | undefined): Promise<ToolResult>;
|
|
3
3
|
//# sourceMappingURL=status-handler.d.ts.map
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { listSpecs } from '../storage/spec-store.js';
|
|
2
2
|
import { getKnowledge } from '../storage/knowledge-store/knowledge.js';
|
|
3
|
-
// SPEC-1011 Bug F: fallback resolver using disk fingerprints
|
|
4
|
-
import { resolveProjectFromPath } from '../storage/project-resolver.js';
|
|
5
3
|
import { hashProjectPath, readJson } from '../storage/base-store.js';
|
|
6
4
|
import { execFile as execFileCb } from 'node:child_process';
|
|
7
5
|
import { promisify } from 'node:util';
|
|
8
6
|
import { join } from 'node:path';
|
|
9
7
|
import { readFile } from 'node:fs/promises';
|
|
10
8
|
import { shouldSuggestBtw, buildBtwHint } from '../engine/context-advisor/btw-advisor.js';
|
|
11
|
-
import { autoCompleteSpecs } from './git/auto-complete-ops.js';
|
|
12
9
|
import { computeSessionAgeMinutes } from '../engine/session-handoff-generator.js';
|
|
13
10
|
import { loadSessionState } from '../storage/session-state-store.js';
|
|
14
11
|
import { consumeUpdateBanner } from '../engine/update-notifier.js';
|
|
@@ -16,21 +13,32 @@ import { getPolicy, getRecords } from '../storage/approval-store.js';
|
|
|
16
13
|
import { calculateSLAStatus } from '../engine/approval-workflow.js';
|
|
17
14
|
import { readPlanuConfig } from '../engine/planu-config-writer.js';
|
|
18
15
|
import { computePlanuDriftScore } from '../engine/dashboard/drift-score.js';
|
|
19
|
-
import { findStaleBranches, findStaleWorktrees, findStaleStashes, } from '../engine/housekeeping/index.js';
|
|
20
16
|
import { loadTokenWastePolicy } from '../engine/token-optimizer/index.js';
|
|
21
17
|
import { getProjectGraphFreshnessHint } from '../engine/project-graph/index.js';
|
|
22
|
-
|
|
23
|
-
import { reconcileStatusFromDisk } from '../engine/status-reconciler/index.js';
|
|
18
|
+
import { getToolExecutionSignal } from './safe-handler.js';
|
|
24
19
|
const execFile = promisify(execFileCb);
|
|
20
|
+
const STATUS_READ_TIMEOUT_MS = 1_500;
|
|
25
21
|
// ---------------------------------------------------------------------------
|
|
26
22
|
// Git helpers
|
|
27
23
|
// ---------------------------------------------------------------------------
|
|
28
|
-
async function getGitState(projectPath) {
|
|
24
|
+
async function getGitState(projectPath, signal) {
|
|
29
25
|
try {
|
|
30
26
|
const [stagedResult, modifiedResult, branchResult] = await Promise.all([
|
|
31
|
-
execFile('git', ['diff', '--cached', '--name-only'], {
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
execFile('git', ['diff', '--cached', '--name-only'], {
|
|
28
|
+
cwd: projectPath,
|
|
29
|
+
timeout: STATUS_READ_TIMEOUT_MS,
|
|
30
|
+
signal,
|
|
31
|
+
}),
|
|
32
|
+
execFile('git', ['diff', '--name-only'], {
|
|
33
|
+
cwd: projectPath,
|
|
34
|
+
timeout: STATUS_READ_TIMEOUT_MS,
|
|
35
|
+
signal,
|
|
36
|
+
}),
|
|
37
|
+
execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
38
|
+
cwd: projectPath,
|
|
39
|
+
timeout: STATUS_READ_TIMEOUT_MS,
|
|
40
|
+
signal,
|
|
41
|
+
}),
|
|
34
42
|
]);
|
|
35
43
|
const staged = countGitPathLines(stagedResult.stdout);
|
|
36
44
|
const modified = countGitPathLines(modifiedResult.stdout);
|
|
@@ -183,18 +191,13 @@ function buildOutput(params) {
|
|
|
183
191
|
// ---------------------------------------------------------------------------
|
|
184
192
|
// Public handler
|
|
185
193
|
// ---------------------------------------------------------------------------
|
|
186
|
-
export async function handlePlanStatus(args) {
|
|
194
|
+
export async function handlePlanStatus(args, signal = getToolExecutionSignal()) {
|
|
195
|
+
signal?.throwIfAborted();
|
|
187
196
|
const projectId = args.projectId ?? hashProjectPath(args.projectPath);
|
|
188
197
|
const planuConfigPath = join(args.projectPath, 'planu.json');
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
// Skip the resolver fallback when projectId is explicitly provided — caller already
|
|
193
|
-
// knows the project; resolver would compute hash redundantly and break test contracts.
|
|
194
|
-
let knowledgeExists = (await getKnowledge(projectId)) !== null;
|
|
195
|
-
if (!knowledgeExists && args.projectId === undefined) {
|
|
196
|
-
knowledgeExists = (await resolveProjectFromPath(args.projectPath)) !== null;
|
|
197
|
-
}
|
|
198
|
+
// Status is strictly read-only. Missing knowledge is repaired by startupSync/init_project,
|
|
199
|
+
// never as a side effect of reading the status snapshot.
|
|
200
|
+
const knowledgeExists = (await getKnowledge(projectId)) !== null;
|
|
198
201
|
if (!knowledgeExists) {
|
|
199
202
|
return {
|
|
200
203
|
content: [
|
|
@@ -206,59 +209,21 @@ export async function handlePlanStatus(args) {
|
|
|
206
209
|
isError: true,
|
|
207
210
|
};
|
|
208
211
|
}
|
|
209
|
-
|
|
210
|
-
// Skip when projectId is explicitly provided to preserve callers' contract that projectPath
|
|
211
|
-
// is not re-hashed; the reconciler is best-effort observability and not load-bearing here.
|
|
212
|
-
if (args.projectId === undefined) {
|
|
213
|
-
void reconcileStatusFromDisk(args.projectPath, projectId).catch(() => {
|
|
214
|
-
/* best-effort */
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
const [snapshot, git, ci, slaBreaches, planuConfig, autoCompleted, sessionState, driftScore, pendingCleanup, tokenWastePolicy, graphFreshnessHint,] = await Promise.all([
|
|
212
|
+
const [snapshot, git, ci, slaBreaches, planuConfig, sessionState, driftScore, tokenWastePolicy, graphFreshnessHint,] = await Promise.all([
|
|
218
213
|
getStatusSpecSnapshot(projectId),
|
|
219
|
-
getGitState(args.projectPath),
|
|
214
|
+
getGitState(args.projectPath, signal),
|
|
220
215
|
getCiState(args.projectPath),
|
|
221
216
|
getSLABreaches(projectId),
|
|
222
217
|
readPlanuConfig(planuConfigPath, readFile),
|
|
223
|
-
// SPEC-720: autoCompleteSpecs now returns {completed, blocked} — extract completed for display
|
|
224
|
-
autoCompleteSpecs(projectId, args.projectPath)
|
|
225
|
-
.then((r) => r.completed)
|
|
226
|
-
.catch(() => []),
|
|
227
218
|
loadSessionState(args.projectPath).catch(() => null),
|
|
228
219
|
// SPEC-748: compute drift score from pending.json drift_review entries vs done specs
|
|
229
220
|
computePlanuDriftScore(args.projectPath).catch(() => null),
|
|
230
|
-
// SPEC-751: pending cleanup counters
|
|
231
|
-
(async () => {
|
|
232
|
-
try {
|
|
233
|
-
const [branchResult, worktrees, stashes] = await Promise.all([
|
|
234
|
-
findStaleBranches({ projectPath: args.projectPath }),
|
|
235
|
-
findStaleWorktrees({ projectPath: args.projectPath }),
|
|
236
|
-
findStaleStashes({ projectPath: args.projectPath }),
|
|
237
|
-
]);
|
|
238
|
-
return {
|
|
239
|
-
branches: branchResult.stale.length,
|
|
240
|
-
worktrees: worktrees.length,
|
|
241
|
-
stashes: stashes.length,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
catch {
|
|
245
|
-
return { branches: 0, worktrees: 0, stashes: 0 };
|
|
246
|
-
}
|
|
247
|
-
})(),
|
|
248
221
|
loadTokenWastePolicy(args.projectPath)
|
|
249
222
|
.then((loaded) => loaded.policy)
|
|
250
223
|
.catch(() => null),
|
|
251
224
|
getProjectGraphFreshnessHint({ projectId, projectPath: args.projectPath }).catch(() => undefined),
|
|
252
225
|
]);
|
|
253
|
-
|
|
254
|
-
let staleStatusWarnings = [];
|
|
255
|
-
try {
|
|
256
|
-
const { detectReleaseStatusDrift } = await import('../engine/drift-detection/release-status-drift.js');
|
|
257
|
-
staleStatusWarnings = await detectReleaseStatusDrift(args.projectPath);
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
/* best-effort — never block planu_status */
|
|
261
|
-
}
|
|
226
|
+
signal?.throwIfAborted();
|
|
262
227
|
const sessionAgeMinutes = sessionState !== null ? computeSessionAgeMinutes(sessionState.savedAt) : null;
|
|
263
228
|
const SESSION_TIP_THRESHOLD_MIN = 60;
|
|
264
229
|
const sessionTip = sessionAgeMinutes !== null && sessionAgeMinutes > SESSION_TIP_THRESHOLD_MIN
|
|
@@ -279,7 +244,6 @@ export async function handlePlanStatus(args) {
|
|
|
279
244
|
slaBreaches,
|
|
280
245
|
workMode,
|
|
281
246
|
updateBanner,
|
|
282
|
-
autoCompleted,
|
|
283
247
|
sessionTip,
|
|
284
248
|
tokenWasteHint,
|
|
285
249
|
graphFreshnessHint,
|
|
@@ -304,7 +268,6 @@ export async function handlePlanStatus(args) {
|
|
|
304
268
|
// SPEC-748: drift score
|
|
305
269
|
planu_drift_score: driftScore?.score ?? 0,
|
|
306
270
|
lastAuditAt: driftScore?.lastAuditAt ?? null,
|
|
307
|
-
...(autoCompleted.length > 0 ? { autoCompleted } : {}),
|
|
308
271
|
...(sessionTip !== undefined ? { sessionTip } : {}),
|
|
309
272
|
...(tokenWasteHint !== undefined ? { tokenWasteHint } : {}),
|
|
310
273
|
...(graphFreshnessHint !== undefined ? { graphFreshnessHint } : {}),
|
|
@@ -316,9 +279,7 @@ export async function handlePlanStatus(args) {
|
|
|
316
279
|
: {}),
|
|
317
280
|
...(updateBanner !== undefined ? { update_banner: updateBanner } : {}),
|
|
318
281
|
// SPEC-751: pending cleanup counters
|
|
319
|
-
pending_cleanup:
|
|
320
|
-
// SPEC-1012: stale status warnings (released but not done)
|
|
321
|
-
...(staleStatusWarnings.length > 0 ? { staleStatusWarnings } : {}),
|
|
282
|
+
pending_cleanup: { branches: 0, worktrees: 0, stashes: 0 },
|
|
322
283
|
},
|
|
323
284
|
};
|
|
324
285
|
}
|
|
@@ -5,12 +5,11 @@ import { addLesson, getLessons } from '../../storage/lessons-store.js';
|
|
|
5
5
|
import { dispatchFeedbackEvent } from '../learn.js';
|
|
6
6
|
import { auditDeps } from '../../engine/dep-auditor/index.js';
|
|
7
7
|
import { getComplianceGateConfig } from '../../storage/compliance-gate-config-store.js';
|
|
8
|
-
import { readJson, writeJson, projectDataDir } from '../../storage/base-store.js';
|
|
9
|
-
import { randomUUID } from 'node:crypto';
|
|
10
8
|
import { withEscalation } from '../../engine/escalator/with-escalation.js';
|
|
11
9
|
import { writeImplementationReviewReport } from '../../engine/validator/validation-report-writer.js';
|
|
12
10
|
import { writeSpecReviewFeedback } from '../../engine/validator/spec-review-writer.js';
|
|
13
11
|
import { formatKeyValue } from '../output-formatter.js';
|
|
12
|
+
import { recordForceDoneBypass } from './force-done-audit.js';
|
|
14
13
|
/**
|
|
15
14
|
* SPEC-721 / SPEC-222 Trigger 1: Run validate engine before marking done.
|
|
16
15
|
*
|
|
@@ -28,7 +27,6 @@ import { formatKeyValue } from '../output-formatter.js';
|
|
|
28
27
|
* @param budgetMs Max milliseconds to wait for validate. Default: 9000.
|
|
29
28
|
*/
|
|
30
29
|
export async function runValidateGate(spec, projectPath, forceStatus, forceStatusReason, budgetMs = 9_000, reviewContext) {
|
|
31
|
-
// ---- Path A: explicit force bypass ----------------------------------------
|
|
32
30
|
if (forceStatus) {
|
|
33
31
|
if (!forceStatusReason || forceStatusReason.trim().length < 100) {
|
|
34
32
|
return {
|
|
@@ -63,7 +61,12 @@ export async function runValidateGate(spec, projectPath, forceStatus, forceStatu
|
|
|
63
61
|
scoreSource: 'forced-best-effort-validateSpec',
|
|
64
62
|
};
|
|
65
63
|
}
|
|
66
|
-
|
|
64
|
+
if (reviewContext) {
|
|
65
|
+
const report = await readApprovedValidationReportGate(reviewContext.specId, reviewContext.projectId, false);
|
|
66
|
+
if (report.ok && report.score === 100) {
|
|
67
|
+
return { blocked: false, score: 100, forced: false };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
67
70
|
const TIMEOUT_SENTINEL = Symbol('timeout');
|
|
68
71
|
let validateResult;
|
|
69
72
|
try {
|
|
@@ -247,33 +250,6 @@ export async function checkSecurityGate(projectPath, specId, force) {
|
|
|
247
250
|
return null;
|
|
248
251
|
}
|
|
249
252
|
}
|
|
250
|
-
function forceDoneBypassLogPath(projectId) {
|
|
251
|
-
return `${projectDataDir(projectId)}/force-bypass-log.json`;
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* Writes an audit record when force=true bypasses DoD gates on 'done' transition.
|
|
255
|
-
* Never throws — audit failures must not block transitions.
|
|
256
|
-
*/
|
|
257
|
-
async function recordForceDoneBypass(specId, projectId, reason, failingItems, warningItems) {
|
|
258
|
-
try {
|
|
259
|
-
const logPath = forceDoneBypassLogPath(projectId);
|
|
260
|
-
const entries = await readJson(logPath, []);
|
|
261
|
-
entries.push({
|
|
262
|
-
id: randomUUID(),
|
|
263
|
-
timestamp: new Date().toISOString(),
|
|
264
|
-
action: 'force_done_bypass',
|
|
265
|
-
specId,
|
|
266
|
-
projectId,
|
|
267
|
-
reason,
|
|
268
|
-
failingItems,
|
|
269
|
-
warningItems,
|
|
270
|
-
});
|
|
271
|
-
await writeJson(logPath, entries);
|
|
272
|
-
}
|
|
273
|
-
catch {
|
|
274
|
-
/* best-effort — never block transition due to audit failures */
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
253
|
/**
|
|
278
254
|
* SPEC-725: Check the validation-report artifact gate before marking done.
|
|
279
255
|
* SPEC-1050: Fail closed. A done transition requires a fresh validation-report
|
|
@@ -5,7 +5,7 @@ import { readEvidenceArtifacts } from '../../engine/evidence-gates/artifact-read
|
|
|
5
5
|
import { checkLifecycleEvidenceGate } from '../../engine/evidence-gates/lifecycle-gate.js';
|
|
6
6
|
import { buildSpecEvidenceIndex } from '../../engine/evidence-index/index-builder.js';
|
|
7
7
|
import { checkDoneDriftContract } from '../../engine/evidence-index/done-drift.js';
|
|
8
|
-
import {
|
|
8
|
+
import { parseGroundedTechnicalPaths } from '../../engine/spec-grounding/contract.js';
|
|
9
9
|
import { extractAcceptanceCriteriaTexts } from '../../engine/spec-format/acceptance-criteria.js';
|
|
10
10
|
import { formatKeyValue } from '../output-formatter.js';
|
|
11
11
|
/** SPEC-1054: BDD/SDD lifecycle evidence gate. */
|
|
@@ -33,9 +33,7 @@ export async function checkLifecycleEvidenceTransitionGate(args) {
|
|
|
33
33
|
artifacts,
|
|
34
34
|
projectPath: args.projectPath,
|
|
35
35
|
});
|
|
36
|
-
const groundedTechnicalPaths = body
|
|
37
|
-
? parseTechnicalReferenceGroundingRecords(body).map((record) => record.path)
|
|
38
|
-
: [];
|
|
36
|
+
const groundedTechnicalPaths = body ? parseGroundedTechnicalPaths(body) : [];
|
|
39
37
|
result.issues.push(...checkDoneDriftContract({ index, groundedTechnicalPaths }));
|
|
40
38
|
}
|
|
41
39
|
if (result.issues.length === 0) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { projectDataDir, readJson, writeJson } from '../../storage/base-store.js';
|
|
3
|
+
export async function recordForceDoneBypass(specId, projectId, reason, failingItems, warningItems) {
|
|
4
|
+
try {
|
|
5
|
+
const logPath = `${projectDataDir(projectId)}/force-bypass-log.json`;
|
|
6
|
+
const entries = await readJson(logPath, []);
|
|
7
|
+
entries.push({
|
|
8
|
+
id: randomUUID(),
|
|
9
|
+
timestamp: new Date().toISOString(),
|
|
10
|
+
action: 'force_done_bypass',
|
|
11
|
+
specId,
|
|
12
|
+
projectId,
|
|
13
|
+
reason,
|
|
14
|
+
failingItems,
|
|
15
|
+
warningItems,
|
|
16
|
+
});
|
|
17
|
+
await writeJson(logPath, entries);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Best effort: audit storage failure must not hide the original gate result.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=force-done-audit.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolResult } from '../../types/index.js';
|
|
2
2
|
import type { Spec } from '../../types/spec/core.js';
|
|
3
|
+
/** SPEC-642 / SPEC-1111 / SPEC-1144: verify canonical QA evidence before done. */
|
|
3
4
|
export declare function checkQaGate(spec: Spec, projectPath: string | undefined, force: boolean): Promise<ToolResult | null>;
|
|
4
5
|
//# sourceMappingURL=qa-gate.d.ts.map
|
|
@@ -1,36 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { getToolExecutionSignal } from '../safe-handler.js';
|
|
2
|
+
/** SPEC-642 / SPEC-1111 / SPEC-1144: verify canonical QA evidence before done. */
|
|
3
3
|
export async function checkQaGate(spec, projectPath, force) {
|
|
4
4
|
if (force || !projectPath) {
|
|
5
5
|
return null;
|
|
6
6
|
}
|
|
7
7
|
const specId = spec.id;
|
|
8
|
-
const {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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) {
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
if (await hasPassedExternalQaEvidence(spec, projectPath)) {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
let effectiveResult = state?.lastResult ?? null;
|
|
23
|
-
const canReuseLocalQa = effectiveResult !== null &&
|
|
24
|
-
effectiveResult.fingerprint === fingerprint &&
|
|
25
|
-
freshEnough &&
|
|
26
|
-
effectiveResult.passed;
|
|
27
|
-
if (!canReuseLocalQa) {
|
|
28
|
-
effectiveResult = await runQaGate(spec, projectPath);
|
|
29
|
-
await saveGateState(projectPath, specId, effectiveResult);
|
|
30
|
-
}
|
|
31
|
-
if (effectiveResult === null) {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
8
|
+
const { runQaGate } = await import('../../engine/qa-gate.js');
|
|
9
|
+
const effectiveResult = await runQaGate(spec, projectPath, {
|
|
10
|
+
projectId: spec.projectId,
|
|
11
|
+
signal: getToolExecutionSignal(),
|
|
12
|
+
});
|
|
34
13
|
if (effectiveResult.passed) {
|
|
35
14
|
return null;
|
|
36
15
|
}
|
|
@@ -63,27 +42,4 @@ export async function checkQaGate(spec, projectPath, force) {
|
|
|
63
42
|
},
|
|
64
43
|
};
|
|
65
44
|
}
|
|
66
|
-
async function hasPassedExternalQaEvidence(spec, projectPath) {
|
|
67
|
-
try {
|
|
68
|
-
const { readEvidenceArtifacts } = await import('../../engine/evidence-gates/artifact-reader.js');
|
|
69
|
-
const artifacts = await readEvidenceArtifacts({
|
|
70
|
-
spec,
|
|
71
|
-
projectId: spec.projectId,
|
|
72
|
-
specId: spec.id,
|
|
73
|
-
projectPath,
|
|
74
|
-
});
|
|
75
|
-
const rows = artifacts.traceabilityMatrix?.rows ?? [];
|
|
76
|
-
return rows.length > 0 && rows.every((row) => isPassedQaEvidence(row.validationEvidence));
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
function isPassedQaEvidence(value) {
|
|
83
|
-
if (!value) {
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
return (/\b(?:pnpm\s+validate|pnpm\s+test|pnpm\s+typecheck)\b[\s\S]{0,120}\bpass(?:ed|ing)?\b/i.test(value) ||
|
|
87
|
-
/\bpass(?:ed|ing)?\b[\s\S]{0,120}\b(?:pnpm\s+validate|pnpm\s+test|pnpm\s+typecheck)\b/i.test(value));
|
|
88
|
-
}
|
|
89
45
|
//# sourceMappingURL=qa-gate.js.map
|