@patwoodman/dsh-sdlc-flow-core 0.4.0-beta.22 → 0.4.0-beta.24

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/README.md CHANGED
@@ -22,7 +22,7 @@ dsh plugin --profile <profile> add @patwoodman/dsh-sdlc-flow-core
22
22
  ## Requirements
23
23
 
24
24
  - `@deepseek-ai/dsh` `>=0.2.0-rc.2 <0.3.0-0`
25
- - Node.js ≥ 22.5 (`node:sqlite`)
25
+ - Node.js ≥ 22.13 (`node:sqlite`)
26
26
 
27
27
  ## License
28
28
 
package/lib/escalation.js CHANGED
@@ -101,17 +101,27 @@ export function planEscalation(block, { mode = 'interactive' } = {}) {
101
101
  };
102
102
  }
103
103
 
104
- /** Apply a decision to a blocked task/project. */
105
- export function applyResolution({ resolution, optionId = null, decidedBy = null } = {}) {
104
+ /**
105
+ * Apply a decision to a blocked task/project.
106
+ *
107
+ * Approval alone is not a corrective plan: `ESCALATED→READY` requires
108
+ * `correctivePlanApproved === true`, which is granted only when an explicit
109
+ * `correctivePlan` accompanies the approval. Rejecting/cancelling blocks.
110
+ */
111
+ export function applyResolution({ resolution, optionId = null, decidedBy = null, correctivePlan = null } = {}) {
106
112
  if (!['approved', 'rejected', 'cancelled', 'unavailable'].includes(resolution)) {
107
113
  throw new Error(`invalid resolution: ${resolution}`);
108
114
  }
109
- const unblock = resolution === 'approved';
115
+ const approved = resolution === 'approved';
116
+ const unblock = approved && correctivePlan != null;
110
117
  return {
111
118
  resolution,
112
119
  optionId,
113
120
  decidedBy,
114
- taskTo: unblock ? 'READY' : 'BLOCKED',
121
+ correctivePlanApproved: unblock,
122
+ blockerResolved: unblock,
123
+ waiting_on: unblock ? null : 'user-decision',
124
+ taskTo: unblock ? 'READY' : (approved ? 'ESCALATED' : 'BLOCKED'),
115
125
  projectTo: unblock ? null : 'BLOCKED',
116
126
  };
117
127
  }
@@ -64,9 +64,20 @@ export function integrationSteps(task, { mergeBase = null } = {}) {
64
64
  */
65
65
  export function postMergeOutcome({ gatesPass, mergeCommit, failedTasks = [] } = {}) {
66
66
  if (gatesPass) return { action: 'done', tag: true };
67
+ // Never guess the merge commit: `git revert -m 1 HEAD` on a missing SHA can
68
+ // revert an unrelated merge. A failed post-merge gate without a commit is an
69
+ // explicit refusal, not a HEAD fallback.
70
+ if (typeof mergeCommit !== 'string' || mergeCommit.trim() === '' || mergeCommit.trim() === 'HEAD') {
71
+ return {
72
+ action: 'refuse',
73
+ failedTasks,
74
+ reason: 'MISSING_MERGE_COMMIT',
75
+ detail: 'post-merge gate failure requires the merge commit to revert',
76
+ };
77
+ }
67
78
  return {
68
79
  action: 'revert',
69
- argv: ['git', 'revert', '-m', '1', mergeCommit ?? 'HEAD'],
80
+ argv: ['git', 'revert', '-m', '1', mergeCommit],
70
81
  failedTasks,
71
82
  reason: 'POST_MERGE_GATE_FAILURE',
72
83
  };
package/lib/recovery.js CHANGED
@@ -78,11 +78,14 @@ export function reconcile({
78
78
  transitions.push({ taskId: task.id, to: 'ESCALATED', actor: 'recovery', evidence: 'active-state-without-run' });
79
79
  continue;
80
80
  }
81
- const deadPid = run.pid != null && !isAlive(run.pid);
81
+ // A pid of null is not a live worker: in-process/API runs record no pid, and
82
+ // a crash leaves them unfinished forever. findOrphans already treats null as
83
+ // an orphan ('no-pid'); reconcile must agree or the resume path strands them.
84
+ const deadPid = run.pid == null || !isAlive(run.pid);
82
85
  const missingWorktree = Boolean(task.worktree_path) && !worktreeExists(task.worktree_path);
83
86
  const missingBranch = Boolean(task.branch) && !branchExists(task.branch);
84
87
 
85
- if (deadPid) discrepancies.push({ taskId: task.id, kind: 'dead-worker', detail: `pid ${run.pid} is not alive` });
88
+ if (deadPid) discrepancies.push({ taskId: task.id, kind: 'dead-worker', detail: run.pid == null ? 'no-pid' : `pid ${run.pid} is not alive` });
86
89
  if (missingWorktree) discrepancies.push({ taskId: task.id, kind: 'missing-worktree', detail: task.worktree_path });
87
90
  if (missingBranch) discrepancies.push({ taskId: task.id, kind: 'missing-branch', detail: task.branch });
88
91
 
package/lib/runtime.js CHANGED
@@ -15,12 +15,13 @@ import { existsSync, mkdirSync } from 'node:fs';
15
15
  import { openDatabase, migrate, quickCheck } from './schema.js';
16
16
  import { TaskStateService } from './state-machine.js';
17
17
  import { ModelRouter } from './router.js';
18
- import { budgetsForRisk, normalizeTaskBudget, PROJECT_BUDGET_DEFAULTS, checkDispatch, checkProjectBudget } from './budget.js';
18
+ import { budgetsForRisk, normalizeTaskBudget, PROJECT_BUDGET_DEFAULTS, checkDispatch, checkProjectBudget, evaluateRun } from './budget.js';
19
19
  import { riskClass } from './risk.js';
20
20
  import { InstanceLock, InstanceLockedError } from './instance-lock.js';
21
21
  import { deriveWorkerResult, scopeVerdict, resolveClaims } from './worker-result.js';
22
22
  import { exportMetrics } from './metrics.js';
23
23
  import { GuardPlugin } from '@patwoodman/dsh-sdlc-flow-guard';
24
+ import { applyResolution } from './escalation.js';
24
25
 
25
26
  export class RuntimeLockedError extends Error {
26
27
  constructor(holder) {
@@ -213,13 +214,16 @@ export class Runtime {
213
214
 
214
215
  // ── worker runs ──────────────────────────────────────────────────────────
215
216
 
216
- startWorkerRun({ taskId, attempt, sessionId = null, pid = null, model = null, worktreePath = null, contextManifest = null, id = null }) {
217
+ startWorkerRun({ taskId, attempt, sessionId = null, pid = null, model = null, worktreePath = null, contextManifest = null, nonce = null, id = null }) {
217
218
  const runId = id ?? `${taskId}-a${attempt}-${randomUUID().slice(0, 8)}`;
218
219
  this.db.prepare(
219
220
  `INSERT INTO worker_runs(id, task_id, attempt, session_id, pid, model_provider, model_id,
220
221
  status, started_at, worktree_path, context_manifest_json)
221
222
  VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?)`,
222
- ).run(runId, taskId, attempt, sessionId, pid, model?.provider ?? null, model?.id ?? null, this.now(), worktreePath, contextManifest ? JSON.stringify(contextManifest) : null);
223
+ ).run(
224
+ runId, taskId, attempt, sessionId, pid, model?.provider ?? null, model?.id ?? null, this.now(), worktreePath,
225
+ contextManifest || nonce ? JSON.stringify({ ...(contextManifest ?? {}), ...(nonce ? { nonce } : {}) }) : null,
226
+ );
223
227
  return runId;
224
228
  }
225
229
 
@@ -251,9 +255,26 @@ export class Runtime {
251
255
  * resolve the result channel, and persist. Never trusts the worker (L.1.2).
252
256
  */
253
257
  ingestResult({ runId, filesChanged, contract = {}, outboxRaw = null, finalText = null, runMeta = {}, runtime = {} }) {
254
- const claims = resolveClaims({ outboxRaw, finalText, runMeta });
258
+ // Derive the run identity (and the nonce, when the dispatcher stored one)
259
+ // from the worker_runs row; caller-supplied fields still win.
260
+ const storedRun = this.getWorkerRun(runId);
261
+ const effectiveMeta = {
262
+ runId: storedRun?.id,
263
+ taskId: storedRun?.task_id,
264
+ attempt: storedRun?.attempt,
265
+ nonce: storedRun?.context_manifest?.nonce,
266
+ ...runMeta,
267
+ };
268
+ const claims = resolveClaims({ outboxRaw, finalText, runMeta: effectiveMeta });
255
269
  const result = deriveWorkerResult({ filesChanged, contract, claims: claims.claims, runtime });
256
270
  const scope = scopeVerdict(result);
271
+ const taskRow = this.db.prepare('SELECT id, state, attempt, risk_class FROM tasks WHERE id = (SELECT task_id FROM worker_runs WHERE id = ?)').get(runId);
272
+ const spendBefore = taskRow ? this.spentForTask(taskRow.id) : { costUsd: 0, durationMs: 0, tokens: 0 };
273
+ const projectBefore = this.spentTotals();
274
+ const costKnown = typeof runtime.costUsd === 'number';
275
+ const tokensKnown = runtime.tokens != null && typeof runtime.tokens === 'object';
276
+ const tokensTotal = tokensKnown ? (runtime.tokens.in ?? 0) + (runtime.tokens.out ?? 0) : null;
277
+
257
278
  this.finishWorkerRun(runId, {
258
279
  status: result.status,
259
280
  exitCode: runtime.exitCode ?? null,
@@ -262,7 +283,33 @@ export class Runtime {
262
283
  costUsd: runtime.costUsd ?? 0,
263
284
  result: { ...result, claims_source: claims.source, scope },
264
285
  });
265
- return { result, scope, claimsSource: claims.source };
286
+
287
+ // Post-run budget accounting. The old path recorded any cost and still let
288
+ // the result proceed to VERIFYING/ACCEPTED; a per-attempt overrun or project
289
+ // breach is now evidence (FAILED) before acceptance can happen. Missing
290
+ // cost/tokens is UNKNOWN, never zero.
291
+ let budget = { action: 'unknown', available: false, reason: 'COST_UNKNOWN' };
292
+ if (taskRow && costKnown && tokensKnown) {
293
+ budget = {
294
+ available: true,
295
+ ...evaluateRun({
296
+ attempt: taskRow.attempt ?? 0,
297
+ run: { costUsd: runtime.costUsd ?? 0, tokens: tokensTotal, durationMs: runtime.durationMs ?? 0 },
298
+ taskBudget: budgetsForRisk(taskRow.risk_class, this.config.budgets?.perTask),
299
+ taskSpend: { costUsd: spendBefore.costUsd },
300
+ projectBudget: this.config.budgets?.perProject ?? PROJECT_BUDGET_DEFAULTS,
301
+ projectSpend: projectBefore,
302
+ }),
303
+ };
304
+ const breach = budget.action === 'block' || budget.perAttemptOver === true;
305
+ if (breach && taskRow.state === 'IN_PROGRESS') {
306
+ this.state.transition(taskRow.id, 'FAILED', 'worker-mgr', {
307
+ hardFailure: true,
308
+ payload: { code: budget.reason ?? 'BUDGET_EXHAUSTED', budget_action: budget.action },
309
+ });
310
+ }
311
+ }
312
+ return { result, scope, claimsSource: claims.source, budget };
266
313
  }
267
314
 
268
315
  // ── routing, budgets, model runs ─────────────────────────────────────────
@@ -375,6 +422,64 @@ export class Runtime {
375
422
  : this.db.prepare('SELECT * FROM requirements ORDER BY created_at').all();
376
423
  }
377
424
 
425
+ // ── escalations (decision authority stays human/lead) ───────────────────
426
+
427
+ /** Persist an escalation decision packet before it is put to the user. */
428
+ recordEscalation({ id, taskId = null, attempt = null, level = null, packet = null } = {}) {
429
+ if (typeof id !== 'string' || id === '') throw new Error('recordEscalation: id is required');
430
+ this.db.prepare(
431
+ `INSERT INTO escalations(id, task_id, attempt, level, packet_json, created_at)
432
+ VALUES (?, ?, ?, ?, ?, ?)`,
433
+ ).run(id, taskId, attempt, level, JSON.stringify(packet ?? {}), this.now());
434
+ this.state.record({
435
+ taskId, actor: 'escalation-mgr', action: 'escalation-recorded',
436
+ payload: { escalation_id: id, level },
437
+ });
438
+ return this.getEscalation(id);
439
+ }
440
+
441
+ getEscalation(id) {
442
+ const row = this.db.prepare('SELECT * FROM escalations WHERE id = ?').get(id);
443
+ if (!row) return null;
444
+ return { ...row, packet: safeJson(row.packet_json, {}) };
445
+ }
446
+
447
+ getEscalations({ taskId = null } = {}) {
448
+ const rows = taskId
449
+ ? this.db.prepare('SELECT * FROM escalations WHERE task_id = ? ORDER BY created_at').all(taskId)
450
+ : this.db.prepare('SELECT * FROM escalations ORDER BY created_at').all();
451
+ return rows.map((row) => ({ ...row, packet: safeJson(row.packet_json, {}) }));
452
+ }
453
+
454
+ /**
455
+ * Apply a recorded decision. Approval without an explicit corrective plan
456
+ * leaves the task ESCALATED (the state guard requires the plan); rejection
457
+ * blocks it. Returns the resolution context actually applied.
458
+ */
459
+ resolveEscalation(id, { resolution, decidedBy = null, optionId = null, correctivePlan = null } = {}) {
460
+ const row = this.getEscalation(id);
461
+ if (!row) throw new Error(`resolveEscalation: unknown escalation ${id}`);
462
+ const ctx = applyResolution({ resolution, optionId, decidedBy, correctivePlan });
463
+ this.db.prepare(
464
+ 'UPDATE escalations SET resolution = ?, resolved_by = ?, resolved_at = ? WHERE id = ?',
465
+ ).run(ctx.resolution, decidedBy, this.now(), id);
466
+ this.state.record({
467
+ taskId: row.task_id ?? null, actor: 'escalation-mgr', action: 'escalation-resolved',
468
+ payload: { escalation_id: id, resolution: ctx.resolution, decided_by: decidedBy, option_id: optionId },
469
+ });
470
+ const task = row.task_id ? this.getTask(row.task_id) : null;
471
+ let transition = null;
472
+ if (task && task.state === 'ESCALATED' && ctx.taskTo !== 'ESCALATED') {
473
+ transition = this.state.transition(row.task_id, ctx.taskTo, 'escalation-mgr', {
474
+ ...ctx,
475
+ correctivePlanApproved: ctx.correctivePlanApproved,
476
+ userDecisionRequired: ctx.resolution !== 'approved',
477
+ payload: { escalation_id: id, resolution: ctx.resolution },
478
+ });
479
+ }
480
+ return { ...ctx, transition };
481
+ }
482
+
378
483
  // ── evidence ─────────────────────────────────────────────────────────────
379
484
 
380
485
  recordTestResults(taskId, runId, gateResults) {
package/lib/scheduler.js CHANGED
@@ -158,11 +158,14 @@ export function planStep(state, {
158
158
  }
159
159
  }
160
160
 
161
- // Recompute active set including transitions proposed above (READY is not active).
162
- const projected = tasks.map((t) => {
163
- const change = transitions.find((x) => x.taskId === t.id);
164
- return change ? { ...t, state: change.to } : t;
165
- });
161
+ // Recompute active set including transitions proposed above (READY is not
162
+ // active). Copy every task: the working snapshot mutates state below, and
163
+ // aliasing the caller's objects would leak those mutations into its DAG.
164
+ const projected = tasks.map((task) => ({ ...task }));
165
+ for (const change of transitions) {
166
+ const task = projected.find((candidate) => candidate.id === change.taskId);
167
+ if (task) task.state = change.to;
168
+ }
166
169
 
167
170
  let activeCount = activeTasks(projected).length;
168
171
  for (const task of projected) {
package/lib/schema.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { DatabaseSync } from 'node:sqlite';
14
+ import { copyFileSync, existsSync } from 'node:fs';
14
15
 
15
16
  export const SCHEMA_VERSION = 1;
16
17
 
@@ -214,7 +215,28 @@ export const TABLES = [
214
215
  * @param {import('node:sqlite').DatabaseSync} db
215
216
  * @returns {number} applied schema version
216
217
  */
218
+ export function schemaVersion(db) {
219
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'").get();
220
+ if (!table) return null;
221
+ const row = db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
222
+ return row?.version ?? null;
223
+ }
224
+
225
+ /** Refuse a database written by a newer plugin before any DDL mutates it. */
226
+ export function assertSchemaCompatible(db) {
227
+ const version = schemaVersion(db);
228
+ if (version !== null && version > SCHEMA_VERSION) {
229
+ throw new Error(
230
+ `sdlc.db schema version ${version} is newer than this plugin (${SCHEMA_VERSION}); `
231
+ + 'refusing to open or migrate it. Upgrade the plugin or restore a backup.',
232
+ );
233
+ }
234
+ return version;
235
+ }
236
+
217
237
  export function migrate(db) {
238
+ // Check BEFORE pragmas/DDL: a future schema must not be partially mutated.
239
+ assertSchemaCompatible(db);
218
240
  for (const pragma of PRAGMAS) {
219
241
  try { db.exec(pragma); } catch { /* :memory: and read-only mounts may refuse WAL */ }
220
242
  }
@@ -228,6 +250,13 @@ export function migrate(db) {
228
250
  );
229
251
  return SCHEMA_VERSION;
230
252
  }
253
+ if (row.version < SCHEMA_VERSION) {
254
+ db.prepare('INSERT INTO schema_version(version, applied_at) VALUES (?, ?)').run(
255
+ SCHEMA_VERSION,
256
+ new Date().toISOString(),
257
+ );
258
+ return SCHEMA_VERSION;
259
+ }
231
260
  return row.version;
232
261
  }
233
262
 
@@ -261,9 +290,20 @@ export function quickCheck(db) {
261
290
  */
262
291
  export function openDatabase(dbPath, { requireQuickCheck = true, DatabaseSync: Ctor = DatabaseSync } = {}) {
263
292
  const db = new Ctor(dbPath);
264
- migrate(db);
265
- if (requireQuickCheck) quickCheck(db);
266
- return db;
293
+ try {
294
+ // Integrity + compatibility BEFORE any write. A torn DB must refuse to open
295
+ // (B.2.3), and a newer schema must never be migrated by an older plugin.
296
+ const version = assertSchemaCompatible(db);
297
+ if (requireQuickCheck) quickCheck(db);
298
+ if (version !== null && version < SCHEMA_VERSION && dbPath !== ':memory:' && existsSync(dbPath)) {
299
+ try { copyFileSync(dbPath, `${dbPath}.bak-v${version}`); } catch { /* backup is best-effort; migration is still checked */ }
300
+ }
301
+ migrate(db);
302
+ return db;
303
+ } catch (error) {
304
+ try { db.close(); } catch { /* already closed */ }
305
+ throw error;
306
+ }
267
307
  }
268
308
 
269
309
  export function tableNames(db) {
@@ -277,4 +317,4 @@ export function triggerNames(db) {
277
317
  .map((r) => r.name);
278
318
  }
279
319
 
280
- export default { migrate, quickCheck, openDatabase, SCHEMA_VERSION, DDL, TABLES };
320
+ export default { migrate, quickCheck, openDatabase, schemaVersion, assertSchemaCompatible, SCHEMA_VERSION, DDL, TABLES };
@@ -38,7 +38,7 @@ const G = {
38
38
  launchFailed: (ctx = {}) => ctx.launchFailed === true,
39
39
  hardFailure: (ctx = {}) => ctx.hardFailure === true,
40
40
  gatesFailed: (ctx = {}) => ctx.gatesPass === false,
41
- gatesPass: (ctx = {}) => ctx.gatesPass !== false,
41
+ gatesPass: (ctx = {}) => ctx.gatesPass === true,
42
42
  loopMatched: (ctx = {}) => ctx.loopMatched === true,
43
43
  mergeSlotFree: (ctx = {}) => ctx.mergeSlotFree !== false,
44
44
  mergedAndGreen: (ctx = {}) => ctx.merged === true && ctx.postMergeGatesPass !== false,
@@ -277,8 +277,18 @@ export class TaskStateService {
277
277
  const pairLegal = TASK_TRANSITIONS.some((t) => t.to === to && (t.from === from || t.from === '*'));
278
278
  return fail(pairLegal ? `actor ${actor} may not perform ${from ?? '—'}→${to}` : `no transition ${from ?? '—'}→${to}`);
279
279
  }
280
+ // Single-writer is a DATABASE invariant, not a caller promise. When the
281
+ // caller does not state the slot, derive it from the one MERGING task in
282
+ // the store; a second ACCEPTED→MERGING with `{}` must fail closed.
283
+ let effectiveCtx = ctx;
284
+ if (to === 'MERGING') {
285
+ // The caller may VETO a free slot, never grant one: the holder is read
286
+ // inside the same BEGIN IMMEDIATE transaction as the state write.
287
+ const holder = this.db.prepare("SELECT id FROM tasks WHERE state = 'MERGING' AND id <> ?").get(taskId);
288
+ effectiveCtx = { ...ctx, mergeSlotFree: ctx.mergeSlotFree !== false && holder == null };
289
+ }
280
290
  const guard = GUARDS[entry.guard] ?? GUARDS.always;
281
- if (!guard(ctx)) return fail(`guard ${entry.guard} rejected ${from ?? '—'}→${to}`);
291
+ if (!guard(effectiveCtx)) return fail(`guard ${entry.guard} rejected ${from ?? '—'}→${to}`);
282
292
 
283
293
  const attemptDelta = to === 'READY' && (from === 'FAILED' || from === 'ESCALATED') ? 1 : 0;
284
294
  const ts = this.now();
package/lib/verifier.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * This module is the pure decision table; the DSH service wires it to the DB.
6
6
  */
7
7
 
8
- import { violatesBehavioralTestRule } from './risk.js';
8
+ import { violatesBehavioralTestRule, requiredGates as riskRequiredGates } from './risk.js';
9
9
 
10
10
  export const REJECTION_CODES = [
11
11
  'SCOPE_VIOLATION',
@@ -38,6 +38,7 @@ export function decideAcceptance({
38
38
  claims = null,
39
39
  runtimeStatus = 'completed',
40
40
  blockedBySkippedGate = false,
41
+ requiredGates = null,
41
42
  } = {}) {
42
43
  const reasons = [];
43
44
 
@@ -73,6 +74,29 @@ export function decideAcceptance({
73
74
  if (failedGates.length) {
74
75
  return reject('FAILED', 'GATE_FAILED', failedGates.map(([g, r]) => `${g}: ${r.status}`));
75
76
  }
77
+ // A required gate must be PRESENT and PASSING. The old check only required
78
+ // *some* evidence, so an R4 task with `syntax: pass` (or a required gate
79
+ // marked `skipped:*`) was accepted. Callers that know the risk-derived gate
80
+ // set pass it explicitly; skipped-but-not-required gates are excluded by the
81
+ // caller (requiredGates().filter(g => g.required)), preserving B.4/L.5.
82
+ // Default to the risk-derived floor. A caller that needs a deliberately
83
+ // reduced set (appearance-only, configured skips) passes it explicitly; R1
84
+ // is the only class that may be accepted without gate evidence.
85
+ const enforcedGates = Array.isArray(requiredGates)
86
+ ? requiredGates
87
+ : (riskClass === 'R1' ? [] : riskRequiredGates(riskClass).filter((g) => g.required).map((g) => g.gate));
88
+ if (enforcedGates.length > 0) {
89
+ const missing = enforcedGates.filter((gate) => {
90
+ const result = gateResults[gate];
91
+ return !result || !String(result.status).startsWith('pass');
92
+ });
93
+ if (missing.length) {
94
+ if (Object.keys(gateResults).length === 0) {
95
+ return reject('FAILED', 'GATE_FAILED', ['no gate evidence recorded']);
96
+ }
97
+ return reject('FAILED', 'SKIPPED_GATE_NOT_PASSED', missing.map((gate) => `${gate}: ${gateResults[gate]?.status ?? 'missing'}`));
98
+ }
99
+ }
76
100
  const requiredButMissing = Object.keys(gateResults).length === 0;
77
101
  if (requiredButMissing && riskClass !== 'R1') {
78
102
  return reject('FAILED', 'GATE_FAILED', ['no gate evidence recorded']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patwoodman/dsh-sdlc-flow-core",
3
- "version": "0.4.0-beta.22",
3
+ "version": "0.4.0-beta.24",
4
4
  "type": "module",
5
5
  "description": "Host services (tasks, scheduler, router, verifier, integration, evidence, escalation, worker-mgr). NEVER installed into a worker profile.",
6
6
  "main": "lib/index.js",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "icon": "icon.svg",
42
42
  "engines": {
43
- "node": ">=22.5.0"
43
+ "node": ">=22.13.0"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"
@@ -50,6 +50,6 @@
50
50
  "url": "https://github.com/pwoodman/dsh-sdlc/issues"
51
51
  },
52
52
  "dependencies": {
53
- "@patwoodman/dsh-sdlc-flow-guard": "0.4.0-beta.22"
53
+ "@patwoodman/dsh-sdlc-flow-guard": "0.4.0-beta.24"
54
54
  }
55
55
  }