@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.
- package/CHANGELOG.md +21 -0
- package/bin/ukit +12 -4
- package/manifests/engineConformance.yaml +29 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +2 -1
- package/scripts/audit/decision-coverage.mjs +295 -0
- package/scripts/bench/outline-savings.mjs +19 -4
- package/scripts/bench/parallel-agents.mjs +15 -4
- package/scripts/bench/runGold.mjs +22 -4
- package/scripts/bench/v3-ceremony.mjs +7 -1
- package/scripts/bug/triage.mjs +56 -17
- package/scripts/index/build-index.mjs +94 -28
- package/scripts/index/query-index.mjs +48 -14
- package/scripts/index/refresh-index.mjs +142 -62
- package/scripts/perf/audit-perf.mjs +8 -2
- package/scripts/skill/audit-skill.mjs +54 -25
- package/src/bug/triageBug.js +9 -6
- package/src/cli/adapters.js +6 -0
- package/src/cli/commands/code.js +7 -1
- package/src/cli/commands/indexArgs.js +4 -2
- package/src/cli/commands/indexTools.js +8 -1
- package/src/cli/commands/install.js +13 -0
- package/src/cli/commands/memory.js +10 -3
- package/src/cli/commands/status.js +17 -1
- package/src/cli/commands/update.js +7 -0
- package/src/context/detectProjectContext.js +3 -1
- package/src/core/codeintel/analogy.js +1 -1
- package/src/core/codeintel/diagnostics.js +9 -6
- package/src/core/codeintel/graph.js +14 -8
- package/src/core/codeintel/impact.js +0 -1
- package/src/core/codeintel/invalidation.js +5 -3
- package/src/core/codeintel/packet.js +11 -0
- package/src/core/codeintel/router.js +17 -3
- package/src/core/codeintel/semanticProvider.js +1 -1
- package/src/core/codeintel/summaries.js +11 -7
- package/src/core/compact/contextBudget.js +26 -12
- package/src/core/compact/index.js +15 -8
- package/src/core/docContracts.js +10 -2
- package/src/core/experiments/deliberation.js +321 -0
- package/src/core/experiments/dynamicWorkflow.js +492 -0
- package/src/core/fileOps.js +8 -1
- package/src/core/gatewayProbe.js +29 -1
- package/src/core/gatewayResilienceEnv.js +44 -3
- package/src/core/handoffDocValidator.js +3 -1
- package/src/core/hookChainDoctor.js +16 -1
- package/src/core/memory/deltaOverlays.js +448 -0
- package/src/core/memory/learningCandidates.js +302 -0
- package/src/core/memory/migrate.js +59 -32
- package/src/core/memory/recordStore.js +24 -1
- package/src/core/memory/store.js +44 -8
- package/src/core/memory/storeV2.js +48 -33
- package/src/core/memory/userMemory.js +11 -10
- package/src/core/output/index.js +15 -10
- package/src/core/permissionPolicy.js +8 -0
- package/src/core/runtimeConfig.js +224 -4
- package/src/core/sensitiveValueScanner.js +10 -2
- package/src/core/taskBudgetValidator.js +12 -17
- package/src/core/taskProgressGuard.js +59 -9
- package/src/core/unattendedDoctor.js +5 -2
- package/src/core/uninstall.js +37 -8
- package/src/decision/client.js +371 -0
- package/src/decision/lease.js +198 -0
- package/src/decision/preflight.js +492 -0
- package/src/decision/protocol.js +308 -0
- package/src/decision/registry.js +384 -0
- package/src/decision/shadow.js +281 -0
- package/src/decision/statePacket.js +165 -0
- package/src/diagnostics/failurePatterns.js +2 -1
- package/src/diagnostics/ledgerFiles.js +3 -1
- package/src/diagnostics/routeOutcomes.js +1 -29
- package/src/index/buildIndex.js +23 -11
- package/src/index/impactContext.js +21 -2
- package/src/index/importResolution.js +13 -7
- package/src/index/queryIndex.js +11 -5
- package/src/index/resolveContext.js +16 -6
- package/src/index/taskRouting.js +63 -2
- package/src/index/verificationPlan.js +12 -1
- package/src/learning/patternProposals.js +6 -0
- package/src/render/renderTemplate.js +1 -1
- package/src/skill/auditSkill.js +3 -1
- package/src/stack/detectStack.js +3 -1
- package/template_project/.claude/agents/handoff-planner.md +2 -5
- package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
- package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
- package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
- package/template_project/.claude/hooks/handoff-resume.sh +10 -1
- package/template_project/.claude/hooks/protect-files.sh +0 -1
- package/template_project/.claude/hooks/record-execution.mjs +13 -1
- package/template_project/.claude/hooks/sensitive-data-guard.mjs +71 -5
- package/template_project/.claude/hooks/session-episode.sh +9 -2
- package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
- package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
- package/template_project/.claude/ukit/index/lib/index-core.mjs +123 -39
- package/template_project/.claude/ukit/index/route-task.mjs +444 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
- package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
- package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
- package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
- package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
- package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
- package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
- package/template_project/ukit/storage/config.json +34 -1
- package/template_project/.claude/ukit/skill-router-state.json +0 -1
- 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
|
+
}
|
package/src/core/fileOps.js
CHANGED
|
@@ -62,7 +62,14 @@ export async function cleanupEmptyParents(targetPath, projectRoot) {
|
|
|
62
62
|
break;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
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
|
}
|
package/src/core/gatewayProbe.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|