@warren-dean/code-kit 0.13.0 → 0.15.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 +17 -0
- package/README.md +35 -30
- package/bin/code-kit.mjs +26 -6
- package/hooks/guard-bash.mjs +5 -0
- package/hooks/lib/harness.mjs +9 -0
- package/hooks/lib/loop-state.mjs +49 -9
- package/hooks/mod/decisions.mjs +75 -0
- package/hooks/mod/loop.mjs +91 -0
- package/hooks/mod/mission.mjs +19 -15
- package/hooks/mod/register.mjs +305 -14
- package/hooks/mod/register.test.ts +384 -8
- package/hooks/mod/view.mjs +67 -4
- package/package.json +1 -1
- package/skills/init/SKILL.md +2 -1
package/hooks/mod/loop.mjs
CHANGED
|
@@ -148,3 +148,94 @@ export function restartPrompt({ type, id, story, minutes }) {
|
|
|
148
148
|
/** The message a quiet agent gets (LOOP-3). */
|
|
149
149
|
export const nudgeText = (minutes) =>
|
|
150
150
|
`No tool call for ${minutes} minutes: report where you are, or carry on.`;
|
|
151
|
+
|
|
152
|
+
/** LOOP-11: what a lead turn left the build at, as the loop reads the lead's reply. */
|
|
153
|
+
export const OUTCOMES = new Set(['done', 'started', 'needs-person', 'blocked']);
|
|
154
|
+
const ANSWER_SHOWN = 4000;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* LOOP-11: the side call that reads the lead's last reply before the loop prompts it again: over the
|
|
158
|
+
* lead's own conversation (so it knows the whole exchange), with the reply itself for a call that
|
|
159
|
+
* can't fork. `prompt` is the loop's own prompt for that turn, or null when the person started it.
|
|
160
|
+
*/
|
|
161
|
+
export function outcomePrompt({ prompt = null, answer = '' }) {
|
|
162
|
+
const reply = String(answer ?? '').trim();
|
|
163
|
+
const shown =
|
|
164
|
+
reply.length > ANSWER_SHOWN ? `${reply.slice(0, ANSWER_SHOWN)}\n[… cut short]` : reply;
|
|
165
|
+
return [
|
|
166
|
+
prompt
|
|
167
|
+
? `code-kit's lead loop prompted you: "${prompt}"`
|
|
168
|
+
: "The person prompted you, and code-kit's lead loop is about to prompt you with the build's next step.",
|
|
169
|
+
`Your reply ended your turn. It read:\n"""\n${shown}\n"""`,
|
|
170
|
+
'Say where that reply left things, so the loop knows whether to prompt you again. Answer with one JSON object and nothing else:',
|
|
171
|
+
'{"outcome": "done" | "started" | "needs-person" | "blocked", "question": "…", "options": [{"label": "…", "effect": "…"}], "reason": "…"}',
|
|
172
|
+
'- done: you finished what was asked, or there was nothing to do.',
|
|
173
|
+
'- started: you started work that carries on without you, such as lane agents.',
|
|
174
|
+
"- needs-person: you asked the person something, or wait on their answer, approval or feedback before you go on. question: what you wait on, as one question; options: 2 to 4 answers they could give (a label of at most five words, and what it does), or [] when it's theirs to put in words.",
|
|
175
|
+
"- blocked: you didn't take the step, for a reason no answer from the person settles now. reason: why, in a sentence.",
|
|
176
|
+
].join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* LOOP-11: the side call's answer: { outcome, question, options: [{ label, effect }], reason }, or
|
|
181
|
+
* null when it isn't one (the loop then goes on as before).
|
|
182
|
+
*/
|
|
183
|
+
export function readOutcome(text) {
|
|
184
|
+
const raw = String(text ?? '');
|
|
185
|
+
const json = raw.slice(raw.indexOf('{'), raw.lastIndexOf('}') + 1);
|
|
186
|
+
let parsed;
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(json);
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
if (!parsed || !OUTCOMES.has(parsed.outcome)) return null;
|
|
193
|
+
const words = (s, n) =>
|
|
194
|
+
typeof s === 'string' && s.trim() ? s.replace(/\s+/g, ' ').trim().slice(0, n) : null;
|
|
195
|
+
const options = (Array.isArray(parsed.options) ? parsed.options : [])
|
|
196
|
+
.map((o) => ({ label: words(o?.label, 40), effect: words(o?.effect, 200) }))
|
|
197
|
+
.filter((o) => o.label)
|
|
198
|
+
.slice(0, 4);
|
|
199
|
+
return {
|
|
200
|
+
outcome: parsed.outcome,
|
|
201
|
+
question: words(parsed.question, 400),
|
|
202
|
+
options: options.length >= 2 ? options : [],
|
|
203
|
+
reason: words(parsed.reason, 300),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* LOOP-12: a design is made with the person, so the loop hands a design story to the lead once; while
|
|
209
|
+
* the stories `next` names have all been handed over, it leaves them to the person and the lead.
|
|
210
|
+
*/
|
|
211
|
+
export const designHandedOver = (step, handed) =>
|
|
212
|
+
step?.step === 'design' &&
|
|
213
|
+
(step.args ?? '')
|
|
214
|
+
.split(/\s+/)
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
.every((id) => handed.has(id));
|
|
217
|
+
|
|
218
|
+
/** LOOP-11: the lead's prompt with the person's answer to what it waited on. */
|
|
219
|
+
export const answeredPrompt = (question, answer) => {
|
|
220
|
+
const said = /[.!?]$/.test(answer) ? answer : `${answer}.`;
|
|
221
|
+
return question
|
|
222
|
+
? `The person answered what you asked ("${question}"): ${said} Carry on with it.`
|
|
223
|
+
: `The person says: ${said} Carry on with it.`;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/** SET-4: the `model:` an agent file's frontmatter names, or null. */
|
|
227
|
+
export function agentFileModel(text) {
|
|
228
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text ?? ''))?.[1] ?? '';
|
|
229
|
+
return /^model:\s*['"]?([^'"\s]+)['"]?\s*$/m.exec(front)?.[1] ?? null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* SET-4: the model to start a lane agent on, or null to leave the spawn as it is: a model the lead's
|
|
234
|
+
* call names wins, then the lane agent file's own (other than `inherit`), then `harness.agents.lanes.model`.
|
|
235
|
+
*/
|
|
236
|
+
export function laneModel({ type, model = null, lanes = {}, setting = null, fileModel = null }) {
|
|
237
|
+
if (model || !setting) return null;
|
|
238
|
+
if (!Object.values(lanes).some((l) => l.agent === type)) return null;
|
|
239
|
+
if (fileModel && fileModel !== 'inherit') return null;
|
|
240
|
+
return setting;
|
|
241
|
+
}
|
package/hooks/mod/mission.mjs
CHANGED
|
@@ -50,7 +50,7 @@ export function missionLines({
|
|
|
50
50
|
}) {
|
|
51
51
|
if (state?.kind !== 'ok') return [];
|
|
52
52
|
const working = state.lanes.filter((l) => l.name !== 'lead' && l.active && live[l.name]);
|
|
53
|
-
const loopOn = Boolean(loop?.started) && !['off', 'stopped'].includes(loop?.state);
|
|
53
|
+
const loopOn = Boolean(loop?.started) && !['off', 'stopped', 'elsewhere'].includes(loop?.state);
|
|
54
54
|
if (!leadBusy && !working.length && !loopOn) return [];
|
|
55
55
|
const rows = [];
|
|
56
56
|
const goal = goalOf(state.stories);
|
|
@@ -64,25 +64,29 @@ export function missionLines({
|
|
|
64
64
|
? `lead ${live.lead?.tool ? `at work: ${live.lead.tool}` : 'at work'}`
|
|
65
65
|
: 'lead idle';
|
|
66
66
|
const loopWord =
|
|
67
|
-
loop?.state === '
|
|
68
|
-
? 'loop
|
|
69
|
-
: loop?.state === '
|
|
70
|
-
? 'loop
|
|
71
|
-
: loop?.state === '
|
|
72
|
-
? '
|
|
73
|
-
:
|
|
74
|
-
?
|
|
75
|
-
:
|
|
67
|
+
loop?.state === 'elsewhere'
|
|
68
|
+
? 'loop in another session'
|
|
69
|
+
: loop?.state === 'paused'
|
|
70
|
+
? 'loop paused'
|
|
71
|
+
: loop?.state === 'off'
|
|
72
|
+
? 'loop off'
|
|
73
|
+
: loop?.state === 'done'
|
|
74
|
+
? 'milestone done'
|
|
75
|
+
: loopOn
|
|
76
|
+
? `loop ${loop.autonomy === 'propose' ? 'proposing' : 'on'}${loop.last ? ` · last: ${loop.last}` : ''}`
|
|
77
|
+
: null;
|
|
76
78
|
rows.push({
|
|
77
79
|
kind: 'now',
|
|
78
80
|
label: 'now',
|
|
79
81
|
text: [lead, loopWord].filter(Boolean).join(' · '),
|
|
80
82
|
actions:
|
|
81
|
-
loop?.state === '
|
|
82
|
-
? [{ id: '
|
|
83
|
-
:
|
|
84
|
-
? [{ id: '
|
|
85
|
-
:
|
|
83
|
+
loop?.state === 'elsewhere'
|
|
84
|
+
? [{ id: 'leadHere', label: 'Lead from here' }]
|
|
85
|
+
: loop?.state === 'paused'
|
|
86
|
+
? [{ id: 'resume', label: 'Resume' }]
|
|
87
|
+
: loopOn && loop?.state === 'running'
|
|
88
|
+
? [{ id: 'pause', label: 'Pause' }]
|
|
89
|
+
: [],
|
|
86
90
|
});
|
|
87
91
|
const next = nextText(nextStep);
|
|
88
92
|
if (next && loop?.state !== 'done') rows.push({ kind: 'next', label: 'next', text: next });
|
package/hooks/mod/register.mjs
CHANGED
|
@@ -51,8 +51,14 @@ import {
|
|
|
51
51
|
toolLine,
|
|
52
52
|
} from './panes.mjs';
|
|
53
53
|
import {
|
|
54
|
+
agentFileModel,
|
|
55
|
+
answeredPrompt,
|
|
56
|
+
designHandedOver,
|
|
57
|
+
laneModel,
|
|
54
58
|
leadPrompt,
|
|
55
59
|
nudgeText,
|
|
60
|
+
outcomePrompt,
|
|
61
|
+
readOutcome,
|
|
56
62
|
restartPrompt,
|
|
57
63
|
stallDue,
|
|
58
64
|
stepKey,
|
|
@@ -61,7 +67,7 @@ import {
|
|
|
61
67
|
withoutInFlight,
|
|
62
68
|
} from './loop.mjs';
|
|
63
69
|
import { goalOf, nextText } from './mission.mjs';
|
|
64
|
-
import { contextOf, decisionCards, limitsWhy } from './decisions.mjs';
|
|
70
|
+
import { contextOf, decisionCards, limitsWhy, nativeChoice } from './decisions.mjs';
|
|
65
71
|
import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
|
|
66
72
|
import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
|
|
67
73
|
import { harnessSettings } from '../lib/harness.mjs';
|
|
@@ -111,7 +117,22 @@ const loop = {
|
|
|
111
117
|
nextStep: null, // what `code-kit next` last said, for the band's next row
|
|
112
118
|
done: null, // { count } once every story is done
|
|
113
119
|
doneDismissed: false,
|
|
120
|
+
// LOOP-11: the step the lead's turn was prompted with, until that turn ends: { prompt, key, label, kind, target }
|
|
121
|
+
sent: null,
|
|
122
|
+
// LOOP-11, LOOP-12: what holds the loop: { kind: 'person' | 'stopped' | 'blocked' | 'design', key, label, prompt, question, options, reason }
|
|
123
|
+
waiting: null,
|
|
124
|
+
handedDesign: new Set(), // LOOP-12: design stories handed to the lead this session
|
|
114
125
|
};
|
|
126
|
+
// LOOP-10: whether this session runs the loop, the session that does when another does, and when this
|
|
127
|
+
// one last renewed its lease.
|
|
128
|
+
const lease = { mine: false, other: null, renewed: -Infinity };
|
|
129
|
+
const LEASE_RENEW_MS = 30000;
|
|
130
|
+
// LOOP-11: the lead's last reply, read once before the loop next prompts it: { turnId, text, aborted, sent, read }
|
|
131
|
+
let lastReply = null;
|
|
132
|
+
let turnQuestions = []; // the open questions as the lead's running turn started
|
|
133
|
+
// DECIDE-4: decisions asked in Claude Code's own question dialog this session, and whether one is open.
|
|
134
|
+
const offered = new Set();
|
|
135
|
+
let offering = false;
|
|
115
136
|
/** LOOP-8: the times in a row the lead is prompted with one step before the person is asked. */
|
|
116
137
|
const ASK_LIMIT = 2;
|
|
117
138
|
let leadTurn = null; // the lead's running turn, if any
|
|
@@ -207,7 +228,7 @@ export function register(on) {
|
|
|
207
228
|
const cli = `${$.plugin.root}/bin/code-kit.mjs`;
|
|
208
229
|
const cwd = e.cwd ?? (await $.session.cwd());
|
|
209
230
|
const session = await $.session.id();
|
|
210
|
-
where = { cli, cwd, interactive: e.isInteractive !== false };
|
|
231
|
+
where = { cli, cwd, session, interactive: e.isInteractive !== false };
|
|
211
232
|
// The held call's answer, for the `code-kit hold` it waits in.
|
|
212
233
|
const answerHeld = (id, answer) =>
|
|
213
234
|
$.process.run(['node', cli, 'hold', id, '--answer', answer], { cwd });
|
|
@@ -217,9 +238,12 @@ export function register(on) {
|
|
|
217
238
|
};
|
|
218
239
|
// What a CLI run said, in a line: why it refused, for a notice.
|
|
219
240
|
const firstLine = (ran) => (ran.stderr || ran.stdout).trim().split('\n')[0];
|
|
220
|
-
// A session starts with the loop running: a pause recorded by an earlier one no longer holds.
|
|
221
|
-
|
|
241
|
+
// A session starts with the loop running: a pause recorded by an earlier one no longer holds. A pause
|
|
242
|
+
// by a session that still runs the loop (LOOP-10) is that session's, and stays.
|
|
243
|
+
const recorded = await json('loop').catch(() => null);
|
|
244
|
+
if (recorded?.paused && (!recorded.lead || recorded.lead.session === session))
|
|
222
245
|
await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
|
|
246
|
+
lease.other = recorded?.lead && recorded.lead.session !== session ? recorded.lead : null;
|
|
223
247
|
const stamp = async (path) => {
|
|
224
248
|
const s = await $.fs.stat(`${cwd}/${path}`).catch(() => null);
|
|
225
249
|
return s ? `${path}@${s.mtimeMs}` : `${path}-`;
|
|
@@ -270,6 +294,11 @@ export function register(on) {
|
|
|
270
294
|
boardSynced = at;
|
|
271
295
|
$.process.run(['node', cli, 'team', 'board'], { cwd, timeoutMs: 120000 }).catch(() => {});
|
|
272
296
|
}
|
|
297
|
+
// LOOP-10: while another session runs the loop, whether it still does (its lease lapses as it ends).
|
|
298
|
+
if (!lease.mine) {
|
|
299
|
+
const lead = (await json('loop').catch(() => null))?.lead ?? null;
|
|
300
|
+
lease.other = lead && lead.session !== session ? lead : null;
|
|
301
|
+
}
|
|
273
302
|
// The agents at work; none where the session has none bound (claude -p before it mounts).
|
|
274
303
|
const atWork = agentsAtWork(await $.agent.list().catch(() => []));
|
|
275
304
|
model = {
|
|
@@ -634,6 +663,12 @@ export function register(on) {
|
|
|
634
663
|
}
|
|
635
664
|
if (q.kind === 'question')
|
|
636
665
|
model = { ...model, questions: (model.questions ?? []).filter((x) => x.id !== q.id) };
|
|
666
|
+
// LOOP-11: answered, what the lead waited on no longer holds the loop.
|
|
667
|
+
if (
|
|
668
|
+
q.kind === 'lead' ||
|
|
669
|
+
(q.kind === 'question' && loop.waiting?.questionIds?.includes(q.id))
|
|
670
|
+
)
|
|
671
|
+
loop.waiting = null;
|
|
637
672
|
$.ui.invalidate('ui.render');
|
|
638
673
|
const said = /[.!?]$/.test(text) ? text : `${text}.`;
|
|
639
674
|
$.prompt
|
|
@@ -641,7 +676,9 @@ export function register(on) {
|
|
|
641
676
|
text:
|
|
642
677
|
q.kind === 'question'
|
|
643
678
|
? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
|
|
644
|
-
:
|
|
679
|
+
: q.kind === 'lead'
|
|
680
|
+
? answeredPrompt(q.question, text)
|
|
681
|
+
: `The person answered an open question in ${q.path}, "${q.question}": ${said} Record it in the brief's decision log, take it out of the open questions, and carry on with it.`,
|
|
645
682
|
})
|
|
646
683
|
.catch(() => {});
|
|
647
684
|
await act.reload();
|
|
@@ -1004,17 +1041,184 @@ export function register(on) {
|
|
|
1004
1041
|
loop.steps.push({ at: await $.clock.now(), kind, target, ...(prompt ? { prompt } : {}) });
|
|
1005
1042
|
if (loop.steps.length > 50) loop.steps.shift();
|
|
1006
1043
|
},
|
|
1007
|
-
// LOOP-1: a prompt to the lead, recorded. It isn't awaited: the turn it starts runs on.
|
|
1008
|
-
|
|
1044
|
+
// LOOP-1: a prompt to the lead, recorded. It isn't awaited: the turn it starts runs on. LOOP-11:
|
|
1045
|
+
// the step is kept until that turn ends, so the loop reads the reply to it.
|
|
1046
|
+
submit: async (
|
|
1047
|
+
prompt,
|
|
1048
|
+
kind,
|
|
1049
|
+
target,
|
|
1050
|
+
{ key = `${kind}:${target}`, label = `${kind} ${target}` } = {},
|
|
1051
|
+
) => {
|
|
1009
1052
|
loop.started = true;
|
|
1010
1053
|
loop.proposal = null;
|
|
1054
|
+
loop.sent = { prompt, key, label, kind, target };
|
|
1055
|
+
// LOOP-12: a design story is handed to the lead once; the person designs it with the lead.
|
|
1056
|
+
if (kind === 'design')
|
|
1057
|
+
for (const id of String(target).split(/\s+/).filter(Boolean)) loop.handedDesign.add(id);
|
|
1011
1058
|
await act.record(kind, target, prompt);
|
|
1012
1059
|
$.ui.invalidate('ui.render');
|
|
1013
1060
|
$.prompt.submit({ text: prompt }).catch((err) => {
|
|
1061
|
+
loop.sent = null;
|
|
1014
1062
|
notice = `The loop couldn't prompt the lead: ${err?.message ?? err}`;
|
|
1015
1063
|
$.ui.invalidate('ui.render');
|
|
1016
1064
|
});
|
|
1017
1065
|
},
|
|
1066
|
+
// LOOP-10: this session takes or renews the lease of the one session that runs the loop; another
|
|
1067
|
+
// session holding it keeps it, unless the person moves it here (`take`).
|
|
1068
|
+
lead: async ({ take = false } = {}) => {
|
|
1069
|
+
const now = await $.clock.now();
|
|
1070
|
+
if (lease.mine && !take && now - lease.renewed < LEASE_RENEW_MS) return true;
|
|
1071
|
+
const ran = await $.process
|
|
1072
|
+
.run(['node', cli, 'loop', 'lead', '--session', session, ...(take ? ['--take'] : [])], {
|
|
1073
|
+
cwd,
|
|
1074
|
+
})
|
|
1075
|
+
.catch(() => ({ exitCode: 1 }));
|
|
1076
|
+
const was = lease.mine;
|
|
1077
|
+
lease.mine = ran.exitCode === 0;
|
|
1078
|
+
lease.renewed = now;
|
|
1079
|
+
lease.other = lease.mine ? null : ((await json('loop').catch(() => null))?.lead ?? null);
|
|
1080
|
+
// Taken over by another session: this one stops prompting its lead, and offers nothing more.
|
|
1081
|
+
if (was && !lease.mine) {
|
|
1082
|
+
loop.proposal = null;
|
|
1083
|
+
loop.pending = [];
|
|
1084
|
+
loop.waiting = null;
|
|
1085
|
+
}
|
|
1086
|
+
if (was !== lease.mine || !lease.mine) $.ui.invalidate('ui.render');
|
|
1087
|
+
return lease.mine;
|
|
1088
|
+
},
|
|
1089
|
+
// LOOP-10: the person moves the loop to this session; the other stops prompting its lead.
|
|
1090
|
+
leadHere: async () => {
|
|
1091
|
+
if (!(await act.lead({ take: true }))) {
|
|
1092
|
+
notice = "This session couldn't take the loop: code-kit loop lead failed.";
|
|
1093
|
+
$.ui.invalidate('ui.render');
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
loop.lastKey = null;
|
|
1097
|
+
await act.record('lead', 'this session');
|
|
1098
|
+
$.ui.invalidate('ui.render');
|
|
1099
|
+
await act.step('resume');
|
|
1100
|
+
},
|
|
1101
|
+
// LOOP-11: the lead's last reply, read once before the loop prompts it again. A reply that waits on
|
|
1102
|
+
// the person, or a turn they stopped, holds the loop until they speak; a step the lead didn't take
|
|
1103
|
+
// holds that step while it's still next.
|
|
1104
|
+
hear: async () => {
|
|
1105
|
+
const reply = lastReply;
|
|
1106
|
+
if (!reply || reply.read) return;
|
|
1107
|
+
reply.read = true;
|
|
1108
|
+
const sent = reply.sent;
|
|
1109
|
+
const step = sent
|
|
1110
|
+
? {
|
|
1111
|
+
key: sent.key,
|
|
1112
|
+
label: sent.label,
|
|
1113
|
+
prompt: sent.prompt,
|
|
1114
|
+
stepKind: sent.kind,
|
|
1115
|
+
target: sent.target,
|
|
1116
|
+
}
|
|
1117
|
+
: {};
|
|
1118
|
+
if (reply.aborted) {
|
|
1119
|
+
loop.waiting = { kind: 'stopped', ...step };
|
|
1120
|
+
$.ui.invalidate('ui.render');
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
// It asked through code-kit: those questions are the person's decisions already.
|
|
1124
|
+
const open = ((await json('ask').catch(() => null)) ?? []).filter(
|
|
1125
|
+
(q) => q.state === 'open',
|
|
1126
|
+
);
|
|
1127
|
+
const asked = open.filter((q) => !reply.asked.includes(q.id));
|
|
1128
|
+
if (asked.length) {
|
|
1129
|
+
model = { ...model, questions: open };
|
|
1130
|
+
loop.waiting = {
|
|
1131
|
+
kind: 'person',
|
|
1132
|
+
...step,
|
|
1133
|
+
questionIds: asked.map((q) => q.id),
|
|
1134
|
+
reason: `it asked you ${asked.map((q) => q.id).join(', ')} through code-kit`,
|
|
1135
|
+
};
|
|
1136
|
+
$.ui.invalidate('ui.render');
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (!reply.text.trim()) return;
|
|
1140
|
+
const said = await act.sideCall(
|
|
1141
|
+
outcomePrompt({ prompt: sent?.prompt ?? null, answer: reply.text }),
|
|
1142
|
+
);
|
|
1143
|
+
const heard = said.isAnswered ? readOutcome(said.text) : null;
|
|
1144
|
+
// Spec 05's data: what came of the step, once known.
|
|
1145
|
+
const record = sent ? loop.steps.findLast((x) => x.prompt === sent.prompt) : null;
|
|
1146
|
+
if (record) record.outcome = heard?.outcome ?? 'unread';
|
|
1147
|
+
if (heard?.outcome === 'needs-person')
|
|
1148
|
+
loop.waiting = {
|
|
1149
|
+
kind: 'person',
|
|
1150
|
+
...step,
|
|
1151
|
+
question: heard.question,
|
|
1152
|
+
options: heard.options,
|
|
1153
|
+
reason: heard.reason,
|
|
1154
|
+
};
|
|
1155
|
+
else if (heard?.outcome === 'blocked' && sent)
|
|
1156
|
+
loop.waiting = { kind: 'blocked', ...step, reason: heard.reason };
|
|
1157
|
+
if (loop.waiting) $.ui.invalidate('ui.render');
|
|
1158
|
+
},
|
|
1159
|
+
// LOOP-11: the person's choice from the lead's options, or their own words, told to the lead.
|
|
1160
|
+
answerLead: (line) =>
|
|
1161
|
+
act.sendAnswer({ kind: 'lead', question: line.waiting.question }, line.choice),
|
|
1162
|
+
answerLeadTyped: (line, text) =>
|
|
1163
|
+
act.sendAnswer({ kind: 'lead', question: line.waiting.question }, text),
|
|
1164
|
+
answerLeadText: async (line) => {
|
|
1165
|
+
answering = {
|
|
1166
|
+
kind: 'lead',
|
|
1167
|
+
question: line.waiting.question,
|
|
1168
|
+
options: line.waiting.options ?? [],
|
|
1169
|
+
text: '',
|
|
1170
|
+
error: null,
|
|
1171
|
+
aside: { thread: [], draft: '' },
|
|
1172
|
+
};
|
|
1173
|
+
await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
|
|
1174
|
+
$.ui.invalidate('ui.render');
|
|
1175
|
+
},
|
|
1176
|
+
// DECIDE-4: a question's answer typed in the dialog, as the person's own words.
|
|
1177
|
+
answerTyped: (line, text) => act.sendAnswer({ kind: 'question', ...line.question }, text),
|
|
1178
|
+
// LOOP-11, LOOP-12: the person asks the lead again for the step that held the loop.
|
|
1179
|
+
askAgain: async () => {
|
|
1180
|
+
const w = loop.waiting;
|
|
1181
|
+
if (!w?.prompt || leadTurn) return;
|
|
1182
|
+
loop.waiting = null;
|
|
1183
|
+
loop.asked = { key: w.key, times: 0 };
|
|
1184
|
+
await act.submit(w.prompt, w.stepKind, w.target, { key: w.key, label: w.label });
|
|
1185
|
+
},
|
|
1186
|
+
// LOOP-11: the person lets the loop go on.
|
|
1187
|
+
carryOn: async () => {
|
|
1188
|
+
loop.waiting = null;
|
|
1189
|
+
loop.lastKey = null;
|
|
1190
|
+
$.ui.invalidate('ui.render');
|
|
1191
|
+
await act.step('resume');
|
|
1192
|
+
},
|
|
1193
|
+
// DECIDE-4: the next decision the person settles with a choice, asked in Claude Code's own question
|
|
1194
|
+
// dialog while the lead is idle. Each is asked once: dismissed, it stays in the band and the
|
|
1195
|
+
// Decisions tab. Never in a session another one runs the loop for, nor over what the person types.
|
|
1196
|
+
offer: async () => {
|
|
1197
|
+
if (offering || leadTurn || loop.sent || !where.interactive) return;
|
|
1198
|
+
if (lease.other && !lease.mine) return;
|
|
1199
|
+
const now = await $.clock.now();
|
|
1200
|
+
const choice = cardsNow(now)
|
|
1201
|
+
.map((card) => ({ card, choice: nativeChoice(card) }))
|
|
1202
|
+
.find((c) => c.choice && !offered.has(c.choice.key));
|
|
1203
|
+
if (!choice) return;
|
|
1204
|
+
const box = await $.prompt.read().catch(() => null);
|
|
1205
|
+
if (box?.text?.trim()) return;
|
|
1206
|
+
offered.add(choice.choice.key);
|
|
1207
|
+
offering = true;
|
|
1208
|
+
const { question, header, options, typed } = choice.choice;
|
|
1209
|
+
const answer = await $.ui
|
|
1210
|
+
.ask(question, { options: options.map((o) => o.label), header })
|
|
1211
|
+
.catch(() => null)
|
|
1212
|
+
.finally(() => {
|
|
1213
|
+
offering = false;
|
|
1214
|
+
});
|
|
1215
|
+
if (answer == null) return;
|
|
1216
|
+
const picked = options.find((o) => o.label === answer);
|
|
1217
|
+
if (picked?.act) await act[picked.act](picked.line);
|
|
1218
|
+
else if (!picked && typed && answer.trim())
|
|
1219
|
+
await act[typed](choice.card.line, answer.trim());
|
|
1220
|
+
act.offer().catch(() => {});
|
|
1221
|
+
},
|
|
1018
1222
|
// LOOP-1, LOOP-5, LOOP-7: with the lead idle and no draft, the step `next` reports.
|
|
1019
1223
|
step: async (trigger) => {
|
|
1020
1224
|
const h = harness();
|
|
@@ -1023,6 +1227,11 @@ export function register(on) {
|
|
|
1023
1227
|
if (trigger === 'tick' && !loop.started && !loop.pending.length) return;
|
|
1024
1228
|
const box = await $.prompt.read().catch(() => null);
|
|
1025
1229
|
if (box?.text?.trim()) return;
|
|
1230
|
+
// LOOP-10: one session in the project runs the loop; in any other, the lead is the person's alone.
|
|
1231
|
+
if (!(await act.lead())) return;
|
|
1232
|
+
// LOOP-11: the lead's last reply first: one that waits on the person holds every step.
|
|
1233
|
+
await act.hear();
|
|
1234
|
+
if (loop.waiting && ['person', 'stopped'].includes(loop.waiting.kind)) return;
|
|
1026
1235
|
if (loop.pending.length) {
|
|
1027
1236
|
const { story, prompt } = loop.pending.shift();
|
|
1028
1237
|
// Until the person has chosen how hands-off the loop is, or under propose, a restart is offered
|
|
@@ -1034,6 +1243,7 @@ export function register(on) {
|
|
|
1034
1243
|
label: `Restart ${story}`,
|
|
1035
1244
|
kind: 'restart',
|
|
1036
1245
|
target: story,
|
|
1246
|
+
key: `restart:${story}`,
|
|
1037
1247
|
first: !chosenYet,
|
|
1038
1248
|
};
|
|
1039
1249
|
$.ui.invalidate('ui.render');
|
|
@@ -1080,7 +1290,19 @@ export function register(on) {
|
|
|
1080
1290
|
loop.asked = { key, times: 0 };
|
|
1081
1291
|
if (loop.proposal?.repeated) loop.proposal = null;
|
|
1082
1292
|
}
|
|
1293
|
+
// LOOP-11, LOOP-12: a step the lead didn't take, or designs left to the person and the lead, hold
|
|
1294
|
+
// that step while it's still next; any other step goes on.
|
|
1295
|
+
if (loop.waiting) {
|
|
1296
|
+
if (loop.waiting.key === key) return;
|
|
1297
|
+
loop.waiting = null;
|
|
1298
|
+
$.ui.invalidate('ui.render');
|
|
1299
|
+
}
|
|
1083
1300
|
if (!prompt) return;
|
|
1301
|
+
if (designHandedOver(step, loop.handedDesign)) {
|
|
1302
|
+
loop.waiting = { kind: 'design', key, label, prompt, stepKind: kind, target };
|
|
1303
|
+
$.ui.invalidate('ui.render');
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1084
1306
|
// Never the same step twice while the project stands still.
|
|
1085
1307
|
const print = await act.fingerprint();
|
|
1086
1308
|
if (key === loop.lastKey && print === loop.lastPrint) return;
|
|
@@ -1090,7 +1312,7 @@ export function register(on) {
|
|
|
1090
1312
|
// moving elsewhere (lanes committing) doesn't make a third prompt useful. The person decides.
|
|
1091
1313
|
if (loop.asked.times >= ASK_LIMIT) {
|
|
1092
1314
|
if (loop.proposal?.label !== label) {
|
|
1093
|
-
loop.proposal = { prompt, label, kind, target, repeated: loop.asked.times };
|
|
1315
|
+
loop.proposal = { prompt, label, kind, target, key, repeated: loop.asked.times };
|
|
1094
1316
|
$.ui.invalidate('ui.render');
|
|
1095
1317
|
}
|
|
1096
1318
|
return;
|
|
@@ -1098,12 +1320,12 @@ export function register(on) {
|
|
|
1098
1320
|
// Until the person has chosen how hands-off the loop is, its first step is offered, not taken.
|
|
1099
1321
|
const chosen = (model.check?.harnessSet ?? []).includes('autonomy');
|
|
1100
1322
|
if (h.autonomy === 'propose' || !chosen) {
|
|
1101
|
-
loop.proposal = { prompt, label, kind, target, first: !chosen };
|
|
1323
|
+
loop.proposal = { prompt, label, kind, target, key, first: !chosen };
|
|
1102
1324
|
$.ui.invalidate('ui.render');
|
|
1103
1325
|
return;
|
|
1104
1326
|
}
|
|
1105
1327
|
loop.asked.times += 1;
|
|
1106
|
-
await act.submit(prompt, kind, target);
|
|
1328
|
+
await act.submit(prompt, kind, target, { key, label });
|
|
1107
1329
|
},
|
|
1108
1330
|
// The first offer's answers: the person's choice of autonomy, recorded as theirs, then acted on.
|
|
1109
1331
|
chooseAutonomy: async (value) => {
|
|
@@ -1146,10 +1368,10 @@ export function register(on) {
|
|
|
1146
1368
|
},
|
|
1147
1369
|
go: async () => {
|
|
1148
1370
|
if (!loop.proposal || leadTurn) return;
|
|
1149
|
-
const { prompt, kind, target, repeated } = loop.proposal;
|
|
1371
|
+
const { prompt, kind, target, key, label, repeated } = loop.proposal;
|
|
1150
1372
|
// The person asked again: once more, then it's theirs to offer again if it still doesn't take.
|
|
1151
1373
|
loop.asked.times = repeated ? ASK_LIMIT : loop.asked.times + 1;
|
|
1152
|
-
await act.submit(prompt, kind, target);
|
|
1374
|
+
await act.submit(prompt, kind, target, { key, label });
|
|
1153
1375
|
},
|
|
1154
1376
|
// LOOP-6: pausing stops new steps at once; a turn in progress finishes.
|
|
1155
1377
|
pause: async () => {
|
|
@@ -1405,6 +1627,9 @@ export function register(on) {
|
|
|
1405
1627
|
if (busy) return;
|
|
1406
1628
|
busy = true;
|
|
1407
1629
|
try {
|
|
1630
|
+
// LOOP-10: the lead session renews its lease; DECIDE-4: with the lead idle, a decision waiting.
|
|
1631
|
+
if (lease.mine) await act.lead();
|
|
1632
|
+
if (!leadTurn) act.offer().catch(() => {});
|
|
1408
1633
|
await act.stalls();
|
|
1409
1634
|
await act.animate();
|
|
1410
1635
|
const now = await act.fingerprint();
|
|
@@ -1605,6 +1830,26 @@ export function register(on) {
|
|
|
1605
1830
|
});
|
|
1606
1831
|
});
|
|
1607
1832
|
|
|
1833
|
+
// SET-4: a lane agent starts on the lane model the person set, unless the lead's call names one or
|
|
1834
|
+
// the lane's agent file sets its own.
|
|
1835
|
+
on('agent.spawn', async ($, e, next) => {
|
|
1836
|
+
const setting = harness().agents.lanes.model;
|
|
1837
|
+
const lanes = model.check?.lanes ?? {};
|
|
1838
|
+
if (!setting || e.model || !Object.values(lanes).some((l) => l.agent === e.subagentType))
|
|
1839
|
+
return next(e);
|
|
1840
|
+
const file = where
|
|
1841
|
+
? await $.fs.read(`${where.cwd}/.claude/agents/${e.subagentType}.md`).catch(() => null)
|
|
1842
|
+
: null;
|
|
1843
|
+
const chosen = laneModel({
|
|
1844
|
+
type: e.subagentType,
|
|
1845
|
+
model: e.model,
|
|
1846
|
+
lanes,
|
|
1847
|
+
setting,
|
|
1848
|
+
fileModel: typeof file === 'string' ? agentFileModel(file) : null,
|
|
1849
|
+
});
|
|
1850
|
+
return next(chosen ? { ...e, model: chosen } : e);
|
|
1851
|
+
}).catch(($, e, next) => next(e));
|
|
1852
|
+
|
|
1608
1853
|
// USE-1: each model request's usage, as the request ends; the lead's requests carry no agentId.
|
|
1609
1854
|
on('turn.step', async function* ($, e, next) {
|
|
1610
1855
|
const res = yield* next(e);
|
|
@@ -1629,6 +1874,8 @@ export function register(on) {
|
|
|
1629
1874
|
|
|
1630
1875
|
on('turn.start', async ($, e, next) => {
|
|
1631
1876
|
leadTurn = e.turnId;
|
|
1877
|
+
// LOOP-11: the questions already open, so the loop knows which the lead asked in this turn.
|
|
1878
|
+
turnQuestions = (model.questions ?? []).map((q) => q.id);
|
|
1632
1879
|
$.ui.invalidate('ui.render'); // the lead's lane reads working while its turn runs
|
|
1633
1880
|
if (reviewTurn === 'next') reviewTurn = e.turnId;
|
|
1634
1881
|
return next(e);
|
|
@@ -1650,6 +1897,16 @@ export function register(on) {
|
|
|
1650
1897
|
// as the spike found it must be). The prompt it submits isn't awaited: that turn isn't this hook's.
|
|
1651
1898
|
if (!e.agentId && (!leadTurn || leadTurn === e.turnId)) {
|
|
1652
1899
|
leadTurn = null;
|
|
1900
|
+
// LOOP-11: the lead's reply, read before the loop next prompts it, with the step it answered.
|
|
1901
|
+
lastReply = {
|
|
1902
|
+
turnId: e.turnId,
|
|
1903
|
+
text: e.answer ?? '',
|
|
1904
|
+
aborted: Boolean(e.isAborted),
|
|
1905
|
+
sent: loop.sent,
|
|
1906
|
+
asked: turnQuestions,
|
|
1907
|
+
read: false,
|
|
1908
|
+
};
|
|
1909
|
+
loop.sent = null;
|
|
1653
1910
|
$.ui.invalidate('ui.render');
|
|
1654
1911
|
// What the person asked for goes first, whatever the loop's state.
|
|
1655
1912
|
if (personAsks.length) {
|
|
@@ -1658,15 +1915,47 @@ export function register(on) {
|
|
|
1658
1915
|
return res;
|
|
1659
1916
|
}
|
|
1660
1917
|
await act?.step('turn').catch(() => {});
|
|
1918
|
+
// DECIDE-4: a decision waiting, asked in Claude Code's own dialog; not awaited, the person answers.
|
|
1919
|
+
act?.offer().catch(() => {});
|
|
1661
1920
|
}
|
|
1662
1921
|
return res;
|
|
1663
1922
|
});
|
|
1664
1923
|
|
|
1924
|
+
// LOOP-11: the person speaking to the lead answers what the loop waited on, whatever they said; the
|
|
1925
|
+
// loop reads the lead's reply to them before it prompts again. Designs stay theirs (LOOP-12).
|
|
1926
|
+
on('prompt.submit', async ($, e, next) => {
|
|
1927
|
+
if (['composer', 'bridge'].includes(e.origin?.kind) && loop.waiting?.kind !== 'design') {
|
|
1928
|
+
if (loop.waiting) $.ui.invalidate('ui.render');
|
|
1929
|
+
loop.waiting = null;
|
|
1930
|
+
loop.asked = { key: null, times: 0 };
|
|
1931
|
+
}
|
|
1932
|
+
return next(e);
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
// LOOP-10: the lead session ends: the next session to act takes the loop at once.
|
|
1936
|
+
on('session.end', async ($, e, next) => {
|
|
1937
|
+
if (lease.mine && where) {
|
|
1938
|
+
lease.mine = false;
|
|
1939
|
+
await $.process
|
|
1940
|
+
.run(['node', where.cli, 'loop', 'release', '--session', where.session], { cwd: where.cwd })
|
|
1941
|
+
.catch(() => {});
|
|
1942
|
+
}
|
|
1943
|
+
return next(e);
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1665
1946
|
// What the pane and band show of the loop.
|
|
1666
1947
|
const loopNow = (now) => {
|
|
1667
1948
|
const h = harness();
|
|
1668
1949
|
const state =
|
|
1669
|
-
h.autonomy === 'off'
|
|
1950
|
+
h.autonomy === 'off'
|
|
1951
|
+
? 'off'
|
|
1952
|
+
: lease.other && !lease.mine
|
|
1953
|
+
? 'elsewhere'
|
|
1954
|
+
: loop.done
|
|
1955
|
+
? 'done'
|
|
1956
|
+
: loop.paused
|
|
1957
|
+
? 'paused'
|
|
1958
|
+
: 'running';
|
|
1670
1959
|
const laneNotes = {};
|
|
1671
1960
|
for (const a of activity.values()) {
|
|
1672
1961
|
if (!a.lane) continue;
|
|
@@ -1684,6 +1973,7 @@ export function register(on) {
|
|
|
1684
1973
|
started: loop.started,
|
|
1685
1974
|
last: last ? `${last.kind} ${last.target}` : null,
|
|
1686
1975
|
proposal: loop.proposal,
|
|
1976
|
+
waiting: loop.waiting,
|
|
1687
1977
|
flagged: [...flagged.values()],
|
|
1688
1978
|
doneCount: loop.done?.count ?? 0,
|
|
1689
1979
|
steps: loop.steps,
|
|
@@ -1820,6 +2110,7 @@ export function register(on) {
|
|
|
1820
2110
|
flag: (t) => `${t} stalled again after its restarts, so it came to you.`,
|
|
1821
2111
|
pause: () => 'You paused the loop.',
|
|
1822
2112
|
resume: () => 'You resumed the loop.',
|
|
2113
|
+
lead: () => 'You moved the loop to this session.',
|
|
1823
2114
|
done: (t) => `Every story was done (${t}).`,
|
|
1824
2115
|
};
|
|
1825
2116
|
const journalNow = () => {
|
|
@@ -1829,7 +2120,7 @@ export function register(on) {
|
|
|
1829
2120
|
.map((x) => ({
|
|
1830
2121
|
at: x.at,
|
|
1831
2122
|
kind: 'loop',
|
|
1832
|
-
who: ['pause', 'resume'].includes(x.kind) ? 'person' : 'lead',
|
|
2123
|
+
who: ['pause', 'resume', 'lead'].includes(x.kind) ? 'person' : 'lead',
|
|
1833
2124
|
text: STEP_WORDS[x.kind](x.target),
|
|
1834
2125
|
}));
|
|
1835
2126
|
return [...recorded, ...steps].sort((a, b) => a.at - b.at);
|