@warren-dean/code-kit 0.12.2 → 0.14.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 +21 -0
- package/README.md +113 -40
- package/SECURITY.md +11 -2
- package/bin/code-kit.mjs +87 -23
- package/hooks/guard-bash.mjs +40 -0
- package/hooks/lib/checks.mjs +19 -6
- package/hooks/lib/config.mjs +8 -0
- package/hooks/lib/loop-state.mjs +49 -9
- package/hooks/lib/next.mjs +34 -8
- package/hooks/lib/plan.mjs +4 -2
- package/hooks/lib/queue.mjs +25 -6
- package/hooks/lib/regression.mjs +321 -0
- package/hooks/lib/verify.mjs +53 -7
- package/hooks/mod/decisions.mjs +99 -4
- package/hooks/mod/loop.mjs +75 -1
- package/hooks/mod/mission.mjs +19 -15
- package/hooks/mod/panes.mjs +11 -1
- package/hooks/mod/register.mjs +361 -35
- package/hooks/mod/register.test.ts +508 -70
- package/hooks/mod/view.mjs +161 -28
- package/hooks/stop-check.mjs +1 -1
- package/package.json +1 -1
- package/skills/dispatch/SKILL.md +1 -0
- package/skills/review/SKILL.md +3 -1
- package/skills/spec-design/SKILL.md +1 -1
- package/templates/agent.md +2 -0
- package/templates/claude-md.md +1 -1
- package/templates/plan.md +1 -1
package/hooks/mod/register.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
answerPane,
|
|
22
22
|
approvalAsidePrompt,
|
|
23
23
|
approvePane,
|
|
24
|
+
questionAsidePrompt,
|
|
24
25
|
commandItself,
|
|
25
26
|
band,
|
|
26
27
|
bandLines,
|
|
@@ -50,8 +51,12 @@ import {
|
|
|
50
51
|
toolLine,
|
|
51
52
|
} from './panes.mjs';
|
|
52
53
|
import {
|
|
54
|
+
answeredPrompt,
|
|
55
|
+
designHandedOver,
|
|
53
56
|
leadPrompt,
|
|
54
57
|
nudgeText,
|
|
58
|
+
outcomePrompt,
|
|
59
|
+
readOutcome,
|
|
55
60
|
restartPrompt,
|
|
56
61
|
stallDue,
|
|
57
62
|
stepKey,
|
|
@@ -60,7 +65,7 @@ import {
|
|
|
60
65
|
withoutInFlight,
|
|
61
66
|
} from './loop.mjs';
|
|
62
67
|
import { goalOf, nextText } from './mission.mjs';
|
|
63
|
-
import { contextOf, decisionCards, limitsWhy } from './decisions.mjs';
|
|
68
|
+
import { contextOf, decisionCards, limitsWhy, nativeChoice } from './decisions.mjs';
|
|
64
69
|
import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
|
|
65
70
|
import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
|
|
66
71
|
import { harnessSettings } from '../lib/harness.mjs';
|
|
@@ -110,7 +115,22 @@ const loop = {
|
|
|
110
115
|
nextStep: null, // what `code-kit next` last said, for the band's next row
|
|
111
116
|
done: null, // { count } once every story is done
|
|
112
117
|
doneDismissed: false,
|
|
118
|
+
// LOOP-11: the step the lead's turn was prompted with, until that turn ends: { prompt, key, label, kind, target }
|
|
119
|
+
sent: null,
|
|
120
|
+
// LOOP-11, LOOP-12: what holds the loop: { kind: 'person' | 'stopped' | 'blocked' | 'design', key, label, prompt, question, options, reason }
|
|
121
|
+
waiting: null,
|
|
122
|
+
handedDesign: new Set(), // LOOP-12: design stories handed to the lead this session
|
|
113
123
|
};
|
|
124
|
+
// LOOP-10: whether this session runs the loop, the session that does when another does, and when this
|
|
125
|
+
// one last renewed its lease.
|
|
126
|
+
const lease = { mine: false, other: null, renewed: -Infinity };
|
|
127
|
+
const LEASE_RENEW_MS = 30000;
|
|
128
|
+
// LOOP-11: the lead's last reply, read once before the loop next prompts it: { turnId, text, aborted, sent, read }
|
|
129
|
+
let lastReply = null;
|
|
130
|
+
let turnQuestions = []; // the open questions as the lead's running turn started
|
|
131
|
+
// DECIDE-4: decisions asked in Claude Code's own question dialog this session, and whether one is open.
|
|
132
|
+
const offered = new Set();
|
|
133
|
+
let offering = false;
|
|
114
134
|
/** LOOP-8: the times in a row the lead is prompted with one step before the person is asked. */
|
|
115
135
|
const ASK_LIMIT = 2;
|
|
116
136
|
let leadTurn = null; // the lead's running turn, if any
|
|
@@ -206,7 +226,7 @@ export function register(on) {
|
|
|
206
226
|
const cli = `${$.plugin.root}/bin/code-kit.mjs`;
|
|
207
227
|
const cwd = e.cwd ?? (await $.session.cwd());
|
|
208
228
|
const session = await $.session.id();
|
|
209
|
-
where = { cli, cwd, interactive: e.isInteractive !== false };
|
|
229
|
+
where = { cli, cwd, session, interactive: e.isInteractive !== false };
|
|
210
230
|
// The held call's answer, for the `code-kit hold` it waits in.
|
|
211
231
|
const answerHeld = (id, answer) =>
|
|
212
232
|
$.process.run(['node', cli, 'hold', id, '--answer', answer], { cwd });
|
|
@@ -216,9 +236,12 @@ export function register(on) {
|
|
|
216
236
|
};
|
|
217
237
|
// What a CLI run said, in a line: why it refused, for a notice.
|
|
218
238
|
const firstLine = (ran) => (ran.stderr || ran.stdout).trim().split('\n')[0];
|
|
219
|
-
// A session starts with the loop running: a pause recorded by an earlier one no longer holds.
|
|
220
|
-
|
|
239
|
+
// A session starts with the loop running: a pause recorded by an earlier one no longer holds. A pause
|
|
240
|
+
// by a session that still runs the loop (LOOP-10) is that session's, and stays.
|
|
241
|
+
const recorded = await json('loop').catch(() => null);
|
|
242
|
+
if (recorded?.paused && (!recorded.lead || recorded.lead.session === session))
|
|
221
243
|
await $.process.run(['node', cli, 'loop', 'resume'], { cwd }).catch(() => {});
|
|
244
|
+
lease.other = recorded?.lead && recorded.lead.session !== session ? recorded.lead : null;
|
|
222
245
|
const stamp = async (path) => {
|
|
223
246
|
const s = await $.fs.stat(`${cwd}/${path}`).catch(() => null);
|
|
224
247
|
return s ? `${path}@${s.mtimeMs}` : `${path}-`;
|
|
@@ -269,6 +292,11 @@ export function register(on) {
|
|
|
269
292
|
boardSynced = at;
|
|
270
293
|
$.process.run(['node', cli, 'team', 'board'], { cwd, timeoutMs: 120000 }).catch(() => {});
|
|
271
294
|
}
|
|
295
|
+
// LOOP-10: while another session runs the loop, whether it still does (its lease lapses as it ends).
|
|
296
|
+
if (!lease.mine) {
|
|
297
|
+
const lead = (await json('loop').catch(() => null))?.lead ?? null;
|
|
298
|
+
lease.other = lead && lead.session !== session ? lead : null;
|
|
299
|
+
}
|
|
272
300
|
// The agents at work; none where the session has none bound (claude -p before it mounts).
|
|
273
301
|
const atWork = agentsAtWork(await $.agent.list().catch(() => []));
|
|
274
302
|
model = {
|
|
@@ -564,12 +592,24 @@ export function register(on) {
|
|
|
564
592
|
answerWith: (line) => act.sendAnswer({ kind: 'question', ...line.question }, line.choice),
|
|
565
593
|
// ASK-2, ASK-4: Answer… opens a box for the person's own words.
|
|
566
594
|
answer: async (line) => {
|
|
567
|
-
answering = {
|
|
595
|
+
answering = {
|
|
596
|
+
kind: 'question',
|
|
597
|
+
...line.question,
|
|
598
|
+
text: '',
|
|
599
|
+
error: null,
|
|
600
|
+
aside: { thread: [], draft: '' },
|
|
601
|
+
};
|
|
568
602
|
await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
|
|
569
603
|
$.ui.invalidate('ui.render');
|
|
570
604
|
},
|
|
571
605
|
answerBrief: async (line) => {
|
|
572
|
-
answering = {
|
|
606
|
+
answering = {
|
|
607
|
+
kind: 'brief',
|
|
608
|
+
...line.brief,
|
|
609
|
+
text: '',
|
|
610
|
+
error: null,
|
|
611
|
+
aside: { thread: [], draft: '' },
|
|
612
|
+
};
|
|
573
613
|
await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
|
|
574
614
|
$.ui.invalidate('ui.render');
|
|
575
615
|
},
|
|
@@ -613,17 +653,32 @@ export function register(on) {
|
|
|
613
653
|
}
|
|
614
654
|
}
|
|
615
655
|
notice = null;
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
text:
|
|
619
|
-
q.kind === 'question'
|
|
620
|
-
? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
|
|
621
|
-
: `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.`,
|
|
622
|
-
});
|
|
656
|
+
// Answered, the decision goes at once; the lead hears it as a turn of its own once it's idle, which
|
|
657
|
+
// can be long after (so the prompt isn't waited on).
|
|
623
658
|
if (q.kind === 'brief') {
|
|
624
659
|
briefLater.add(q.question);
|
|
625
660
|
model = { ...model, brief: (model.brief ?? []).filter((b) => b.question !== q.question) };
|
|
626
661
|
}
|
|
662
|
+
if (q.kind === 'question')
|
|
663
|
+
model = { ...model, questions: (model.questions ?? []).filter((x) => x.id !== q.id) };
|
|
664
|
+
// LOOP-11: answered, what the lead waited on no longer holds the loop.
|
|
665
|
+
if (
|
|
666
|
+
q.kind === 'lead' ||
|
|
667
|
+
(q.kind === 'question' && loop.waiting?.questionIds?.includes(q.id))
|
|
668
|
+
)
|
|
669
|
+
loop.waiting = null;
|
|
670
|
+
$.ui.invalidate('ui.render');
|
|
671
|
+
const said = /[.!?]$/.test(text) ? text : `${text}.`;
|
|
672
|
+
$.prompt
|
|
673
|
+
.submit({
|
|
674
|
+
text:
|
|
675
|
+
q.kind === 'question'
|
|
676
|
+
? `The person answered ${q.id} (${q.question}): ${said}${q.blocks ? ` ${q.story} no longer waits on them.` : ''} Carry on with it.`
|
|
677
|
+
: q.kind === 'lead'
|
|
678
|
+
? answeredPrompt(q.question, text)
|
|
679
|
+
: `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.`,
|
|
680
|
+
})
|
|
681
|
+
.catch(() => {});
|
|
627
682
|
await act.reload();
|
|
628
683
|
return true;
|
|
629
684
|
},
|
|
@@ -634,16 +689,20 @@ export function register(on) {
|
|
|
634
689
|
deniedDone: async (line) => {
|
|
635
690
|
denied.delete(line.denial.id);
|
|
636
691
|
$.ui.invalidate('ui.render');
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
692
|
+
$.prompt
|
|
693
|
+
.submit({
|
|
694
|
+
text: `The person ran it themselves, or allowed it: ${line.denial.command ?? line.denial.what} (Claude Code had refused it for ${line.denial.who}). Check it worked, and carry on.`,
|
|
695
|
+
})
|
|
696
|
+
.catch(() => {});
|
|
640
697
|
},
|
|
641
698
|
deniedNo: async (line) => {
|
|
642
699
|
denied.delete(line.denial.id);
|
|
643
700
|
$.ui.invalidate('ui.render');
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
701
|
+
$.prompt
|
|
702
|
+
.submit({
|
|
703
|
+
text: `The person won't run ${line.denial.command ?? line.denial.what} now (Claude Code refused it for ${line.denial.who}). Don't try to get around it: carry on with other work, and if a story can't go on without it, ask with code-kit ask --blocks.`,
|
|
704
|
+
})
|
|
705
|
+
.catch(() => {});
|
|
647
706
|
},
|
|
648
707
|
// REPORT-3: the person sends a drafted report to code-kit's GitHub, or discards it.
|
|
649
708
|
sendReport: async (line) => {
|
|
@@ -675,6 +734,40 @@ export function register(on) {
|
|
|
675
734
|
heldId: line.held.id,
|
|
676
735
|
outside: line.held.outside ?? null,
|
|
677
736
|
}),
|
|
737
|
+
// ACT-5: a side call over the lead's own conversation (it shares the prompt cache, and nothing is
|
|
738
|
+
// added to the conversation); before the lead's first reply, from the prompt's facts alone.
|
|
739
|
+
sideCall: async (prompt) => {
|
|
740
|
+
const failed = (err) => ({ isAnswered: false, reason: String(err?.message ?? err) });
|
|
741
|
+
const reply = await $.model.fork({ prompt }).catch(failed);
|
|
742
|
+
if (reply.isAnswered || reply.reason !== 'nothing-to-fork') return reply;
|
|
743
|
+
return $.model.complete({ model: 'sonnet', prompt, maxTokens: 800 }).catch(failed);
|
|
744
|
+
},
|
|
745
|
+
// ASK-5: a question about a question, asked in the answer box before answering.
|
|
746
|
+
askAnswer: async (question) => {
|
|
747
|
+
const q = question.trim();
|
|
748
|
+
if (!answering || !q) return;
|
|
749
|
+
const asked = answering;
|
|
750
|
+
const turn = { question: q, pending: true };
|
|
751
|
+
const thread = asked.aside.thread;
|
|
752
|
+
answering = { ...asked, aside: { thread: [...thread, turn], draft: '' } };
|
|
753
|
+
$.ui.invalidate('ui.render');
|
|
754
|
+
const reply = await act.sideCall(questionAsidePrompt({ q: asked, thread, question: q }));
|
|
755
|
+
if (!answering || answering.question !== asked.question) return;
|
|
756
|
+
const done = reply.isAnswered
|
|
757
|
+
? { question: q, answer: reply.text }
|
|
758
|
+
: {
|
|
759
|
+
question: q,
|
|
760
|
+
error: `No answer: ${reply.reason}${reply.status ? ` (${reply.status})` : ''}`,
|
|
761
|
+
};
|
|
762
|
+
answering = {
|
|
763
|
+
...answering,
|
|
764
|
+
aside: {
|
|
765
|
+
...answering.aside,
|
|
766
|
+
thread: answering.aside.thread.map((t) => (t === turn ? done : t)),
|
|
767
|
+
},
|
|
768
|
+
};
|
|
769
|
+
$.ui.invalidate('ui.render');
|
|
770
|
+
},
|
|
678
771
|
// ACT-5: a question about the approval, answered in its pane by a side call over the lead's own
|
|
679
772
|
// conversation (it shares the prompt cache, and nothing is added to the conversation); before the
|
|
680
773
|
// lead's first reply, from code-kit's facts alone.
|
|
@@ -694,13 +787,7 @@ export function register(on) {
|
|
|
694
787
|
thread,
|
|
695
788
|
question: q,
|
|
696
789
|
});
|
|
697
|
-
|
|
698
|
-
.fork({ prompt })
|
|
699
|
-
.catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
|
|
700
|
-
if (!reply.isAnswered && reply.reason === 'nothing-to-fork')
|
|
701
|
-
reply = await $.model
|
|
702
|
-
.complete({ model: 'sonnet', prompt, maxTokens: 800 })
|
|
703
|
-
.catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
|
|
790
|
+
const reply = await act.sideCall(prompt);
|
|
704
791
|
// Answered for this approval only: a pane closed or moved on meanwhile keeps what it has.
|
|
705
792
|
if (!approving || approving.request !== asked.request) return;
|
|
706
793
|
const done = reply.isAnswered
|
|
@@ -952,17 +1039,184 @@ export function register(on) {
|
|
|
952
1039
|
loop.steps.push({ at: await $.clock.now(), kind, target, ...(prompt ? { prompt } : {}) });
|
|
953
1040
|
if (loop.steps.length > 50) loop.steps.shift();
|
|
954
1041
|
},
|
|
955
|
-
// LOOP-1: a prompt to the lead, recorded. It isn't awaited: the turn it starts runs on.
|
|
956
|
-
|
|
1042
|
+
// LOOP-1: a prompt to the lead, recorded. It isn't awaited: the turn it starts runs on. LOOP-11:
|
|
1043
|
+
// the step is kept until that turn ends, so the loop reads the reply to it.
|
|
1044
|
+
submit: async (
|
|
1045
|
+
prompt,
|
|
1046
|
+
kind,
|
|
1047
|
+
target,
|
|
1048
|
+
{ key = `${kind}:${target}`, label = `${kind} ${target}` } = {},
|
|
1049
|
+
) => {
|
|
957
1050
|
loop.started = true;
|
|
958
1051
|
loop.proposal = null;
|
|
1052
|
+
loop.sent = { prompt, key, label, kind, target };
|
|
1053
|
+
// LOOP-12: a design story is handed to the lead once; the person designs it with the lead.
|
|
1054
|
+
if (kind === 'design')
|
|
1055
|
+
for (const id of String(target).split(/\s+/).filter(Boolean)) loop.handedDesign.add(id);
|
|
959
1056
|
await act.record(kind, target, prompt);
|
|
960
1057
|
$.ui.invalidate('ui.render');
|
|
961
1058
|
$.prompt.submit({ text: prompt }).catch((err) => {
|
|
1059
|
+
loop.sent = null;
|
|
962
1060
|
notice = `The loop couldn't prompt the lead: ${err?.message ?? err}`;
|
|
963
1061
|
$.ui.invalidate('ui.render');
|
|
964
1062
|
});
|
|
965
1063
|
},
|
|
1064
|
+
// LOOP-10: this session takes or renews the lease of the one session that runs the loop; another
|
|
1065
|
+
// session holding it keeps it, unless the person moves it here (`take`).
|
|
1066
|
+
lead: async ({ take = false } = {}) => {
|
|
1067
|
+
const now = await $.clock.now();
|
|
1068
|
+
if (lease.mine && !take && now - lease.renewed < LEASE_RENEW_MS) return true;
|
|
1069
|
+
const ran = await $.process
|
|
1070
|
+
.run(['node', cli, 'loop', 'lead', '--session', session, ...(take ? ['--take'] : [])], {
|
|
1071
|
+
cwd,
|
|
1072
|
+
})
|
|
1073
|
+
.catch(() => ({ exitCode: 1 }));
|
|
1074
|
+
const was = lease.mine;
|
|
1075
|
+
lease.mine = ran.exitCode === 0;
|
|
1076
|
+
lease.renewed = now;
|
|
1077
|
+
lease.other = lease.mine ? null : ((await json('loop').catch(() => null))?.lead ?? null);
|
|
1078
|
+
// Taken over by another session: this one stops prompting its lead, and offers nothing more.
|
|
1079
|
+
if (was && !lease.mine) {
|
|
1080
|
+
loop.proposal = null;
|
|
1081
|
+
loop.pending = [];
|
|
1082
|
+
loop.waiting = null;
|
|
1083
|
+
}
|
|
1084
|
+
if (was !== lease.mine || !lease.mine) $.ui.invalidate('ui.render');
|
|
1085
|
+
return lease.mine;
|
|
1086
|
+
},
|
|
1087
|
+
// LOOP-10: the person moves the loop to this session; the other stops prompting its lead.
|
|
1088
|
+
leadHere: async () => {
|
|
1089
|
+
if (!(await act.lead({ take: true }))) {
|
|
1090
|
+
notice = "This session couldn't take the loop: code-kit loop lead failed.";
|
|
1091
|
+
$.ui.invalidate('ui.render');
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
loop.lastKey = null;
|
|
1095
|
+
await act.record('lead', 'this session');
|
|
1096
|
+
$.ui.invalidate('ui.render');
|
|
1097
|
+
await act.step('resume');
|
|
1098
|
+
},
|
|
1099
|
+
// LOOP-11: the lead's last reply, read once before the loop prompts it again. A reply that waits on
|
|
1100
|
+
// the person, or a turn they stopped, holds the loop until they speak; a step the lead didn't take
|
|
1101
|
+
// holds that step while it's still next.
|
|
1102
|
+
hear: async () => {
|
|
1103
|
+
const reply = lastReply;
|
|
1104
|
+
if (!reply || reply.read) return;
|
|
1105
|
+
reply.read = true;
|
|
1106
|
+
const sent = reply.sent;
|
|
1107
|
+
const step = sent
|
|
1108
|
+
? {
|
|
1109
|
+
key: sent.key,
|
|
1110
|
+
label: sent.label,
|
|
1111
|
+
prompt: sent.prompt,
|
|
1112
|
+
stepKind: sent.kind,
|
|
1113
|
+
target: sent.target,
|
|
1114
|
+
}
|
|
1115
|
+
: {};
|
|
1116
|
+
if (reply.aborted) {
|
|
1117
|
+
loop.waiting = { kind: 'stopped', ...step };
|
|
1118
|
+
$.ui.invalidate('ui.render');
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
// It asked through code-kit: those questions are the person's decisions already.
|
|
1122
|
+
const open = ((await json('ask').catch(() => null)) ?? []).filter(
|
|
1123
|
+
(q) => q.state === 'open',
|
|
1124
|
+
);
|
|
1125
|
+
const asked = open.filter((q) => !reply.asked.includes(q.id));
|
|
1126
|
+
if (asked.length) {
|
|
1127
|
+
model = { ...model, questions: open };
|
|
1128
|
+
loop.waiting = {
|
|
1129
|
+
kind: 'person',
|
|
1130
|
+
...step,
|
|
1131
|
+
questionIds: asked.map((q) => q.id),
|
|
1132
|
+
reason: `it asked you ${asked.map((q) => q.id).join(', ')} through code-kit`,
|
|
1133
|
+
};
|
|
1134
|
+
$.ui.invalidate('ui.render');
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (!reply.text.trim()) return;
|
|
1138
|
+
const said = await act.sideCall(
|
|
1139
|
+
outcomePrompt({ prompt: sent?.prompt ?? null, answer: reply.text }),
|
|
1140
|
+
);
|
|
1141
|
+
const heard = said.isAnswered ? readOutcome(said.text) : null;
|
|
1142
|
+
// Spec 05's data: what came of the step, once known.
|
|
1143
|
+
const record = sent ? loop.steps.findLast((x) => x.prompt === sent.prompt) : null;
|
|
1144
|
+
if (record) record.outcome = heard?.outcome ?? 'unread';
|
|
1145
|
+
if (heard?.outcome === 'needs-person')
|
|
1146
|
+
loop.waiting = {
|
|
1147
|
+
kind: 'person',
|
|
1148
|
+
...step,
|
|
1149
|
+
question: heard.question,
|
|
1150
|
+
options: heard.options,
|
|
1151
|
+
reason: heard.reason,
|
|
1152
|
+
};
|
|
1153
|
+
else if (heard?.outcome === 'blocked' && sent)
|
|
1154
|
+
loop.waiting = { kind: 'blocked', ...step, reason: heard.reason };
|
|
1155
|
+
if (loop.waiting) $.ui.invalidate('ui.render');
|
|
1156
|
+
},
|
|
1157
|
+
// LOOP-11: the person's choice from the lead's options, or their own words, told to the lead.
|
|
1158
|
+
answerLead: (line) =>
|
|
1159
|
+
act.sendAnswer({ kind: 'lead', question: line.waiting.question }, line.choice),
|
|
1160
|
+
answerLeadTyped: (line, text) =>
|
|
1161
|
+
act.sendAnswer({ kind: 'lead', question: line.waiting.question }, text),
|
|
1162
|
+
answerLeadText: async (line) => {
|
|
1163
|
+
answering = {
|
|
1164
|
+
kind: 'lead',
|
|
1165
|
+
question: line.waiting.question,
|
|
1166
|
+
options: line.waiting.options ?? [],
|
|
1167
|
+
text: '',
|
|
1168
|
+
error: null,
|
|
1169
|
+
aside: { thread: [], draft: '' },
|
|
1170
|
+
};
|
|
1171
|
+
await $.ui.open({ id: ANSWER_ID, title: 'Answer', focus: true, closeOnEscape: true });
|
|
1172
|
+
$.ui.invalidate('ui.render');
|
|
1173
|
+
},
|
|
1174
|
+
// DECIDE-4: a question's answer typed in the dialog, as the person's own words.
|
|
1175
|
+
answerTyped: (line, text) => act.sendAnswer({ kind: 'question', ...line.question }, text),
|
|
1176
|
+
// LOOP-11, LOOP-12: the person asks the lead again for the step that held the loop.
|
|
1177
|
+
askAgain: async () => {
|
|
1178
|
+
const w = loop.waiting;
|
|
1179
|
+
if (!w?.prompt || leadTurn) return;
|
|
1180
|
+
loop.waiting = null;
|
|
1181
|
+
loop.asked = { key: w.key, times: 0 };
|
|
1182
|
+
await act.submit(w.prompt, w.stepKind, w.target, { key: w.key, label: w.label });
|
|
1183
|
+
},
|
|
1184
|
+
// LOOP-11: the person lets the loop go on.
|
|
1185
|
+
carryOn: async () => {
|
|
1186
|
+
loop.waiting = null;
|
|
1187
|
+
loop.lastKey = null;
|
|
1188
|
+
$.ui.invalidate('ui.render');
|
|
1189
|
+
await act.step('resume');
|
|
1190
|
+
},
|
|
1191
|
+
// DECIDE-4: the next decision the person settles with a choice, asked in Claude Code's own question
|
|
1192
|
+
// dialog while the lead is idle. Each is asked once: dismissed, it stays in the band and the
|
|
1193
|
+
// Decisions tab. Never in a session another one runs the loop for, nor over what the person types.
|
|
1194
|
+
offer: async () => {
|
|
1195
|
+
if (offering || leadTurn || loop.sent || !where.interactive) return;
|
|
1196
|
+
if (lease.other && !lease.mine) return;
|
|
1197
|
+
const now = await $.clock.now();
|
|
1198
|
+
const choice = cardsNow(now)
|
|
1199
|
+
.map((card) => ({ card, choice: nativeChoice(card) }))
|
|
1200
|
+
.find((c) => c.choice && !offered.has(c.choice.key));
|
|
1201
|
+
if (!choice) return;
|
|
1202
|
+
const box = await $.prompt.read().catch(() => null);
|
|
1203
|
+
if (box?.text?.trim()) return;
|
|
1204
|
+
offered.add(choice.choice.key);
|
|
1205
|
+
offering = true;
|
|
1206
|
+
const { question, header, options, typed } = choice.choice;
|
|
1207
|
+
const answer = await $.ui
|
|
1208
|
+
.ask(question, { options: options.map((o) => o.label), header })
|
|
1209
|
+
.catch(() => null)
|
|
1210
|
+
.finally(() => {
|
|
1211
|
+
offering = false;
|
|
1212
|
+
});
|
|
1213
|
+
if (answer == null) return;
|
|
1214
|
+
const picked = options.find((o) => o.label === answer);
|
|
1215
|
+
if (picked?.act) await act[picked.act](picked.line);
|
|
1216
|
+
else if (!picked && typed && answer.trim())
|
|
1217
|
+
await act[typed](choice.card.line, answer.trim());
|
|
1218
|
+
act.offer().catch(() => {});
|
|
1219
|
+
},
|
|
966
1220
|
// LOOP-1, LOOP-5, LOOP-7: with the lead idle and no draft, the step `next` reports.
|
|
967
1221
|
step: async (trigger) => {
|
|
968
1222
|
const h = harness();
|
|
@@ -971,6 +1225,11 @@ export function register(on) {
|
|
|
971
1225
|
if (trigger === 'tick' && !loop.started && !loop.pending.length) return;
|
|
972
1226
|
const box = await $.prompt.read().catch(() => null);
|
|
973
1227
|
if (box?.text?.trim()) return;
|
|
1228
|
+
// LOOP-10: one session in the project runs the loop; in any other, the lead is the person's alone.
|
|
1229
|
+
if (!(await act.lead())) return;
|
|
1230
|
+
// LOOP-11: the lead's last reply first: one that waits on the person holds every step.
|
|
1231
|
+
await act.hear();
|
|
1232
|
+
if (loop.waiting && ['person', 'stopped'].includes(loop.waiting.kind)) return;
|
|
974
1233
|
if (loop.pending.length) {
|
|
975
1234
|
const { story, prompt } = loop.pending.shift();
|
|
976
1235
|
// Until the person has chosen how hands-off the loop is, or under propose, a restart is offered
|
|
@@ -982,6 +1241,7 @@ export function register(on) {
|
|
|
982
1241
|
label: `Restart ${story}`,
|
|
983
1242
|
kind: 'restart',
|
|
984
1243
|
target: story,
|
|
1244
|
+
key: `restart:${story}`,
|
|
985
1245
|
first: !chosenYet,
|
|
986
1246
|
};
|
|
987
1247
|
$.ui.invalidate('ui.render');
|
|
@@ -1028,7 +1288,19 @@ export function register(on) {
|
|
|
1028
1288
|
loop.asked = { key, times: 0 };
|
|
1029
1289
|
if (loop.proposal?.repeated) loop.proposal = null;
|
|
1030
1290
|
}
|
|
1291
|
+
// LOOP-11, LOOP-12: a step the lead didn't take, or designs left to the person and the lead, hold
|
|
1292
|
+
// that step while it's still next; any other step goes on.
|
|
1293
|
+
if (loop.waiting) {
|
|
1294
|
+
if (loop.waiting.key === key) return;
|
|
1295
|
+
loop.waiting = null;
|
|
1296
|
+
$.ui.invalidate('ui.render');
|
|
1297
|
+
}
|
|
1031
1298
|
if (!prompt) return;
|
|
1299
|
+
if (designHandedOver(step, loop.handedDesign)) {
|
|
1300
|
+
loop.waiting = { kind: 'design', key, label, prompt, stepKind: kind, target };
|
|
1301
|
+
$.ui.invalidate('ui.render');
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1032
1304
|
// Never the same step twice while the project stands still.
|
|
1033
1305
|
const print = await act.fingerprint();
|
|
1034
1306
|
if (key === loop.lastKey && print === loop.lastPrint) return;
|
|
@@ -1038,7 +1310,7 @@ export function register(on) {
|
|
|
1038
1310
|
// moving elsewhere (lanes committing) doesn't make a third prompt useful. The person decides.
|
|
1039
1311
|
if (loop.asked.times >= ASK_LIMIT) {
|
|
1040
1312
|
if (loop.proposal?.label !== label) {
|
|
1041
|
-
loop.proposal = { prompt, label, kind, target, repeated: loop.asked.times };
|
|
1313
|
+
loop.proposal = { prompt, label, kind, target, key, repeated: loop.asked.times };
|
|
1042
1314
|
$.ui.invalidate('ui.render');
|
|
1043
1315
|
}
|
|
1044
1316
|
return;
|
|
@@ -1046,12 +1318,12 @@ export function register(on) {
|
|
|
1046
1318
|
// Until the person has chosen how hands-off the loop is, its first step is offered, not taken.
|
|
1047
1319
|
const chosen = (model.check?.harnessSet ?? []).includes('autonomy');
|
|
1048
1320
|
if (h.autonomy === 'propose' || !chosen) {
|
|
1049
|
-
loop.proposal = { prompt, label, kind, target, first: !chosen };
|
|
1321
|
+
loop.proposal = { prompt, label, kind, target, key, first: !chosen };
|
|
1050
1322
|
$.ui.invalidate('ui.render');
|
|
1051
1323
|
return;
|
|
1052
1324
|
}
|
|
1053
1325
|
loop.asked.times += 1;
|
|
1054
|
-
await act.submit(prompt, kind, target);
|
|
1326
|
+
await act.submit(prompt, kind, target, { key, label });
|
|
1055
1327
|
},
|
|
1056
1328
|
// The first offer's answers: the person's choice of autonomy, recorded as theirs, then acted on.
|
|
1057
1329
|
chooseAutonomy: async (value) => {
|
|
@@ -1094,10 +1366,10 @@ export function register(on) {
|
|
|
1094
1366
|
},
|
|
1095
1367
|
go: async () => {
|
|
1096
1368
|
if (!loop.proposal || leadTurn) return;
|
|
1097
|
-
const { prompt, kind, target, repeated } = loop.proposal;
|
|
1369
|
+
const { prompt, kind, target, key, label, repeated } = loop.proposal;
|
|
1098
1370
|
// The person asked again: once more, then it's theirs to offer again if it still doesn't take.
|
|
1099
1371
|
loop.asked.times = repeated ? ASK_LIMIT : loop.asked.times + 1;
|
|
1100
|
-
await act.submit(prompt, kind, target);
|
|
1372
|
+
await act.submit(prompt, kind, target, { key, label });
|
|
1101
1373
|
},
|
|
1102
1374
|
// LOOP-6: pausing stops new steps at once; a turn in progress finishes.
|
|
1103
1375
|
pause: async () => {
|
|
@@ -1353,6 +1625,9 @@ export function register(on) {
|
|
|
1353
1625
|
if (busy) return;
|
|
1354
1626
|
busy = true;
|
|
1355
1627
|
try {
|
|
1628
|
+
// LOOP-10: the lead session renews its lease; DECIDE-4: with the lead idle, a decision waiting.
|
|
1629
|
+
if (lease.mine) await act.lead();
|
|
1630
|
+
if (!leadTurn) act.offer().catch(() => {});
|
|
1356
1631
|
await act.stalls();
|
|
1357
1632
|
await act.animate();
|
|
1358
1633
|
const now = await act.fingerprint();
|
|
@@ -1577,6 +1852,8 @@ export function register(on) {
|
|
|
1577
1852
|
|
|
1578
1853
|
on('turn.start', async ($, e, next) => {
|
|
1579
1854
|
leadTurn = e.turnId;
|
|
1855
|
+
// LOOP-11: the questions already open, so the loop knows which the lead asked in this turn.
|
|
1856
|
+
turnQuestions = (model.questions ?? []).map((q) => q.id);
|
|
1580
1857
|
$.ui.invalidate('ui.render'); // the lead's lane reads working while its turn runs
|
|
1581
1858
|
if (reviewTurn === 'next') reviewTurn = e.turnId;
|
|
1582
1859
|
return next(e);
|
|
@@ -1598,6 +1875,16 @@ export function register(on) {
|
|
|
1598
1875
|
// as the spike found it must be). The prompt it submits isn't awaited: that turn isn't this hook's.
|
|
1599
1876
|
if (!e.agentId && (!leadTurn || leadTurn === e.turnId)) {
|
|
1600
1877
|
leadTurn = null;
|
|
1878
|
+
// LOOP-11: the lead's reply, read before the loop next prompts it, with the step it answered.
|
|
1879
|
+
lastReply = {
|
|
1880
|
+
turnId: e.turnId,
|
|
1881
|
+
text: e.answer ?? '',
|
|
1882
|
+
aborted: Boolean(e.isAborted),
|
|
1883
|
+
sent: loop.sent,
|
|
1884
|
+
asked: turnQuestions,
|
|
1885
|
+
read: false,
|
|
1886
|
+
};
|
|
1887
|
+
loop.sent = null;
|
|
1601
1888
|
$.ui.invalidate('ui.render');
|
|
1602
1889
|
// What the person asked for goes first, whatever the loop's state.
|
|
1603
1890
|
if (personAsks.length) {
|
|
@@ -1606,15 +1893,47 @@ export function register(on) {
|
|
|
1606
1893
|
return res;
|
|
1607
1894
|
}
|
|
1608
1895
|
await act?.step('turn').catch(() => {});
|
|
1896
|
+
// DECIDE-4: a decision waiting, asked in Claude Code's own dialog; not awaited, the person answers.
|
|
1897
|
+
act?.offer().catch(() => {});
|
|
1609
1898
|
}
|
|
1610
1899
|
return res;
|
|
1611
1900
|
});
|
|
1612
1901
|
|
|
1902
|
+
// LOOP-11: the person speaking to the lead answers what the loop waited on, whatever they said; the
|
|
1903
|
+
// loop reads the lead's reply to them before it prompts again. Designs stay theirs (LOOP-12).
|
|
1904
|
+
on('prompt.submit', async ($, e, next) => {
|
|
1905
|
+
if (['composer', 'bridge'].includes(e.origin?.kind) && loop.waiting?.kind !== 'design') {
|
|
1906
|
+
if (loop.waiting) $.ui.invalidate('ui.render');
|
|
1907
|
+
loop.waiting = null;
|
|
1908
|
+
loop.asked = { key: null, times: 0 };
|
|
1909
|
+
}
|
|
1910
|
+
return next(e);
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
// LOOP-10: the lead session ends: the next session to act takes the loop at once.
|
|
1914
|
+
on('session.end', async ($, e, next) => {
|
|
1915
|
+
if (lease.mine && where) {
|
|
1916
|
+
lease.mine = false;
|
|
1917
|
+
await $.process
|
|
1918
|
+
.run(['node', where.cli, 'loop', 'release', '--session', where.session], { cwd: where.cwd })
|
|
1919
|
+
.catch(() => {});
|
|
1920
|
+
}
|
|
1921
|
+
return next(e);
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1613
1924
|
// What the pane and band show of the loop.
|
|
1614
1925
|
const loopNow = (now) => {
|
|
1615
1926
|
const h = harness();
|
|
1616
1927
|
const state =
|
|
1617
|
-
h.autonomy === 'off'
|
|
1928
|
+
h.autonomy === 'off'
|
|
1929
|
+
? 'off'
|
|
1930
|
+
: lease.other && !lease.mine
|
|
1931
|
+
? 'elsewhere'
|
|
1932
|
+
: loop.done
|
|
1933
|
+
? 'done'
|
|
1934
|
+
: loop.paused
|
|
1935
|
+
? 'paused'
|
|
1936
|
+
: 'running';
|
|
1618
1937
|
const laneNotes = {};
|
|
1619
1938
|
for (const a of activity.values()) {
|
|
1620
1939
|
if (!a.lane) continue;
|
|
@@ -1632,6 +1951,7 @@ export function register(on) {
|
|
|
1632
1951
|
started: loop.started,
|
|
1633
1952
|
last: last ? `${last.kind} ${last.target}` : null,
|
|
1634
1953
|
proposal: loop.proposal,
|
|
1954
|
+
waiting: loop.waiting,
|
|
1635
1955
|
flagged: [...flagged.values()],
|
|
1636
1956
|
doneCount: loop.done?.count ?? 0,
|
|
1637
1957
|
steps: loop.steps,
|
|
@@ -1768,6 +2088,7 @@ export function register(on) {
|
|
|
1768
2088
|
flag: (t) => `${t} stalled again after its restarts, so it came to you.`,
|
|
1769
2089
|
pause: () => 'You paused the loop.',
|
|
1770
2090
|
resume: () => 'You resumed the loop.',
|
|
2091
|
+
lead: () => 'You moved the loop to this session.',
|
|
1771
2092
|
done: (t) => `Every story was done (${t}).`,
|
|
1772
2093
|
};
|
|
1773
2094
|
const journalNow = () => {
|
|
@@ -1777,7 +2098,7 @@ export function register(on) {
|
|
|
1777
2098
|
.map((x) => ({
|
|
1778
2099
|
at: x.at,
|
|
1779
2100
|
kind: 'loop',
|
|
1780
|
-
who: ['pause', 'resume'].includes(x.kind) ? 'person' : 'lead',
|
|
2101
|
+
who: ['pause', 'resume', 'lead'].includes(x.kind) ? 'person' : 'lead',
|
|
1781
2102
|
text: STEP_WORDS[x.kind](x.target),
|
|
1782
2103
|
}));
|
|
1783
2104
|
return [...recorded, ...steps].sort((a, b) => a.at - b.at);
|
|
@@ -2004,6 +2325,11 @@ export function register(on) {
|
|
|
2004
2325
|
},
|
|
2005
2326
|
onSubmit: (value) => act.confirmAnswer(value),
|
|
2006
2327
|
onCancel: () => act.cancelAnswer(),
|
|
2328
|
+
onAskInput: (value) => {
|
|
2329
|
+
answering = { ...answering, aside: { ...answering.aside, draft: value } };
|
|
2330
|
+
$.ui.invalidate('ui.render');
|
|
2331
|
+
},
|
|
2332
|
+
onAsk: (value) => act.askAnswer(value),
|
|
2007
2333
|
});
|
|
2008
2334
|
});
|
|
2009
2335
|
|