u-foo 2.5.15 → 3.0.0

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/code/agent.js +333 -243
  3. package/src/code/commands.js +16 -0
  4. package/src/code/context/assembler.js +18 -13
  5. package/src/code/context/executionSegment.js +97 -119
  6. package/src/code/context/index.js +11 -1
  7. package/src/code/context/planGraph.js +1410 -0
  8. package/src/code/context/planGraphService.js +857 -0
  9. package/src/code/context/planMode.js +398 -0
  10. package/src/code/context/planProjection.js +432 -0
  11. package/src/code/context/promptLayers.js +21 -5
  12. package/src/code/context/stateCommit.js +2 -0
  13. package/src/code/context/toolRuntime.js +172 -0
  14. package/src/code/context/userInteraction.js +457 -0
  15. package/src/code/context/userNudge.js +116 -0
  16. package/src/code/dispatch.js +17 -1
  17. package/src/code/index.js +2 -0
  18. package/src/code/nativeRunner.js +518 -37
  19. package/src/code/repl.js +160 -18
  20. package/src/code/runtime/agentWakeup.js +58 -0
  21. package/src/code/runtime/graphOwner.js +41 -0
  22. package/src/code/runtime/graphYieldRouter.js +42 -0
  23. package/src/code/runtime/index.js +15 -0
  24. package/src/code/runtime/loopMailbox.js +124 -0
  25. package/src/code/runtime/runtimeEvents.js +39 -0
  26. package/src/code/runtime/taskControl.js +565 -0
  27. package/src/code/runtime/taskFocus.js +165 -0
  28. package/src/code/runtime/taskLoop.js +383 -0
  29. package/src/code/runtime/taskRun.js +187 -0
  30. package/src/code/runtime/toolProvenance.js +70 -0
  31. package/src/code/runtime/workspaceLease.js +208 -0
  32. package/src/code/sessionStore.js +0 -10
  33. package/src/code/skills/injection.js +1 -0
  34. package/src/code/taskDecomposer.js +32 -8
  35. package/src/code/tools/askUser.js +11 -0
  36. package/src/code/tools/planGraph.js +29 -0
  37. package/src/ui/format/index.js +25 -1
  38. package/src/ui/format/markdownRenderer.js +224 -2
  39. package/src/ui/ink/UcodeApp.js +285 -22
  40. package/src/code/context/featureFlag.js +0 -13
