@ctrl-spc/cs 0.6.0 → 0.7.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.
@@ -0,0 +1,1484 @@
1
+ import { statSync } from 'node:fs';
2
+ import { readCodebasePaths } from './config.js';
3
+ export const EMPTY_WORK_CONTEXT = {
4
+ items: [],
5
+ checkout: null,
6
+ gitRemoteUrl: null,
7
+ miss: null,
8
+ };
9
+ const LINK_KINDS = [
10
+ 'work-item',
11
+ 'epic',
12
+ 'sprint',
13
+ 'skill',
14
+ 'credential',
15
+ 'workflow',
16
+ ];
17
+ /** The kind of ONE link row, defaulting to `'work-item'` — see `AttachedKind`.
18
+ *
19
+ * 18d SLICE 1: this returns the REAL kind for all six, where it used to
20
+ * collapse everything it did not recognise into `'work-item'`. That collapse
21
+ * was safe while the only unrecognised kinds were hypothetical; once the
22
+ * drawer writes three of them it became actively wrong, sending a skill's uuid
23
+ * into the `tasks` read and, worse, past `readAttachedStructureProject`'s
24
+ * `!== 'work-item'` filter into the `sprints` read.
25
+ *
26
+ * The fallback SURVIVES for genuinely unrecognised kinds, for the reason
27
+ * `AttachedKind` gives: a kind this build has never heard of is a newer
28
+ * writer's, and dropping the link would silently lose something the user did. */
29
+ function linkKind(row) {
30
+ const kind = row.kind;
31
+ return LINK_KINDS.includes(kind) ? kind : 'work-item';
32
+ }
33
+ /**
34
+ * EVERY attachment on a request, resolved to something a worker can read.
35
+ *
36
+ * `workItemForTodo` in orchestrator.ts already reads this table, but takes
37
+ * `.limit(1)` because the SCOPE GATE only needs one unapproved item to refuse.
38
+ * That limit is correct there and wrong here: I6 is precisely that only the
39
+ * first attachment ever reached the worker.
40
+ *
41
+ * Two reads rather than a join, because `cliv2_loose_todo_links.work_item_id`
42
+ * is a BY-VALUE reference with no foreign key (AGENTS.md: a `cliv2_*` row never
43
+ * couples structurally to a v1-owned table), so PostgREST cannot embed `tasks`
44
+ * through a relationship that does not exist in the schema.
45
+ *
46
+ * A link pointing at a deleted task RESOLVES TO NOTHING and is dropped, which
47
+ * is the visible degradation the link table's own migration argues for.
48
+ *
49
+ * 18b SLICE 6 — ONLY THE WORK-ITEM LINKS ARE READ FROM `tasks`, and the filter
50
+ * is what makes that read correct rather than merely narrower. An epic's id is
51
+ * not in `tasks`, so before this filter an epic link went into the `in (...)`
52
+ * list, matched nothing, and was dropped as if it pointed at a DELETED work item
53
+ * — the two states are indistinguishable once the kind is thrown away. That
54
+ * mattered because of what the caller does next: `buildWorkContext` returns
55
+ * `EMPTY_WORK_CONTEXT` on `items.length === 0`, so a request attached ONLY to an
56
+ * epic lost its checkout AND its project, and per Slice 2 a code-capable run then
57
+ * refused or ran nowhere. Filtering here keeps the epic out of a read that could
58
+ * never have resolved it, and `readAttachedStructure` below is what stops the
59
+ * request losing its project because of it.
60
+ *
61
+ * READING THE EPIC OR SPRINT ITSELF IS SLICE 6b, deliberately not here. Slice 6
62
+ * attaches; 6b describes. What this function owes Slice 6 is that attaching an
63
+ * epic does not COST the request anything it already had.
64
+ */
65
+ export async function readAttachedItems(client, todoId) {
66
+ const { data: links, error: linkError } = await client
67
+ .from('cliv2_loose_todo_links')
68
+ .select('work_item_id, kind, created_at')
69
+ .eq('todo_id', todoId)
70
+ .order('created_at', { ascending: true });
71
+ if (linkError)
72
+ throw new Error(linkError.message);
73
+ const ids = (links ?? [])
74
+ .filter((row) => linkKind(row) === 'work-item')
75
+ .map((row) => row.work_item_id)
76
+ .filter((id) => typeof id === 'string' && id !== '');
77
+ if (ids.length === 0)
78
+ return [];
79
+ const { data: tasks, error: taskError } = await client
80
+ .from('tasks')
81
+ .select('id, name, description, is_idea, project_id, projects(name)')
82
+ .in('id', ids);
83
+ if (taskError)
84
+ throw new Error(taskError.message);
85
+ /* Indexed, then emitted IN LINK ORDER. The `in` read comes back in whatever
86
+ order the server chose, and the user attached these one at a time — the
87
+ first one they picked reading third would be a small, permanent lie about
88
+ what they did. */
89
+ const byId = new Map();
90
+ for (const row of tasks ?? []) {
91
+ const task = row;
92
+ const project = Array.isArray(task.projects) ? task.projects[0] : task.projects;
93
+ byId.set(task.id, {
94
+ id: task.id,
95
+ name: task.name,
96
+ description: task.description ?? '',
97
+ isIdea: task.is_idea === true,
98
+ projectId: task.project_id,
99
+ projectName: project?.name ?? '',
100
+ });
101
+ }
102
+ return ids.map((id) => byId.get(id)).filter((item) => item !== undefined);
103
+ }
104
+ /**
105
+ * 18b SLICE 6 — THE PROJECT AN ATTACHED EPIC OR SPRINT BELONGS TO.
106
+ *
107
+ * THE ONE FACT SLICE 6 NEEDS OUT OF AN EPIC, and deliberately the only one. The
108
+ * name, the description, the sprints underneath it and the backlog order are all
109
+ * Slice 6b's, and none of them are read here. This answers a single question:
110
+ * WHICH PROJECT, so a request attached only to an epic can still resolve its
111
+ * codebase and still refuse honestly when it cannot.
112
+ *
113
+ * WITHOUT IT THE ATTACHMENT COSTS THE USER THE RUN. `resolveWorkContext` returns
114
+ * `EMPTY_WORK_CONTEXT` when no work item resolves, and Slice 2 gates the tick's
115
+ * refusal on `miss` being non-null — so an epic-only code request would neither
116
+ * run in the right place nor say why. Attaching MORE context must never leave a
117
+ * request with LESS than it had.
118
+ *
119
+ * IDS ONLY, NEVER A PATH. Same invariant as everything else in this module: what
120
+ * comes back is a project id, which travels to `resolveProjectContext` and turns
121
+ * into a cwd locally. Nothing here reaches a prompt or a column.
122
+ *
123
+ * THE FIRST ATTACHMENT WINS, matching the work-item path exactly (`items[0]`
124
+ * decides the directory). Two epics in two projects has no single right answer,
125
+ * and guessing between them is the failure this module refuses everywhere else.
126
+ *
127
+ * BEST-EFFORT, LIKE EVERY OTHER READ IN THE TICK. `epics` and `sprints` are
128
+ * v1-owned product tables read through the user's own RLS, exactly as the web
129
+ * app reads them (AGENTS.md permits the read; it is v2's WRITES that must stay
130
+ * in `cliv2_*`). A failure returns null and the caller degrades to "no project",
131
+ * which refuses — rather than throwing and costing the user the request.
132
+ */
133
+ export async function readAttachedStructureProject(client, todoId) {
134
+ const { data: links, error: linkError } = await client
135
+ .from('cliv2_loose_todo_links')
136
+ .select('work_item_id, kind, created_at')
137
+ .eq('todo_id', todoId)
138
+ .order('created_at', { ascending: true });
139
+ if (linkError)
140
+ throw new Error(linkError.message);
141
+ /* In attach order, so "the first one" means the first the user picked — the
142
+ same promise `readAttachedItems` keeps about its own ordering. */
143
+ /* 18d SLICE 1 — NAMED, NOT "EVERYTHING THAT IS NOT A WORK ITEM". This filter
144
+ used to read `!== 'work-item'`, which was exhaustive while the column held
145
+ three kinds and became a bug the moment it held six: a selected skill would
146
+ have counted as structural and had its uuid queried against `sprints`. The
147
+ two kinds this function can actually resolve are named. */
148
+ const structural = (links ?? [])
149
+ .map((row) => ({ id: row.work_item_id, kind: linkKind(row) }))
150
+ .filter((row) => (row.kind === 'epic' || row.kind === 'sprint') && typeof row.id === 'string' && row.id !== '');
151
+ if (structural.length === 0)
152
+ return null;
153
+ const first = structural[0];
154
+ /* The TABLE is chosen by the kind, because that is the only thing that
155
+ distinguishes the two ids. Querying both and taking whichever hit would
156
+ resolve a sprint id that happened to collide with an epic id, which is the
157
+ kind of "works until it does not" this module's comments exist to prevent. */
158
+ const table = first.kind === 'epic' ? 'epics' : 'sprints';
159
+ /* 18b SLICE 6b — THE NAME COMES BACK TOO, not just the project.
160
+ Slice 6 read only what it needed to resolve a checkout, and deliberately
161
+ stopped there. But the agent was then never told WHICH epic the request was
162
+ about: asked to "summarise this epic's sprints", it had a working directory
163
+ and no idea what "this epic" referred to. The name and id are on the row
164
+ this query already fetches, so carrying them costs nothing. */
165
+ const { data, error } = await client
166
+ .from(table)
167
+ .select('id, name, project_id, projects(name)')
168
+ .eq('id', first.id)
169
+ .limit(1);
170
+ if (error)
171
+ throw new Error(error.message);
172
+ const row = (data ?? [])[0];
173
+ if (!row?.project_id)
174
+ return null;
175
+ const project = Array.isArray(row.projects) ? row.projects[0] : row.projects;
176
+ return {
177
+ id: row.id,
178
+ /* `kind` comes from the LINK, not from the row: it is what chose the table,
179
+ and the row itself carries no such column. */
180
+ kind: first.kind === 'epic' ? 'epic' : 'sprint',
181
+ name: row.name ?? '',
182
+ projectId: row.project_id,
183
+ projectName: project?.name ?? '',
184
+ };
185
+ }
186
+ /**
187
+ * THE CODEBASE A WORK ITEM MEANS, as a canonical `host/path` identity.
188
+ *
189
+ * TARGETS FIRST. `cliv2_task_codebase_targets` is the work item saying which
190
+ * codebase it is about, and a project may hold several. Falling straight to
191
+ * "the project's codebase" would put the worker in the wrong repo of the right
192
+ * project whenever the item had bothered to say.
193
+ *
194
+ * THEN THE PROJECT'S ONE CODEBASE, and only when there is exactly one. With two
195
+ * and no target there is no answer, and picking the oldest would be a guess
196
+ * dressed as a resolution — the Gherkin's "does not silently work in the wrong
197
+ * directory" forbids exactly that.
198
+ */
199
+ export async function resolveCodebaseRemote(client, item) {
200
+ const { data: targets, error: targetError } = await client
201
+ .from('cliv2_task_codebase_targets')
202
+ .select('git_remote_url, created_at')
203
+ .eq('task_id', item.id)
204
+ .order('created_at', { ascending: true })
205
+ .limit(1);
206
+ if (targetError)
207
+ throw new Error(targetError.message);
208
+ const targeted = (targets ?? [])[0];
209
+ if (targeted?.git_remote_url)
210
+ return targeted.git_remote_url;
211
+ const { data: codebases, error: codebaseError } = await client
212
+ .from('cliv2_codebases')
213
+ .select('git_remote_url, created_at')
214
+ .eq('project_id', item.projectId)
215
+ .order('created_at', { ascending: true })
216
+ .limit(2);
217
+ if (codebaseError)
218
+ throw new Error(codebaseError.message);
219
+ const rows = (codebases ?? []);
220
+ /* EXACTLY ONE, or nothing. Two codebases and no target is genuinely ambiguous
221
+ and the honest answer is to refuse — see the header. */
222
+ return rows.length === 1 ? rows[0].git_remote_url : null;
223
+ }
224
+ /**
225
+ * 18b SLICE 2 — THE SAME QUESTION, ASKED OF A PROJECT INSTEAD OF A WORK ITEM.
226
+ *
227
+ * A request typed with nothing attached has no work item to resolve through, so
228
+ * there is no target to consult and no `AttachedItem` to carry a project name.
229
+ * It has one thing: the `project_id` the user chose in the composer (Slice 1).
230
+ * This is the whole resolution available to it — straight to the project's
231
+ * codebases, with nothing to break a tie.
232
+ *
233
+ * IT RETURNS THE CANDIDATES RATHER THAN A BOOLEAN, because the three outcomes
234
+ * need three different sentences on the card: none registered, exactly one (run
235
+ * there), or several with no target (say WHICH several). `resolveCodebaseRemote`
236
+ * collapses the first and third into `null`, which is why a user with two
237
+ * codebases was told they had none.
238
+ *
239
+ * `limit(3)` rather than `limit(2)`: the refusal names the candidates, so it
240
+ * needs more than "there is more than one". Three is enough to write an honest
241
+ * sentence without reading a project's entire codebase list into memory.
242
+ */
243
+ export async function resolveProjectCodebases(client, projectId) {
244
+ const { data, error } = await client
245
+ .from('cliv2_codebases')
246
+ .select('git_remote_url, created_at')
247
+ .eq('project_id', projectId)
248
+ .order('created_at', { ascending: true })
249
+ .limit(3);
250
+ if (error)
251
+ throw new Error(error.message);
252
+ return (data ?? []).map(r => r.git_remote_url);
253
+ }
254
+ /**
255
+ * EVERYTHING THE WORKER NEEDS, for one request.
256
+ *
257
+ * NEVER THROWS on the codebase half. The attachments are the load-bearing read
258
+ * (they carry I6's title, description and project); the checkout is a bonus
259
+ * that a question-answering request does not need at all. A failed codebase
260
+ * lookup therefore degrades to "no directory" — the worker still runs, with the
261
+ * `not-located` shape the caller reports — rather than costing the user the run.
262
+ *
263
+ * The FIRST attached item decides the directory. A request touching two items in
264
+ * two repos has no single right answer, and the scope gate already refuses to
265
+ * dispatch a unit whose items are not all approved.
266
+ */
267
+ export async function resolveWorkContext(client, todoId, warn = console.warn,
268
+ /** Injected so the resolution is testable without writing the real user's
269
+ * config file. Production always uses the real one. */
270
+ codebasePaths = readCodebasePaths,
271
+ /** Injected for the same reason — a test must not depend on which folders
272
+ * happen to exist on the machine running it. */
273
+ folderExists = defaultFolderExists,
274
+ /** 18b SLICE 4, THE CODEBASE THE USER CHOSE, for an ATTACHED request.
275
+ *
276
+ * The unattached path has carried this since Slice 4. This one could not,
277
+ * so an attached request that was asked which codebase had nowhere to put
278
+ * the answer and would have been asked again on every tick forever. */
279
+ chosenRemote = null) {
280
+ let items = [];
281
+ try {
282
+ items = await readAttachedItems(client, todoId);
283
+ }
284
+ catch (err) {
285
+ /* Best-effort, like every other prompt input in the tick. A failed read
286
+ costs the worker its context, which is bad; failing the dispatch costs
287
+ the user their work, which is worse. */
288
+ warn(`[orchestrator] could not read what this request is attached to: ${err.message}`);
289
+ return EMPTY_WORK_CONTEXT;
290
+ }
291
+ /* 18b SLICE 6 — NO WORK ITEM IS NOT THE SAME AS NOTHING ATTACHED.
292
+ A request attached only to an EPIC or a SPRINT resolves no `AttachedItem`
293
+ (they are not `tasks` rows), and returning the empty context here would cost
294
+ it its checkout and its project — so a code-capable run would refuse or run
295
+ nowhere, which is strictly worse than the same request with NOTHING attached.
296
+ The structure still names a project, so the request resolves through it by
297
+ exactly the path an unattached request uses (`resolveProjectContext`), and
298
+ gets the same checkout and the same honest refusals.
299
+
300
+ 18b SLICE 6b — AND THE STRUCTURE ITSELF NOW TRAVELS. Slice 6 carried only
301
+ the project out of here, because naming the epic to the agent was 6b's job.
302
+ It is now done: the resolved context keeps the epic or sprint, so
303
+ `describeStructure` can tell the agent WHICH one the request is about. The
304
+ checkout resolution below is unchanged — the project is still what decides
305
+ the directory. */
306
+ if (items.length === 0) {
307
+ let structure = null;
308
+ try {
309
+ structure = await readAttachedStructureProject(client, todoId);
310
+ }
311
+ catch (err) {
312
+ warn(`[orchestrator] could not read the attached epic or sprint: ${err.message}`);
313
+ }
314
+ if (!structure)
315
+ return EMPTY_WORK_CONTEXT;
316
+ const context = await resolveProjectContext(client, structure.projectId, structure.projectName, warn, codebasePaths, folderExists);
317
+ /* Attached to the RESOLVED context, whatever it turned out to be. A miss is
318
+ still a context: the request refuses, and the card that says why is better
319
+ for naming the epic it was about. */
320
+ return { ...context, structure };
321
+ }
322
+ const primary = items[0];
323
+ /* ═══ THE ANSWER WINS OVER THE MEMORY. ═══
324
+ `chosenRemote` is the user answering "which codebase should this run in?"
325
+ for THIS request, seconds ago; a target is what some earlier answer left on
326
+ the work item. When both exist the newer statement is the operative one, and
327
+ it is resolved by exactly the path an unattached answer takes.
328
+
329
+ 18b SLICE 9 MAKES THIS REACHABLE AND LOAD-BEARING. Keeping a choice writes
330
+ the target, so from that moment the request that was just answered has both.
331
+ Picking TWO and keeping both writes two targets in one statement, sharing a
332
+ `created_at`, so "the oldest target" is not a defined order, and without
333
+ this the run could land in the other one of the two the user picked.
334
+
335
+ THROUGH THE PROJECT RESOLVER, not straight to the folder, because that is
336
+ where a chosen codebase is CHECKED against the project's own list before it
337
+ is used. An answer can name a codebase the project no longer has (it was
338
+ detached between the question and the answer), and running there would be
339
+ worse than asking again. One verification, both paths. */
340
+ if (chosenRemote) {
341
+ const chosenContext = await resolveProjectContext(client, primary.projectId, primary.projectName, warn, codebasePaths, folderExists, chosenRemote);
342
+ return { ...chosenContext, items };
343
+ }
344
+ let remote = null;
345
+ try {
346
+ remote = await resolveCodebaseRemote(client, primary);
347
+ }
348
+ catch (err) {
349
+ warn(`[orchestrator] could not resolve the codebase: ${err.message}`);
350
+ }
351
+ /* NO TARGET AND NO SINGLE CODEBASE, so this item cannot decide on its own,
352
+ and "cannot decide" is two different facts. `resolveCodebaseRemote` folds
353
+ both into null, so an attached request against a project with TWO codebases
354
+ was told the project "has no codebase registered": false, and a dead end,
355
+ because the ambiguity the card knows how to ASK about (Slice 4) never
356
+ reached it.
357
+
358
+ THE PROJECT RESOLVER IS THE ONE THAT TELLS THEM APART, and it is already
359
+ the resolution an unattached request gets: none, exactly one, or several
360
+ with the candidates named. Delegating here rather than restating it is also
361
+ what keeps a chosen codebase working on this path: `chosenRemote` is
362
+ verified against the project's own list there, once, for both paths.
363
+
364
+ THE ITEMS SURVIVE THE DELEGATION. They are the prompt's load-bearing
365
+ context (I6) and the project resolver has none of its own, so a refusal
366
+ from here still names the work item the request was about. */
367
+ if (!remote) {
368
+ const context = await resolveProjectContext(client, primary.projectId, primary.projectName, warn, codebasePaths, folderExists, chosenRemote);
369
+ return { ...context, items };
370
+ }
371
+ const local = codebasePaths()[remote];
372
+ if (!local) {
373
+ return {
374
+ items,
375
+ checkout: null,
376
+ gitRemoteUrl: remote,
377
+ miss: { kind: 'not-located', projectName: primary.projectName, gitRemoteUrl: remote },
378
+ };
379
+ }
380
+ /* THE FOLDER IS CHECKED, NOT TRUSTED. `codebase-paths.json` has no removal
381
+ path — nothing ever deletes an entry (not `removeCodebase`, not the
382
+ superseded-machine cleanup) — so a repo the user moved or deleted leaves a
383
+ stale absolute path behind forever. Spawning into it fails obscurely: the
384
+ child dies on a chdir the daemon never sees, and the unit is released to
385
+ retry the same broken path every time. Checking here turns that into the
386
+ honest refusal the Gherkin asks for. */
387
+ if (!folderExists(local)) {
388
+ return {
389
+ items,
390
+ checkout: null,
391
+ gitRemoteUrl: remote,
392
+ miss: { kind: 'path-missing', projectName: primary.projectName, gitRemoteUrl: remote },
393
+ };
394
+ }
395
+ return { items, checkout: local, gitRemoteUrl: remote, miss: null };
396
+ }
397
+ /**
398
+ * 18b SLICE 2 — THE CONTEXT FOR A REQUEST WITH NOTHING ATTACHED.
399
+ *
400
+ * Its own function rather than a fifth parameter on `resolveWorkContext`,
401
+ * because the two resolutions genuinely differ and merging them would make both
402
+ * harder to read: the attached path resolves THROUGH a work item (target first,
403
+ * then that item's project) and carries the item into the prompt; this path has
404
+ * only a project id and carries no items at all. Every existing caller and test
405
+ * fake of `resolveWorkContext` also keeps its arity.
406
+ *
407
+ * WHY IT IS A MISS AND NOT AN EMPTY CONTEXT. Before this slice, no attachments
408
+ * meant `EMPTY_WORK_CONTEXT` with `miss: null` — deliberately, since "nothing
409
+ * attached" was not a failure to find anything. But the refusal in the tick is
410
+ * gated on `miss` being non-null, so an unattached code request could not refuse
411
+ * at any wording: it ran with no working directory at all, which under the
412
+ * launchd login item is the filesystem root. Now a request that HAS a project
413
+ * and cannot resolve it to a checkout produces a real miss, and refuses like any
414
+ * other. A request with no project at all still produces the empty context —
415
+ * there is nothing to miss.
416
+ */
417
+ export async function resolveProjectContext(client, projectId, projectName, warn = console.warn, codebasePaths = readCodebasePaths, folderExists = defaultFolderExists,
418
+ /** 18b SLICE 4 — THE CODEBASE THE USER CHOSE, when they have chosen one.
419
+ *
420
+ * A project with several codebases and no target used to be a dead end: the
421
+ * request refused, naming the candidates, and settling it meant leaving the
422
+ * panel for project settings. Slice 4 asks on the card instead, and this is
423
+ * the answer coming back — the git remote of the ONE codebase this run is
424
+ * for. A fan-out across several is N runs, each arriving here with its own.
425
+ *
426
+ * Given one, the ambiguity is gone by construction: it is treated exactly as
427
+ * a single-codebase project would be, including the located and path-missing
428
+ * checks below, so a chosen codebase that is not on this machine refuses in
429
+ * the same words as any other. */
430
+ chosenRemote = null) {
431
+ /* No project — every row predating Slice 1, and any the composer could not
432
+ resolve. Unchanged from before this slice: no checkout, and no miss to
433
+ report, because nothing was ever pointed at. */
434
+ if (!projectId)
435
+ return EMPTY_WORK_CONTEXT;
436
+ /* The name travels with every return below, including the refusals: the
437
+ prompt names it when the run happens, and the card names it when it does
438
+ not. */
439
+ /* The name is for the SENTENCE, not for the resolution. Falling back to the id
440
+ would put a uuid in front of the user; "its project" is what the existing
441
+ refusal already says when it has no name. */
442
+ const named = projectName || 'its project';
443
+ let remotes = [];
444
+ try {
445
+ remotes = await resolveProjectCodebases(client, projectId);
446
+ }
447
+ catch (err) {
448
+ /* Best-effort like every other read in the tick, but NOT silent: a failed
449
+ read here is reported as "no codebase", which is the same conservative
450
+ shape the attached path uses. It refuses rather than running nowhere. */
451
+ warn(`[orchestrator] could not resolve the project's codebases: ${err.message}`);
452
+ return { items: [], checkout: null, gitRemoteUrl: null, miss: { kind: 'no-codebase', projectName: named }, projectName };
453
+ }
454
+ if (remotes.length === 0) {
455
+ return { items: [], checkout: null, gitRemoteUrl: null, miss: { kind: 'no-codebase', projectName: named }, projectName };
456
+ }
457
+ /* 18b SLICE 4 — THE USER ALREADY SAID WHICH, so there is nothing ambiguous
458
+ left. Verified against the project's own list rather than trusted: the
459
+ answer arrives from a `selected_options` array the user could in principle
460
+ have been shown before a codebase was detached, and running in a repository
461
+ the project no longer has is worse than asking again. An answer that does
462
+ not match falls through to the ambiguity below and is re-asked. */
463
+ if (chosenRemote && remotes.includes(chosenRemote)) {
464
+ return locate(chosenRemote, named, projectName, codebasePaths, folderExists);
465
+ }
466
+ /* SEVERAL, AND NOTHING SAYS WHICH. Its own kind, so the card can name the
467
+ candidates and tell the user to set a target — rather than the old
468
+ `no-codebase` wording, which told a user with two codebases they had none.
469
+ Slice 4 turns this into a QUESTION on the card rather than a refusal; the
470
+ miss is still what carries the candidates to it. */
471
+ if (remotes.length > 1) {
472
+ return {
473
+ items: [],
474
+ checkout: null,
475
+ gitRemoteUrl: null,
476
+ miss: { kind: 'ambiguous-codebase', projectName: named, gitRemoteUrls: remotes },
477
+ projectName,
478
+ };
479
+ }
480
+ return locate(remotes[0], named, projectName, codebasePaths, folderExists);
481
+ }
482
+ /** ONE REMOTE, RESOLVED TO A CHECKOUT ON THIS MACHINE — or the honest reason it
483
+ * cannot be. Extracted in 18b Slice 4 so the project's ONLY codebase and the
484
+ * codebase the user CHOSE take exactly the same path: a chosen codebase that is
485
+ * not on this machine has to refuse in the same words as any other, and two
486
+ * copies of these two checks is how that quietly stops being true. */
487
+ function locate(remote, named, projectName, codebasePaths, folderExists) {
488
+ const local = codebasePaths()[remote];
489
+ if (!local) {
490
+ return { items: [], checkout: null, gitRemoteUrl: remote, miss: { kind: 'not-located', projectName: named, gitRemoteUrl: remote }, projectName };
491
+ }
492
+ /* Checked, not trusted — same reason as the attached path: `codebase-paths.json`
493
+ has no removal path, so a moved or deleted repo leaves a stale absolute path
494
+ behind forever and spawning into it fails obscurely. */
495
+ if (!folderExists(local)) {
496
+ return { items: [], checkout: null, gitRemoteUrl: remote, miss: { kind: 'path-missing', projectName: named, gitRemoteUrl: remote }, projectName };
497
+ }
498
+ return { items: [], checkout: local, gitRemoteUrl: remote, miss: null, projectName };
499
+ }
500
+ /** `statSync().isDirectory()` rather than `existsSync`: a path that exists as a
501
+ * FILE is not a working directory, and handing one to spawn dies the same
502
+ * obscure way a missing one does. */
503
+ function defaultFolderExists(path) {
504
+ try {
505
+ return statSync(path).isDirectory();
506
+ }
507
+ catch {
508
+ return false;
509
+ }
510
+ }
511
+ /**
512
+ * WHAT THE WORKER IS WORKING ON, as prompt text — I6.
513
+ *
514
+ * Today the prompt carries a bare todo uuid and nothing else: not the item's
515
+ * title, not its description, not the project, and only the first attachment
516
+ * even reached the resolution. A worker told "refactor the auth module" with no
517
+ * idea which product that is has to guess, and it guesses from the repo it was
518
+ * never placed in.
519
+ *
520
+ * NO ABSOLUTE PATH IS EVER PUT IN HERE. The worker learns where it is by BEING
521
+ * there (the cwd), not by being told a path that would then travel into the
522
+ * answer column and the cloud. That is the phase's own Gherkin scenario, and it
523
+ * is why this function takes the items rather than the whole context.
524
+ */
525
+ export function describeAttachments(items) {
526
+ if (items.length === 0)
527
+ return [];
528
+ const lines = [''];
529
+ lines.push(items.length === 1
530
+ ? 'This request is attached to a work item:'
531
+ : `This request is attached to ${items.length} items:`);
532
+ for (const item of items) {
533
+ const kind = item.isIdea ? 'Product idea' : 'Work item';
534
+ lines.push('');
535
+ lines.push(` ${kind}: ${item.name}`);
536
+ if (item.projectName)
537
+ lines.push(` Project: ${item.projectName}`);
538
+ lines.push(` Id: ${item.id}`);
539
+ if (item.description.trim()) {
540
+ /* THE DESCRIPTION IS INDENTED AS A BLOCK, not flattened onto one line: it
541
+ is authored prose that can carry its own paragraphs and lists, and
542
+ collapsing it would destroy the structure the user wrote. */
543
+ lines.push(' Description:');
544
+ for (const line of item.description.trim().split('\n'))
545
+ lines.push(` ${line}`);
546
+ }
547
+ }
548
+ return lines;
549
+ }
550
+ /** The line that tells a worker it is ALREADY in the repository — so it does
551
+ * not go looking for one, and does not wander outside it. Empty when there is
552
+ * no checkout, because claiming a directory the worker is not in would be
553
+ * worse than saying nothing. */
554
+ export function describeCheckout(context) {
555
+ if (!context.checkout)
556
+ return [];
557
+ /* 18b SLICE 2 — IT SAYS WHOSE REPOSITORY IT IS, TRUTHFULLY. This used to be
558
+ hardcoded to "this work item's repository", which is false for a request
559
+ that resolved its checkout from its PROJECT and has no work item at all —
560
+ the agent would be told to look for an item it was never given. The two
561
+ cases now say what is actually true of each. */
562
+ const whose = context.items.length > 0 ? 'this work item\'s' : 'this project\'s';
563
+ return [
564
+ '',
565
+ `You are ALREADY INSIDE ${whose} repository — your working directory is its`,
566
+ 'checkout on this machine. Use relative paths from here. Do not search the filesystem',
567
+ 'for the project, do not clone it, and do not modify anything outside this directory.',
568
+ ];
569
+ }
570
+ /**
571
+ * 18b SLICE 2 — THE PROJECT A REQUEST BELONGS TO, IN THE PROMPT.
572
+ *
573
+ * `describeAttachments` returns nothing when there are no attachments, so an
574
+ * unattached request's prompt named no project even once Slice 1 had recorded
575
+ * one. Proven, not assumed: asked "in one sentence, say what this project is
576
+ * for" against TalentTrack, the subagent replied that the request "came through
577
+ * with no project attached" and listed every project in the workspace.
578
+ *
579
+ * NAME ONLY, NEVER THE PATH. The path invariant is absolute here: the worker
580
+ * learns where it is by BEING there (`describeCheckout`), and no absolute path
581
+ * is ever put in a prompt or in a database column (AGENTS.md).
582
+ */
583
+ export function describeProject(projectName, hasItems) {
584
+ /* An attached request already names its project per item, and repeating it
585
+ would be two answers to one question when the item's project and the
586
+ request's disagree. The item wins there — it is the more specific fact. */
587
+ if (hasItems || !projectName)
588
+ return [];
589
+ return ['', `This request belongs to the project: ${projectName}`];
590
+ }
591
+ /**
592
+ * 18b SLICE 6b — WHICH EPIC OR SPRINT THE REQUEST IS ABOUT.
593
+ *
594
+ * Slice 6 let the user attach one and resolved its project so the run had a
595
+ * directory, but the prompt never named it. Asked to "summarise this epic's
596
+ * sprints", the agent had no way to know which epic "this" meant, and its only
597
+ * honest move was to list every epic in the project or ask.
598
+ *
599
+ * THE IDENTITY, NOT THE CONTENTS. This says which epic and gives its id; it does
600
+ * NOT summarise the sprints or paste the backlog. Two reasons, and the second is
601
+ * the load-bearing one:
602
+ *
603
+ * 1. The tools already do it better. `list_structure` returns the project's
604
+ * epics and sprints; `list_tasks` returns the backlog with each row's epic
605
+ * and sprint, in order. Both are already granted to every worker.
606
+ * 2. A PRE-BAKED SUMMARY GOES STALE THE MOMENT IT IS WRITTEN. The prompt is
607
+ * built when the run is dispatched; the board can change between then and
608
+ * the answer. An id the agent resolves for itself is correct when it is
609
+ * read. This is the same argument the links table makes for storing a
610
+ * reference rather than a copied name.
611
+ *
612
+ * THE ID IS GIVEN because `list_structure` returns every epic in the project and
613
+ * the agent has to know which row is the one it was pointed at. Names are not
614
+ * unique — "Search 1" is a plausible name for both a sprint and a work item
615
+ * inside it, which is exactly why Slice 6 put the kind on the chip.
616
+ */
617
+ export function describeStructure(structure) {
618
+ if (!structure)
619
+ return [];
620
+ const kind = structure.kind === 'epic' ? 'epic' : 'sprint';
621
+ const named = structure.name.trim();
622
+ const lines = [
623
+ '',
624
+ named
625
+ ? `This request is about the ${kind} "${named}" (id: ${structure.id}).`
626
+ /* An unnamed row is a real case — `name` is nullable on neither table in
627
+ practice, but the read coalesces to '' rather than throwing, and a
628
+ prompt reading `the epic ""` would be worse than one that just gives the
629
+ id. */
630
+ : `This request is about a ${kind} (id: ${structure.id}).`,
631
+ ];
632
+ lines.push(kind === 'epic'
633
+ ? 'Read what is under it with list_structure (the project\'s epics and sprints) and'
634
+ : 'Read what is in it with list_tasks, whose rows each name their epic and sprint.');
635
+ if (kind === 'epic') {
636
+ lines.push('list_tasks, whose rows each name their epic and sprint. Match on the id above.');
637
+ }
638
+ else {
639
+ lines.push('Match on the id above; do not answer about another sprint\'s work.');
640
+ }
641
+ return lines;
642
+ }
643
+ /**
644
+ * 18d SLICE 1 — WHAT THE USER SELECTED, READ BACK FOR THE PROMPT.
645
+ *
646
+ * The drawer's whole promise is that a selection BINDS: the skill the user
647
+ * pointed at is the one that gets read, not one a discovery step chose. That
648
+ * promise lives or dies here, because the prompt is the only channel to a
649
+ * headless `claude -p` run.
650
+ *
651
+ * ONE READ FOR THE LINKS, THEN ONE PER KIND ACTUALLY PRESENT. `skills`,
652
+ * `credentials` and `cliv2_workflows` are read through the user's own RLS,
653
+ * exactly as `tasks` and `epics` are read above — AGENTS.md permits v2 to READ
654
+ * shared product data; it is v2's writes that must stay in `cliv2_*`.
655
+ *
656
+ * NAMES, NEVER VALUES. The `credentials` select is `id, name` and nothing else.
657
+ * A secret has no business in a prompt, and the prompt travels into the answer
658
+ * column: story 2's pass condition is precisely that the value appears in no
659
+ * answer, no progress line and no record.
660
+ *
661
+ * BEST-EFFORT, LIKE EVERY OTHER READ IN THE TICK. A failure yields `[]` rather
662
+ * than costing the user the request. The cost of the empty case is that the run
663
+ * falls back to discovery, which is the behaviour of every send that selected
664
+ * nothing — degraded, not broken.
665
+ *
666
+ * IN LINK ORDER, so "the first skill" means the first one picked.
667
+ */
668
+ export async function readSelectedTools(client, todoId) {
669
+ try {
670
+ const { data: links, error } = await client
671
+ .from('cliv2_loose_todo_links')
672
+ .select('work_item_id, kind, created_at')
673
+ .eq('todo_id', todoId)
674
+ .in('kind', ['skill', 'credential', 'workflow'])
675
+ .order('created_at', { ascending: true });
676
+ if (error)
677
+ throw new Error(error.message);
678
+ const wanted = (links ?? [])
679
+ .map((row) => ({
680
+ id: row.work_item_id,
681
+ kind: linkKind(row),
682
+ }))
683
+ .filter((row) => (row.kind === 'skill' || row.kind === 'credential' || row.kind === 'workflow') &&
684
+ typeof row.id === 'string' &&
685
+ row.id !== '');
686
+ if (wanted.length === 0)
687
+ return [];
688
+ const idsOf = (kind) => wanted.filter((row) => row.kind === kind).map((row) => row.id);
689
+ /* Resolved name by name into one map keyed `kind:id`, because the three id
690
+ keyspaces are independent and a skill and a workflow could share a uuid. */
691
+ const named = new Map();
692
+ const skillIds = idsOf('skill');
693
+ if (skillIds.length > 0) {
694
+ /* The org comes through `skill_bundles`, because `skills` has no `org_id`
695
+ of its own — the same hop `loadSkills` makes in the web app. It is
696
+ carried only so the prompt can pass `get_skill`'s optional `org_id`,
697
+ which is what disambiguates a skill name held by two organizations.
698
+
699
+ `deleted_at is null` MATCHES WHAT `get_skill` WILL DO. Skills are
700
+ archive-not-remove, so a removed skill's row survives; without this
701
+ filter the prompt would bind the run to a skill and `get_skill` would
702
+ then answer "no skill named X", leaving the worker between an
703
+ instruction it must follow and a tool that refuses. Dropped here
704
+ instead, which is the documented fallback: discovery. */
705
+ const { data, error } = await client
706
+ .from('skills')
707
+ .select('id, name, skill_bundles(org_id)')
708
+ .in('id', skillIds)
709
+ .is('deleted_at', null);
710
+ if (error)
711
+ throw new Error(error.message);
712
+ for (const row of data ?? []) {
713
+ const skill = row;
714
+ const bundle = Array.isArray(skill.skill_bundles) ? skill.skill_bundles[0] : skill.skill_bundles;
715
+ named.set(`skill:${skill.id}`, { name: skill.name, orgId: bundle?.org_id ?? null });
716
+ }
717
+ }
718
+ const credentialIds = idsOf('credential');
719
+ if (credentialIds.length > 0) {
720
+ /* `id, name` ONLY. Not the secret (which this client cannot read anyway),
721
+ not `username`, not `kind`. */
722
+ const { data, error } = await client.from('credentials').select('id, name').in('id', credentialIds);
723
+ if (error)
724
+ throw new Error(error.message);
725
+ for (const row of data ?? []) {
726
+ const credential = row;
727
+ named.set(`credential:${credential.id}`, { name: credential.name, orgId: null });
728
+ }
729
+ }
730
+ const workflowIds = idsOf('workflow');
731
+ if (workflowIds.length > 0) {
732
+ /* `archived_at is null` MATCHES `start_workflow`, which refuses an
733
+ archived workflow. Binding the run to one the tool will not start is
734
+ the same trap the skills read above avoids. */
735
+ const { data, error } = await client
736
+ .from('cliv2_workflows')
737
+ .select('id, name, org_id')
738
+ .in('id', workflowIds)
739
+ .is('archived_at', null);
740
+ if (error)
741
+ throw new Error(error.message);
742
+ for (const row of data ?? []) {
743
+ const workflow = row;
744
+ named.set(`workflow:${workflow.id}`, { name: workflow.name, orgId: workflow.org_id ?? null });
745
+ }
746
+ }
747
+ /* A selection whose row no longer resolves is DROPPED rather than named as
748
+ an id, matching what an attachment pointing at a deleted task does. */
749
+ return wanted
750
+ .map((row) => {
751
+ const hit = named.get(`${row.kind}:${row.id}`);
752
+ return hit ? { kind: row.kind, id: row.id, name: hit.name, orgId: hit.orgId } : null;
753
+ })
754
+ .filter((tool) => tool !== null);
755
+ }
756
+ catch {
757
+ return [];
758
+ }
759
+ }
760
+ /** A name going inside a double-quoted JSON-ish literal in the prompt. Skill,
761
+ * credential and workflow names are user-authored on the product's own pages,
762
+ * so one containing a quote or a backslash would otherwise print a literal the
763
+ * agent cannot parse. */
764
+ function quoted(value) {
765
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
766
+ }
767
+ /**
768
+ * 18d SLICE 1 — THE SELECTION BINDS, AND THIS IS WHERE IT SAYS SO.
769
+ *
770
+ * NOTHING SELECTED PRODUCES NOTHING. The guard returns `[]`, so a send that
771
+ * selected nothing yields a prompt byte-identical to today's and the general
772
+ * "list_skills, then get_skill" guidance in `describeToolSurface` still fires.
773
+ * That is how "an empty selection changes nothing" is guaranteed structurally
774
+ * rather than by test: discovery is not something this block turns off, it is
775
+ * something this block does not mention.
776
+ *
777
+ * NAMES AND IDS BOTH. `get_skill` and `get_credential` take a NAME, which is
778
+ * what makes them usable at all; but credential names are unique only per
779
+ * (org, creator) — `credentials_owner_name_uq` — so a user who can reach their
780
+ * own token and one shared to them under the same name gets a refusal from
781
+ * `get_credential`, which accepts no id and no org to disambiguate with. The id
782
+ * is printed anyway, for the agent's own reading and so the refusal is
783
+ * intelligible. That limit is Slice 3's to close, not this slice's.
784
+ *
785
+ * A SEPARATE FUNCTION, NOT LINES INSIDE `describeToolSurface`, whose character
786
+ * budget is pinned by a test. This block is conditional; that one is not.
787
+ */
788
+ export function describeSelectedTools(selected) {
789
+ if (!selected || selected.length === 0)
790
+ return [];
791
+ const lines = [
792
+ '',
793
+ "The user SELECTED these in the panel's tool drawer. Use exactly these; do not",
794
+ 'substitute one for another and do not run a discovery step to re-choose them.',
795
+ ];
796
+ for (const tool of selected) {
797
+ if (tool.kind === 'skill') {
798
+ const args = tool.orgId
799
+ ? `{ name: ${quoted(tool.name)}, org_id: ${quoted(tool.orgId)} }`
800
+ : `{ name: ${quoted(tool.name)} }`;
801
+ lines.push(` Skill: ${tool.name} — get_skill(${args}), then FOLLOW it for the rest of this run.`);
802
+ }
803
+ else if (tool.kind === 'credential') {
804
+ lines.push(` Credential: ${tool.name} (id: ${tool.id}) — read its value with ` +
805
+ `get_credential({ name: ${quoted(tool.name)} }) when the work needs it.`);
806
+ }
807
+ else {
808
+ /* The id is printed because `start_workflow` takes ids, not names. The
809
+ work item is described rather than printed as a bare `work_item_id`
810
+ key: this function does not know which item is attached, and a
811
+ half-written object literal is worse than a sentence. */
812
+ lines.push(` Workflow: ${tool.name} — start it on the work item this request is ` +
813
+ `attached to, with start_workflow({ workflow_id: ${quoted(tool.id)}, work_item_id: ... }).`);
814
+ }
815
+ }
816
+ return lines;
817
+ }
818
+ /**
819
+ * DOES THIS INSTRUCTION ACTUALLY NEED THE CODE?
820
+ *
821
+ * The unhappy path must refuse a codebase instruction whose checkout is missing.
822
+ * It must NOT refuse "what is this work item about?", which is answerable from
823
+ * the product alone and is the panel's most common use — turning that into a
824
+ * "locate your codebase" dead end would be a worse regression than the bug.
825
+ *
826
+ * SO THE DEFAULT IS TO RUN, and this is the deliberate direction of the error.
827
+ * A wrong "needs code" STOPS work the user could have had; a wrong "does not"
828
+ * costs a run that reports it could not find the files, which is recoverable and
829
+ * self-explanatory. Only a clear signal flips it.
830
+ *
831
+ * A KEYWORD MATCH, AND ITS CEILING IS STATED. This cannot classify intent — it
832
+ * is a word list, and `checkDirection`'s substring weakness in this same product
833
+ * is the standing lesson about pretending otherwise. It catches the verbs that
834
+ * mean "change the code" and nothing subtler. What makes that acceptable here is
835
+ * that the ONLY consequence of a miss is which of two honest outcomes the user
836
+ * gets: a refusal naming the fix, or a run that finds nothing to edit and says
837
+ * so. Neither writes anything, and neither is silent.
838
+ *
839
+ * ponytail: a word list, not a classifier. Phase 7 introduces real work typing
840
+ * (I23) — when a request carries a kind, this should read that instead.
841
+ */
842
+ const CODE_VERBS = [
843
+ 'refactor', 'implement', 'fix', 'debug', 'edit', 'rename', 'delete',
844
+ 'add a test', 'write a test', 'add tests', 'run the test', 'run tests',
845
+ 'commit', 'build', 'compile', 'install', 'upgrade', 'migrate',
846
+ 'readme', 'codebase', 'repository', 'the repo', 'this repo',
847
+ ];
848
+ export function instructionNeedsCheckout(instruction) {
849
+ const text = instruction.toLowerCase();
850
+ return CODE_VERBS.some((verb) => text.includes(verb));
851
+ }
852
+ /* ═══ !Cleanup PHASE 2b — SCOPE GATES BUILDING, NOT THINKING. ═══
853
+ *
854
+ * THE USER'S RULE, verbatim: "Scope should be a gate to BUILDING, not a gate to
855
+ * performing research and planning."
856
+ *
857
+ * THE DEADLOCK THIS BREAKS, found by trying to walk Phase 2 as a user rather
858
+ * than by reading the code. `orchestratorTick` asked `cliv2_scope_is_approved`
859
+ * before dispatching ANYTHING, so a work item with no approved scope refused
860
+ * research, planning, spec.md, prd.md, a mock, and even a plain question about
861
+ * itself. Writing the scope document is exactly how a user OBTAINS a scope to
862
+ * approve — so the gate blocked the only work that could unblock it, and the
863
+ * card's suggested next step ("Open the work item to draft its scope") named a
864
+ * control that does not exist. The walk could only proceed via the database.
865
+ *
866
+ * THE PRODUCT ALREADY AGREED, which is what makes this narrow: `create_artifact`
867
+ * is NOT scope-gated in mcp.ts. Only `create_stage`, `create_step` and
868
+ * `update_step` are. An agent that gets to run may already write documents and
869
+ * mocks; the one surface contradicting that was the daemon's blanket gate. This
870
+ * changes the daemon and leaves 16g's three MCP call sites exactly as shipped.
871
+ *
872
+ * SO THE QUESTION IS "IS THIS BUILDING?", NOT "IS THIS APPROVED?".
873
+ *
874
+ * THE DEFAULT IS **NOT** BUILDING, and the direction of that error is chosen,
875
+ * not incidental. Guessing "building" wrongly re-creates the deadlock this
876
+ * phase exists to remove, and the user is stuck again. Guessing "planning"
877
+ * wrongly dispatches a worker that is TOLD not to edit the repository (see
878
+ * `describePlanningPosture`) against an item whose scope nobody approved — and
879
+ * the execution-writing tools are still gated in mcp.ts regardless. One error
880
+ * is a dead end; the other is a run that writes a document. They are not
881
+ * symmetric, so the default follows the recoverable one.
882
+ *
883
+ * ponytail: a word list, not a classifier — same ceiling and same upgrade path
884
+ * as `instructionNeedsCheckout`. Phase 7's work typing (I23) is the real answer;
885
+ * when a request carries a kind, this reads that instead of guessing.
886
+ */
887
+ const BUILD_VERBS = [
888
+ 'implement', 'build', 'code ', 'write the code', 'refactor', 'fix',
889
+ 'add a test', 'write a test', 'add tests', 'make it work',
890
+ 'ship it', 'deploy', 'migrate', 'rename', 'delete',
891
+ /* CHANGING A FILE IS BUILDING, even when the change is small and even when
892
+ the thing being written is prose. Phase 2's own happy path — "add a comment
893
+ at the top of the README" — matched none of the verbs above, so it fell
894
+ through to planning and the worker would have been told it must not edit
895
+ the repository. A README edit is an edit. */
896
+ 'edit', 'update the', 'change the', 'add a comment', 'remove the',
897
+ 'commit', 'readme',
898
+ ];
899
+ /** Words that mean THINKING, and that beat a build verb when both appear.
900
+ * "write a spec for the fix" is planning about a fix, not a fix — and without
901
+ * this the bare `fix` would send it down the gated path. */
902
+ const PLANNING_VERBS = [
903
+ 'spec', 'prd', 'research', 'investigate', 'explore', 'analyse',
904
+ 'analyze', 'wireframe', 'diagram', 'summarise', 'summarize',
905
+ 'recommend', 'propose', 'estimate',
906
+ ];
907
+ /* A QUESTION IS PLANNING, BUT ONLY WHEN IT IS ACTUALLY ASKING — anchored at the
908
+ START, not matched anywhere in the sentence.
909
+ CAUGHT BY RUNNING THE CLASSIFIER OVER PHASE 2'S OWN HAPPY PATH, not by a unit
910
+ test: "add a comment at the top of the README explaining what this repo is"
911
+ was classified PLANNING, because the unanchored `what ` and `explain` matched
912
+ words describing the COMMENT'S CONTENT. The consequence was not cosmetic — the
913
+ worker would have been told it must not edit the repository, so the phase's
914
+ own headline scenario could never have completed. Interrogatives only mean a
915
+ question when they open the sentence; anywhere else they are ordinary words. */
916
+ const QUESTION_OPENERS = /^\s*(what|why|how|which|who|when|where|is|are|does|do|can|should|could|would|explain|tell me|describe|summar)/i;
917
+ /* Words that name a DOCUMENT to produce. Unlike the interrogatives these are
918
+ safe anywhere, but `plan`, `design`, `mock`, `scope` and `document` are only
919
+ planning when something is being MADE — "implement the design" and "delete the
920
+ mock data" are builds that happen to contain them, which is why they need a
921
+ producing verb rather than standing alone. */
922
+ const PRODUCE_VERBS = /\b(write|draft|create|produce|make|prepare|put together|come up with)\b[^.]{0,40}\b(plan|design|mock|scope|document|doc|spec|prd|proposal|outline|brief)/i;
923
+ /* `plan` and `design` used as VERBS — "plan the migration", "design how search
924
+ works". Separate from the pattern above because there is no producing verb to
925
+ anchor on: the word IS the instruction. Still narrow enough that "implement
926
+ the design" and "delete the mock data" stay builds, because those have the
927
+ word in object position rather than leading a clause. */
928
+ const PLAN_AS_VERB = /\b(plan|design|scope|spec)\s+(the|this|out|how|what|a\b|an\b|for\b)/i;
929
+ /**
930
+ * DOES THIS INSTRUCTION BUILD SOMETHING? Only a true answer needs approved scope.
931
+ *
932
+ * Planning wins ties deliberately — see the block comment above.
933
+ */
934
+ export function instructionIsBuilding(instruction) {
935
+ const text = instruction.toLowerCase();
936
+ if (QUESTION_OPENERS.test(instruction))
937
+ return false;
938
+ if (PRODUCE_VERBS.test(instruction))
939
+ return false;
940
+ if (PLAN_AS_VERB.test(instruction))
941
+ return false;
942
+ if (PLANNING_VERBS.some((verb) => text.includes(verb)))
943
+ return false;
944
+ return BUILD_VERBS.some((verb) => text.includes(verb));
945
+ }
946
+ /**
947
+ * WHAT A PLANNING RUN IS ALLOWED TO DO — the other half of opening the gate.
948
+ *
949
+ * Dispatching planning against an unapproved work item is only safe if the
950
+ * worker KNOWS it is planning. Without this it receives an instruction, finds
951
+ * itself inside a real checkout (Phase 2), and builds — which is precisely what
952
+ * the scope gate exists to prevent, arrived at by the back door.
953
+ *
954
+ * NOT STAGES AND STEPS (user ruling, 2026-08-06). Stages and steps "hold what is
955
+ * true" and are stamped with the approved scope revision (16g Slice 4), so
956
+ * recording planning there would assert execution against a contract nobody
957
+ * approved. The live view is the right surface: present tense, and referenced by
958
+ * nothing. It is no longer EPHEMERAL — 18c Slice 4 keeps the final frame on the
959
+ * work item after the run ends — but that does not make it a record, which is
960
+ * the property this ruling actually turned on: nothing links to a frame, and no
961
+ * agent can read one back.
962
+ *
963
+ * THE LIVE VIEW IS NAMED ONLY WHEN IT IS REACHABLE. `draw_live_view` is
964
+ * session-anchored and refuses without `begin_work`, which a panel worker
965
+ * structurally cannot call (I12, Phase 4). Telling a worker to call a tool that
966
+ * will refuse it teaches it the product is broken, so the line is conditional
967
+ * and Phase 4 turns it on.
968
+ */
969
+ export function describePlanningPosture(canDrawLiveView = false) {
970
+ return [
971
+ '',
972
+ 'THIS IS PLANNING OR RESEARCH, NOT BUILDING. This work item has no approved scope',
973
+ 'yet, which is exactly why you are being asked to think rather than to build.',
974
+ '',
975
+ 'You MAY: read the codebase, research, and produce documents and pictures —',
976
+ 'a spec, a PRD, a plan, a mock, a diagram — with create_artifact on the work item.',
977
+ 'You MUST NOT: edit, create or delete any file in the repository, and must not',
978
+ 'run anything that changes it. If the answer is code, WRITE IT INTO A DOCUMENT',
979
+ 'rather than into the repo.',
980
+ ...(canDrawLiveView
981
+ ? ['', 'Use draw_live_view to show what you are working through as you go.']
982
+ : []),
983
+ ];
984
+ }
985
+ /**
986
+ * 18c SLICE 9: SAY WHAT YOU ARE ABOUT TO CHANGE, BEFORE YOU CHANGE IT.
987
+ *
988
+ * Spec: .implementations/18-orchestrator/18c-you-watch-the-work-happen/ux.md
989
+ * § "Slice 9. A run says what it is about to change" and § Story 8.
990
+ *
991
+ * THIS IS THE WRITE HALF OF THE PROBE, and it is the whole reason the slice
992
+ * exists. `reserve_work_paths` and `release_work_paths` have been registered
993
+ * since the MCP server shipped, carrying conflict detection, read/write modes
994
+ * and a read-to-write upgrade, and `cliv2_work_reservations` has had ZERO
995
+ * ROWS, EVER, because no prompt ever told an agent to declare. The coordinator's
996
+ * read half arrived later and has therefore never had anything to read. Nothing
997
+ * about the tool needed building; this sentence did.
998
+ *
999
+ * IT IS THE RISKIEST PREMISE IN THE FEATURE AND IT IS HERE TO BE ALLOWED TO
1000
+ * FAIL. Whether an agent will reliably declare BEFORE it edits is what four
1001
+ * later folders depend on, and 18g at position seven is an expensive place to
1002
+ * find out. So this asks plainly, once, in the run's own terms, and the tester
1003
+ * checks the result against what actually changed in the checkout. Two passes
1004
+ * out of three is a failure. It is deliberately NOT reinforced with a gate, a
1005
+ * retry or a refusal: a probe that coerces the behaviour cannot measure it.
1006
+ *
1007
+ * ─── WHY IT IS ITS OWN BLOCK AND NOT A LINE IN `describeToolSurface` ────────
1008
+ *
1009
+ * Three reasons, and the third is the load-bearing one.
1010
+ *
1011
+ * 1. IT IS CONDITIONAL ON HAVING A REPOSITORY. A run with no checkout has
1012
+ * nothing to declare, and naming a tool that has no meaningful argument to
1013
+ * pass is the "named a tool that will refuse it" mistake `describeToolSurface`'s
1014
+ * own header is about.
1015
+ * 2. IT IS CONDITIONAL ON BEING ALLOWED TO WRITE. A planning run is told in
1016
+ * the strongest terms not to touch a file (`describePlanningPosture`), so
1017
+ * telling it to declare the files it is about to change would be two
1018
+ * instructions that contradict each other, and the agent gets to pick.
1019
+ * 3. THE TOOL SURFACE HAS A HARD CHARACTER BUDGET (test/tool-surface.test.mjs,
1020
+ * "R2: the guidance stays short enough to be read"). That cap exists so
1021
+ * growing the always-on guidance is a decision somebody makes rather than a
1022
+ * drift, and it has been raised once, deliberately, with the reasoning
1023
+ * recorded. This text is not always-on, so spending the budget on it would
1024
+ * charge every run for something only a building run in a checkout reads.
1025
+ *
1026
+ * ─── WHAT IT SAYS, AND WHY EACH PART IS THERE ───────────────────────────────
1027
+ *
1028
+ * BEFORE, STATED AS THE POINT RATHER THAN AS AN ORDERING DETAIL. The scenario is
1029
+ * "it shows them before anything in the checkout has changed". An agent told to
1030
+ * "reserve the paths you edit" reasonably reserves them as it goes, which
1031
+ * produces a declaration that is a log rather than a declaration and proves
1032
+ * nothing about the premise.
1033
+ *
1034
+ * THE ADDRESS IS GIVEN, NOT ASKED FOR. The run is standing in the checkout and
1035
+ * could read its own remote, but the spelling it would find is the checkout's,
1036
+ * which is exactly the mismatch gap 14 is about. Handing it the canonical value
1037
+ * the product already resolved removes a guess. The tool canonicalizes either
1038
+ * way now, so this is belt and braces rather than the fix.
1039
+ *
1040
+ * A REMOTE URL IS NOT A PATH, so this does not touch the invariant. It is a
1041
+ * shareable, path-free identifier: the same value `checkoutMissAnswer` already
1042
+ * prints to the user and the same one the cloud column stores. No absolute local
1043
+ * path appears here, and the paths the agent declares BACK are repo-relative:
1044
+ * the database CHECK refuses an absolute one outright.
1045
+ *
1046
+ * DECLARING AND LEAVING IT ALONE IS EXPLICITLY FINE. Story 8: "a file it named
1047
+ * but left alone is not a failure". Without that sentence an agent under-declares
1048
+ * to avoid being wrong, which biases the very measurement being taken.
1049
+ *
1050
+ * RELEASING IS MENTIONED AND NOT LEANED ON. Gap 15 is fixed in the product
1051
+ * (mcp.ts `releaseSessionReservations`), so the run finishing releases whatever
1052
+ * it declared whether or not the agent remembers. The line is here because an
1053
+ * agent that does release promptly frees a path earlier than its session end
1054
+ * would, and because a tool nobody is told about is a tool nobody calls, which
1055
+ * is the entire lesson this slice is built on.
1056
+ *
1057
+ * NOTHING HERE SEQUENCES, WAITS, ISOLATES, OFFERS OR MERGES. Story 8 declares,
1058
+ * matches and releases and stops. A conflict comes back as information; what a
1059
+ * collision should OFFER the user is 18g's and must not be implied here. The
1060
+ * text says so, because an agent told about conflicts and not told what to do
1061
+ * with one will invent a policy.
1062
+ */
1063
+ export function describeDeclarationDuty(gitRemoteUrl) {
1064
+ /* NO REPOSITORY, NOTHING TO DECLARE. Silence rather than a hedged sentence:
1065
+ the run has no codebase key to pass and no files to name. */
1066
+ if (!gitRemoteUrl)
1067
+ return [];
1068
+ return [
1069
+ '',
1070
+ 'BEFORE YOU CHANGE ANY FILE, SAY WHICH FILES YOU ARE ABOUT TO CHANGE. Work out which',
1071
+ 'ones the job needs, then call it ONCE, before the first edit:',
1072
+ ` reserve_work_paths({ git_remote_url: '${gitRemoteUrl}', paths: [...] })`,
1073
+ 'Paths are relative to the repository root: never absolute, and never a folder on',
1074
+ 'this machine. The user sees what you declared on this run\'s card while you work, so',
1075
+ 'declaring afterwards defeats the point of it.',
1076
+ '',
1077
+ 'Naming a file and then leaving it alone is fine. Changing one you did not name is',
1078
+ 'not, so if the work turns out to need a file you missed, declare that one too before',
1079
+ 'you touch it. If the call reports a conflict, that is information and nothing more:',
1080
+ 'it does not block you, and you decide what to do about it.',
1081
+ '',
1082
+ 'Call release_work_paths with the ids you were given once you have finished with',
1083
+ 'those files.',
1084
+ ];
1085
+ }
1086
+ /* ═══ !Cleanup PHASE 3 — THE WORKER KNOWS THE PRODUCT EXISTS. ═══
1087
+ *
1088
+ * THE GAP (I7). The worker is handed 50 tools as NAMES ONLY. Every word of
1089
+ * teaching this product has written — 42,192 characters across those 50
1090
+ * descriptions, plus 9,362 more in per-parameter `.describe()` strings — arrives
1091
+ * only once a tool has already been chosen, which is exactly the moment it is no
1092
+ * longer needed.
1093
+ *
1094
+ * THE FIGURE IS MEASURED FROM THE BUILT SERVER, not from the source, and that
1095
+ * distinction cost a correction. Counting the string literals in mcp.ts gives
1096
+ * ~26,500, because most descriptions are written as several `+`-concatenated
1097
+ * literals and a source-level count sees only the fragments. What an agent
1098
+ * actually receives is the registered string: 42,192. The plan's number was
1099
+ * right and is left alone.
1100
+ *
1101
+ * A worker that never looks never learns, so the tools that decide the shape of
1102
+ * a run —
1103
+ * skills, credentials, and the read-before-you-write ones — are named HERE, in
1104
+ * the prompt, in the order they are called.
1105
+ *
1106
+ * ONLY WHAT IT CAN ACTUALLY CALL. Verified against mcp.ts rather than assumed,
1107
+ * because naming a tool that will refuse teaches the worker the product is
1108
+ * broken — the standing lesson from `describePlanningPosture`, and the reason
1109
+ * this function takes a flag rather than printing one list.
1110
+ *
1111
+ * A panel worker has NO SESSION and structurally cannot open one: `begin_work`
1112
+ * needs a work item and `cliv2_agent_sessions.task_id` is NOT NULL (I12,
1113
+ * Phase 4). What it may call:
1114
+ *
1115
+ * read list_tasks, get_task, get_project_context, list_steps,
1116
+ * list_skills, get_skill, list_credentials, get_credential
1117
+ * write create_artifact (takes task_id), ask_question (takes todo_id),
1118
+ * report_blocked, and create_task / create_epic / create_sprint /
1119
+ * reorder_backlog ONLY when directed
1120
+ *
1121
+ * and what refuses it outright: begin_work, draw_live_view, present_mocks,
1122
+ * present_wireframes, create_stage, create_step, update_step, add_comment,
1123
+ * record_user_input, record_context_exploration, and every undirected gated
1124
+ * write.
1125
+ *
1126
+ * THE TRAP IN THE PHASE'S OWN SECOND HAPPY PATH, found by reading the gate
1127
+ * rather than the plan. "It offers, the user approves in the Inbox, and one work
1128
+ * item appears" cannot happen through the permission gate: `runDirected` falls
1129
+ * back to `runGated`, `runGated` calls `checkGrant`, and `checkGrant` with a
1130
+ * null session returns "call begin_work first" WITHOUT raising an Ask. For a
1131
+ * panel worker the undirected path is a dead end, not a consent round. The only
1132
+ * route to the experience the plan describes is `ask_question({ todo_id })`,
1133
+ * which is anchored on the request rather than on a session — so that is what
1134
+ * this text says, and it is why it says it in that order.
1135
+ */
1136
+ /**
1137
+ * THE TOOLS THAT DECIDE THE SHAPE OF A RUN, by role and in call order — I7,
1138
+ * I9, I10.
1139
+ *
1140
+ * TEN, NOT FIFTY. R2's standing objection to this phase is that guidance long
1141
+ * enough to be complete is guidance nobody reads, and I29 says the same about
1142
+ * the descriptions. So this names the ones a worker cannot do its job without
1143
+ * and lets the other forty be discovered: the read-first pair, the two that
1144
+ * make a stored process reachable, the two that make a stored secret reachable,
1145
+ * the one route to the user, and the two ways to leave something behind.
1146
+ *
1147
+ * THE INFORMATION-VERSUS-WORK RULE IS FIRST (I9), because it is the rule that
1148
+ * decides whether any of the rest applies. Nothing anywhere in the product
1149
+ * states it today and both entry points push the other way: the server's
1150
+ * standing instructions say to use a tool for everything, and the dispatch
1151
+ * prompt says to do the work with its tools. A worker that opens a work item
1152
+ * for every passing question fills the board with noise the user must clean up.
1153
+ */
1154
+ export function describeToolSurface(canUseSession = false,
1155
+ /* CAN IT REACH THE USER AT ALL? `ask_question` is anchored on the request id,
1156
+ and a prompt built without one (every caller before 16d, and the tests that
1157
+ pin that path) has no id to put in the call. Naming the tool anyway would
1158
+ tell the worker to call something with an argument it was never given —
1159
+ the same "named a tool that will refuse it" mistake this function's header
1160
+ is about, one level down. The orchestrator passes true whenever it has a
1161
+ todo id, which on the real dispatch path is always. */
1162
+ canAsk = true) {
1163
+ return [
1164
+ '',
1165
+ 'CTRL+SPC holds this user\'s real projects, work items and artifacts. FIRST, DECIDE',
1166
+ 'WHICH KIND OF REQUEST THIS IS — get it wrong and everything below is wrong too:',
1167
+ '',
1168
+ ' A QUESTION IS ANSWERED, NOT RECORDED. If the user asked what something is, how it',
1169
+ ' works, where it lives or why it was done that way, find out and say so. Create',
1170
+ ' NOTHING: no work item, no artifact, no epic, no sprint, no product idea, no',
1171
+ ' comment. If nothing about the codebase or the schema needs to change, nothing',
1172
+ ' needs to be filed either — an unwanted work item is worse than no answer, because',
1173
+ ' the user has to clean it up.',
1174
+ '',
1175
+ ' WORK IS DIFFERENT. If they asked you to change, build, fix or produce something,',
1176
+ ' do it — and record it as described below.',
1177
+ '',
1178
+ ...(canAsk
1179
+ ? [
1180
+ ' WHEN IT IS WORK BUT THEY DID NOT ASK YOU TO CREATE ANYTHING, OFFER FIRST. Say',
1181
+ ' what you would create and ask, using ask_question with this request\'s id. Never',
1182
+ ' create one silently, and never several on your own initiative.',
1183
+ ]
1184
+ : [
1185
+ ' WHEN IT IS WORK BUT THEY DID NOT ASK YOU TO CREATE ANYTHING, DO NOT CREATE IT.',
1186
+ ' Say in your answer what you would create and why, and let the user decide. Never',
1187
+ ' create one silently, and never several on your own initiative.',
1188
+ ]),
1189
+ '',
1190
+ 'BEFORE YOU CREATE ANYTHING, LOOK FOR IT with list_tasks (get_task for detail). If a',
1191
+ 'work item already covers this, name it and use it — a near-duplicate is refused,',
1192
+ 'and that is the right refusal.',
1193
+ '',
1194
+ 'THE PRODUCT MAY ALREADY KNOW HOW TO DO THIS. Before improvising a method:',
1195
+ ' - list_skills, then get_skill — the team\'s own written processes. A skill is a',
1196
+ ' document TO FOLLOW, not reference material to summarise. If one matches the work,',
1197
+ ' read it and follow it for the rest of the run.',
1198
+ /* ═══ 18d SLICE 4 — THIS LINE CONTRADICTED THE SHIPPED INSTRUCTION. ═══
1199
+
1200
+ Slice 3 rewrote CREDENTIAL_INSTRUCTION (mcp.ts) to lead with permission,
1201
+ because the old wording made claude fetch a credential and then refuse to
1202
+ use it across three real runs, ending "Blocked", while codex read the same
1203
+ words and made the call. That rewrite explicitly permits "a config file on
1204
+ this machine".
1205
+
1206
+ THIS LINE STILL CARRIED THE OLD RULE, and it arrives FIRST — at the top of
1207
+ every worker's prompt, hundreds of turns before the tool result. So the
1208
+ agent's first and most recent word on the subject disagreed, and the
1209
+ posture story 3 needs (be refused, then do NOT route around it) is exactly
1210
+ where a contradictory instruction reproduces the "Blocked" ending.
1211
+
1212
+ NET-NEUTRAL BY CONSTRUCTION. tool-surface.test.mjs caps this guidance at
1213
+ 3440 chars with 16 to spare and three raise arguments already rejected on
1214
+ the record, so the replacement is measured, not argued. */
1215
+ ' - list_credentials, then get_credential — secrets the user already stored. If the',
1216
+ ' work needs one, FETCH IT and USE it, never ask for it. Keep the VALUE out of',
1217
+ ' your answer, a commit or an artifact; name the credential instead.',
1218
+ ' - get_project_context — the instructions, architecture, design and conventions the',
1219
+ ' team keeps here instead of in a repo.',
1220
+ '',
1221
+ ...(canAsk
1222
+ ? [
1223
+ 'TO REACH THE USER there is one route: ask_question. Not a terminal prompt, which',
1224
+ 'nobody is watching, and not a question in your answer, which nobody can answer.',
1225
+ '',
1226
+ ]
1227
+ : []),
1228
+ 'TO LEAVE SOMETHING BEHIND: create_artifact for anything that outlives the run — a',
1229
+ 'spec, a plan, a document, a mock. Never a loose file, and never only in your answer.',
1230
+ /* !Cleanup PHASE 7 SLICE 1b — EVERY RUN OPENS A SESSION NOW.
1231
+ USER RULING, 2026-08-06: "Panel agents absolutely must be able to do
1232
+ anything another agent can do."
1233
+
1234
+ WHAT THIS REPLACED, because the reversal matters: Phase 4 could not widen
1235
+ the session anchor, so a panel worker had no session and the whole block
1236
+ below was gated off. It compensated by having the worker ASK for a work
1237
+ item — right when the work deserves one, wrong as the only route, because
1238
+ it made "can this agent record a step?" depend on whether the user agreed
1239
+ to put a row on their board. Slice 1b made `cliv2_agent_sessions.task_id`
1240
+ nullable and added `todo_id`, so a panel run opens a session on its
1241
+ REQUEST and the tools stop refusing it.
1242
+
1243
+ The ask-for-a-work-item route (Phase 4) is KEPT below, because it is still
1244
+ correct for its own reason: work that genuinely has to be built deserves a
1245
+ visible item. It is no longer the price of using the tools. */
1246
+ '',
1247
+ /* !Cleanup Phase 7 Slice 1b: the ANCHORS NAMED depend on what this run
1248
+ actually has. `todo_id` is only mentionable when there IS a request id —
1249
+ 16d's rule, and it is right: a worker told to pass an id it was never
1250
+ given has a dead instruction, which is I8 one level down. */
1251
+ ...(canAsk
1252
+ ? [
1253
+ 'OPEN A SESSION FIRST: begin_work with task_id if there is a work item above, or',
1254
+ 'todo_id (this request) if not. It attributes what you produce to this run, and most',
1255
+ 'tools refuse without it. end_work when you finish.',
1256
+ ]
1257
+ : [
1258
+ 'OPEN A SESSION FIRST: begin_work({ task_id }) on the work item above. It attributes',
1259
+ 'what you produce to this run, and most tools refuse without it. end_work when you',
1260
+ 'finish.',
1261
+ ]),
1262
+ '',
1263
+ /* ═══ 18c SLICE 3 — WHICH SURFACE THIS RUN ACTUALLY HAS. ═══
1264
+
1265
+ This block used to name create_stage / create_step / update_step
1266
+ unconditionally. Slice 3 closed those tools to runs with no work item,
1267
+ which turned the instruction into the I8 defect one level up: a worker
1268
+ steered by its own prompt into a route that now refuses it. The gate
1269
+ measured the cost. Codex read the refusal, could not tell a closed route
1270
+ from a bad id, went looking for a work item to borrow, and tried to hang
1271
+ a panel request's plan on an unrelated real one.
1272
+
1273
+ `canUseSession` IS "this run has a work item" (the orchestrator passes
1274
+ `context.items.length > 0`), which is exactly the condition the three
1275
+ tools now require, so the two cannot drift apart.
1276
+
1277
+ report_activity is named in BOTH branches, because every run writes it
1278
+ and the user watches it live either way. What differs is only whether
1279
+ there is a plan to commit alongside it. */
1280
+ 'TO SHOW YOUR WORK AS IT HAPPENS: report_activity({ title }) whenever what you are',
1281
+ 'doing changes. The user watches live.',
1282
+ /* THE PLAN IS A SEPARATE ACT FROM SAYING WHAT YOU ARE DOING, and only one of
1283
+ them is available to every run. See the banner above. */
1284
+ ...(canUseSession
1285
+ ? [
1286
+ 'TO COMMIT THE PLAN: create_stage and create_step, then update_step as it changes,',
1287
+ 'in_progress when you start, done when finished. That is how any agent, on any',
1288
+ 'machine, picks the work up after you are gone.',
1289
+ /* ═══ 18d SLICE 4 — WHERE A CONTINUATION LEARNS THE TOOL EXISTS. ═══
1290
+
1291
+ start_workflow's `remember` carries the same instruction, and that
1292
+ is NOT enough on its own: the run that picks up stage 2 never calls
1293
+ start_workflow (the workflow is already started, and a re-run is
1294
+ refused), so it would never hear of hand_off_stage and stage 3
1295
+ would never be reached. A two-stage fixture hides this entirely,
1296
+ which is why the walk uses three.
1297
+
1298
+ CHEAP BECAUSE IT IS CONDITIONAL. Only a run with a work item can
1299
+ have a process at all, so this costs nothing to the tools-only
1300
+ path, and the tool's own refusals cover a run whose item has no
1301
+ workflow stages. */
1302
+ 'IF THE ITEM CARRIES A WORKFLOW: work ONE stage. When its last step is done, call',
1303
+ 'hand_off_stage and end your run — a fresh agent takes the next stage from the',
1304
+ 'record. Never work two stages, and never hand over notes instead of records.',
1305
+ ]
1306
+ : [
1307
+ 'Steps are a WORK ITEM\'s plan and this has none, so create_stage refuses. Ask',
1308
+ 'for an item if this needs one, never borrow.',
1309
+ ]),
1310
+ /* ═══ 18c SLICE 7, GAP 8 — THE WORKER IS TOLD SCREENSHOTS EXIST. ═══
1311
+ Spec: ux.md § "Slice 7", and story 4's scenario is the test: the user
1312
+ types "show me what the recruiter search screen looks like at
1313
+ http://localhost:8000/" and never says the word screenshot, and an image
1314
+ has to come back rather than a description.
1315
+
1316
+ IT HAD NEVER BEEN NAMED HERE. This block named present_mocks,
1317
+ present_wireframes and draw_live_view; `attach_screenshot` appeared in
1318
+ nothing a worker reads except the server instruction listing the tools
1319
+ that REFUSE without a work item. So the single mention of the capability
1320
+ in everything an agent ever saw was a statement of its limitation, which
1321
+ is exactly why the tool shipped complete and uninvoked. Same failure as
1322
+ skills and credentials before I10 named them.
1323
+
1324
+ UNCONDITIONAL, unlike the three around it, because Slice 7 is what makes
1325
+ that true: the tool now attaches to whichever anchor the session has, so
1326
+ there is no branch where naming it would be naming a tool that refuses —
1327
+ the mistake this function's header is about.
1328
+
1329
+ IT SAYS WHEN, NOT JUST WHAT. "Whenever seeing it answers better than
1330
+ reading about it" is the trigger the scenario needs; a worker told only
1331
+ that the tool exists still writes a paragraph, because describing the
1332
+ screen is what it would have done anyway. */
1333
+ /* ═══ 18c SLICE 7, CORRECTED AT THE WALK — IT SAID "take a PNG", WHICH THE
1334
+ WORKER CANNOT DO. ═══
1335
+
1336
+ Naming the tool (above) was necessary and was not sufficient. The walk ran
1337
+ story 4 on both platforms and every run finished `done` with no screenshot
1338
+ and no tool call: `--permission-mode acceptEdits` approves file edits and
1339
+ nothing else, so a headless worker's every non-read-only shell command is
1340
+ refused by a CLI with no human to approve it. "Take a PNG" was an
1341
+ instruction to do the one thing the run could not do, and an agent that
1342
+ tries and is refused falls back to describing the screen in prose — which
1343
+ is exactly what the walk saw.
1344
+
1345
+ SO THE SENTENCE NAMES THE URL, NOT THE PNG. The product captures it now
1346
+ (`captureUrlScreenshot`), and the worker's whole job is to pass the
1347
+ address. Saying "you do not need a browser or a shell" is not padding: an
1348
+ agent that has already been refused a command reasons that pictures are
1349
+ impossible in this run, and has to be told otherwise in words. */
1350
+ '',
1351
+ 'TO SHOW THE USER A REAL SCREEN: attach_screenshot({ url }) — CTRL+SPC opens the page',
1352
+ 'and captures it, so you need no browser and no shell. Whenever seeing it',
1353
+ 'answers better than reading about it, with or without a work item.',
1354
+ ...(canUseSession
1355
+ ? [
1356
+ 'ALSO: present_mocks or present_wireframes for something the user reviews;',
1357
+ 'draw_live_view for what you are working through right now, which is a picture of',
1358
+ 'the present and not a record. Anything that must outlive the run is an artifact, a',
1359
+ 'step or a comment.',
1360
+ ]
1361
+ : canAsk
1362
+ ? [
1363
+ /* PHASE 4 (I12), kept and SHORTENED by Slice 1b. It used to have to
1364
+ explain that the worker could not record anything without an item;
1365
+ it can now, so only the reason an item is still worth having
1366
+ remains. `present_mocks` and `draw_live_view` are the tools that
1367
+ genuinely need one.
1368
+
1369
+ 18c Slice 7: the blank line stays, and is now this block's own
1370
+ separator rather than the picture block's. The screenshot lines
1371
+ above are two sentences on one subject and this is a different
1372
+ one, so running them together loses the paragraph break the whole
1373
+ guidance is scanned by. */
1374
+ '',
1375
+ 'IF SOMETHING MUST ACTUALLY BE BUILT — code, a schema change, a real deliverable',
1376
+ '— ASK FOR A WORK ITEM, with these EXACT values:',
1377
+ /* 18b SLICE 5 — THE QUESTION MUST BE A QUESTION (user, 2026-08-08).
1378
+ This used to say `question: <a specific name for the work item>`,
1379
+ because the web read that field verbatim as the created item's
1380
+ title. So the field labelled "Question" held a noun phrase, and
1381
+ the verb had to live in the options ("Create the work item" /
1382
+ "Not now"). `name` now carries the title, freeing the question to
1383
+ be one. Kept to the same length as what it replaced: the guidance
1384
+ has a hard character budget (test/tool-surface.test.mjs), and the
1385
+ shape below teaches the rule by showing it. */
1386
+ " ask_question({ todo_id, category: 'work-item', context: <what you would build",
1387
+ " and why>, name: 'Rework search ranking', question: 'Create a work item called",
1388
+ ' “Rework search ranking”?\', answer_mode: \'single_select\', options: [\'Yes\', \'No\'] })',
1389
+ '`name` is the bare title and becomes the work item verbatim; the question ASKS.',
1390
+ 'Then STOP. Answering it creates the item and runs you again. Not for answering,',
1391
+ 'explaining or looking things up, and never twice for the same thing.',
1392
+ ]
1393
+ : []),
1394
+ ];
1395
+ }
1396
+ /**
1397
+ * WHY THERE IS NOWHERE TO WORK, in the user's words — the phase's unhappy path.
1398
+ *
1399
+ * Returned as the request's ANSWER rather than raised as a failure, deliberately.
1400
+ * A released unit is retried forever, and no number of retries will make a repo
1401
+ * appear on this machine: the fix is a human locating it in the companion. So
1402
+ * this is a finished request that says what to do, not a dead one that keeps
1403
+ * trying. Nothing is spawned and nothing is modified.
1404
+ */
1405
+ export function checkoutMissAnswer(miss) {
1406
+ switch (miss.kind) {
1407
+ case 'no-codebase':
1408
+ return [
1409
+ `This request needs the code, but ${miss.projectName || 'its project'} has no codebase registered.`,
1410
+ '',
1411
+ 'Add one in the project\'s settings (Codebases → Add codebase), then send this again.',
1412
+ ].join('\n');
1413
+ case 'not-located':
1414
+ return [
1415
+ `This request needs the code, but ${miss.gitRemoteUrl} has not been located on this computer.`,
1416
+ '',
1417
+ 'Open the CTRL+SPC companion on this machine and use Locate… on that codebase to point',
1418
+ 'it at your checkout, then send this again. Nothing was changed.',
1419
+ ].join('\n');
1420
+ case 'path-missing':
1421
+ return [
1422
+ `This request needs the code. ${miss.gitRemoteUrl} was located on this computer before,`,
1423
+ 'but that folder is gone — it was moved, renamed or deleted.',
1424
+ '',
1425
+ 'Open the CTRL+SPC companion and use Locate… to point it at the checkout again, then',
1426
+ 'send this again. Nothing was changed.',
1427
+ ].join('\n');
1428
+ /* 18b Slice 2. NAMES THE CANDIDATES, because "several codebases" leaves the
1429
+ user to go and find out which — and this refusal exists precisely so they
1430
+ do not have to. Choosing one from the card is Slice 4; until then the fix
1431
+ is to set a target, which is a real thing the user can do today. */
1432
+ case 'ambiguous-codebase':
1433
+ return [
1434
+ `This request needs the code, but ${miss.projectName} has ${miss.gitRemoteUrls.length} codebases and`,
1435
+ 'nothing says which one to work in:',
1436
+ '',
1437
+ ...miss.gitRemoteUrls.map(url => ` · ${url}`),
1438
+ '',
1439
+ 'Set the target codebase in the project\'s settings, then send this again. Nothing',
1440
+ 'was changed.',
1441
+ ].join('\n');
1442
+ }
1443
+ }
1444
+ /**
1445
+ * !Cleanup PHASE 6b (I43) — TELL A REPLACEMENT THAT IT IS ONE.
1446
+ *
1447
+ * THE USER'S RULING, 2026-08-06: *"If a subagent dies, the subagent that picks
1448
+ * it up must check the current state before working."*
1449
+ *
1450
+ * THE DEFECT THIS CLOSES. When a worker died the unit was released and a FRESH
1451
+ * agent spawned with the ORIGINAL instruction and no idea any of it had happened.
1452
+ * Meanwhile everything the dead run wrote is still there — work items,
1453
+ * artifacts, steps, comments — and anything it did outside CTRL+SPC (files
1454
+ * edited, a build run, a commit made) is not visible to any tool at all. So the
1455
+ * retry did not resume, it REDID, on top of a board already holding half the
1456
+ * work. The user cleans up the duplicates.
1457
+ *
1458
+ * WHY THIS IS NOT COVERED BY THE EXISTING "LOOK FOR IT FIRST" LINE. That line is
1459
+ * generic advice a fresh agent may reasonably skim past on a task it believes is
1460
+ * new. This is a specific, load-bearing FACT — *a previous attempt at this exact
1461
+ * request already ran and stopped partway* — and it changes what the agent
1462
+ * should do first. Guidance and a fact are not interchangeable here.
1463
+ *
1464
+ * DELIBERATELY NOT A SUMMARY OF WHAT THE DEAD RUN DID. The honest position is
1465
+ * that the product does not know: no creation tool records which request it was
1466
+ * working (that is I19, its own phase), so any list this text produced would be
1467
+ * inferred and would be WRONG exactly when it mattered — naming work the run did
1468
+ * not do, or missing work it did. Telling the agent to LOOK is true; telling it
1469
+ * what it will find is not. The step line is the one honest exception: it is
1470
+ * recorded against this unit's worker, so it is quoted when present.
1471
+ */
1472
+ export function describeInterruptedRun(lastStep) {
1473
+ const lines = [
1474
+ '',
1475
+ 'A PREVIOUS ATTEMPT AT THIS REQUEST ALREADY RAN AND DID NOT FINISH. Its agent',
1476
+ 'stopped partway — killed, crashed, or the machine went away — so anything it had',
1477
+ 'already done IS STILL THERE. You are continuing, not starting.',
1478
+ ];
1479
+ if (lastStep) {
1480
+ lines.push('', `The last thing it was seen doing: ${lastStep}`, 'That is where it stopped, not necessarily where it got to.');
1481
+ }
1482
+ lines.push('', 'SO CHECK BEFORE YOU BUILD. Call list_tasks and get_task, and look at the actual', 'files, before creating anything. If the work item, artifact or change you were', 'about to make already exists, USE IT and carry on from there — do not make a', 'second one. What you cannot see through the tools (a file that was edited, a', 'command that was run) you must check directly.', '', 'If it turns out the work was already finished, say so and stop. A duplicate is', 'worse than a no-op, because the user has to find it and clean it up.');
1483
+ return lines;
1484
+ }