@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +129 -22
  2. package/dist/engine/abortable-process-runner.d.ts +8 -0
  3. package/dist/engine/abortable-process-runner.js +107 -0
  4. package/dist/engine/cascade-hooks/core/append-releases.d.ts +1 -0
  5. package/dist/engine/cascade-hooks/core/append-releases.js +51 -14
  6. package/dist/engine/cascade-hooks/hooks/orchestration-plan.hook.js +16 -21
  7. package/dist/engine/ci-generator/yaml-builder.js +5 -1
  8. package/dist/engine/context-md-generator.js +3 -1
  9. package/dist/engine/criterion-identity.d.ts +6 -0
  10. package/dist/engine/criterion-identity.js +63 -0
  11. package/dist/engine/evidence-gates/lifecycle-gate.js +22 -44
  12. package/dist/engine/evidence-index/index-builder.js +7 -19
  13. package/dist/engine/frontmatter-parser.d.ts +5 -0
  14. package/dist/engine/frontmatter-parser.js +24 -0
  15. package/dist/engine/project-command-runtime.d.ts +5 -0
  16. package/dist/{tools/validate-runtime.js → engine/project-command-runtime.js} +8 -21
  17. package/dist/engine/project-graph/builder.js +1 -35
  18. package/dist/engine/project-graph/cache.js +37 -1
  19. package/dist/engine/qa-gate.d.ts +4 -1
  20. package/dist/engine/qa-gate.js +72 -59
  21. package/dist/engine/safety/cross-process-lock.d.ts +1 -6
  22. package/dist/engine/safety/cross-process-lock.js +18 -16
  23. package/dist/engine/safety/lock-scavenger.d.ts +9 -0
  24. package/dist/engine/safety/lock-scavenger.js +114 -0
  25. package/dist/engine/session-context-generator.js +24 -2
  26. package/dist/engine/session-safeguard/learnings-buffer.js +7 -2
  27. package/dist/engine/session-state/writer.js +15 -8
  28. package/dist/engine/skill-registry/installer.js +1 -1
  29. package/dist/engine/spec-format/acceptance-criteria.js +8 -26
  30. package/dist/engine/spec-grounding/contract.d.ts +3 -1
  31. package/dist/engine/spec-grounding/contract.js +39 -14
  32. package/dist/engine/universal-rules/installer.js +2 -2
  33. package/dist/engine/validation-evidence-digest.d.ts +4 -0
  34. package/dist/engine/validation-evidence-digest.js +83 -0
  35. package/dist/engine/validation-evidence-ledger.d.ts +30 -0
  36. package/dist/engine/validation-evidence-ledger.js +274 -0
  37. package/dist/engine/validation-impact-planner.d.ts +4 -0
  38. package/dist/engine/validation-impact-planner.js +71 -0
  39. package/dist/engine/validator/spec-compliance-runner.d.ts +1 -1
  40. package/dist/engine/validator/spec-compliance-runner.js +37 -40
  41. package/dist/engine/validator/validation-report-writer.d.ts +1 -0
  42. package/dist/engine/validator/validation-report-writer.js +1 -1
  43. package/dist/engine/validator.js +7 -11
  44. package/dist/storage/convention-baseline.js +5 -0
  45. package/dist/storage/session-state-store.js +13 -6
  46. package/dist/storage/spec-store.d.ts +5 -0
  47. package/dist/storage/spec-store.js +7 -0
  48. package/dist/tools/git/git-helpers.d.ts +5 -1
  49. package/dist/tools/git/git-helpers.js +3 -2
  50. package/dist/tools/init-project/conventions-writer.js +1 -1
  51. package/dist/tools/init-project/scaffold-writer.js +1 -1
  52. package/dist/tools/safe-handler.d.ts +5 -1
  53. package/dist/tools/safe-handler.js +73 -12
  54. package/dist/tools/status-handler.d.ts +1 -1
  55. package/dist/tools/status-handler.js +27 -66
  56. package/dist/tools/update-status/dod-gates.js +7 -31
  57. package/dist/tools/update-status/evidence-gate.js +2 -4
  58. package/dist/tools/update-status/force-done-audit.d.ts +2 -0
  59. package/dist/tools/update-status/force-done-audit.js +23 -0
  60. package/dist/tools/update-status/qa-gate.d.ts +1 -0
  61. package/dist/tools/update-status/qa-gate.js +7 -51
  62. package/dist/tools/update-status-convention-gate.js +132 -97
  63. package/dist/tools/validate-lint.d.ts +1 -1
  64. package/dist/tools/validate-lint.js +78 -165
  65. package/dist/tools/validate.js +3 -1
  66. package/dist/transports/http-transport.js +22 -8
  67. package/dist/transports/mcp-types.d.ts +2 -2
  68. package/dist/transports/transport-factory.js +110 -12
  69. package/dist/types/abortable-process.d.ts +26 -0
  70. package/dist/types/abortable-process.js +2 -0
  71. package/dist/types/criterion-identity.d.ts +12 -0
  72. package/dist/types/criterion-identity.js +2 -0
  73. package/dist/types/index.d.ts +2 -0
  74. package/dist/types/index.js +2 -0
  75. package/dist/types/qa-gate.d.ts +0 -5
  76. package/dist/types/spec-format.d.ts +2 -0
  77. package/dist/types/spec-lock-v2.d.ts +7 -0
  78. package/dist/types/validation-evidence.d.ts +62 -0
  79. package/dist/types/validation-evidence.js +2 -0
  80. package/package.json +43 -23
  81. package/planu-native.json +1 -1
  82. package/planu-plugin.json +1 -1
  83. package/dist/storage/qa-gate-store.d.ts +0 -4
  84. package/dist/storage/qa-gate-store.js +0 -24
  85. package/dist/tools/validate-runtime.d.ts +0 -14
  86. package/dist/types/data/estimation.d.ts +0 -147
  87. package/dist/types/data/estimation.js +0 -2
  88. package/dist/types/data/index.d.ts +0 -5
  89. package/dist/types/data/index.js +0 -6
  90. package/dist/types/data/velocity.d.ts +0 -168
  91. 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(`- Project root: \`${knowledge.projectPath}\``);
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-kevin-Documents-desarrollo-sdd-mcp-server');
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 60s, env PLANU_TOOL_TIMEOUT_MS).
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
- // Auto-start hooks + workers on first tool call with projectPath (fire-and-forget)
148
- ensureHooksStarted(obj.projectPath);
149
- ensureWorkersStarted(obj.projectPath);
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
- /** Default timeout: 60 seconds. Override via PLANU_TOOL_TIMEOUT_MS env var. */
165
- const DEFAULT_TIMEOUT_MS = 60_000;
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
- reject(new Error(`Tool execution timed out after ${ms}ms`));
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([fn(), timeoutPromise]);
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 60s, env PLANU_TOOL_TIMEOUT_MS).
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 rawResult = (await withTimeout(() => handler(safeArgs, extra), getTimeoutMs()));
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
- // SPEC-1011 Bug G: fire-and-forget status.json reconciliation
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'], { cwd: projectPath }),
32
- execFile('git', ['diff', '--name-only'], { cwd: projectPath }),
33
- execFile('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }),
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
- // SPEC-1011 Bug F: Use resilient resolver that falls back to disk fingerprints.
190
- // If knowledge.json is missing but planu/ or data/projects/{id}/ exist, the project
191
- // is recovered silently and a minimal knowledge entry is written.
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
- // SPEC-1011 Bug G: reconcile status.json with disk reality (fire-and-forget, non-blocking).
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
- // SPEC-1012: Detect stale release status (best-effort, non-blocking)
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: pendingCleanup,
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
- // ---- Normal path — fail-CLOSED --------------------------------------------
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 { parseTechnicalReferenceGroundingRecords } from '../../engine/spec-grounding/contract.js';
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,2 @@
1
+ export declare function recordForceDoneBypass(specId: string, projectId: string, reason: string, failingItems: string[], warningItems: string[]): Promise<void>;
2
+ //# sourceMappingURL=force-done-audit.d.ts.map
@@ -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
- /** SPEC-642 / SPEC-1111: verify and persist local QA evidence before done. */
2
- const QA_GATE_TTL_MS = 15 * 60 * 1000;
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 { 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);
11
- const state = await getGateState(projectPath, specId).catch(() => null);
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) {
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