flowviant 0.125.30 → 0.125.31
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/bin/lib/agentPlan.mjs +8 -0
- package/bin/lib/agentQueueContext.mjs +6 -0
- package/bin/lib/agentSplit.mjs +90 -0
- package/bin/lib/architectureContract.mjs +57 -0
- package/bin/lib/architectureReport.mjs +38 -0
- package/bin/lib/artifactContracts.mjs +21 -2
- package/bin/lib/claudeInput.mjs +1 -1
- package/bin/lib/claudeSubagents.mjs +46 -6
- package/bin/lib/claudeWorkflows.mjs +94 -0
- package/bin/lib/cliLimit.mjs +5 -0
- package/bin/lib/codexInput.mjs +191 -0
- package/bin/lib/codexSubagentEvents.mjs +3 -1
- package/bin/lib/codexSubagents.mjs +34 -9
- package/bin/lib/conversationOutput.mjs +14 -0
- package/bin/lib/crossCliChild.mjs +327 -0
- package/bin/lib/crossCliLaunch.mjs +92 -0
- package/bin/lib/crossCliPosture.mjs +55 -0
- package/bin/lib/crossCliResearch.mjs +19 -0
- package/bin/lib/fixtures/workflow-ultracode.jsonl +22 -0
- package/bin/lib/nativeAgentTools.mjs +12 -5
- package/bin/lib/orchestrator.mjs +24 -7
- package/bin/lib/orchestratorClient.mjs +8 -2
- package/bin/lib/orchestratorPrompt.mjs +13 -5
- package/bin/lib/orchestratorProtocol.mjs +18 -0
- package/bin/lib/outsideCli.mjs +124 -0
- package/bin/lib/prompts.mjs +7 -5
- package/bin/lib/runTurn.mjs +30 -8
- package/bin/lib/runtimeClaude.mjs +7 -3
- package/bin/lib/runtimeCodex.mjs +12 -6
- package/bin/lib/runtimeLimits.mjs +59 -1
- package/bin/lib/splitGate.mjs +115 -0
- package/bin/lib/subagentBounds.mjs +26 -0
- package/bin/lib/subagentLedger.mjs +110 -0
- package/bin/lib/trace.mjs +134 -6
- package/bin/lib/turnDeclaration.mjs +4 -4
- package/bin/lib/turnGaps.mjs +6 -2
- package/bin/lib/turnSubagents.mjs +23 -9
- package/bin/lib/wikiDraft.mjs +9 -0
- package/bin/lib/workAgentResume.mjs +11 -2
- package/bin/lib/workAgentTurnExecution.mjs +31 -7
- package/bin/lib/workAgentTurnOutcome.mjs +11 -0
- package/bin/lib/workAgentTurnReports.mjs +5 -2
- package/package.json +1 -1
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* plan window is not Flowviant's capacity — it is the VENDOR measuring its own
|
|
9
9
|
* plan, and this file only carries that measurement to Project settings ›
|
|
10
10
|
* Machines, where it reads as "used", never "left" or "free". Nothing here
|
|
11
|
-
* decides, gates
|
|
11
|
+
* decides, gates or parks anything off these numbers; the one other relay is
|
|
12
|
+
* a hit limit's own reset, carried on the park (`limitResetAt`, 2026-11-06)
|
|
13
|
+
* so the server retries when the CLI said, not on a guess.
|
|
12
14
|
*
|
|
13
15
|
* TWO SOURCES, ONE SHAPE:
|
|
14
16
|
*
|
|
@@ -135,6 +137,8 @@ export function recordClaudeRateLimit(info, now = Date.now()) {
|
|
|
135
137
|
capWindows(state.windows);
|
|
136
138
|
state.status = CLAUDE_STATUSES.has(info.status) ? info.status : 'other';
|
|
137
139
|
state.at = at;
|
|
140
|
+
// The binding window's reset rides the event itself beside a rejection.
|
|
141
|
+
state.rejectedResetsAt = state.status === 'rejected' ? isoFromStamp(info.resetsAt) : null;
|
|
138
142
|
}
|
|
139
143
|
|
|
140
144
|
/**
|
|
@@ -370,6 +374,60 @@ export function runtimeLimitsReport({ detected, plan, now = Date.now() } = {}) {
|
|
|
370
374
|
return Object.keys(out).length ? fitRuntimeLimits(out) : null;
|
|
371
375
|
}
|
|
372
376
|
|
|
377
|
+
/** A park's reset is relayed only past now and within this (the longest plan
|
|
378
|
+
* window is a week); the server applies the same bound (`PARK_RESET_MAX_MS`). */
|
|
379
|
+
export const PARK_RESET_MAX_MS = 8 * 24 * 60 * 60_000;
|
|
380
|
+
/** A rejection older than this is not the one this turn hit. */
|
|
381
|
+
const LIMIT_READING_FRESH_MS = 30 * 60_000;
|
|
382
|
+
|
|
383
|
+
/** An ISO reset after `now` and within the bound, or null. */
|
|
384
|
+
export function boundedResetAt(iso, now = Date.now()) {
|
|
385
|
+
const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
|
|
386
|
+
return Number.isFinite(t) && t > now && t - now <= PARK_RESET_MAX_MS ? new Date(t).toISOString() : null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The latest reset among windows the CLI measured as full (≥ 100% used):
|
|
390
|
+
* the account carries on only once every full window has reset. */
|
|
391
|
+
const fullWindowsReset = (windows) => {
|
|
392
|
+
let latest = null;
|
|
393
|
+
for (const w of windows?.values?.() ?? []) {
|
|
394
|
+
if (w.usedPct != null && w.usedPct >= 100 && w.resetsAt && (!latest || w.resetsAt > latest)) latest = w.resetsAt;
|
|
395
|
+
}
|
|
396
|
+
return latest;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* WHEN THE LIMIT THIS TURN HIT RESETS, IN THE CLI'S OWN MEASURE (2026-11-06) —
|
|
401
|
+
* ISO, or null. Relayed on the park (`/fleet/agent-parked` `resetsAt`) so the
|
|
402
|
+
* server retries then instead of after its fixed cooldown.
|
|
403
|
+
*
|
|
404
|
+
* Only a REJECTION read fresh: Claude's `rate_limit_event` with status
|
|
405
|
+
* `rejected` (its own `resetsAt`, else a full window's), or a Codex rollout
|
|
406
|
+
* naming the limit reached — read now, off this turn's thread under the
|
|
407
|
+
* agent's CODEX_HOME (`env`), because the after-close read in runTurn.mjs is
|
|
408
|
+
* not awaited — with a full window's `resets_at`. Anything unmeasured, stale,
|
|
409
|
+
* past or past the bound is null, and the server's cooldown stands. Never
|
|
410
|
+
* throws.
|
|
411
|
+
*/
|
|
412
|
+
export async function limitResetAt(runtime, { threadId, env, now = Date.now() } = {}) {
|
|
413
|
+
try {
|
|
414
|
+
const fresh = (st) => st?.status === 'rejected' && now - Date.parse(st.at) <= LIMIT_READING_FRESH_MS;
|
|
415
|
+
if (runtime === 'claude' || !runtime) {
|
|
416
|
+
if (!fresh(claudeState)) return null;
|
|
417
|
+
return boundedResetAt(claudeState.rejectedResetsAt ?? fullWindowsReset(claudeState.windows), now);
|
|
418
|
+
}
|
|
419
|
+
if (runtime === 'codex') {
|
|
420
|
+
// The agent's own home first, then the person's (its sessions link there).
|
|
421
|
+
if (threadId && !(await learnCodexLimits(threadId, { env, now })) && env) await learnCodexLimits(threadId, { now });
|
|
422
|
+
if (!fresh(codexState)) return null;
|
|
423
|
+
return boundedResetAt(fullWindowsReset(codexState.windows), now);
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
373
431
|
/** Test-only: forget everything learned, the plan memo and the path cache. */
|
|
374
432
|
export function resetRuntimeLimitsForTest() {
|
|
375
433
|
claudeState = null;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PERSON'S YES BEFORE CHILDREN EDIT FILES (2026-11-06, D13/D28–D31, E5).
|
|
3
|
+
*
|
|
4
|
+
* A card turn's job carries `subagents` (`SubagentSplitJob`, shared
|
|
5
|
+
* subagentSplit.ts, `DAEMON_SUBAGENT_SPLIT_MIN`) from a server that asks:
|
|
6
|
+
* - `ask`: a child that edits files waits for an approved split. The gate
|
|
7
|
+
* refuses its spawn in words that tell the model to propose the split
|
|
8
|
+
* (agentSplit.mjs) and stop. A read-only lookup on the agent's own CLI
|
|
9
|
+
* starts without asking (D13).
|
|
10
|
+
* - `approved`: this turn is the person's "Use N subagents".
|
|
11
|
+
* - `always`: the agent's Always switch (D29). It only skips this question;
|
|
12
|
+
* Claude's own workflow/ultracode modes stay separate (D14).
|
|
13
|
+
* ABSENT IS THE OLD SERVER, and the old behaviour stands: nothing is held and
|
|
14
|
+
* nothing is proposed. A research card's children only read already (the
|
|
15
|
+
* gate fences them), so nothing there waits either.
|
|
16
|
+
*
|
|
17
|
+
* WHETHER A CHILD EDITS, per CLI, conservatively — unknown is editing:
|
|
18
|
+
* - Claude's Agent/Task: by `subagent_type`. Only the built-in read-only
|
|
19
|
+
* types (Explore, Plan: no Edit/Write/NotebookEdit tools) read; general-
|
|
20
|
+
* purpose, a project's own type, or none named, edit. The gate also refuses
|
|
21
|
+
* such a child's file-write tools (orchestrator.mjs), since a type is the
|
|
22
|
+
* model's word; its shell is not fenced.
|
|
23
|
+
* - Codex's spawn_agent: by a declared read-only sandbox on the spawn, which
|
|
24
|
+
* no measured Codex input carries today, so every Codex spawn waits.
|
|
25
|
+
* Pure: no record, slot or request is read here.
|
|
26
|
+
*/
|
|
27
|
+
import { isBackgroundSpawnTool, isSpawnTool } from './nativeAgentTools.mjs';
|
|
28
|
+
import { CLAUDE_CHILD_MODELS, claudeFamily } from './orchestratorNative.mjs';
|
|
29
|
+
import { SPLIT_SOLO_LABEL } from './agentSplit.mjs';
|
|
30
|
+
|
|
31
|
+
export const SPLIT_MODES = Object.freeze(['ask', 'approved', 'always']);
|
|
32
|
+
/** Claude Code's built-in subagent types whose tools exclude file writes. */
|
|
33
|
+
export const CLAUDE_READ_ONLY_TYPES = Object.freeze(['Explore', 'Plan']);
|
|
34
|
+
const READ_ONLY_SANDBOX = /^read[-_ ]?only$/i;
|
|
35
|
+
|
|
36
|
+
/** The job's split field, or null when absent or unreadable (the old path). */
|
|
37
|
+
export function splitPolicy(job) {
|
|
38
|
+
const s = job?.subagents;
|
|
39
|
+
if (!s || typeof s !== 'object' || !SPLIT_MODES.includes(s.mode)) return null;
|
|
40
|
+
const str = v => typeof v === 'string' && v.trim() ? v.trim() : null;
|
|
41
|
+
return { mode: s.mode, runtime: str(s.runtime), model: str(s.model), effort: str(s.effort) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether this spawn's child may edit files; true whenever it cannot tell. */
|
|
45
|
+
export function childEdits(event, input = event?.tool_input ?? {}) {
|
|
46
|
+
if (isBackgroundSpawnTool(event?.tool_name)) return !CLAUDE_READ_ONLY_TYPES.includes(input.subagent_type);
|
|
47
|
+
if (isSpawnTool(event?.tool_name)) return ![input.sandbox, input.sandbox_mode, input.sandbox_policy].some(v => typeof v === 'string' && READ_ONLY_SANDBOX.test(v));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const splitRefusal = runtime => `This child may edit files, and the person has not approved a split, so it was not started. Propose the split instead: end your turn with the split JSON (status "split") naming each part, the paths it touches and whether it edits files, then stop and wait for the person's answer. ${runtime === 'codex' ? 'On Codex every spawn waits for that answer; do read-only lookups yourself.' : 'A read-only lookup starts without asking: spawn it with the Explore or Plan agent type.'} Or do this part yourself.`;
|
|
52
|
+
|
|
53
|
+
/** The deny words for a spawn the split holds, or null when it may start. */
|
|
54
|
+
export function splitHold(policy, event, spawn, { runtime, access }) {
|
|
55
|
+
if (!policy || policy.mode !== 'ask' || access === 'read') return null;
|
|
56
|
+
return childEdits(event, spawn?.input) ? splitRefusal(runtime) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether the gate admitted this spawn as a read-only lookup (ask mode). */
|
|
60
|
+
export const readOnlyLookup = (policy, event, spawn, access) => Boolean(policy && policy.mode === 'ask' && access !== 'read' && !childEdits(event, spawn?.input));
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The children's model and effort under an approved or Always split: the
|
|
64
|
+
* person's batch choice, where they named one, over the card's. On Claude the
|
|
65
|
+
* Agent tool takes only a family alias; a choice outside the known families
|
|
66
|
+
* is not honourable there, and the card's model stands (`unhonoured`).
|
|
67
|
+
*/
|
|
68
|
+
export function splitBrain(policy, runtime, card = {}) {
|
|
69
|
+
if (!policy || policy.mode === 'ask') return { brain: {}, unhonoured: [] };
|
|
70
|
+
// A batch on another CLI (E7) chose that CLI's model: native children keep the card's.
|
|
71
|
+
if (policy.runtime && policy.runtime !== runtime) return { brain: {}, unhonoured: [] };
|
|
72
|
+
const brain = {}, unhonoured = [];
|
|
73
|
+
if (policy.model) {
|
|
74
|
+
if (runtime === 'claude' && !CLAUDE_CHILD_MODELS.includes(claudeFamily(policy.model))) unhonoured.push(`Claude's Agent tool names only ${CLAUDE_CHILD_MODELS.join(', ')}, so children run on ${card.model ?? 'the card’s model'}, not ${policy.model}`);
|
|
75
|
+
else brain.model = policy.model;
|
|
76
|
+
}
|
|
77
|
+
if (policy.effort) brain.effort = policy.effort;
|
|
78
|
+
return { brain, unhonoured };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const choiceWords = (policy, runtime, card) => {
|
|
82
|
+
const { brain, unhonoured } = splitBrain(policy, runtime, card);
|
|
83
|
+
const model = brain.model ?? card.model, effort = brain.effort ?? card.effort;
|
|
84
|
+
const at = `${model ?? 'the card’s model'}${effort ? ` at ${effort} effort` : ''}`;
|
|
85
|
+
const codex = runtime === 'codex' && brain.model ? ' On Codex a model outside this machine’s catalog cannot be named for a child; it then inherits the card’s.' : '';
|
|
86
|
+
return `Run every child on ${at}: the person chose it for the whole batch, so name no other model or effort for a child.${unhonoured.length ? ` ${unhonoured.join('. ')}.` : ''}${codex}`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const CLI_LABEL = { claude: 'Claude Code', codex: 'Codex' };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A BATCH ON ANOTHER CLI (2026-11-06, D21/D30, E7): the person chose the other
|
|
93
|
+
* CLI for every child, so each part starts with card_orchestrator's
|
|
94
|
+
* start_cli_agent instead of a native spawn — when this machine has that CLI
|
|
95
|
+
* ready (`crossCli`, the CLIs the controller offers); otherwise the parts are
|
|
96
|
+
* the lead's own, and it says why.
|
|
97
|
+
*/
|
|
98
|
+
const otherCliWords = (policy, crossCli) => {
|
|
99
|
+
const cli = policy.runtime, name = CLI_LABEL[cli] ?? cli;
|
|
100
|
+
if (!crossCli.includes(cli)) return `The person chose ${name} for the children, and ${name} is not ready on this machine, so no child can start on it: do the parts yourself, and say so in your update.`;
|
|
101
|
+
const at = [policy.model, policy.effort && `${policy.effort} effort`].filter(Boolean).join(' at ');
|
|
102
|
+
return `Run every child on ${name}${at ? `, ${at}` : ''}: the person chose it for the whole batch. Start each part with card_orchestrator.start_cli_agent (cli "${cli}", a title, its kind and the whole brief, naming no other model or effort), never with a native spawn, then read each with cli_agent_result until it is done, stopped or limited. A limited child hit ${name}'s own plan limit: its words say so; retry it later with followup_cli_agent or do its part yourself.`;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** The words this run's split policy adds to the orchestrator's contract. */
|
|
106
|
+
export function splitContract(policy, { runtime = 'claude', model = null, effort = null, crossCli = [] } = {}) {
|
|
107
|
+
if (!policy) return '';
|
|
108
|
+
const card = { model, effort };
|
|
109
|
+
const batch = () => policy.runtime && policy.runtime !== runtime ? otherCliWords(policy, crossCli) : choiceWords(policy, runtime, card);
|
|
110
|
+
if (policy.mode === 'approved') return `THE PERSON APPROVED YOUR SPLIT for this turn: start the parts you proposed ${policy.runtime && policy.runtime !== runtime ? 'now' : 'as native subagents now, as above'}. ${batch()}`;
|
|
111
|
+
if (policy.mode === 'always') return `SPLITS ARE PRE-APPROVED ON THIS AGENT (its Always use subagents switch): split independent parts as above without proposing first. ${batch()} This switch starts no workflow or other mode of the CLI; it only skips the person's question.`;
|
|
112
|
+
return `SUBAGENTS THAT EDIT FILES NEED THE PERSON'S YES. For children that edit files this replaces "this contract is the authorization" above. When parts of the card's work can run side by side and would edit files, do not spawn them: write any code they share first, as above, then propose the split and stop. End your turn with this JSON as your final answer, in place of delivered or blocked:
|
|
113
|
+
{"status":"split","split":{"reason":"one or two sentences on why these parts can run side by side","parts":[{"title":"Port gizmo shaders","touches":["src/gizmo.frag","src/gizmo.ts"],"edits":true},{"title":"Audit textures","touches":["assets/textures/**"],"edits":false}]},"progress":"…"}
|
|
114
|
+
Name every part you would give a child (at most 12), the paths or areas it touches (at most 8), and whether it edits files or only reads. The person answers "Use N subagents" — your next turn then says the split is approved and which model and effort the children run on — or "${SPLIT_SOLO_LABEL}", which means: do every part yourself with no child that edits files, and do not propose this split again. ${runtime === 'codex' ? 'On Codex the gate cannot tell a reading child from an editing one, so every spawn waits for that answer: do lookups yourself.' : 'A read-only lookup needs no approval: spawn it with the Explore or Plan agent type; the gate holds every other spawn.'} Children already running keep going: write the proposal as your final answer once they finish. A split is not a question for blocked JSON.`;
|
|
115
|
+
}
|
|
@@ -13,8 +13,34 @@
|
|
|
13
13
|
* shared `SUBAGENT_STATUSES`. */
|
|
14
14
|
|
|
15
15
|
export const SUBAGENT_CAP = 32;
|
|
16
|
+
export const WORKFLOW_AGENT_CAP = 200;
|
|
17
|
+
export const WORKFLOW_CAP = 32;
|
|
16
18
|
/** In the order the trace relay sends them. */
|
|
17
19
|
export const SUBAGENT_FIELD_CAPS = Object.freeze({ kind: 80, title: 200, now: 400, brief: 24_000 });
|
|
18
20
|
export const SUBAGENT_STATUSES = Object.freeze(['running', 'waiting', 'asked', 'stopped', 'done']);
|
|
19
21
|
/** A child that has finished or been stopped: the controller revives neither. */
|
|
20
22
|
export const SETTLED_SUBAGENT_STATUSES = Object.freeze(['done', 'stopped']);
|
|
23
|
+
|
|
24
|
+
/** WHAT EACH CHILD'S RECORD CARRIES BEYOND ITS DESCRIPTOR (2026-11-06, S4.1),
|
|
25
|
+
* and its own step budget. Before this a child was id/kind/title/brief/now
|
|
26
|
+
* and a status, its steps shared the turn's 400 with the lead and every
|
|
27
|
+
* sibling, and a 33rd child vanished without a count. The server keeps the
|
|
28
|
+
* same numbers (`agentTraceAgents.ts`, check-app-parity rule 38):
|
|
29
|
+
* · `SUBAGENT_STEP_CAP` — the newest steps each child keeps of its own,
|
|
30
|
+
* past which its oldest are said as "N earlier not kept";
|
|
31
|
+
* · `SUBAGENT_RESULT_CAP` — its final message, as its CLI handed it back;
|
|
32
|
+
* · `SUBAGENT_RUNS_CAP` — the turns one resumed child is remembered in;
|
|
33
|
+
* · `SUBAGENT_KEY_MAX` — its stable identity across turns;
|
|
34
|
+
* · `SUBAGENT_CLIS` — the CLIs whose children are recorded (a CLI outside
|
|
35
|
+
* the list is unknown and the field is absent).
|
|
36
|
+
* `OUTSIDE_*` bounds the lead's shell-outs to another CLI ("Outside" rows,
|
|
37
|
+
* D23): shown, never blocked, never counted toward the 32. */
|
|
38
|
+
export const SUBAGENT_STEP_CAP = 120;
|
|
39
|
+
export const SUBAGENT_RESULT_CAP = 8_000;
|
|
40
|
+
export const SUBAGENT_RUNS_CAP = 16;
|
|
41
|
+
export const SUBAGENT_KEY_MAX = 160;
|
|
42
|
+
export const SUBAGENT_CLIS = Object.freeze(['claude', 'codex']);
|
|
43
|
+
export const OUTSIDE_CAP = 32;
|
|
44
|
+
export const OUTSIDE_COMMAND_CAP = 600;
|
|
45
|
+
export const OUTSIDE_CLIS = Object.freeze(['claude', 'codex', 'gemini']);
|
|
46
|
+
export const OUTSIDE_STATUSES = Object.freeze(['running', 'done', 'failed']);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH TURNS EACH OF AN AGENT'S SUBAGENTS APPEARED IN (2026-11-06, S4.1,
|
|
3
|
+
* D26). A child resumed in a later turn — Claude's `Agent({ resume })` of
|
|
4
|
+
* its native agent id, a Codex follow-up to its child thread — is the same
|
|
5
|
+
* child, and the chat shows it once ("continued in run 3"). Each turn's
|
|
6
|
+
* trace keeps its own bounded record; this ledger is how that record knows
|
|
7
|
+
* the earlier turns, by the stable `key` turnSubagents.mjs gives it.
|
|
8
|
+
*
|
|
9
|
+
* Kept beside the agent's resume markers, in the worktree's private git dir
|
|
10
|
+
* (workAgentResume.mjs): a child can only be resumed where its conversation
|
|
11
|
+
* is, so this is exactly as durable as the resume it describes. Codex
|
|
12
|
+
* children also keep the task paths they answered to, so a follow-up by
|
|
13
|
+
* path reaches its thread in a later turn (codexSubagents.mjs
|
|
14
|
+
* `knownTargets`).
|
|
15
|
+
*
|
|
16
|
+
* BEST EFFORT BOTH WAYS, like the markers: unreadable is unknown (runs
|
|
17
|
+
* carry no field), unwritable costs the next turn its history.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { CLAUDE_SESSION_RE, CODEX_THREAD_RE, CODEX_CHILD_THREAD_RE } from './conversationIds.mjs';
|
|
21
|
+
import { SUBAGENT_KEY_MAX, SUBAGENT_RUNS_CAP } from './subagentBounds.mjs';
|
|
22
|
+
|
|
23
|
+
/** Children remembered per agent; the oldest go first. */
|
|
24
|
+
export const LEDGER_KEYS = 128;
|
|
25
|
+
const LEDGER_BYTES = 256 * 1024;
|
|
26
|
+
const NAMES = 4;
|
|
27
|
+
const TURN_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
28
|
+
|
|
29
|
+
function readLedger(path) {
|
|
30
|
+
if (!path || !existsSync(path)) return new Map();
|
|
31
|
+
try {
|
|
32
|
+
const raw = readFileSync(path, 'utf8');
|
|
33
|
+
if (raw.length > LEDGER_BYTES) return null;
|
|
34
|
+
const v = JSON.parse(raw);
|
|
35
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
|
|
36
|
+
const out = new Map();
|
|
37
|
+
for (const [key, entry] of Object.entries(v).slice(-LEDGER_KEYS)) {
|
|
38
|
+
if (key.length > SUBAGENT_KEY_MAX || !entry || typeof entry !== 'object') continue;
|
|
39
|
+
const r = Array.isArray(entry.r) ? entry.r.filter(t => typeof t === 'string' && TURN_RE.test(t)).slice(-SUBAGENT_RUNS_CAP) : [];
|
|
40
|
+
const n = Array.isArray(entry.n) ? entry.n.filter(s => typeof s === 'string' && s.length <= 160).slice(-NAMES) : [];
|
|
41
|
+
const c = entry.c;
|
|
42
|
+
const valid = c && ['codex', 'claude'].includes(c.cli) &&
|
|
43
|
+
(c.cli === 'codex' ? CODEX_THREAD_RE : CLAUDE_SESSION_RE).test(c.native ?? '') &&
|
|
44
|
+
key === `${c.cli}:${c.native}` && typeof c.wt === 'string' && c.wt.startsWith('/') && c.wt.length <= 4096;
|
|
45
|
+
out.set(key, { r, n, ...(valid ? { c } : {}) });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One turn's view of the agent's ledger at `path` (null: no git dir). */
|
|
54
|
+
export function subagentLedger(path, turnId) {
|
|
55
|
+
const ledger = path ? readLedger(path) : null;
|
|
56
|
+
let dirty = false;
|
|
57
|
+
return {
|
|
58
|
+
/** Null when the ledger is unknown: the trace then sends no runs. */
|
|
59
|
+
runsOf: ledger && TURN_RE.test(turnId ?? '') ? (key, { title } = {}) => {
|
|
60
|
+
if (typeof key !== 'string' || !key || key.length > SUBAGENT_KEY_MAX) return null;
|
|
61
|
+
const entry = ledger.get(key) ?? { r: [], n: [] };
|
|
62
|
+
if (!entry.r.includes(turnId)) { entry.r = [...entry.r, turnId].slice(-SUBAGENT_RUNS_CAP); dirty = true; }
|
|
63
|
+
if (typeof title === 'string' && title && title.length <= 160 && !entry.n.includes(title)) { entry.n = [...entry.n, title].slice(-NAMES); dirty = true; }
|
|
64
|
+
// Most recently seen last, so the bound sheds the longest unheard.
|
|
65
|
+
ledger.delete(key); ledger.set(key, entry);
|
|
66
|
+
while (ledger.size > LEDGER_KEYS) ledger.delete(ledger.keys().next().value);
|
|
67
|
+
return [...entry.r];
|
|
68
|
+
} : null,
|
|
69
|
+
/** A Codex child's earlier names → its thread, for this turn's reader. */
|
|
70
|
+
codexTargets() {
|
|
71
|
+
const out = new Map();
|
|
72
|
+
for (const [key, { n }] of ledger ?? []) {
|
|
73
|
+
const thread = key.startsWith('codex:') ? key.slice(6) : '';
|
|
74
|
+
if (!CODEX_CHILD_THREAD_RE.test(thread)) continue;
|
|
75
|
+
for (const name of n) out.set(name, thread);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
},
|
|
79
|
+
/** Cross-CLI identity is measured at init, never reconstructed from a title. */
|
|
80
|
+
rememberCli(key, child) {
|
|
81
|
+
if (!ledger || !key || key.length > SUBAGENT_KEY_MAX) return;
|
|
82
|
+
const entry = ledger.get(key) ?? { r: [], n: [] };
|
|
83
|
+
entry.c = { ...child, id: entry.c?.id ?? child.id };
|
|
84
|
+
ledger.delete(key); ledger.set(key, entry);
|
|
85
|
+
while (ledger.size > LEDGER_KEYS) ledger.delete(ledger.keys().next().value);
|
|
86
|
+
dirty = true;
|
|
87
|
+
this.persist();
|
|
88
|
+
},
|
|
89
|
+
cliChild(id, wt) {
|
|
90
|
+
for (const [key, entry] of ledger ?? []) {
|
|
91
|
+
if (entry.c?.wt === wt && (key === id || entry.c.id === id)) return { ...entry.c, key };
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
},
|
|
95
|
+
persist() {
|
|
96
|
+
if (!dirty || !path || !ledger) return;
|
|
97
|
+
try {
|
|
98
|
+
let data = JSON.stringify(Object.fromEntries(ledger));
|
|
99
|
+
while (Buffer.byteLength(data) > LEDGER_BYTES && ledger.size) {
|
|
100
|
+
ledger.delete(ledger.keys().next().value);
|
|
101
|
+
data = JSON.stringify(Object.fromEntries(ledger));
|
|
102
|
+
}
|
|
103
|
+
writeFileSync(path, data);
|
|
104
|
+
dirty = false;
|
|
105
|
+
} catch {
|
|
106
|
+
/* the next turn's children carry no history */
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/bin/lib/trace.mjs
CHANGED
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
import { THINK_MARKER, scrubNativeText } from './runtimeEvents.mjs';
|
|
63
63
|
import { withoutTurnDeclaration } from './turnDeclaration.mjs';
|
|
64
64
|
import { MAX_MCP_SERVERS, MCP_NAME_MAX, MCP_STATUSES } from './mcpReportBounds.mjs';
|
|
65
|
-
import {
|
|
65
|
+
import { WORKFLOW_AGENT_CAP, WORKFLOW_CAP, OUTSIDE_CAP, OUTSIDE_COMMAND_CAP, OUTSIDE_STATUSES, SETTLED_SUBAGENT_STATUSES, SUBAGENT_CAP, SUBAGENT_CLIS, SUBAGENT_FIELD_CAPS,
|
|
66
|
+
SUBAGENT_KEY_MAX, SUBAGENT_RESULT_CAP, SUBAGENT_RUNS_CAP, SUBAGENT_STATUSES } from './subagentBounds.mjs';
|
|
66
67
|
|
|
67
68
|
/** One batch every two seconds — the discipline the tab's narrator keeps, for
|
|
68
69
|
* the same reason: a turn emits hundreds of entries and nobody is reading them
|
|
@@ -111,7 +112,18 @@ function newRunId() {
|
|
|
111
112
|
* with no such route, a body the server will never accept). Resolving falsy
|
|
112
113
|
* keeps them queued for the next flush.
|
|
113
114
|
*/
|
|
114
|
-
|
|
115
|
+
/** One native window a brief or result is scrubbed over before its cap. */
|
|
116
|
+
const NATIVE_WINDOW = 65_536;
|
|
117
|
+
const EFFORT_RE = /^[a-z]{1,24}$/;
|
|
118
|
+
const RUN_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `cli` is the runtime whose children this turn records. `runsOf(key)` is
|
|
122
|
+
* the agent's subagent ledger (workAgentResume.mjs): the turns a child of
|
|
123
|
+
* that stable key appeared in, this one included; absent, a child's runs are
|
|
124
|
+
* unknown and carry no field.
|
|
125
|
+
*/
|
|
126
|
+
export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run = newRunId(), orchestrationVersion, cli, runsOf }) {
|
|
115
127
|
/** Entries not yet delivered. `base` is the ABSOLUTE index of queue[0] in
|
|
116
128
|
* THIS RUN's stream, so `base + queue.length` is everything this relay has
|
|
117
129
|
* ever accepted — sent, queued or shed. Absolute for the TURN is the
|
|
@@ -124,6 +136,19 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
124
136
|
* by the bare-marker collapse in `prose()` and nothing else. */
|
|
125
137
|
let lastEntry = null;
|
|
126
138
|
const agentReadings = new Map();
|
|
139
|
+
/** Each child's own steps, sent or shed: its record's `steps`, against
|
|
140
|
+
* which the server says "N earlier not kept" past its own budget. */
|
|
141
|
+
const childSteps = new Map();
|
|
142
|
+
/** Children past the 32, counted and never recorded (bounded: a count). */
|
|
143
|
+
const unrecorded = new Set();
|
|
144
|
+
let notRecorded = 0;
|
|
145
|
+
/** The lead's own entries this run, sent or shed: where a child or an
|
|
146
|
+
* Outside row sits in the lead's thread (its `position`). */
|
|
147
|
+
let leadCount = 0;
|
|
148
|
+
/** The lead's shell-outs to another CLI ("Outside", D23), by call id. */
|
|
149
|
+
const outsideRows = new Map();
|
|
150
|
+
const workflowRows = new Map(), workflowPositions = new Map();
|
|
151
|
+
let extrasPending = false;
|
|
127
152
|
const mcpSaid = new Set();
|
|
128
153
|
let dirty = orchestrationVersion === 2;
|
|
129
154
|
let markerPending = dirty;
|
|
@@ -145,6 +170,8 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
145
170
|
const push = (entry) => {
|
|
146
171
|
if (stopped || !entry) return;
|
|
147
172
|
lastEntry = entry;
|
|
173
|
+
if (entry.agentId) childSteps.set(entry.agentId, (childSteps.get(entry.agentId) ?? 0) + 1);
|
|
174
|
+
else if (entry.k !== 'agent') leadCount += 1;
|
|
148
175
|
queue.push(entry);
|
|
149
176
|
while (queue.length > TRACE_BUFFER) {
|
|
150
177
|
queue.shift();
|
|
@@ -156,7 +183,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
156
183
|
|
|
157
184
|
const drain = async (deadlineMs) => {
|
|
158
185
|
const until = deadlineMs > 0 ? Date.now() + deadlineMs : 0;
|
|
159
|
-
while (queue.length || markerPending) {
|
|
186
|
+
while (queue.length || markerPending || extrasPending) {
|
|
160
187
|
if (until && Date.now() > until) {
|
|
161
188
|
dirty = true;
|
|
162
189
|
return;
|
|
@@ -164,10 +191,17 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
164
191
|
const entries = queue.slice(0, TRACE_BATCH);
|
|
165
192
|
const seq = base;
|
|
166
193
|
dirty = false;
|
|
194
|
+
// The turn's counts and Outside rows ride beside the stream, whole: they
|
|
195
|
+
// take no seq, so an older server ignoring them sheds no step.
|
|
196
|
+
const extras = extrasPending;
|
|
197
|
+
extrasPending = false;
|
|
167
198
|
let ok = false;
|
|
168
199
|
try {
|
|
169
200
|
ok = (await post({ agentId, turnId, run, seq, entries,
|
|
170
201
|
...(orchestrationVersion === 2 ? { orchestrationVersion } : {}),
|
|
202
|
+
...(extras && notRecorded > 0 ? { notRecorded } : {}),
|
|
203
|
+
...(extras && outsideRows.size ? { outside: [...outsideRows.values()] } : {}),
|
|
204
|
+
...(extras && workflowRows.size ? { workflows: [...workflowRows.values()] } : {}),
|
|
171
205
|
})) !== false;
|
|
172
206
|
} catch {
|
|
173
207
|
ok = false;
|
|
@@ -176,6 +210,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
176
210
|
// Held, at the SAME seq: a retry the server has already seen is
|
|
177
211
|
// trimmed against its high-water mark rather than doubled.
|
|
178
212
|
dirty = true;
|
|
213
|
+
extrasPending ||= extras;
|
|
179
214
|
return;
|
|
180
215
|
}
|
|
181
216
|
markerPending = false;
|
|
@@ -201,6 +236,30 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
201
236
|
// The first report stamps even a silent or immediately failed CLI invocation.
|
|
202
237
|
schedule();
|
|
203
238
|
const scoped = (id) => id ? `${run}:${scrubNativeText(id, 96, scrub)}` : undefined;
|
|
239
|
+
/** A child's S4.1 detail, each field present only when its source said it. */
|
|
240
|
+
const cleanKey = v => scrubNativeText(v, SUBAGENT_KEY_MAX, scrub);
|
|
241
|
+
const detailOf = (update) => {
|
|
242
|
+
const out = {};
|
|
243
|
+
const childCli = update.cli ?? cli;
|
|
244
|
+
if (SUBAGENT_CLIS.includes(childCli)) out.cli = childCli;
|
|
245
|
+
if (typeof update.key === 'string' && update.key && update.key.length <= SUBAGENT_KEY_MAX) out.key = scrubNativeText(update.key, SUBAGENT_KEY_MAX, scrub);
|
|
246
|
+
if (update.parent === 'lead') out.parent = 'lead';
|
|
247
|
+
else if (typeof update.parent === 'string' && update.parent && update.parent !== update.id) out.parent = scoped(update.parent);
|
|
248
|
+
if (typeof update.model === 'string' && update.model) out.model = scrubNativeText(update.model, 80, scrub);
|
|
249
|
+
if (typeof update.effort === 'string' && EFFORT_RE.test(update.effort)) out.effort = update.effort;
|
|
250
|
+
// A subagent on another CLI stopped by its CLI's plan limit (E7 S7.5), cleared when it runs again.
|
|
251
|
+
if (typeof update.limited === 'boolean') out.limited = update.limited;
|
|
252
|
+
if (typeof update.parentKey === 'string') out.parentKey = cleanKey(update.parentKey);
|
|
253
|
+
if (typeof update.phase === 'string') out.phase = scrubNativeText(update.phase, 200, scrub);
|
|
254
|
+
for (const k of ['tokens', 'toolCalls']) if (Number.isSafeInteger(update[k]) && update[k] >= 0) out[k] = update[k];
|
|
255
|
+
for (const k of ['startedAt', 'endedAt']) if (typeof update[k] === 'string' && Number.isFinite(Date.parse(update[k]))) out[k] = update[k];
|
|
256
|
+
if (typeof update.result === 'string' && update.result.trim()) {
|
|
257
|
+
const full = scrubNativeText(update.result, NATIVE_WINDOW, scrub, NATIVE_WINDOW);
|
|
258
|
+
out.result = full.slice(0, SUBAGENT_RESULT_CAP);
|
|
259
|
+
out.resultStatus = update.resultStatus === 'truncated' || update.result.length > NATIVE_WINDOW || full.length > SUBAGENT_RESULT_CAP ? 'truncated' : 'complete';
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
};
|
|
204
263
|
return {
|
|
205
264
|
agentId: scoped,
|
|
206
265
|
/**
|
|
@@ -283,11 +342,44 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
283
342
|
}
|
|
284
343
|
if (typeof fields.now === 'string') fields.now = fields.now.replace(/\s+/g, ' ').trim();
|
|
285
344
|
if (SUBAGENT_STATUSES.includes(update.status)) fields.status = update.status;
|
|
345
|
+
Object.assign(fields, detailOf(update));
|
|
286
346
|
const previous = agentReadings.get(update.id) ?? {};
|
|
287
|
-
if (agentReadings.has(update.id) &&
|
|
288
|
-
|
|
347
|
+
if (!agentReadings.has(update.id) && fields.kind !== 'workflow' && [...agentReadings.values()].filter(a => a.kind !== 'workflow').length >= SUBAGENT_CAP) {
|
|
348
|
+
// A 33rd child runs; its record does not (D25): the turn says how many.
|
|
349
|
+
if (!unrecorded.has(update.id)) {
|
|
350
|
+
notRecorded += 1;
|
|
351
|
+
if (unrecorded.size < 4_096) unrecorded.add(update.id);
|
|
352
|
+
extrasPending = true; dirty = true; schedule();
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (!agentReadings.has(update.id)) {
|
|
357
|
+
// Where the lead stood when the child was first seen, and when.
|
|
358
|
+
fields.position = leadCount;
|
|
359
|
+
fields.startedAt ??= new Date().toISOString();
|
|
360
|
+
}
|
|
361
|
+
const key = fields.key ?? previous.key, title = fields.title ?? previous.title;
|
|
362
|
+
// The ledger learns a child's key (and the names a Codex follow-up may
|
|
363
|
+
// use for it) and answers the turns it appeared in, this one included.
|
|
364
|
+
if (key && (key !== previous.key || title !== previous.title) && typeof runsOf === 'function') {
|
|
365
|
+
let runs = null;
|
|
366
|
+
try { runs = runsOf(key, { title }); } catch { /* the ledger is a readout */ }
|
|
367
|
+
if (Array.isArray(runs)) fields.runs = runs.filter(r => typeof r === 'string' && RUN_ID_RE.test(r)).slice(-SUBAGENT_RUNS_CAP);
|
|
368
|
+
}
|
|
369
|
+
const status = fields.status ?? previous.status;
|
|
370
|
+
if (SETTLED_SUBAGENT_STATUSES.includes(status)) { if (!previous.endedAt) fields.endedAt ??= new Date().toISOString(); }
|
|
371
|
+
const same = (k, v) => k === 'endedAt' ? Boolean(previous.endedAt) : k === 'runs' ? JSON.stringify(previous.runs) === JSON.stringify(v) : previous[k] === v;
|
|
372
|
+
if (agentReadings.has(update.id) && Object.entries(fields).every(([k, v]) => same(k, v))) return;
|
|
289
373
|
const reading = { ...previous, ...fields };
|
|
374
|
+
// A child running again (a follow-up) has not ended.
|
|
375
|
+
if (!SETTLED_SUBAGENT_STATUSES.includes(status)) delete reading.endedAt;
|
|
290
376
|
agentReadings.set(update.id, reading);
|
|
377
|
+
if (reading.kind === 'workflow') {
|
|
378
|
+
const siblings = [...agentReadings].filter(([, a]) => a.kind === 'workflow' && a.parentKey === reading.parentKey)
|
|
379
|
+
.sort((a, b) => (b[1].startedAt ?? '').localeCompare(a[1].startedAt ?? ''));
|
|
380
|
+
for (const [id] of siblings.slice(WORKFLOW_AGENT_CAP)) agentReadings.delete(id);
|
|
381
|
+
if (!agentReadings.has(update.id)) return;
|
|
382
|
+
}
|
|
291
383
|
// A later update repairs metadata whose first report was shed while
|
|
292
384
|
// offline; a delta alone cannot recover the child's prompt or title.
|
|
293
385
|
push({ k: 'agent', id: scoped(update.id), ...reading });
|
|
@@ -319,6 +411,38 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
319
411
|
}
|
|
320
412
|
if (fresh.length) push({ k: 'note', t: scrub(`The CLI reported MCP servers that are not connected: ${fresh.join(', ')}.`).slice(0, TRACE_PROSE_CAP) });
|
|
321
413
|
},
|
|
414
|
+
/**
|
|
415
|
+
* ONE SHELL COMMAND THE LEAD RAN THAT STARTS ANOTHER CLI (D23): its row
|
|
416
|
+
* by call id, updated at its result. Shown and never blocked, never a
|
|
417
|
+
* subagent and never counted toward the 32. `row` is outsideCli.mjs's.
|
|
418
|
+
*/
|
|
419
|
+
outside(id, row) {
|
|
420
|
+
if (stopped || typeof id !== 'string' || !id || !row) return;
|
|
421
|
+
const previous = outsideRows.get(id);
|
|
422
|
+
if (!previous && outsideRows.size >= OUTSIDE_CAP) return;
|
|
423
|
+
const next = { ...(previous ?? {
|
|
424
|
+
id: scrubNativeText(id, 96, scrub), position: leadCount, startedAt: new Date().toISOString(),
|
|
425
|
+
}) };
|
|
426
|
+
if (typeof row.cli === 'string') next.cli = row.cli;
|
|
427
|
+
if (typeof row.model === 'string' && row.model) next.model = scrubNativeText(row.model, 80, scrub);
|
|
428
|
+
if (typeof row.command === 'string') next.command = scrubNativeText(row.command, OUTSIDE_COMMAND_CAP, scrub);
|
|
429
|
+
if (OUTSIDE_STATUSES.includes(row.status)) next.status = row.status;
|
|
430
|
+
if (next.status && next.status !== 'running' && !next.endedAt) next.endedAt = new Date().toISOString();
|
|
431
|
+
if (previous && JSON.stringify(previous) === JSON.stringify(next)) return;
|
|
432
|
+
outsideRows.set(id, next);
|
|
433
|
+
extrasPending = true; dirty = true; schedule();
|
|
434
|
+
},
|
|
435
|
+
workflow(row) {
|
|
436
|
+
if (stopped || !row) return;
|
|
437
|
+
if (row.launcherId && workflowPositions.size < WORKFLOW_CAP && !workflowPositions.has(row.launcherId)) workflowPositions.set(row.launcherId, leadCount);
|
|
438
|
+
if (!row.id || (!workflowRows.has(row.id) && workflowRows.size >= WORKFLOW_CAP)) return;
|
|
439
|
+
const { launcherId, ...next } = row;
|
|
440
|
+
next.position = workflowRows.get(row.id)?.position ?? workflowPositions.get(launcherId) ?? leadCount;
|
|
441
|
+
for (const k of ['id', 'key', 'name', 'description']) if (typeof next[k] === 'string') next[k] = scrubNativeText(next[k], k === 'description' ? 600 : 200, scrub);
|
|
442
|
+
next.phases = (next.phases ?? []).slice(0, 64).map(p => ({ ...p, title: scrubNativeText(p.title, 200, scrub) }));
|
|
443
|
+
workflowRows.set(row.id, next);
|
|
444
|
+
extrasPending = true; dirty = true; schedule();
|
|
445
|
+
},
|
|
322
446
|
flush,
|
|
323
447
|
/** No more entries, no more timers. The queue survives so a final flush can
|
|
324
448
|
* still deliver it. */
|
|
@@ -326,8 +450,12 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
|
|
|
326
450
|
if (!stopped && orchestrationVersion === 2) {
|
|
327
451
|
// Even a quiet child retains its identity after another child's noisy
|
|
328
452
|
// stream overflowed the relay. These snapshots remain sequence-safe.
|
|
329
|
-
for (const [id, reading] of agentReadings)
|
|
453
|
+
for (const [id, reading] of agentReadings) {
|
|
454
|
+
const steps = childSteps.get(scoped(id));
|
|
455
|
+
push({ k: 'agent', id: scoped(id), ...reading, ...(steps ? { steps } : {}) });
|
|
456
|
+
}
|
|
330
457
|
}
|
|
458
|
+
if (!stopped && (notRecorded > 0 || outsideRows.size || workflowRows.size)) extrasPending = true;
|
|
331
459
|
stopped = true;
|
|
332
460
|
if (timer) {
|
|
333
461
|
clearTimeout(timer);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* inside prose or another language's fence stay as the CLI wrote them. */
|
|
5
5
|
import { jsonObjectClosers } from './modelJson.mjs';
|
|
6
6
|
|
|
7
|
-
const statuses = new Set(['delivered', 'blocked', 'prerequisite', 'nothing']);
|
|
7
|
+
const statuses = new Set(['delivered', 'blocked', 'prerequisite', 'split', 'nothing']);
|
|
8
8
|
function declaration(body) {
|
|
9
9
|
const raw = body.trim();
|
|
10
10
|
if (!raw.startsWith('{')) return false;
|
|
@@ -13,13 +13,13 @@ function declaration(body) {
|
|
|
13
13
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
14
14
|
&& ((Object.hasOwn(value, 'summary') && Object.hasOwn(value, 'progress'))
|
|
15
15
|
|| (typeof value.status === 'string'
|
|
16
|
-
&& (statuses.has(value.status) || ['summary', 'progress', 'question'].some(key => Object.hasOwn(value, key)))));
|
|
16
|
+
&& (statuses.has(value.status) || ['summary', 'progress', 'question', 'split'].some(key => Object.hasOwn(value, key)))));
|
|
17
17
|
} catch {
|
|
18
18
|
// A truncated/invalid declaration must not fall through as an answer.
|
|
19
19
|
// Require a protocol key at the root's opening and the declared status;
|
|
20
20
|
// arbitrary malformed JSON remains useful diagnostic output.
|
|
21
|
-
return /^\{\s*"(?:status|summary|progress|question)"\s*:/.test(raw)
|
|
22
|
-
&& /"status"\s*:\s*"(?:delivered|blocked|prerequisite|nothing)"/.test(raw);
|
|
21
|
+
return /^\{\s*"(?:status|summary|progress|question|split)"\s*:/.test(raw)
|
|
22
|
+
&& /"status"\s*:\s*"(?:delivered|blocked|prerequisite|split|nothing)"/.test(raw);
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
package/bin/lib/turnGaps.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* listed. A malformed entry costs that entry alone.
|
|
17
17
|
*/
|
|
18
18
|
export const GAP_STATUSES = ['not built', 'partly', 'unverified', 'unowned'];
|
|
19
|
+
export const GAP_DISPOSITIONS = ['required', 'owned', 'optional', 'decision'];
|
|
19
20
|
export const MAX_GAPS = 40;
|
|
20
21
|
export const MAX_GAP_ITEM = 300;
|
|
21
22
|
export const MAX_GAP_WHY = 800;
|
|
@@ -32,7 +33,10 @@ export function parseGaps(raw, scrub) {
|
|
|
32
33
|
const status = typeof g.status === 'string' ? g.status.trim().toLowerCase() : '';
|
|
33
34
|
if (!GAP_STATUSES.includes(status)) continue;
|
|
34
35
|
const why = typeof g.why === 'string' && g.why.trim() ? cut(scrub(g.why.trim()), MAX_GAP_WHY) : null;
|
|
35
|
-
|
|
36
|
+
const disposition = GAP_DISPOSITIONS.includes(g.disposition) ? g.disposition : null;
|
|
37
|
+
const ownerTaskId = typeof g.ownerTaskId === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(g.ownerTaskId) ? g.ownerTaskId : null;
|
|
38
|
+
out.push({ item: cut(scrub(g.item.trim()), MAX_GAP_ITEM), status, ...(why ? { why } : {}),
|
|
39
|
+
...(disposition ? { disposition } : {}), ...(ownerTaskId ? { ownerTaskId } : {}) });
|
|
36
40
|
}
|
|
37
41
|
return out;
|
|
38
42
|
}
|
|
@@ -41,7 +45,7 @@ export function parseGaps(raw, scrub) {
|
|
|
41
45
|
export function gapsMarkdown(gaps) {
|
|
42
46
|
if (!Array.isArray(gaps)) return null;
|
|
43
47
|
if (!gaps.length) return '**Gaps against the request:** none reported.';
|
|
44
|
-
return ['**Gaps against the request**', ...gaps.map((g) => `- **${g.status}** — ${oneLine(g.item)}${g.why ? `: ${oneLine(g.why)}` : ''}`)].join('\n');
|
|
48
|
+
return ['**Gaps against the request**', ...gaps.map((g) => `- **${g.status}** — ${oneLine(g.item)}${g.why ? `: ${oneLine(g.why)}` : ''}${g.disposition ? ` (${g.disposition}${g.ownerTaskId ? `: card ${g.ownerTaskId}` : ''})` : ''}`)].join('\n');
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
const oneLine = (s) => s.replace(/\s*\n\s*/g, ' ');
|