@@ -0,0 +1,432 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Plan UI projection — user-facing progress view over planGraph + TaskRuns.
5
+ * Hides IR fields (dependsOn, childGraphId, revisions) from the default surface.
6
+ */
7
+
8
+ const { projectPlanView } = require("./planGraphService");
9
+ const { listActiveWritingTaskRuns } = require("../runtime/taskRun");
10
+ const { hasActiveWriteLease } = require("../runtime/workspaceLease");
11
+
12
+ const ACTIVE_STATUSES = new Set(["running", "waiting_llm", "waiting_approval"]);
13
+ const DONE_STATUSES = new Set(["succeeded"]);
14
+ const FAILED_STATUSES = new Set(["failed", "blocked"]);
15
+ const CANCELLED_STATUSES = new Set(["cancelled", "skipped"]);
16
+
17
+ function ensurePlanUiState(executionState = null) {
18
+ const state = executionState && typeof executionState === "object" ? executionState : {};
19
+ if (!state.planUi || typeof state.planUi !== "object") {
20
+ state.planUi = { bandMode: "auto" };
21
+ }
22
+ if (!state.planUi.bandMode) state.planUi.bandMode = "auto";
23
+ return state.planUi;
24
+ }
25
+
26
+ function getBandMode(executionState = null) {
27
+ return ensurePlanUiState(executionState).bandMode || "auto";
28
+ }
29
+
30
+ function setBandMode(executionState = null, mode = "auto") {
31
+ const ui = ensurePlanUiState(executionState);
32
+ const next = String(mode || "auto").trim().toLowerCase();
33
+ const allowed = new Set(["auto", "hidden", "expanded", "debug"]);
34
+ ui.bandMode = allowed.has(next) ? next : "auto";
35
+ return ui.bandMode;
36
+ }
37
+
38
+ function statusToMark(status = "") {
39
+ const value = String(status || "").trim().toLowerCase();
40
+ if (DONE_STATUSES.has(value)) return { mark: "✓", kind: "done" };
41
+ if (FAILED_STATUSES.has(value)) return { mark: "✗", kind: "failed" };
42
+ if (CANCELLED_STATUSES.has(value)) return { mark: "⊘", kind: "cancelled" };
43
+ if (ACTIVE_STATUSES.has(value)) return { mark: "→", kind: "active" };
44
+ return { mark: "○", kind: "pending" };
45
+ }
46
+
47
+ function nodeTitle(node = null) {
48
+ if (!node) return "";
49
+ if (node.type === "tool") {
50
+ return node.tool ? `${node.id}:${node.tool}` : (node.title || node.id);
51
+ }
52
+ return String(node.title || node.objective || node.id || "").trim();
53
+ }
54
+
55
+ function executionKind(node = null) {
56
+ if (!node || node.type !== "task") return "";
57
+ const exec = node.execution;
58
+ if (exec && typeof exec === "object") {
59
+ return String(exec.kind || "").trim().toLowerCase();
60
+ }
61
+ return String(exec || "").trim().toLowerCase();
62
+ }
63
+
64
+ function truncate(text = "", max = 48) {
65
+ const value = String(text || "").replace(/\s+/g, " ").trim();
66
+ if (!value) return "";
67
+ const limit = Number.isFinite(max) && max > 0 ? Math.floor(max) : 48;
68
+ if (value.length <= limit) return value;
69
+ return `${value.slice(0, Math.max(1, limit - 1))}…`;
70
+ }
71
+
72
+ function basenamePath(filePath = "") {
73
+ const raw = String(filePath || "").trim();
74
+ if (!raw) return "";
75
+ const parts = raw.split(/[/\\]/).filter(Boolean);
76
+ return parts[parts.length - 1] || raw;
77
+ }
78
+
79
+ function countTaskProgress(view = []) {
80
+ const tasks = view.filter((node) => node && node.type === "task" && !node.generated);
81
+ const total = tasks.length;
82
+ const done = tasks.filter((node) => DONE_STATUSES.has(String(node.status || "").toLowerCase())).length;
83
+ return { done, total };
84
+ }
85
+
86
+ function resolveFocus(view = [], planGraph = {}, activeRuns = []) {
87
+ const byId = new Map(view.map((node) => [node.id, node]));
88
+
89
+ for (const run of activeRuns) {
90
+ const parent = byId.get(run.parentNodeId);
91
+ if (parent) {
92
+ return {
93
+ nodeId: parent.id,
94
+ title: nodeTitle(parent),
95
+ kind: executionKind(parent) === "task_loop" ? "task_loop" : "task",
96
+ status: parent.status || "running",
97
+ taskRunId: run.id,
98
+ };
99
+ }
100
+ }
101
+
102
+ const waiting = planGraph.waitingFor;
103
+ if (waiting && waiting.id && byId.has(waiting.id)) {
104
+ const node = byId.get(waiting.id);
105
+ return {
106
+ nodeId: node.id,
107
+ title: nodeTitle(node),
108
+ kind: node.type === "tool" ? "tool" : (executionKind(node) === "task_loop" ? "task_loop" : "task"),
109
+ status: node.status || "waiting_llm",
110
+ taskRunId: "",
111
+ };
112
+ }
113
+
114
+ const active = view.find((node) => ACTIVE_STATUSES.has(String(node.status || "").toLowerCase()));
115
+ if (active) {
116
+ return {
117
+ nodeId: active.id,
118
+ title: nodeTitle(active),
119
+ kind: active.type === "tool" ? "tool" : (executionKind(active) === "task_loop" ? "task_loop" : "task"),
120
+ status: active.status,
121
+ taskRunId: "",
122
+ };
123
+ }
124
+
125
+ const ready = view.find((node) => String(node.status || "").toLowerCase() === "ready" && node.type === "task");
126
+ if (ready) {
127
+ return {
128
+ nodeId: ready.id,
129
+ title: nodeTitle(ready),
130
+ kind: executionKind(ready) === "task_loop" ? "task_loop" : "task",
131
+ status: ready.status,
132
+ taskRunId: "",
133
+ };
134
+ }
135
+
136
+ return null;
137
+ }
138
+
139
+ function buildTreeRows(view = [], {
140
+ focusId = "",
141
+ includeToolsUnderFocus = false,
142
+ debug = false,
143
+ } = {}) {
144
+ const byId = new Map(view.map((node) => [node.id, node]));
145
+ const roots = view.filter((node) => (
146
+ node
147
+ && !node.parentId
148
+ && node.type === "task"
149
+ && !node.generated
150
+ ));
151
+ const rows = [];
152
+
153
+ function walk(node, depth) {
154
+ if (!node) return;
155
+ const isTool = node.type === "tool";
156
+ if (isTool && !debug) {
157
+ if (!includeToolsUnderFocus || node.parentId !== focusId) return;
158
+ }
159
+ if (!debug && node.generated && node.type !== "task" && node.type !== "tool") return;
160
+ if (!debug && node.type !== "task" && node.type !== "tool") return;
161
+
162
+ const { mark, kind } = statusToMark(node.status);
163
+ rows.push({
164
+ depth,
165
+ id: node.id,
166
+ title: nodeTitle(node),
167
+ mark,
168
+ kind,
169
+ type: node.type,
170
+ status: node.status || "pending",
171
+ });
172
+
173
+ const childIds = Array.isArray(node.children) ? node.children : [];
174
+ for (const childId of childIds) {
175
+ const child = byId.get(childId);
176
+ if (!child) continue;
177
+ if (child.type === "task") {
178
+ walk(child, depth + 1);
179
+ } else if (
180
+ child.type === "tool"
181
+ && (debug || (includeToolsUnderFocus && node.id === focusId))
182
+ ) {
183
+ walk(child, depth + 1);
184
+ }
185
+ }
186
+ }
187
+
188
+ for (const root of roots) walk(root, 0);
189
+ return rows;
190
+ }
191
+
192
+ function formatTreeLine(row = {}) {
193
+ const indent = row.depth > 0
194
+ ? `${" ".repeat(Math.max(0, row.depth - 1))}├─ `
195
+ : "";
196
+ return `${indent}${row.mark} ${row.title}`;
197
+ }
198
+
199
+ function buildCompactSummary(rows = [], focusId = "") {
200
+ const top = rows.filter((row) => row.depth === 0 && row.type === "task");
201
+ if (top.length === 0) return "";
202
+ return top
203
+ .map((row) => `${row.title} ${row.mark}`)
204
+ .join(" · ");
205
+ }
206
+
207
+ function buildDebugLines(executionState = null, planGraph = {}) {
208
+ const lines = [];
209
+ const pg = planGraph && typeof planGraph === "object" ? planGraph : {};
210
+ lines.push(`graphId=${pg.graphId || "-"} spec=${Number(pg.specRevision) || 0} state=${Number(pg.stateRevision) || 0}`);
211
+ const nodes = Array.isArray(pg.nodes) ? pg.nodes : [];
212
+ for (const node of nodes.slice(0, 12)) {
213
+ const deps = Array.isArray(node.dependsOn) ? node.dependsOn.join(",") : "";
214
+ lines.push(
215
+ `${node.id} type=${node.type} status=${node.status || "pending"}`
216
+ + (deps ? ` deps=[${deps}]` : "")
217
+ + (node.parentTaskId ? ` parent=${node.parentTaskId}` : "")
218
+ );
219
+ }
220
+ if (nodes.length > 12) lines.push(`… +${nodes.length - 12} nodes`);
221
+ const runs = listActiveWritingTaskRuns(executionState);
222
+ for (const run of runs) {
223
+ lines.push(`taskRun ${run.id} node=${run.parentNodeId} status=${run.status} phase=${run.phase || ""}`);
224
+ }
225
+ const lease = executionState && executionState.workspaceLease;
226
+ if (lease && lease.holder) {
227
+ lines.push(`lease ${lease.holder.kind}${lease.holder.taskRunId ? `:${lease.holder.taskRunId}` : ""}`);
228
+ }
229
+ return lines;
230
+ }
231
+
232
+ function buildTaskRunProjection(executionState = null, view = []) {
233
+ const active = listActiveWritingTaskRuns(executionState)[0] || null;
234
+ if (!active) return null;
235
+ const byId = new Map(view.map((node) => [node.id, node]));
236
+ const parent = byId.get(active.parentNodeId);
237
+ const files = Array.isArray(active.changedFiles) ? active.changedFiles : [];
238
+ const hint = files.slice(-2).map(basenamePath).filter(Boolean).join(", ");
239
+ return {
240
+ phase: String(active.phase || active.status || "running"),
241
+ status: String(active.status || ""),
242
+ parentTitle: parent ? nodeTitle(parent) : active.parentNodeId,
243
+ taskRunId: active.id,
244
+ changedFilesHint: hint,
245
+ };
246
+ }
247
+
248
+ function projectionHash({
249
+ bandMode = "auto",
250
+ specRevision = 0,
251
+ stateRevision = 0,
252
+ focusId = "",
253
+ taskRunId = "",
254
+ leaseHeld = false,
255
+ progressDone = 0,
256
+ progressTotal = 0,
257
+ bandLines = [],
258
+ } = {}) {
259
+ return [
260
+ bandMode,
261
+ specRevision,
262
+ stateRevision,
263
+ focusId,
264
+ taskRunId,
265
+ leaseHeld ? "1" : "0",
266
+ progressDone,
267
+ progressTotal,
268
+ bandLines.join("\n"),
269
+ ].join("|");
270
+ }
271
+
272
+ /**
273
+ * Build TUI-facing plan projection.
274
+ *
275
+ * @param {object|null} executionState
276
+ * @param {{ cols?: number, activityMessage?: string, maxBandRows?: number }} [options]
277
+ */
278
+ function buildPlanUiProjection(executionState = null, options = {}) {
279
+ const state = executionState && typeof executionState === "object" ? executionState : {};
280
+ const bandMode = getBandMode(state);
281
+ const cols = Number(options.cols) > 0 ? Math.floor(Number(options.cols)) : 80;
282
+ const narrow = cols < 60;
283
+ const activityMessage = String(options.activityMessage || "").trim();
284
+
285
+ const pg = state.planGraph && typeof state.planGraph === "object" ? state.planGraph : {};
286
+ const view = projectPlanView(pg);
287
+ const taskNodes = view.filter((node) => node && node.type === "task" && !node.generated);
288
+ const hasPlan = Boolean(pg.graphId) && taskNodes.length > 0;
289
+ const progress = countTaskProgress(view);
290
+ const activeRuns = listActiveWritingTaskRuns(state);
291
+ const focus = resolveFocus(view, pg, activeRuns);
292
+ const taskRun = buildTaskRunProjection(state, view);
293
+ const leaseHeld = hasActiveWriteLease(state);
294
+
295
+ const includeTools = bandMode === "expanded" || bandMode === "debug";
296
+ const tree = hasPlan
297
+ ? buildTreeRows(view, {
298
+ focusId: focus ? focus.nodeId : "",
299
+ includeToolsUnderFocus: includeTools,
300
+ debug: bandMode === "debug",
301
+ })
302
+ : [];
303
+
304
+ const progressLabel = progress.total > 0 ? `${progress.done}/${progress.total}` : "";
305
+ const focusTitle = focus ? truncate(focus.title, narrow ? 18 : 28) : "";
306
+
307
+ let bandLines = [];
308
+ let visible = false;
309
+
310
+ if (hasPlan && bandMode !== "hidden") {
311
+ visible = true;
312
+ if (bandMode === "debug") {
313
+ bandLines = buildDebugLines(state, pg);
314
+ } else if (narrow) {
315
+ const summary = buildCompactSummary(tree, focus && focus.nodeId);
316
+ bandLines = [truncate(
317
+ `Plan${focusTitle ? ` · ${focusTitle}` : ""}${progressLabel ? ` (${progressLabel})` : ""}${summary && !focusTitle ? ` ${summary}` : ""}`,
318
+ Math.max(24, cols - 2)
319
+ )];
320
+ } else if (bandMode === "auto") {
321
+ const summary = buildCompactSummary(tree, focus && focus.nodeId);
322
+ const header = truncate(
323
+ `Plan${pg.objective ? ` · ${pg.objective}` : ""}${summary ? ` ${summary}` : ""}`,
324
+ Math.max(24, cols - 2)
325
+ );
326
+ bandLines = [header];
327
+ if (focus) {
328
+ const focusChildren = view
329
+ .filter((node) => node && node.parentId === focus.nodeId)
330
+ .map((node) => {
331
+ const { mark } = statusToMark(node.status);
332
+ return `${mark} ${nodeTitle(node)}`;
333
+ });
334
+ if (focusChildren.length > 0) {
335
+ bandLines.push(truncate(
336
+ ` └ ${focusChildren.join(" · ")}`,
337
+ Math.max(24, cols - 2)
338
+ ));
339
+ } else if (focus.title) {
340
+ bandLines.push(truncate(` → ${focus.title}`, Math.max(24, cols - 2)));
341
+ }
342
+ }
343
+ if (taskRun) {
344
+ const leaseBit = leaseHeld ? "writing" : taskRun.phase;
345
+ const files = taskRun.changedFilesHint ? ` · ${taskRun.changedFilesHint}` : "";
346
+ bandLines.push(truncate(` TaskLoop ${leaseBit}${files}`, Math.max(24, cols - 2)));
347
+ }
348
+ const maxRows = Number.isFinite(options.maxBandRows) ? options.maxBandRows : 3;
349
+ bandLines = bandLines.slice(0, Math.max(1, maxRows));
350
+ } else {
351
+ // expanded
352
+ const title = pg.objective ? `Plan · ${pg.objective}` : "Plan";
353
+ bandLines = [truncate(title, Math.max(24, cols - 2))];
354
+ for (const row of tree) {
355
+ bandLines.push(truncate(formatTreeLine(row), Math.max(24, cols - 2)));
356
+ }
357
+ if (taskRun) {
358
+ const files = taskRun.changedFilesHint ? ` · ${taskRun.changedFilesHint}` : "";
359
+ bandLines.push(truncate(`TaskLoop · ${taskRun.phase}${files}`, Math.max(24, cols - 2)));
360
+ }
361
+ const maxRows = Number.isFinite(options.maxBandRows) ? options.maxBandRows : 7;
362
+ bandLines = bandLines.slice(0, Math.max(1, maxRows));
363
+ }
364
+ }
365
+
366
+ const progressLabelForStatus = progress.total > 0 ? `${progress.done}/${progress.total}` : "";
367
+ let statusLine = "";
368
+ if (hasPlan) {
369
+ const parts = ["Plan"];
370
+ if (focusTitle) parts.push(focusTitle);
371
+ if (progressLabelForStatus) parts.push(`(${progressLabelForStatus})`);
372
+ if (taskRun) parts.push(`TaskLoop ${taskRun.phase || "running"}`);
373
+ else if (focus && ACTIVE_STATUSES.has(String(focus.status || "").toLowerCase())) {
374
+ parts.push(String(focus.status).replace(/_/g, " "));
375
+ }
376
+ statusLine = parts.join(" · ");
377
+ }
378
+
379
+ let idleHint = "";
380
+ if (hasPlan && progress.total > 0 && progress.done < progress.total) {
381
+ idleHint = focusTitle
382
+ ? `Plan waiting: ${focusTitle}${progressLabelForStatus ? ` (${progressLabelForStatus})` : ""}`
383
+ : `Plan (${progressLabelForStatus})`;
384
+ }
385
+
386
+ let activityStatusLine = activityMessage;
387
+ if (hasPlan && statusLine) {
388
+ if (!activityMessage) {
389
+ activityStatusLine = statusLine;
390
+ } else {
391
+ const tail = truncate(activityMessage, narrow ? 24 : 36);
392
+ activityStatusLine = `${statusLine} · ${tail}`;
393
+ }
394
+ }
395
+
396
+ const hash = projectionHash({
397
+ bandMode,
398
+ specRevision: Number(pg.specRevision) || 0,
399
+ stateRevision: Number(pg.stateRevision) || 0,
400
+ focusId: focus ? focus.nodeId : "",
401
+ taskRunId: taskRun ? taskRun.taskRunId : "",
402
+ leaseHeld,
403
+ progressDone: progress.done,
404
+ progressTotal: progress.total,
405
+ bandLines,
406
+ });
407
+
408
+ return {
409
+ hasPlan,
410
+ visible,
411
+ bandMode,
412
+ objective: String(pg.objective || "").trim(),
413
+ progress,
414
+ focus,
415
+ tree,
416
+ taskRun,
417
+ leaseHeld,
418
+ statusLine,
419
+ idleHint,
420
+ activityStatusLine,
421
+ bandLines,
422
+ hash,
423
+ };
424
+ }
425
+
426
+ module.exports = {
427
+ ensurePlanUiState,
428
+ getBandMode,
429
+ setBandMode,
430
+ statusToMark,
431
+ buildPlanUiProjection,
432
+ };
@@ -31,7 +31,7 @@ const {
31
31
  } = require("../skills");
