@ngockhoale/ukit 3.0.2 → 3.0.4

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 (109) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/bin/ukit +12 -4
  3. package/manifests/engineConformance.yaml +29 -0
  4. package/manifests/platform.full.yaml +13 -0
  5. package/package.json +2 -1
  6. package/scripts/audit/decision-coverage.mjs +295 -0
  7. package/scripts/bench/outline-savings.mjs +19 -4
  8. package/scripts/bench/parallel-agents.mjs +15 -4
  9. package/scripts/bench/runGold.mjs +22 -4
  10. package/scripts/bench/v3-ceremony.mjs +7 -1
  11. package/scripts/bug/triage.mjs +56 -17
  12. package/scripts/index/build-index.mjs +94 -28
  13. package/scripts/index/query-index.mjs +48 -14
  14. package/scripts/index/refresh-index.mjs +142 -62
  15. package/scripts/perf/audit-perf.mjs +8 -2
  16. package/scripts/skill/audit-skill.mjs +54 -25
  17. package/src/bug/triageBug.js +9 -6
  18. package/src/cli/adapters.js +6 -0
  19. package/src/cli/commands/code.js +7 -1
  20. package/src/cli/commands/indexArgs.js +4 -2
  21. package/src/cli/commands/indexTools.js +8 -1
  22. package/src/cli/commands/install.js +13 -0
  23. package/src/cli/commands/memory.js +10 -3
  24. package/src/cli/commands/status.js +17 -1
  25. package/src/cli/commands/update.js +7 -0
  26. package/src/context/detectProjectContext.js +3 -1
  27. package/src/core/codeintel/analogy.js +1 -1
  28. package/src/core/codeintel/diagnostics.js +9 -6
  29. package/src/core/codeintel/graph.js +14 -8
  30. package/src/core/codeintel/impact.js +0 -1
  31. package/src/core/codeintel/invalidation.js +5 -3
  32. package/src/core/codeintel/packet.js +11 -0
  33. package/src/core/codeintel/router.js +17 -3
  34. package/src/core/codeintel/semanticProvider.js +1 -1
  35. package/src/core/codeintel/summaries.js +11 -7
  36. package/src/core/compact/contextBudget.js +26 -12
  37. package/src/core/compact/index.js +15 -8
  38. package/src/core/docContracts.js +10 -2
  39. package/src/core/experiments/deliberation.js +321 -0
  40. package/src/core/experiments/dynamicWorkflow.js +492 -0
  41. package/src/core/fileOps.js +8 -1
  42. package/src/core/gatewayProbe.js +29 -1
  43. package/src/core/gatewayResilienceEnv.js +44 -3
  44. package/src/core/handoffDocValidator.js +3 -1
  45. package/src/core/hookChainDoctor.js +16 -1
  46. package/src/core/memory/deltaOverlays.js +448 -0
  47. package/src/core/memory/learningCandidates.js +302 -0
  48. package/src/core/memory/migrate.js +59 -32
  49. package/src/core/memory/recordStore.js +24 -1
  50. package/src/core/memory/store.js +44 -8
  51. package/src/core/memory/storeV2.js +48 -33
  52. package/src/core/memory/userMemory.js +11 -10
  53. package/src/core/output/index.js +15 -10
  54. package/src/core/permissionPolicy.js +8 -0
  55. package/src/core/runtimeConfig.js +224 -4
  56. package/src/core/sensitiveValueScanner.js +10 -2
  57. package/src/core/taskBudgetValidator.js +12 -17
  58. package/src/core/taskProgressGuard.js +59 -9
  59. package/src/core/unattendedDoctor.js +5 -2
  60. package/src/core/uninstall.js +37 -8
  61. package/src/decision/client.js +371 -0
  62. package/src/decision/lease.js +198 -0
  63. package/src/decision/preflight.js +492 -0
  64. package/src/decision/protocol.js +308 -0
  65. package/src/decision/registry.js +384 -0
  66. package/src/decision/shadow.js +281 -0
  67. package/src/decision/statePacket.js +165 -0
  68. package/src/diagnostics/failurePatterns.js +2 -1
  69. package/src/diagnostics/ledgerFiles.js +3 -1
  70. package/src/diagnostics/routeOutcomes.js +1 -29
  71. package/src/index/buildIndex.js +23 -11
  72. package/src/index/impactContext.js +21 -2
  73. package/src/index/importResolution.js +13 -7
  74. package/src/index/queryIndex.js +11 -5
  75. package/src/index/resolveContext.js +16 -6
  76. package/src/index/taskRouting.js +63 -2
  77. package/src/index/verificationPlan.js +12 -1
  78. package/src/learning/patternProposals.js +6 -0
  79. package/src/render/renderTemplate.js +1 -1
  80. package/src/skill/auditSkill.js +3 -1
  81. package/src/stack/detectStack.js +3 -1
  82. package/template_project/.claude/agents/handoff-planner.md +2 -5
  83. package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
  84. package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
  85. package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
  86. package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
  87. package/template_project/.claude/hooks/handoff-resume.sh +10 -1
  88. package/template_project/.claude/hooks/protect-files.sh +0 -1
  89. package/template_project/.claude/hooks/record-execution.mjs +13 -1
  90. package/template_project/.claude/hooks/sensitive-data-guard.mjs +71 -5
  91. package/template_project/.claude/hooks/session-episode.sh +9 -2
  92. package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
  93. package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
  94. package/template_project/.claude/ukit/index/lib/index-core.mjs +123 -39
  95. package/template_project/.claude/ukit/index/route-task.mjs +444 -0
  96. package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
  97. package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
  98. package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
  99. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
  100. package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
  101. package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
  102. package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
  103. package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
  104. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
  105. package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
  106. package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
  107. package/template_project/ukit/storage/config.json +34 -1
  108. package/template_project/.claude/ukit/skill-router-state.json +0 -1
  109. package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
