@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.
- package/dist/agents.js +70 -5
- package/dist/autostart.js +15 -1
- package/dist/browser.js +386 -0
- package/dist/codebases.js +15 -0
- package/dist/codex-home.js +562 -0
- package/dist/companion.js +9 -12
- package/dist/config.js +23 -0
- package/dist/daemon.js +33 -3
- package/dist/env.js +42 -0
- package/dist/failure-reason.js +98 -0
- package/dist/index.js +1 -1
- package/dist/mcp.js +7627 -298
- package/dist/orchestrator.js +6011 -0
- package/dist/panel3/answer.js +166 -0
- package/dist/panel3/checkout.js +29 -0
- package/dist/panel3/cli.js +83 -0
- package/dist/panel3/client.js +181 -0
- package/dist/panel3/coordinator.js +18 -0
- package/dist/panel3/presence.js +162 -0
- package/dist/panel3/prompt.js +945 -0
- package/dist/panel3/run.js +2516 -0
- package/dist/panel3/say.js +262 -0
- package/dist/panel3/secrets.js +98 -0
- package/dist/panel3/session.js +128 -0
- package/dist/panel3/show.js +997 -0
- package/dist/panel3/spawn.js +565 -0
- package/dist/panel3/tools.js +1906 -0
- package/dist/presence.js +178 -6
- package/dist/win-shell.js +162 -0
- package/dist/work-context.js +1484 -0
- package/dist/workflows.js +68 -0
- package/package.json +3 -2
|
@@ -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
|
+
}
|