32
32
  const { hashContent } = require("./artifacts");
33
33
 
34
- const PROMPT_VERSION = "native-v4";
34
+ const PROMPT_VERSION = "native-v5";
35
35
 
36
36
  function buildImmutablePrefix() {
37
37
  return [
@@ -44,12 +44,28 @@ function buildImmutablePrefix() {
44
44
  getOutputEfficiencySection(),
45
45
  [
46
46
  "Tool calling grammar:",
47
- "- Use read, write, edit, bash, artifact_read tools.",
48
- "- Tool results may reference artifactId; use artifact_read to hydrate raw content.",
47
+ "- Use read, write, edit, bash, and artifact_read for direct work, even when it takes several tool calls. Use plan_graph only when the work needs a durable semantic/executable plan, explicit dependencies or checkpoints, or asynchronous TaskRuns.",
48
+ "- Plan Mode is a runtime posture for the Agent Loop, not an agent tool. While Plan Mode is ON, direct write, edit, and bash calls from the Agent Loop are blocked; read and artifact_read remain available.",
49
+ "- In the Agent Loop, plan_graph operation=create automatically enables Plan Mode. The user may also use /plan on or /plan off.",
50
+ "- Turning Plan Mode off does not cancel an existing graph or running TaskRuns. Use plan_graph operation=cancel_graph or operation=control with cancel_task to stop them.",
51
+ "- When the user enables Plan Mode and no active graph exists, create a plan_graph before performing side effects.",
52
+ "- Use plan_graph for durable graph structure and TaskRun lifecycle: create, patch, inspect, cancel_graph, and control.",
53
+ "- After an accepted plan_graph create or patch, Runtime automatically advances ready tool nodes. Never invent or request an execute_graph tool.",
54
+ "- Do not call plan_graph together with read, write, edit, bash, or artifact_read in the same assistant turn.",
55
+ "- When an active graph is waiting on a task, advance that node through plan_graph instead of bypassing it with direct workspace tools: use patch.expand_node for execution.kind=expand, control.complete_task (nodeId) for execution.kind=inline_llm, or control.start_task for execution.kind=task_loop.",
56
+ "- control.complete_task with nodeId completes a waiting_llm inline_llm task for the current Graph owner. control.complete_task with taskRunId is reserved for the owning TaskLoop. Do not directly complete expand or aggregate tasks.",
57
+ "- While Plan Mode is ON, workspace mutations must be represented as plan_graph tool nodes or performed inside a running task_loop.",
58
+ "- Treat a User reminder as the latest user instruction. Reconcile it before continuing from tool results. If it is compatible with the active plan, resume the waiting plan node; otherwise patch, cancel, or replan first.",
59
+ "- Use execution.kind=task_loop for work that should continue asynchronously without occupying the Agent Loop. plan_graph operation=control action=start_task starts the TaskRun and returns immediately.",
60
+ "- TaskLoops do not consume User reminders. The Agent Loop is woken by runtime task_started, task_succeeded, task_failed, and task_cancelled events.",
61
+ "- Runtime enforces TaskRun concurrency limits and workspace write leases. Direct Agent write, edit, or bash calls may be rejected while writing TaskRuns are active.",
62
+ "- Tool results may contain an artifactId. Use artifact_read to hydrate raw stored output or a slice of it; use read for workspace file paths.",
63
+ "- Use ask_user only when user input is required to proceed. It must be the only tool call in the turn. Use kind=approval for yes/no confirmation, kind=choice for numbered options, and kind=chat for free text.",
64
+ "- The answer to ask_user is returned only as that tool's result, not as a separate user message or pending User reminder. Continue from the returned answer and do not repeat the question.",
65
+ "- ask_user is available only to the Agent Loop. It pauses the Agent Loop, but running TaskRuns continue unless explicitly cancelled.",
66
+ "- Prefer structured argument references when passing upstream node outputs or artifacts into downstream tool arguments. Use string templates only when string interpolation is required, and reference only existing upstream nodes.",
49
67
  "State commit schema (optional at segment end):",
50
68
  '{"stateCommit":{"factsAdd":[],"hypothesesUpdate":[],"decisionsAdd":[],"questionsClose":[],"nextObjective":""},"contextPlan":{"retainRaw":[],"retainRegions":[],"summarize":[],"evict":[],"rehydrateNext":[]}}',
51
- "Context action schema:",
52
- '{"type":"execution_segment","objective":"","steps":[],"checkpoint":{"after":[]}}',
53
69
  ].join("\n"),
54
70
  ].join("\n\n");
55
71
  }
@@ -230,6 +230,8 @@ function extractBalancedJsonObjects(text = "") {
230
230
 
231
231
  function isStructuredSideEffectPayload(parsed = null) {
232
232
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
233
+ // Strict: only recognized control envelopes. Do not treat arbitrary JSON
234
+ // (examples, docs, code) as executable side effects.
233
235
  return Boolean(
234
236
  parsed.stateCommit
235
237
  || parsed.contextPlan
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Tool runtime descriptors, resource locks, and node claim leases.
5
+ * Used by the plan graph scheduler — not a second tool ABI.
6
+ */
7
+
8
+ const { randomUUID } = require("crypto");
9
+
10
+ const DEFAULT_LEASE_MS = 120000;
11
+
12
+ const TOOL_DESCRIPTORS = Object.freeze({
13
+ read: {
14
+ sideEffect: "none",
15
+ supportsCancellation: true,
16
+ retryClass: "safe",
17
+ resourceKeys(args = {}) {
18
+ const path = String(args.path || "").trim();
19
+ // Share the file key with write/edit so mixed batches stay conflict-free.
20
+ return path ? [`file:${path}`] : [];
21
+ },
22
+ },
23
+ artifact_read: {
24
+ sideEffect: "none",
25
+ supportsCancellation: true,
26
+ retryClass: "safe",
27
+ resourceKeys(args = {}) {
28
+ const id = String(args.artifactId || args.id || "").trim();
29
+ return id ? [`artifact:${id}`] : [];
30
+ },
31
+ },
32
+ write: {
33
+ sideEffect: "workspace",
34
+ supportsCancellation: false,
35
+ retryClass: "unsafe",
36
+ resourceKeys(args = {}) {
37
+ const path = String(args.path || "").trim();
38
+ return path ? [`file:${path}`] : ["workspace:*"];
39
+ },
40
+ },
41
+ edit: {
42
+ sideEffect: "workspace",
43
+ supportsCancellation: false,
44
+ retryClass: "unsafe",
45
+ resourceKeys(args = {}) {
46
+ const path = String(args.path || "").trim();
47
+ return path ? [`file:${path}`] : ["workspace:*"];
48
+ },
49
+ },
50
+ bash: {
51
+ sideEffect: "workspace",
52
+ supportsCancellation: true,
53
+ retryClass: "conditional",
54
+ resourceKeys() {
55
+ return ["workspace:*"];
56
+ },
57
+ },
58
+ });
59
+
60
+ function getToolDescriptor(tool = "") {
61
+ const name = String(tool || "").trim().toLowerCase();
62
+ return TOOL_DESCRIPTORS[name] || {
63
+ sideEffect: "external",
64
+ supportsCancellation: false,
65
+ retryClass: "unsafe",
66
+ resourceKeys: () => ["workspace:*"],
67
+ };
68
+ }
69
+
70
+ function createLease({
71
+ workerId = "local",
72
+ leaseMs = DEFAULT_LEASE_MS,
73
+ now = Date.now(),
74
+ } = {}) {
75
+ const ttl = Number.isFinite(leaseMs) ? Math.max(1000, Math.floor(leaseMs)) : DEFAULT_LEASE_MS;
76
+ return {
77
+ id: `lease_${randomUUID().slice(0, 8)}`,
78
+ workerId: String(workerId || "local"),
79
+ claimedAt: new Date(now).toISOString(),
80
+ expiresAt: new Date(now + ttl).toISOString(),
81
+ };
82
+ }
83
+
84
+ function isLeaseExpired(lease = null, now = Date.now()) {
85
+ if (!lease || !lease.expiresAt) return true;
86
+ const exp = Date.parse(String(lease.expiresAt));
87
+ if (!Number.isFinite(exp)) return true;
88
+ return now >= exp;
89
+ }
90
+
91
+ function recoverExpiredLeases(nodeMap = new Map(), {
92
+ now = Date.now(),
93
+ } = {}) {
94
+ const recovered = [];
95
+ for (const node of nodeMap.values()) {
96
+ if (node.status !== "running") continue;
97
+ if (!isLeaseExpired(node.lease, now)) continue;
98
+ const descriptor = getToolDescriptor(node.tool);
99
+ if (descriptor.retryClass === "safe") {
100
+ node.status = "pending";
101
+ node.lease = null;
102
+ node.error = "lease expired; retrying";
103
+ recovered.push({ id: node.id, action: "retry" });
104
+ } else {
105
+ node.status = "failed";
106
+ node.error = "lease expired; unsafe to auto-retry";
107
+ node.lease = null;
108
+ recovered.push({ id: node.id, action: "fail" });
109
+ }
110
+ }
111
+ return recovered;
112
+ }
113
+
114
+ function resourcesConflict(a = [], b = []) {
115
+ const setB = new Set(b);
116
+ for (const key of a) {
117
+ if (setB.has(key)) return true;
118
+ if (key === "workspace:*" && b.length > 0) return true;
119
+ if (setB.has("workspace:*") && a.length > 0) return true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Claim a conflict-free batch of ready tool nodes.
126
+ * parallel=false → at most one. Locks are advisory within a single advance pass.
127
+ */
128
+ function claimSafeReadyToolBatch(readyTools = [], {
129
+ parallel = true,
130
+ resolveArgs = (node) => node.args || {},
131
+ workerId = "local",
132
+ leaseMs = DEFAULT_LEASE_MS,
133
+ now = Date.now(),
134
+ maxBatch = 8,
135
+ } = {}) {
136
+ const claimed = [];
137
+ const held = [];
138
+
139
+ for (const node of readyTools) {
140
+ if (!parallel && claimed.length >= 1) break;
141
+ if (claimed.length >= maxBatch) break;
142
+
143
+ const args = resolveArgs(node) || {};
144
+ const descriptor = getToolDescriptor(node.tool);
145
+ const keys = typeof descriptor.resourceKeys === "function"
146
+ ? descriptor.resourceKeys(args)
147
+ : [];
148
+
149
+ const conflict = held.some((entry) => resourcesConflict(entry.keys, keys));
150
+ if (conflict) continue;
151
+
152
+ node.status = "running";
153
+ node.attempt = (Number(node.attempt) || 0) + 1;
154
+ node.lease = createLease({ workerId, leaseMs, now });
155
+ node.executionId = `exec_${node.id}_${node.attempt}_${randomUUID().slice(0, 6)}`;
156
+ claimed.push(node);
157
+ held.push({ keys, nodeId: node.id });
158
+ }
159
+
160
+ return claimed;
161
+ }
162
+
163
+ module.exports = {
164
+ DEFAULT_LEASE_MS,
165
+ TOOL_DESCRIPTORS,
166
+ getToolDescriptor,
167
+ createLease,
168
+ isLeaseExpired,
169
+ recoverExpiredLeases,
170
+ resourcesConflict,
171
+ claimSafeReadyToolBatch,
172
+ };