@@ -0,0 +1,492 @@
1
+ /**
2
+ * dynamicWorkflow.js (TASK-012 / M06.2)
3
+ *
4
+ * Bounded dynamic-workflow experiment — compose a workflow only from a frozen
5
+ * known-block catalog, validate the graph before execution, and run a
6
+ * dependency-graph program with per-task route/completion and finite breakers
7
+ * (`maxAttemptsPerFailure`, global `noProgressCap`, genuine-blocker
8
+ * classification). Behind `experiments.dynamicWorkflow.enabled` (default
9
+ * false). Not wired into route-task; evaluated by fixtures (SPEC §5 FR-022;
10
+ * §10 "no-progress loop").
11
+ *
12
+ * Disabled posture: `composeWorkflow` returns `{status:'disabled'}` and
13
+ * `runProgram` returns `{status:'disabled'}` BEFORE any runner call — zero
14
+ * calls, zero prompt content.
15
+ *
16
+ * Program state reuses the C10 CompactResumable record *shape* (derived from
17
+ * CONTRACTS.md §C10 — schemaVersion/taskId/route/budget/workflow/decisions/
18
+ * escalationHistory/unresolvedFailures/nextAction, bounded arrays). No new
19
+ * state DB, no persistence, no auto-promotion: a mid-run failure degrades to
20
+ * the caller-supplied stable `fallback` workflow and the composition is never
21
+ * promoted (`promoted:false` always).
22
+ *
23
+ * Runner contract: `runner({task, attempt, strategy, state}) → result` where
24
+ * `result.status === 'ok'` (or the task's own `completion(result)` predicate)
25
+ * completes the task; any other result is a failure whose signature is
26
+ * `result.failure ?? result.status`. An identical repeated failure signature
27
+ * is not progress and is capped by `maxAttemptsPerFailure`; consecutive
28
+ * non-completing attempts are capped globally by `noProgressCap`. The runner
29
+ * is also the fault-injection hook — tests inject failures through it.
30
+ */
31
+
32
+ const DEFAULT_MAX_ATTEMPTS_PER_FAILURE = 2;
33
+ const DEFAULT_NO_PROGRESS_CAP = 3;
34
+
35
+ // C10 bounds (CONTRACTS.md §C10): hypotheses ≤8, decisions ≤12,
36
+ // evidenceRefs ≤24, escalationHistory ≤8.
37
+ const MAX_DECISIONS = 12;
38
+ const MAX_ESCALATIONS = 8;
39
+
40
+ /**
41
+ * Frozen known-block catalog — the only blocks a composition may use.
42
+ * `permissions` are required grants; `owns` are exclusively-owned resources
43
+ * (two blocks owning the same resource is an ownership conflict); `cost` is
44
+ * charged against `constraints.budgetLimit`.
45
+ */
46
+ export const KNOWN_BLOCKS = Object.freeze({
47
+ 'scan-context': Object.freeze({ permissions: ['read'], owns: [], cost: 1 }),
48
+ plan: Object.freeze({ permissions: ['read'], owns: [], cost: 1 }),
49
+ edit: Object.freeze({ permissions: ['read', 'write'], owns: ['worktree'], cost: 2 }),
50
+ 'apply-patch': Object.freeze({ permissions: ['read', 'write'], owns: ['worktree'], cost: 2 }),
51
+ verify: Object.freeze({ permissions: ['read', 'execute'], owns: [], cost: 1 }),
52
+ report: Object.freeze({ permissions: ['read'], owns: [], cost: 1 }),
53
+ });
54
+
55
+ function dynamicWorkflowEnabled(config) {
56
+ return config?.experiments?.dynamicWorkflow?.enabled === true;
57
+ }
58
+
59
+ function breakersOf(config) {
60
+ const cfg = config?.experiments?.dynamicWorkflow ?? {};
61
+ return {
62
+ maxAttemptsPerFailure:
63
+ typeof cfg.maxAttemptsPerFailure === 'number' && cfg.maxAttemptsPerFailure > 0
64
+ ? cfg.maxAttemptsPerFailure
65
+ : DEFAULT_MAX_ATTEMPTS_PER_FAILURE,
66
+ noProgressCap:
67
+ typeof cfg.noProgressCap === 'number' && cfg.noProgressCap > 0
68
+ ? cfg.noProgressCap
69
+ : DEFAULT_NO_PROGRESS_CAP,
70
+ };
71
+ }
72
+
73
+ function isPlainObject(value) {
74
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
75
+ }
76
+
77
+ function boundedPush(list, item, cap) {
78
+ if (list.length < cap) {
79
+ list.push(item);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Compose a workflow graph from the frozen known-block catalog.
85
+ * Unknown block ids pass through unresolved so `validateComposition` can
86
+ * reject them with a named reason — a composed graph is only executable
87
+ * after `validateComposition` returns `{valid:true}`.
88
+ *
89
+ * @param {{blocks: Array<string|{id:string, needs?:string[]}>,
90
+ * constraints?: {allowedPermissions?: string[], budgetLimit?: number},
91
+ * config?: object}} input
92
+ * @returns {{version:1, nodes: Array, edges: Array, constraints: object} |
93
+ * {status:'disabled', reasons:string[]}}
94
+ */
95
+ export function composeWorkflow({ blocks = [], constraints = {}, config = null } = {}) {
96
+ if (!dynamicWorkflowEnabled(config)) {
97
+ return { status: 'disabled', reasons: ['experiment-disabled'] };
98
+ }
99
+ const list = Array.isArray(blocks) ? blocks : [];
100
+ const nodes = list.map((entry) => {
101
+ const id = typeof entry === 'string' ? entry : entry?.id;
102
+ const requestedNeeds = typeof entry === 'string' ? [] : entry?.needs;
103
+ const known = KNOWN_BLOCKS[id] ?? null;
104
+ return {
105
+ id,
106
+ needs: Array.isArray(requestedNeeds) ? [...requestedNeeds] : [],
107
+ known,
108
+ permissions: known ? [...known.permissions] : [],
109
+ owns: known ? [...known.owns] : [],
110
+ cost: known ? known.cost : 0,
111
+ };
112
+ });
113
+ const edges = [];
114
+ for (const node of nodes) {
115
+ for (const dep of node.needs) {
116
+ edges.push({ from: dep, to: node.id });
117
+ }
118
+ }
119
+ return {
120
+ version: 1,
121
+ nodes,
122
+ edges,
123
+ constraints: isPlainObject(constraints) ? { ...constraints } : {},
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Validate a composed graph before execution. Rejects malformed graphs,
129
+ * duplicate/unknown blocks, missing dependency targets, cycles, permission
130
+ * violations (block requires a grant absent from
131
+ * `constraints.allowedPermissions`), ownership conflicts (two blocks owning
132
+ * the same resource), and budget overruns (Σ block cost >
133
+ * `constraints.budgetLimit`).
134
+ *
135
+ * @param {object} graph — output of `composeWorkflow`
136
+ * @returns {{valid: boolean, errors: string[]}}
137
+ */
138
+ export function validateComposition(graph) {
139
+ const errors = [];
140
+ if (!isPlainObject(graph) || !Array.isArray(graph.nodes)) {
141
+ return { valid: false, errors: ['malformed-graph'] };
142
+ }
143
+ const nodes = graph.nodes;
144
+ const constraints = isPlainObject(graph.constraints) ? graph.constraints : {};
145
+
146
+ const seen = new Set();
147
+ const ids = new Set();
148
+ for (const node of nodes) {
149
+ if (typeof node?.id !== 'string' || !node.id) {
150
+ errors.push('malformed-node');
151
+ continue;
152
+ }
153
+ if (seen.has(node.id)) {
154
+ errors.push(`duplicate-block:${node.id}`);
155
+ continue;
156
+ }
157
+ seen.add(node.id);
158
+ ids.add(node.id);
159
+ if (!KNOWN_BLOCKS[node.id]) {
160
+ errors.push(`unknown-block:${node.id}`);
161
+ }
162
+ }
163
+
164
+ const needs = new Map();
165
+ for (const node of nodes) {
166
+ if (typeof node?.id !== 'string' || !node.id) continue;
167
+ const list = Array.isArray(node.needs) ? node.needs : [];
168
+ needs.set(node.id, list);
169
+ for (const dep of list) {
170
+ if (!ids.has(dep)) {
171
+ errors.push(`missing-dependency:${dep}`);
172
+ }
173
+ }
174
+ }
175
+
176
+ // Cycle detection — iterative DFS, three-color.
177
+ const color = new Map(); // 0=unvisited implicit, 1=in-stack, 2=done
178
+ for (const start of ids) {
179
+ if (color.get(start) === 2) continue;
180
+ const stack = [[start, 0]];
181
+ color.set(start, 1);
182
+ while (stack.length > 0) {
183
+ const frame = stack[stack.length - 1];
184
+ const [id, idx] = frame;
185
+ const deps = (needs.get(id) ?? []).filter((d) => ids.has(d));
186
+ if (idx >= deps.length) {
187
+ color.set(id, 2);
188
+ stack.pop();
189
+ continue;
190
+ }
191
+ frame[1] = idx + 1;
192
+ const dep = deps[idx];
193
+ const depColor = color.get(dep) ?? 0;
194
+ if (depColor === 1) {
195
+ errors.push(`cycle:${dep}->${id}`);
196
+ } else if (depColor === 0) {
197
+ color.set(dep, 1);
198
+ stack.push([dep, 0]);
199
+ }
200
+ }
201
+ }
202
+
203
+ // Permission violations.
204
+ if (constraints.allowedPermissions !== undefined) {
205
+ const allowed = new Set(
206
+ Array.isArray(constraints.allowedPermissions) ? constraints.allowedPermissions : [],
207
+ );
208
+ for (const node of nodes) {
209
+ if (typeof node?.id !== 'string' || !KNOWN_BLOCKS[node.id]) continue;
210
+ const required = KNOWN_BLOCKS[node.id].permissions;
211
+ const missing = required.filter((p) => !allowed.has(p));
212
+ if (missing.length > 0) {
213
+ errors.push(`permission-denied:${node.id}:${missing.join(',')}`);
214
+ }
215
+ }
216
+ }
217
+
218
+ // Ownership conflicts — two blocks claiming the same exclusive resource.
219
+ const owners = new Map();
220
+ for (const node of nodes) {
221
+ if (typeof node?.id !== 'string' || !KNOWN_BLOCKS[node.id]) continue;
222
+ for (const resource of KNOWN_BLOCKS[node.id].owns) {
223
+ if (owners.has(resource)) {
224
+ errors.push(`ownership-conflict:${resource}`);
225
+ } else {
226
+ owners.set(resource, node.id);
227
+ }
228
+ }
229
+ }
230
+
231
+ // Budget overrun.
232
+ if (typeof constraints.budgetLimit === 'number') {
233
+ const total = nodes.reduce(
234
+ (sum, node) => sum + (KNOWN_BLOCKS[node?.id]?.cost ?? 0),
235
+ 0,
236
+ );
237
+ if (total > constraints.budgetLimit) {
238
+ errors.push('budget-exceeded');
239
+ }
240
+ }
241
+
242
+ return { valid: errors.length === 0, errors };
243
+ }
244
+
245
+ function defaultCompletion(result) {
246
+ return result?.status === 'ok' || result?.ok === true;
247
+ }
248
+
249
+ function failureSignature(result) {
250
+ if (typeof result?.failure === 'string' && result.failure) {
251
+ return result.failure;
252
+ }
253
+ return String(result?.status ?? 'unknown');
254
+ }
255
+
256
+ function newProgramState({ programId, tasks }) {
257
+ // C10 CompactResumableRun shape (CONTRACTS.md §C10) — in-memory only,
258
+ // bounded and redacted; no new state DB.
259
+ return {
260
+ schemaVersion: 1,
261
+ taskId: programId,
262
+ taskBoundary: 'experiment-program',
263
+ route: { routeVersion: 1, mode: 'dynamic-workflow', rigor: 'bounded', contractVersion: 1 },
264
+ budget: { policyVersion: 1, consumed: 0, remaining: tasks.length },
265
+ workflow: {
266
+ workflowId: programId,
267
+ workflowVersion: 1,
268
+ phase: 'execute',
269
+ completedBlocks: [],
270
+ },
271
+ invariants: [],
272
+ hypotheses: [],
273
+ decisions: [],
274
+ evidenceRefs: [],
275
+ escalationHistory: [],
276
+ unresolvedFailures: [],
277
+ nextAction: null,
278
+ sourceSnapshot: null,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Run a dependency-graph program of bounded tasks. Each task carries its own
284
+ * `route` and `completion(result)` predicate; tasks execute in dependency
285
+ * order and dependents of a blocked task are skipped. Finite breakers:
286
+ * `maxAttemptsPerFailure` caps retries of an identical failure signature
287
+ * (identical retry ≠ progress), `noProgressCap` caps consecutive
288
+ * non-completing attempts program-wide. A tripped breaker is a genuine
289
+ * blocker — the run degrades to the caller-supplied stable `fallback`
290
+ * workflow when present, else returns `status:'blocked'`. Never infinite,
291
+ * never auto-promoted.
292
+ *
293
+ * @param {{tasks: Array<{id:string, needs?:string[], route?:object,
294
+ * completion?:Function, strategies?:string[]}>, runner: Function,
295
+ * fallback?: Function, programId?: string, config?: object}} input
296
+ * @returns {object} `{status:'ok'|'blocked'|'degraded'|'disabled'|'invalid',
297
+ * order, completed, skipped, blockers, attempts, state, promoted:false}`
298
+ */
299
+ export function runProgram({
300
+ tasks = [],
301
+ runner = null,
302
+ fallback = null,
303
+ programId = 'dynamic-workflow-program',
304
+ config = null,
305
+ } = {}) {
306
+ if (!dynamicWorkflowEnabled(config)) {
307
+ return { status: 'disabled', reasons: ['experiment-disabled'], promoted: false };
308
+ }
309
+ if (typeof runner !== 'function') {
310
+ throw new TypeError('runProgram runner must be a function.');
311
+ }
312
+ const list = Array.isArray(tasks) ? tasks : [];
313
+ const { maxAttemptsPerFailure, noProgressCap } = breakersOf(config);
314
+
315
+ // Structural validation — same named reasons as validateComposition.
316
+ const errors = [];
317
+ const ids = new Set();
318
+ const needs = new Map();
319
+ for (const task of list) {
320
+ if (typeof task?.id !== 'string' || !task.id) {
321
+ errors.push('malformed-task');
322
+ continue;
323
+ }
324
+ if (ids.has(task.id)) {
325
+ errors.push(`duplicate-task:${task.id}`);
326
+ continue;
327
+ }
328
+ ids.add(task.id);
329
+ }
330
+ for (const task of list) {
331
+ if (typeof task?.id !== 'string' || !task.id) continue;
332
+ const deps = Array.isArray(task.needs) ? task.needs : [];
333
+ needs.set(task.id, deps);
334
+ for (const dep of deps) {
335
+ if (!ids.has(dep)) {
336
+ errors.push(`missing-dependency:${dep}`);
337
+ }
338
+ }
339
+ }
340
+ // Topological order (Kahn) — deterministic input-order tie-break; leftover
341
+ // in-degree means a cycle.
342
+ const indegree = new Map([...ids].map((id) => [id, 0]));
343
+ for (const task of list) {
344
+ if (typeof task?.id !== 'string' || !task.id || !needs.has(task.id)) continue;
345
+ for (const dep of needs.get(task.id)) {
346
+ if (ids.has(dep)) {
347
+ indegree.set(task.id, indegree.get(task.id) + 1);
348
+ }
349
+ }
350
+ }
351
+ const order = [];
352
+ const ready = list
353
+ .filter((t) => typeof t?.id === 'string' && indegree.get(t.id) === 0)
354
+ .map((t) => t.id);
355
+ const dependents = new Map([...ids].map((id) => [id, []]));
356
+ for (const task of list) {
357
+ if (typeof task?.id !== 'string' || !task.id || !needs.has(task.id)) continue;
358
+ for (const dep of needs.get(task.id)) {
359
+ if (ids.has(dep)) {
360
+ dependents.get(dep).push(task.id);
361
+ }
362
+ }
363
+ }
364
+ while (ready.length > 0) {
365
+ const id = ready.shift();
366
+ order.push(id);
367
+ for (const next of dependents.get(id)) {
368
+ indegree.set(next, indegree.get(next) - 1);
369
+ if (indegree.get(next) === 0) {
370
+ ready.push(next);
371
+ }
372
+ }
373
+ }
374
+ if (order.length < ids.size) {
375
+ const stuck = [...ids].filter((id) => !order.includes(id));
376
+ errors.push(`cycle:${stuck.join(',')}`);
377
+ }
378
+ if (errors.length > 0) {
379
+ return { status: 'invalid', errors, promoted: false };
380
+ }
381
+
382
+ const state = newProgramState({ programId, tasks: list });
383
+ const taskById = new Map(list.map((t) => [t.id, t]));
384
+ const completed = [];
385
+ const completedSet = new Set();
386
+ const skipped = [];
387
+ const blockers = [];
388
+ const attempts = {};
389
+ let noProgress = 0;
390
+ let programBlocked = false;
391
+
392
+ for (const id of order) {
393
+ if (programBlocked) {
394
+ skipped.push(id);
395
+ continue;
396
+ }
397
+ const task = taskById.get(id);
398
+ const deps = needs.get(id) ?? [];
399
+ if (deps.some((dep) => !completedSet.has(dep))) {
400
+ skipped.push(id);
401
+ continue;
402
+ }
403
+
404
+ const strategies = Array.isArray(task.strategies) ? task.strategies : [];
405
+ const completion = typeof task.completion === 'function' ? task.completion : defaultCompletion;
406
+ const signatureCounts = new Map();
407
+ let done = false;
408
+ let blockedReason = null;
409
+ let blockedSignature = null;
410
+ let attempt = 0;
411
+
412
+ while (!done && blockedReason === null) {
413
+ attempt += 1;
414
+ const strategy = strategies.length > 0 ? strategies[attempt - 1] : undefined;
415
+ const result = runner({ task, attempt, strategy, state }) ?? {};
416
+ state.budget.consumed += 1;
417
+ if (completion(result)) {
418
+ done = true;
419
+ break;
420
+ }
421
+ noProgress += 1;
422
+ const signature = failureSignature(result);
423
+ const count = (signatureCounts.get(signature) ?? 0) + 1;
424
+ signatureCounts.set(signature, count);
425
+ if (count >= maxAttemptsPerFailure) {
426
+ blockedReason = 'repeated-identical-failure';
427
+ blockedSignature = signature;
428
+ } else if (noProgress >= noProgressCap) {
429
+ blockedReason = 'no-progress-cap';
430
+ blockedSignature = signature;
431
+ } else if (strategies.length > 0 && attempt >= strategies.length) {
432
+ blockedReason = 'strategies-exhausted';
433
+ blockedSignature = signature;
434
+ }
435
+ }
436
+
437
+ attempts[id] = attempt;
438
+ if (done) {
439
+ completed.push(id);
440
+ completedSet.add(id);
441
+ state.workflow.completedBlocks.push(id);
442
+ boundedPush(
443
+ state.decisions,
444
+ { taskId: id, route: task.route ?? null, attempts: attempt },
445
+ MAX_DECISIONS,
446
+ );
447
+ continue;
448
+ }
449
+
450
+ // Genuine blocker — finite, classified, recorded.
451
+ programBlocked = true;
452
+ const blocker = { taskId: id, reason: blockedReason, signature: blockedSignature, attempts: attempt };
453
+ blockers.push(blocker);
454
+ boundedPush(state.unresolvedFailures, blocker, MAX_ESCALATIONS);
455
+ boundedPush(
456
+ state.escalationHistory,
457
+ { taskId: id, reason: blockedReason, at: 'program' },
458
+ MAX_ESCALATIONS,
459
+ );
460
+ state.nextAction = `blocked:${id}:${blockedReason}`;
461
+ }
462
+
463
+ // `remaining` counts unfinished tasks (blocked/skipped stay unfinished) —
464
+ // decrementing per attempt would corrupt it on retries.
465
+ state.budget.remaining = Math.max(0, ids.size - completedSet.size);
466
+
467
+ const result = {
468
+ order,
469
+ completed,
470
+ skipped,
471
+ blockers,
472
+ attempts,
473
+ state,
474
+ promoted: false, // no auto-promotion — ever
475
+ };
476
+
477
+ if (blockers.length === 0) {
478
+ state.workflow.phase = 'complete';
479
+ state.nextAction = null;
480
+ return { status: 'ok', ...result };
481
+ }
482
+
483
+ state.workflow.phase = 'blocked';
484
+ if (typeof fallback === 'function') {
485
+ const fallbackResult = fallback({ state, blockers }) ?? {};
486
+ if (fallbackResult.status === 'ok') {
487
+ return { status: 'degraded', fallback: fallbackResult, ...result };
488
+ }
489
+ blockers.push({ taskId: null, reason: 'fallback-failed' });
490
+ }
491
+ return { status: 'blocked', ...result, blockers };
492
+ }
@@ -62,7 +62,14 @@ export async function cleanupEmptyParents(targetPath, projectRoot) {
62
62
  break;
63
63
  }
64
64
 
65
- await fs.rmdir(currentDir);
65
+ try {
66
+ await fs.rmdir(currentDir);
67
+ } catch {
68
+ // Raced: the dir filled up or vanished between readdir and rmdir
69
+ // (concurrent install/uninstall). Either way the walk stops here —
70
+ // a non-empty or missing parent can never be pruned.
71
+ break;
72
+ }
66
73
  currentDir = path.dirname(currentDir);
67
74
  }
68
75
  }
@@ -131,7 +131,9 @@ export function resolveGatewayApiKey({
131
131
  }
132
132
  const settingsPaths = [
133
133
  ...(projectRoot ? [path.join(projectRoot, SETTINGS_RELATIVE_PROBE_PATH)] : []),
134
- path.join(homeDir, SETTINGS_RELATIVE_PROBE_PATH),
134
+ // homeDir may be explicitly null — never let path.join throw (contract:
135
+ // this resolver never throws).
136
+ ...(homeDir ? [path.join(homeDir, SETTINGS_RELATIVE_PROBE_PATH)] : []),
135
137
  ];
136
138
  for (const settingsPath of settingsPaths) {
137
139
  const envBlock = readSettingsEnvBlock(settingsPath);
@@ -513,6 +515,32 @@ export async function probeGateway({
513
515
  ? (apiKeySource ?? 'api-key')
514
516
  : (envKey?.source ?? null);
515
517
  const effectiveModel = pickString(model, pickModel(env)) ?? 'claude-sonnet-4-5';
518
+
519
+ // Contract: never propagates exceptions — a missing/non-string baseUrl is a
520
+ // structured failure, not a TypeError on `.replace`.
521
+ if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) {
522
+ const failure = (error) => ({
523
+ ok: false,
524
+ eventsReceived: 0,
525
+ firstEventMs: null,
526
+ chunks: 0,
527
+ buffered: false,
528
+ httpOk: false,
529
+ isAnthropicMessage: false,
530
+ requestIdPresent: false,
531
+ bodyBytes: null,
532
+ error,
533
+ });
534
+ const reason = 'invalid baseUrl (non-empty string required)';
535
+ return {
536
+ streaming: failure(reason),
537
+ nonStreaming: failure(reason),
538
+ verdict: 'fail',
539
+ hints: [`Gateway probe could not run: ${reason} — see ${GATEWAY_DOC}.`],
540
+ model: effectiveModel,
541
+ };
542
+ }
543
+
516
544
  const url = `${baseUrl.replace(/\/+$/, '')}/v1/messages`;
517
545
  const headers = {
518
546
  'content-type': 'application/json',
@@ -118,13 +118,54 @@ export function detectCustomGateway({ rootDir, homeDir, env } = {}) {
118
118
  return { customGateway: false, baseUrl: null, source: null };
119
119
  }
120
120
 
121
+ // Async twin of readBaseUrlFromSettingsFileSync — same swallow-everything
122
+ // contract, but through fs.promises so the probe never blocks the event loop.
123
+ async function readBaseUrlFromSettingsFileAsync(settingsPath) {
124
+ let raw;
125
+ try {
126
+ raw = await fs.readFile(settingsPath, 'utf8');
127
+ } catch {
128
+ return null;
129
+ }
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(raw);
133
+ } catch {
134
+ return null;
135
+ }
136
+ return readBaseUrlFromEnvBlock(parsed?.env);
137
+ }
138
+
121
139
  /**
122
140
  * Async variant used by `applyGatewayResilienceEnv` so the settings-file probes can run
123
141
  * without blocking the event loop. Mirrors `detectCustomGateway` exactly; the only
124
142
  * difference is the Promise wrapper for callers that already sit in an async pipeline.
125
143
  */
126
- export function detectCustomGatewayAsync({ rootDir, homeDir, env } = {}) {
127
- return Promise.resolve(detectCustomGateway({ rootDir, homeDir, env }));
144
+ export async function detectCustomGatewayAsync({ rootDir, homeDir, env } = {}) {
145
+ const envValue = env ? readBaseUrlFromEnvValue(env[BASE_URL_ENV_VAR]) : null;
146
+ if (envValue) {
147
+ return { customGateway: true, baseUrl: envValue, source: 'env' };
148
+ }
149
+
150
+ if (rootDir) {
151
+ const projectValue = await readBaseUrlFromSettingsFileAsync(
152
+ path.join(rootDir, SETTINGS_RELATIVE_PATH),
153
+ );
154
+ if (projectValue) {
155
+ return { customGateway: true, baseUrl: projectValue, source: 'claude-settings' };
156
+ }
157
+ }
158
+
159
+ if (homeDir) {
160
+ const homeValue = await readBaseUrlFromSettingsFileAsync(
161
+ path.join(homeDir, SETTINGS_RELATIVE_PATH),
162
+ );
163
+ if (homeValue) {
164
+ return { customGateway: true, baseUrl: homeValue, source: 'claude-settings' };
165
+ }
166
+ }
167
+
168
+ return { customGateway: false, baseUrl: null, source: null };
128
169
  }
129
170
 
130
171
  function backupPathFor(settingsPath) {
@@ -154,7 +195,7 @@ export async function applyGatewayResilienceEnv({
154
195
  homeDir = os.homedir(),
155
196
  env = process.env,
156
197
  } = {}) {
157
- const detection = detectCustomGateway({ rootDir: projectRoot, homeDir, env });
198
+ const detection = await detectCustomGatewayAsync({ rootDir: projectRoot, homeDir, env });
158
199
 
159
200
  if (!detection.customGateway) {
160
201
  return {
@@ -64,7 +64,9 @@ function planSectionPositions(sections) {
64
64
  const positions = new Map();
65
65
  for (const s of sections) {
66
66
  for (const token of PLAN_SECTIONS) {
67
- if (!positions.has(token) && s.name.startsWith(token)) positions.set(token, s.pos);
67
+ // `## §10`/`## §12` must not satisfy `§1` — the token only matches when it
68
+ // is the whole heading or is followed by a non-digit boundary (space, colon…).
69
+ if (!positions.has(token) && new RegExp(`^${token}(?!\\d)`).test(s.name)) positions.set(token, s.pos);
68
70
  }
69
71
  }
70
72
  return positions;
@@ -79,6 +79,22 @@ async function tailLines(filePath, size) {
79
79
  }
80
80
 
81
81
  export async function inspectHookChainHealth({ projectRoot, now = Date.now() } = {}) {
82
+ const label = 'hook-chain health';
83
+
84
+ // Never-fatal posture extends to malformed input: a missing projectRoot is
85
+ // "unknown", not a TypeError (same contract as inspectProjectImportant).
86
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
87
+ return {
88
+ label,
89
+ passed: true,
90
+ failed: false,
91
+ severity: 'info',
92
+ remediationClass: 'advisory',
93
+ detail: 'no telemetry — invalid projectRoot (unknown, not failure)',
94
+ counts: { rowsScanned: 0, filesScanned: 0, ok: 0 },
95
+ };
96
+ }
97
+
82
98
  const dir = telemetryDirFor(projectRoot);
83
99
  const files = await recentTelemetryFiles(dir, now);
84
100
 
@@ -144,7 +160,6 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
144
160
  }
145
161
  }
146
162
 
147
- const label = 'hook-chain health';
148
163
 
149
164
  if (counts.rowsScanned === 0) {
150
165
  return {