@warren-dean/code-kit 0.15.1 → 0.16.1
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 +19 -0
- package/README.md +20 -15
- package/bin/code-kit.mjs +39 -8
- package/hooks/guard-bash.mjs +21 -19
- package/hooks/lib/dependencies.mjs +21 -3
- package/hooks/lib/next.mjs +11 -1
- package/hooks/lib/rules.mjs +51 -4
- package/hooks/mod/decisions.mjs +1 -20
- package/hooks/mod/hold.mjs +2 -0
- package/hooks/mod/loop.mjs +80 -9
- package/hooks/mod/register.mjs +85 -32
- package/hooks/mod/register.test.ts +110 -19
- package/hooks/mod/view.mjs +35 -19
- package/package.json +1 -1
- package/skills/decide/SKILL.md +60 -0
- package/skills/dispatch/SKILL.md +1 -1
- package/skills/init/SKILL.md +3 -2
- package/skills/next/SKILL.md +2 -1
- package/skills/review/SKILL.md +1 -1
- package/templates/agent.md +2 -2
- package/templates/claude-md.md +3 -2
package/hooks/mod/loop.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/** The steps the loop hands the lead; the rest (spec-design, init, check) wait for the person. */
|
|
6
6
|
const LEAD_STEPS = new Set([
|
|
7
|
+
'decide',
|
|
7
8
|
'dispatch',
|
|
8
9
|
'review',
|
|
9
10
|
'lead',
|
|
@@ -39,8 +40,8 @@ export function storiesInFlight(agents) {
|
|
|
39
40
|
* gives way to the next one `next` listed; with nothing left, the loop waits, saying for what, until an
|
|
40
41
|
* agent finishes (which moves the project on).
|
|
41
42
|
*/
|
|
42
|
-
export function withoutInFlight(step, inFlight) {
|
|
43
|
-
if (!step || !inFlight?.size) return step;
|
|
43
|
+
export function withoutInFlight(step, inFlight, parked = new Map()) {
|
|
44
|
+
if (!step || (!inFlight?.size && !parked?.size)) return step;
|
|
44
45
|
const chain = [step, ...(step.then ?? [])].map(({ then: _then, ...s }) => s);
|
|
45
46
|
const kept = [];
|
|
46
47
|
const busy = [];
|
|
@@ -50,19 +51,72 @@ export function withoutInFlight(step, inFlight) {
|
|
|
50
51
|
continue;
|
|
51
52
|
}
|
|
52
53
|
const ids = (s.args ?? '').split(/\s+/).filter(Boolean);
|
|
53
|
-
const
|
|
54
|
-
|
|
54
|
+
const held = (id) => inFlight?.has(id.toUpperCase()) || parked?.has(id.toUpperCase());
|
|
55
|
+
const free = ids.filter((id) => !held(id));
|
|
56
|
+
busy.push(...ids.filter(held));
|
|
55
57
|
if (free.length) kept.push({ ...s, args: free.join(' ') });
|
|
56
58
|
}
|
|
57
59
|
if (kept.length) return { ...kept[0], ...(kept.length > 1 ? { then: kept.slice(1) } : {}) };
|
|
58
|
-
const names = [...new Set(busy)];
|
|
60
|
+
const names = [...new Set(busy.map((id) => id.toUpperCase()))];
|
|
61
|
+
const working = names.filter((id) => inFlight?.has(id));
|
|
62
|
+
// LOOP-13: what each parked story waits on, as the lead said.
|
|
63
|
+
const waits = names
|
|
64
|
+
.filter((id) => !inFlight?.has(id))
|
|
65
|
+
.map(
|
|
66
|
+
(id) =>
|
|
67
|
+
`${id} waits${parked.get(id)?.on?.length ? ` on ${parked.get(id).on.join(', ')}` : ''}`,
|
|
68
|
+
);
|
|
59
69
|
return {
|
|
60
70
|
step: 'wait',
|
|
61
|
-
why:
|
|
71
|
+
why: [
|
|
72
|
+
working.length
|
|
73
|
+
? `${working.join(', ')} ${working.length === 1 ? 'is' : 'are'} still with ${working.length === 1 ? 'its agent' : 'their agents'}`
|
|
74
|
+
: null,
|
|
75
|
+
...waits,
|
|
76
|
+
]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join('; ')
|
|
79
|
+
.concat('; the loop goes on when one moves'),
|
|
62
80
|
inFlight: names,
|
|
63
81
|
};
|
|
64
82
|
}
|
|
65
83
|
|
|
84
|
+
/** LOOP-13: how long a parked story waits with nothing it waits on moving, before the lead is asked again. */
|
|
85
|
+
export const PARK_MS = 30 * 60_000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* LOOP-13: the stories to watch while `id` is parked: itself, the stories the lead said it waits on,
|
|
89
|
+
* and the stories that depend on it and aren't done (a pair the lead merges together).
|
|
90
|
+
*/
|
|
91
|
+
export function parkWatch(id, on = [], stories = []) {
|
|
92
|
+
const dependents = stories
|
|
93
|
+
.filter((s) => (s.dependsOn ?? []).includes(id) && s.state !== 'done')
|
|
94
|
+
.map((s) => s.id);
|
|
95
|
+
return [...new Set([id, ...on, ...dependents])];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* LOOP-13: where the watched stories stand, as one string: each one's state, its branch's commit and
|
|
100
|
+
* whether an agent is at work on it. A parked story wakes when this changes.
|
|
101
|
+
*/
|
|
102
|
+
export function storiesSignature(ids, stories = [], heads = new Map(), inFlight = new Set()) {
|
|
103
|
+
return ids
|
|
104
|
+
.map((id) => {
|
|
105
|
+
const s = stories.find((x) => x.id === id);
|
|
106
|
+
return `${id}:${s?.state ?? '?'}:${s?.branch ? (heads.get(s.branch) ?? '') : ''}:${inFlight.has(id) ? 'busy' : ''}`;
|
|
107
|
+
})
|
|
108
|
+
.join('|');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The story ids a step names, upper-cased: `review ST-158 ST-172` gives ST-158 and ST-172. */
|
|
112
|
+
export const stepStories = (step) =>
|
|
113
|
+
STORY_STEPS.has(step?.stepKind ?? step?.kind ?? step?.step)
|
|
114
|
+
? String(step?.target ?? step?.args ?? '')
|
|
115
|
+
.split(/\s+/)
|
|
116
|
+
.filter((id) => /^ST-\d+$/i.test(id))
|
|
117
|
+
.map((id) => id.toUpperCase())
|
|
118
|
+
: [];
|
|
119
|
+
|
|
66
120
|
/**
|
|
67
121
|
* The prompt for a step from `code-kit next --json` (LOOP-1), or null when it isn't the lead's to
|
|
68
122
|
* take. Every ready story is named: the dispatch skill decides how many lanes go (LOOP-2). A prompt
|
|
@@ -80,6 +134,11 @@ export function leadPrompt(step, { cli = 'code-kit' } = {}) {
|
|
|
80
134
|
// SETUP-2: the base has no rules yet; the lead puts the approved ones there, then merges as usual.
|
|
81
135
|
if (step.step === 'rules')
|
|
82
136
|
return `Put code-kit's approved rules onto ${ids[0] ?? 'the base'}: run node "${cli}" queue rules. It commits the rules as committed on this branch, without switching branches; after it, merge the queue as usual.`;
|
|
137
|
+
// HELM-4: a lane waits on the lead's decision; the decide skill takes each request on its merits.
|
|
138
|
+
if (step.step === 'decide')
|
|
139
|
+
return ids.length
|
|
140
|
+
? `${step.why ?? 'A lane waits on your decision.'} Run /code-kit:decide ${ids.join(' ')}.`
|
|
141
|
+
: null;
|
|
83
142
|
// ONB-3: a companion set up before any coding.
|
|
84
143
|
if (step.step === 'setup')
|
|
85
144
|
return ids.length
|
|
@@ -115,6 +174,7 @@ export function stepLabel(step) {
|
|
|
115
174
|
const ids = (step.args ?? '').split(/\s+/).filter(Boolean).join(', ');
|
|
116
175
|
const verb =
|
|
117
176
|
{
|
|
177
|
+
decide: 'Decide requests',
|
|
118
178
|
dispatch: 'Dispatch',
|
|
119
179
|
review: 'Review',
|
|
120
180
|
lead: 'Build',
|
|
@@ -150,7 +210,7 @@ export const nudgeText = (minutes) =>
|
|
|
150
210
|
`No tool call for ${minutes} minutes: report where you are, or carry on.`;
|
|
151
211
|
|
|
152
212
|
/** 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']);
|
|
213
|
+
export const OUTCOMES = new Set(['done', 'started', 'needs-person', 'blocked', 'waiting']);
|
|
154
214
|
const ANSWER_SHOWN = 4000;
|
|
155
215
|
|
|
156
216
|
/**
|
|
@@ -168,11 +228,12 @@ export function outcomePrompt({ prompt = null, answer = '' }) {
|
|
|
168
228
|
: "The person prompted you, and code-kit's lead loop is about to prompt you with the build's next step.",
|
|
169
229
|
`Your reply ended your turn. It read:\n"""\n${shown}\n"""`,
|
|
170
230
|
'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": "…"}',
|
|
231
|
+
'{"outcome": "done" | "started" | "needs-person" | "blocked" | "waiting", "question": "…", "options": [{"label": "…", "effect": "…"}], "reason": "…", "waits": [{"story": "ST-n", "on": ["ST-n"], "why": "…"}]}',
|
|
172
232
|
'- done: you finished what was asked, or there was nothing to do.',
|
|
173
233
|
'- started: you started work that carries on without you, such as lane agents.',
|
|
174
234
|
"- 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
|
-
"-
|
|
235
|
+
"- waiting: the step isn't due yet for some or all of its stories: they wait on other work still going (a lane building a story they merge with, a rebuild, a check running). waits: one entry per story that waits, with the stories it waits on (their ST ids, [] when it's other work) and why, in a few words. Use this, not done, when you said a story can't be taken yet.",
|
|
236
|
+
"- blocked: you didn't take the step, for a reason no answer from the person settles now and no work in flight will change. reason: why, in a sentence.",
|
|
176
237
|
].join('\n');
|
|
177
238
|
}
|
|
178
239
|
|
|
@@ -196,11 +257,21 @@ export function readOutcome(text) {
|
|
|
196
257
|
.map((o) => ({ label: words(o?.label, 40), effect: words(o?.effect, 200) }))
|
|
197
258
|
.filter((o) => o.label)
|
|
198
259
|
.slice(0, 4);
|
|
260
|
+
const story = (v) =>
|
|
261
|
+
typeof v === 'string' && /^ST-\d+$/i.test(v.trim()) ? v.trim().toUpperCase() : null;
|
|
262
|
+
const waits = (Array.isArray(parsed.waits) ? parsed.waits : [])
|
|
263
|
+
.map((w) => ({
|
|
264
|
+
story: story(w?.story),
|
|
265
|
+
on: (Array.isArray(w?.on) ? w.on : []).map(story).filter(Boolean),
|
|
266
|
+
why: words(w?.why, 200),
|
|
267
|
+
}))
|
|
268
|
+
.filter((w) => w.story);
|
|
199
269
|
return {
|
|
200
270
|
outcome: parsed.outcome,
|
|
201
271
|
question: words(parsed.question, 400),
|
|
202
272
|
options: options.length >= 2 ? options : [],
|
|
203
273
|
reason: words(parsed.reason, 300),
|
|
274
|
+
waits,
|
|
204
275
|
};
|
|
205
276
|
}
|
|
206
277
|
|
package/hooks/mod/register.mjs
CHANGED
|
@@ -58,16 +58,20 @@ import {
|
|
|
58
58
|
leadPrompt,
|
|
59
59
|
nudgeText,
|
|
60
60
|
outcomePrompt,
|
|
61
|
+
PARK_MS,
|
|
62
|
+
parkWatch,
|
|
61
63
|
readOutcome,
|
|
62
64
|
restartPrompt,
|
|
63
65
|
stallDue,
|
|
64
66
|
stepKey,
|
|
65
67
|
stepLabel,
|
|
68
|
+
stepStories,
|
|
66
69
|
storiesInFlight,
|
|
70
|
+
storiesSignature,
|
|
67
71
|
withoutInFlight,
|
|
68
72
|
} from './loop.mjs';
|
|
69
73
|
import { goalOf, nextText } from './mission.mjs';
|
|
70
|
-
import { contextOf, decisionCards,
|
|
74
|
+
import { contextOf, decisionCards, nativeChoice } from './decisions.mjs';
|
|
71
75
|
import { DEFAULT_TEAM_UI, TEAM_ID, teamFrame } from './teamview.mjs';
|
|
72
76
|
import { glowOf, mapStoryOf, touchOf } from './codemap.mjs';
|
|
73
77
|
import { harnessSettings } from '../lib/harness.mjs';
|
|
@@ -122,6 +126,8 @@ const loop = {
|
|
|
122
126
|
// LOOP-11, LOOP-12: what holds the loop: { kind: 'person' | 'stopped' | 'blocked' | 'design', key, label, prompt, question, options, reason }
|
|
123
127
|
waiting: null,
|
|
124
128
|
handedDesign: new Set(), // LOOP-12: design stories handed to the lead this session
|
|
129
|
+
// LOOP-13: stories the lead said wait on other work: id → { on, why, watch, sig, at }
|
|
130
|
+
parked: new Map(),
|
|
125
131
|
};
|
|
126
132
|
// LOOP-10: whether this session runs the loop, the session that does when another does, and when this
|
|
127
133
|
// one last renewed its lease.
|
|
@@ -268,7 +274,11 @@ export function register(on) {
|
|
|
268
274
|
const valid = Boolean(check?.valid);
|
|
269
275
|
const status = valid ? await json('status') : null;
|
|
270
276
|
const nextStep = valid ? await json('next') : null;
|
|
271
|
-
const
|
|
277
|
+
const all = valid ? await json('requests') : null;
|
|
278
|
+
// HELM-6: the person sees only their own decisions; the lead decides the rest.
|
|
279
|
+
const requests = all
|
|
280
|
+
? { ...all, open: (all.open ?? []).filter((r) => r.by !== 'lead') }
|
|
281
|
+
: null;
|
|
272
282
|
const stops = valid ? ((await json('stops', '--session', session)) ?? []) : [];
|
|
273
283
|
const queue = valid ? ((await json('queue')) ?? []) : [];
|
|
274
284
|
// VIEW-2: when each story's work happened, for the lanes' timelines.
|
|
@@ -532,7 +542,6 @@ export function register(on) {
|
|
|
532
542
|
reason: prefilledReason(line.request),
|
|
533
543
|
error: null,
|
|
534
544
|
heldId: line.heldId ?? null,
|
|
535
|
-
outside: line.outside ?? null,
|
|
536
545
|
aside: { thread: [], draft: '' },
|
|
537
546
|
};
|
|
538
547
|
await $.ui.open({ id: APPROVE_ID, title: 'Approve', focus: true, closeOnEscape: true });
|
|
@@ -581,7 +590,7 @@ export function register(on) {
|
|
|
581
590
|
const same = (r) =>
|
|
582
591
|
[...r.names].sort().join() === [...line.request.names].sort().join() &&
|
|
583
592
|
(r.lane ?? r.actor) === (line.request.lane ?? line.request.actor);
|
|
584
|
-
const numbers = open.flatMap((r, i) => (same(r) ? [i + 1] : []));
|
|
593
|
+
const numbers = open.flatMap((r, i) => (same(r) ? [r.n ?? i + 1] : []));
|
|
585
594
|
if (!numbers.length) return;
|
|
586
595
|
const ran = await $.process.run(
|
|
587
596
|
['node', cli, 'requests', 'drop', numbers.join(','), '--via', 'pane'],
|
|
@@ -734,7 +743,6 @@ export function register(on) {
|
|
|
734
743
|
why: line.held.refusal ?? null,
|
|
735
744
|
},
|
|
736
745
|
heldId: line.held.id,
|
|
737
|
-
outside: line.held.outside ?? null,
|
|
738
746
|
}),
|
|
739
747
|
// ACT-5: a side call over the lead's own conversation (it shares the prompt cache, and nothing is
|
|
740
748
|
// added to the conversation); before the lead's first reply, from the prompt's facts alone.
|
|
@@ -785,7 +793,6 @@ export function register(on) {
|
|
|
785
793
|
const prompt = approvalAsidePrompt({
|
|
786
794
|
request: asked.request,
|
|
787
795
|
story: row?.story ?? null,
|
|
788
|
-
outside: limitsWhy(asked.outside) ?? null,
|
|
789
796
|
thread,
|
|
790
797
|
question: q,
|
|
791
798
|
});
|
|
@@ -1098,6 +1105,45 @@ export function register(on) {
|
|
|
1098
1105
|
$.ui.invalidate('ui.render');
|
|
1099
1106
|
await act.step('resume');
|
|
1100
1107
|
},
|
|
1108
|
+
// LOOP-13: where the watched stories stand now: their states, branch commits, and agents at work.
|
|
1109
|
+
storiesNow: async () => {
|
|
1110
|
+
const refs = await $.process
|
|
1111
|
+
.run(['git', 'for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads'], { cwd })
|
|
1112
|
+
.catch(() => ({ stdout: '' }));
|
|
1113
|
+
const heads = new Map(
|
|
1114
|
+
String(refs.stdout ?? '')
|
|
1115
|
+
.split('\n')
|
|
1116
|
+
.map((l) => /^\s*(\S+)\s+(?:refs\/heads\/)?(\S+)/.exec(l))
|
|
1117
|
+
.filter(Boolean)
|
|
1118
|
+
.map((m) => [m[2], m[1]]),
|
|
1119
|
+
);
|
|
1120
|
+
const inFlight = storiesInFlight(await $.agent.list().catch(() => []));
|
|
1121
|
+
const stories = model.state?.stories ?? [];
|
|
1122
|
+
return {
|
|
1123
|
+
heads,
|
|
1124
|
+
inFlight,
|
|
1125
|
+
stories,
|
|
1126
|
+
sig: (ids) => storiesSignature(ids, stories, heads, inFlight),
|
|
1127
|
+
};
|
|
1128
|
+
},
|
|
1129
|
+
park: async (waits) => {
|
|
1130
|
+
if (!waits.length) return;
|
|
1131
|
+
const now = await $.clock.now();
|
|
1132
|
+
const seen = await act.storiesNow();
|
|
1133
|
+
for (const w of waits) {
|
|
1134
|
+
const watch = parkWatch(w.id, w.on, seen.stories);
|
|
1135
|
+
loop.parked.set(w.id, { on: w.on, why: w.why, watch, sig: seen.sig(watch), at: now });
|
|
1136
|
+
}
|
|
1137
|
+
$.ui.invalidate('ui.render');
|
|
1138
|
+
},
|
|
1139
|
+
// LOOP-13: a parked story wakes when anything it watches moves, or after PARK_MS with nothing moving.
|
|
1140
|
+
unpark: async () => {
|
|
1141
|
+
if (!loop.parked.size) return;
|
|
1142
|
+
const now = await $.clock.now();
|
|
1143
|
+
const seen = await act.storiesNow();
|
|
1144
|
+
for (const [id, p] of loop.parked)
|
|
1145
|
+
if (now - p.at >= PARK_MS || seen.sig(p.watch) !== p.sig) loop.parked.delete(id);
|
|
1146
|
+
},
|
|
1101
1147
|
// LOOP-11: the lead's last reply, read once before the loop prompts it again. A reply that waits on
|
|
1102
1148
|
// the person, or a turn they stopped, holds the loop until they speak; a step the lead didn't take
|
|
1103
1149
|
// holds that step while it's still next.
|
|
@@ -1144,6 +1190,21 @@ export function register(on) {
|
|
|
1144
1190
|
// Spec 05's data: what came of the step, once known.
|
|
1145
1191
|
const record = sent ? loop.steps.findLast((x) => x.prompt === sent.prompt) : null;
|
|
1146
1192
|
if (record) record.outcome = heard?.outcome ?? 'unread';
|
|
1193
|
+
// LOOP-13: stories the lead said wait on other work are parked, each until what it waits on moves.
|
|
1194
|
+
const ids = stepStories(sent);
|
|
1195
|
+
if (['waiting', 'blocked'].includes(heard?.outcome) && ids.length) {
|
|
1196
|
+
const named = new Map((heard.waits ?? []).map((w) => [w.story, w]));
|
|
1197
|
+
// A blocked step, or a reply that names none, parks all its stories; otherwise those it names.
|
|
1198
|
+
const waiting =
|
|
1199
|
+
heard.outcome === 'blocked' || !named.size ? ids : ids.filter((id) => named.has(id));
|
|
1200
|
+
await act.park(
|
|
1201
|
+
waiting.map((id) => ({
|
|
1202
|
+
id,
|
|
1203
|
+
on: named.get(id)?.on ?? [],
|
|
1204
|
+
why: named.get(id)?.why ?? heard.reason ?? null,
|
|
1205
|
+
})),
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1147
1208
|
if (heard?.outcome === 'needs-person')
|
|
1148
1209
|
loop.waiting = {
|
|
1149
1210
|
kind: 'person',
|
|
@@ -1153,7 +1214,7 @@ export function register(on) {
|
|
|
1153
1214
|
reason: heard.reason,
|
|
1154
1215
|
};
|
|
1155
1216
|
else if (heard?.outcome === 'blocked' && sent)
|
|
1156
|
-
loop.waiting = { kind: 'blocked', ...step, reason: heard.reason };
|
|
1217
|
+
loop.waiting = { kind: 'blocked', ...step, reason: heard.reason, stories: ids };
|
|
1157
1218
|
if (loop.waiting) $.ui.invalidate('ui.render');
|
|
1158
1219
|
},
|
|
1159
1220
|
// LOOP-11: the person's choice from the lead's options, or their own words, told to the lead.
|
|
@@ -1180,11 +1241,13 @@ export function register(on) {
|
|
|
1180
1241
|
const w = loop.waiting;
|
|
1181
1242
|
if (!w?.prompt || leadTurn) return;
|
|
1182
1243
|
loop.waiting = null;
|
|
1244
|
+
for (const id of w.stories ?? []) loop.parked.delete(id);
|
|
1183
1245
|
loop.asked = { key: w.key, times: 0 };
|
|
1184
1246
|
await act.submit(w.prompt, w.stepKind, w.target, { key: w.key, label: w.label });
|
|
1185
1247
|
},
|
|
1186
1248
|
// LOOP-11: the person lets the loop go on.
|
|
1187
1249
|
carryOn: async () => {
|
|
1250
|
+
for (const id of loop.waiting?.stories ?? []) loop.parked.delete(id);
|
|
1188
1251
|
loop.waiting = null;
|
|
1189
1252
|
loop.lastKey = null;
|
|
1190
1253
|
$.ui.invalidate('ui.render');
|
|
@@ -1252,12 +1315,15 @@ export function register(on) {
|
|
|
1252
1315
|
return act.submit(prompt, 'restart', story);
|
|
1253
1316
|
}
|
|
1254
1317
|
const ran = await $.process.run(['node', cli, 'next', '--json'], { cwd });
|
|
1318
|
+
// LOOP-13: a parked story stays out of the step until what it waits on moves.
|
|
1319
|
+
await act.unpark();
|
|
1255
1320
|
// LOOP-9: never a story an agent is already at work on, building or reviewing it.
|
|
1256
1321
|
const step =
|
|
1257
1322
|
ran.exitCode === 0
|
|
1258
1323
|
? withoutInFlight(
|
|
1259
1324
|
parseJson(ran.stdout),
|
|
1260
1325
|
storiesInFlight(await $.agent.list().catch(() => [])),
|
|
1326
|
+
loop.parked,
|
|
1261
1327
|
)
|
|
1262
1328
|
: null;
|
|
1263
1329
|
// MISSION-1: the band shows what the loop takes next.
|
|
@@ -1293,9 +1359,17 @@ export function register(on) {
|
|
|
1293
1359
|
// LOOP-11, LOOP-12: a step the lead didn't take, or designs left to the person and the lead, hold
|
|
1294
1360
|
// that step while it's still next; any other step goes on.
|
|
1295
1361
|
if (loop.waiting) {
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1362
|
+
// LOOP-13: a blocked step's stories are parked, so the line stays while any of them is, and
|
|
1363
|
+
// other steps go on; a step without stories holds while it's still next.
|
|
1364
|
+
const stories = loop.waiting.stories ?? [];
|
|
1365
|
+
const held = stories.length
|
|
1366
|
+
? stories.some((id) => loop.parked.has(id))
|
|
1367
|
+
: loop.waiting.key === key;
|
|
1368
|
+
if (held && !stories.length) return;
|
|
1369
|
+
if (!held) {
|
|
1370
|
+
loop.waiting = null;
|
|
1371
|
+
$.ui.invalidate('ui.render');
|
|
1372
|
+
}
|
|
1299
1373
|
}
|
|
1300
1374
|
if (!prompt) return;
|
|
1301
1375
|
if (designHandedOver(step, loop.handedDesign)) {
|
|
@@ -1757,26 +1831,6 @@ export function register(on) {
|
|
|
1757
1831
|
// HOLD-6: nobody to ask (claude -p, the SDK), or hold switched off: refused as today.
|
|
1758
1832
|
if (!ask || !where?.interactive || minutes <= 0 || model.state?.kind !== 'ok') return res;
|
|
1759
1833
|
const id = e.tool_use_id;
|
|
1760
|
-
let outsideLimits = null;
|
|
1761
|
-
// HOLD-5: under autonomy, the project's delegated rules decide first, without asking.
|
|
1762
|
-
if ((settings?.autonomy ?? 'autonomous') === 'autonomous') {
|
|
1763
|
-
const delegated = await $.process.run(
|
|
1764
|
-
[
|
|
1765
|
-
'node',
|
|
1766
|
-
where.cli,
|
|
1767
|
-
'approve',
|
|
1768
|
-
...ask.names,
|
|
1769
|
-
...(ask.lane ? ['--lane', ask.lane] : []),
|
|
1770
|
-
'--delegated',
|
|
1771
|
-
'--reason',
|
|
1772
|
-
`Held call within the delegated rules: ${ask.what}`,
|
|
1773
|
-
],
|
|
1774
|
-
{ cwd: where.cwd, timeoutMs: 60000 },
|
|
1775
|
-
);
|
|
1776
|
-
if (delegated.exitCode === 0) return next(e);
|
|
1777
|
-
// DECIDE-2: why it's outside the limits the person set, for the decision's card.
|
|
1778
|
-
outsideLimits = (delegated.stderr || delegated.stdout || '').trim();
|
|
1779
|
-
}
|
|
1780
1834
|
const agent = e.agentId ? agentsSeen.get(e.agentId) : null;
|
|
1781
1835
|
held.set(id, {
|
|
1782
1836
|
id,
|
|
@@ -1788,10 +1842,9 @@ export function register(on) {
|
|
|
1788
1842
|
what: ask.what,
|
|
1789
1843
|
since: await $.clock.now(),
|
|
1790
1844
|
minutes,
|
|
1791
|
-
// DECIDE-2: the call itself, the rule that refused it
|
|
1845
|
+
// DECIDE-2: the call itself, and the rule that refused it.
|
|
1792
1846
|
command: e.command ?? e.file_path ?? null,
|
|
1793
1847
|
refusal: text.split('\n')[0],
|
|
1794
|
-
outside: outsideLimits,
|
|
1795
1848
|
});
|
|
1796
1849
|
$.ui.invalidate('ui.render');
|
|
1797
1850
|
// The wait is a process the band's answer ends (`code-kit hold`), so it isn't the hook's own time.
|
|
@@ -97,10 +97,7 @@ function project() {
|
|
|
97
97
|
setExit: 0,
|
|
98
98
|
allowed: false, // an approval in force: the hooks beneath let the call through
|
|
99
99
|
bashRuns: 0,
|
|
100
|
-
|
|
101
|
-
delegateRefusal:
|
|
102
|
-
'This project delegates no approvals to the lead ("approvals.delegate" isn\'t set). Ask the person.',
|
|
103
|
-
delegated: [] as string[][],
|
|
100
|
+
delegated: [] as string[][], // HELM-6: the mod never approves for the lead, so this stays empty
|
|
104
101
|
holds: [] as string[][],
|
|
105
102
|
draft: '', // what the person has typed in the prompt
|
|
106
103
|
heads: { 'web/st-4': 'abc123def4567890\n' } as Record<string, string>,
|
|
@@ -284,8 +281,7 @@ function stub(on: any, w: World) {
|
|
|
284
281
|
if (sub === 'queue') return ran(0, JSON.stringify(w.queue));
|
|
285
282
|
if (sub === 'approve' && argv.includes('--delegated')) {
|
|
286
283
|
w.delegated.push([...argv.slice(2)]);
|
|
287
|
-
|
|
288
|
-
return ran(w.delegateExit, '', w.delegateExit ? w.delegateRefusal : '');
|
|
284
|
+
return ran(1, '', 'Nothing was approved.');
|
|
289
285
|
}
|
|
290
286
|
if (sub === 'hold') {
|
|
291
287
|
const id = argv[3];
|
|
@@ -653,6 +649,24 @@ test('BAND-2 and BAND-4 one open request reads "1 approval waiting", with Approv
|
|
|
653
649
|
await ui.unmount();
|
|
654
650
|
});
|
|
655
651
|
|
|
652
|
+
test("HELM-6 a request the lead decides isn't the person's: only theirs waits in the band", async ($, on) => {
|
|
653
|
+
const w = project();
|
|
654
|
+
w.requests = [
|
|
655
|
+
{ ...dayjs, n: 1, by: 'lead' },
|
|
656
|
+
{ ...dayjs, names: ['contract'], what: 'packages/contracts/a.ts', n: 2, by: 'person' },
|
|
657
|
+
];
|
|
658
|
+
await start($, on, w);
|
|
659
|
+
const ui = await bandUi($);
|
|
660
|
+
expect(await ui.find({ type: 'Text', text: '1 approval waiting' })).toBeDefined();
|
|
661
|
+
await press($, 'band-decide');
|
|
662
|
+
await ui.unmount();
|
|
663
|
+
// Not needed drops it by the number the CLI gave it, past the lead's request it doesn't show.
|
|
664
|
+
const decisions = await pane($, PANE);
|
|
665
|
+
await press($, 'decide-dropRequest', PANE);
|
|
666
|
+
expect(w.acts).toContainEqual(['requests', 'drop', '2', '--via', 'pane']);
|
|
667
|
+
await decisions.unmount();
|
|
668
|
+
});
|
|
669
|
+
|
|
656
670
|
test('ACT-1 and BAND-3 approving with the prefilled reason grants it for the lane, and the line goes', async ($, on) => {
|
|
657
671
|
const w = project();
|
|
658
672
|
w.requests = [dayjs];
|
|
@@ -1412,9 +1426,8 @@ test('DECIDE-2 a decision in the band says which story it is for, and d opens it
|
|
|
1412
1426
|
expect(card?.text).toMatch(/BOOK-1 Request a booking/);
|
|
1413
1427
|
expect(card?.text).toMatch(/npm install dayjs/);
|
|
1414
1428
|
expect(card?.text).toMatch(/ST-4 Booking form \(BOOK-1\) was handed to the web lane/);
|
|
1415
|
-
expect(card?.text).toMatch(
|
|
1416
|
-
|
|
1417
|
-
);
|
|
1429
|
+
expect(card?.text).toMatch(/Blocked: a new dependency \(dayjs\) needs a person's approval\./);
|
|
1430
|
+
expect(card?.text).not.toMatch(/limits you set/);
|
|
1418
1431
|
expect((await ui.find({ key: 'decision-option-approveHeld' }))?.text).toMatch(
|
|
1419
1432
|
/The held call runs now, and the agent carries on/,
|
|
1420
1433
|
);
|
|
@@ -1972,10 +1985,12 @@ test('HOLD-4 Refuse, or no answer in the hold time, lets the refusal through and
|
|
|
1972
1985
|
await ui.unmount();
|
|
1973
1986
|
});
|
|
1974
1987
|
|
|
1975
|
-
|
|
1988
|
+
const leadInstall =
|
|
1989
|
+
'Blocked: a new dependency (dayjs) is the lead\'s decision. The lead decides this, not the person. Hand the lead a change request and stop: the package, the story and requirement that need it, what the project already has that you checked, and anything lighter that would do. The lead approves it, or tells you what to use instead.\n Decided by the lead: node "/x/bin/code-kit.mjs" approve dep-dayjs --lane web --delegated --story <ST-n> --reason "<what the story needs it for, what the project already has, how it fits>"\nCommand: npm install dayjs';
|
|
1990
|
+
|
|
1991
|
+
test("HOLD-5 and HELM-6 a lane's call the lead decides isn't held, and the mod doesn't approve it for the lead", async ($, on) => {
|
|
1976
1992
|
const w = project();
|
|
1977
|
-
w.refusal =
|
|
1978
|
-
w.delegateExit = 0;
|
|
1993
|
+
w.refusal = leadInstall;
|
|
1979
1994
|
await start($, on, w);
|
|
1980
1995
|
const res: any = await $.tool.call({
|
|
1981
1996
|
tool: 'Bash',
|
|
@@ -1983,18 +1998,17 @@ test('HOLD-5 within the delegated rules the call is approved as delegated and ru
|
|
|
1983
1998
|
command: 'npm install dayjs',
|
|
1984
1999
|
agentId: 'a1',
|
|
1985
2000
|
} as any);
|
|
1986
|
-
expect(refused(res)).toBe(
|
|
1987
|
-
expect(w.delegated[0]).toEqual(
|
|
1988
|
-
expect.arrayContaining(['approve', 'dep-dayjs', '--lane', 'web', '--delegated']),
|
|
1989
|
-
);
|
|
2001
|
+
expect(refused(res)).toBe(true);
|
|
1990
2002
|
expect(w.holds).toEqual([]);
|
|
2003
|
+
expect(w.delegated).toEqual([]);
|
|
2004
|
+
const band = await bandUi($);
|
|
2005
|
+
expect(await band.find({ type: 'Text', text: /wants to install/ })).toBeUndefined();
|
|
2006
|
+
await band.unmount();
|
|
1991
2007
|
});
|
|
1992
2008
|
|
|
1993
|
-
test(
|
|
2009
|
+
test("HOLD-5 a call the person decides is held for them, without trying the lead's rules", async ($, on) => {
|
|
1994
2010
|
const w = project();
|
|
1995
2011
|
w.refusal = webInstall;
|
|
1996
|
-
w.delegateExit = 0;
|
|
1997
|
-
(w.check as any).harness = { autonomy: 'propose', hold: { minutes: 2 } };
|
|
1998
2012
|
const clock = await start($, on, w);
|
|
1999
2013
|
const { call } = await heldCall($, w, clock);
|
|
2000
2014
|
expect(w.delegated).toEqual([]);
|
|
@@ -2496,6 +2510,83 @@ test('LOOP-11 dismissed, what the lead waits on stays in the band, and the loop
|
|
|
2496
2510
|
expect(w.prompts.at(-1)).toBe('Review ST-4: run /code-kit:review ST-4.');
|
|
2497
2511
|
});
|
|
2498
2512
|
|
|
2513
|
+
test('LOOP-13 stories the lead says wait on other work are parked, each until what it waits on moves', async ($, on) => {
|
|
2514
|
+
const w = project();
|
|
2515
|
+
w.stories.push(
|
|
2516
|
+
{
|
|
2517
|
+
id: 'ST-158',
|
|
2518
|
+
title: 'Storage',
|
|
2519
|
+
lane: 'web',
|
|
2520
|
+
state: 'in review',
|
|
2521
|
+
branch: 'web/st-158',
|
|
2522
|
+
waitingOn: [],
|
|
2523
|
+
},
|
|
2524
|
+
{
|
|
2525
|
+
id: 'ST-164',
|
|
2526
|
+
title: 'Callers use the storage',
|
|
2527
|
+
lane: 'api',
|
|
2528
|
+
state: 'in progress',
|
|
2529
|
+
branch: 'api/st-164',
|
|
2530
|
+
dependsOn: ['ST-158'],
|
|
2531
|
+
waitingOn: [],
|
|
2532
|
+
},
|
|
2533
|
+
{
|
|
2534
|
+
id: 'ST-172',
|
|
2535
|
+
title: 'Silent tests',
|
|
2536
|
+
lane: 'core',
|
|
2537
|
+
state: 'in review',
|
|
2538
|
+
branch: 'core/st-172',
|
|
2539
|
+
waitingOn: [],
|
|
2540
|
+
},
|
|
2541
|
+
{
|
|
2542
|
+
id: 'ST-173',
|
|
2543
|
+
title: 'Verdicts',
|
|
2544
|
+
lane: 'lead',
|
|
2545
|
+
state: 'in review',
|
|
2546
|
+
branch: 'lead/st-173',
|
|
2547
|
+
waitingOn: [],
|
|
2548
|
+
},
|
|
2549
|
+
);
|
|
2550
|
+
w.refs += ' s1 refs/heads/web/st-158\n s2 refs/heads/api/st-164\n s3 refs/heads/core/st-172\n';
|
|
2551
|
+
w.next = { step: 'review', args: 'ST-158 ST-172', then: [] };
|
|
2552
|
+
w.sideReply = unread;
|
|
2553
|
+
const clock = await start($, on, w);
|
|
2554
|
+
await leadTurn($);
|
|
2555
|
+
await settle(clock);
|
|
2556
|
+
expect(w.prompts.at(-1)).toBe('Review ST-158, ST-172: run /code-kit:review.');
|
|
2557
|
+
// The lead says neither is due yet; next then names a third story as well.
|
|
2558
|
+
w.sideReply = outcome({
|
|
2559
|
+
outcome: 'waiting',
|
|
2560
|
+
waits: [
|
|
2561
|
+
{ story: 'ST-158', on: [], why: 'it merges with its paired branch' },
|
|
2562
|
+
{ story: 'ST-172', on: [], why: 'its merge checks are re-running' },
|
|
2563
|
+
],
|
|
2564
|
+
});
|
|
2565
|
+
w.next = { step: 'review', args: 'ST-158 ST-172 ST-173', then: [] };
|
|
2566
|
+
await leadSays(
|
|
2567
|
+
$,
|
|
2568
|
+
't-2',
|
|
2569
|
+
'None of these is due: ST-158 merges with ST-164; ST-172 re-runs its checks.',
|
|
2570
|
+
);
|
|
2571
|
+
await settle(clock);
|
|
2572
|
+
// Only the story that isn't waiting is asked for.
|
|
2573
|
+
expect(w.prompts.at(-1)).toBe('Review ST-173: run /code-kit:review ST-173.');
|
|
2574
|
+
const asked = w.prompts.length;
|
|
2575
|
+
// Lanes committing elsewhere don't wake them, and nothing is prompted while both still wait.
|
|
2576
|
+
w.sideReply = unread;
|
|
2577
|
+
w.next = { step: 'review', args: 'ST-158 ST-172', then: [] };
|
|
2578
|
+
w.refs += ' x1 refs/heads/core/st-9\n';
|
|
2579
|
+
await leadTurn($, 't-3');
|
|
2580
|
+
await settle(clock);
|
|
2581
|
+
expect(w.prompts).toHaveLength(asked);
|
|
2582
|
+
// The story building on ST-158 commits: ST-158 wakes, and ST-172 still waits.
|
|
2583
|
+
w.refs = w.refs.replace(' s2 refs/heads/api/st-164', ' s9 refs/heads/api/st-164');
|
|
2584
|
+
await leadTurn($, 't-4');
|
|
2585
|
+
await settle(clock);
|
|
2586
|
+
expect(w.prompts.at(-1)).toBe('Review ST-158: run /code-kit:review ST-158.');
|
|
2587
|
+
expect(w.prompts).toHaveLength(asked + 1);
|
|
2588
|
+
});
|
|
2589
|
+
|
|
2499
2590
|
test('LOOP-11 a turn the person stopped holds the loop, with no dialog, until they speak or press Carry on', async ($, on) => {
|
|
2500
2591
|
const w = project();
|
|
2501
2592
|
w.sideReply = unread;
|