@warren-dean/code-kit 0.5.0 → 0.7.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +50 -0
- package/README.md +26 -10
- package/bin/code-kit.mjs +398 -39
- package/hooks/guard-bash.mjs +35 -14
- package/hooks/lane-audit.mjs +44 -1
- package/hooks/lib/adapters/context-graph.mjs +75 -6
- package/hooks/lib/adapters/index.mjs +18 -0
- package/hooks/lib/config.mjs +46 -2
- package/hooks/lib/dependencies.mjs +26 -9
- package/hooks/lib/harness.mjs +131 -0
- package/hooks/lib/next.mjs +34 -3
- package/hooks/lib/plan.mjs +29 -0
- package/hooks/lib/queue.mjs +75 -0
- package/hooks/lib/reviews.mjs +18 -0
- package/hooks/lib/rules.mjs +8 -4
- package/hooks/lib/shell.mjs +1 -1
- package/hooks/lib/timeline.mjs +77 -0
- package/hooks/mod/art.mjs +200 -0
- package/hooks/mod/hold.mjs +25 -0
- package/hooks/mod/loop.mjs +68 -0
- package/hooks/mod/panes.mjs +943 -0
- package/hooks/mod/register.mjs +1102 -14
- package/hooks/mod/register.test.ts +1747 -13
- package/hooks/mod/review.mjs +102 -0
- package/hooks/mod/view.mjs +763 -36
- package/package.json +1 -1
- package/skills/init/SKILL.md +5 -4
- package/skills/review/SKILL.md +8 -1
package/hooks/mod/register.mjs
CHANGED
|
@@ -12,19 +12,50 @@ import {
|
|
|
12
12
|
NOT_CODE_KIT,
|
|
13
13
|
PANE_ID,
|
|
14
14
|
RESULT_ID,
|
|
15
|
+
SETTINGS_ID,
|
|
15
16
|
approvalsText,
|
|
16
17
|
agentsAtWork,
|
|
18
|
+
shownState,
|
|
19
|
+
attribution,
|
|
17
20
|
approvePane,
|
|
18
21
|
band,
|
|
19
22
|
bandLines,
|
|
20
|
-
|
|
23
|
+
finishedStories,
|
|
24
|
+
ago,
|
|
21
25
|
parseJson,
|
|
26
|
+
planOf,
|
|
22
27
|
prefilledReason,
|
|
23
28
|
projectState,
|
|
24
29
|
refusalCard,
|
|
25
30
|
refusalView,
|
|
26
31
|
resultPane,
|
|
32
|
+
settingsView,
|
|
33
|
+
usageSummary,
|
|
34
|
+
withUsage,
|
|
27
35
|
} from './view.mjs';
|
|
36
|
+
import { holdable } from './hold.mjs';
|
|
37
|
+
import { axisStart, barCells, poseOf, rasterCells, sprite, tintOf } from './art.mjs';
|
|
38
|
+
import {
|
|
39
|
+
DEFAULT_UI,
|
|
40
|
+
SENDBACK_ID,
|
|
41
|
+
closeGoesBack,
|
|
42
|
+
laneActions,
|
|
43
|
+
lanesFrame,
|
|
44
|
+
liveLevel,
|
|
45
|
+
sendBackPane,
|
|
46
|
+
toolLine,
|
|
47
|
+
} from './panes.mjs';
|
|
48
|
+
import { leadPrompt, nudgeText, restartPrompt, stallDue, stepKey, stepLabel } from './loop.mjs';
|
|
49
|
+
import { harnessSettings } from '../lib/harness.mjs';
|
|
50
|
+
import {
|
|
51
|
+
REVIEW_WAIT_MS,
|
|
52
|
+
findingsCount,
|
|
53
|
+
leadReviewPrompt,
|
|
54
|
+
readReport,
|
|
55
|
+
reviewerSpec,
|
|
56
|
+
startReviewerPrompt,
|
|
57
|
+
withVerify,
|
|
58
|
+
} from './review.mjs';
|
|
28
59
|
|
|
29
60
|
// What the session knows of the project, read again when it changes.
|
|
30
61
|
let model = { state: null, requests: null, stops: [] };
|
|
@@ -34,6 +65,55 @@ let approving = null; // the open Approve… confirmation: { request, reason, er
|
|
|
34
65
|
let result = null; // what the last merge reported
|
|
35
66
|
let notice = null; // why the person's last act failed
|
|
36
67
|
let act = null; // the session's actions, made at session start
|
|
68
|
+
let settingRows = []; // the harness settings, from `code-kit settings --json`
|
|
69
|
+
let pendingSetting = null; // a change waiting for the person's reason: { key, value, reason, error }
|
|
70
|
+
let ledger = {}; // the session's tokens by agent and story (USE-1)
|
|
71
|
+
let rateLimits = []; // the session's limits, as session.measure last gave them
|
|
72
|
+
const agentsSeen = new Map(); // agent id → its $.agent.list() entry, kept once it leaves the list
|
|
73
|
+
const held = new Map(); // tool_use_id → a call held for the person (HOLD-2)
|
|
74
|
+
let where = null; // { cli, cwd, interactive }, from session start
|
|
75
|
+
// The lead loop (spec 05): what it did, and what it waits on.
|
|
76
|
+
const loop = {
|
|
77
|
+
paused: false,
|
|
78
|
+
started: false, // it has taken a step this session: from then on it also acts between turns
|
|
79
|
+
steps: [], // { at, kind, target, prompt? }, kept for the session
|
|
80
|
+
lastKey: null, // the last step submitted or proposed, and the project as it stood then
|
|
81
|
+
lastPrint: null,
|
|
82
|
+
asked: { key: null, times: 0 }, // the step the lead was last prompted with, and how often in a row
|
|
83
|
+
proposal: null, // under autonomy propose, or a step asked too often: { prompt, label, kind, target }
|
|
84
|
+
pending: [], // restarts waiting for the lead to be idle: { story, prompt }
|
|
85
|
+
done: null, // { count } once every story is done
|
|
86
|
+
doneDismissed: false,
|
|
87
|
+
};
|
|
88
|
+
/** LOOP-8: the times in a row the lead is prompted with one step before the person is asked. */
|
|
89
|
+
const ASK_LIMIT = 2;
|
|
90
|
+
let leadTurn = null; // the lead's running turn, if any
|
|
91
|
+
const activity = new Map(); // agent id → { at, inFlight, nudged, stopped, lane, story }
|
|
92
|
+
const restarts = new Map(); // story → times the loop restarted it
|
|
93
|
+
const flagged = new Map(); // story → { lane, story, count }: stalled past its restarts
|
|
94
|
+
/** An agent's activity record, made on first sight. */
|
|
95
|
+
const activityOf = (agentId, now) => {
|
|
96
|
+
let a = activity.get(agentId);
|
|
97
|
+
if (!a) activity.set(agentId, (a = { at: now, inFlight: 0, nudged: false, stopped: false }));
|
|
98
|
+
return a;
|
|
99
|
+
};
|
|
100
|
+
const harness = () => harnessSettings(model.check?.harness);
|
|
101
|
+
// Panes v2 (spec 11): the Lanes pane's own state, the lead's last tool call, and a send-back waiting
|
|
102
|
+
// for the person's reason.
|
|
103
|
+
let paneUi = { ...DEFAULT_UI };
|
|
104
|
+
let leadLast = null; // { tool, at }
|
|
105
|
+
let sendingBack = null; // { lane, branch, story, reason, error }
|
|
106
|
+
let storyOpen = null; // the drill-down's story (VIEW-7): { id, lane, data, error }
|
|
107
|
+
const personAsks = []; // prompts the person asked for (stop an agent), waiting for the lead to be idle
|
|
108
|
+
// VIEW-3: the characters' animation, and the surface the Lanes pane was last drawn on.
|
|
109
|
+
let frame = 0;
|
|
110
|
+
let animation = null; // the timer repainting working characters, while one works
|
|
111
|
+
let paneSurface = null;
|
|
112
|
+
let paneColumns = 80;
|
|
113
|
+
// The reviewer (spec 07): each branch's review at its head, and the agent type registered for it.
|
|
114
|
+
const reviews = new Map(); // branch → { branch, head, story, state, startedAt, findings, why?, error? }
|
|
115
|
+
let reviewerAgent = null; // the registered agent's full name
|
|
116
|
+
let reviewerModel = null; // the model it was registered with ('' for the session's)
|
|
37
117
|
const refusals = new Map(); // tool_use_id → the refusal text a hook gave (CARD-1)
|
|
38
118
|
const expanded = new Set(); // cards showing their raw text
|
|
39
119
|
const REFUSALS_KEPT = 200;
|
|
@@ -63,9 +143,21 @@ export function register(on) {
|
|
|
63
143
|
description: 'Run code-kit verify on this branch, checks included',
|
|
64
144
|
})
|
|
65
145
|
.catch(unavailable('verify-branch'));
|
|
146
|
+
await $.command
|
|
147
|
+
.register({
|
|
148
|
+
name: 'harness',
|
|
149
|
+
description: "code-kit's harness settings: autonomy, agents, hold time",
|
|
150
|
+
immediate: true,
|
|
151
|
+
})
|
|
152
|
+
.catch(unavailable('harness'));
|
|
153
|
+
rateLimits = (await $.session.usage().catch(() => null))?.rateLimits ?? [];
|
|
66
154
|
const cli = `${$.plugin.root}/bin/code-kit.mjs`;
|
|
67
155
|
const cwd = e.cwd ?? (await $.session.cwd());
|
|
68
156
|
const session = await $.session.id();
|
|
157
|
+
where = { cli, cwd, interactive: e.isInteractive !== false };
|
|
158
|
+
// The held call's answer, for the `code-kit hold` it waits in.
|
|
159
|
+
const answerHeld = (id, answer) =>
|
|
160
|
+
$.process.run(['node', cli, 'hold', id, '--answer', answer], { cwd });
|
|
69
161
|
const json = async (...args) => {
|
|
70
162
|
const ran = await $.process.run(['node', cli, ...args, '--json'], { cwd });
|
|
71
163
|
return ran.exitCode === 0 ? parseJson(ran.stdout) : null;
|
|
@@ -96,14 +188,149 @@ export function register(on) {
|
|
|
96
188
|
const nextStep = valid ? await json('next') : null;
|
|
97
189
|
const requests = valid ? await json('requests') : null;
|
|
98
190
|
const stops = valid ? ((await json('stops', '--session', session)) ?? []) : [];
|
|
99
|
-
const
|
|
191
|
+
const queue = valid ? ((await json('queue')) ?? []) : [];
|
|
192
|
+
// VIEW-2: when each story's work happened, for the lanes' timelines.
|
|
193
|
+
const timeline = valid && check.docs?.plan ? await json('timeline') : null;
|
|
194
|
+
// JOIN-1: what the active adapters know of the lanes' work, where any does.
|
|
195
|
+
const adapters =
|
|
196
|
+
valid && check.adapters?.length
|
|
197
|
+
? ((await json('adapters', '--session', session)) ?? [])
|
|
198
|
+
: [];
|
|
199
|
+
// The agents at work; none where the session has none bound (claude -p before it mounts).
|
|
200
|
+
const atWork = agentsAtWork(await $.agent.list().catch(() => []));
|
|
100
201
|
model = {
|
|
101
202
|
state: projectState(check, status, nextStep, atWork),
|
|
102
203
|
requests,
|
|
103
204
|
stops,
|
|
205
|
+
queue,
|
|
206
|
+
status,
|
|
207
|
+
timeline,
|
|
208
|
+
adapters,
|
|
104
209
|
check,
|
|
105
210
|
base: status?.base ?? null,
|
|
106
211
|
};
|
|
212
|
+
if (model.state.kind === 'ok') await act.registerReviewer();
|
|
213
|
+
$.ui.invalidate('ui.render');
|
|
214
|
+
},
|
|
215
|
+
// REVW-2: the reviewer, read-only by the hooks' rule for agents outside the lanes. Registered
|
|
216
|
+
// again when its model setting changes.
|
|
217
|
+
registerReviewer: async () => {
|
|
218
|
+
const wanted = harness().agents.reviewer.model ?? '';
|
|
219
|
+
if (reviewerModel === wanted) return;
|
|
220
|
+
const spec = reviewerSpec({
|
|
221
|
+
cli,
|
|
222
|
+
checklist: `${$.plugin.root}/skills/review/SKILL.md`,
|
|
223
|
+
model: wanted || undefined,
|
|
224
|
+
});
|
|
225
|
+
// Until the session is bound (claude -p, early on) registering fails: it's tried again next time.
|
|
226
|
+
const done = await $.agent.register(spec).catch((err) => {
|
|
227
|
+
$.ui.log(`code-kit: the reviewer agent isn't available yet: ${err?.message ?? err}`, {
|
|
228
|
+
to: 'debug',
|
|
229
|
+
});
|
|
230
|
+
return null;
|
|
231
|
+
});
|
|
232
|
+
if (!done) return;
|
|
233
|
+
reviewerModel = wanted;
|
|
234
|
+
reviewerAgent = done.agent ?? null;
|
|
235
|
+
},
|
|
236
|
+
// REVW-1, REVW-4, LOOP-4: for stories in review, start the reviewer at each branch's head, and
|
|
237
|
+
// once it has reported (or failed, or was skipped) prompt the lead's review with its findings.
|
|
238
|
+
// Null while every review is still running.
|
|
239
|
+
reviewStep: async (step) => {
|
|
240
|
+
const now = await $.clock.now();
|
|
241
|
+
const plan = planOf(rateLimits, harness().background.pauseAtPercent);
|
|
242
|
+
for (const id of (step.args ?? '').split(/\s+/).filter(Boolean)) {
|
|
243
|
+
const story = (model.state.stories ?? []).find((s) => s.id === id);
|
|
244
|
+
if (!story?.branch) continue;
|
|
245
|
+
const head = (
|
|
246
|
+
await $.process.run(['git', 'rev-parse', '--verify', '--quiet', story.branch], { cwd })
|
|
247
|
+
).stdout.trim();
|
|
248
|
+
if (!head) continue;
|
|
249
|
+
let review = reviews.get(story.branch);
|
|
250
|
+
if (!review || review.head !== head) {
|
|
251
|
+
// USE-4: past the plan's pause point, no reviewer starts; the lead reviews alone.
|
|
252
|
+
review = {
|
|
253
|
+
branch: story.branch,
|
|
254
|
+
head,
|
|
255
|
+
story: id,
|
|
256
|
+
startedAt: now,
|
|
257
|
+
findings: [],
|
|
258
|
+
...(plan.paused
|
|
259
|
+
? {
|
|
260
|
+
state: 'skipped',
|
|
261
|
+
why: `background agents paused, the plan at ${plan.percent}%`,
|
|
262
|
+
}
|
|
263
|
+
: { state: 'running' }),
|
|
264
|
+
};
|
|
265
|
+
reviews.set(story.branch, review);
|
|
266
|
+
if (!plan.paused)
|
|
267
|
+
return {
|
|
268
|
+
prompt: startReviewerPrompt({
|
|
269
|
+
agent: reviewerAgent,
|
|
270
|
+
story,
|
|
271
|
+
branch: story.branch,
|
|
272
|
+
head,
|
|
273
|
+
}),
|
|
274
|
+
kind: 'reviewer',
|
|
275
|
+
target: story.branch,
|
|
276
|
+
key: `reviewer:${story.branch}@${head}`,
|
|
277
|
+
label: `Start the reviewer on ${story.branch}`,
|
|
278
|
+
};
|
|
279
|
+
await act.record('skipped', `review of ${story.branch}`);
|
|
280
|
+
}
|
|
281
|
+
if (review.state === 'running') {
|
|
282
|
+
if (now - review.startedAt < REVIEW_WAIT_MS) continue;
|
|
283
|
+
review.state = 'failed';
|
|
284
|
+
review.error = 'no report within 15 minutes';
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
prompt: leadReviewPrompt(id, review),
|
|
288
|
+
kind: 'review',
|
|
289
|
+
target: id,
|
|
290
|
+
key: `review:${story.branch}@${head}`,
|
|
291
|
+
label: `Review ${id}`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
},
|
|
296
|
+
// REVW-3: the reviewer's report, read from the turn it ended with; a failed verify is a blocker.
|
|
297
|
+
reviewed: async (agentId, answer) => {
|
|
298
|
+
let review = [...reviews.values()].find((r) => r.agentId === agentId);
|
|
299
|
+
if (!review) {
|
|
300
|
+
const agent = ((await $.agent.list().catch(() => [])) ?? []).find(
|
|
301
|
+
(a) => a.id === agentId,
|
|
302
|
+
);
|
|
303
|
+
if (!agent || !reviewerAgent || agent.type !== reviewerAgent) return;
|
|
304
|
+
review = [...reviews.values()].find(
|
|
305
|
+
(r) => r.state === 'running' && agent.description?.includes(r.branch),
|
|
306
|
+
);
|
|
307
|
+
if (!review) return;
|
|
308
|
+
review.agentId = agentId;
|
|
309
|
+
}
|
|
310
|
+
const report = readReport(answer);
|
|
311
|
+
if (report.error) {
|
|
312
|
+
review.state = 'failed';
|
|
313
|
+
review.error = report.error;
|
|
314
|
+
} else {
|
|
315
|
+
review.state = 'done';
|
|
316
|
+
review.findings = report.findings;
|
|
317
|
+
// With the branch checked out in a worktree, verify there too: its problems are blockers.
|
|
318
|
+
const story = (model.state?.stories ?? []).find((s) => s.id === review.story);
|
|
319
|
+
if (story?.worktree) {
|
|
320
|
+
const ran = await $.process.run(['node', cli, 'verify', '--no-checks', '--json'], {
|
|
321
|
+
cwd: story.worktree,
|
|
322
|
+
timeoutMs: 120000,
|
|
323
|
+
});
|
|
324
|
+
if (ran.exitCode !== 0) {
|
|
325
|
+
const problems = Object.values(parseJson(ran.stdout)?.found ?? {}).flat();
|
|
326
|
+
review.findings = withVerify(review.findings, { passed: false, problems });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
await act.record(
|
|
331
|
+
'reviewed',
|
|
332
|
+
`${review.branch}: ${review.state === 'done' ? findingsCount(review.findings) : review.error}`,
|
|
333
|
+
);
|
|
107
334
|
$.ui.invalidate('ui.render');
|
|
108
335
|
},
|
|
109
336
|
// What a refresh waits on, read cheaply: branches and HEAD, the config and the plan, requests,
|
|
@@ -114,14 +341,18 @@ export function register(on) {
|
|
|
114
341
|
{ cwd },
|
|
115
342
|
);
|
|
116
343
|
const plan = model.check?.docs?.plan;
|
|
344
|
+
const specs = model.check?.docs?.specs;
|
|
117
345
|
return [
|
|
118
346
|
refs.stdout,
|
|
119
347
|
await stamp('.claude/code-kit.json'),
|
|
120
348
|
plan ? await stamp(plan) : '',
|
|
349
|
+
// TRACE-3: the map follows the specs too.
|
|
350
|
+
specs ? await listing(specs) : '',
|
|
121
351
|
await stamp('.claude/state/requests.jsonl'),
|
|
352
|
+
await stamp('.claude/state/merge-queue.json'),
|
|
122
353
|
await listing('.claude/approvals'),
|
|
123
354
|
await listing('.claude/state/stop-blocks'),
|
|
124
|
-
[...agentsAtWork(await $.agent.list())].sort().join(','),
|
|
355
|
+
[...agentsAtWork(await $.agent.list().catch(() => []))].sort().join(','),
|
|
125
356
|
].join('\n');
|
|
126
357
|
},
|
|
127
358
|
// PANE-1: open the Lanes pane, or close it when it's open.
|
|
@@ -145,7 +376,12 @@ export function register(on) {
|
|
|
145
376
|
},
|
|
146
377
|
// ACT-1: the confirmation, prefilled from the request.
|
|
147
378
|
approve: async (line) => {
|
|
148
|
-
approving = {
|
|
379
|
+
approving = {
|
|
380
|
+
request: line.request,
|
|
381
|
+
reason: prefilledReason(line.request),
|
|
382
|
+
error: null,
|
|
383
|
+
heldId: line.heldId ?? null,
|
|
384
|
+
};
|
|
149
385
|
await $.ui.open({ id: APPROVE_ID, title: 'Approve', focus: true, closeOnEscape: true });
|
|
150
386
|
$.ui.invalidate('ui.render');
|
|
151
387
|
},
|
|
@@ -175,14 +411,26 @@ export function register(on) {
|
|
|
175
411
|
const why = (ran.stderr || ran.stdout).trim().split('\n')[0];
|
|
176
412
|
notice = `Nothing was approved: ${why}`;
|
|
177
413
|
approving = { ...approving, error: notice };
|
|
414
|
+
// HOLD-3: a held call whose approval couldn't be written is refused as the hooks refused it.
|
|
415
|
+
if (approving.heldId) await answerHeld(approving.heldId, 'refused');
|
|
178
416
|
$.ui.invalidate('ui.render');
|
|
179
417
|
return;
|
|
180
418
|
}
|
|
419
|
+
if (approving.heldId) await answerHeld(approving.heldId, 'approved');
|
|
181
420
|
approving = null;
|
|
182
421
|
notice = null;
|
|
183
422
|
await $.ui.close({ id: APPROVE_ID });
|
|
184
423
|
await act.reload();
|
|
185
424
|
},
|
|
425
|
+
// HOLD-2: Approve opens ACT-1's confirmation for the held call; Refuse lets the refusal through.
|
|
426
|
+
approveHeld: (line) =>
|
|
427
|
+
act.approve({
|
|
428
|
+
request: { names: line.held.names, lane: line.held.lane, what: line.held.what },
|
|
429
|
+
heldId: line.held.id,
|
|
430
|
+
}),
|
|
431
|
+
refuseHeld: async (line) => {
|
|
432
|
+
await answerHeld(line.held.id, 'refused');
|
|
433
|
+
},
|
|
186
434
|
cancelApproval: async () => {
|
|
187
435
|
approving = null;
|
|
188
436
|
await $.ui.close({ id: APPROVE_ID });
|
|
@@ -220,6 +468,27 @@ export function register(on) {
|
|
|
220
468
|
await act.reload();
|
|
221
469
|
},
|
|
222
470
|
// CARD-2: no model call; the transcript shows it.
|
|
471
|
+
// MQ-2: the head of the queue, merged by the person: verified first, sent back if it fails.
|
|
472
|
+
mergeQueue: async (line) => {
|
|
473
|
+
const answer = await $.ui
|
|
474
|
+
.ask(
|
|
475
|
+
`Merge ${line.branch}, next in the merge queue, into ${model.base ?? 'the base branch'}? code-kit verify runs first; if it conflicts or fails, it goes back to its lane.`,
|
|
476
|
+
['Merge', 'Cancel'],
|
|
477
|
+
)
|
|
478
|
+
.catch(() => 'Cancel');
|
|
479
|
+
if (answer !== 'Merge') return;
|
|
480
|
+
const ran = await $.process.run(['node', cli, 'queue', 'merge', '--person'], {
|
|
481
|
+
cwd,
|
|
482
|
+
timeoutMs: 600000,
|
|
483
|
+
});
|
|
484
|
+
result = {
|
|
485
|
+
ok: ran.exitCode === 0,
|
|
486
|
+
title: ran.exitCode === 0 ? `Merged ${line.branch}` : `Not merged: ${line.branch}`,
|
|
487
|
+
text: `${ran.stdout}${ran.stderr}`.trim(),
|
|
488
|
+
};
|
|
489
|
+
await $.ui.open({ id: RESULT_ID, title: 'Merge', focus: true, closeOnEscape: true });
|
|
490
|
+
await act.reload();
|
|
491
|
+
},
|
|
223
492
|
approvals: async () => {
|
|
224
493
|
await act.reload();
|
|
225
494
|
if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
|
|
@@ -231,6 +500,433 @@ export function register(on) {
|
|
|
231
500
|
const ran = await $.process.run(['node', cli, 'verify'], { cwd, timeoutMs: 600000 });
|
|
232
501
|
return { text: `${ran.stdout}${ran.stderr}`.trim() };
|
|
233
502
|
},
|
|
503
|
+
// SET-2: the Settings view; a change is the person's, confirmed with a reason.
|
|
504
|
+
harness: async () => {
|
|
505
|
+
if ((await $.ui.panes()).some((p) => p.id === SETTINGS_ID)) {
|
|
506
|
+
await $.ui.close({ id: SETTINGS_ID });
|
|
507
|
+
return {};
|
|
508
|
+
}
|
|
509
|
+
await act.reload();
|
|
510
|
+
if (model.state.kind === 'none') return { text: NOT_CODE_KIT };
|
|
511
|
+
settingRows = (await json('settings')) ?? [];
|
|
512
|
+
await $.ui.open({ id: SETTINGS_ID, title: 'Harness', focus: true, closeOnEscape: true });
|
|
513
|
+
$.ui.invalidate('ui.render');
|
|
514
|
+
return {};
|
|
515
|
+
},
|
|
516
|
+
chooseSetting: (key, value) => {
|
|
517
|
+
const row = settingRows.find((x) => x.key === key);
|
|
518
|
+
if (row && String(row.value ?? 'null') === value) return;
|
|
519
|
+
pendingSetting = { key, value, reason: '', error: null };
|
|
520
|
+
$.ui.invalidate('ui.render');
|
|
521
|
+
},
|
|
522
|
+
confirmSetting: async (reason) => {
|
|
523
|
+
if (!pendingSetting) return;
|
|
524
|
+
if (!reason?.trim()) {
|
|
525
|
+
pendingSetting = {
|
|
526
|
+
...pendingSetting,
|
|
527
|
+
error: 'Give a reason: the approval log keeps it.',
|
|
528
|
+
};
|
|
529
|
+
$.ui.invalidate('ui.render');
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const ran = await $.process.run(
|
|
533
|
+
[
|
|
534
|
+
'node',
|
|
535
|
+
cli,
|
|
536
|
+
'settings',
|
|
537
|
+
'set',
|
|
538
|
+
pendingSetting.key,
|
|
539
|
+
pendingSetting.value,
|
|
540
|
+
'--reason',
|
|
541
|
+
reason.trim(),
|
|
542
|
+
'--via',
|
|
543
|
+
'pane',
|
|
544
|
+
],
|
|
545
|
+
{ cwd },
|
|
546
|
+
);
|
|
547
|
+
if (ran.exitCode !== 0) {
|
|
548
|
+
pendingSetting = {
|
|
549
|
+
...pendingSetting,
|
|
550
|
+
error: (ran.stderr || ran.stdout).trim().split('\n')[0],
|
|
551
|
+
};
|
|
552
|
+
$.ui.invalidate('ui.render');
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
pendingSetting = null;
|
|
556
|
+
settingRows = (await json('settings')) ?? settingRows;
|
|
557
|
+
await act.reload();
|
|
558
|
+
},
|
|
559
|
+
cancelSetting: () => {
|
|
560
|
+
pendingSetting = null;
|
|
561
|
+
$.ui.invalidate('ui.render');
|
|
562
|
+
},
|
|
563
|
+
// USE-1: a model request's tokens, put down to the agent that made it and its story and lane.
|
|
564
|
+
measure: async (agentId, usage) => {
|
|
565
|
+
if (agentId && !agentsSeen.has(agentId))
|
|
566
|
+
for (const a of (await $.agent.list().catch(() => [])) ?? []) agentsSeen.set(a.id, a);
|
|
567
|
+
const who = attribution(agentId, agentsSeen.get(agentId) ?? null, model.check, model.state);
|
|
568
|
+
ledger = withUsage(ledger, who, usage);
|
|
569
|
+
$.ui.invalidate('ui.render');
|
|
570
|
+
},
|
|
571
|
+
// --- the lead loop (spec 05) ---------------------------------------------------------------
|
|
572
|
+
record: async (kind, target, prompt) => {
|
|
573
|
+
loop.steps.push({ at: await $.clock.now(), kind, target, ...(prompt ? { prompt } : {}) });
|
|
574
|
+
if (loop.steps.length > 50) loop.steps.shift();
|
|
575
|
+
},
|
|
576
|
+
// LOOP-1: a prompt to the lead, recorded. It isn't awaited: the turn it starts runs on.
|
|
577
|
+
submit: async (prompt, kind, target) => {
|
|
578
|
+
loop.started = true;
|
|
579
|
+
loop.proposal = null;
|
|
580
|
+
await act.record(kind, target, prompt);
|
|
581
|
+
$.ui.invalidate('ui.render');
|
|
582
|
+
$.prompt.submit({ text: prompt }).catch((err) => {
|
|
583
|
+
notice = `The loop couldn't prompt the lead: ${err?.message ?? err}`;
|
|
584
|
+
$.ui.invalidate('ui.render');
|
|
585
|
+
});
|
|
586
|
+
},
|
|
587
|
+
// LOOP-1, LOOP-5, LOOP-7: with the lead idle and no draft, the step `next` reports.
|
|
588
|
+
step: async (trigger) => {
|
|
589
|
+
const h = harness();
|
|
590
|
+
if (loop.paused || h.autonomy === 'off' || model.state?.kind !== 'ok' || loop.done) return;
|
|
591
|
+
if (leadTurn) return;
|
|
592
|
+
if (trigger === 'tick' && !loop.started && !loop.pending.length) return;
|
|
593
|
+
const box = await $.prompt.read().catch(() => null);
|
|
594
|
+
if (box?.text?.trim()) return;
|
|
595
|
+
if (loop.pending.length) {
|
|
596
|
+
const { story, prompt } = loop.pending.shift();
|
|
597
|
+
return act.submit(prompt, 'restart', story);
|
|
598
|
+
}
|
|
599
|
+
const ran = await $.process.run(['node', cli, 'next', '--json'], { cwd });
|
|
600
|
+
const step = ran.exitCode === 0 ? parseJson(ran.stdout) : null;
|
|
601
|
+
if (!step) {
|
|
602
|
+
notice = `The loop is waiting: code-kit next failed (${(ran.stderr || ran.stdout).trim().split('\n')[0]})`;
|
|
603
|
+
$.ui.invalidate('ui.render');
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (step.step === 'done') {
|
|
607
|
+
const count = (model.state.stories ?? []).filter((s) => s.state === 'done').length;
|
|
608
|
+
loop.done = { count };
|
|
609
|
+
await act.record('done', `${count} stories`);
|
|
610
|
+
$.ui.invalidate('ui.render');
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
let prompt = leadPrompt(step, { cli });
|
|
614
|
+
let kind = step.step;
|
|
615
|
+
let target = step.args ?? '';
|
|
616
|
+
let key = stepKey(step);
|
|
617
|
+
let label = stepLabel(step);
|
|
618
|
+
// LOOP-4: with the reviewer on, a branch is reviewed in the background before the lead's review.
|
|
619
|
+
if (step.step === 'review' && harness().agents.reviewer.on && reviewerAgent) {
|
|
620
|
+
const due = await act.reviewStep(step);
|
|
621
|
+
if (!due) return;
|
|
622
|
+
({ prompt, kind, target, key, label } = due);
|
|
623
|
+
}
|
|
624
|
+
// A different step, or none for the lead, starts the count of times in a row again.
|
|
625
|
+
if (key !== loop.asked.key || !prompt) {
|
|
626
|
+
loop.asked = { key, times: 0 };
|
|
627
|
+
if (loop.proposal?.repeated) loop.proposal = null;
|
|
628
|
+
}
|
|
629
|
+
if (!prompt) return;
|
|
630
|
+
// Never the same step twice while the project stands still.
|
|
631
|
+
const print = await act.fingerprint();
|
|
632
|
+
if (key === loop.lastKey && print === loop.lastPrint) return;
|
|
633
|
+
loop.lastKey = key;
|
|
634
|
+
loop.lastPrint = print;
|
|
635
|
+
// LOOP-8: a step the lead was asked for twice and that is still next didn't take: the project
|
|
636
|
+
// moving elsewhere (lanes committing) doesn't make a third prompt useful. The person decides.
|
|
637
|
+
if (loop.asked.times >= ASK_LIMIT) {
|
|
638
|
+
if (loop.proposal?.label !== label) {
|
|
639
|
+
loop.proposal = { prompt, label, kind, target, repeated: loop.asked.times };
|
|
640
|
+
$.ui.invalidate('ui.render');
|
|
641
|
+
}
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
// Until the person has chosen how hands-off the loop is, its first step is offered, not taken.
|
|
645
|
+
const chosen = (model.check?.harnessSet ?? []).includes('autonomy');
|
|
646
|
+
if (h.autonomy === 'propose' || !chosen) {
|
|
647
|
+
loop.proposal = { prompt, label, kind, target, first: !chosen };
|
|
648
|
+
$.ui.invalidate('ui.render');
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
loop.asked.times += 1;
|
|
652
|
+
await act.submit(prompt, kind, target);
|
|
653
|
+
},
|
|
654
|
+
// The first offer's answers: the person's choice of autonomy, recorded as theirs, then acted on.
|
|
655
|
+
chooseAutonomy: async (value) => {
|
|
656
|
+
const ran = await $.process.run(
|
|
657
|
+
[
|
|
658
|
+
'node',
|
|
659
|
+
cli,
|
|
660
|
+
'settings',
|
|
661
|
+
'set',
|
|
662
|
+
'autonomy',
|
|
663
|
+
value,
|
|
664
|
+
'--reason',
|
|
665
|
+
'Chosen when code-kit first offered to keep the build moving between prompts',
|
|
666
|
+
'--via',
|
|
667
|
+
'pane',
|
|
668
|
+
],
|
|
669
|
+
{ cwd },
|
|
670
|
+
);
|
|
671
|
+
if (ran.exitCode !== 0) {
|
|
672
|
+
notice = `Nothing was changed: ${(ran.stderr || ran.stdout).trim().split('\n')[0]}`;
|
|
673
|
+
$.ui.invalidate('ui.render');
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
await act.reload();
|
|
677
|
+
return true;
|
|
678
|
+
},
|
|
679
|
+
keepGoing: async () => {
|
|
680
|
+
if (!(await act.chooseAutonomy('autonomous'))) return;
|
|
681
|
+
await act.go();
|
|
682
|
+
},
|
|
683
|
+
askEach: async () => {
|
|
684
|
+
if (!(await act.chooseAutonomy('propose'))) return;
|
|
685
|
+
if (loop.proposal) loop.proposal = { ...loop.proposal, first: false };
|
|
686
|
+
$.ui.invalidate('ui.render');
|
|
687
|
+
},
|
|
688
|
+
loopOff: async () => {
|
|
689
|
+
if (!(await act.chooseAutonomy('off'))) return;
|
|
690
|
+
loop.proposal = null;
|
|
691
|
+
$.ui.invalidate('ui.render');
|
|
692
|
+
},
|
|
693
|
+
go: async () => {
|
|
694
|
+
if (!loop.proposal || leadTurn) return;
|
|
695
|
+
const { prompt, kind, target, repeated } = loop.proposal;
|
|
696
|
+
// The person asked again: once more, then it's theirs to offer again if it still doesn't take.
|
|
697
|
+
loop.asked.times = repeated ? ASK_LIMIT : loop.asked.times + 1;
|
|
698
|
+
await act.submit(prompt, kind, target);
|
|
699
|
+
},
|
|
700
|
+
// LOOP-6: pausing stops new steps at once; a turn in progress finishes.
|
|
701
|
+
pause: async () => {
|
|
702
|
+
loop.paused = true;
|
|
703
|
+
loop.proposal = null;
|
|
704
|
+
await act.record('pause', 'the loop');
|
|
705
|
+
$.ui.invalidate('ui.render');
|
|
706
|
+
},
|
|
707
|
+
resume: async () => {
|
|
708
|
+
loop.paused = false;
|
|
709
|
+
loop.lastKey = null;
|
|
710
|
+
await act.record('resume', 'the loop');
|
|
711
|
+
$.ui.invalidate('ui.render');
|
|
712
|
+
await act.step('resume');
|
|
713
|
+
},
|
|
714
|
+
dismissDone: () => {
|
|
715
|
+
loop.doneDismissed = true;
|
|
716
|
+
$.ui.invalidate('ui.render');
|
|
717
|
+
},
|
|
718
|
+
// LOOP-3: a lane stalled past its restarts; Resume lets the loop restart it again.
|
|
719
|
+
resumeStall: async (line) => {
|
|
720
|
+
const { story } = line.stall;
|
|
721
|
+
flagged.delete(story);
|
|
722
|
+
restarts.set(story, 0);
|
|
723
|
+
for (const a of activity.values()) if (a.story === story) a.stopped = false;
|
|
724
|
+
await act.record('resume', story);
|
|
725
|
+
$.ui.invalidate('ui.render');
|
|
726
|
+
},
|
|
727
|
+
// LOOP-3: each lane agent at work, by the time since its last tool call.
|
|
728
|
+
stalls: async () => {
|
|
729
|
+
const h = harness();
|
|
730
|
+
if (loop.paused || h.autonomy === 'off' || model.state?.kind !== 'ok') return;
|
|
731
|
+
const now = await $.clock.now();
|
|
732
|
+
const lanes = Object.entries(model.check?.lanes ?? {});
|
|
733
|
+
const waitingOnPerson = new Set([...held.values()].map((c) => c.agentId));
|
|
734
|
+
for (const agent of (await $.agent.list().catch(() => [])) ?? []) {
|
|
735
|
+
if (agent.status !== 'running') continue;
|
|
736
|
+
const lane = lanes.find(([, l]) => l.agent === agent.type)?.[0];
|
|
737
|
+
if (!lane) continue;
|
|
738
|
+
agentsSeen.set(agent.id, agent);
|
|
739
|
+
const a = activityOf(agent.id, now);
|
|
740
|
+
a.lane = lane;
|
|
741
|
+
a.story = attribution(agent.id, agent, model.check, model.state).story ?? lane;
|
|
742
|
+
// A held call, or a long command still running, isn't quiet.
|
|
743
|
+
if (a.inFlight > 0 || waitingOnPerson.has(agent.id)) {
|
|
744
|
+
a.at = now;
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const due = stallDue(now - a.at, a, restarts.get(a.story) ?? 0, h.stall);
|
|
748
|
+
if (due === 'nudge') {
|
|
749
|
+
a.nudged = true;
|
|
750
|
+
await act.record('nudge', `${lane} · ${a.story}`);
|
|
751
|
+
// An undelivered message still counts as the nudge; the restart follows.
|
|
752
|
+
await $.session
|
|
753
|
+
.send({ to: { agentId: agent.id }, text: nudgeText(h.stall.nudgeMinutes) })
|
|
754
|
+
.catch(() => {});
|
|
755
|
+
} else if (due === 'restart') {
|
|
756
|
+
a.stopped = true;
|
|
757
|
+
const n = (restarts.get(a.story) ?? 0) + 1;
|
|
758
|
+
restarts.set(a.story, n);
|
|
759
|
+
loop.pending.push({
|
|
760
|
+
story: a.story,
|
|
761
|
+
prompt: restartPrompt({
|
|
762
|
+
type: agent.type,
|
|
763
|
+
id: agent.id,
|
|
764
|
+
story: a.story,
|
|
765
|
+
minutes: h.stall.restartMinutes,
|
|
766
|
+
}),
|
|
767
|
+
});
|
|
768
|
+
await act.record('restart', `${lane} · ${a.story} (${n}/${h.stall.maxRestarts})`);
|
|
769
|
+
} else if (due === 'flag') {
|
|
770
|
+
a.stopped = true;
|
|
771
|
+
flagged.set(a.story, { lane, story: a.story, count: (restarts.get(a.story) ?? 0) + 1 });
|
|
772
|
+
await act.record('flag', `${lane} · ${a.story}`);
|
|
773
|
+
}
|
|
774
|
+
if (due) $.ui.invalidate('ui.render');
|
|
775
|
+
}
|
|
776
|
+
if (loop.pending.length) await act.step('tick');
|
|
777
|
+
},
|
|
778
|
+
// VIEW-3: working characters repaint in place while the pane shows them; nothing runs otherwise.
|
|
779
|
+
animate: async () => {
|
|
780
|
+
const open = (await $.ui.panes()).some((p) => p.id === PANE_ID);
|
|
781
|
+
const now = await $.clock.now();
|
|
782
|
+
const art = artNow(now, liveNow(now));
|
|
783
|
+
const working = Object.keys(art.poses).filter((n) => art.poses[n] === 'working');
|
|
784
|
+
if (!open || paneSurface !== 'terminal' || !working.length) {
|
|
785
|
+
animation?.cancel();
|
|
786
|
+
animation = null;
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (animation) return;
|
|
790
|
+
animation = $.clock.every(400, async () => {
|
|
791
|
+
frame = (frame + 1) % 2;
|
|
792
|
+
const at = await $.clock.now();
|
|
793
|
+
const still = artNow(at, liveNow(at));
|
|
794
|
+
for (const name of Object.keys(still.poses).filter((n) => still.poses[n] === 'working'))
|
|
795
|
+
await $.ui
|
|
796
|
+
.blit({
|
|
797
|
+
requestId: PANE_ID,
|
|
798
|
+
key: `char-${name}`,
|
|
799
|
+
cells: rasterCells(
|
|
800
|
+
sprite('working', frame, still.tints[name], { lead: name === 'lead' }),
|
|
801
|
+
),
|
|
802
|
+
})
|
|
803
|
+
.catch(() => {});
|
|
804
|
+
});
|
|
805
|
+
},
|
|
806
|
+
// --- panes v2: the selected lane's acts (VIEW-6) ----------------------------------------
|
|
807
|
+
// A prompt the person asked for: submitted now if the lead is idle, else when it next is.
|
|
808
|
+
askLead: async (prompt, kind, target) => {
|
|
809
|
+
await act.record(kind, target, prompt);
|
|
810
|
+
if (leadTurn) personAsks.push(prompt);
|
|
811
|
+
else
|
|
812
|
+
$.prompt.submit({ text: prompt }).catch((err) => {
|
|
813
|
+
notice = `Couldn't prompt the lead: ${err?.message ?? err}`;
|
|
814
|
+
});
|
|
815
|
+
$.ui.invalidate('ui.render');
|
|
816
|
+
},
|
|
817
|
+
runningAgentOf: async (lane) =>
|
|
818
|
+
((await $.agent.list().catch(() => [])) ?? []).find(
|
|
819
|
+
(a) => a.status === 'running' && a.type === lane.agent,
|
|
820
|
+
) ?? null,
|
|
821
|
+
nudgeLane: async (lane) => {
|
|
822
|
+
const agent = await act.runningAgentOf(lane);
|
|
823
|
+
if (!agent) return;
|
|
824
|
+
await act.record('nudge', `${lane.name} · by the person`);
|
|
825
|
+
await $.session
|
|
826
|
+
.send({
|
|
827
|
+
to: { agentId: agent.id },
|
|
828
|
+
text: 'The person asks: report where you are, or carry on.',
|
|
829
|
+
})
|
|
830
|
+
.catch(() => {});
|
|
831
|
+
$.ui.invalidate('ui.render');
|
|
832
|
+
},
|
|
833
|
+
stopLane: async (lane) => {
|
|
834
|
+
const agent = await act.runningAgentOf(lane);
|
|
835
|
+
if (!agent) return;
|
|
836
|
+
const answer = await $.ui
|
|
837
|
+
.ask(
|
|
838
|
+
`Stop ${agent.type} on ${lane.story?.id ?? lane.name}? Its uncommitted work stays in its worktree.`,
|
|
839
|
+
['Stop', 'Cancel'],
|
|
840
|
+
)
|
|
841
|
+
.catch(() => 'Cancel');
|
|
842
|
+
if (answer !== 'Stop') return;
|
|
843
|
+
await act.askLead(
|
|
844
|
+
`The person asked to stop ${agent.type} (agent ${agent.id})${lane.story ? ` on ${lane.story.id}` : ''}: stop it with TaskStop, and don't dispatch its story again until they say so.`,
|
|
845
|
+
'stop',
|
|
846
|
+
lane.name,
|
|
847
|
+
);
|
|
848
|
+
},
|
|
849
|
+
sendBack: async (lane) => {
|
|
850
|
+
if (!lane.branch) return;
|
|
851
|
+
sendingBack = {
|
|
852
|
+
lane: lane.name,
|
|
853
|
+
branch: lane.branch,
|
|
854
|
+
story: lane.story?.id ?? lane.branch,
|
|
855
|
+
reason: '',
|
|
856
|
+
error: null,
|
|
857
|
+
};
|
|
858
|
+
await $.ui.open({ id: SENDBACK_ID, title: 'Send back', focus: true, closeOnEscape: true });
|
|
859
|
+
$.ui.invalidate('ui.render');
|
|
860
|
+
},
|
|
861
|
+
confirmSendBack: async (reason) => {
|
|
862
|
+
if (!sendingBack) return;
|
|
863
|
+
if (!reason?.trim()) {
|
|
864
|
+
sendingBack = { ...sendingBack, error: 'Give a reason: the lane reads it for its fix.' };
|
|
865
|
+
$.ui.invalidate('ui.render');
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const ran = await $.process.run(
|
|
869
|
+
['node', cli, 'sent-back', sendingBack.branch, '--reason', reason.trim()],
|
|
870
|
+
{ cwd },
|
|
871
|
+
);
|
|
872
|
+
if (ran.exitCode !== 0) {
|
|
873
|
+
sendingBack = { ...sendingBack, error: (ran.stderr || ran.stdout).trim().split('\n')[0] };
|
|
874
|
+
$.ui.invalidate('ui.render');
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
await act.record('sent back', `${sendingBack.branch}: ${reason.trim()}`);
|
|
878
|
+
sendingBack = null;
|
|
879
|
+
await $.ui.close({ id: SENDBACK_ID });
|
|
880
|
+
await act.reload();
|
|
881
|
+
},
|
|
882
|
+
cancelSendBack: async () => {
|
|
883
|
+
sendingBack = null;
|
|
884
|
+
await $.ui.close({ id: SENDBACK_ID });
|
|
885
|
+
},
|
|
886
|
+
// VIEW-7: a story's drill-down, its facts from `code-kit story --json`.
|
|
887
|
+
openStory: async (lane) => {
|
|
888
|
+
if (!lane.story) return;
|
|
889
|
+
const id = lane.story.id;
|
|
890
|
+
paneUi = { ...paneUi, story: id, file: null };
|
|
891
|
+
storyOpen = { id, lane: lane.name, data: null, error: null };
|
|
892
|
+
$.ui.invalidate('ui.render');
|
|
893
|
+
const ran = await $.process.run(['node', cli, 'story', id, '--json'], {
|
|
894
|
+
cwd,
|
|
895
|
+
timeoutMs: 180000,
|
|
896
|
+
});
|
|
897
|
+
if (storyOpen?.id !== id) return;
|
|
898
|
+
const data = ran.exitCode === 0 ? parseJson(ran.stdout) : null;
|
|
899
|
+
storyOpen = data
|
|
900
|
+
? { ...storyOpen, data }
|
|
901
|
+
: {
|
|
902
|
+
...storyOpen,
|
|
903
|
+
error: (ran.stderr || ran.stdout).trim().split('\n')[0] || 'code-kit story failed',
|
|
904
|
+
};
|
|
905
|
+
$.ui.invalidate('ui.render');
|
|
906
|
+
},
|
|
907
|
+
closeStory: () => {
|
|
908
|
+
paneUi = { ...paneUi, story: null, file: null };
|
|
909
|
+
storyOpen = null;
|
|
910
|
+
$.ui.invalidate('ui.render');
|
|
911
|
+
},
|
|
912
|
+
// VIEW-6: a lane's act, from its key or button.
|
|
913
|
+
laneAct: async (id, lane) => {
|
|
914
|
+
if (id === 'approve') {
|
|
915
|
+
const call = [...held.values()].find((c) => (c.lane ?? 'lead') === lane.name);
|
|
916
|
+
if (call) return act.approveHeld({ held: call });
|
|
917
|
+
const request = (model.requests?.open ?? []).find(
|
|
918
|
+
(r) => (r.lane ?? 'lead') === lane.name,
|
|
919
|
+
);
|
|
920
|
+
if (request) return act.approve({ request });
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (id === 'review' && lane.story)
|
|
924
|
+
return act.review({ story: { ...lane.story, branch: lane.branch } });
|
|
925
|
+
if (id === 'merge') return act.mergeQueue({ branch: lane.branch });
|
|
926
|
+
if (id === 'sendBack') return act.sendBack(lane);
|
|
927
|
+
if (id === 'nudge') return act.nudgeLane(lane);
|
|
928
|
+
if (id === 'stop') return act.stopLane(lane);
|
|
929
|
+
},
|
|
234
930
|
dismiss: async () => {
|
|
235
931
|
notice = null;
|
|
236
932
|
$.ui.invalidate('ui.render');
|
|
@@ -246,6 +942,8 @@ export function register(on) {
|
|
|
246
942
|
if (busy) return;
|
|
247
943
|
busy = true;
|
|
248
944
|
try {
|
|
945
|
+
await act.stalls();
|
|
946
|
+
await act.animate();
|
|
249
947
|
const now = await act.fingerprint();
|
|
250
948
|
const paneOpen = (await $.ui.panes()).some((p) => p.id === PANE_ID);
|
|
251
949
|
quiet += 1;
|
|
@@ -254,10 +952,16 @@ export function register(on) {
|
|
|
254
952
|
await act.reload();
|
|
255
953
|
// Taken again: what it covers (the plan) can change with what was read.
|
|
256
954
|
seen = await act.fingerprint();
|
|
955
|
+
// Between turns, a change (a lane finished, a branch moved) can make a step due.
|
|
956
|
+
if (now !== seen || quiet === 0) await act.step('tick');
|
|
257
957
|
} finally {
|
|
258
958
|
busy = false;
|
|
259
959
|
}
|
|
260
960
|
});
|
|
961
|
+
// HOLD-2: the countdown on a held call, redrawn each second while one waits.
|
|
962
|
+
$.clock.every(1000, () => {
|
|
963
|
+
if (held.size) $.ui.invalidate('ui.render');
|
|
964
|
+
});
|
|
261
965
|
return next(e);
|
|
262
966
|
});
|
|
263
967
|
|
|
@@ -265,6 +969,9 @@ export function register(on) {
|
|
|
265
969
|
text: `code-kit is still starting; try /${name} again in a moment.`,
|
|
266
970
|
});
|
|
267
971
|
on('command.run', { command: 'lanes' }, async ($, e) => (act ? act.lanes() : starting('lanes')));
|
|
972
|
+
on('command.run', { command: 'harness' }, async ($, e) =>
|
|
973
|
+
act ? act.harness() : starting('harness'),
|
|
974
|
+
);
|
|
268
975
|
on('command.run', { command: 'approvals' }, async ($, e) =>
|
|
269
976
|
act ? act.approvals() : starting('approvals'),
|
|
270
977
|
);
|
|
@@ -273,15 +980,82 @@ export function register(on) {
|
|
|
273
980
|
);
|
|
274
981
|
|
|
275
982
|
// CARD-1: the text a code-kit hook refused a call with, kept for the call's result to draw as a card.
|
|
983
|
+
// HOLD-1 to HOLD-6: a refusal a person's approval would allow is held while the band asks. The hooks
|
|
984
|
+
// stay the judge: the call is run again once the approval is written, and refused as before otherwise.
|
|
276
985
|
on('tool.call', async ($, e, next) => {
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
986
|
+
// LOOP-3: a lane agent's tool call is activity; while it runs, the agent isn't quiet.
|
|
987
|
+
const calledAt = await $.clock.now();
|
|
988
|
+
const busy = e.agentId ? activityOf(e.agentId, calledAt) : null;
|
|
989
|
+
if (busy) {
|
|
990
|
+
busy.inFlight += 1;
|
|
991
|
+
busy.nudged = false;
|
|
992
|
+
busy.stopped = false;
|
|
993
|
+
// VIEW-4: the agent's current or last tool call.
|
|
994
|
+
busy.tool = toolLine(e);
|
|
995
|
+
busy.at = calledAt;
|
|
996
|
+
} else if (!e.agentId) leadLast = { tool: toolLine(e), at: calledAt };
|
|
997
|
+
try {
|
|
998
|
+
const res = await next(e);
|
|
999
|
+
const text = res?.deny ?? (res?.isError ? (res.text ?? String(res.result ?? '')) : null);
|
|
1000
|
+
if (text && refusalCard(text)) {
|
|
1001
|
+
refusals.set(e.tool_use_id, text);
|
|
1002
|
+
if (refusals.size > REFUSALS_KEPT) refusals.delete(refusals.keys().next().value);
|
|
1003
|
+
}
|
|
1004
|
+
const ask = text ? holdable(text) : null;
|
|
1005
|
+
const settings = model.check?.harness;
|
|
1006
|
+
const minutes = settings?.hold?.minutes ?? 2;
|
|
1007
|
+
// HOLD-6: nobody to ask (claude -p, the SDK), or hold switched off: refused as today.
|
|
1008
|
+
if (!ask || !where?.interactive || minutes <= 0 || model.state?.kind !== 'ok') return res;
|
|
1009
|
+
const id = e.tool_use_id;
|
|
1010
|
+
// HOLD-5: under autonomy, the project's delegated rules decide first, without asking.
|
|
1011
|
+
if ((settings?.autonomy ?? 'autonomous') === 'autonomous') {
|
|
1012
|
+
const delegated = await $.process.run(
|
|
1013
|
+
[
|
|
1014
|
+
'node',
|
|
1015
|
+
where.cli,
|
|
1016
|
+
'approve',
|
|
1017
|
+
...ask.names,
|
|
1018
|
+
...(ask.lane ? ['--lane', ask.lane] : []),
|
|
1019
|
+
'--delegated',
|
|
1020
|
+
'--reason',
|
|
1021
|
+
`Held call within the delegated rules: ${ask.what}`,
|
|
1022
|
+
],
|
|
1023
|
+
{ cwd: where.cwd, timeoutMs: 60000 },
|
|
1024
|
+
);
|
|
1025
|
+
if (delegated.exitCode === 0) return next(e);
|
|
1026
|
+
}
|
|
1027
|
+
const agent = e.agentId ? agentsSeen.get(e.agentId) : null;
|
|
1028
|
+
held.set(id, {
|
|
1029
|
+
id,
|
|
1030
|
+
agentId: e.agentId ?? null,
|
|
1031
|
+
who: ask.lane ?? agent?.type ?? (e.agentId ? 'an agent' : 'the lead'),
|
|
1032
|
+
kind: ask.kind,
|
|
1033
|
+
names: ask.names,
|
|
1034
|
+
lane: ask.lane,
|
|
1035
|
+
what: ask.what,
|
|
1036
|
+
since: await $.clock.now(),
|
|
1037
|
+
minutes,
|
|
1038
|
+
});
|
|
1039
|
+
$.ui.invalidate('ui.render');
|
|
1040
|
+
// The wait is a process the band's answer ends (`code-kit hold`), so it isn't the hook's own time.
|
|
1041
|
+
const waited = await $.process
|
|
1042
|
+
.run(['node', where.cli, 'hold', id, '--minutes', String(minutes)], {
|
|
1043
|
+
cwd: where.cwd,
|
|
1044
|
+
// A little past the hold time, within the host's ten minutes for a process.
|
|
1045
|
+
timeoutMs: Math.min(minutes * 60000 + 15000, 600000),
|
|
1046
|
+
})
|
|
1047
|
+
.catch(() => null);
|
|
1048
|
+
held.delete(id);
|
|
1049
|
+
$.ui.invalidate('ui.render');
|
|
1050
|
+
// HOLD-3: the same call, now with the approval in force; HOLD-4: refused as the hooks refused it.
|
|
1051
|
+
return waited?.stdout.trim() === 'approved' ? next(e) : res;
|
|
1052
|
+
} finally {
|
|
1053
|
+
if (busy) {
|
|
1054
|
+
busy.inFlight -= 1;
|
|
1055
|
+
busy.at = await $.clock.now();
|
|
1056
|
+
}
|
|
282
1057
|
}
|
|
283
|
-
|
|
284
|
-
}).catch(($, e, next) => next(e)); // it only watches: whatever fails here, the call goes on as it would
|
|
1058
|
+
}).catch(($, e, next) => next(e)); // whatever fails here, the call goes on as it would
|
|
285
1059
|
// A refused call's text: kept from its tool.call, or the output its row carries (what the model read).
|
|
286
1060
|
const refusalOf = (id, output) =>
|
|
287
1061
|
refusalCard(refusals.get(id) ?? (typeof output === 'string' ? output : null));
|
|
@@ -313,7 +1087,31 @@ export function register(on) {
|
|
|
313
1087
|
});
|
|
314
1088
|
});
|
|
315
1089
|
|
|
1090
|
+
// USE-1: each model request's usage, as the request ends; the lead's requests carry no agentId.
|
|
1091
|
+
on('turn.step', async function* ($, e, next) {
|
|
1092
|
+
const res = yield* next(e);
|
|
1093
|
+
if (e.agentId) activityOf(e.agentId, 0).at = await $.clock.now();
|
|
1094
|
+
if (res?.usage && act) await act.measure(e.agentId, res.usage).catch(() => {});
|
|
1095
|
+
return res;
|
|
1096
|
+
});
|
|
1097
|
+
// USE-4: the plan's 5-hour use, as Claude Code measures it.
|
|
1098
|
+
on('session.measure', async ($, e, next) => {
|
|
1099
|
+
if (e.changed.includes('rateLimits')) {
|
|
1100
|
+
rateLimits = e.rateLimits;
|
|
1101
|
+
$.ui.invalidate('ui.render');
|
|
1102
|
+
}
|
|
1103
|
+
return next(e);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// What the pane and band show of usage: the session's tokens and the plan's use.
|
|
1107
|
+
const usageNow = () => ({
|
|
1108
|
+
usage: usageSummary(ledger, finishedStories(model.state)),
|
|
1109
|
+
plan: planOf(rateLimits, model.check?.harness?.background?.pauseAtPercent),
|
|
1110
|
+
});
|
|
1111
|
+
|
|
316
1112
|
on('turn.start', async ($, e, next) => {
|
|
1113
|
+
leadTurn = e.turnId;
|
|
1114
|
+
$.ui.invalidate('ui.render'); // the lead's lane reads working while its turn runs
|
|
317
1115
|
if (reviewTurn === 'next') reviewTurn = e.turnId;
|
|
318
1116
|
return next(e);
|
|
319
1117
|
});
|
|
@@ -324,17 +1122,295 @@ export function register(on) {
|
|
|
324
1122
|
reviewTurn = null;
|
|
325
1123
|
$.ui.invalidate('ui.render');
|
|
326
1124
|
}
|
|
1125
|
+
const res = await next(e);
|
|
1126
|
+
// REVW-3: a subagent's last turn may be the reviewer's report.
|
|
1127
|
+
if (e.agentId && act) await act.reviewed(e.agentId, e.answer).catch(() => {});
|
|
1128
|
+
// LOOP-1: the lead is idle; the loop's next step, if one is due (submitted from turn.complete,
|
|
1129
|
+
// as the spike found it must be). The prompt it submits isn't awaited: that turn isn't this hook's.
|
|
1130
|
+
if (!e.agentId && (!leadTurn || leadTurn === e.turnId)) {
|
|
1131
|
+
leadTurn = null;
|
|
1132
|
+
$.ui.invalidate('ui.render');
|
|
1133
|
+
// What the person asked for goes first, whatever the loop's state.
|
|
1134
|
+
if (personAsks.length) {
|
|
1135
|
+
const text = personAsks.shift();
|
|
1136
|
+
$.prompt.submit({ text }).catch(() => {});
|
|
1137
|
+
return res;
|
|
1138
|
+
}
|
|
1139
|
+
await act?.step('turn').catch(() => {});
|
|
1140
|
+
}
|
|
1141
|
+
return res;
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// What the pane and band show of the loop.
|
|
1145
|
+
const loopNow = (now) => {
|
|
1146
|
+
const h = harness();
|
|
1147
|
+
const state =
|
|
1148
|
+
h.autonomy === 'off' ? 'off' : loop.done ? 'done' : loop.paused ? 'paused' : 'running';
|
|
1149
|
+
const laneNotes = {};
|
|
1150
|
+
for (const a of activity.values()) {
|
|
1151
|
+
if (!a.lane) continue;
|
|
1152
|
+
const n = restarts.get(a.story) ?? 0;
|
|
1153
|
+
if (a.nudged && !a.stopped) laneNotes[a.lane] = 'nudged';
|
|
1154
|
+
if (a.stopped && n) laneNotes[a.lane] = `restarted ${n}/${h.stall.maxRestarts}`;
|
|
1155
|
+
}
|
|
1156
|
+
for (const f of flagged.values()) laneNotes[f.lane] = 'stalled';
|
|
1157
|
+
const last = [...loop.steps]
|
|
1158
|
+
.reverse()
|
|
1159
|
+
.find((s) => ['dispatch', 'review', 'lead', 'restart'].includes(s.kind));
|
|
1160
|
+
return {
|
|
1161
|
+
state: state === 'done' && loop.doneDismissed ? 'stopped' : state,
|
|
1162
|
+
autonomy: h.autonomy,
|
|
1163
|
+
started: loop.started,
|
|
1164
|
+
last: last ? `${last.kind} ${last.target}` : null,
|
|
1165
|
+
proposal: loop.proposal,
|
|
1166
|
+
flagged: [...flagged.values()],
|
|
1167
|
+
doneCount: loop.done?.count ?? 0,
|
|
1168
|
+
steps: loop.steps,
|
|
1169
|
+
laneNotes,
|
|
1170
|
+
now,
|
|
1171
|
+
reviews: [...reviews.values()].map((r) => ({
|
|
1172
|
+
...r,
|
|
1173
|
+
summary: findingsCount(r.findings),
|
|
1174
|
+
blockers: r.findings.filter((f) => f.severity === 'blocker').length,
|
|
1175
|
+
})),
|
|
1176
|
+
};
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
// The project as the panes draw it: the lead active while its turn runs, which the last read can't know.
|
|
1180
|
+
const stateNow = () =>
|
|
1181
|
+
model.state?.kind === 'ok'
|
|
1182
|
+
? {
|
|
1183
|
+
...model.state,
|
|
1184
|
+
lanes: model.state.lanes.map((l) =>
|
|
1185
|
+
l.name === 'lead' ? { ...l, active: Boolean(leadTurn) } : l,
|
|
1186
|
+
),
|
|
1187
|
+
}
|
|
1188
|
+
: model.state;
|
|
1189
|
+
// VIEW-4: each lane's current or last tool call, and how long ago.
|
|
1190
|
+
// JOIN-2: each lane's facts from the adapters: edits without understanding and cards owed.
|
|
1191
|
+
const factsOf = (lane) => {
|
|
1192
|
+
const key = lane.agent ?? 'lead';
|
|
1193
|
+
const out = { withoutUnderstanding: [], cardsOwed: [] };
|
|
1194
|
+
for (const a of model.adapters ?? []) {
|
|
1195
|
+
const f = a.lanes?.[key];
|
|
1196
|
+
if (!f) continue;
|
|
1197
|
+
out.withoutUnderstanding.push(...f.withoutUnderstanding);
|
|
1198
|
+
out.cardsOwed.push(...f.cardsOwed.filter((c) => !out.cardsOwed.includes(c)));
|
|
1199
|
+
}
|
|
1200
|
+
return out;
|
|
1201
|
+
};
|
|
1202
|
+
const combinedNow = () => {
|
|
1203
|
+
if (!(model.adapters ?? []).some((a) => a.lanes)) return null;
|
|
1204
|
+
const lanesNow = stateNow()?.kind === 'ok' ? stateNow().lanes : [];
|
|
1205
|
+
return Object.fromEntries(
|
|
1206
|
+
lanesNow.map((l) => {
|
|
1207
|
+
const f = factsOf(l);
|
|
1208
|
+
return [l.name, { without: f.withoutUnderstanding.length, owed: f.cardsOwed.length }];
|
|
1209
|
+
}),
|
|
1210
|
+
);
|
|
1211
|
+
};
|
|
1212
|
+
// VIEW-2, VIEW-3: each lane's pose and tint, and its story's bar on the shared axis.
|
|
1213
|
+
const artNow = (now, live) => {
|
|
1214
|
+
const lanesNow = stateNow()?.kind === 'ok' ? stateNow().lanes : [];
|
|
1215
|
+
const names = lanesNow.map((l) => l.name);
|
|
1216
|
+
const queue = model.queue ?? [];
|
|
1217
|
+
const asking = new Set([
|
|
1218
|
+
...[...held.values()].map((c) => c.lane ?? 'lead'),
|
|
1219
|
+
...(model.requests?.open ?? []).map((r) => r.lane ?? 'lead'),
|
|
1220
|
+
]);
|
|
1221
|
+
const stalled = new Set([...flagged.values()].map((f) => f.lane));
|
|
1222
|
+
const times = (model.timeline?.stories ?? []).filter((t) =>
|
|
1223
|
+
lanesNow.some((l) => l.story?.id === t.id),
|
|
1224
|
+
);
|
|
1225
|
+
const start = axisStart(times, now);
|
|
1226
|
+
const width = Math.max(10, Math.min(120, paneColumns - 14));
|
|
1227
|
+
const poses = {};
|
|
1228
|
+
const tints = {};
|
|
1229
|
+
const bars = {};
|
|
1230
|
+
for (const l of lanesNow) {
|
|
1231
|
+
poses[l.name] = poseOf(l, {
|
|
1232
|
+
live: live[l.name],
|
|
1233
|
+
waiting: asking.has(l.name),
|
|
1234
|
+
flagged: stalled.has(l.name),
|
|
1235
|
+
shown: shownState(l, queue),
|
|
1236
|
+
});
|
|
1237
|
+
tints[l.name] = tintOf(l.name, names);
|
|
1238
|
+
const t = times.find((x) => x.id === l.story?.id);
|
|
1239
|
+
if (t) bars[l.name] = barCells(t, { start, now, width });
|
|
1240
|
+
}
|
|
1241
|
+
return { surface: paneSurface, frame, poses, tints, bars, svgWidth: width * 6 };
|
|
1242
|
+
};
|
|
1243
|
+
const liveNow = (now) => {
|
|
1244
|
+
const stall = harness().stall;
|
|
1245
|
+
const live = {};
|
|
1246
|
+
if (leadLast) live.lead = { tool: leadLast.tool, ago: ago(now - leadLast.at), level: null };
|
|
1247
|
+
for (const a of activity.values()) {
|
|
1248
|
+
if (!a.lane || !a.tool) continue;
|
|
1249
|
+
if (live[a.lane] && live[a.lane].at > a.at) continue;
|
|
1250
|
+
live[a.lane] = {
|
|
1251
|
+
tool: a.tool,
|
|
1252
|
+
at: a.at,
|
|
1253
|
+
ago: a.inFlight > 0 ? 'now' : ago(now - a.at),
|
|
1254
|
+
level: liveLevel(now - a.at, stall, {
|
|
1255
|
+
flagged: [...flagged.values()].some((f) => f.lane === a.lane),
|
|
1256
|
+
running: a.inFlight > 0,
|
|
1257
|
+
}),
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
return live;
|
|
1261
|
+
};
|
|
1262
|
+
// VIEW-7: the drill-down's view of the open story, with what the session knows of it.
|
|
1263
|
+
const storyNow = (open, now, usage) => {
|
|
1264
|
+
const lane = model.state?.lanes?.find((l) => l.name === open.lane) ?? null;
|
|
1265
|
+
const branch = open.data?.branch ?? lane?.branch ?? null;
|
|
1266
|
+
return {
|
|
1267
|
+
story: open,
|
|
1268
|
+
review: branch ? (reviews.get(branch) ?? null) : null,
|
|
1269
|
+
tokens: usage?.stories?.[open.id] ?? 0,
|
|
1270
|
+
outlier: usage?.outliers?.find((o) => o.id === open.id)?.ratio ?? null,
|
|
1271
|
+
steps: loop.steps.filter(
|
|
1272
|
+
(s) => s.target.includes(open.id) || (branch && s.target.includes(branch)),
|
|
1273
|
+
),
|
|
1274
|
+
actions: lane
|
|
1275
|
+
? laneActions(lane, {
|
|
1276
|
+
queue: model.queue ?? [],
|
|
1277
|
+
held: [...held.values()],
|
|
1278
|
+
requests: model.requests?.open ?? [],
|
|
1279
|
+
}).filter((a) => !['nudge', 'stop'].includes(a.id))
|
|
1280
|
+
: [],
|
|
1281
|
+
file: paneUi.file,
|
|
1282
|
+
now,
|
|
1283
|
+
understanding: lane && (model.adapters ?? []).some((a) => a.lanes) ? factsOf(lane) : null,
|
|
1284
|
+
};
|
|
1285
|
+
};
|
|
1286
|
+
// The band's lines, which the pane pins as what needs the person (VIEW-5).
|
|
1287
|
+
const linesNow = (now) =>
|
|
1288
|
+
bandLines({
|
|
1289
|
+
...model,
|
|
1290
|
+
reviewing,
|
|
1291
|
+
notice,
|
|
1292
|
+
...usageNow(),
|
|
1293
|
+
held: [...held.values()],
|
|
1294
|
+
now,
|
|
1295
|
+
loop: loopNow(now),
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
|
|
1299
|
+
const now = await $.clock.now();
|
|
1300
|
+
paneSurface = e.surface;
|
|
1301
|
+
paneColumns = e.props.bodyColumns ?? paneColumns;
|
|
1302
|
+
const { usage, plan } = usageNow();
|
|
1303
|
+
const lanesNow = stateNow()?.kind === 'ok' ? stateNow().lanes : [];
|
|
1304
|
+
return lanesFrame(
|
|
1305
|
+
{
|
|
1306
|
+
state: stateNow(),
|
|
1307
|
+
usage,
|
|
1308
|
+
plan,
|
|
1309
|
+
loop: { ...loopNow(now), queue: model.queue ?? [] },
|
|
1310
|
+
needs: linesNow(now),
|
|
1311
|
+
live: liveNow(now),
|
|
1312
|
+
held: [...held.values()],
|
|
1313
|
+
requests: model.requests?.open ?? [],
|
|
1314
|
+
ui: paneUi,
|
|
1315
|
+
status: model.status ?? null,
|
|
1316
|
+
// VIEW-6: what each lane owns, for the selected lane's detail.
|
|
1317
|
+
paths: {
|
|
1318
|
+
lead: model.check?.lead?.paths ?? [],
|
|
1319
|
+
...Object.fromEntries(
|
|
1320
|
+
Object.entries(model.check?.lanes ?? {}).map(([n, l]) => [n, l.paths ?? []]),
|
|
1321
|
+
),
|
|
1322
|
+
},
|
|
1323
|
+
art: artNow(now, liveNow(now)),
|
|
1324
|
+
combined: combinedNow(),
|
|
1325
|
+
placement: e.props.placement,
|
|
1326
|
+
story: storyOpen ? storyNow(storyOpen, now, usage) : null,
|
|
1327
|
+
},
|
|
1328
|
+
$.ui.resolve(e),
|
|
1329
|
+
{
|
|
1330
|
+
onTab: (tab) => {
|
|
1331
|
+
paneUi = { ...paneUi, tab };
|
|
1332
|
+
$.ui.invalidate('ui.render');
|
|
1333
|
+
},
|
|
1334
|
+
onFilter: (state) => {
|
|
1335
|
+
paneUi = { ...paneUi, filter: paneUi.filter === state ? null : state };
|
|
1336
|
+
$.ui.invalidate('ui.render');
|
|
1337
|
+
},
|
|
1338
|
+
onMove: (step) => {
|
|
1339
|
+
if (!lanesNow.length) return;
|
|
1340
|
+
const at = lanesNow.findIndex((l) => l.name === paneUi.selected);
|
|
1341
|
+
const to =
|
|
1342
|
+
at < 0
|
|
1343
|
+
? step > 0
|
|
1344
|
+
? 0
|
|
1345
|
+
: lanesNow.length - 1
|
|
1346
|
+
: (at + step + lanesNow.length) % lanesNow.length;
|
|
1347
|
+
paneUi = { ...paneUi, selected: lanesNow[to].name };
|
|
1348
|
+
$.ui.invalidate('ui.render');
|
|
1349
|
+
// VIEW-6: Enter opens the selected lane's story: the focus waits on its Open button.
|
|
1350
|
+
if (
|
|
1351
|
+
lanesNow[to].story ||
|
|
1352
|
+
(model.state?.stories ?? []).some((s) => s.lane === lanesNow[to].name)
|
|
1353
|
+
)
|
|
1354
|
+
$.ui.focus({ requestId: PANE_ID, key: 'open-story' }).catch(() => {});
|
|
1355
|
+
},
|
|
1356
|
+
onOpen: (lane) => act?.openStory(lane),
|
|
1357
|
+
// TRACE-2: a cell selected by its keys or the requirement list.
|
|
1358
|
+
onCell: (cell) => {
|
|
1359
|
+
paneUi = { ...paneUi, cell };
|
|
1360
|
+
$.ui.invalidate('ui.render');
|
|
1361
|
+
},
|
|
1362
|
+
story: {
|
|
1363
|
+
onBack: () => act?.closeStory(),
|
|
1364
|
+
onFile: (file) => {
|
|
1365
|
+
paneUi = { ...paneUi, file };
|
|
1366
|
+
$.ui.invalidate('ui.render');
|
|
1367
|
+
},
|
|
1368
|
+
// JOIN-4: a file in the adapter's own view, through the command it names.
|
|
1369
|
+
onOpenIn: (adapter, path) =>
|
|
1370
|
+
$.command.run({ command: adapter.open.command, args: path }).catch((err) => {
|
|
1371
|
+
notice = `Couldn't open ${path} in ${adapter.name}: ${err?.message ?? err}`;
|
|
1372
|
+
$.ui.invalidate('ui.render');
|
|
1373
|
+
}),
|
|
1374
|
+
onAct: (id) => {
|
|
1375
|
+
const lane = lanesNow.find((l) => l.name === storyOpen?.lane);
|
|
1376
|
+
if (lane) act?.laneAct(id, lane);
|
|
1377
|
+
},
|
|
1378
|
+
},
|
|
1379
|
+
onNeed: (id, line) => act?.[id]?.(line),
|
|
1380
|
+
onLane: (id, lane) => act?.laneAct(id, lane),
|
|
1381
|
+
onSearch: (search) => {
|
|
1382
|
+
paneUi = { ...paneUi, search };
|
|
1383
|
+
$.ui.invalidate('ui.render');
|
|
1384
|
+
},
|
|
1385
|
+
onFocusSearch: () => $.ui.focus({ requestId: PANE_ID, key: 'filter' }).catch(() => {}),
|
|
1386
|
+
},
|
|
1387
|
+
);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
// VIEW-6: Escape on a story goes back to the lanes; on the lanes, it closes the pane.
|
|
1391
|
+
on('ui.close', async ($, e, next) => {
|
|
1392
|
+
if (closeGoesBack(e, PANE_ID, paneUi)) {
|
|
1393
|
+
act?.closeStory();
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
327
1396
|
return next(e);
|
|
328
1397
|
});
|
|
329
1398
|
|
|
330
|
-
on('ui.render', { component: 'Pane', requestId:
|
|
331
|
-
|
|
1399
|
+
on('ui.render', { component: 'Pane', requestId: SENDBACK_ID }, async ($, e) =>
|
|
1400
|
+
sendBackPane(sendingBack, $.ui.resolve(e), {
|
|
1401
|
+
onInput: (value) => {
|
|
1402
|
+
if (sendingBack) sendingBack = { ...sendingBack, reason: value };
|
|
1403
|
+
$.ui.invalidate('ui.render');
|
|
1404
|
+
},
|
|
1405
|
+
onSubmit: (value) => act.confirmSendBack(value),
|
|
1406
|
+
onCancel: () => act.cancelSendBack(),
|
|
1407
|
+
}),
|
|
332
1408
|
);
|
|
333
1409
|
|
|
334
1410
|
// BAND-2: absent when nothing waits. Its lines go above whatever else the band holds (another
|
|
335
1411
|
// plugin's lines, such as Context Graph's, or Claude Code's own), which it keeps.
|
|
336
1412
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
337
|
-
const lines =
|
|
1413
|
+
const lines = linesNow(await $.clock.now());
|
|
338
1414
|
if (!lines.length || !act) return next(e);
|
|
339
1415
|
const ours = band(lines, $.ui.resolve(e), (id, line) => act[id](line));
|
|
340
1416
|
const below = await next(e);
|
|
@@ -353,6 +1429,18 @@ export function register(on) {
|
|
|
353
1429
|
});
|
|
354
1430
|
});
|
|
355
1431
|
|
|
1432
|
+
on('ui.render', { component: 'Pane', requestId: SETTINGS_ID }, async ($, e) =>
|
|
1433
|
+
settingsView(settingRows, pendingSetting, $.ui.resolve(e), {
|
|
1434
|
+
onChoose: (key, value) => act.chooseSetting(key, value),
|
|
1435
|
+
onReason: (value) => {
|
|
1436
|
+
if (pendingSetting) pendingSetting = { ...pendingSetting, reason: value };
|
|
1437
|
+
$.ui.invalidate('ui.render');
|
|
1438
|
+
},
|
|
1439
|
+
onConfirm: (reason) => act.confirmSetting(reason),
|
|
1440
|
+
onCancel: () => act.cancelSetting(),
|
|
1441
|
+
}),
|
|
1442
|
+
);
|
|
1443
|
+
|
|
356
1444
|
on('ui.render', { component: 'Pane', requestId: RESULT_ID }, async ($, e) =>
|
|
357
1445
|
resultPane(result, $.ui.resolve(e)),
|
|
358
1446
|
);
|