@ctrl-spc/cs 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp.js +3690 -50
- package/dist/screenshots.js +196 -0
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { createServer as createHttpServer } from 'node:http';
|
|
@@ -9,6 +9,8 @@ import { z } from 'zod';
|
|
|
9
9
|
import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
|
|
10
10
|
import { agentPath } from './agents.js';
|
|
11
11
|
import { mcpToken, readSession } from './config.js';
|
|
12
|
+
import { readPngScreenshot, } from './screenshots.js';
|
|
13
|
+
import { hostedRemoteIdentity } from './git-remote.js';
|
|
12
14
|
export function attributionFromClientName(name) {
|
|
13
15
|
if (!name)
|
|
14
16
|
return null;
|
|
@@ -18,6 +20,50 @@ export function attributionFromClientName(name) {
|
|
|
18
20
|
return 'codex';
|
|
19
21
|
return null;
|
|
20
22
|
}
|
|
23
|
+
/** 31 Slice 2: the grounding manifest — what the author READ before writing.
|
|
24
|
+
* The three keys are a CONTRACT with the web parser
|
|
25
|
+
* (web/src/lib/artifactGrounding.ts ID_LIST_KEYS): anything else stored in
|
|
26
|
+
* the column renders as no grounding, so the keys are pinned here and
|
|
27
|
+
* validated in the handler rather than left to convention. */
|
|
28
|
+
const GROUNDING_KEYS = ['client_context_record_ids', 'project_document_ids', 'codebases'];
|
|
29
|
+
/**
|
|
30
|
+
* Validate a grounding manifest into exactly what will be stored, or refuse
|
|
31
|
+
* it (31 Slice 2). Enforcement stays MANIFEST ONLY — nothing here inspects
|
|
32
|
+
* whether the reads happened; it only refuses a manifest the web parser
|
|
33
|
+
* could never render, which would otherwise be a provenance claim that
|
|
34
|
+
* silently reads as no claim at all.
|
|
35
|
+
*/
|
|
36
|
+
function validateGrounding(value) {
|
|
37
|
+
const refuse = (message) => ({
|
|
38
|
+
ok: false,
|
|
39
|
+
error: errorResult(`create_artifact: ${message} Nothing was created.`),
|
|
40
|
+
});
|
|
41
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
42
|
+
return refuse('grounding must be an object of id lists: { client_context_record_ids, project_document_ids, codebases }.');
|
|
43
|
+
}
|
|
44
|
+
const manifest = {};
|
|
45
|
+
let named = 0;
|
|
46
|
+
for (const [key, lists] of Object.entries(value)) {
|
|
47
|
+
if (!GROUNDING_KEYS.includes(key)) {
|
|
48
|
+
return refuse(`grounding key "${key}" is not part of the manifest contract — use only ` +
|
|
49
|
+
`${GROUNDING_KEYS.join(' / ')} (anything else renders as NO grounding in the web app).`);
|
|
50
|
+
}
|
|
51
|
+
if (lists === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
if (!Array.isArray(lists) || lists.some((entry) => typeof entry !== 'string' || !entry.trim())) {
|
|
54
|
+
return refuse(`grounding.${key} must be an array of non-empty strings.`);
|
|
55
|
+
}
|
|
56
|
+
if (lists.length === 0)
|
|
57
|
+
continue;
|
|
58
|
+
manifest[key] = lists.map((entry) => entry.trim());
|
|
59
|
+
named += lists.length;
|
|
60
|
+
}
|
|
61
|
+
if (named === 0) {
|
|
62
|
+
return refuse('this grounding manifest names nothing you read — omit `grounding` instead, and say in the ' +
|
|
63
|
+
'artifact itself that available context went unread.');
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, value: manifest };
|
|
66
|
+
}
|
|
21
67
|
/** Awaits a Supabase call and throws on error — callers catch once, at the
|
|
22
68
|
* handler boundary (mirrors v1's `must`). */
|
|
23
69
|
async function must(query) {
|
|
@@ -26,6 +72,12 @@ async function must(query) {
|
|
|
26
72
|
throw new Error(error.message);
|
|
27
73
|
return data;
|
|
28
74
|
}
|
|
75
|
+
/** The message of a thrown value, WITHOUT assuming it is an `Error`. `(err as
|
|
76
|
+
* Error).message` is a lie the type system permits: a thrown string, a
|
|
77
|
+
* `DOMException` from an aborted fetch, or a rejected non-Error makes it a
|
|
78
|
+
* `TypeError` raised from inside the very catch block that exists to contain
|
|
79
|
+
* failures — which escapes the catch and defeats it. */
|
|
80
|
+
const errorMessage = (err) => (err instanceof Error ? err.message : String(err));
|
|
29
81
|
function textResult(payload) {
|
|
30
82
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
31
83
|
}
|
|
@@ -60,7 +112,13 @@ async function listTasksHandler(client, args) {
|
|
|
60
112
|
let query = client
|
|
61
113
|
.from('tasks')
|
|
62
114
|
.select(`id, project_id, name, status, due_date, ${TASK_TAGS}`)
|
|
63
|
-
.is('archived_at', null)
|
|
115
|
+
.is('archived_at', null)
|
|
116
|
+
// 22b: a Product Idea is a `tasks` row the organization has NOT agreed to
|
|
117
|
+
// build. It is not backlog work, and an agent that cannot tell the
|
|
118
|
+
// difference will happily start on one — so it never appears here. The
|
|
119
|
+
// agent surface for ideas is a later slice; until then they are invisible
|
|
120
|
+
// to tools, exactly as they are to the board.
|
|
121
|
+
.eq('is_idea', false);
|
|
64
122
|
if (args.project_id)
|
|
65
123
|
query = query.eq('project_id', args.project_id);
|
|
66
124
|
const rows = (await must(query
|
|
@@ -143,7 +201,32 @@ async function fetchFeedbackRounds(client, taskId) {
|
|
|
143
201
|
* Adapted from v1's `getTaskHandler` for the column shapes, with every
|
|
144
202
|
* topology / checkout / coordination section dropped.
|
|
145
203
|
*/
|
|
146
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Fetch a task's live artifacts WITH their grounding manifests (31 Slice 2),
|
|
206
|
+
* degrading gracefully against a database that predates migration
|
|
207
|
+
* 20260730150000 — the fetchFeedbackRounds posture, for the same reason: the
|
|
208
|
+
* published CLI is versioned independently of DB migrations, and get_task
|
|
209
|
+
* must not die because one optional column is missing. Any OTHER error still
|
|
210
|
+
* throws, so genuinely failed queries surface through get_task's normal
|
|
211
|
+
* error path.
|
|
212
|
+
*/
|
|
213
|
+
async function fetchTaskArtifacts(client, taskId) {
|
|
214
|
+
const artifactsQuery = (columns) => client
|
|
215
|
+
.from('artifacts')
|
|
216
|
+
.select(columns)
|
|
217
|
+
.eq('task_id', taskId)
|
|
218
|
+
.is('deleted_at', null)
|
|
219
|
+
.order('created_at', { ascending: true })
|
|
220
|
+
.order('id', { ascending: true });
|
|
221
|
+
const withGrounding = await artifactsQuery(`${ARTIFACT_COLUMNS},grounding`);
|
|
222
|
+
if (!withGrounding.error)
|
|
223
|
+
return (withGrounding.data ?? []);
|
|
224
|
+
if (!isUndefinedColumnError(withGrounding.error))
|
|
225
|
+
throw new Error(withGrounding.error.message);
|
|
226
|
+
const base = await must(artifactsQuery(ARTIFACT_COLUMNS));
|
|
227
|
+
return base ?? [];
|
|
228
|
+
}
|
|
229
|
+
export async function getTaskHandler(client, args) {
|
|
147
230
|
try {
|
|
148
231
|
const row = await must(client
|
|
149
232
|
.from('tasks')
|
|
@@ -153,20 +236,17 @@ async function getTaskHandler(client, args) {
|
|
|
153
236
|
.maybeSingle());
|
|
154
237
|
if (!row)
|
|
155
238
|
return errorResult(`No task found for id "${args.id}".`);
|
|
156
|
-
const [comments, artifacts, decisions, feedback] = await Promise.all([
|
|
239
|
+
const [comments, artifacts, decisions, feedback, openFlags] = await Promise.all([
|
|
157
240
|
must(client
|
|
158
241
|
.from('comments')
|
|
159
242
|
.select('id,task_id,body,author_id,from_agent,agent_run_id,created_at,updated_at')
|
|
160
243
|
.eq('task_id', row.id)
|
|
161
244
|
.order('created_at', { ascending: true })
|
|
162
245
|
.order('id', { ascending: true })),
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
.is('deleted_at', null)
|
|
168
|
-
.order('created_at', { ascending: true })
|
|
169
|
-
.order('id', { ascending: true })),
|
|
246
|
+
// 31 Slice 2: artifacts ride with their grounding manifests (nullable
|
|
247
|
+
// column, degrade-tolerant helper) so a reader can tell what an
|
|
248
|
+
// artifact's author read — and, by absence, what it did not.
|
|
249
|
+
fetchTaskArtifacts(client, row.id),
|
|
170
250
|
// D3: the item's decisions — the questions ask_question opened and the
|
|
171
251
|
// answers the user gave in the web UI, so the agent can read them back.
|
|
172
252
|
must(client
|
|
@@ -186,12 +266,79 @@ async function getTaskHandler(client, args) {
|
|
|
186
266
|
// a pre-20260724210000 database the helper degrades to the base columns
|
|
187
267
|
// with defaulted resolution state rather than failing the whole read.
|
|
188
268
|
fetchFeedbackRounds(client, row.id),
|
|
269
|
+
// 24c Slice 2: the item's OPEN flags — what the human-invoked
|
|
270
|
+
// apply-flags agent was handed. Dismissed/resolved flags are a human's
|
|
271
|
+
// settled judgement and are deliberately not surfaced here.
|
|
272
|
+
must(client
|
|
273
|
+
.from('cliv2_work_item_flags')
|
|
274
|
+
.select('id, suggested_edit, quote, source_id, agent_name, raised_at')
|
|
275
|
+
.eq('work_item_id', row.id)
|
|
276
|
+
.eq('status', 'open')
|
|
277
|
+
.order('raised_at', { ascending: true })
|
|
278
|
+
.order('id', { ascending: true })),
|
|
189
279
|
]);
|
|
280
|
+
// Source attribution for the flags (which material the quote lives in) —
|
|
281
|
+
// read only when flags exist, so an unflagged item costs nothing extra.
|
|
282
|
+
const flagList = openFlags ?? [];
|
|
283
|
+
let flagsSection = {};
|
|
284
|
+
if (flagList.length > 0) {
|
|
285
|
+
const flagSourceIds = [...new Set(flagList.map((flag) => flag.source_id))];
|
|
286
|
+
const flagSources = (await must(client
|
|
287
|
+
.from('client_sources')
|
|
288
|
+
.select('id, kind, from_name, happened_at')
|
|
289
|
+
.in('id', flagSourceIds))) ?? [];
|
|
290
|
+
const sourceById = new Map(flagSources.map((source) => [source.id, source]));
|
|
291
|
+
flagsSection = {
|
|
292
|
+
flags: flagList.map((flag) => {
|
|
293
|
+
const source = sourceById.get(flag.source_id);
|
|
294
|
+
return {
|
|
295
|
+
id: flag.id,
|
|
296
|
+
suggested_edit: flag.suggested_edit,
|
|
297
|
+
quote: flag.quote,
|
|
298
|
+
source_id: flag.source_id,
|
|
299
|
+
// Attribution degrades visibly, never silently: a source the
|
|
300
|
+
// caller cannot resolve reads as unresolved, not omitted.
|
|
301
|
+
source_kind: source?.kind ?? null,
|
|
302
|
+
source_from: source?.from_name ?? null,
|
|
303
|
+
source_happened_at: source?.happened_at ?? null,
|
|
304
|
+
agent_name: flag.agent_name,
|
|
305
|
+
raised_at: flag.raised_at,
|
|
306
|
+
};
|
|
307
|
+
}),
|
|
308
|
+
flags_note: 'These flags were raised by an intake agent because newer client material contradicts this ' +
|
|
309
|
+
'item. They are guidance, not patches: act on them ONLY because the human handed you this ' +
|
|
310
|
+
'item. When the edits are made, call resolve_work_item_flags with this work item id.',
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
// Grounding presence, made legible in the listing: a null manifest is
|
|
314
|
+
// DROPPED (an ungrounded artifact claims nothing — an ever-present null
|
|
315
|
+
// would train agents to skim past the real ones), and one note explains
|
|
316
|
+
// the key when at least one artifact carries it.
|
|
317
|
+
const artifactList = (artifacts ?? []).map((artifact) => {
|
|
318
|
+
const { grounding, ...rest } = artifact;
|
|
319
|
+
return grounding !== null && grounding !== undefined ? { ...rest, grounding } : rest;
|
|
320
|
+
});
|
|
321
|
+
const anyGrounded = artifactList.some((artifact) => 'grounding' in artifact);
|
|
190
322
|
const { task_tags: _drop, ...task } = row;
|
|
191
323
|
return textResult({
|
|
192
324
|
task: { ...task, tags: tagsOf(row) },
|
|
325
|
+
// 22b Slice 4: FIRST key after the task when the item is an idea, so it
|
|
326
|
+
// cannot be skimmed past. The flag alone (is_idea buried among columns)
|
|
327
|
+
// told an agent nothing, and every sentence about the boundary lived in
|
|
328
|
+
// tools this flow never calls.
|
|
329
|
+
...(row.is_idea === true ? { product_idea_boundary: IDEA_BOUNDARY } : {}),
|
|
330
|
+
// 24c Slice 2: present ONLY when open flags exist — an empty flags
|
|
331
|
+
// shell on every item would train agents to skim past the real ones.
|
|
332
|
+
...flagsSection,
|
|
193
333
|
comments: comments ?? [],
|
|
194
|
-
artifacts:
|
|
334
|
+
artifacts: artifactList,
|
|
335
|
+
...(anyGrounded
|
|
336
|
+
? {
|
|
337
|
+
artifacts_grounding_note: 'Artifacts carrying `grounding` were written after reading the named context ' +
|
|
338
|
+
'(client_context_record_ids / project_document_ids / codebases). An artifact without ' +
|
|
339
|
+
'one claims no grounding — its text should say why available context went unread.',
|
|
340
|
+
}
|
|
341
|
+
: {}),
|
|
195
342
|
decisions: decisions ?? [],
|
|
196
343
|
feedback: feedback ?? [],
|
|
197
344
|
});
|
|
@@ -211,7 +358,211 @@ async function getTaskHandler(client, args) {
|
|
|
211
358
|
* `from_agent` is rejected ("agent artifacts must be created through
|
|
212
359
|
* create_agent_artifact"), so un-coordinated tool use writes as the user.
|
|
213
360
|
*/
|
|
214
|
-
|
|
361
|
+
/* 22b Slice 2: an idea has not been agreed to, so a plan for BUILDING it is a
|
|
362
|
+
category error — the web does not offer the type while `is_idea`, and the two
|
|
363
|
+
tools that can set a type refuse it. Enforced at the tool rather than in the
|
|
364
|
+
database by the user's ruling; the residual (a service-key caller going
|
|
365
|
+
straight to PostgREST) is named in the feature's ux.md. */
|
|
366
|
+
const PLAN_ON_IDEA_REFUSAL = 'A Product Idea cannot carry an implementation plan. Promote it to the Backlog first, or record this as an analysis or spec instead.';
|
|
367
|
+
/* 22b Slice 3 — an agent turning a signal into Product Ideas.
|
|
368
|
+
Two tools, because everything else it needs already ships. */
|
|
369
|
+
/** The Description of a Product Idea is the human's own thinking (22b, user
|
|
370
|
+
ruling): the agent may fill an EMPTY one, and may never overwrite or edit one
|
|
371
|
+
that has content. Its own analysis belongs in an artifact. */
|
|
372
|
+
/** A Product Idea has no status. The web hides the control (22b user ruling)
|
|
373
|
+
because "Backlog" under a banner saying the item is NOT in the Backlog is a
|
|
374
|
+
contradiction — and an agent that sets `in_progress` on an idea pre-starts
|
|
375
|
+
work nobody agreed to, invisibly, since the board does not render ideas. */
|
|
376
|
+
const STATUS_ON_IDEA_REFUSAL = 'A Product Idea has no status — it is not on the board, and nothing about it has been agreed to. Promote it first (a human does that in the web app); then it can move through Backlog / In Progress / Done.';
|
|
377
|
+
/* 22b Slice 4 — the boundary, stated where the agent actually reads it.
|
|
378
|
+
An agent is handed an idea id through /ctrl-spc work <id> or a human saying
|
|
379
|
+
"look at wi-4821"; the flows that carry the Concept guidance
|
|
380
|
+
(list_product_ideas, create_product_idea) are never called. get_task is the
|
|
381
|
+
read every such flow passes through, so the read is where the item says what
|
|
382
|
+
it is. "Do not implement" has NO enforcement point — an agent edits files
|
|
383
|
+
directly, outside this tool surface — so it is stated, honestly, as a rule
|
|
384
|
+
rather than pretended to be a guard. The other three rules are backed by
|
|
385
|
+
refusals that already ship (Slices 2 and 3). */
|
|
386
|
+
const IDEA_BOUNDARY = {
|
|
387
|
+
what_this_is: 'This work item is a PRODUCT IDEA — pre-backlog. Nobody has agreed to build it.',
|
|
388
|
+
you_may: [
|
|
389
|
+
'Explore it: read the codebase, and write what you find as artifacts on this item (create_artifact — analysis, spec, diagram, mock, wireframe, user_story).',
|
|
390
|
+
"Write the description ONLY if it is empty. It is the human's own thinking, and one with content cannot be changed.",
|
|
391
|
+
],
|
|
392
|
+
you_may_not: [
|
|
393
|
+
'Change production code for it. Exploration is not implementation — no source files, no migrations, nothing that ships.',
|
|
394
|
+
'Promote it, or treat it as started work. Promotion is a human decision, taken in the web app; no tool you have can do it.',
|
|
395
|
+
],
|
|
396
|
+
};
|
|
397
|
+
const DESCRIPTION_IS_THE_HUMANS_REFUSAL = "The description of a Product Idea is the human's own. This one already has content, so it cannot be changed — put your analysis in an artifact instead (create_artifact), where it sits next to the original signal. "
|
|
398
|
+
+ 'This is enforced by the tool, not a preference: asking again, or being told to, will not change the answer. If the human wants their own words replaced, they edit them themselves in the web app. Do not offer to overwrite it.';
|
|
399
|
+
/**
|
|
400
|
+
* The project's Product Ideas — the pre-backlog work items `list_tasks`
|
|
401
|
+
* deliberately hides (Slice 1), so an agent stops treating unapproved ideas as
|
|
402
|
+
* committed work.
|
|
403
|
+
*
|
|
404
|
+
* WHICH IS EXACTLY WHY THIS EXISTS. With ideas invisible, an agent asked to
|
|
405
|
+
* capture a signal cannot tell whether the project already has an idea for that
|
|
406
|
+
* problem, and the only thing it can do is create another one. This is the
|
|
407
|
+
* deliberate way to see them.
|
|
408
|
+
*
|
|
409
|
+
* Returns enough to JUDGE OVERLAP and nothing more: title, whether a human has
|
|
410
|
+
* written a description, how many artifacts it carries, and when it last moved.
|
|
411
|
+
* Artifact bodies are not included — reading three ideas' worth of analysis to
|
|
412
|
+
* decide "is this the same problem?" would spend the context window on the
|
|
413
|
+
* question rather than the answer. `get_task` reads one in full when the answer
|
|
414
|
+
* is "maybe".
|
|
415
|
+
*/
|
|
416
|
+
/** Ideas returned in one call. Past this the listing is TRUNCATED and says so —
|
|
417
|
+
* the same doctrine as MANIFEST_FETCH_ROWS: an unreported truncation here reads
|
|
418
|
+
* as "no existing idea covers this", which is precisely the duplicate this tool
|
|
419
|
+
* exists to prevent. */
|
|
420
|
+
const IDEA_FETCH_ROWS = 200;
|
|
421
|
+
/** Ids per artifact-count request. `.in()` puts every id in the GET query
|
|
422
|
+
* string (~37 bytes each), so one batch of 200 ideas would push ~8 KB at the
|
|
423
|
+
* gateway's URI limit and fail the whole tool exactly when a project has
|
|
424
|
+
* enough ideas for duplicate-checking to matter. */
|
|
425
|
+
const COUNT_BATCH = 50;
|
|
426
|
+
export async function listProductIdeasHandler(client, args) {
|
|
427
|
+
try {
|
|
428
|
+
if (args.project_id && !UUID_RE.test(args.project_id)) {
|
|
429
|
+
return errorResult(`"${args.project_id}" is not a project id. Call list_tasks (or list_product_ideas with no argument) to see your projects and their ids.`);
|
|
430
|
+
}
|
|
431
|
+
const projects = (await must(client.from('projects').select('id, name'))) ?? [];
|
|
432
|
+
let query = client
|
|
433
|
+
.from('tasks')
|
|
434
|
+
.select('id, project_id, name, description, created_at, updated_at', { count: 'exact' })
|
|
435
|
+
.eq('is_idea', true)
|
|
436
|
+
.is('archived_at', null);
|
|
437
|
+
if (args.project_id)
|
|
438
|
+
query = query.eq('project_id', args.project_id);
|
|
439
|
+
const { rows, total } = await mustWithCount(query.order('updated_at', { ascending: false }).limit(IDEA_FETCH_ROWS));
|
|
440
|
+
const counts = new Map();
|
|
441
|
+
for (let i = 0; i < rows.length; i += COUNT_BATCH) {
|
|
442
|
+
const ids = rows.slice(i, i + COUNT_BATCH).map((row) => row.id);
|
|
443
|
+
const artifacts = (await must(client
|
|
444
|
+
.from('artifacts')
|
|
445
|
+
.select('task_id')
|
|
446
|
+
.in('task_id', ids)
|
|
447
|
+
.is('deleted_at', null))) ?? [];
|
|
448
|
+
for (const artifact of artifacts) {
|
|
449
|
+
counts.set(artifact.task_id, (counts.get(artifact.task_id) ?? 0) + 1);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const ideas = rows.map((row) => ({
|
|
453
|
+
id: row.id,
|
|
454
|
+
project_id: row.project_id,
|
|
455
|
+
project: projects.find((p) => p.id === row.project_id) ?? { id: row.project_id },
|
|
456
|
+
title: row.name,
|
|
457
|
+
// Not the text: whether the human has written one. That is what decides
|
|
458
|
+
// whether you may fill it, and it keeps their thinking out of a listing.
|
|
459
|
+
has_description: typeof row.description === 'string' && row.description.trim().length > 0,
|
|
460
|
+
artifact_count: counts.get(row.id) ?? 0,
|
|
461
|
+
created_at: row.created_at,
|
|
462
|
+
last_activity_at: row.updated_at,
|
|
463
|
+
}));
|
|
464
|
+
return textResult({
|
|
465
|
+
projects,
|
|
466
|
+
ideas,
|
|
467
|
+
// Say what was left out rather than letting a truncated listing read as
|
|
468
|
+
// "nothing like this exists yet".
|
|
469
|
+
...(total > rows.length
|
|
470
|
+
? {
|
|
471
|
+
omitted: {
|
|
472
|
+
ideas: total - rows.length,
|
|
473
|
+
note: `Showing the ${rows.length} most recently active of ${total} Product Ideas. Narrow with project_id before concluding that nothing covers your signal.`,
|
|
474
|
+
},
|
|
475
|
+
}
|
|
476
|
+
: {}),
|
|
477
|
+
guidance: [
|
|
478
|
+
'Before creating anything, check these for an idea that already covers the same problem — including ideas in OTHER projects listed here — and add an artifact to it instead of creating a near-duplicate.',
|
|
479
|
+
'Read the codebase before proposing anything. Read the repository you are working in, whether or not a codebase is attached to the project: an empty idea list does not mean an empty product.',
|
|
480
|
+
'CREATING NOTHING IS A VALID AND COMPLETE OUTCOME. If the product already does what the signal asks for, do not create an idea for it, do not create an idea for an adjacent gap you noticed instead, and do not record the absence anywhere. Just say so briefly and stop.',
|
|
481
|
+
'One signal is usually AT MOST one idea. Several customers describing one problem is one idea, not one per quote.',
|
|
482
|
+
'SAME SUBJECT IS NOT THE SAME PROBLEM. An existing idea about what a digest CONTAINS does not cover a signal about WHEN it is delivered; an idea about doing something faster does not cover a signal about undoing it. Fold a signal into an existing idea only when the underlying problem is the one that idea already names — otherwise it is its own idea, even though it sounds adjacent. When you genuinely cannot tell, add the artifact to the existing idea AND say which reading you took, so a human can split it.',
|
|
483
|
+
].join(' '),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
return errorResult(`list_product_ideas failed: ${errorMessage(err)}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Create a pre-backlog work item — `is_idea = true` — through the RPC Slice 1
|
|
492
|
+
* already shipped. It is NOT in the Backlog, no work starts, and only a human
|
|
493
|
+
* can promote it.
|
|
494
|
+
*
|
|
495
|
+
* `description` is accepted only because a brand-new idea's description is empty
|
|
496
|
+
* by definition; there is nothing to overwrite. Every later write to it goes
|
|
497
|
+
* through update_task, which refuses once a human has written there.
|
|
498
|
+
*/
|
|
499
|
+
export async function createProductIdeaHandler(client, userId, args) {
|
|
500
|
+
try {
|
|
501
|
+
const title = args.title?.trim();
|
|
502
|
+
if (!args.project_id)
|
|
503
|
+
return errorResult('create_product_idea requires project_id.');
|
|
504
|
+
if (!UUID_RE.test(args.project_id)) {
|
|
505
|
+
return errorResult(`"${args.project_id}" is not a project id. Call list_product_ideas (or list_tasks) to see your projects and their ids.`);
|
|
506
|
+
}
|
|
507
|
+
if (!title)
|
|
508
|
+
return errorResult('create_product_idea requires a title.');
|
|
509
|
+
// 24c Slice 3 (Q4 ruling): from an intake run the tool takes an explicit
|
|
510
|
+
// project_id AND the client it serves, and validates the edge — an idea
|
|
511
|
+
// from a client conversation must land in a project attached to THAT
|
|
512
|
+
// client (24b's projects.client_id), never in whichever project the agent
|
|
513
|
+
// happened to think of. Additive: without client_id the 22b behaviour is
|
|
514
|
+
// byte-for-byte unchanged.
|
|
515
|
+
if (args.client_id !== undefined) {
|
|
516
|
+
const resolvedClient = await resolveClient(client, args.client_id, 'create_product_idea');
|
|
517
|
+
if (!resolvedClient.ok)
|
|
518
|
+
return resolvedClient.error;
|
|
519
|
+
const project = await must(client.from('projects').select('id, name, client_id').eq('id', args.project_id).maybeSingle());
|
|
520
|
+
if (!project) {
|
|
521
|
+
return errorResult(`create_product_idea: no project found for id "${args.project_id}". Nothing was created.`);
|
|
522
|
+
}
|
|
523
|
+
if (project.client_id !== resolvedClient.value.id) {
|
|
524
|
+
return errorResult(`create_product_idea: project "${project.name}" (${project.id}) is not attached to client ` +
|
|
525
|
+
`"${resolvedClient.value.name}" — an idea from this client's material lands in one of THIS ` +
|
|
526
|
+
"client's projects. get_client_context lists them under `projects`; when there are several, " +
|
|
527
|
+
'choose one and say why. Nothing was created.');
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const id = await must(client.rpc('create_product_idea', {
|
|
531
|
+
p_project: args.project_id,
|
|
532
|
+
p_name: title,
|
|
533
|
+
p_description: args.description?.trim() ?? '',
|
|
534
|
+
p_owner: userId,
|
|
535
|
+
}));
|
|
536
|
+
if (!id)
|
|
537
|
+
throw new Error('create_product_idea returned no id.');
|
|
538
|
+
// The row is COMMITTED by this point. A failure to read it back is not a
|
|
539
|
+
// failed create, and reporting it as one makes the agent retry — producing
|
|
540
|
+
// the duplicate this whole tool exists to avoid.
|
|
541
|
+
let task = null;
|
|
542
|
+
let readBackWarning;
|
|
543
|
+
try {
|
|
544
|
+
task = await fetchTask(client, id);
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
readBackWarning = `The Product Idea was created (id ${id}) but could not be read back: ${errorMessage(err)}. Do NOT create it again — call get_task with that id.`;
|
|
548
|
+
}
|
|
549
|
+
// `status` is omitted deliberately: the row carries 'backlog' so it has a
|
|
550
|
+
// place to land on promotion, but echoing it directly under "not in the
|
|
551
|
+
// Backlog" contradicts the note in the one result an agent reads to decide
|
|
552
|
+
// whether it just did something committal. The web hides the same field for
|
|
553
|
+
// the same reason.
|
|
554
|
+
const { status: _unpromotedStatus, ...ideaRow } = (task ?? { id });
|
|
555
|
+
return textResult({
|
|
556
|
+
product_idea: ideaRow,
|
|
557
|
+
...(readBackWarning ? { warning: readBackWarning } : {}),
|
|
558
|
+
note: 'Created as a Product Idea — not in the Backlog, and no work has started. Put your analysis in an artifact on it (create_artifact). Only a human can promote it.',
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
return errorResult(`create_product_idea failed: ${errorMessage(err)}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
export async function createArtifactHandler(client, userId, currentSession, args) {
|
|
215
566
|
try {
|
|
216
567
|
if (!args.task_id)
|
|
217
568
|
return errorResult('create_artifact requires task_id.');
|
|
@@ -231,9 +582,21 @@ async function createArtifactHandler(client, userId, currentSession, args) {
|
|
|
231
582
|
if (args.purpose_key?.trim().startsWith('wireframe_option:')) {
|
|
232
583
|
return errorResult("The purpose_key prefix 'wireframe_option:' is reserved for present_wireframes / present_mocks presentations.");
|
|
233
584
|
}
|
|
234
|
-
|
|
585
|
+
// 31 Slice 2: the grounding manifest, validated to the exact key contract
|
|
586
|
+
// the web parser reads — refused BEFORE any write, like every other shape
|
|
587
|
+
// problem.
|
|
588
|
+
let grounding;
|
|
589
|
+
if (args.grounding !== undefined) {
|
|
590
|
+
const validated = validateGrounding(args.grounding);
|
|
591
|
+
if (!validated.ok)
|
|
592
|
+
return validated.error;
|
|
593
|
+
grounding = validated.value;
|
|
594
|
+
}
|
|
595
|
+
const task = await must(client.from('tasks').select('id, is_idea').eq('id', args.task_id).is('archived_at', null).maybeSingle());
|
|
235
596
|
if (!task)
|
|
236
597
|
return errorResult(`No task found for id "${args.task_id}".`);
|
|
598
|
+
if (task.is_idea === true && args.type === 'plan')
|
|
599
|
+
return errorResult(PLAN_ON_IDEA_REFUSAL);
|
|
237
600
|
const row = await must(client
|
|
238
601
|
.from('artifacts')
|
|
239
602
|
.insert({
|
|
@@ -246,6 +609,9 @@ async function createArtifactHandler(client, userId, currentSession, args) {
|
|
|
246
609
|
created_by: userId,
|
|
247
610
|
from_agent: null,
|
|
248
611
|
agent_run_id: null,
|
|
612
|
+
// Only ever ADDS the key: an insert without grounding stays
|
|
613
|
+
// byte-compatible with a database that predates the column.
|
|
614
|
+
...(grounding !== undefined ? { grounding } : {}),
|
|
249
615
|
})
|
|
250
616
|
.select(ARTIFACT_COLUMNS)
|
|
251
617
|
.single());
|
|
@@ -273,12 +639,310 @@ async function createArtifactHandler(client, userId, currentSession, args) {
|
|
|
273
639
|
attributionWarning = `Artifact created, but recording session attribution failed: ${err.message}`;
|
|
274
640
|
}
|
|
275
641
|
}
|
|
276
|
-
return textResult(
|
|
642
|
+
return textResult({
|
|
643
|
+
// The re-select uses ARTIFACT_COLUMNS (which deliberately predates the
|
|
644
|
+
// grounding column, so every other read path keeps working against a
|
|
645
|
+
// pre-migration database); the stored manifest is echoed from the
|
|
646
|
+
// validated input instead.
|
|
647
|
+
artifact: grounding !== undefined ? { ...row, grounding } : row,
|
|
648
|
+
...(attributionWarning ? { attribution_warning: attributionWarning } : {}),
|
|
649
|
+
});
|
|
277
650
|
}
|
|
278
651
|
catch (err) {
|
|
279
652
|
return errorResult(`create_artifact failed: ${err.message}`);
|
|
280
653
|
}
|
|
281
654
|
}
|
|
655
|
+
const SCREENSHOT_PLATFORM_LABEL = {
|
|
656
|
+
web: 'Web',
|
|
657
|
+
ios: 'iOS',
|
|
658
|
+
android: 'Android',
|
|
659
|
+
};
|
|
660
|
+
function attachScreenshotFailure(title, reason) {
|
|
661
|
+
return errorResult(`Couldn’t attach "${title}": ${reason}. No artifact was created.`);
|
|
662
|
+
}
|
|
663
|
+
function screenshotArtifactId(taskId, title, platform, target, bytes) {
|
|
664
|
+
const digest = createHash('sha256')
|
|
665
|
+
.update('ctrl-spc:screenshot:v1\0')
|
|
666
|
+
.update(taskId)
|
|
667
|
+
.update('\0')
|
|
668
|
+
.update(title)
|
|
669
|
+
.update('\0')
|
|
670
|
+
.update(platform)
|
|
671
|
+
.update('\0')
|
|
672
|
+
.update(target)
|
|
673
|
+
.update('\0')
|
|
674
|
+
.update(bytes)
|
|
675
|
+
.digest()
|
|
676
|
+
.subarray(0, 16);
|
|
677
|
+
// RFC 9562-shaped, deterministic UUID. The content-addressed identity makes
|
|
678
|
+
// a retry after Storage succeeded but Postgres failed converge on the same
|
|
679
|
+
// object instead of leaking one new object per retry.
|
|
680
|
+
digest[6] = (digest[6] & 0x0f) | 0x50;
|
|
681
|
+
digest[8] = (digest[8] & 0x3f) | 0x80;
|
|
682
|
+
const hex = digest.toString('hex');
|
|
683
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
684
|
+
}
|
|
685
|
+
function isDuplicateStorageObject(error) {
|
|
686
|
+
return /duplicate|already exists|resource exists/i.test(`${error.message} ${error.error ?? ''} ${error.name ?? ''}`);
|
|
687
|
+
}
|
|
688
|
+
function screenshotArtifactMatches(row, expected) {
|
|
689
|
+
return (row.id === expected.id &&
|
|
690
|
+
row.task_id === expected.taskId &&
|
|
691
|
+
row.type === 'image' &&
|
|
692
|
+
row.format === 'png' &&
|
|
693
|
+
row.title === expected.title &&
|
|
694
|
+
row.content === expected.content &&
|
|
695
|
+
row.storage_path === expected.storagePath &&
|
|
696
|
+
row.created_by === expected.userId &&
|
|
697
|
+
row.deleted_at === null);
|
|
698
|
+
}
|
|
699
|
+
async function fetchScreenshotArtifact(client, artifactId) {
|
|
700
|
+
return must(client
|
|
701
|
+
.from('artifacts')
|
|
702
|
+
.select('id,task_id,type,format,title,content,storage_path,created_by,deleted_at')
|
|
703
|
+
.eq('id', artifactId)
|
|
704
|
+
.maybeSingle());
|
|
705
|
+
}
|
|
706
|
+
async function downloadScreenshotObject(client, storagePath) {
|
|
707
|
+
const { data, error } = await client.storage.from('artifacts').download(storagePath);
|
|
708
|
+
if (error)
|
|
709
|
+
throw new Error(error.message);
|
|
710
|
+
if (!data)
|
|
711
|
+
throw new Error('Storage returned no file data');
|
|
712
|
+
const arrayBuffer = await data.arrayBuffer();
|
|
713
|
+
return Buffer.from(arrayBuffer);
|
|
714
|
+
}
|
|
715
|
+
async function ensureScreenshotAttribution(client, userId, sessionId, artifactId) {
|
|
716
|
+
try {
|
|
717
|
+
await must(client.from('cliv2_agent_outputs').insert({
|
|
718
|
+
user_id: userId,
|
|
719
|
+
session_id: sessionId,
|
|
720
|
+
kind: 'artifact',
|
|
721
|
+
product_id: artifactId,
|
|
722
|
+
}));
|
|
723
|
+
return undefined;
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
// A completed call whose response was lost already has this unique
|
|
727
|
+
// attribution row. Treat it as converged rather than warning or duplicating.
|
|
728
|
+
try {
|
|
729
|
+
const existing = await must(client
|
|
730
|
+
.from('cliv2_agent_outputs')
|
|
731
|
+
.select('user_id,session_id,product_id')
|
|
732
|
+
.eq('kind', 'artifact')
|
|
733
|
+
.eq('product_id', artifactId)
|
|
734
|
+
.maybeSingle());
|
|
735
|
+
if (existing?.user_id === userId && existing.product_id === artifactId)
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
// Preserve the original attribution failure below.
|
|
740
|
+
}
|
|
741
|
+
return `Screenshot attached, but recording session attribution failed: ${err.message}`;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function attachedScreenshotResult(title, platform, target, artifactId, attributionWarning) {
|
|
745
|
+
const success = `✓ Attached "${title}" to this work item.\n` +
|
|
746
|
+
` ${SCREENSHOT_PLATFORM_LABEL[platform]} · ${target} · image artifact ${artifactId}`;
|
|
747
|
+
return attributionWarning
|
|
748
|
+
? { content: [{ type: 'text', text: `${success}\n Warning: ${attributionWarning}` }] }
|
|
749
|
+
: { content: [{ type: 'text', text: success }] };
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Attach one already-captured PNG to this connection's open work session.
|
|
753
|
+
*
|
|
754
|
+
* The local file is fully validated before the first hosted request. The
|
|
755
|
+
* Storage upload intentionally precedes the artifact INSERT: the private bucket
|
|
756
|
+
* has no DELETE policy (objects follow the artifact soft-delete posture), so an
|
|
757
|
+
* upload failure must never leave a live broken card. There is no transaction
|
|
758
|
+
* across Supabase Storage and Postgres; if the upload succeeds but the row
|
|
759
|
+
* insert fails, the stable object key is reported for maintenance cleanup.
|
|
760
|
+
*/
|
|
761
|
+
export async function attachScreenshotHandler(client, userId, currentSession, args, deps = {}) {
|
|
762
|
+
const title = args.title?.trim() ?? '';
|
|
763
|
+
if (!currentSession) {
|
|
764
|
+
return errorResult('attach_screenshot needs an open work session — call begin_work first. No artifact was created.');
|
|
765
|
+
}
|
|
766
|
+
if (!title) {
|
|
767
|
+
return attachScreenshotFailure('Untitled screenshot', 'title must be non-empty');
|
|
768
|
+
}
|
|
769
|
+
const target = args.target?.trim() ?? '';
|
|
770
|
+
if (!target) {
|
|
771
|
+
return attachScreenshotFailure(title, 'target must be non-empty');
|
|
772
|
+
}
|
|
773
|
+
if (!['web', 'ios', 'android'].includes(args.platform)) {
|
|
774
|
+
return attachScreenshotFailure(title, "platform must be 'web', 'ios', or 'android'");
|
|
775
|
+
}
|
|
776
|
+
let screenshot;
|
|
777
|
+
try {
|
|
778
|
+
screenshot = await (deps.readScreenshot ?? readPngScreenshot)(args.path);
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
return attachScreenshotFailure(title, err.message);
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
// Resolve only the open session's task. There is deliberately no task_id
|
|
785
|
+
// argument, so one MCP connection cannot attach a screenshot to some other
|
|
786
|
+
// work item while it is working this one.
|
|
787
|
+
const task = await must(client
|
|
788
|
+
.from('tasks')
|
|
789
|
+
.select('id,project_id')
|
|
790
|
+
.eq('id', currentSession.taskId)
|
|
791
|
+
.is('archived_at', null)
|
|
792
|
+
.maybeSingle());
|
|
793
|
+
if (!task) {
|
|
794
|
+
return attachScreenshotFailure(title, 'the open work item was not found or is archived');
|
|
795
|
+
}
|
|
796
|
+
const project = await must(client
|
|
797
|
+
.from('projects')
|
|
798
|
+
.select('id,org_id')
|
|
799
|
+
.eq('id', task.project_id)
|
|
800
|
+
.is('archived_at', null)
|
|
801
|
+
.maybeSingle());
|
|
802
|
+
if (!project) {
|
|
803
|
+
return attachScreenshotFailure(title, 'the work item project was not found or is not accessible');
|
|
804
|
+
}
|
|
805
|
+
const artifactId = deps.artifactId?.() ??
|
|
806
|
+
screenshotArtifactId(task.id, title, args.platform, target, screenshot.bytes);
|
|
807
|
+
const storagePath = `${project.org_id}/${task.project_id}/${task.id}/${artifactId}.png`;
|
|
808
|
+
const metadata = {
|
|
809
|
+
platform: args.platform,
|
|
810
|
+
target,
|
|
811
|
+
width: screenshot.width,
|
|
812
|
+
height: screenshot.height,
|
|
813
|
+
size_bytes: screenshot.sizeBytes,
|
|
814
|
+
};
|
|
815
|
+
const content = JSON.stringify(metadata);
|
|
816
|
+
const expectedArtifact = {
|
|
817
|
+
id: artifactId,
|
|
818
|
+
taskId: task.id,
|
|
819
|
+
title,
|
|
820
|
+
content,
|
|
821
|
+
storagePath,
|
|
822
|
+
userId,
|
|
823
|
+
};
|
|
824
|
+
// Supabase Storage documents that upload needs INSERT RLS. upsert:false is
|
|
825
|
+
// intentional: this call never replaces an existing screenshot and
|
|
826
|
+
// therefore does not require SELECT + UPDATE permissions.
|
|
827
|
+
const { error: uploadError } = await client.storage
|
|
828
|
+
.from('artifacts')
|
|
829
|
+
.upload(storagePath, screenshot.bytes, {
|
|
830
|
+
contentType: 'image/png',
|
|
831
|
+
upsert: false,
|
|
832
|
+
});
|
|
833
|
+
if (uploadError && !isDuplicateStorageObject(uploadError)) {
|
|
834
|
+
return attachScreenshotFailure(title, `the PNG could not be uploaded: ${uploadError.message}`);
|
|
835
|
+
}
|
|
836
|
+
if (uploadError) {
|
|
837
|
+
let existing;
|
|
838
|
+
try {
|
|
839
|
+
existing = await fetchScreenshotArtifact(client, artifactId);
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
return errorResult(`Couldn’t attach "${title}": the screenshot object already exists, but CTRL+SPC could not confirm ` +
|
|
843
|
+
`whether artifact ${artifactId} is complete: ${err.message}. The existing object and any ` +
|
|
844
|
+
'artifact record were left unchanged; do not retry until the artifact can be checked.');
|
|
845
|
+
}
|
|
846
|
+
if (existing) {
|
|
847
|
+
if (!screenshotArtifactMatches(existing, expectedArtifact)) {
|
|
848
|
+
return errorResult(`Couldn’t attach "${title}": the retry key is already linked to a different artifact record. ` +
|
|
849
|
+
`Existing artifact ${artifactId} was left unchanged; no new artifact was created.`);
|
|
850
|
+
}
|
|
851
|
+
const warning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
|
|
852
|
+
return attachedScreenshotResult(title, args.platform, target, artifactId, warning);
|
|
853
|
+
}
|
|
854
|
+
// No artifact row means this may be an orphan from an interrupted call.
|
|
855
|
+
// Verify the private object's exact bytes before linking it; a key
|
|
856
|
+
// collision must never turn someone else's object into this evidence.
|
|
857
|
+
let existingBytes;
|
|
858
|
+
try {
|
|
859
|
+
existingBytes = await downloadScreenshotObject(client, storagePath);
|
|
860
|
+
}
|
|
861
|
+
catch (err) {
|
|
862
|
+
return errorResult(`Couldn’t attach "${title}": the screenshot object already exists, but CTRL+SPC could not verify ` +
|
|
863
|
+
`its bytes: ${err.message}. The existing object was left unchanged; no artifact was created.`);
|
|
864
|
+
}
|
|
865
|
+
if (!existingBytes.equals(screenshot.bytes)) {
|
|
866
|
+
return errorResult(`Couldn’t attach "${title}": the retry key points to an existing screenshot with different bytes. ` +
|
|
867
|
+
`The existing object was left unchanged; no artifact was created.`);
|
|
868
|
+
}
|
|
869
|
+
// The exact object is an orphan from a previous call that failed at
|
|
870
|
+
// Postgres. Continue with the same deterministic id/path to finish it.
|
|
871
|
+
}
|
|
872
|
+
let row;
|
|
873
|
+
try {
|
|
874
|
+
row = await must(client
|
|
875
|
+
.from('artifacts')
|
|
876
|
+
.insert({
|
|
877
|
+
id: artifactId,
|
|
878
|
+
task_id: task.id,
|
|
879
|
+
type: 'image',
|
|
880
|
+
format: 'png',
|
|
881
|
+
title,
|
|
882
|
+
content,
|
|
883
|
+
storage_path: storagePath,
|
|
884
|
+
created_by: userId,
|
|
885
|
+
from_agent: null,
|
|
886
|
+
agent_run_id: null,
|
|
887
|
+
})
|
|
888
|
+
.select(ARTIFACT_COLUMNS)
|
|
889
|
+
.single());
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
// The insert response may have been lost after Postgres committed, or a
|
|
893
|
+
// concurrent retry may have won. Re-read before describing an orphan.
|
|
894
|
+
let existing;
|
|
895
|
+
try {
|
|
896
|
+
existing = await fetchScreenshotArtifact(client, artifactId);
|
|
897
|
+
}
|
|
898
|
+
catch (readErr) {
|
|
899
|
+
return errorResult(`Couldn’t confirm whether "${title}" finished attaching after the PNG upload: ` +
|
|
900
|
+
`${readErr.message}. Artifact ${artifactId} may already exist; the private object at ` +
|
|
901
|
+
`"${storagePath}" was left unchanged. Do not retry until the artifact can be checked.`);
|
|
902
|
+
}
|
|
903
|
+
if (existing) {
|
|
904
|
+
if (!screenshotArtifactMatches(existing, expectedArtifact)) {
|
|
905
|
+
return errorResult(`Couldn’t attach "${title}": artifact id ${artifactId} is already used by a different record. ` +
|
|
906
|
+
'The existing artifact was left unchanged; no new artifact was created.');
|
|
907
|
+
}
|
|
908
|
+
const warning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
|
|
909
|
+
return attachedScreenshotResult(title, args.platform, target, artifactId, warning);
|
|
910
|
+
}
|
|
911
|
+
// The bucket intentionally has no DELETE policy. Removing an object by
|
|
912
|
+
// writing storage.objects directly is unsupported by Supabase, so do not
|
|
913
|
+
// pretend this cross-service boundary is atomic.
|
|
914
|
+
return errorResult(`Couldn’t attach "${title}": the PNG uploaded, but the artifact record could not be created: ` +
|
|
915
|
+
`${err.message}. No artifact was created. The unlinked private object at ` +
|
|
916
|
+
`"${storagePath}" may require maintenance cleanup.`);
|
|
917
|
+
}
|
|
918
|
+
if (!row) {
|
|
919
|
+
let existing;
|
|
920
|
+
try {
|
|
921
|
+
existing = await fetchScreenshotArtifact(client, artifactId);
|
|
922
|
+
}
|
|
923
|
+
catch (readErr) {
|
|
924
|
+
return errorResult(`Couldn’t confirm whether "${title}" finished attaching after the PNG upload: ` +
|
|
925
|
+
`${readErr.message}. Artifact ${artifactId} may already exist; the private object at ` +
|
|
926
|
+
`"${storagePath}" was left unchanged. Do not retry until the artifact can be checked.`);
|
|
927
|
+
}
|
|
928
|
+
if (existing) {
|
|
929
|
+
if (!screenshotArtifactMatches(existing, expectedArtifact)) {
|
|
930
|
+
return errorResult(`Couldn’t attach "${title}": artifact id ${artifactId} is already used by a different record. ` +
|
|
931
|
+
'The existing artifact was left unchanged; no new artifact was created.');
|
|
932
|
+
}
|
|
933
|
+
const warning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
|
|
934
|
+
return attachedScreenshotResult(title, args.platform, target, artifactId, warning);
|
|
935
|
+
}
|
|
936
|
+
return errorResult(`Couldn’t attach "${title}": the PNG uploaded, but the artifact record returned no row. ` +
|
|
937
|
+
`No artifact was created. The unlinked private object at "${storagePath}" may require maintenance cleanup.`);
|
|
938
|
+
}
|
|
939
|
+
const attributionWarning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
|
|
940
|
+
return attachedScreenshotResult(title, args.platform, target, artifactId, attributionWarning);
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
return attachScreenshotFailure(title, err.message);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
282
946
|
/** Re-fetch one task with the SAME select get_task uses (`*` + the tag join),
|
|
283
947
|
* returning the parsed task object ({ ...row, tags }, task_tags dropped) or null.
|
|
284
948
|
* Shared by update_task and create_task so their result shape matches get_task's
|
|
@@ -298,15 +962,30 @@ async function fetchTask(client, id) {
|
|
|
298
962
|
* the same `save_task_if_current` RPC v1 uses; an `edit_conflict` maps to a clear
|
|
299
963
|
* "call get_task again and retry" message. Re-fetches with get_task's select.
|
|
300
964
|
*/
|
|
301
|
-
async function updateTaskHandler(client, args) {
|
|
965
|
+
export async function updateTaskHandler(client, args) {
|
|
302
966
|
try {
|
|
303
967
|
const { id, fields, expected_revision } = args;
|
|
304
968
|
if (!Number.isInteger(expected_revision) || expected_revision < 1) {
|
|
305
969
|
return errorResult('update_task requires expected_revision (a positive integer) from the most recent get_task response.');
|
|
306
970
|
}
|
|
307
|
-
const current = await must(client.from('tasks').select('id,status,revision').eq('id', id).is('archived_at', null).maybeSingle());
|
|
971
|
+
const current = await must(client.from('tasks').select('id,status,revision,is_idea,description').eq('id', id).is('archived_at', null).maybeSingle());
|
|
308
972
|
if (!current)
|
|
309
973
|
return errorResult(`No task found for id "${id}".`);
|
|
974
|
+
// 22b Slice 3: on a Product Idea the description IS the user-only section.
|
|
975
|
+
// An empty one may be filled; one with content is the human's and is never
|
|
976
|
+
// edited or overwritten — not with a prompt, not "just this once".
|
|
977
|
+
if (fields.description !== undefined
|
|
978
|
+
&& current.is_idea === true
|
|
979
|
+
&& (current.description ?? '').trim().length > 0) {
|
|
980
|
+
return errorResult(DESCRIPTION_IS_THE_HUMANS_REFUSAL);
|
|
981
|
+
}
|
|
982
|
+
// Found by the Step 2b end-to-end gate: status was writable on an idea. The
|
|
983
|
+
// board hides ideas, so it looked harmless — but the value persisted, and
|
|
984
|
+
// promotion then landed the item in In Progress while the human's
|
|
985
|
+
// confirmation dialog promised "Backlog … this will not start work".
|
|
986
|
+
if (fields.status !== undefined && current.is_idea === true) {
|
|
987
|
+
return errorResult(STATUS_ON_IDEA_REFUSAL);
|
|
988
|
+
}
|
|
310
989
|
const patch = {};
|
|
311
990
|
if (fields.name !== undefined)
|
|
312
991
|
patch.name = fields.name;
|
|
@@ -355,7 +1034,7 @@ async function updateTaskHandler(client, args) {
|
|
|
355
1034
|
* "call get_task again and retry" message updateTask uses. Re-fetches the live
|
|
356
1035
|
* artifact with ARTIFACT_COLUMNS so the result matches get_task / create_artifact.
|
|
357
1036
|
*/
|
|
358
|
-
async function updateArtifactHandler(client, args) {
|
|
1037
|
+
export async function updateArtifactHandler(client, args) {
|
|
359
1038
|
try {
|
|
360
1039
|
const { id, fields, expected_revision } = args;
|
|
361
1040
|
if (!Number.isInteger(expected_revision) || expected_revision < 1) {
|
|
@@ -365,6 +1044,24 @@ async function updateArtifactHandler(client, args) {
|
|
|
365
1044
|
// tool exposes (title / type / format / content). purpose_key is deliberately
|
|
366
1045
|
// excluded (it could escape the context-artifact reservation), and deleted_at is
|
|
367
1046
|
// never sent from here.
|
|
1047
|
+
// update_artifact is the OTHER door onto the plan rule: its patch carries
|
|
1048
|
+
// `type`, so refusing only at insert would leave "create an analysis, then
|
|
1049
|
+
// retype it to plan" wide open.
|
|
1050
|
+
if (fields.type === 'plan') {
|
|
1051
|
+
// No `deleted_at` filter: save_artifact_if_current does not filter on it
|
|
1052
|
+
// either, so a soft-deleted artifact is still writable — filtering here
|
|
1053
|
+
// would find no owner and skip the check on exactly the rows the RPC will
|
|
1054
|
+
// happily update.
|
|
1055
|
+
const owner = await must(client.from('artifacts').select('task_id').eq('id', id).maybeSingle());
|
|
1056
|
+
// Refuse unless it is PROVEN not to be an idea. A null task (RLS, a race,
|
|
1057
|
+
// a deleted row) is not proof, and this branch guards a rule — failing
|
|
1058
|
+
// open here would let the exact write it exists to stop through.
|
|
1059
|
+
if (owner) {
|
|
1060
|
+
const task = await must(client.from('tasks').select('is_idea').eq('id', owner.task_id).maybeSingle());
|
|
1061
|
+
if (task?.is_idea !== false)
|
|
1062
|
+
return errorResult(PLAN_ON_IDEA_REFUSAL);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
368
1065
|
const patch = {};
|
|
369
1066
|
if (fields.title !== undefined)
|
|
370
1067
|
patch.title = fields.title;
|
|
@@ -478,6 +1175,46 @@ async function addCommentHandler(client, userId, currentSession, args) {
|
|
|
478
1175
|
return errorResult(`add_comment failed: ${err.message}`);
|
|
479
1176
|
}
|
|
480
1177
|
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Validate an epic/sprint placement target BEFORE anything is created (feature
|
|
1180
|
+
* 32, Slice 1). The three refusals mirror app.guard_task's same-project rule
|
|
1181
|
+
* and the archive semantics 27/28 shipped (an archived container "leaves the
|
|
1182
|
+
* picker" — it takes no NEW work), but they fire HERE, where the agent can fix
|
|
1183
|
+
* the call, instead of as a Postgres exception after the task row exists.
|
|
1184
|
+
*/
|
|
1185
|
+
async function resolvePlacementTarget(client, table, noun, id, projectId) {
|
|
1186
|
+
if (!UUID_RE.test(id)) {
|
|
1187
|
+
return {
|
|
1188
|
+
ok: false,
|
|
1189
|
+
error: errorResult(`create_task: "${id}" is not ${noun === 'epic' ? 'an epic' : 'a sprint'} id. Use the id ` +
|
|
1190
|
+
`create_${noun} returned, or the one a duplicate-name refusal named. Nothing was created.`),
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
const row = await must(client.from(table).select('id, name, project_id, archived_at').eq('id', id).maybeSingle());
|
|
1194
|
+
if (!row) {
|
|
1195
|
+
return {
|
|
1196
|
+
ok: false,
|
|
1197
|
+
error: errorResult(`create_task: no ${noun} found for id "${id}". Nothing was created.`),
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
if (row.project_id !== projectId) {
|
|
1201
|
+
return {
|
|
1202
|
+
ok: false,
|
|
1203
|
+
error: errorResult(`create_task: ${noun} "${row.name}" (${row.id}) belongs to a different project than ` +
|
|
1204
|
+
`"${projectId}". ${noun === 'epic' ? 'An epic groups' : 'A sprint sequences'} work inside ONE ` +
|
|
1205
|
+
`project — create the task in the ${noun}'s project, or pick ${noun === 'epic' ? 'an epic' : 'a sprint'} from this one. ` +
|
|
1206
|
+
'Nothing was created.'),
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
if (row.archived_at !== null) {
|
|
1210
|
+
return {
|
|
1211
|
+
ok: false,
|
|
1212
|
+
error: errorResult(`create_task: ${noun} "${row.name}" (${row.id}) is archived — it has left the picker and takes ` +
|
|
1213
|
+
`no new work. Place the task in a live ${noun}, or leave ${noun}_id off. Nothing was created.`),
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
return { ok: true, value: { id: row.id } };
|
|
1217
|
+
}
|
|
481
1218
|
/**
|
|
482
1219
|
* Create a new task in a project the user owns (via RLS), owned by the user.
|
|
483
1220
|
* Adapted from v1's `createTaskHandler`, minimal — tags / depends_on /
|
|
@@ -485,11 +1222,32 @@ async function addCommentHandler(client, userId, currentSession, args) {
|
|
|
485
1222
|
* (SECURITY DEFINER; the old `create_task_with_workflow` was removed with the
|
|
486
1223
|
* Workflows feature, see 20260721150000_drop_workflows.sql) to insert the task
|
|
487
1224
|
* and return its id, then re-fetches with get_task's select.
|
|
1225
|
+
*
|
|
1226
|
+
* Feature 32 (agile skeleton), Slice 1: optional `epic_id` / `sprint_id` place
|
|
1227
|
+
* the new task at creation — additive args per the Step −1 ruling (small
|
|
1228
|
+
* orthogonal tools; update_task's refusal of sprint fields stands untouched).
|
|
1229
|
+
* Both targets are validated BEFORE the create (same project, not archived),
|
|
1230
|
+
* and the placement itself goes through `save_task_if_current` — the ONLY
|
|
1231
|
+
* task-update path the web uses, whose p_changes whitelist has carried
|
|
1232
|
+
* `epic_id` / `sprint_id` since 20260727080000 — with the fresh row's
|
|
1233
|
+
* revision, so the CAS contract is honored rather than bypassed.
|
|
488
1234
|
*/
|
|
489
|
-
async function createTaskHandler(client, userId, currentSession, args) {
|
|
1235
|
+
export async function createTaskHandler(client, userId, currentSession, args) {
|
|
490
1236
|
try {
|
|
491
1237
|
if (!args.name || !args.name.trim())
|
|
492
1238
|
return errorResult('create_task requires a non-empty name.');
|
|
1239
|
+
// Placement targets are validated before ANY write: a refusal here must
|
|
1240
|
+
// leave nothing to un-create.
|
|
1241
|
+
if (args.epic_id !== undefined) {
|
|
1242
|
+
const epic = await resolvePlacementTarget(client, 'epics', 'epic', args.epic_id, args.project_id);
|
|
1243
|
+
if (!epic.ok)
|
|
1244
|
+
return epic.error;
|
|
1245
|
+
}
|
|
1246
|
+
if (args.sprint_id !== undefined) {
|
|
1247
|
+
const sprint = await resolvePlacementTarget(client, 'sprints', 'sprint', args.sprint_id, args.project_id);
|
|
1248
|
+
if (!sprint.ok)
|
|
1249
|
+
return sprint.error;
|
|
1250
|
+
}
|
|
493
1251
|
const taskId = await must(client.rpc('create_task', {
|
|
494
1252
|
p_project: args.project_id,
|
|
495
1253
|
p_name: args.name,
|
|
@@ -498,9 +1256,40 @@ async function createTaskHandler(client, userId, currentSession, args) {
|
|
|
498
1256
|
}));
|
|
499
1257
|
if (!taskId)
|
|
500
1258
|
throw new Error('Task insert returned no id.');
|
|
501
|
-
|
|
1259
|
+
let task = await fetchTask(client, taskId);
|
|
502
1260
|
if (!task)
|
|
503
1261
|
throw new Error('Created task could not be re-fetched.');
|
|
1262
|
+
// Place the fresh task under its epic/sprint. The task row is COMMITTED by
|
|
1263
|
+
// this point, so a placement failure is surfaced as a warning on the real
|
|
1264
|
+
// result — reporting it as a failed create would make the agent retry and
|
|
1265
|
+
// duplicate the task (the create_product_idea read-back lesson).
|
|
1266
|
+
let placementWarning;
|
|
1267
|
+
if (args.epic_id !== undefined || args.sprint_id !== undefined) {
|
|
1268
|
+
try {
|
|
1269
|
+
const revision = task.revision;
|
|
1270
|
+
if (typeof revision !== 'number' || !Number.isInteger(revision) || revision < 1) {
|
|
1271
|
+
throw new Error('the created task carried no usable revision');
|
|
1272
|
+
}
|
|
1273
|
+
await must(client.rpc('save_task_if_current', {
|
|
1274
|
+
p_task_id: taskId,
|
|
1275
|
+
p_expected_revision: revision,
|
|
1276
|
+
p_changes: {
|
|
1277
|
+
...(args.epic_id !== undefined ? { epic_id: args.epic_id } : {}),
|
|
1278
|
+
...(args.sprint_id !== undefined ? { sprint_id: args.sprint_id } : {}),
|
|
1279
|
+
},
|
|
1280
|
+
p_tag_ids: null,
|
|
1281
|
+
p_reorder: false,
|
|
1282
|
+
p_before_task_id: null,
|
|
1283
|
+
}));
|
|
1284
|
+
task = (await fetchTask(client, taskId)) ?? task;
|
|
1285
|
+
}
|
|
1286
|
+
catch (err) {
|
|
1287
|
+
placementWarning =
|
|
1288
|
+
`The task was created (id ${taskId}) but placing it under its epic/sprint failed: ` +
|
|
1289
|
+
`${errorMessage(err)}. Do NOT create the task again — report the failed placement; ` +
|
|
1290
|
+
'a human can place it from the board.';
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
504
1293
|
// D2a attribution: if this connection has an open session, record that the
|
|
505
1294
|
// session produced this NEW task — one cliv2_agent_outputs row referencing the
|
|
506
1295
|
// task BY VALUE. This is a follow-up task the subagent created, NOT the
|
|
@@ -524,12 +1313,219 @@ async function createTaskHandler(client, userId, currentSession, args) {
|
|
|
524
1313
|
attributionWarning = `Task created, but recording session attribution failed: ${err.message}`;
|
|
525
1314
|
}
|
|
526
1315
|
}
|
|
527
|
-
return textResult(
|
|
1316
|
+
return textResult({
|
|
1317
|
+
task,
|
|
1318
|
+
...(placementWarning ? { placement_warning: placementWarning } : {}),
|
|
1319
|
+
...(attributionWarning ? { attribution_warning: attributionWarning } : {}),
|
|
1320
|
+
});
|
|
528
1321
|
}
|
|
529
1322
|
catch (err) {
|
|
530
1323
|
return errorResult(`create_task failed: ${err.message}`);
|
|
531
1324
|
}
|
|
532
1325
|
}
|
|
1326
|
+
/* ---------------------------------------------------------------------------
|
|
1327
|
+
* Agile skeleton (feature 32, Slice 1) — `create_epic` / `create_sprint`,
|
|
1328
|
+
* beside the grown `create_task` above.
|
|
1329
|
+
*
|
|
1330
|
+
* The agent lays out the WHOLE skeleton from one feature ask: an epic (what
|
|
1331
|
+
* for), work items under it, sprints (ordered batches). Step −1 rulings
|
|
1332
|
+
* (ux.md, 2026-07-30): DIRECT WRITE as the user under RLS — a skeleton of
|
|
1333
|
+
* backlog items is un-started work, closer to an idea than an edit, so no
|
|
1334
|
+
* propose queue; SMALL ORTHOGONAL TOOLS, not a composite draft pass;
|
|
1335
|
+
* IDEMPOTENCE = read-before-write plus a duplicate-name refusal that teaches
|
|
1336
|
+
* extending the existing structure instead of minting a parallel one.
|
|
1337
|
+
*
|
|
1338
|
+
* THE CALIBRATION RULE LIVES IN THE DESCRIPTIONS (teach-in-the-description
|
|
1339
|
+
* doctrine): agents dramatically overestimate how long building takes because
|
|
1340
|
+
* their training is saturated with human engineering timelines. The
|
|
1341
|
+
* descriptions state the bias outright, size sprints as ordered batches of
|
|
1342
|
+
* agent-executable work, and forbid duration estimates on items unless the
|
|
1343
|
+
* user asked. Nothing here stores an estimate — there is no column for one,
|
|
1344
|
+
* deliberately.
|
|
1345
|
+
*
|
|
1346
|
+
* GRANTS, verified against the seeded rows rather than assumed: members hold
|
|
1347
|
+
* ('member','epic','create') (20260727080000 §4) and ('member','sprint',
|
|
1348
|
+
* 'create') (20260708000000 init.sql §9), and no later migration revokes
|
|
1349
|
+
* either — so the epics_insert / sprints_insert RLS policies already admit
|
|
1350
|
+
* every org member and NO new grants migration is needed. A denial for some
|
|
1351
|
+
* future role surfaces through the normal error path.
|
|
1352
|
+
*
|
|
1353
|
+
* NO cliv2_agent_outputs attribution row is written for an epic or a sprint:
|
|
1354
|
+
* the kind check is ('artifact','comment','task','decision') and widening it
|
|
1355
|
+
* is a migration this slice does not need — the skeleton's provenance is
|
|
1356
|
+
* legible through its work items, which create_task already attributes.
|
|
1357
|
+
* ------------------------------------------------------------------------- */
|
|
1358
|
+
/** Date args are refused by shape HERE (the resolveClient idiom) so a
|
|
1359
|
+
* malformed date gets a clean tool error instead of a Postgres cast failure
|
|
1360
|
+
* after other work happened. */
|
|
1361
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
1362
|
+
/** The project a skeleton tool writes into, resolved by id: UUID-shape-checked
|
|
1363
|
+
* before the read, and a miss (no such project, or one outside the caller's
|
|
1364
|
+
* orgs — RLS makes those indistinguishable on purpose) refused by id. */
|
|
1365
|
+
async function resolveSkeletonProject(client, projectIdArg, tool) {
|
|
1366
|
+
const projectId = typeof projectIdArg === 'string' ? projectIdArg.trim() : '';
|
|
1367
|
+
if (!projectId || !UUID_RE.test(projectId)) {
|
|
1368
|
+
return {
|
|
1369
|
+
ok: false,
|
|
1370
|
+
error: errorResult(`${tool}: "${projectId}" is not a project id. Call list_tasks to see your projects and their ids. ` +
|
|
1371
|
+
'Nothing was created.'),
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
const row = await must(client.from('projects').select('id, name').eq('id', projectId).maybeSingle());
|
|
1375
|
+
if (!row) {
|
|
1376
|
+
return {
|
|
1377
|
+
ok: false,
|
|
1378
|
+
error: errorResult(`${tool}: no project found for id "${projectId}". Nothing was created.`),
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
return { ok: true, value: row };
|
|
1382
|
+
}
|
|
1383
|
+
/** Refuse a malformed date arg by name; `undefined` passes (the arg is
|
|
1384
|
+
* optional everywhere it appears). */
|
|
1385
|
+
function validDateArg(tool, field, value) {
|
|
1386
|
+
if (value === undefined)
|
|
1387
|
+
return null;
|
|
1388
|
+
if (!ISO_DATE_RE.test(value)) {
|
|
1389
|
+
return errorResult(`${tool}: ${field} must be an ISO date (YYYY-MM-DD), got "${value}". Nothing was created.`);
|
|
1390
|
+
}
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Create an Epic in a project — writes public.epics as the user (RLS
|
|
1395
|
+
* epics_insert, the 'epic'/'create' grant every member holds).
|
|
1396
|
+
*
|
|
1397
|
+
* THE DUPLICATE-NAME REFUSAL IS THE IDEMPOTENCE MECHANISM (Step −1 ruling 4):
|
|
1398
|
+
* a re-run, or a "feature ABC v2" ask, must EXTEND the existing epic, not
|
|
1399
|
+
* mint a second one — 24c's updating-beats-creating rule applied to
|
|
1400
|
+
* structure. The match is case-insensitive on the trimmed name, against LIVE
|
|
1401
|
+
* epics only: an archived epic has left the picker and can take no new work,
|
|
1402
|
+
* so a fresh epic re-using its name is a legitimate new start, not a
|
|
1403
|
+
* duplicate. The refusal lists the project's live epics so it doubles as the
|
|
1404
|
+
* read surface the skeleton pass otherwise lacks (no epic-listing tool ships
|
|
1405
|
+
* in this slice).
|
|
1406
|
+
*/
|
|
1407
|
+
export async function createEpicHandler(client, args) {
|
|
1408
|
+
try {
|
|
1409
|
+
const project = await resolveSkeletonProject(client, args.project_id, 'create_epic');
|
|
1410
|
+
if (!project.ok)
|
|
1411
|
+
return project.error;
|
|
1412
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
1413
|
+
if (!name)
|
|
1414
|
+
return errorResult('create_epic requires a non-empty name. Nothing was created.');
|
|
1415
|
+
for (const [field, value] of [
|
|
1416
|
+
['start_date', args.start_date],
|
|
1417
|
+
['target_date', args.target_date],
|
|
1418
|
+
]) {
|
|
1419
|
+
const refused = validDateArg('create_epic', field, value);
|
|
1420
|
+
if (refused)
|
|
1421
|
+
return refused;
|
|
1422
|
+
}
|
|
1423
|
+
// Mirrors the DB's epics_target_not_before_start check, refused where the
|
|
1424
|
+
// agent can fix it instead of as a constraint violation.
|
|
1425
|
+
if (args.start_date && args.target_date && args.target_date < args.start_date) {
|
|
1426
|
+
return errorResult(`create_epic: target_date (${args.target_date}) is before start_date (${args.start_date}). ` +
|
|
1427
|
+
'Nothing was created.');
|
|
1428
|
+
}
|
|
1429
|
+
const epics = (await must(client.from('epics').select('id, name, archived_at').eq('project_id', project.value.id))) ?? [];
|
|
1430
|
+
const live = epics.filter((epic) => epic.archived_at === null);
|
|
1431
|
+
const duplicate = live.find((epic) => epic.name.trim().toLowerCase() === name.toLowerCase());
|
|
1432
|
+
if (duplicate) {
|
|
1433
|
+
const listing = live.map((epic) => `"${epic.name}" (${epic.id})`).join(', ');
|
|
1434
|
+
return errorResult(`create_epic: an epic named "${duplicate.name}" already exists in "${project.value.name}" ` +
|
|
1435
|
+
`(id ${duplicate.id}). Do not create a duplicate — EXTEND the existing epic: create the new ` +
|
|
1436
|
+
`work items with epic_id ${duplicate.id}, and reshape what already hangs under it. A re-run ` +
|
|
1437
|
+
'or a "v2" ask extends the existing skeleton; it never mints a second one. Live epics in ' +
|
|
1438
|
+
`this project: ${listing}. Nothing was created.`);
|
|
1439
|
+
}
|
|
1440
|
+
const epic = await must(client
|
|
1441
|
+
.from('epics')
|
|
1442
|
+
.insert({
|
|
1443
|
+
project_id: project.value.id,
|
|
1444
|
+
name,
|
|
1445
|
+
...(args.description?.trim() ? { description: args.description.trim() } : {}),
|
|
1446
|
+
...(args.start_date ? { start_date: args.start_date } : {}),
|
|
1447
|
+
...(args.target_date ? { target_date: args.target_date } : {}),
|
|
1448
|
+
})
|
|
1449
|
+
.select('id, project_id, name, description, start_date, target_date, created_at')
|
|
1450
|
+
.single());
|
|
1451
|
+
if (!epic)
|
|
1452
|
+
throw new Error('Epic insert returned no row.');
|
|
1453
|
+
return textResult({
|
|
1454
|
+
epic,
|
|
1455
|
+
note: 'Created as an empty epic — no work has started and none is declared. Place work items under ' +
|
|
1456
|
+
"it with create_task's epic_id, sequence them with create_sprint + sprint_id, and add NO " +
|
|
1457
|
+
'duration estimates to anything unless the user asked: sequencing is the value.',
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
catch (err) {
|
|
1461
|
+
return errorResult(`create_epic failed: ${errorMessage(err)}`);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Create a Sprint in a project — writes public.sprints as the user (RLS
|
|
1466
|
+
* sprints_insert, the 'sprint'/'create' grant every member holds). Dates are
|
|
1467
|
+
* optional (28 made the columns nullable): for agent-executed work a sprint is
|
|
1468
|
+
* an ORDERED BATCH, not a time-box, and an invented fortnight is exactly the
|
|
1469
|
+
* human-timeline bias this feature exists to counter.
|
|
1470
|
+
*
|
|
1471
|
+
* Same duplicate-name posture as create_epic, for the same reason: a re-run
|
|
1472
|
+
* must extend the existing sprint sequence, never mint a parallel one.
|
|
1473
|
+
*/
|
|
1474
|
+
export async function createSprintHandler(client, args) {
|
|
1475
|
+
try {
|
|
1476
|
+
const project = await resolveSkeletonProject(client, args.project_id, 'create_sprint');
|
|
1477
|
+
if (!project.ok)
|
|
1478
|
+
return project.error;
|
|
1479
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
1480
|
+
if (!name)
|
|
1481
|
+
return errorResult('create_sprint requires a non-empty name. Nothing was created.');
|
|
1482
|
+
for (const [field, value] of [
|
|
1483
|
+
['start_date', args.start_date],
|
|
1484
|
+
['end_date', args.end_date],
|
|
1485
|
+
]) {
|
|
1486
|
+
const refused = validDateArg('create_sprint', field, value);
|
|
1487
|
+
if (refused)
|
|
1488
|
+
return refused;
|
|
1489
|
+
}
|
|
1490
|
+
// Mirrors the sprints table's own start <= end check (init.sql), refused
|
|
1491
|
+
// where the agent can fix it.
|
|
1492
|
+
if (args.start_date && args.end_date && args.end_date < args.start_date) {
|
|
1493
|
+
return errorResult(`create_sprint: end_date (${args.end_date}) is before start_date (${args.start_date}). ` +
|
|
1494
|
+
'Nothing was created.');
|
|
1495
|
+
}
|
|
1496
|
+
const sprints = (await must(client.from('sprints').select('id, name, archived_at').eq('project_id', project.value.id))) ?? [];
|
|
1497
|
+
const live = sprints.filter((sprint) => sprint.archived_at === null);
|
|
1498
|
+
const duplicate = live.find((sprint) => sprint.name.trim().toLowerCase() === name.toLowerCase());
|
|
1499
|
+
if (duplicate) {
|
|
1500
|
+
const listing = live.map((sprint) => `"${sprint.name}" (${sprint.id})`).join(', ');
|
|
1501
|
+
return errorResult(`create_sprint: a sprint named "${duplicate.name}" already exists in "${project.value.name}" ` +
|
|
1502
|
+
`(id ${duplicate.id}). Reuse it — place items into it with create_task's sprint_id — or name ` +
|
|
1503
|
+
'the NEXT batch in the sequence; a re-run extends the existing sprint sequence, it never ' +
|
|
1504
|
+
`mints a parallel one. Live sprints in this project: ${listing}. Nothing was created.`);
|
|
1505
|
+
}
|
|
1506
|
+
const sprint = await must(client
|
|
1507
|
+
.from('sprints')
|
|
1508
|
+
.insert({
|
|
1509
|
+
project_id: project.value.id,
|
|
1510
|
+
name,
|
|
1511
|
+
...(args.start_date ? { start_date: args.start_date } : {}),
|
|
1512
|
+
...(args.end_date ? { end_date: args.end_date } : {}),
|
|
1513
|
+
})
|
|
1514
|
+
.select('id, project_id, name, start_date, end_date, created_at')
|
|
1515
|
+
.single());
|
|
1516
|
+
if (!sprint)
|
|
1517
|
+
throw new Error('Sprint insert returned no row.');
|
|
1518
|
+
return textResult({
|
|
1519
|
+
sprint,
|
|
1520
|
+
note: 'Created. Sprints for agent-executed work are ordered batches: the first holds what unblocks ' +
|
|
1521
|
+
"everything else, each sized to what agents deliver in a session or a day. Place items with " +
|
|
1522
|
+
"create_task's sprint_id, and add NO duration estimates unless the user asked.",
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
catch (err) {
|
|
1526
|
+
return errorResult(`create_sprint failed: ${errorMessage(err)}`);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
533
1529
|
/**
|
|
534
1530
|
* Ask the user a question about the work item the open session is on (D3). Unlike
|
|
535
1531
|
* the write tools, this REQUIRES an open session — the question is a decision on
|
|
@@ -1414,24 +2410,2216 @@ async function getCredentialHandler(client, args) {
|
|
|
1414
2410
|
}
|
|
1415
2411
|
}
|
|
1416
2412
|
// ---------------------------------------------------------------------------
|
|
1417
|
-
//
|
|
2413
|
+
// Project context (feature 13a, Phase 3) — `get_project_context`.
|
|
2414
|
+
//
|
|
2415
|
+
// READ-ONLY, and read-only over a V1-OWNED PRODUCT TABLE. `public.project_documents`
|
|
2416
|
+
// is the table the web app writes under Project settings → Project context (Phase 1)
|
|
2417
|
+
// and on a codebase page (Phase 2). AGENTS.md § "Database schema naming" allows
|
|
2418
|
+
// exactly this: the v2 surface may `select` shared product data through the signed-in
|
|
2419
|
+
// user's RLS, as the web app does; what it may not do is WRITE outside `cliv2_*` or
|
|
2420
|
+
// hold its own state there. This tool writes nothing and adds no `cliv2_*` row.
|
|
2421
|
+
//
|
|
2422
|
+
// SCOPE IS FILTERED IN THE QUERY, NOT IN JS. A document is project-scoped when
|
|
2423
|
+
// `codebase_id is null` and codebase-scoped when `codebase_id = <id>` — the whole
|
|
2424
|
+
// model, per 20260726220000_project_documents_codebase_scope.sql. Both scopes are
|
|
2425
|
+
// read with their own predicate (`.is('codebase_id', null)` / `.eq('codebase_id', id)`)
|
|
2426
|
+
// so a codebase document can NEVER reach a project-only response, whatever the
|
|
2427
|
+
// caller passed. Filtering after the fact would make that a code-path property
|
|
2428
|
+
// instead of a query property; both earlier phases were reviewed on this point.
|
|
2429
|
+
//
|
|
2430
|
+
// NO OPEN SESSION IS REQUIRED when `task_id` is passed. That is a deliberate
|
|
2431
|
+
// departure from the '<tool> needs an open work session — call begin_work first.'
|
|
2432
|
+
// gate every WRITE tool carries: reading a project's standing context BEFORE
|
|
2433
|
+
// opening a work session is a legitimate and expected flow (an agent orienting
|
|
2434
|
+
// itself), and the tool mutates nothing that would need attributing to a session.
|
|
2435
|
+
// With neither a session nor a task_id there is genuinely no project to resolve,
|
|
2436
|
+
// and the refusal is phrased as the instruction (the doctrine at registerTool):
|
|
2437
|
+
// it names both ways out.
|
|
1418
2438
|
// ---------------------------------------------------------------------------
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
2439
|
+
/** The five types `public.project_documents.type` admits, verbatim from the
|
|
2440
|
+
* migration's check constraint and the web composer's Select. */
|
|
2441
|
+
const PROJECT_DOCUMENT_TYPES = ['instructions', 'architecture', 'design', 'conventions', 'other'];
|
|
2442
|
+
/** Phase 4's `propose_project_context` resolves the project exactly as Phase 3's
|
|
2443
|
+
* reader does, so the refusal copy is written once and parameterized by the
|
|
2444
|
+
* tool doing the asking rather than duplicated and left to drift. */
|
|
2445
|
+
const noProjectContextError = (tool) => `${tool} needs to know which project. Call begin_work first, or pass task_id.`;
|
|
2446
|
+
/** The empty-state note, verbatim from the approved transcript. It says where the
|
|
2447
|
+
* documents come from, so an agent that finds none doesn't try to create one —
|
|
2448
|
+
* there is no write path, by design.
|
|
2449
|
+
*
|
|
2450
|
+
* IT ONLY FIRES ON AN UNNARROWED READ (no `codebase`, no `types`) — a user ruling —
|
|
2451
|
+
* AND ONLY WHEN THE PROJECT REALLY IS EMPTY, codebase scopes included; see
|
|
2452
|
+
* `unnarrowedEmptyNote`.
|
|
2453
|
+
* When a filter emptied the result the project may be full of documents, and
|
|
2454
|
+
* telling the agent "this project has no context documents yet" would make it
|
|
2455
|
+
* conclude there is no context to read. That case gets `narrowedEmptyNote` below,
|
|
2456
|
+
* which names the filter and points at relaxing it. It also never points at
|
|
2457
|
+
* *Project settings → Project context*, because a codebase's own documents are
|
|
2458
|
+
* written on the codebase page (Phase 2), not there. */
|
|
2459
|
+
const NO_PROJECT_DOCUMENTS_NOTE = 'This project has no context documents yet. They are written by people in the web app, under Project settings → Project context.';
|
|
2460
|
+
/**
|
|
2461
|
+
* WHOLE-RESPONSE BYTE BUDGET. `project_documents.content` is unbounded `text` and
|
|
2462
|
+
* the default call — `get_project_context({})` — asks for every project document
|
|
2463
|
+
* at once, so without a cap one team's long architecture doc can blow an agent's
|
|
2464
|
+
* context window with no warning.
|
|
2465
|
+
*
|
|
2466
|
+
* THE BUDGET IS A BOUND, AND A BOUND CAN COST ROWS. A document that doesn't fit
|
|
2467
|
+
* comes back with its content cut at an explicit marker plus `content_length`, so
|
|
2468
|
+
* the agent still sees it exists and how big it is. But metadata is not free: with
|
|
2469
|
+
* enough documents the zero-content stubs alone exceed the budget, and at that
|
|
2470
|
+
* point "never drop a row" and "never exceed 100KB" cannot both hold. The bound
|
|
2471
|
+
* wins and the tail is dropped — with an explicit omitted COUNT in the note, so
|
|
2472
|
+
* the invariant that survives is *the agent is always told what it did not get*,
|
|
2473
|
+
* not *every row appears*.
|
|
2474
|
+
*/
|
|
2475
|
+
const MAX_RESPONSE_BYTES = 100_000;
|
|
2476
|
+
/** A hard row cap per scope, so a pathological document count can't make the
|
|
2477
|
+
* response unbounded in row terms even when every document is tiny. Both scopes
|
|
2478
|
+
* are read separately, so a codebase-scoped call can return up to 2× this. */
|
|
2479
|
+
const MAX_DOCUMENT_ROWS = 200;
|
|
2480
|
+
const TRUNCATION_MARKER = '…[truncated by get_project_context]';
|
|
2481
|
+
/** Emitted once, at the top level, whenever any document was cut. Phrased as the
|
|
2482
|
+
* next action: narrow with `types` and call again. */
|
|
2483
|
+
const TRUNCATED_NOTE = 'Some documents were truncated to keep this response under 100KB — each one carries its full ' +
|
|
2484
|
+
'character count as `content_length`. Call again with `types` (and `codebase`) to narrow the read ' +
|
|
2485
|
+
'and get the documents you need in full.';
|
|
2486
|
+
/** Appended to TRUNCATED_NOTE when the budget ran out before every document could
|
|
2487
|
+
* even be listed. It names the count, because a silently short array is exactly
|
|
2488
|
+
* the failure this whole block exists to prevent. */
|
|
2489
|
+
const omittedNote = (count) => `${count} further document${count === 1 ? ' was' : 's were'} omitted entirely — not even ` +
|
|
2490
|
+
'their titles fit the 100KB budget. Call again with `types` (and `codebase`) to reach them.';
|
|
2491
|
+
/** Emitted when a scope hit the row cap, so the caller learns the array is a
|
|
2492
|
+
* prefix rather than the whole scope. */
|
|
2493
|
+
const ROW_CAP_NOTE = `Only the first ${MAX_DOCUMENT_ROWS} documents per scope were read — this project has more. ` +
|
|
2494
|
+
'Call again with `types` (and `codebase`) to narrow the read and reach the rest.';
|
|
2495
|
+
/**
|
|
2496
|
+
* One scope's documents, ordered deterministically.
|
|
2497
|
+
*
|
|
2498
|
+
* `codebaseId === null` reads the PROJECT scope (`codebase_id is null`); a value
|
|
2499
|
+
* reads that codebase's own (`codebase_id = <id>`). The two predicates are the
|
|
2500
|
+
* whole difference, and they live here rather than in the caller so neither can
|
|
2501
|
+
* be forgotten.
|
|
2502
|
+
*
|
|
2503
|
+
* ORDER: `created_at` ascending, `id` ascending as the tie-break. `created_at` is
|
|
2504
|
+
* what BOTH web reads use (it is the order the card list renders and new documents
|
|
2505
|
+
* append in, and the trailing column of `project_documents_scope_idx`), so an agent
|
|
2506
|
+
* sees the documents in the order the team wrote them and in the same order the
|
|
2507
|
+
* humans see them. `id` breaks ties so two documents created in the same tick can
|
|
2508
|
+
* never swap places between calls.
|
|
2509
|
+
*
|
|
2510
|
+
* ROW CAP: the query asks for `MAX_DOCUMENT_ROWS + 1`. Postgres truncating at
|
|
2511
|
+
* exactly the cap would be invisible to JS — the caller would get a full-looking
|
|
2512
|
+
* array of 200 with no signal that 300 more exist — so the extra row is the
|
|
2513
|
+
* overflow probe: if it comes back, the scope is `capped` and the response says
|
|
2514
|
+
* so. The byte budget then caps what the kept rows are allowed to say.
|
|
2515
|
+
*
|
|
2516
|
+
* ponytail: nothing caps the bytes FETCHED. `MAX_DOCUMENT_ROWS` caps rows, not
|
|
2517
|
+
* size, so a project with 200 × 1MB documents still pulls ~200MB over the wire
|
|
2518
|
+
* before the budget discards most of it. Bounding the fetch needs a server-side
|
|
2519
|
+
* cut — an RPC selecting `substring(content, 1, N)`. (Phase 3b did add
|
|
2520
|
+
* `project_documents.content_chars`, so a SIZE is now free; a truncated BODY
|
|
2521
|
+
* still is not, and this reader needs the body.) Deliberately left: the ceiling
|
|
2522
|
+
* is real and this comment is the marker, not an oversight.
|
|
2523
|
+
*/
|
|
2524
|
+
async function fetchProjectDocuments(client, projectId, codebaseId, types) {
|
|
2525
|
+
let query = client
|
|
2526
|
+
.from('project_documents')
|
|
2527
|
+
.select('title, type, content, created_at, updated_at')
|
|
2528
|
+
.eq('project_id', projectId);
|
|
2529
|
+
query = codebaseId === null ? query.is('codebase_id', null) : query.eq('codebase_id', codebaseId);
|
|
2530
|
+
if (types)
|
|
2531
|
+
query = query.in('type', types);
|
|
2532
|
+
const rows = (await must(query
|
|
2533
|
+
.order('created_at', { ascending: true })
|
|
2534
|
+
.order('id', { ascending: true })
|
|
2535
|
+
.limit(MAX_DOCUMENT_ROWS + 1))) ?? [];
|
|
2536
|
+
return { rows: rows.slice(0, MAX_DOCUMENT_ROWS), capped: rows.length > MAX_DOCUMENT_ROWS };
|
|
2537
|
+
}
|
|
2538
|
+
/**
|
|
2539
|
+
* Does this project hold ANY codebase-scoped document? Asked only when an
|
|
2540
|
+
* unnarrowed read of the project scope came back empty, so the note can say
|
|
2541
|
+
* "pass `codebase`" instead of "this project has no context documents yet" —
|
|
2542
|
+
* a project whose documents all live on codebase pages (Phase 2) is exactly the
|
|
2543
|
+
* shape that made the flat empty-state note a lie.
|
|
2544
|
+
*/
|
|
2545
|
+
async function hasCodebaseScopedDocuments(client, projectId) {
|
|
2546
|
+
const rows = await must(client
|
|
2547
|
+
.from('project_documents')
|
|
2548
|
+
.select('type')
|
|
2549
|
+
.eq('project_id', projectId)
|
|
2550
|
+
.not('codebase_id', 'is', null)
|
|
2551
|
+
.limit(1));
|
|
2552
|
+
return (rows ?? []).length > 0;
|
|
2553
|
+
}
|
|
2554
|
+
/**
|
|
2555
|
+
* The distinct document TYPES one scope actually holds, ignoring any `types`
|
|
2556
|
+
* narrowing. Read only when a narrowed read came back empty, so the note can tell
|
|
2557
|
+
* the agent what relaxing the filter would get it instead of leaving it to guess.
|
|
2558
|
+
*
|
|
2559
|
+
* NO ROW LIMIT. This is a `select distinct` in intent: there are five possible
|
|
2560
|
+
* types, and a cap with no `.order()` would leave *which* rows come back
|
|
2561
|
+
* unspecified — a type present only on row 201 would silently vanish from
|
|
2562
|
+
* "Without the type filter it would return: …". One narrow column, at most on a
|
|
2563
|
+
* cold empty-result path.
|
|
2564
|
+
*/
|
|
2565
|
+
async function fetchProjectDocumentTypes(client, projectId, codebaseId) {
|
|
2566
|
+
let query = client.from('project_documents').select('type').eq('project_id', projectId);
|
|
2567
|
+
query = codebaseId === null ? query.is('codebase_id', null) : query.eq('codebase_id', codebaseId);
|
|
2568
|
+
const rows = await must(query);
|
|
2569
|
+
return [...new Set((rows ?? []).map((row) => row.type))];
|
|
2570
|
+
}
|
|
2571
|
+
const jsonBytes = (value) => Buffer.byteLength(JSON.stringify(value, null, 2), 'utf8');
|
|
2572
|
+
/** CHARACTERS, meaning code points — what `content_length` claims to be and what a
|
|
2573
|
+
* person counting emoji would say. `String.length` is UTF-16 code units, which
|
|
2574
|
+
* double-counts every astral character (60,000 emoji would report 120,000).
|
|
2575
|
+
* The surrogate test short-circuits the allocating spread for the ordinary case. */
|
|
2576
|
+
const countCharacters = (text) => /[\uD800-\uDFFF]/.test(text) ? [...text].length : text.length;
|
|
2577
|
+
/**
|
|
2578
|
+
* Fit the shaped documents into MAX_RESPONSE_BYTES, in order, cutting content
|
|
2579
|
+
* first and dropping the tail only when even the metadata will not fit.
|
|
2580
|
+
*
|
|
2581
|
+
* The cost of a candidate response is measured by SERIALIZING IT — the same
|
|
2582
|
+
* `JSON.stringify(payload, null, 2)` textResult emits — rather than by summing
|
|
2583
|
+
* per-document estimates, because indentation and escaping make an estimate wrong
|
|
2584
|
+
* in the unsafe direction. It is always measured WITH the WORST-CASE note present
|
|
2585
|
+
* (every note this response could carry, with the largest possible omitted count),
|
|
2586
|
+
* so choosing the real note afterwards can never push the response back over.
|
|
2587
|
+
*
|
|
2588
|
+
* THE STUB FLOOR IS SIZED FIRST. Every not-yet-emitted document is reserved as its
|
|
2589
|
+
* zero-content stub, so the room available to document i is the budget minus what
|
|
2590
|
+
* documents i+1… cost at minimum. That reserve is only sound if the reserve itself
|
|
2591
|
+
* fits: at ~270 bytes of JSON per stub, 400 stubs (both scopes at MAX_DOCUMENT_ROWS)
|
|
2592
|
+
* blow a 100KB budget on metadata alone. So the number of stubs the budget can hold
|
|
2593
|
+
* is binary-searched up front, and anything past it is dropped and COUNTED — see
|
|
2594
|
+
* MAX_RESPONSE_BYTES for why a bound that cannot drop rows is not a bound.
|
|
2595
|
+
*
|
|
2596
|
+
* Two consequences worth keeping:
|
|
2597
|
+
* - Once a document is cut all the way to zero characters, the budget is spent;
|
|
2598
|
+
* every remaining document is stubbed WITHOUT probing. Otherwise each of them
|
|
2599
|
+
* runs a full ⌈log2(len)⌉ binary search whose every probe re-serializes the
|
|
2600
|
+
* whole payload — measured at seconds of blocked event loop for 400 × 1MB.
|
|
2601
|
+
* - Cutting is by CODE POINT, not by UTF-16 code unit, so a prefix can never
|
|
2602
|
+
* split a surrogate pair. That also keeps `fits()` monotone in `chars`, which
|
|
2603
|
+
* is the binary search's precondition — a half-pair prefix serializes LARGER
|
|
2604
|
+
* than the whole pair (`\ud83d` escapes to 6 bytes), so a code-unit slice made
|
|
2605
|
+
* the predicate non-monotone and the search correct only by accident.
|
|
2606
|
+
* `content_length` is a code-point count for the same reason: the note calls it
|
|
2607
|
+
* a character count, and 60,000 emoji are 60,000 characters, not 120,000.
|
|
2608
|
+
*/
|
|
2609
|
+
function fitToBudget(envelope, documents, extraNotes) {
|
|
2610
|
+
// Fast path: everything fits whole and no truncation note is needed. Guarded by
|
|
2611
|
+
// a CHEAP lower bound first — JSON can only ever be longer than the content it
|
|
2612
|
+
// quotes, so once the raw content alone exceeds the budget there is no point
|
|
2613
|
+
// serializing it to find out. Without the guard, 400 × 1MB documents get a
|
|
2614
|
+
// 400MB `JSON.stringify` before the first real decision.
|
|
2615
|
+
const rawChars = documents.reduce((total, document) => total + document.content.length, 0);
|
|
2616
|
+
if (rawChars <= MAX_RESPONSE_BYTES) {
|
|
2617
|
+
const asIs = { ...envelope, documents };
|
|
2618
|
+
if (extraNotes.length)
|
|
2619
|
+
asIs.note = extraNotes.join(' ');
|
|
2620
|
+
if (jsonBytes(asIs) <= MAX_RESPONSE_BYTES) {
|
|
2621
|
+
return { documents, truncated: false, omitted: 0 };
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
// The largest note this response could end up carrying. Every real note is a
|
|
2625
|
+
// prefix-or-shorter of it (the omitted count can only shrink), so budgeting
|
|
2626
|
+
// against it is safe in the only direction that matters.
|
|
2627
|
+
const worstCaseNote = [...extraNotes, TRUNCATED_NOTE, omittedNote(documents.length)].join(' ');
|
|
2628
|
+
const size = (docs) => jsonBytes({ ...envelope, documents: docs, note: worstCaseNote });
|
|
2629
|
+
const stubs = documents.map((document) => ({
|
|
2630
|
+
...document,
|
|
2631
|
+
content: TRUNCATION_MARKER,
|
|
2632
|
+
truncated: true,
|
|
2633
|
+
content_length: countCharacters(document.content),
|
|
2634
|
+
}));
|
|
2635
|
+
// Code points are materialized LAZILY, for the one document actually being cut:
|
|
2636
|
+
// `[...content]` on 400 × 1MB is 400M array elements nobody reads.
|
|
2637
|
+
let pointsIndex = -1;
|
|
2638
|
+
let points = [];
|
|
2639
|
+
const withPrefix = (index, chars) => {
|
|
2640
|
+
if (chars <= 0)
|
|
2641
|
+
return { ...stubs[index], content: TRUNCATION_MARKER };
|
|
2642
|
+
if (pointsIndex !== index) {
|
|
2643
|
+
pointsIndex = index;
|
|
2644
|
+
points = [...documents[index].content];
|
|
2645
|
+
}
|
|
2646
|
+
return {
|
|
2647
|
+
...stubs[index],
|
|
2648
|
+
content: `${points.slice(0, chars).join('')}\n\n${TRUNCATION_MARKER}`,
|
|
2649
|
+
};
|
|
2650
|
+
};
|
|
2651
|
+
// How many stubs the budget can hold at all. `size` is monotone in the number
|
|
2652
|
+
// of stubs, so this binary search is exact.
|
|
2653
|
+
let floorLo = 0;
|
|
2654
|
+
let floorHi = stubs.length;
|
|
2655
|
+
while (floorLo < floorHi) {
|
|
2656
|
+
const mid = Math.ceil((floorLo + floorHi) / 2);
|
|
2657
|
+
if (size(stubs.slice(0, mid)) <= MAX_RESPONSE_BYTES)
|
|
2658
|
+
floorLo = mid;
|
|
2659
|
+
else
|
|
2660
|
+
floorHi = mid - 1;
|
|
2661
|
+
}
|
|
2662
|
+
if (floorLo < documents.length) {
|
|
2663
|
+
// Metadata alone overflows. No content can fit, so don't pretend to look for
|
|
2664
|
+
// any: emit the stubs that fit and report the rest as an explicit count.
|
|
2665
|
+
return {
|
|
2666
|
+
documents: stubs.slice(0, floorLo),
|
|
2667
|
+
truncated: true,
|
|
2668
|
+
omitted: documents.length - floorLo,
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
const fitted = [];
|
|
2672
|
+
let truncated = false;
|
|
2673
|
+
let exhausted = false;
|
|
2674
|
+
for (let index = 0; index < documents.length; index += 1) {
|
|
2675
|
+
// INVARIANT: `[...fitted, stubs[index], ...rest] <= MAX_RESPONSE_BYTES`. Base
|
|
2676
|
+
// case is the stub-floor check above (fitted empty, every document a stub);
|
|
2677
|
+
// each iteration pushes something the last `fits()` call measured against the
|
|
2678
|
+
// same tail, so it holds inductively. That is what makes a zero-character cut
|
|
2679
|
+
// — and every stub after it — safe to push unchecked.
|
|
2680
|
+
if (exhausted) {
|
|
2681
|
+
fitted.push(stubs[index]);
|
|
2682
|
+
continue;
|
|
2683
|
+
}
|
|
2684
|
+
const document = documents[index];
|
|
2685
|
+
const rest = stubs.slice(index + 1);
|
|
2686
|
+
const fits = (candidate) => size([...fitted, candidate, ...rest]) <= MAX_RESPONSE_BYTES;
|
|
2687
|
+
if (fits(document)) {
|
|
2688
|
+
fitted.push(document);
|
|
2689
|
+
continue;
|
|
2690
|
+
}
|
|
2691
|
+
truncated = true;
|
|
2692
|
+
let lo = 0;
|
|
2693
|
+
let hi = stubs[index].content_length ?? 0;
|
|
2694
|
+
while (lo < hi) {
|
|
2695
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
2696
|
+
if (fits(withPrefix(index, mid)))
|
|
2697
|
+
lo = mid;
|
|
2698
|
+
else
|
|
2699
|
+
hi = mid - 1;
|
|
2700
|
+
}
|
|
2701
|
+
if (lo === 0)
|
|
2702
|
+
exhausted = true;
|
|
2703
|
+
fitted.push(withPrefix(index, lo));
|
|
2704
|
+
}
|
|
2705
|
+
return { documents: fitted, truncated, omitted: 0 };
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* PROJECT RESOLUTION, shared by `get_project_context` (Phase 3) and
|
|
2709
|
+
* `propose_project_context` (Phase 4). Both paths reduce to one task id — `task_id` when given,
|
|
2710
|
+
* otherwise the open session's task (the session registry carries `taskId`, not a
|
|
2711
|
+
* project) — and the project is read off that task. Agents therefore never have to
|
|
2712
|
+
* find, or be trusted with, a project id. An archived or deleted task resolves to
|
|
2713
|
+
* nothing and is refused by id, which is also what happens when a session's task
|
|
2714
|
+
* was archived or deleted after begin_work.
|
|
2715
|
+
*
|
|
2716
|
+
* The task id is UUID-shape-checked BEFORE the read, exactly as resolve_feedback
|
|
2717
|
+
* does, so `{ task_id: "task-1" }` gets a clean tool error naming it instead of a
|
|
2718
|
+
* Postgres `invalid input syntax for type uuid`. A `task_id` that was supplied but
|
|
2719
|
+
* is blank is refused rather than falling through to the open session's task —
|
|
2720
|
+
* silently answering about a DIFFERENT task and reporting success is the worst
|
|
2721
|
+
* possible reading of " ".
|
|
2722
|
+
*
|
|
2723
|
+
* The project's NAME rides along on the task select (`projects(name)`) rather than
|
|
2724
|
+
* costing a second round-trip; the FK makes the embed non-null, so there is no
|
|
2725
|
+
* "no project for task" state to write copy for.
|
|
2726
|
+
*/
|
|
2727
|
+
async function resolveContextProject(client, session, taskIdArg, tool) {
|
|
2728
|
+
if (taskIdArg !== undefined && taskIdArg.trim() === '') {
|
|
2729
|
+
return {
|
|
2730
|
+
ok: false,
|
|
2731
|
+
error: errorResult(`${tool}: task_id was blank. Pass a task id, or omit it to use the open session's task.`),
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
const taskId = taskIdArg?.trim() || session?.taskId;
|
|
2735
|
+
if (!taskId)
|
|
2736
|
+
return { ok: false, error: errorResult(noProjectContextError(tool)) };
|
|
2737
|
+
if (!UUID_RE.test(taskId)) {
|
|
2738
|
+
return { ok: false, error: errorResult(`${tool}: not a valid task id: "${taskId}".`) };
|
|
2739
|
+
}
|
|
2740
|
+
const task = await must(client
|
|
2741
|
+
.from('tasks')
|
|
2742
|
+
.select('project_id, projects(name)')
|
|
2743
|
+
.eq('id', taskId)
|
|
2744
|
+
.is('archived_at', null)
|
|
2745
|
+
.maybeSingle());
|
|
2746
|
+
if (!task)
|
|
2747
|
+
return { ok: false, error: errorResult(`${tool}: no task found for id "${taskId}".`) };
|
|
2748
|
+
// PostgREST returns a to-one embed as an object, but has returned an array
|
|
2749
|
+
// for the same shape across versions, so both are unwrapped. A MISSING name
|
|
2750
|
+
// is not defaulted to '': `tasks.project_id` is a non-null FK, so a blank
|
|
2751
|
+
// project in the envelope could only mean the read did not return what it
|
|
2752
|
+
// was asked for, and answering with an empty project name is a silent wrong
|
|
2753
|
+
// value in a field the agent uses to know which project it is reading.
|
|
2754
|
+
const embedded = Array.isArray(task.projects) ? (task.projects[0] ?? null) : task.projects;
|
|
2755
|
+
if (!embedded?.name) {
|
|
2756
|
+
return {
|
|
2757
|
+
ok: false,
|
|
2758
|
+
error: errorResult(`${tool}: could not read the project for task "${taskId}". Try again, ` +
|
|
2759
|
+
'and if it keeps happening report it — a task always has a project.'),
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
return { ok: true, value: { id: task.project_id, name: embedded.name } };
|
|
2763
|
+
}
|
|
2764
|
+
/**
|
|
2765
|
+
* CODEBASE MATCHING is over the project's own `cliv2_codebases` rows only, on
|
|
2766
|
+
* either the display name or the canonical `host/path` git remote (case-insensitive;
|
|
2767
|
+
* a raw remote URL is canonicalized with the same `hostedRemoteIdentity` the CLI
|
|
2768
|
+
* uses to register one, so `https://github.com/acme/web.git` matches `github.com/acme/web`).
|
|
2769
|
+
* A miss names the known codebases rather than just refusing — the agent can retry
|
|
2770
|
+
* without a second call.
|
|
2771
|
+
*
|
|
2772
|
+
* A NAME CAN BE AMBIGUOUS. `cliv2_codebases` is unique on `(project_id,
|
|
2773
|
+
* git_remote_url)` and NOT on `name`, so one project holding `github.com/acme/web`
|
|
2774
|
+
* and `gitlab.com/acme/web` — both named "web" — is normal. Taking the first hit
|
|
2775
|
+
* would silently answer about whichever was created first, with nothing in the
|
|
2776
|
+
* response for the agent to notice. Every match is collected and more than one is
|
|
2777
|
+
* an error listing the candidates by remote, phrased like the unknown-codebase
|
|
2778
|
+
* miss: it tells the agent exactly what to pass instead.
|
|
2779
|
+
*/
|
|
2780
|
+
async function resolveContextCodebase(client, projectId, wanted, tool) {
|
|
2781
|
+
const rows = (await must(client
|
|
2782
|
+
.from('cliv2_codebases')
|
|
2783
|
+
.select('id, name, git_remote_url')
|
|
2784
|
+
.eq('project_id', projectId)
|
|
2785
|
+
.order('created_at', { ascending: true }))) ?? [];
|
|
2786
|
+
// `normalizeRemoteUrl` already lowercases (src/git-remote.ts:32) and is
|
|
2787
|
+
// idempotent, and `git_remote_url` is STORED canonical (src/codebases.ts:87),
|
|
2788
|
+
// so the identity comparison subsumes any raw-value comparison. Name matching
|
|
2789
|
+
// stays case-insensitive.
|
|
2790
|
+
const needle = wanted.toLowerCase();
|
|
2791
|
+
const identity = hostedRemoteIdentity(wanted) ?? null;
|
|
2792
|
+
const found = rows.filter((row) => row.name.toLowerCase() === needle ||
|
|
2793
|
+
(identity !== null && row.git_remote_url.toLowerCase() === identity));
|
|
2794
|
+
if (found.length === 0) {
|
|
2795
|
+
return {
|
|
2796
|
+
ok: false,
|
|
2797
|
+
error: errorResult(`${tool}: no codebase named "${wanted}" in this project. ` +
|
|
2798
|
+
(rows.length
|
|
2799
|
+
? // Deduped: `name` has no unique constraint (only (project_id,
|
|
2800
|
+
// git_remote_url) does), so two codebases can share one name.
|
|
2801
|
+
// Listing it twice tells the agent nothing and reads as a bug.
|
|
2802
|
+
`Known codebases: ${[...new Set(rows.map((row) => row.name))].join(', ')}.`
|
|
2803
|
+
: 'This project has no codebases yet.')),
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
if (found.length > 1) {
|
|
2807
|
+
return {
|
|
2808
|
+
ok: false,
|
|
2809
|
+
error: errorResult(`${tool}: more than one codebase named "${wanted}" in this project. ` +
|
|
2810
|
+
`Matching git remotes: ${found.map((row) => row.git_remote_url).join(', ')}. ` +
|
|
2811
|
+
'Pass the git remote instead of the name.'),
|
|
2812
|
+
};
|
|
2813
|
+
}
|
|
2814
|
+
return { ok: true, value: { id: found[0].id, name: found[0].name } };
|
|
2815
|
+
}
|
|
2816
|
+
/**
|
|
2817
|
+
* Read the context documents for the project a task belongs to, optionally also
|
|
2818
|
+
* those of one of its codebases, optionally narrowed to some types. Project and
|
|
2819
|
+
* codebase resolution are the two helpers above, shared with Phase 4's
|
|
2820
|
+
* `propose_project_context`.
|
|
2821
|
+
*/
|
|
2822
|
+
export async function getProjectContextHandler(client, session, args) {
|
|
2823
|
+
try {
|
|
2824
|
+
const resolvedProject = await resolveContextProject(client, session, args.task_id, 'get_project_context');
|
|
2825
|
+
if (!resolvedProject.ok)
|
|
2826
|
+
return resolvedProject.error;
|
|
2827
|
+
const project = resolvedProject.value;
|
|
2828
|
+
const wanted = args.codebase?.trim();
|
|
2829
|
+
let codebase = null;
|
|
2830
|
+
if (wanted) {
|
|
2831
|
+
const resolvedCodebase = await resolveContextCodebase(client, project.id, wanted, 'get_project_context');
|
|
2832
|
+
if (!resolvedCodebase.ok)
|
|
2833
|
+
return resolvedCodebase.error;
|
|
2834
|
+
codebase = resolvedCodebase.value;
|
|
2835
|
+
}
|
|
2836
|
+
// An empty `types` array is treated as "not narrowed", the same as omitting it:
|
|
2837
|
+
// `.in('type', [])` would return nothing, which is never what an agent that sent
|
|
2838
|
+
// an empty list meant.
|
|
2839
|
+
const types = args.types && args.types.length > 0 ? args.types : undefined;
|
|
2840
|
+
// Project documents FIRST, then the codebase's — the order the transcript
|
|
2841
|
+
// fixes and the order the feature's North Star states ("project docs first,
|
|
2842
|
+
// then the codebase's own").
|
|
2843
|
+
const projectDocuments = await fetchProjectDocuments(client, project.id, null, types);
|
|
2844
|
+
const codebaseDocuments = codebase
|
|
2845
|
+
? await fetchProjectDocuments(client, project.id, codebase.id, types)
|
|
2846
|
+
: { rows: [], capped: false };
|
|
2847
|
+
const shape = (row, scope) => ({
|
|
2848
|
+
title: row.title,
|
|
2849
|
+
type: row.type,
|
|
2850
|
+
scope,
|
|
2851
|
+
created_at: row.created_at,
|
|
2852
|
+
updated_at: row.updated_at,
|
|
2853
|
+
content: row.content,
|
|
2854
|
+
});
|
|
2855
|
+
const envelope = {
|
|
2856
|
+
project: project.name,
|
|
2857
|
+
scope: codebase ? `project + codebase:${codebase.name}` : 'project',
|
|
2858
|
+
};
|
|
2859
|
+
// Notes that are true regardless of how the budget lands, and that the budget
|
|
2860
|
+
// therefore has to reserve room for.
|
|
2861
|
+
const notes = [];
|
|
2862
|
+
if (projectDocuments.capped || codebaseDocuments.capped)
|
|
2863
|
+
notes.push(ROW_CAP_NOTE);
|
|
2864
|
+
const fit = fitToBudget(envelope, [
|
|
2865
|
+
...projectDocuments.rows.map((row) => shape(row, 'project')),
|
|
2866
|
+
...codebaseDocuments.rows.map((row) => shape(row, 'codebase')),
|
|
2867
|
+
], notes);
|
|
2868
|
+
if (fit.truncated)
|
|
2869
|
+
notes.push(TRUNCATED_NOTE);
|
|
2870
|
+
if (fit.omitted > 0)
|
|
2871
|
+
notes.push(omittedNote(fit.omitted));
|
|
2872
|
+
const payload = { ...envelope, documents: fit.documents };
|
|
2873
|
+
if (notes.length === 0 && fit.documents.length === 0) {
|
|
2874
|
+
// Only a read that returned NOTHING and hit no cap can be an empty state.
|
|
2875
|
+
const narrowed = Boolean(codebase) || Boolean(types);
|
|
2876
|
+
notes.push(narrowed
|
|
2877
|
+
? await narrowedEmptyNote(client, project.id, codebase, types)
|
|
2878
|
+
: await unnarrowedEmptyNote(client, project.id));
|
|
2879
|
+
}
|
|
2880
|
+
if (notes.length)
|
|
2881
|
+
payload.note = notes.join(' ');
|
|
2882
|
+
return textResult(payload);
|
|
2883
|
+
}
|
|
2884
|
+
catch (err) {
|
|
2885
|
+
return errorResult(`get_project_context failed: ${err.message}`);
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* The note for a read that a FILTER emptied, not an empty project. It names what
|
|
2890
|
+
* matched nothing and what the same read would return unnarrowed, so an agent
|
|
2891
|
+
* learns that relaxing `types` would help rather than concluding the project has
|
|
2892
|
+
* no context. It never points at *Project settings → Project context*: with a
|
|
2893
|
+
* codebase in play those documents are written on the codebase page (Phase 2).
|
|
2894
|
+
*/
|
|
2895
|
+
async function narrowedEmptyNote(client, projectId, codebase, types) {
|
|
2896
|
+
const filters = [
|
|
2897
|
+
...(codebase ? [`codebase "${codebase.name}"`] : []),
|
|
2898
|
+
...(types ? [`types ${types.join(', ')}`] : []),
|
|
2899
|
+
];
|
|
2900
|
+
const matched = `No context documents match this read (${filters.join('; ')})`;
|
|
2901
|
+
const scopePhrase = codebase
|
|
2902
|
+
? `neither this project nor codebase "${codebase.name}" has any context documents at all`
|
|
2903
|
+
: 'this project has no context documents at all';
|
|
2904
|
+
// Without a `types` narrowing there is no type filter to relax, and every scope
|
|
2905
|
+
// this call read came back empty — so the answer is settled without a second
|
|
2906
|
+
// read, and "Without the type filter it would return: …" would be nonsense.
|
|
2907
|
+
if (!types)
|
|
2908
|
+
return `${matched}, and ${scopePhrase}.`;
|
|
2909
|
+
// The extra read is COSMETIC — it only enriches the note. Its failure must not
|
|
2910
|
+
// turn a correct empty answer into `get_project_context failed: …`, so it is
|
|
2911
|
+
// caught here rather than at the handler boundary, and the note degrades to the
|
|
2912
|
+
// sentence that is true without it.
|
|
2913
|
+
let available;
|
|
2914
|
+
try {
|
|
2915
|
+
available = [
|
|
2916
|
+
...new Set([
|
|
2917
|
+
...(await fetchProjectDocumentTypes(client, projectId, null)),
|
|
2918
|
+
...(codebase ? await fetchProjectDocumentTypes(client, projectId, codebase.id) : []),
|
|
2919
|
+
]),
|
|
2920
|
+
].sort();
|
|
2921
|
+
}
|
|
2922
|
+
catch {
|
|
2923
|
+
return `${matched}. Call again with different \`types\`, or omit \`types\` to get everything.`;
|
|
2924
|
+
}
|
|
2925
|
+
if (available.length === 0)
|
|
2926
|
+
return `${matched}, and ${scopePhrase}.`;
|
|
2927
|
+
return (`${matched}. ` +
|
|
2928
|
+
`Without the type filter it would return: ${available.join(', ')}. ` +
|
|
2929
|
+
'Call again with different `types`, or omit `types` to get everything.');
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* The note for an UNNARROWED read that came back empty. Only the project scope is
|
|
2933
|
+
* read when no `codebase` was passed, so "this project has no context documents
|
|
2934
|
+
* yet" is a lie for exactly the project Phase 2 exists to serve — one whose
|
|
2935
|
+
* documents all live on codebase pages. So before saying it, ask whether any
|
|
2936
|
+
* codebase-scoped document exists, and if so name the way to reach it.
|
|
2937
|
+
*
|
|
2938
|
+
* The lookup is cosmetic and its failure is swallowed: a correct empty answer is
|
|
2939
|
+
* never discarded because a note lookup failed. Falling back to the approved
|
|
2940
|
+
* empty-state note is the honest default — it is what the read this call actually
|
|
2941
|
+
* made returned.
|
|
2942
|
+
*/
|
|
2943
|
+
async function unnarrowedEmptyNote(client, projectId) {
|
|
2944
|
+
try {
|
|
2945
|
+
if (await hasCodebaseScopedDocuments(client, projectId)) {
|
|
2946
|
+
return ('This project has no project-wide context documents, but at least one of its codebases ' +
|
|
2947
|
+
'has its own. Call again with `codebase` set to the codebase you are working in to read them.');
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
catch {
|
|
2951
|
+
// Fall through to the empty-state note.
|
|
2952
|
+
}
|
|
2953
|
+
return NO_PROJECT_DOCUMENTS_NOTE;
|
|
2954
|
+
}
|
|
2955
|
+
// ---------------------------------------------------------------------------
|
|
2956
|
+
// Project context (feature 13a, Phase 4) — `propose_project_context`.
|
|
2957
|
+
//
|
|
2958
|
+
// THE ONLY WRITE THIS FEATURE'S CLI SURFACE MAKES, AND IT CREATES NO DOCUMENT.
|
|
2959
|
+
// An agent that has just read the repo it is working in hands over a batch of
|
|
2960
|
+
// PROPOSALS; approval happens in the web app, one by one or all at once, and
|
|
2961
|
+
// only an accept creates a `public.project_documents` row. So this tool writes
|
|
2962
|
+
// `public.cliv2_context_proposals` — a `cliv2_*` table, per AGENTS.md
|
|
2963
|
+
// § "Database schema naming": v2 may READ shared product data through the
|
|
2964
|
+
// user's RLS (that is what Phase 3 does), but its own state and every write it
|
|
2965
|
+
// makes live behind the `cliv2_` prefix. `project_id`, `codebase_id` and
|
|
2966
|
+
// `scanned_codebase_id` are held BY VALUE with no FK, for the same reason.
|
|
2967
|
+
//
|
|
2968
|
+
// SCOPE IS PER PROPOSAL, NOT PER CALL. A settled user ruling: one scan produces
|
|
2969
|
+
// both project-scoped and codebase-scoped proposals at once and they are
|
|
2970
|
+
// reviewed together in project settings. `scope: "project"` stores
|
|
2971
|
+
// `codebase_id = null`; `scope: "codebase"` stores the SCANNED codebase's id.
|
|
2972
|
+
// There is deliberately no way to target a different codebase — the agent read
|
|
2973
|
+
// one repo, and a proposal aimed somewhere it never looked is a claim it cannot
|
|
2974
|
+
// support.
|
|
2975
|
+
//
|
|
2976
|
+
// A RE-SCAN REPLACES, AND AN EMPTY SCAN CLEARS. Before inserting, this user's
|
|
2977
|
+
// pending rows for (project_id, scanned_codebase_id) are deleted. A scan
|
|
2978
|
+
// reports what the repo holds NOW; it carries no memory of past answers — so a
|
|
2979
|
+
// scan that finds NOTHING is a real finding and is accepted: it runs the same
|
|
2980
|
+
// replace-delete, inserts nothing, and reports what it cleared. Refusing it
|
|
2981
|
+
// would mean a repo whose context files were deleted could never get back to
|
|
2982
|
+
// "no proposals", which the review surface models as a state (user ruling,
|
|
2983
|
+
// 2026-07-27). `codebase` stays required either way — a scan always names the
|
|
2984
|
+
// repo it read. The user ruled explicitly
|
|
2985
|
+
// that rejecting a proposal DISCARDS it and stores nothing — there is no
|
|
2986
|
+
// rejected list and no "already rejected" state to suppress against, and a
|
|
2987
|
+
// later scan is free to propose the same thing again (ux.md § Corrections,
|
|
2988
|
+
// Phase 4 / 2026-07-27). Anything that looked like suppression here would be
|
|
2989
|
+
// inventing that state.
|
|
2990
|
+
//
|
|
2991
|
+
// NOTHING IS EVER SILENTLY TRUNCATED. The batch is bounded by a proposal COUNT
|
|
2992
|
+
// and a total CHARACTER budget, and a batch over either is REFUSED with the
|
|
2993
|
+
// limit and the actual figure named. Phase 3's log is a catalogue of what
|
|
2994
|
+
// silent truncation costs; a write path has an easier answer than a read path —
|
|
2995
|
+
// refuse, and let the agent send a smaller batch — so it takes it.
|
|
2996
|
+
//
|
|
2997
|
+
// NO ABSOLUTE PATH REACHES THE CLOUD — IN `source_path`. That column, and not
|
|
2998
|
+
// the whole row, is what this rule covers, because `source_path` is
|
|
2999
|
+
// PRODUCT-EMITTED: the agent derives it from the filesystem it just walked, and
|
|
3000
|
+
// it is rendered in `mono` on every proposal card by hosted browser JS, where
|
|
3001
|
+
// this product hard-enforces that absolute local paths never appear (AGENTS.md;
|
|
3002
|
+
// the contract test at web/src/lib/hosted-path-privacy.contract.test.ts).
|
|
3003
|
+
// `content` is deliberately NOT policed the same way: it is AUTHORED prose that
|
|
3004
|
+
// the agent read verbatim out of the repo, a hand-typed document may legally
|
|
3005
|
+
// contain any text at all, and a check there would refuse AGENTS.md itself —
|
|
3006
|
+
// the very document this feature exists to import (user ruling, 2026-07-27).
|
|
3007
|
+
// The table cannot police the path either — no check constraint tells a legal
|
|
3008
|
+
// relative path from an absolute one across POSIX, Windows drive-letter,
|
|
3009
|
+
// drive-relative and UNC forms without knowing the platform — so this tool
|
|
3010
|
+
// does, and it REFUSES rather than rewriting: silently rewriting a path the
|
|
3011
|
+
// agent sent is how a proposal ends up pointing at a file that isn't there.
|
|
3012
|
+
// ---------------------------------------------------------------------------
|
|
3013
|
+
/** The scopes a proposal may carry. `project` stores `codebase_id = null`;
|
|
3014
|
+
* `codebase` stores the scanned codebase's id. */
|
|
3015
|
+
const PROPOSAL_SCOPES = ['project', 'codebase'];
|
|
3016
|
+
/** `public.project_documents.title` is `length(title) <= 100` (Phase 1 §1).
|
|
3017
|
+
* Refused HERE so the agent learns at propose time, rather than the user
|
|
3018
|
+
* discovering at accept time that a proposal can never become a document. */
|
|
3019
|
+
const MAX_PROPOSAL_TITLE_CHARS = 100;
|
|
3020
|
+
/** How many proposals one scan may hand over. A review surface is a human
|
|
3021
|
+
* reading cards; a hundred of them is not a review. Refused, never trimmed. */
|
|
3022
|
+
const MAX_PROPOSALS = 50;
|
|
3023
|
+
/** Total characters across the whole batch (content + title + reason +
|
|
3024
|
+
* source_path). `content` has no length cap on `project_documents` and gets
|
|
3025
|
+
* none here — a bound this table enforced but the document table did not would
|
|
3026
|
+
* make a legal document un-acceptable — so the BATCH is what is bounded, which
|
|
3027
|
+
* is the thing that actually protects the write. */
|
|
3028
|
+
const MAX_PROPOSAL_BATCH_CHARS = 400_000;
|
|
3029
|
+
/** Absolute in any form a repo path can arrive in: POSIX (`/etc`), UNC and
|
|
3030
|
+
* root-relative Windows (`\\server\share`, `\Users\Lane\repo`), Windows
|
|
3031
|
+
* drive-letter (`C:\repo`, `C:/repo`) and drive-RELATIVE (`C:AGENTS.md`, which
|
|
3032
|
+
* resolves against that drive's current directory and still discloses a local
|
|
3033
|
+
* layout). One leading separator of EITHER kind, or any drive letter, is
|
|
3034
|
+
* enough — a single leading backslash is a Windows absolute path just as `/`
|
|
3035
|
+
* is a POSIX one.
|
|
3036
|
+
*
|
|
3037
|
+
* `cliv2_work_reservations_path_relative`
|
|
3038
|
+
* (20260722160000_cliv2_coordination.sql) is the same rule for the same
|
|
3039
|
+
* reason, and it has the narrower form with both of those holes. That is a
|
|
3040
|
+
* pre-existing bug on that table, not a licence to repeat it here. */
|
|
3041
|
+
const ABSOLUTE_PATH_RE = /^(?:[\\/]|[A-Za-z]:)/;
|
|
3042
|
+
/** A `..` segment under either separator. A relative path that climbs out of
|
|
3043
|
+
* the scanned repo names a file the scan had no business reading, and it is
|
|
3044
|
+
* refused for the same privacy reason an absolute path is. */
|
|
3045
|
+
const ESCAPING_PATH_RE = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
|
|
3046
|
+
/** Where the user reviews what was proposed. The agent's job ends at proposing,
|
|
3047
|
+
* so the success result has to say this in prose it can relay verbatim. */
|
|
3048
|
+
const REVIEW_LOCATION = 'Project settings → Project context';
|
|
3049
|
+
/**
|
|
3050
|
+
* Validate one proposal into the row it will become, or refuse it by index AND
|
|
3051
|
+
* by title — an agent that sent twelve proposals needs to know WHICH one, and
|
|
3052
|
+
* an index alone is a poor handle when it is re-reading its own array.
|
|
3053
|
+
*/
|
|
3054
|
+
function validateProposal(proposal, index, project, scanned) {
|
|
3055
|
+
const at = `proposal ${index + 1}`;
|
|
3056
|
+
const refuse = (message) => ({
|
|
3057
|
+
ok: false,
|
|
3058
|
+
error: errorResult(`propose_project_context: ${at} ${message}`),
|
|
3059
|
+
});
|
|
3060
|
+
if (!proposal || typeof proposal !== 'object') {
|
|
3061
|
+
return refuse('is not an object. Each proposal needs title, type, content, source_path, reason and scope.');
|
|
3062
|
+
}
|
|
3063
|
+
const title = typeof proposal.title === 'string' ? proposal.title.trim() : '';
|
|
3064
|
+
if (!title)
|
|
3065
|
+
return refuse('has no title.');
|
|
3066
|
+
if (countCharacters(title) > MAX_PROPOSAL_TITLE_CHARS) {
|
|
3067
|
+
return refuse(`has a ${countCharacters(title)}-character title; the limit is ${MAX_PROPOSAL_TITLE_CHARS}. ` +
|
|
3068
|
+
'Shorten it — a document title is a heading, not a summary.');
|
|
3069
|
+
}
|
|
3070
|
+
if (!PROJECT_DOCUMENT_TYPES.includes(proposal.type)) {
|
|
3071
|
+
return refuse(`("${title}") has type "${String(proposal.type)}", which is not a context document type. ` +
|
|
3072
|
+
`Use one of: ${PROJECT_DOCUMENT_TYPES.join(', ')}.`);
|
|
3073
|
+
}
|
|
3074
|
+
if (!PROPOSAL_SCOPES.includes(proposal.scope)) {
|
|
3075
|
+
return refuse(`("${title}") has scope "${String(proposal.scope)}". Use "project" for context that applies to the ` +
|
|
3076
|
+
`whole project, or "codebase" for context that belongs to "${scanned.name}" alone.`);
|
|
3077
|
+
}
|
|
3078
|
+
const content = typeof proposal.content === 'string' ? proposal.content : '';
|
|
3079
|
+
if (content.trim() === '') {
|
|
3080
|
+
return refuse(`("${title}") has no content. Propose only what you actually read — an empty document helps nobody.`);
|
|
3081
|
+
}
|
|
3082
|
+
const reason = typeof proposal.reason === 'string' ? proposal.reason.trim() : '';
|
|
3083
|
+
if (!reason) {
|
|
3084
|
+
return refuse(`("${title}") has no reason. Every proposal carries one line saying why this belongs in the ` +
|
|
3085
|
+
"project's context, so the user can judge the judgement and not just the text.");
|
|
3086
|
+
}
|
|
3087
|
+
const rawPath = typeof proposal.source_path === 'string' ? proposal.source_path.trim() : '';
|
|
3088
|
+
if (!rawPath) {
|
|
3089
|
+
return refuse(`("${title}") has no source_path. Name the file in the repo this content came from.`);
|
|
3090
|
+
}
|
|
3091
|
+
if (ABSOLUTE_PATH_RE.test(rawPath)) {
|
|
3092
|
+
return refuse(`("${title}") has an absolute source_path: "${rawPath}". Pass it relative to the root of ` +
|
|
3093
|
+
`"${scanned.name}" (e.g. "docs/architecture.md"). Absolute local paths must never leave this machine.`);
|
|
3094
|
+
}
|
|
3095
|
+
if (ESCAPING_PATH_RE.test(rawPath)) {
|
|
3096
|
+
return refuse(`("${title}") has a source_path that climbs out of the repo: "${rawPath}". Propose only files ` +
|
|
3097
|
+
`inside "${scanned.name}".`);
|
|
3098
|
+
}
|
|
3099
|
+
// A leading "./" is the one rewrite made, because it changes nothing about
|
|
3100
|
+
// which file is named and "./AGENTS.md" renders as noise on a card.
|
|
3101
|
+
const sourcePath = rawPath.replace(/^\.\//, '');
|
|
3102
|
+
return {
|
|
3103
|
+
ok: true,
|
|
3104
|
+
value: {
|
|
3105
|
+
project_id: project.id,
|
|
3106
|
+
codebase_id: proposal.scope === 'project' ? null : scanned.id,
|
|
3107
|
+
scanned_codebase_id: scanned.id,
|
|
3108
|
+
title,
|
|
3109
|
+
type: proposal.type,
|
|
3110
|
+
content,
|
|
3111
|
+
source_path: sourcePath,
|
|
3112
|
+
reason,
|
|
3113
|
+
},
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
/**
|
|
3117
|
+
* Hand over a batch of proposed context documents for a repo the agent has read.
|
|
3118
|
+
*
|
|
3119
|
+
* WRITES PENDING ROWS ONLY. No `project_documents` row is created here under any
|
|
3120
|
+
* argument — the web app's accept does that. The success result therefore ends
|
|
3121
|
+
* by telling the agent where the human reviews them, because that is the only
|
|
3122
|
+
* remaining step and the agent cannot take it.
|
|
3123
|
+
*
|
|
3124
|
+
* REQUIRES NO OPEN SESSION when `task_id` is passed, matching Phase 3. This is a
|
|
3125
|
+
* write, and every other write tool in this file demands a session — but the
|
|
3126
|
+
* session exists to ATTRIBUTE work to a task, and a proposal is attributed to a
|
|
3127
|
+
* project and a codebase, neither of which the session supplies. Scanning a repo
|
|
3128
|
+
* before opening a work session is the ordinary flow (an agent orienting itself),
|
|
3129
|
+
* and refusing it would buy nothing.
|
|
3130
|
+
*
|
|
3131
|
+
* THE REPLACE AND THE INSERT ARE NOT ATOMIC. Two statements, no transaction —
|
|
3132
|
+
* PostgREST has none to offer, and an RPC to get one would put v2 logic in the
|
|
3133
|
+
* database for a case that cannot corrupt anything: the delete is scoped to this
|
|
3134
|
+
* user's rows for exactly this (project, scanned codebase), so a failure between
|
|
3135
|
+
* the two leaves the previous scan's proposals gone and the new ones unwritten.
|
|
3136
|
+
* The recovery is to run the scan again, which is precisely what this tool does.
|
|
3137
|
+
* The insert failing after the delete is reported as a failure, so nothing is
|
|
3138
|
+
* silently lost.
|
|
3139
|
+
*/
|
|
3140
|
+
export async function proposeProjectContextHandler(client, session, args) {
|
|
3141
|
+
try {
|
|
3142
|
+
const resolvedProject = await resolveContextProject(client, session, args.task_id, 'propose_project_context');
|
|
3143
|
+
if (!resolvedProject.ok)
|
|
3144
|
+
return resolvedProject.error;
|
|
3145
|
+
const project = resolvedProject.value;
|
|
3146
|
+
// The scanned codebase is REQUIRED — every proposal, project-scoped ones
|
|
3147
|
+
// included, records which repo the claim came from, and a project-scoped
|
|
3148
|
+
// proposal with no provenance is unreviewable.
|
|
3149
|
+
const wanted = args.codebase?.trim();
|
|
3150
|
+
if (!wanted) {
|
|
3151
|
+
return errorResult('propose_project_context: name the codebase you scanned in `codebase` (its name or git remote). ' +
|
|
3152
|
+
'Every proposal records which repo it came from.');
|
|
3153
|
+
}
|
|
3154
|
+
const resolvedCodebase = await resolveContextCodebase(client, project.id, wanted, 'propose_project_context');
|
|
3155
|
+
if (!resolvedCodebase.ok)
|
|
3156
|
+
return resolvedCodebase.error;
|
|
3157
|
+
const scanned = resolvedCodebase.value;
|
|
3158
|
+
// AN EMPTY BATCH IS A REAL SCAN RESULT, NOT A MISTAKE. `[]` says the repo
|
|
3159
|
+
// holds no standing context now, and it clears whatever an earlier scan left
|
|
3160
|
+
// pending — without it there is no path from a real scan back to "no
|
|
3161
|
+
// proposals" (user ruling, 2026-07-27). A MISSING or non-array `proposals`
|
|
3162
|
+
// is still a malformed call, and is refused so the empty-scan meaning stays
|
|
3163
|
+
// something the agent has to state on purpose.
|
|
3164
|
+
const proposals = args.proposals;
|
|
3165
|
+
if (!Array.isArray(proposals)) {
|
|
3166
|
+
return errorResult('propose_project_context: `proposals` must be an array. Send the documents you found, or send ' +
|
|
3167
|
+
'`[]` to report that this repo holds no standing context — an empty scan is a finding, and it ' +
|
|
3168
|
+
'clears anything an earlier scan left waiting for review.');
|
|
3169
|
+
}
|
|
3170
|
+
if (proposals.length > MAX_PROPOSALS) {
|
|
3171
|
+
return errorResult(`propose_project_context: ${proposals.length} proposals is more than the limit of ${MAX_PROPOSALS}. ` +
|
|
3172
|
+
'Nothing was written. Propose the documents that carry standing context for the whole team, ' +
|
|
3173
|
+
'not every file you read.');
|
|
3174
|
+
}
|
|
3175
|
+
const rows = [];
|
|
3176
|
+
for (const [index, proposal] of proposals.entries()) {
|
|
3177
|
+
const validated = validateProposal(proposal, index, project, scanned);
|
|
3178
|
+
if (!validated.ok)
|
|
3179
|
+
return validated.error;
|
|
3180
|
+
rows.push(validated.value);
|
|
3181
|
+
}
|
|
3182
|
+
// Measured AFTER per-proposal validation so the agent hears about a broken
|
|
3183
|
+
// proposal before it hears about the batch's size — the specific fault is
|
|
3184
|
+
// more useful than the aggregate one.
|
|
3185
|
+
const totalChars = rows.reduce((total, row) => total +
|
|
3186
|
+
countCharacters(row.content) +
|
|
3187
|
+
countCharacters(row.title) +
|
|
3188
|
+
countCharacters(row.reason) +
|
|
3189
|
+
countCharacters(row.source_path), 0);
|
|
3190
|
+
if (totalChars > MAX_PROPOSAL_BATCH_CHARS) {
|
|
3191
|
+
return errorResult(`propose_project_context: this batch is ${totalChars.toLocaleString('en-US')} characters, over the ` +
|
|
3192
|
+
`limit of ${MAX_PROPOSAL_BATCH_CHARS.toLocaleString('en-US')}. Nothing was written, and nothing was ` +
|
|
3193
|
+
'truncated. Send the batch in smaller parts, or drop the documents that are too long to be ' +
|
|
3194
|
+
'standing context.');
|
|
3195
|
+
}
|
|
3196
|
+
// A RE-SCAN REPLACES: this user's pending rows for this (project, scanned
|
|
3197
|
+
// codebase) go first. RLS scopes the delete to the user; the two eq filters
|
|
3198
|
+
// scope it to this scan's subject, so another codebase's pending proposals —
|
|
3199
|
+
// and another user's — are untouched. `.select('id')` makes the replaced
|
|
3200
|
+
// COUNT knowable, which is what the result reports instead of leaving the
|
|
3201
|
+
// agent to guess whether its earlier scan is still standing.
|
|
3202
|
+
const replaced = (await must(client
|
|
3203
|
+
.from('cliv2_context_proposals')
|
|
3204
|
+
.delete()
|
|
3205
|
+
.eq('project_id', project.id)
|
|
3206
|
+
.eq('scanned_codebase_id', scanned.id)
|
|
3207
|
+
.select('id'))) ?? [];
|
|
3208
|
+
// Nothing to insert on an empty scan — the delete above WAS the whole call.
|
|
3209
|
+
if (rows.length)
|
|
3210
|
+
await must(client.from('cliv2_context_proposals').insert(rows));
|
|
3211
|
+
const projectScoped = rows.filter((row) => row.codebase_id === null).length;
|
|
3212
|
+
const codebaseScoped = rows.length - projectScoped;
|
|
3213
|
+
const noun = rows.length === 1 ? 'proposal' : 'proposals';
|
|
3214
|
+
const sentences = [];
|
|
3215
|
+
if (rows.length === 0) {
|
|
3216
|
+
sentences.push(`Nothing was found: this scan of "${scanned.name}" turned up no standing context to propose for ` +
|
|
3217
|
+
`"${project.name}", so nothing is waiting for review.`);
|
|
3218
|
+
sentences.push(replaced.length
|
|
3219
|
+
? `It cleared ${replaced.length} proposal${replaced.length === 1 ? '' : 's'} from an earlier scan ` +
|
|
3220
|
+
`of "${scanned.name}", which no longer stand — a scan reports what the repo holds now.`
|
|
3221
|
+
: 'There was nothing pending to clear.');
|
|
3222
|
+
sentences.push('No document was touched: accepted context documents are not proposals and are unaffected. Tell ' +
|
|
3223
|
+
'the user the repo holds no standing context worth importing.');
|
|
3224
|
+
}
|
|
3225
|
+
else {
|
|
3226
|
+
sentences.push(`${rows.length} context document ${noun} ${rows.length === 1 ? 'is' : 'are'} now waiting for review ` +
|
|
3227
|
+
`in the web app, under ${REVIEW_LOCATION} for "${project.name}".`);
|
|
3228
|
+
if (replaced.length) {
|
|
3229
|
+
sentences.push(`This re-scan replaced ${replaced.length} proposal${replaced.length === 1 ? '' : 's'} from an ` +
|
|
3230
|
+
`earlier scan of "${scanned.name}" — a scan reports what the repo holds now.`);
|
|
3231
|
+
}
|
|
3232
|
+
sentences.push('Nothing has been created yet: the user accepts or rejects each one there, and only an accept ' +
|
|
3233
|
+
'makes it a context document. Tell them where to look.');
|
|
3234
|
+
}
|
|
3235
|
+
return textResult({
|
|
3236
|
+
project: project.name,
|
|
3237
|
+
scanned_codebase: scanned.name,
|
|
3238
|
+
proposed: rows.length,
|
|
3239
|
+
project_scoped: projectScoped,
|
|
3240
|
+
codebase_scoped: codebaseScoped,
|
|
3241
|
+
replaced: replaced.length,
|
|
3242
|
+
review_in: REVIEW_LOCATION,
|
|
3243
|
+
instruction: sentences.join(' '),
|
|
3244
|
+
});
|
|
3245
|
+
}
|
|
3246
|
+
catch (err) {
|
|
3247
|
+
return errorResult(`propose_project_context failed: ${errorMessage(err)}`);
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
// ---------------------------------------------------------------------------
|
|
3251
|
+
// Client context intake (feature 24c, Slice 1) — `get_client_context` /
|
|
3252
|
+
// `propose_client_context`.
|
|
3253
|
+
//
|
|
3254
|
+
// THE SAME SPLIT AS 13a, ON A CLIENT INSTEAD OF A REPO. The agent READS the
|
|
3255
|
+
// client's existing context (product tables — allowed, AGENTS.md: v2 may
|
|
3256
|
+
// select shared product data through the signed-in user's RLS) and WRITES
|
|
3257
|
+
// only `cliv2_*` state: a pending proposal queue
|
|
3258
|
+
// (`cliv2_client_context_proposals`) reviewed on the client page, and a
|
|
3259
|
+
// processed flag per source (`cliv2_client_source_reads`). Nothing here
|
|
3260
|
+
// writes a product table — `client_context_records` rows are created only by
|
|
3261
|
+
// the web's accept.
|
|
3262
|
+
//
|
|
3263
|
+
// ANCHORING: `client_id`, not a task or session. A client is not reachable
|
|
3264
|
+
// through a task (Slice 1 deliberately predates any project attachment —
|
|
3265
|
+
// ux.md: "a client can have context before any project is attached to it"),
|
|
3266
|
+
// so the copyable instruction on the client page hands the agent the client
|
|
3267
|
+
// id directly, and neither tool requires an open work session — the same
|
|
3268
|
+
// reasoning as get/propose_project_context with task_id: the session exists
|
|
3269
|
+
// to attribute work to a task, and this work is attributed to a client, which
|
|
3270
|
+
// the session cannot supply. cli-v2 has no agent_run; there is nothing else
|
|
3271
|
+
// to anchor to.
|
|
3272
|
+
//
|
|
3273
|
+
// THE CITATION NON-NEGOTIABLE IS ENFORCED AT THE WRITE. Every proposal's
|
|
3274
|
+
// `quote` must be a VERBATIM substring of its source's stored body
|
|
3275
|
+
// (indexOf !== -1) — the Step −1 ruling made a citation "the quoted span
|
|
3276
|
+
// stored verbatim plus the source id", and the web finds the span with
|
|
3277
|
+
// indexOf to highlight it, so a quote this check would refuse is a citation
|
|
3278
|
+
// that can never resolve. Refused at propose time, where the agent can
|
|
3279
|
+
// re-quote — 13a's fail-at-propose argument, applied to the field that IS
|
|
3280
|
+
// this feature.
|
|
3281
|
+
//
|
|
3282
|
+
// VALIDATION IS ALL-BEFORE-ANY-WRITE, AND REFUSAL IS WHOLE-BATCH. One run
|
|
3283
|
+
// hands over one batch (one result per source it read); a partially-applied
|
|
3284
|
+
// batch would flag sources processed whose proposals were refused. So every
|
|
3285
|
+
// result and every proposal is validated first, the refusal names the
|
|
3286
|
+
// offending source and proposal, and nothing is written on any refusal.
|
|
3287
|
+
//
|
|
3288
|
+
// A RE-RUN REPLACES ITS OWN PENDING SET AND RE-FLAGS, NEVER DUPLICATES: per
|
|
3289
|
+
// source, this user's pending proposals are deleted before the fresh ones go
|
|
3290
|
+
// in, and the read row is deleted and re-inserted — the flag reports the
|
|
3291
|
+
// LATEST run. `found_nothing: true` is a real result (a thank-you note holds
|
|
3292
|
+
// no durable context) recorded as a fact, because the mere absence of
|
|
3293
|
+
// proposals also describes a source nobody read.
|
|
3294
|
+
// ---------------------------------------------------------------------------
|
|
3295
|
+
/** The four kinds `client_context_records.kind` admits, verbatim from the
|
|
3296
|
+
* migration's check constraint (20260730080000) and the approved mock. */
|
|
3297
|
+
/** 31's citation convention (Step −1 ruling 1, shared with 24c Slice 3): ONE
|
|
3298
|
+
* fixed markdown line, no schema — a citation is prose the web app can parse,
|
|
3299
|
+
* never a column. Taught VERBATIM in every tool description that writes
|
|
3300
|
+
* grounded prose (create_product_idea, create_artifact), because 31 Slice 1's
|
|
3301
|
+
* gate proved prose conventions in live tool output are schema too: a
|
|
3302
|
+
* convention that lives in only one description is a convention half the
|
|
3303
|
+
* flows never see. */
|
|
3304
|
+
const CITATION_LINE_TEACHING = 'Cite with the fixed citation line — on its own line, directly under the claim it grounds: ' +
|
|
3305
|
+
'> "<verbatim quote>" — <source kind> <id> ' +
|
|
3306
|
+
'(use `client source <id>` for raw client material, `client context record <id>` for accepted ' +
|
|
3307
|
+
'records, `project document <id>` for project context, and a repo-relative file path for code). ' +
|
|
3308
|
+
'The quote is VERBATIM — character for character, never paraphrased, trimmed or normalized — ' +
|
|
3309
|
+
'and it must be a span of the THING YOU CITE: when citing a client source, quote the raw ' +
|
|
3310
|
+
"material itself, never a context record's restatement of it (a record's wording presented as " +
|
|
3311
|
+
"the client's words is the misattribution this convention exists to prevent); when citing a " +
|
|
3312
|
+
"record, quote the record's own quote field.";
|
|
3313
|
+
/** Characters of an idea's body shown in get_client_context's listing — a
|
|
3314
|
+
* snippet to judge overlap by, not the human's whole thinking (the 22b
|
|
3315
|
+
* listing withholds bodies entirely; intake gets a snippet because judging
|
|
3316
|
+
* update-over-create needs more than a title). */
|
|
3317
|
+
const IDEA_BODY_SNIPPET_CHARS = 240;
|
|
3318
|
+
const CLIENT_RECORD_KINDS = ['fact', 'constraint', 'preference', 'decision'];
|
|
3319
|
+
/** `client_context_records.title` is `length(title) <= 100`, the house cap.
|
|
3320
|
+
* Refused HERE so the agent learns at propose time, rather than the user
|
|
3321
|
+
* discovering at accept time that a proposal can never become a record. */
|
|
3322
|
+
const MAX_CLIENT_RECORD_TITLE_CHARS = 100;
|
|
3323
|
+
/** Where the user reviews what was proposed. The agent's job ends at
|
|
3324
|
+
* proposing, so the success result says this in prose it can relay. */
|
|
3325
|
+
const CLIENT_REVIEW_LOCATION = "the client's page in the web app";
|
|
3326
|
+
/**
|
|
3327
|
+
* CLIENT RESOLUTION, shared by both tools. The id is UUID-shape-checked
|
|
3328
|
+
* BEFORE the read (the resolveContextProject idiom) so `"c-1"` gets a clean
|
|
3329
|
+
* tool error instead of a Postgres `invalid input syntax for type uuid`, and
|
|
3330
|
+
* a miss — no such client, or one outside the caller's orgs, which RLS makes
|
|
3331
|
+
* indistinguishable on purpose — is refused by id in the house shape.
|
|
3332
|
+
*/
|
|
3333
|
+
async function resolveClient(client, clientIdArg, tool) {
|
|
3334
|
+
const clientId = typeof clientIdArg === 'string' ? clientIdArg.trim() : '';
|
|
3335
|
+
if (!clientId) {
|
|
3336
|
+
return {
|
|
3337
|
+
ok: false,
|
|
3338
|
+
error: errorResult(`${tool} requires client_id — the id of the client whose context this is, from the instruction that sent you here.`),
|
|
3339
|
+
};
|
|
3340
|
+
}
|
|
3341
|
+
if (!UUID_RE.test(clientId)) {
|
|
3342
|
+
return { ok: false, error: errorResult(`${tool}: not a valid client id: "${clientId}".`) };
|
|
3343
|
+
}
|
|
3344
|
+
const row = await must(client.from('clients').select('id, name').eq('id', clientId).maybeSingle());
|
|
3345
|
+
if (!row)
|
|
3346
|
+
return { ok: false, error: errorResult(`${tool}: no client found for id "${clientId}".`) };
|
|
3347
|
+
return { ok: true, value: { id: row.id, name: row.name } };
|
|
3348
|
+
}
|
|
3349
|
+
/**
|
|
3350
|
+
* List the clients the caller can see, with their attached projects — THE
|
|
3351
|
+
* ENTRY POINT for a client-shaped ask ("build what <client name> asked for"):
|
|
3352
|
+
* without it a thin ask dead-ends, because every other client tool requires a
|
|
3353
|
+
* client_id and nothing resolved a NAME to one (found by the 2026-07-30
|
|
3354
|
+
* live-take rehearsal — a real agent stalled asking the user for a uuid).
|
|
3355
|
+
* READ-ONLY; RLS scopes rows to the caller's orgs.
|
|
3356
|
+
*/
|
|
3357
|
+
export async function listClientsHandler(client) {
|
|
3358
|
+
try {
|
|
3359
|
+
const clients = (await must(client.from('clients').select('id, name, website, archived_at').order('name'))) ?? [];
|
|
3360
|
+
const live = clients.filter((c) => !c.archived_at);
|
|
3361
|
+
const ids = live.map((c) => c.id);
|
|
3362
|
+
const projects = ids.length
|
|
3363
|
+
? ((await must(client.from('projects').select('id, name, client_id').in('client_id', ids))) ?? [])
|
|
3364
|
+
: [];
|
|
3365
|
+
return textResult({
|
|
3366
|
+
clients: live.map((c) => ({
|
|
3367
|
+
id: c.id,
|
|
3368
|
+
name: c.name,
|
|
3369
|
+
website: c.website,
|
|
3370
|
+
projects: projects
|
|
3371
|
+
.filter((p) => p.client_id === c.id)
|
|
3372
|
+
.map((p) => ({ id: p.id, name: p.name })),
|
|
3373
|
+
})),
|
|
3374
|
+
note: live.length === 0
|
|
3375
|
+
? 'No clients visible. Either this org has none, or agency mode is off.'
|
|
3376
|
+
: 'Pass a client id to get_client_context to read what the client actually said before writing anything.',
|
|
3377
|
+
});
|
|
3378
|
+
}
|
|
3379
|
+
catch (err) {
|
|
3380
|
+
return errorResult(`list_clients failed: ${err.message}`);
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* Read a client's whole context surface: every source with its processed
|
|
3385
|
+
* flag, every accepted record with its citation, and the calling user's
|
|
3386
|
+
* pending proposal count. READ-ONLY, over product tables plus the two
|
|
3387
|
+
* `cliv2_*` intake tables.
|
|
3388
|
+
*
|
|
3389
|
+
* THIS IS THE READ-BEFORE-WRITE ux.md NON-NEGOTIABLE 2 REQUIRES — "the agent
|
|
3390
|
+
* therefore reads before it writes … every run. That read is not an
|
|
3391
|
+
* optimization; it is the feature." An agent that proposes without calling
|
|
3392
|
+
* this first is the duplicate farm Slice 4 exists to prevent.
|
|
3393
|
+
*
|
|
3394
|
+
* PROCESSED IS DERIVED FROM `cliv2_client_source_reads`, ACROSS ALL USERS:
|
|
3395
|
+
* the SELECT policy is org-scoped precisely so a source one member's agent
|
|
3396
|
+
* handled reads as handled to every teammate — processed when ANY read row
|
|
3397
|
+
* exists. `found_nothing` is reported true only when every read row says so:
|
|
3398
|
+
* one run that proposed something outranks another that found nothing.
|
|
3399
|
+
*/
|
|
3400
|
+
export async function getClientContextHandler(client, args) {
|
|
3401
|
+
try {
|
|
3402
|
+
const resolved = await resolveClient(client, args.client_id, 'get_client_context');
|
|
3403
|
+
if (!resolved.ok)
|
|
3404
|
+
return resolved.error;
|
|
3405
|
+
const theClient = resolved.value;
|
|
3406
|
+
const sources = (await must(client
|
|
3407
|
+
.from('client_sources')
|
|
3408
|
+
.select('id, kind, from_name, happened_at, body')
|
|
3409
|
+
.eq('client_id', theClient.id)
|
|
3410
|
+
.order('created_at', { ascending: true })
|
|
3411
|
+
.order('id', { ascending: true }))) ?? [];
|
|
3412
|
+
const reads = (await must(client
|
|
3413
|
+
.from('cliv2_client_source_reads')
|
|
3414
|
+
.select('source_id, found_nothing')
|
|
3415
|
+
.eq('client_id', theClient.id))) ?? [];
|
|
3416
|
+
const records = (await must(client
|
|
3417
|
+
.from('client_context_records')
|
|
3418
|
+
.select('id, kind, title, body, reason, quote, source_id')
|
|
3419
|
+
.eq('client_id', theClient.id)
|
|
3420
|
+
.order('created_at', { ascending: true })
|
|
3421
|
+
.order('id', { ascending: true }))) ?? [];
|
|
3422
|
+
// The CALLING user's pending set only — RLS owner-scopes the table, so the
|
|
3423
|
+
// unqualified read is already "mine".
|
|
3424
|
+
const pending = (await must(client.from('cliv2_client_context_proposals').select('id').eq('client_id', theClient.id))) ?? [];
|
|
3425
|
+
// Slice 2 — THE READ-BEFORE-FLAG SURFACE. The client's work items (every
|
|
3426
|
+
// live task in every project attached to this client, 24b's edge) and the
|
|
3427
|
+
// EXISTING flags in every status, each with its (work_item_id, quote)
|
|
3428
|
+
// identity — so a run can see what was already raised or dismissed and
|
|
3429
|
+
// never re-derive it, instead of trying-and-failing against the dedup.
|
|
3430
|
+
// Appended sections; everything above keeps its exact Slice 1 shape.
|
|
3431
|
+
const clientProjects = (await must(client.from('projects').select('id, name').eq('client_id', theClient.id))) ?? [];
|
|
3432
|
+
const projectIds = clientProjects.map((project) => project.id);
|
|
3433
|
+
const workItems = projectIds.length
|
|
3434
|
+
? ((await must(client
|
|
3435
|
+
.from('tasks')
|
|
3436
|
+
.select('id, name, status, description, project_id')
|
|
3437
|
+
.in('project_id', projectIds)
|
|
3438
|
+
.is('archived_at', null)
|
|
3439
|
+
.order('created_at', { ascending: true })
|
|
3440
|
+
.order('id', { ascending: true }))) ?? [])
|
|
3441
|
+
: [];
|
|
3442
|
+
const existingFlags = (await must(client
|
|
3443
|
+
.from('cliv2_work_item_flags')
|
|
3444
|
+
.select('id, work_item_id, source_id, quote, suggested_edit, status')
|
|
3445
|
+
.eq('client_id', theClient.id)
|
|
3446
|
+
.order('raised_at', { ascending: true })
|
|
3447
|
+
.order('id', { ascending: true }))) ?? [];
|
|
3448
|
+
// Slice 3 — THE READ-BEFORE-WRITE SURFACE FOR IDEAS (non-negotiable 2):
|
|
3449
|
+
// the client's projects' existing Product Ideas, so an intake run can
|
|
3450
|
+
// extend the idea that already covers a problem instead of minting a
|
|
3451
|
+
// near-duplicate. A SNIPPET of the body, not the whole: enough to judge
|
|
3452
|
+
// overlap; get_task reads one in full when a title alone cannot settle it.
|
|
3453
|
+
const productIdeas = projectIds.length
|
|
3454
|
+
? ((await must(client
|
|
3455
|
+
.from('tasks')
|
|
3456
|
+
.select('id, project_id, name, description')
|
|
3457
|
+
.eq('is_idea', true)
|
|
3458
|
+
.in('project_id', projectIds)
|
|
3459
|
+
.is('archived_at', null)
|
|
3460
|
+
.order('created_at', { ascending: true })
|
|
3461
|
+
.order('id', { ascending: true }))) ?? [])
|
|
3462
|
+
: [];
|
|
3463
|
+
const readsBySource = new Map();
|
|
3464
|
+
for (const read of reads) {
|
|
3465
|
+
const list = readsBySource.get(read.source_id);
|
|
3466
|
+
if (list)
|
|
3467
|
+
list.push(read);
|
|
3468
|
+
else
|
|
3469
|
+
readsBySource.set(read.source_id, [read]);
|
|
3470
|
+
}
|
|
3471
|
+
const shapedSources = sources.map((source) => {
|
|
3472
|
+
const sourceReads = readsBySource.get(source.id) ?? [];
|
|
3473
|
+
const processed = sourceReads.length > 0;
|
|
3474
|
+
return {
|
|
3475
|
+
id: source.id,
|
|
3476
|
+
kind: source.kind,
|
|
3477
|
+
from_name: source.from_name,
|
|
3478
|
+
happened_at: source.happened_at,
|
|
3479
|
+
processed,
|
|
3480
|
+
// Only meaningful on a processed source; true only when EVERY run that
|
|
3481
|
+
// read it found nothing — a run that proposed something outranks one
|
|
3482
|
+
// that did not.
|
|
3483
|
+
...(processed ? { found_nothing: sourceReads.every((read) => read.found_nothing) } : {}),
|
|
3484
|
+
body: source.body,
|
|
3485
|
+
};
|
|
3486
|
+
});
|
|
3487
|
+
const unprocessed = shapedSources.filter((source) => !source.processed).length;
|
|
3488
|
+
return textResult({
|
|
3489
|
+
client: theClient.name,
|
|
3490
|
+
sources: shapedSources,
|
|
3491
|
+
// Shaped explicitly — the payload is a contract, not whatever the read
|
|
3492
|
+
// happened to return.
|
|
3493
|
+
records: records.map((record) => ({
|
|
3494
|
+
id: record.id,
|
|
3495
|
+
kind: record.kind,
|
|
3496
|
+
title: record.title,
|
|
3497
|
+
body: record.body,
|
|
3498
|
+
reason: record.reason,
|
|
3499
|
+
quote: record.quote,
|
|
3500
|
+
source_id: record.source_id,
|
|
3501
|
+
})),
|
|
3502
|
+
pending_proposals: pending.length,
|
|
3503
|
+
// Slice 3: the client's attached projects, by id — create_product_idea
|
|
3504
|
+
// takes an explicit project_id (Q4 ruling: one project auto-resolves
|
|
3505
|
+
// the choice, several means the agent chooses and says why), and this
|
|
3506
|
+
// is where the legal choices come from.
|
|
3507
|
+
projects: clientProjects.map((project) => ({ id: project.id, name: project.name })),
|
|
3508
|
+
// Slice 3: the existing Product Ideas across those projects — read
|
|
3509
|
+
// BEFORE writing any (update-over-create, non-negotiable 2).
|
|
3510
|
+
product_ideas: productIdeas.map((idea) => {
|
|
3511
|
+
const project = clientProjects.find((candidate) => candidate.id === idea.project_id);
|
|
3512
|
+
const body = (idea.description ?? '').trim();
|
|
3513
|
+
return {
|
|
3514
|
+
id: idea.id,
|
|
3515
|
+
title: idea.name,
|
|
3516
|
+
body_snippet: body.length > IDEA_BODY_SNIPPET_CHARS ? `${body.slice(0, IDEA_BODY_SNIPPET_CHARS)}…` : body,
|
|
3517
|
+
project_id: idea.project_id,
|
|
3518
|
+
project: project ? project.name : idea.project_id,
|
|
3519
|
+
};
|
|
3520
|
+
}),
|
|
3521
|
+
// Slice 2 sections, appended after the Slice 1 shape (backward
|
|
3522
|
+
// compatible: nothing above moved or changed).
|
|
3523
|
+
work_items: workItems.map((item) => {
|
|
3524
|
+
const project = clientProjects.find((candidate) => candidate.id === item.project_id);
|
|
3525
|
+
return {
|
|
3526
|
+
id: item.id,
|
|
3527
|
+
name: item.name,
|
|
3528
|
+
status: item.status,
|
|
3529
|
+
description: item.description,
|
|
3530
|
+
project: project ? project.name : item.project_id,
|
|
3531
|
+
};
|
|
3532
|
+
}),
|
|
3533
|
+
// Every existing flag, ALL statuses, with its (work_item_id, quote)
|
|
3534
|
+
// identity: a flag here — open, dismissed OR resolved — must never be
|
|
3535
|
+
// raised again. raise_work_item_flags skips duplicates, but honoring a
|
|
3536
|
+
// dismissal starts with seeing it.
|
|
3537
|
+
work_item_flags: existingFlags.map((flag) => ({
|
|
3538
|
+
id: flag.id,
|
|
3539
|
+
work_item_id: flag.work_item_id,
|
|
3540
|
+
source_id: flag.source_id,
|
|
3541
|
+
quote: flag.quote,
|
|
3542
|
+
suggested_edit: flag.suggested_edit,
|
|
3543
|
+
status: flag.status,
|
|
3544
|
+
})),
|
|
3545
|
+
note: (unprocessed > 0
|
|
3546
|
+
? `${unprocessed} source${unprocessed === 1 ? '' : 's'} ${unprocessed === 1 ? 'is' : 'are'} unprocessed. ` +
|
|
3547
|
+
'Read each one, then hand over ONE propose_client_context call covering all of them — every ' +
|
|
3548
|
+
"proposal's quote must be a VERBATIM substring of its source's body, and a source that holds " +
|
|
3549
|
+
'nothing durable is reported with found_nothing: true, which is a correct outcome. '
|
|
3550
|
+
: 'Every source has been processed. Propose again only if you have re-read a source and your ' +
|
|
3551
|
+
'proposals changed — a re-run replaces your pending set for that source. ') +
|
|
3552
|
+
'Where the material shows a product opportunity, read product_ideas here FIRST: updating beats ' +
|
|
3553
|
+
'creating — extend the idea that already covers the problem (a cited artifact via ' +
|
|
3554
|
+
'create_artifact) instead of minting a near-duplicate, and create a genuinely new one with ' +
|
|
3555
|
+
"create_product_idea, passing this client_id and one of this client's project ids from " +
|
|
3556
|
+
'`projects`.',
|
|
3557
|
+
});
|
|
3558
|
+
}
|
|
3559
|
+
catch (err) {
|
|
3560
|
+
return errorResult(`get_client_context failed: ${errorMessage(err)}`);
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
/**
|
|
3564
|
+
* Validate one proposed record into the row it will become, or refuse it by
|
|
3565
|
+
* source, index AND title — an agent re-reading its own batch needs all three
|
|
3566
|
+
* handles. The checks mirror `client_context_records`' constraints exactly
|
|
3567
|
+
* (13a's argument: a proposal the records table would reject must fail HERE,
|
|
3568
|
+
* where the agent can see it, not at accept time in the browser), plus the
|
|
3569
|
+
* one check no table can make — the quote must be a verbatim substring of the
|
|
3570
|
+
* source's stored body.
|
|
3571
|
+
*/
|
|
3572
|
+
function validateClientProposal(proposal, index, clientId, source) {
|
|
3573
|
+
const at = `proposal ${index + 1} for source ${source.id}`;
|
|
3574
|
+
const refuse = (message) => ({
|
|
3575
|
+
ok: false,
|
|
3576
|
+
error: errorResult(`propose_client_context: ${at} ${message} Nothing was written.`),
|
|
3577
|
+
});
|
|
3578
|
+
if (!proposal || typeof proposal !== 'object') {
|
|
3579
|
+
return refuse('is not an object. Each proposal needs kind, title, body, reason and quote.');
|
|
3580
|
+
}
|
|
3581
|
+
const title = typeof proposal.title === 'string' ? proposal.title.trim() : '';
|
|
3582
|
+
if (!title)
|
|
3583
|
+
return refuse('has no title.');
|
|
3584
|
+
if (countCharacters(title) > MAX_CLIENT_RECORD_TITLE_CHARS) {
|
|
3585
|
+
return refuse(`has a ${countCharacters(title)}-character title; the limit is ${MAX_CLIENT_RECORD_TITLE_CHARS}. ` +
|
|
3586
|
+
'Shorten it — a record title is a heading, not the record.');
|
|
3587
|
+
}
|
|
3588
|
+
if (!CLIENT_RECORD_KINDS.includes(proposal.kind)) {
|
|
3589
|
+
return refuse(`("${title}") has kind "${String(proposal.kind)}", which is not a context record kind. ` +
|
|
3590
|
+
`Use one of: ${CLIENT_RECORD_KINDS.join(', ')}.`);
|
|
3591
|
+
}
|
|
3592
|
+
const body = typeof proposal.body === 'string' ? proposal.body : '';
|
|
3593
|
+
if (body.trim() === '') {
|
|
3594
|
+
return refuse(`("${title}") has no body. State the durable fact, constraint, preference or decision.`);
|
|
3595
|
+
}
|
|
3596
|
+
const reason = typeof proposal.reason === 'string' ? proposal.reason.trim() : '';
|
|
3597
|
+
if (!reason) {
|
|
3598
|
+
return refuse(`("${title}") has no reason. Every proposal carries one line saying why this is durable client ` +
|
|
3599
|
+
'context, so the user can judge the judgement and not just the text.');
|
|
3600
|
+
}
|
|
3601
|
+
const quote = typeof proposal.quote === 'string' ? proposal.quote : '';
|
|
3602
|
+
if (quote.trim() === '') {
|
|
3603
|
+
return refuse(`("${title}") has no quote. Every record cites its source: quote the span this came from, ` +
|
|
3604
|
+
'VERBATIM from the material.');
|
|
3605
|
+
}
|
|
3606
|
+
// THE CITATION NON-NEGOTIABLE. The quote is stored verbatim and the web
|
|
3607
|
+
// finds it with indexOf to highlight it in the source — so a quote that is
|
|
3608
|
+
// not a verbatim substring is a citation that can never resolve, and it is
|
|
3609
|
+
// refused where the agent can fix it.
|
|
3610
|
+
if (source.body.indexOf(quote) === -1) {
|
|
3611
|
+
return refuse(`("${title}") has a quote that is not a VERBATIM substring of that source's body. ` +
|
|
3612
|
+
'Re-quote the span exactly as it appears in the material — character for character, ' +
|
|
3613
|
+
'including line breaks and punctuation; do not paraphrase, trim or normalize it — and send ' +
|
|
3614
|
+
'the batch again.');
|
|
3615
|
+
}
|
|
3616
|
+
return {
|
|
3617
|
+
ok: true,
|
|
3618
|
+
value: {
|
|
3619
|
+
client_id: clientId,
|
|
3620
|
+
source_id: source.id,
|
|
3621
|
+
kind: proposal.kind,
|
|
3622
|
+
title,
|
|
3623
|
+
body,
|
|
3624
|
+
reason,
|
|
3625
|
+
quote,
|
|
3626
|
+
},
|
|
3627
|
+
};
|
|
3628
|
+
}
|
|
3629
|
+
/**
|
|
3630
|
+
* Hand over one intake run's results: per source read, either the records it
|
|
3631
|
+
* proposes or `found_nothing: true`. WRITES `cliv2_*` STATE ONLY — pending
|
|
3632
|
+
* proposals the user accepts or rejects on the client page, and one read row
|
|
3633
|
+
* per source flagging it processed. No product table is written here under
|
|
3634
|
+
* any argument; only the web's accept creates a `client_context_records` row.
|
|
3635
|
+
*
|
|
3636
|
+
* EVERYTHING IS VALIDATED BEFORE ANYTHING IS WRITTEN, and any refusal names
|
|
3637
|
+
* the offending source and proposal — a partially-applied batch would flag
|
|
3638
|
+
* sources processed whose proposals were refused.
|
|
3639
|
+
*
|
|
3640
|
+
* THE WRITES ARE BATCHED, NOT ATOMIC. Four statements (replace-delete +
|
|
3641
|
+
* insert, per table), no transaction — PostgREST has none to offer, the same
|
|
3642
|
+
* accepted trade as propose_project_context: every delete is scoped to this
|
|
3643
|
+
* user's rows for exactly these sources, so a failure between statements
|
|
3644
|
+
* leaves nothing corrupted, and the recovery is to run the batch again —
|
|
3645
|
+
* which is precisely what a re-run does (replace and re-flag).
|
|
3646
|
+
*/
|
|
3647
|
+
export async function proposeClientContextHandler(client, args) {
|
|
3648
|
+
try {
|
|
3649
|
+
const resolved = await resolveClient(client, args.client_id, 'propose_client_context');
|
|
3650
|
+
if (!resolved.ok)
|
|
3651
|
+
return resolved.error;
|
|
3652
|
+
const theClient = resolved.value;
|
|
3653
|
+
// One result per source read. An EMPTY batch is refused, unlike
|
|
3654
|
+
// propose_project_context's empty scan: there, `[]` is the only way to
|
|
3655
|
+
// say "nothing found"; here that meaning lives on each source as
|
|
3656
|
+
// `found_nothing: true`, so an empty `results` says nothing at all.
|
|
3657
|
+
const results = args.results;
|
|
3658
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
3659
|
+
return errorResult('propose_client_context: `results` must be a non-empty array — one entry per source you read. ' +
|
|
3660
|
+
'A source that holds nothing durable is a real result: report it with `found_nothing: true` ' +
|
|
3661
|
+
'rather than leaving it out, so it is flagged processed.');
|
|
3662
|
+
}
|
|
3663
|
+
// Every source_id is shape-checked and de-duplicated BEFORE the ownership
|
|
3664
|
+
// read. Two results for one source would make the replace-then-insert
|
|
3665
|
+
// last-wins by array order, silently — refused instead.
|
|
3666
|
+
const seen = new Set();
|
|
3667
|
+
for (const [index, result] of results.entries()) {
|
|
3668
|
+
const at = `result ${index + 1}`;
|
|
3669
|
+
if (!result || typeof result !== 'object') {
|
|
3670
|
+
return errorResult(`propose_client_context: ${at} is not an object. Each result needs source_id, and either ` +
|
|
3671
|
+
'proposals or found_nothing: true. Nothing was written.');
|
|
3672
|
+
}
|
|
3673
|
+
const sourceId = typeof result.source_id === 'string' ? result.source_id.trim() : '';
|
|
3674
|
+
if (!sourceId || !UUID_RE.test(sourceId)) {
|
|
3675
|
+
return errorResult(`propose_client_context: ${at} has an invalid source_id: "${String(result.source_id)}". ` +
|
|
3676
|
+
'Use the source ids get_client_context returned. Nothing was written.');
|
|
3677
|
+
}
|
|
3678
|
+
if (seen.has(sourceId)) {
|
|
3679
|
+
return errorResult(`propose_client_context: source ${sourceId} appears in more than one result. Send one result ` +
|
|
3680
|
+
'per source, holding everything you propose from it. Nothing was written.');
|
|
3681
|
+
}
|
|
3682
|
+
seen.add(sourceId);
|
|
3683
|
+
}
|
|
3684
|
+
const sourceIds = [...seen];
|
|
3685
|
+
// Ownership: every named source must exist AND belong to THIS client. The
|
|
3686
|
+
// read is scoped to the client, so a source from another client — even one
|
|
3687
|
+
// the caller can see — comes back missing and is refused by id.
|
|
3688
|
+
const ownedSources = (await must(client
|
|
3689
|
+
.from('client_sources')
|
|
3690
|
+
.select('id, body')
|
|
3691
|
+
.eq('client_id', theClient.id)
|
|
3692
|
+
.in('id', sourceIds))) ?? [];
|
|
3693
|
+
const bodyBySource = new Map(ownedSources.map((source) => [source.id, source.body]));
|
|
3694
|
+
for (const sourceId of sourceIds) {
|
|
3695
|
+
if (!bodyBySource.has(sourceId)) {
|
|
3696
|
+
return errorResult(`propose_client_context: source ${sourceId} does not exist on client "${theClient.name}". ` +
|
|
3697
|
+
'Propose only from the sources get_client_context returned for this client. Nothing was written.');
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
// Slice 3's dedupe assist (the Slice 4 roadmap line, folded in): the
|
|
3701
|
+
// ACCEPTED records citing this run's sources, keyed by (source_id, quote).
|
|
3702
|
+
// A proposal that re-quotes a span an accepted record already cites is the
|
|
3703
|
+
// duplicate farm arriving one accept at a time — refused whole-batch (the
|
|
3704
|
+
// house style; the flag tools' skip semantics don't apply because a
|
|
3705
|
+
// pending proposal, unlike a flag, is cheap to resend corrected).
|
|
3706
|
+
const acceptedRecords = (await must(client
|
|
3707
|
+
.from('client_context_records')
|
|
3708
|
+
.select('id, title, source_id, quote')
|
|
3709
|
+
.eq('client_id', theClient.id)
|
|
3710
|
+
.in('source_id', sourceIds))) ?? [];
|
|
3711
|
+
const acceptedByCitation = new Map(acceptedRecords.map((record) => [`${record.source_id}\u0000${record.quote}`, record]));
|
|
3712
|
+
// Per result: EXACTLY ONE of found_nothing or proposals, then every
|
|
3713
|
+
// proposal validated — all of it before any write.
|
|
3714
|
+
const rows = [];
|
|
3715
|
+
const readRows = [];
|
|
3716
|
+
// 13a's batch caps, ported (gate A, 2026-07-30): without them one propose
|
|
3717
|
+
// call could insert unbounded rows/bytes into the pending queue, and the
|
|
3718
|
+
// review surface renders every one as a card. Counted across the whole
|
|
3719
|
+
// batch, checked before any write.
|
|
3720
|
+
let totalProposals = 0;
|
|
3721
|
+
let totalChars = 0;
|
|
3722
|
+
for (const result of results) {
|
|
3723
|
+
const sourceId = result.source_id.trim();
|
|
3724
|
+
const proposals = result.proposals;
|
|
3725
|
+
const foundNothing = result.found_nothing === true;
|
|
3726
|
+
if (foundNothing && Array.isArray(proposals) && proposals.length > 0) {
|
|
3727
|
+
return errorResult(`propose_client_context: the result for source ${sourceId} says found_nothing but carries ` +
|
|
3728
|
+
`${proposals.length} proposal${proposals.length === 1 ? '' : 's'}. A run either found nothing ` +
|
|
3729
|
+
'durable in a source or proposes from it — never both. Nothing was written.');
|
|
3730
|
+
}
|
|
3731
|
+
if (!foundNothing && (!Array.isArray(proposals) || proposals.length === 0)) {
|
|
3732
|
+
return errorResult(`propose_client_context: the result for source ${sourceId} has no proposals and does not say ` +
|
|
3733
|
+
'found_nothing: true. Send the records you propose from it, or state found_nothing: true — ' +
|
|
3734
|
+
'finding nothing durable is a correct outcome, but it has to be stated on purpose. ' +
|
|
3735
|
+
'Nothing was written.');
|
|
3736
|
+
}
|
|
3737
|
+
if (!foundNothing) {
|
|
3738
|
+
const body = bodyBySource.get(sourceId);
|
|
3739
|
+
for (const [index, proposal] of proposals.entries()) {
|
|
3740
|
+
const validated = validateClientProposal(proposal, index, theClient.id, {
|
|
3741
|
+
id: sourceId,
|
|
3742
|
+
body,
|
|
3743
|
+
});
|
|
3744
|
+
if (!validated.ok)
|
|
3745
|
+
return validated.error;
|
|
3746
|
+
const accepted = acceptedByCitation.get(`${sourceId}\u0000${validated.value.quote}`);
|
|
3747
|
+
if (accepted) {
|
|
3748
|
+
return errorResult(`propose_client_context: proposal ${index + 1} for source ${sourceId} ` +
|
|
3749
|
+
`("${validated.value.title}") re-proposes a span that is already an ACCEPTED context ` +
|
|
3750
|
+
`record — "${accepted.title}" (${accepted.id}) carries this exact (source, quote) ` +
|
|
3751
|
+
'citation. Updating beats creating: drop it and send the batch again proposing only ' +
|
|
3752
|
+
'what is NEW; if the material genuinely adds to that record, propose the addition ' +
|
|
3753
|
+
'under its own quote. Nothing was written.');
|
|
3754
|
+
}
|
|
3755
|
+
totalProposals += 1;
|
|
3756
|
+
totalChars += validated.value.title.length + validated.value.body.length +
|
|
3757
|
+
validated.value.reason.length + validated.value.quote.length;
|
|
3758
|
+
// Fail fast (re-review): the refusal fires the moment a cap is
|
|
3759
|
+
// crossed, not after fully validating an unbounded batch.
|
|
3760
|
+
if (totalProposals > MAX_PROPOSALS || totalChars > MAX_PROPOSAL_BATCH_CHARS)
|
|
3761
|
+
break;
|
|
3762
|
+
rows.push(validated.value);
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
readRows.push({ client_id: theClient.id, source_id: sourceId, found_nothing: foundNothing });
|
|
3766
|
+
}
|
|
3767
|
+
if (totalProposals > MAX_PROPOSALS) {
|
|
3768
|
+
return errorResult(`propose_client_context: ${totalProposals} proposed records is more than the limit of ` +
|
|
3769
|
+
`${MAX_PROPOSALS}. Propose only what is durable — the user reviews every one by hand. ` +
|
|
3770
|
+
'Nothing was written.');
|
|
3771
|
+
}
|
|
3772
|
+
if (totalChars > MAX_PROPOSAL_BATCH_CHARS) {
|
|
3773
|
+
return errorResult(`propose_client_context: the batch is ${totalChars.toLocaleString('en-US')} characters, over the ` +
|
|
3774
|
+
`limit of ${MAX_PROPOSAL_BATCH_CHARS.toLocaleString('en-US')}. Nothing was written. Propose ` +
|
|
3775
|
+
'tighter records — the source stays readable in full; a record distills it.');
|
|
3776
|
+
}
|
|
3777
|
+
// A RE-RUN REPLACES: this user's pending proposals for these sources go
|
|
3778
|
+
// first. RLS owner-scopes the delete to the caller; the filters scope it
|
|
3779
|
+
// to this run's sources, so another source's pending set — and another
|
|
3780
|
+
// user's — is untouched. `.select('id')` makes the replaced count
|
|
3781
|
+
// knowable, which the result reports.
|
|
3782
|
+
const replaced = (await must(client
|
|
3783
|
+
.from('cliv2_client_context_proposals')
|
|
3784
|
+
.delete()
|
|
3785
|
+
.eq('client_id', theClient.id)
|
|
3786
|
+
.in('source_id', sourceIds)
|
|
3787
|
+
.select('id'))) ?? [];
|
|
3788
|
+
if (rows.length)
|
|
3789
|
+
await must(client.from('cliv2_client_context_proposals').insert(rows));
|
|
3790
|
+
// …AND RE-FLAGS: delete-then-insert rather than upsert, so the read row
|
|
3791
|
+
// always reports the LATEST run (fresh read_at, fresh found_nothing) and
|
|
3792
|
+
// `unique (user_id, source_id)` is never contended.
|
|
3793
|
+
await must(client
|
|
3794
|
+
.from('cliv2_client_source_reads')
|
|
3795
|
+
.delete()
|
|
3796
|
+
.eq('client_id', theClient.id)
|
|
3797
|
+
.in('source_id', sourceIds));
|
|
3798
|
+
await must(client.from('cliv2_client_source_reads').insert(readRows));
|
|
3799
|
+
const foundNothingCount = readRows.filter((read) => read.found_nothing).length;
|
|
3800
|
+
const noun = rows.length === 1 ? 'record' : 'records';
|
|
3801
|
+
const sentences = [];
|
|
3802
|
+
if (rows.length === 0) {
|
|
3803
|
+
sentences.push(`Nothing durable was found in the ${sourceIds.length} source${sourceIds.length === 1 ? '' : 's'} ` +
|
|
3804
|
+
`this run read for "${theClient.name}" — a correct outcome, recorded as one; the sources are ` +
|
|
3805
|
+
'now flagged processed.');
|
|
3806
|
+
}
|
|
3807
|
+
else {
|
|
3808
|
+
sentences.push(`${rows.length} proposed context ${noun} ${rows.length === 1 ? 'is' : 'are'} now waiting for ` +
|
|
3809
|
+
`review in ${CLIENT_REVIEW_LOCATION}, on "${theClient.name}".`);
|
|
3810
|
+
if (foundNothingCount > 0) {
|
|
3811
|
+
sentences.push(`${foundNothingCount} source${foundNothingCount === 1 ? '' : 's'} held nothing durable — ` +
|
|
3812
|
+
'a correct outcome, recorded as one.');
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
if (replaced.length) {
|
|
3816
|
+
sentences.push(`This run replaced ${replaced.length} pending proposal${replaced.length === 1 ? '' : 's'} of ` +
|
|
3817
|
+
'yours for these sources — a run reports what the material holds now.');
|
|
3818
|
+
}
|
|
3819
|
+
sentences.push('No context record has been created: the user accepts or rejects each proposal there, and only ' +
|
|
3820
|
+
'an accept makes one a record. Tell them where to look.');
|
|
3821
|
+
return textResult({
|
|
3822
|
+
client: theClient.name,
|
|
3823
|
+
sources_processed: sourceIds.length,
|
|
3824
|
+
proposed: rows.length,
|
|
3825
|
+
found_nothing_sources: foundNothingCount,
|
|
3826
|
+
replaced: replaced.length,
|
|
3827
|
+
review_in: CLIENT_REVIEW_LOCATION,
|
|
3828
|
+
instruction: sentences.join(' '),
|
|
3829
|
+
});
|
|
3830
|
+
}
|
|
3831
|
+
catch (err) {
|
|
3832
|
+
return errorResult(`propose_client_context failed: ${errorMessage(err)}`);
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
/** A flag's identity — the "dismissed stays dismissed" key. `` cannot
|
|
3836
|
+
* appear in a UUID, so the join is collision-free. */
|
|
3837
|
+
const flagIdentity = (workItemId, quote) => `${workItemId}${quote}`;
|
|
3838
|
+
/**
|
|
3839
|
+
* WHO FLAGGED, resolved the way the codebase already names agents: the
|
|
3840
|
+
* machine's effective name (web-owned `display_name` when set, else the
|
|
3841
|
+
* heartbeat's `machine_name` — the 20260721130000 rule) from the caller's own
|
|
3842
|
+
* `cliv2_agents` presence row, plus the connection's provider from the MCP
|
|
3843
|
+
* `initialize` clientInfo (`attributionFromClientName`) — composing e.g.
|
|
3844
|
+
* "lane's-macbook · claude", the approved mock's exact shape. Best-effort and
|
|
3845
|
+
* HONEST in degradation: a missing presence row or unknown provider narrows
|
|
3846
|
+
* the string rather than inventing one, and the floor is 'an agent' — never
|
|
3847
|
+
* a fabricated machine or provider.
|
|
3848
|
+
*/
|
|
3849
|
+
export async function resolveFlagAgentName(client, machineId, provider) {
|
|
3850
|
+
let machine = null;
|
|
3851
|
+
if (machineId) {
|
|
3852
|
+
try {
|
|
3853
|
+
const row = await must(client
|
|
3854
|
+
.from('cliv2_agents')
|
|
3855
|
+
.select('machine_name, display_name')
|
|
3856
|
+
.eq('machine_id', machineId)
|
|
3857
|
+
.maybeSingle());
|
|
3858
|
+
const display = typeof row?.display_name === 'string' ? row.display_name.trim() : '';
|
|
3859
|
+
machine = display || row?.machine_name || null;
|
|
3860
|
+
}
|
|
3861
|
+
catch {
|
|
3862
|
+
machine = null; // attribution must never fail the raise
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
if (machine && provider)
|
|
3866
|
+
return `${machine} · ${provider}`;
|
|
3867
|
+
return machine ?? provider ?? 'an agent';
|
|
3868
|
+
}
|
|
3869
|
+
/**
|
|
3870
|
+
* Raise one intake run's flags on a client's work items. WRITES `cliv2_*`
|
|
3871
|
+
* STATE ONLY — a flag changes nothing on the item; only a human (or the
|
|
3872
|
+
* agent a human hands the copyable command to) acts on it.
|
|
3873
|
+
*
|
|
3874
|
+
* ALL VALIDATION BEFORE ANY WRITE, whole-batch refusal — EXCEPT the dedup:
|
|
3875
|
+
* a flag whose (work_item_id, quote) already exists — open, dismissed OR
|
|
3876
|
+
* resolved, whoever raised it — is SKIPPED and reported, because a re-ingest
|
|
3877
|
+
* re-deriving a known contradiction is the feature working, not an error.
|
|
3878
|
+
* Raising zero after skips is likewise a success.
|
|
3879
|
+
*
|
|
3880
|
+
* `agentName` is resolved by the caller (registration wires
|
|
3881
|
+
* resolveFlagAgentName over this connection's machine + clientInfo) so the
|
|
3882
|
+
* handler stays a pure (client, args) function the tests can drive.
|
|
3883
|
+
*/
|
|
3884
|
+
export async function raiseWorkItemFlagsHandler(client, args, agentName) {
|
|
3885
|
+
try {
|
|
3886
|
+
const resolved = await resolveClient(client, args.client_id, 'raise_work_item_flags');
|
|
3887
|
+
if (!resolved.ok)
|
|
3888
|
+
return resolved.error;
|
|
3889
|
+
const theClient = resolved.value;
|
|
3890
|
+
const flags = args.flags;
|
|
3891
|
+
if (!Array.isArray(flags) || flags.length === 0) {
|
|
3892
|
+
return errorResult('raise_work_item_flags: `flags` must be a non-empty array — one entry per contradiction you ' +
|
|
3893
|
+
'found. A run that flags nothing simply does not call this tool: flagging nothing is a ' +
|
|
3894
|
+
'correct outcome, not something to report here.');
|
|
3895
|
+
}
|
|
3896
|
+
// Shape + caps, before any read. The 13a caps are REUSED on purpose (see
|
|
3897
|
+
// the section header): same reviewed-by-hand queue shape, same limits.
|
|
3898
|
+
if (flags.length > MAX_PROPOSALS) {
|
|
3899
|
+
return errorResult(`raise_work_item_flags: ${flags.length} flags is more than the limit of ${MAX_PROPOSALS}. ` +
|
|
3900
|
+
'Flag only what the material actually contradicts — a human reads every one. Nothing was written.');
|
|
3901
|
+
}
|
|
3902
|
+
const validated = [];
|
|
3903
|
+
let totalChars = 0;
|
|
3904
|
+
for (const [index, flag] of flags.entries()) {
|
|
3905
|
+
const at = `flag ${index + 1}`;
|
|
3906
|
+
if (!flag || typeof flag !== 'object') {
|
|
3907
|
+
return errorResult(`raise_work_item_flags: ${at} is not an object. Each flag needs work_item_id, source_id, ` +
|
|
3908
|
+
'quote and suggested_edit. Nothing was written.');
|
|
3909
|
+
}
|
|
3910
|
+
const workItemId = typeof flag.work_item_id === 'string' ? flag.work_item_id.trim() : '';
|
|
3911
|
+
if (!workItemId || !UUID_RE.test(workItemId)) {
|
|
3912
|
+
return errorResult(`raise_work_item_flags: ${at} has an invalid work_item_id: "${String(flag.work_item_id)}". ` +
|
|
3913
|
+
'Use the work item ids get_client_context returned. Nothing was written.');
|
|
3914
|
+
}
|
|
3915
|
+
const sourceId = typeof flag.source_id === 'string' ? flag.source_id.trim() : '';
|
|
3916
|
+
if (!sourceId || !UUID_RE.test(sourceId)) {
|
|
3917
|
+
return errorResult(`raise_work_item_flags: ${at} has an invalid source_id: "${String(flag.source_id)}". ` +
|
|
3918
|
+
'Use the source ids get_client_context returned. Nothing was written.');
|
|
3919
|
+
}
|
|
3920
|
+
const suggestedEdit = typeof flag.suggested_edit === 'string' ? flag.suggested_edit.trim() : '';
|
|
3921
|
+
if (!suggestedEdit) {
|
|
3922
|
+
return errorResult(`raise_work_item_flags: ${at} has no suggested_edit. State plainly what should change on the ` +
|
|
3923
|
+
'work item and why — it is guidance a human reads, not a patch. Nothing was written.');
|
|
3924
|
+
}
|
|
3925
|
+
const quote = typeof flag.quote === 'string' ? flag.quote : '';
|
|
3926
|
+
if (quote.trim() === '') {
|
|
3927
|
+
return errorResult(`raise_work_item_flags: ${at} has no quote. Every flag cites the client's own words: quote ` +
|
|
3928
|
+
'the span that contradicts the item, VERBATIM from the material. Nothing was written.');
|
|
3929
|
+
}
|
|
3930
|
+
totalChars += suggestedEdit.length + quote.length;
|
|
3931
|
+
if (totalChars > MAX_PROPOSAL_BATCH_CHARS) {
|
|
3932
|
+
return errorResult(`raise_work_item_flags: the batch is over the limit of ` +
|
|
3933
|
+
`${MAX_PROPOSAL_BATCH_CHARS.toLocaleString('en-US')} characters. Nothing was written. ` +
|
|
3934
|
+
'Tighten the suggested edits — the source stays readable in full; a flag distills it.');
|
|
3935
|
+
}
|
|
3936
|
+
validated.push({ work_item_id: workItemId, source_id: sourceId, quote, suggested_edit: suggestedEdit });
|
|
3937
|
+
}
|
|
3938
|
+
// OWNERSHIP, scoped in the queries, not filtered in JS. A work item must
|
|
3939
|
+
// belong to one of THIS client's projects (24b's projects.client_id edge);
|
|
3940
|
+
// a source must belong to THIS client. Either miss is refused by id —
|
|
3941
|
+
// RLS makes "foreign" and "nonexistent" indistinguishable on purpose.
|
|
3942
|
+
const workItemIds = [...new Set(validated.map((flag) => flag.work_item_id))];
|
|
3943
|
+
const sourceIds = [...new Set(validated.map((flag) => flag.source_id))];
|
|
3944
|
+
const clientProjects = (await must(client.from('projects').select('id').eq('client_id', theClient.id))) ?? [];
|
|
3945
|
+
const projectIds = clientProjects.map((project) => project.id);
|
|
3946
|
+
// LIVE items only: an archived work item is outside every agent flow
|
|
3947
|
+
// (the resolve handler's rule), so it is refused by id exactly like a
|
|
3948
|
+
// foreign one — RLS-style, "archived" and "not yours" are
|
|
3949
|
+
// indistinguishable on purpose.
|
|
3950
|
+
const ownedItems = projectIds.length
|
|
3951
|
+
? ((await must(client
|
|
3952
|
+
.from('tasks')
|
|
3953
|
+
.select('id')
|
|
3954
|
+
.in('project_id', projectIds)
|
|
3955
|
+
.in('id', workItemIds)
|
|
3956
|
+
.is('archived_at', null))) ?? [])
|
|
3957
|
+
: [];
|
|
3958
|
+
const ownedItemIds = new Set(ownedItems.map((item) => item.id));
|
|
3959
|
+
for (const workItemId of workItemIds) {
|
|
3960
|
+
if (!ownedItemIds.has(workItemId)) {
|
|
3961
|
+
return errorResult(`raise_work_item_flags: work item ${workItemId} does not belong to a project of client ` +
|
|
3962
|
+
`"${theClient.name}". Flag only the work items get_client_context returned for this client. ` +
|
|
3963
|
+
'Nothing was written.');
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
const ownedSources = (await must(client.from('client_sources').select('id, body').eq('client_id', theClient.id).in('id', sourceIds))) ?? [];
|
|
3967
|
+
const bodyBySource = new Map(ownedSources.map((source) => [source.id, source.body]));
|
|
3968
|
+
for (const sourceId of sourceIds) {
|
|
3969
|
+
if (!bodyBySource.has(sourceId)) {
|
|
3970
|
+
return errorResult(`raise_work_item_flags: source ${sourceId} does not exist on client "${theClient.name}". ` +
|
|
3971
|
+
'Cite only the sources get_client_context returned for this client. Nothing was written.');
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
// THE CITATION NON-NEGOTIABLE, same check as propose_client_context: the
|
|
3975
|
+
// quote must be a VERBATIM substring of its source's stored body, or the
|
|
3976
|
+
// justification is a citation that can never resolve.
|
|
3977
|
+
for (const [index, flag] of validated.entries()) {
|
|
3978
|
+
if (bodyBySource.get(flag.source_id).indexOf(flag.quote) === -1) {
|
|
3979
|
+
return errorResult(`raise_work_item_flags: flag ${index + 1} has a quote that is not a VERBATIM substring of ` +
|
|
3980
|
+
"its source's body. Re-quote the span exactly as it appears in the material — character " +
|
|
3981
|
+
'for character, including line breaks and punctuation; do not paraphrase, trim or ' +
|
|
3982
|
+
'normalize it — and send the batch again. Nothing was written.');
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
// THE DEDUP PRE-READ — the "dismissed stays dismissed" read. Existing
|
|
3986
|
+
// flags on these work items, ANY status and any user's (the SELECT policy
|
|
3987
|
+
// is org-scoped so a teammate's dismissal binds this run too). A flag
|
|
3988
|
+
// whose (work_item_id, quote) identity already exists is SKIPPED, never
|
|
3989
|
+
// re-raised; the DB's unique index backstops the race this read cannot
|
|
3990
|
+
// see. Within-batch duplicates collapse through the same set.
|
|
3991
|
+
const existing = (await must(client.from('cliv2_work_item_flags').select('work_item_id, quote').in('work_item_id', workItemIds))) ?? [];
|
|
3992
|
+
const known = new Set(existing.map((flag) => flagIdentity(flag.work_item_id, flag.quote)));
|
|
3993
|
+
const rows = [];
|
|
3994
|
+
let skipped = 0;
|
|
3995
|
+
for (const flag of validated) {
|
|
3996
|
+
const identity = flagIdentity(flag.work_item_id, flag.quote);
|
|
3997
|
+
if (known.has(identity)) {
|
|
3998
|
+
skipped += 1;
|
|
3999
|
+
continue;
|
|
4000
|
+
}
|
|
4001
|
+
known.add(identity);
|
|
4002
|
+
rows.push({
|
|
4003
|
+
client_id: theClient.id,
|
|
4004
|
+
work_item_id: flag.work_item_id,
|
|
4005
|
+
source_id: flag.source_id,
|
|
4006
|
+
suggested_edit: flag.suggested_edit,
|
|
4007
|
+
quote: flag.quote,
|
|
4008
|
+
agent_name: agentName,
|
|
4009
|
+
});
|
|
4010
|
+
}
|
|
4011
|
+
// THE INSERT, WITH THE RACE THE PRE-READ CANNOT SEE HANDLED IN KIND: a
|
|
4012
|
+
// concurrent run can commit an identical flag between the dedup read and
|
|
4013
|
+
// this insert, and the identity index then refuses the whole batch with a
|
|
4014
|
+
// unique violation (23505). That is the dedup guarantee FIRING, not a
|
|
4015
|
+
// failure — so re-run the pre-read once, drop the rows that now exist,
|
|
4016
|
+
// and retry with the survivors (all-duplicates degrades to the same
|
|
4017
|
+
// skip-success as the pre-read path). The constraint's raw words never
|
|
4018
|
+
// reach the agent: every outcome here is reported in the tool's own.
|
|
4019
|
+
let toInsert = rows;
|
|
4020
|
+
let skippedTotal = skipped;
|
|
4021
|
+
if (toInsert.length) {
|
|
4022
|
+
const { error: insertError } = await client.from('cliv2_work_item_flags').insert(toInsert);
|
|
4023
|
+
if (insertError) {
|
|
4024
|
+
if (insertError.code !== '23505')
|
|
4025
|
+
throw new Error(insertError.message);
|
|
4026
|
+
const raced = (await must(client
|
|
4027
|
+
.from('cliv2_work_item_flags')
|
|
4028
|
+
.select('work_item_id, quote')
|
|
4029
|
+
.in('work_item_id', workItemIds))) ?? [];
|
|
4030
|
+
const racedKnown = new Set(raced.map((flag) => flagIdentity(flag.work_item_id, flag.quote)));
|
|
4031
|
+
const survivors = toInsert.filter((row) => !racedKnown.has(flagIdentity(row.work_item_id, row.quote)));
|
|
4032
|
+
skippedTotal += toInsert.length - survivors.length;
|
|
4033
|
+
toInsert = survivors;
|
|
4034
|
+
if (toInsert.length) {
|
|
4035
|
+
const { error: retryError } = await client.from('cliv2_work_item_flags').insert(toInsert);
|
|
4036
|
+
if (retryError) {
|
|
4037
|
+
if (retryError.code !== '23505')
|
|
4038
|
+
throw new Error(retryError.message);
|
|
4039
|
+
// Raced twice: stop cleanly rather than loop — and still in the
|
|
4040
|
+
// tool's words, never the constraint's.
|
|
4041
|
+
return errorResult('raise_work_item_flags: another run is raising overlapping flags on these work items ' +
|
|
4042
|
+
'right now. Nothing further was written — call get_client_context to see which flags ' +
|
|
4043
|
+
'now exist before raising again.');
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
const sentences = [];
|
|
4049
|
+
if (toInsert.length === 0) {
|
|
4050
|
+
sentences.push(`Every flag in this batch was already raised on its work item — ${skippedTotal} skipped, none ` +
|
|
4051
|
+
'written. That is the system working: a flag that was dismissed stays dismissed, and a ' +
|
|
4052
|
+
'later ingest of overlapping material never raises it again.');
|
|
4053
|
+
}
|
|
4054
|
+
else {
|
|
4055
|
+
sentences.push(`${toInsert.length} flag${toInsert.length === 1 ? '' : 's'} raised on work items of "${theClient.name}". ` +
|
|
4056
|
+
'The flags change NOTHING: a human reads each one where the work item lives and edits by ' +
|
|
4057
|
+
'hand, hands the item to their own agent, or dismisses it.');
|
|
4058
|
+
if (skippedTotal > 0) {
|
|
4059
|
+
sentences.push(`${skippedTotal} ${skippedTotal === 1 ? 'was' : 'were'} skipped — already raised or dismissed; ` +
|
|
4060
|
+
'a dismissal is permanent.');
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
return textResult({
|
|
4064
|
+
client: theClient.name,
|
|
4065
|
+
raised: toInsert.length,
|
|
4066
|
+
skipped: skippedTotal,
|
|
4067
|
+
instruction: sentences.join(' '),
|
|
4068
|
+
});
|
|
4069
|
+
}
|
|
4070
|
+
catch (err) {
|
|
4071
|
+
return errorResult(`raise_work_item_flags failed: ${errorMessage(err)}`);
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
/**
|
|
4075
|
+
* Resolve every OPEN flag on one work item — the last step of the copyable
|
|
4076
|
+
* "apply the flags on <id> … and mark the flags resolved" command, invoked by
|
|
4077
|
+
* the HUMAN'S agent after it made the edits the human told it to make.
|
|
4078
|
+
* Dismissed flags are untouched (a dismissal is the human's judgement, not
|
|
4079
|
+
* this run's), and 0 open flags is a success — the command may be pasted
|
|
4080
|
+
* twice, and "nothing to resolve" is the correct second answer.
|
|
4081
|
+
*/
|
|
4082
|
+
export async function resolveWorkItemFlagsHandler(client, args) {
|
|
4083
|
+
try {
|
|
4084
|
+
const workItemId = typeof args.work_item_id === 'string' ? args.work_item_id.trim() : '';
|
|
4085
|
+
if (!workItemId) {
|
|
4086
|
+
return errorResult('resolve_work_item_flags requires work_item_id — the id of the work item whose flags you were ' +
|
|
4087
|
+
'told to apply.');
|
|
4088
|
+
}
|
|
4089
|
+
if (!UUID_RE.test(workItemId)) {
|
|
4090
|
+
return errorResult(`resolve_work_item_flags: not a valid work item id: "${workItemId}".`);
|
|
4091
|
+
}
|
|
4092
|
+
// The item must resolve under the caller's RLS. DELIBERATELY NO archived
|
|
4093
|
+
// filter (re-review, 2026-07-30): only RAISE refuses archived items.
|
|
4094
|
+
// Resolving is closing bookkeeping on past work — an item archived while
|
|
4095
|
+
// its flags were open must still be resolvable, or the copyable
|
|
4096
|
+
// apply-flags command dead-ends on it forever.
|
|
4097
|
+
const task = await must(client.from('tasks').select('id, name').eq('id', workItemId).maybeSingle());
|
|
4098
|
+
if (!task) {
|
|
4099
|
+
return errorResult(`resolve_work_item_flags: no work item found for id "${workItemId}".`);
|
|
4100
|
+
}
|
|
4101
|
+
// Flip ONLY the open flags; the status filter (not just the trigger)
|
|
4102
|
+
// keeps dismissed/resolved rows out of the statement entirely.
|
|
4103
|
+
const resolvedFlags = (await must(client
|
|
4104
|
+
.from('cliv2_work_item_flags')
|
|
4105
|
+
.update({ status: 'resolved', resolved_at: new Date().toISOString() })
|
|
4106
|
+
.eq('work_item_id', workItemId)
|
|
4107
|
+
.eq('status', 'open')
|
|
4108
|
+
.select('id'))) ?? [];
|
|
4109
|
+
if (resolvedFlags.length === 0) {
|
|
4110
|
+
return textResult({
|
|
4111
|
+
work_item: task.name,
|
|
4112
|
+
resolved: 0,
|
|
4113
|
+
instruction: `"${task.name}" has no open flags — nothing to resolve. Already-dismissed and ` +
|
|
4114
|
+
'already-resolved flags are terminal and stay as they are.',
|
|
4115
|
+
});
|
|
4116
|
+
}
|
|
4117
|
+
return textResult({
|
|
4118
|
+
work_item: task.name,
|
|
4119
|
+
resolved: resolvedFlags.length,
|
|
4120
|
+
instruction: `${resolvedFlags.length} flag${resolvedFlags.length === 1 ? '' : 's'} on "${task.name}" ` +
|
|
4121
|
+
'marked resolved. Resolve only after the edits are actually made — the flag section on the ' +
|
|
4122
|
+
'item now reads as handled.',
|
|
4123
|
+
});
|
|
4124
|
+
}
|
|
4125
|
+
catch (err) {
|
|
4126
|
+
return errorResult(`resolve_work_item_flags failed: ${errorMessage(err)}`);
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
// ---------------------------------------------------------------------------
|
|
4130
|
+
// Project context (feature 13a, Phase 3b) — the `begin_work` MANIFEST.
|
|
4131
|
+
//
|
|
4132
|
+
// Phase 3 shipped a read tool whose only discovery route is its own description
|
|
4133
|
+
// in `tools/list`, which is exactly how `list_credentials` works and exactly why
|
|
4134
|
+
// nobody calls it. `begin_work` is the one per-run handshake there is, so it now
|
|
4135
|
+
// carries TITLES AND SIZES — never content — plus one `instruction` line naming
|
|
4136
|
+
// the literal next call. The content stays behind `get_project_context`, so a
|
|
4137
|
+
// 40KB doc set never lands in a session that did not need it.
|
|
4138
|
+
//
|
|
4139
|
+
// THE MANIFEST MAY NEVER FAIL begin_work. Opening a work session is the
|
|
4140
|
+
// load-bearing operation; this is an extra. That is enforced in TWO places, on
|
|
4141
|
+
// purpose: `projectContextManifest` is the only entry point and its whole body
|
|
4142
|
+
// is inside one try/catch that cannot itself throw (it never touches
|
|
4143
|
+
// `.message` on a value it has not proved is an `Error`), AND the `begin_work`
|
|
4144
|
+
// call site wraps it in a try of its own that falls back to the bare session.
|
|
4145
|
+
// The inner guard is the contract; the outer one means the contract holding is
|
|
4146
|
+
// not a precondition of `begin_work` working.
|
|
4147
|
+
//
|
|
4148
|
+
// ABSENT, NOT EMPTY. No documents means no `project_context` key at all — an
|
|
4149
|
+
// empty array is a shape an agent has to interpret.
|
|
4150
|
+
//
|
|
4151
|
+
// AND THE AGENT IS ALWAYS TOLD WHAT IT DID NOT GET. Every cap here is paired
|
|
4152
|
+
// with a machine-readable `omitted` object, not with a hedging word in the
|
|
4153
|
+
// prose — the same invariant Phase 3's budget was corrected into.
|
|
4154
|
+
// ---------------------------------------------------------------------------
|
|
4155
|
+
/** Documents LISTED per scope. A manifest is a POINTER, not an index: it exists
|
|
4156
|
+
* to make the documents' existence impossible to miss, and `get_project_context`
|
|
4157
|
+
* is one call away for the rest. 12 keeps the whole `project_context` object in
|
|
4158
|
+
* the low hundreds of bytes per scope on realistic data — the approved sizing is
|
|
4159
|
+
* "~150 tokens for a typical project" — and anything past it is COUNTED in
|
|
4160
|
+
* `omitted` rather than hinted at. */
|
|
4161
|
+
const MANIFEST_DOCUMENT_ROWS = 12;
|
|
4162
|
+
/** Rows pulled PER SCOPE by the manifest's two document reads, before the
|
|
4163
|
+
* listing cap is applied in JS. Larger than what is listed because one scope's
|
|
4164
|
+
* window covers every codebase at once (see `buildProjectContextManifest`); the
|
|
4165
|
+
* rows are metadata-only (no `content`, see `content_chars`), so this is tens of
|
|
4166
|
+
* KB at the very worst, and the exact total comes from each query's `count`
|
|
4167
|
+
* rather than from the row count.
|
|
4168
|
+
*
|
|
4169
|
+
* PER SCOPE, not project-wide. A single project-wide read ordered on
|
|
4170
|
+
* `codebase_id` first put the codebase scope entirely outside the window as soon
|
|
4171
|
+
* as the project scope held this many documents — so the manifest emitted no
|
|
4172
|
+
* codebase at all and then told the agent that the unqualified call "returns the
|
|
4173
|
+
* rest", a call that structurally cannot reach a codebase-scoped document. A
|
|
4174
|
+
* bounded slice of EACH scope makes that false negative impossible. */
|
|
4175
|
+
const MANIFEST_FETCH_ROWS = 200;
|
|
4176
|
+
/** The project's own codebases, read so a codebase-scoped document can be NAMED
|
|
4177
|
+
* (`get_project_context` takes a codebase, not a `codebase_id`).
|
|
4178
|
+
*
|
|
4179
|
+
* Its own constant, not `MANIFEST_FETCH_ROWS`: this bounds a different table for
|
|
4180
|
+
* a different reason. Past it a real document becomes unnameable, so — like
|
|
4181
|
+
* every other cap in this block — the overflow is REPORTED, in
|
|
4182
|
+
* `omitted.unnamed_codebases`, rather than quietly turning documents into
|
|
4183
|
+
* ordinary omissions. */
|
|
4184
|
+
const MANIFEST_CODEBASE_ROWS = 200;
|
|
4185
|
+
/** Codebase references NAMED in `omitted.omitted_document_codebases` — the
|
|
4186
|
+
* codebases that hold documents the listing left out.
|
|
4187
|
+
*
|
|
4188
|
+
* Counting omitted codebase-scoped documents without naming their codebases is
|
|
4189
|
+
* the same defect as naming a document no call can reach, one step removed:
|
|
4190
|
+
* `get_project_context` takes a codebase, so "each codebase's own need that
|
|
4191
|
+
* codebase passed as `codebase`" is only actionable if the agent is told WHICH.
|
|
4192
|
+
* Like every other cap in this block, the overflow past this is REPORTED — in
|
|
4193
|
+
* `omitted.further_document_codebases` — rather than truncated in silence. */
|
|
4194
|
+
const MANIFEST_OMITTED_CODEBASE_REFS = 12;
|
|
4195
|
+
/** Codebase targets read for one task. `cliv2_task_codebase_targets` is unique
|
|
4196
|
+
* on `(task_id, git_remote_url)` and nothing bounds how many a task may have,
|
|
4197
|
+
* so the read is capped; the overflow is reported in `omitted.codebases`
|
|
4198
|
+
* (an exact count, from the same query) rather than silently dropped. */
|
|
4199
|
+
const MANIFEST_TARGET_ROWS = 20;
|
|
4200
|
+
/** A hung read must not hold a session open. The manifest is abandoned — not
|
|
4201
|
+
* failed — past this, and `begin_work` answers with the session alone.
|
|
4202
|
+
*
|
|
4203
|
+
* 1.5s, not the 4s first built: `begin_work` is awaited on BOTH of its paths,
|
|
4204
|
+
* so this is a hold on the one handshake every session opens with. Something
|
|
4205
|
+
* optional does not get to delay the load-bearing operation for four seconds. */
|
|
4206
|
+
const MANIFEST_TIMEOUT_MS = 1_500;
|
|
4207
|
+
/** "a", "a and b", "a, b and c" — the transcript's phrasing for the type list. */
|
|
4208
|
+
const humanList = (items) => items.length <= 1
|
|
4209
|
+
? items.join('')
|
|
4210
|
+
: `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
|
|
4211
|
+
/** A literal `get_project_context` call, rendered exactly as an agent should
|
|
4212
|
+
* type it. `codebase` is NOT decoration: `get_project_context` reads the
|
|
4213
|
+
* codebase scope ONLY when it is passed, so an instruction that names a
|
|
4214
|
+
* codebase-scoped document and then omits `codebase` sends the agent to a call
|
|
4215
|
+
* that answers "this project has no context documents". */
|
|
4216
|
+
const contextCall = (type, codebase) => {
|
|
4217
|
+
const args = [
|
|
4218
|
+
...(type ? [`types: ['${type}']`] : []),
|
|
4219
|
+
...(codebase ? [`codebase: '${codebase}'`] : []),
|
|
4220
|
+
];
|
|
4221
|
+
return `get_project_context({${args.length ? ` ${args.join(', ')} ` : ''}})`;
|
|
4222
|
+
};
|
|
4223
|
+
/**
|
|
4224
|
+
* The one `instruction` line. It names a LITERAL call, with `types` already
|
|
4225
|
+
* narrowed, which is the live v2 idiom (`present_wireframes` / `present_mocks`).
|
|
4226
|
+
*
|
|
4227
|
+
* WITH an `instructions` document it is the transcript's line. WITHOUT one it
|
|
4228
|
+
* must NOT tell the agent to fetch a document that does not exist, so it narrows
|
|
4229
|
+
* to a type this project actually has (the first document's — documents are in
|
|
4230
|
+
* creation order, project scope first, so it is deterministic) and offers the
|
|
4231
|
+
* unnarrowed call for the rest. Error/edge copy is the builder's call per Phase
|
|
4232
|
+
* 3's Learned; this is that call, reported.
|
|
4233
|
+
*
|
|
4234
|
+
* `other` is dropped from the prose list ("the standing other for this work" is
|
|
4235
|
+
* not a sentence); when it is the only type there is, the clause falls back to
|
|
4236
|
+
* "standing context" rather than leaving the em-dash with nothing after it.
|
|
4237
|
+
*
|
|
4238
|
+
* EVERY NAMED CALL IS QUALIFIED FROM THE DOCUMENT IT NAMES, never from a single
|
|
4239
|
+
* manifest-wide guess. The `instructions` line is qualified with the
|
|
4240
|
+
* `instructions` document's own `codebase` (absent → the unqualified call, which
|
|
4241
|
+
* is exactly right for a project-scoped one); the fallback line is qualified with
|
|
4242
|
+
* the first document's. A qualifier drawn from anywhere else was the bug: it
|
|
4243
|
+
* could name a codebase whose documents were never listed, sending every call to
|
|
4244
|
+
* a different document than the manifest just showed.
|
|
4245
|
+
*
|
|
4246
|
+
* AND ONE CALL IS NEVER IMPLIED TO COVER MANY CODEBASES. `get_project_context`
|
|
4247
|
+
* takes ONE codebase; when the listing spans more than one, the line says so
|
|
4248
|
+
* plainly and points at each entry's own `codebase` field rather than pretending.
|
|
4249
|
+
*
|
|
4250
|
+
* The COUNT is the project's real total, never "at least N" — a hedging word is
|
|
4251
|
+
* not a signal. When it exceeds what is listed, the `omitted` object carries the
|
|
4252
|
+
* numbers and this line points at the call that returns the rest — SEPARATELY per
|
|
4253
|
+
* scope, because the unqualified call returns project-scoped documents only and
|
|
4254
|
+
* saying it "returns the rest" of a codebase's own is simply false.
|
|
4255
|
+
*
|
|
4256
|
+
* AND THE OMITTED CODEBASE-SCOPED ONES ARE NAMED, NOT JUST COUNTED. Telling an
|
|
4257
|
+
* agent that N documents live on codebases it must pass as `codebase`, without
|
|
4258
|
+
* ever saying which codebases those are, leaves it guessing — counted but
|
|
4259
|
+
* unreachable, the same defect as named but unreachable. The names come from
|
|
4260
|
+
* `omitted.omitted_document_codebases` and this line repeats exactly them, so the
|
|
4261
|
+
* prose and the machine-readable field can never disagree.
|
|
4262
|
+
*/
|
|
4263
|
+
function manifestInstruction(input) {
|
|
4264
|
+
const { projectName, documents, total, omittedProjectDocuments, omittedCodebaseDocuments, omittedDocumentCodebases, furtherDocumentCodebases, omittedCodebases, unnamedCodebases, } = input;
|
|
4265
|
+
const singular = total === 1;
|
|
4266
|
+
const they = singular ? 'it is' : 'they are';
|
|
4267
|
+
const types = [...new Set(documents.map((document) => document.type))];
|
|
4268
|
+
const named = types.filter((type) => type !== 'other');
|
|
4269
|
+
const body = named.length
|
|
4270
|
+
? `${they} the standing ${humanList(named)} for this work, and ${they} not in the repo. `
|
|
4271
|
+
: `${they} standing context for this work, and ${they} not in the repo. `;
|
|
4272
|
+
const lead = `This project keeps ${total} context document${singular ? '' : 's'} in ${projectName} — ${body}`;
|
|
4273
|
+
// The codebases actually REPRESENTED in the listing, in listing order.
|
|
4274
|
+
const listedCodebases = [
|
|
4275
|
+
...new Set(documents.flatMap((document) => (document.codebase ? [document.codebase] : []))),
|
|
4276
|
+
];
|
|
4277
|
+
// `documents` is project scope first, so a project-scoped `instructions`
|
|
4278
|
+
// document wins over a codebase-scoped one — and its unqualified call is the
|
|
4279
|
+
// call that actually returns it.
|
|
4280
|
+
const instructionsDocument = documents.find((document) => document.type === 'instructions');
|
|
4281
|
+
const first = documents[0];
|
|
4282
|
+
// The unqualified call is only OFFERED when it would actually return
|
|
4283
|
+
// something: it reads the project scope, so a listing with no project-scoped
|
|
4284
|
+
// document in it must not point at a call that answers "no context documents".
|
|
4285
|
+
const anyProjectScoped = documents.some((document) => document.scope === 'project');
|
|
4286
|
+
const everything = !anyProjectScoped
|
|
4287
|
+
? ''
|
|
4288
|
+
: listedCodebases.length
|
|
4289
|
+
? `, or ${contextCall(null, null)} for the project-scoped ones`
|
|
4290
|
+
: `, or ${contextCall(null, null)} for all of them`;
|
|
4291
|
+
const core = instructionsDocument
|
|
4292
|
+
? 'Read the instructions document before you change anything: ' +
|
|
4293
|
+
`${contextCall('instructions', instructionsDocument.codebase ?? null)}. ` +
|
|
4294
|
+
'Pull the others when the work touches them.'
|
|
4295
|
+
: `There is no instructions document — read the ones the work touches, e.g. ` +
|
|
4296
|
+
`${contextCall(first.type, first.codebase ?? null)}${everything}.`;
|
|
4297
|
+
const scoping = listedCodebases.length === 0
|
|
4298
|
+
? ''
|
|
4299
|
+
: listedCodebases.length === 1
|
|
4300
|
+
? ` The codebase-scoped documents above belong to ${listedCodebases[0]} — ` +
|
|
4301
|
+
`${contextCall(null, listedCodebases[0])} reads them.`
|
|
4302
|
+
: ` The codebase-scoped documents above span ${listedCodebases.length} codebases and no ` +
|
|
4303
|
+
'single call covers them: `get_project_context` takes one codebase, and every entry ' +
|
|
4304
|
+
'names its own in `codebase`.';
|
|
4305
|
+
const listed = documents.length;
|
|
4306
|
+
const rest = omittedProjectDocuments && omittedCodebaseDocuments
|
|
4307
|
+
? `${contextCall(null, null)} returns the rest of the project-scoped ones, and each ` +
|
|
4308
|
+
"codebase's own need that codebase passed as `codebase`."
|
|
4309
|
+
: omittedProjectDocuments
|
|
4310
|
+
? `${contextCall(null, null)} returns the rest.`
|
|
4311
|
+
: 'the rest are codebase-scoped: pass the codebase that holds them as `codebase`.';
|
|
4312
|
+
// NAMED, not merely counted. "each codebase's own need that codebase passed as
|
|
4313
|
+
// `codebase`" is only actionable if the agent is told WHICH codebases — and the
|
|
4314
|
+
// prose must say exactly what `omitted.omitted_document_codebases` says, so the
|
|
4315
|
+
// list is never encoded in one of them alone.
|
|
4316
|
+
const holders = furtherDocumentCodebases
|
|
4317
|
+
? `${humanList(omittedDocumentCodebases)} and ${furtherDocumentCodebases} more ` +
|
|
4318
|
+
`codebase${furtherDocumentCodebases === 1 ? '' : 's'}`
|
|
4319
|
+
: humanList(omittedDocumentCodebases);
|
|
4320
|
+
const omissions = [
|
|
4321
|
+
...(omittedProjectDocuments + omittedCodebaseDocuments
|
|
4322
|
+
? [` Only ${listed} of them ${listed === 1 ? 'is' : 'are'} listed here — ${rest}`]
|
|
4323
|
+
: []),
|
|
4324
|
+
...(omittedDocumentCodebases.length
|
|
4325
|
+
? [
|
|
4326
|
+
` The omitted codebase-scoped ones are held by ${holders} — pass each named one as ` +
|
|
4327
|
+
'`codebase`.',
|
|
4328
|
+
]
|
|
4329
|
+
: []),
|
|
4330
|
+
...(omittedCodebases
|
|
4331
|
+
? [
|
|
4332
|
+
` This task targets ${omittedCodebases} more codebase${omittedCodebases === 1 ? '' : 's'} ` +
|
|
4333
|
+
'than were read, so their documents were not preferred for the listing (they are still ' +
|
|
4334
|
+
'counted above).',
|
|
4335
|
+
]
|
|
4336
|
+
: []),
|
|
4337
|
+
...(unnamedCodebases
|
|
4338
|
+
? [
|
|
4339
|
+
` This project has ${unnamedCodebases} more codebase${unnamedCodebases === 1 ? '' : 's'} ` +
|
|
4340
|
+
'than were read, so any documents of theirs are neither listed nor counted above.',
|
|
4341
|
+
]
|
|
4342
|
+
: []),
|
|
4343
|
+
];
|
|
4344
|
+
return lead + core + scoping + omissions.join('');
|
|
4345
|
+
}
|
|
4346
|
+
/**
|
|
4347
|
+
* A warning that cannot become the failure it is describing.
|
|
4348
|
+
*
|
|
4349
|
+
* `console.warn` CAN THROW: the companion is routinely detached, and a write to a
|
|
4350
|
+
* closed stderr raises EPIPE synchronously. Every never-fail path in this block
|
|
4351
|
+
* ends in a warn, so an unguarded one turns "the manifest is unavailable" into
|
|
4352
|
+
* `begin_work failed: EPIPE` for a session row that already exists. The MESSAGE
|
|
4353
|
+
* is built inside the guard too — `errorMessage` calls `String(err)`, which a
|
|
4354
|
+
* thrown object with a hostile `toString` can also make throw.
|
|
4355
|
+
*/
|
|
4356
|
+
function warnQuietly(build) {
|
|
4357
|
+
try {
|
|
4358
|
+
console.warn(build());
|
|
4359
|
+
}
|
|
4360
|
+
catch {
|
|
4361
|
+
// A lost warning is not a failure. There is nowhere left to report it to.
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
/** Awaits a Supabase call that asked for `{ count: 'exact' }` and returns both.
|
|
4365
|
+
* `must` deliberately returns only `data`; the manifest needs the count to say
|
|
4366
|
+
* what it omitted, and PostgREST answers it in the SAME round trip (a
|
|
4367
|
+
* `Content-Range` header), so the honesty costs no query. */
|
|
4368
|
+
async function mustWithCount(query) {
|
|
4369
|
+
const { data, error, count } = await query;
|
|
4370
|
+
if (error)
|
|
4371
|
+
throw new Error(error.message);
|
|
4372
|
+
const rows = data ?? [];
|
|
4373
|
+
return { rows, total: count ?? rows.length };
|
|
4374
|
+
}
|
|
4375
|
+
/**
|
|
4376
|
+
* Build the manifest for one task, or return null (no documents at all, in any
|
|
4377
|
+
* scope).
|
|
4378
|
+
*
|
|
4379
|
+
* ONE ROUND TRIP, FOUR QUERIES. A BOUNDED SLICE OF EACH SCOPE (project-scoped
|
|
4380
|
+
* documents, codebase-scoped documents), the task's codebase targets, and the
|
|
4381
|
+
* project's codebases all fan out in a single `Promise.all` — none of them
|
|
4382
|
+
* depends on another's result, and `begin_work` runs this on every session open,
|
|
4383
|
+
* so the sequencing is the cost that matters. The target→codebase match and the
|
|
4384
|
+
* per-scope listing cap happen in memory afterwards.
|
|
4385
|
+
*
|
|
4386
|
+
* WHY TWO DOCUMENT READS AND NOT ONE. A single project-wide read has to order the
|
|
4387
|
+
* scopes against each other, and whichever loses can fall entirely outside the
|
|
4388
|
+
* fetch window: with `MANIFEST_FETCH_ROWS` project-scoped documents, the codebase
|
|
4389
|
+
* scope vanished from the manifest — no codebase named, and an instruction
|
|
4390
|
+
* claiming the unqualified call "returns the rest" of documents that call cannot
|
|
4391
|
+
* return. A window per scope makes that structurally impossible, and each read
|
|
4392
|
+
* reports its own exact `count`, so omissions are counted PER SCOPE and the prose
|
|
4393
|
+
* can say which call actually returns which remainder.
|
|
4394
|
+
*
|
|
4395
|
+
* SCOPE-AWARE WITHOUT BEING ASKED. `cliv2_task_codebase_targets` records which
|
|
4396
|
+
* codebase(s) a task targets, BY VALUE — a canonical `git_remote_url`, no FK —
|
|
4397
|
+
* so the targets are matched against the project's own `cliv2_codebases` rows to
|
|
4398
|
+
* reach the ids `project_documents.codebase_id` uses. A target with no
|
|
4399
|
+
* registered codebase in this project simply contributes nothing. Both sides are
|
|
4400
|
+
* stored canonical (`cliv2_codebases` per src/codebases.ts:87, the targets per
|
|
4401
|
+
* their own hosted-remote CHECK), so equality is enough.
|
|
4402
|
+
*
|
|
4403
|
+
* EVERY CODEBASE-SCOPED DOCUMENT CARRIES ITS OWN CODEBASE. The reference the
|
|
4404
|
+
* agent must literally pass is computed per codebase — the display name when that
|
|
4405
|
+
* name is unique in the project, the canonical git remote when it is not, because
|
|
4406
|
+
* `get_project_context` refuses an ambiguous name and a manifest must never hand
|
|
4407
|
+
* out a call the tool rejects. A codebase-scoped document whose codebase row is
|
|
4408
|
+
* gone (Phase 2 orphans rather than cascades), or whose codebase fell past
|
|
4409
|
+
* `MANIFEST_CODEBASE_ROWS`, is therefore unnameable: it is never listed AND never
|
|
4410
|
+
* counted — the agent has no call that would reach it, so counting it would be a
|
|
4411
|
+
* promise the named call cannot keep.
|
|
4412
|
+
*
|
|
4413
|
+
* MORE THAN ONE TARGET IS NORMAL and all of them are included. Phase 3 refuses
|
|
4414
|
+
* an AMBIGUOUS codebase because the caller typed one name and could have meant
|
|
4415
|
+
* either repo; here nobody typed anything — the task states its targets, and
|
|
4416
|
+
* answering about a subset of them would be the silent pick that refusal exists
|
|
4417
|
+
* to prevent. Documents stay ordered project scope first, then codebase scope,
|
|
4418
|
+
* each carrying `scope`, exactly as Phase 3 returns them.
|
|
4419
|
+
*
|
|
4420
|
+
* THE UNTARGETED-ONLY FALLBACK. A project whose documents all live on codebase
|
|
4421
|
+
* pages the task does not target would otherwise get NO manifest — the exact
|
|
4422
|
+
* silence Phase 3 was corrected for ("call again with `codebase`"). When nothing
|
|
4423
|
+
* is listable in the ordinary scopes, the codebase-scoped documents that DO
|
|
4424
|
+
* exist are listed instead and the instruction names their codebase. That never
|
|
4425
|
+
* widens the ordinary case: when the task's own scopes have documents, another
|
|
4426
|
+
* codebase's stay out (and are counted in `omitted`).
|
|
4427
|
+
*/
|
|
4428
|
+
async function buildProjectContextManifest(client, project, taskId) {
|
|
4429
|
+
// Every read is made through an ASYNC thunk, so a client that throws
|
|
4430
|
+
// SYNCHRONOUSLY (a dead connection, say) rejects the promise `Promise.all` is
|
|
4431
|
+
// already holding rather than throwing while the array is still being built —
|
|
4432
|
+
// which would leave the earlier reads' promises floating and unhandled.
|
|
4433
|
+
const documentColumns = 'title, type, updated_at, codebase_id, content_chars';
|
|
4434
|
+
const [projectRead, codebaseRead, targetRead, codebaseRowsRead] = await Promise.all([
|
|
4435
|
+
(async () => mustWithCount(client
|
|
4436
|
+
.from('project_documents')
|
|
4437
|
+
.select(documentColumns, { count: 'exact' })
|
|
4438
|
+
.eq('project_id', project.id)
|
|
4439
|
+
.is('codebase_id', null)
|
|
4440
|
+
.order('created_at', { ascending: true })
|
|
4441
|
+
.order('id', { ascending: true })
|
|
4442
|
+
.limit(MANIFEST_FETCH_ROWS)))(),
|
|
4443
|
+
(async () => mustWithCount(client
|
|
4444
|
+
.from('project_documents')
|
|
4445
|
+
.select(documentColumns, { count: 'exact' })
|
|
4446
|
+
.eq('project_id', project.id)
|
|
4447
|
+
.not('codebase_id', 'is', null)
|
|
4448
|
+
// Grouped by codebase, then creation order inside each. No `nullsFirst`
|
|
4449
|
+
// is needed — and none is silently load-bearing — because the `not …
|
|
4450
|
+
// is null` predicate means this read has no null `codebase_id` to sort.
|
|
4451
|
+
.order('codebase_id', { ascending: true })
|
|
4452
|
+
.order('created_at', { ascending: true })
|
|
4453
|
+
.order('id', { ascending: true })
|
|
4454
|
+
.limit(MANIFEST_FETCH_ROWS)))(),
|
|
4455
|
+
(async () => mustWithCount(client
|
|
4456
|
+
.from('cliv2_task_codebase_targets')
|
|
4457
|
+
.select('git_remote_url', { count: 'exact' })
|
|
4458
|
+
.eq('task_id', taskId)
|
|
4459
|
+
.order('created_at', { ascending: true })
|
|
4460
|
+
.limit(MANIFEST_TARGET_ROWS)))(),
|
|
4461
|
+
(async () => mustWithCount(client
|
|
4462
|
+
.from('cliv2_codebases')
|
|
4463
|
+
.select('id, name, git_remote_url', { count: 'exact' })
|
|
4464
|
+
.eq('project_id', project.id)
|
|
4465
|
+
.order('created_at', { ascending: true })
|
|
4466
|
+
.limit(MANIFEST_CODEBASE_ROWS)))(),
|
|
4467
|
+
]);
|
|
4468
|
+
const codebases = codebaseRowsRead.rows;
|
|
4469
|
+
const unnamedCodebases = Math.max(0, codebaseRowsRead.total - codebases.length);
|
|
4470
|
+
// What the agent must LITERALLY pass as `codebase`, per codebase id. The name
|
|
4471
|
+
// when it is unique in this project, the canonical git remote when it is not:
|
|
4472
|
+
// `cliv2_codebases` is unique on `(project_id, git_remote_url)` and NOT on
|
|
4473
|
+
// `name`, and `get_project_context` refuses an ambiguous name outright. Matched
|
|
4474
|
+
// case-insensitively because that tool's name matching is.
|
|
4475
|
+
const nameUses = new Map();
|
|
4476
|
+
for (const row of codebases) {
|
|
4477
|
+
const key = row.name.toLowerCase();
|
|
4478
|
+
nameUses.set(key, (nameUses.get(key) ?? 0) + 1);
|
|
4479
|
+
}
|
|
4480
|
+
const refById = new Map(codebases.map((row) => [
|
|
4481
|
+
row.id,
|
|
4482
|
+
(nameUses.get(row.name.toLowerCase()) ?? 0) > 1 ? row.git_remote_url : row.name,
|
|
4483
|
+
]));
|
|
4484
|
+
const targeted = new Set(targetRead.rows.map((row) => row.git_remote_url));
|
|
4485
|
+
const targetedIds = new Set(codebases.filter((row) => targeted.has(row.git_remote_url)).map((row) => row.id));
|
|
4486
|
+
const projectRows = projectRead.rows;
|
|
4487
|
+
// Unnameable rows are dropped from the LISTING and from the COUNTS alike: no
|
|
4488
|
+
// `get_project_context` call reaches them, so a total that included them would
|
|
4489
|
+
// promise documents the instruction's own call cannot return. Only what this
|
|
4490
|
+
// window saw can be discounted, which makes the total an upper bound rather
|
|
4491
|
+
// than a fiction — the direction that cannot over-promise.
|
|
4492
|
+
const nameable = (row) => row.codebase_id !== null && refById.has(row.codebase_id);
|
|
4493
|
+
const namedCodebaseRows = codebaseRead.rows.filter(nameable);
|
|
4494
|
+
const unreachable = codebaseRead.rows.length - namedCodebaseRows.length;
|
|
4495
|
+
const codebaseTotal = Math.max(0, codebaseRead.total - unreachable);
|
|
4496
|
+
const targetedRows = namedCodebaseRows.filter((row) => targetedIds.has(row.codebase_id));
|
|
4497
|
+
const otherRows = namedCodebaseRows.filter((row) => !targetedIds.has(row.codebase_id));
|
|
4498
|
+
const listedProject = projectRows.slice(0, MANIFEST_DOCUMENT_ROWS);
|
|
4499
|
+
const listedCodebase = (listedProject.length || targetedRows.length ? targetedRows : otherRows).slice(0, MANIFEST_DOCUMENT_ROWS);
|
|
4500
|
+
const shape = (row, scope) => ({
|
|
4501
|
+
title: row.title,
|
|
4502
|
+
type: row.type,
|
|
4503
|
+
scope,
|
|
4504
|
+
...(scope === 'codebase' ? { codebase: refById.get(row.codebase_id) } : {}),
|
|
4505
|
+
updated_at: row.updated_at,
|
|
4506
|
+
characters: row.content_chars ?? 0,
|
|
4507
|
+
});
|
|
4508
|
+
const documents = [
|
|
4509
|
+
...listedProject.map((row) => shape(row, 'project')),
|
|
4510
|
+
...listedCodebase.map((row) => shape(row, 'codebase')),
|
|
4511
|
+
];
|
|
4512
|
+
// Absent, not empty.
|
|
4513
|
+
if (documents.length === 0)
|
|
4514
|
+
return null;
|
|
4515
|
+
const omittedProjectDocuments = Math.max(0, projectRead.total - listedProject.length);
|
|
4516
|
+
const omittedCodebaseDocuments = Math.max(0, codebaseTotal - listedCodebase.length);
|
|
4517
|
+
const omittedDocuments = omittedProjectDocuments + omittedCodebaseDocuments;
|
|
4518
|
+
const totalDocuments = documents.length + omittedDocuments;
|
|
4519
|
+
// WHICH codebases hold the documents that were left out. Derived from the rows
|
|
4520
|
+
// themselves (identity against the listed slice), so a codebase with some rows
|
|
4521
|
+
// listed and some omitted is named too — its remainder needs the same call.
|
|
4522
|
+
// Only the fetch window can be attributed, which makes this a lower bound
|
|
4523
|
+
// rather than a fiction: the direction that cannot name a codebase that does
|
|
4524
|
+
// not hold an omitted document.
|
|
4525
|
+
const listedCodebaseRows = new Set(listedCodebase);
|
|
4526
|
+
const omittedCodebaseRefs = omittedCodebaseDocuments
|
|
4527
|
+
? [
|
|
4528
|
+
...new Set(namedCodebaseRows
|
|
4529
|
+
.filter((row) => !listedCodebaseRows.has(row))
|
|
4530
|
+
.map((row) => refById.get(row.codebase_id))),
|
|
4531
|
+
]
|
|
4532
|
+
: [];
|
|
4533
|
+
const omittedDocumentCodebases = omittedCodebaseRefs.slice(0, MANIFEST_OMITTED_CODEBASE_REFS);
|
|
4534
|
+
const furtherDocumentCodebases = omittedCodebaseRefs.length - omittedDocumentCodebases.length;
|
|
4535
|
+
const omittedCodebases = Math.max(0, targetRead.total - targetRead.rows.length);
|
|
4536
|
+
const omitted = omittedDocuments || omittedCodebases || unnamedCodebases
|
|
4537
|
+
? {
|
|
4538
|
+
// Conditional, so `omitted_documents: 0` can never contradict its own
|
|
4539
|
+
// "always > 0 when present" contract.
|
|
4540
|
+
...(omittedDocuments
|
|
4541
|
+
? { total_documents: totalDocuments, omitted_documents: omittedDocuments }
|
|
4542
|
+
: {}),
|
|
4543
|
+
...(omittedDocumentCodebases.length
|
|
4544
|
+
? { omitted_document_codebases: omittedDocumentCodebases }
|
|
4545
|
+
: {}),
|
|
4546
|
+
...(furtherDocumentCodebases
|
|
4547
|
+
? { further_document_codebases: furtherDocumentCodebases }
|
|
4548
|
+
: {}),
|
|
4549
|
+
...(omittedCodebases ? { omitted_codebases: omittedCodebases } : {}),
|
|
4550
|
+
...(unnamedCodebases ? { unnamed_codebases: unnamedCodebases } : {}),
|
|
4551
|
+
}
|
|
4552
|
+
: null;
|
|
4553
|
+
return {
|
|
4554
|
+
project: project.name,
|
|
4555
|
+
documents,
|
|
4556
|
+
...(omitted ? { omitted } : {}),
|
|
4557
|
+
instruction: manifestInstruction({
|
|
4558
|
+
projectName: project.name,
|
|
4559
|
+
documents,
|
|
4560
|
+
total: totalDocuments,
|
|
4561
|
+
omittedProjectDocuments,
|
|
4562
|
+
omittedCodebaseDocuments,
|
|
4563
|
+
omittedDocumentCodebases,
|
|
4564
|
+
furtherDocumentCodebases,
|
|
4565
|
+
omittedCodebases,
|
|
4566
|
+
unnamedCodebases,
|
|
4567
|
+
}),
|
|
4568
|
+
};
|
|
4569
|
+
}
|
|
4570
|
+
/**
|
|
4571
|
+
* The manifest, or null — THE ONLY ENTRY POINT, and it cannot fail.
|
|
4572
|
+
*
|
|
4573
|
+
* Every throw is caught here and every slow path is abandoned here, so a caller
|
|
4574
|
+
* physically has no error to handle: the return type says `Manifest | null` and
|
|
4575
|
+
* nothing else can come out. The catch itself is written so that it cannot
|
|
4576
|
+
* throw either — a thrown non-Error (`throw 'nope'`, an aborted fetch's
|
|
4577
|
+
* `DOMException`, anything) would make `(err as Error).message` a `TypeError`
|
|
4578
|
+
* raised FROM INSIDE the catch, which escapes this function entirely and turns
|
|
4579
|
+
* an optional extra into a permanently failing `begin_work` on a session that
|
|
4580
|
+
* has already been inserted.
|
|
4581
|
+
*/
|
|
4582
|
+
async function projectContextManifest(client, project, taskId) {
|
|
4583
|
+
let timer;
|
|
4584
|
+
try {
|
|
4585
|
+
return await Promise.race([
|
|
4586
|
+
buildProjectContextManifest(client, project, taskId),
|
|
4587
|
+
new Promise((resolve) => {
|
|
4588
|
+
timer = setTimeout(() => resolve(null), MANIFEST_TIMEOUT_MS);
|
|
4589
|
+
}),
|
|
4590
|
+
]);
|
|
4591
|
+
}
|
|
4592
|
+
catch (err) {
|
|
4593
|
+
// Warned, not surfaced: the session is the result the agent asked for.
|
|
4594
|
+
warnQuietly(() => `begin_work: project context manifest unavailable: ${errorMessage(err)}`);
|
|
4595
|
+
return null;
|
|
4596
|
+
}
|
|
4597
|
+
finally {
|
|
4598
|
+
clearTimeout(timer);
|
|
4599
|
+
}
|
|
4600
|
+
}
|
|
4601
|
+
// ---------------------------------------------------------------------------
|
|
4602
|
+
// The twenty-seven tools this server exposes. Exported for the self-check.
|
|
4603
|
+
// ---------------------------------------------------------------------------
|
|
4604
|
+
export const TOOL_NAMES = [
|
|
4605
|
+
'list_tasks',
|
|
4606
|
+
'get_task',
|
|
4607
|
+
'create_artifact',
|
|
4608
|
+
'attach_screenshot',
|
|
4609
|
+
'update_task',
|
|
4610
|
+
'update_artifact',
|
|
4611
|
+
'set_task_role_slugs',
|
|
4612
|
+
'add_comment',
|
|
4613
|
+
'create_task',
|
|
4614
|
+
'create_epic',
|
|
4615
|
+
'create_sprint',
|
|
4616
|
+
'begin_work',
|
|
4617
|
+
'end_work',
|
|
4618
|
+
'ask_question',
|
|
4619
|
+
'present_wireframes',
|
|
4620
|
+
'present_mocks',
|
|
4621
|
+
'resolve_feedback',
|
|
4622
|
+
'record_user_input',
|
|
1435
4623
|
'record_context_exploration',
|
|
1436
4624
|
'reserve_work_paths',
|
|
1437
4625
|
'release_work_paths',
|
|
@@ -1439,8 +4627,17 @@ export const TOOL_NAMES = [
|
|
|
1439
4627
|
'acknowledge_unavailable_checkout',
|
|
1440
4628
|
'list_credentials',
|
|
1441
4629
|
'get_credential',
|
|
4630
|
+
'get_project_context',
|
|
4631
|
+
'propose_project_context',
|
|
4632
|
+
'list_product_ideas',
|
|
4633
|
+
'create_product_idea',
|
|
4634
|
+
'get_client_context',
|
|
4635
|
+
'list_clients',
|
|
4636
|
+
'propose_client_context',
|
|
4637
|
+
'raise_work_item_flags',
|
|
4638
|
+
'resolve_work_item_flags',
|
|
1442
4639
|
];
|
|
1443
|
-
/** Build a per-session McpServer with the
|
|
4640
|
+
/** Build a per-session McpServer with the thirty-three tools. A fresh instance per
|
|
1444
4641
|
* session is what makes `server.server.getClientVersion()` (populated during
|
|
1445
4642
|
* that session's `initialize`) the right source for attribution — mirroring
|
|
1446
4643
|
* v1's per-session `buildServer`. `connectionId` is this connection's key into
|
|
@@ -1464,6 +4661,63 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1464
4661
|
touchSession(connectionId);
|
|
1465
4662
|
return listTasksHandler(client, args);
|
|
1466
4663
|
});
|
|
4664
|
+
server.registerTool('list_product_ideas', {
|
|
4665
|
+
description: 'List the Product Ideas in your CTRL+SPC projects — pre-backlog work items the organization has NOT agreed to build. '
|
|
4666
|
+
+ 'These are deliberately absent from list_tasks, so this is the only way to see them. '
|
|
4667
|
+
+ 'Call this BEFORE capturing a new signal as an idea: if one of these already covers the same problem — in THIS project '
|
|
4668
|
+
+ 'or another one listed here — add an artifact to it (create_artifact) instead of creating a near-duplicate. '
|
|
4669
|
+
+ 'Also read the code first, in the repository you are working in, whether or not a codebase is attached: if the product '
|
|
4670
|
+
+ 'already does what the signal asks for, the right outcome is to create NOTHING and say so. '
|
|
4671
|
+
+ 'Returns titles and counts, not artifact bodies; use get_task to read one in full when a title alone cannot settle '
|
|
4672
|
+
+ 'whether two signals are the same problem.',
|
|
4673
|
+
inputSchema: { project_id: z.string().optional() },
|
|
4674
|
+
}, async (args) => {
|
|
4675
|
+
touchSession(connectionId);
|
|
4676
|
+
return listProductIdeasHandler(client, args);
|
|
4677
|
+
});
|
|
4678
|
+
server.registerTool('create_product_idea', {
|
|
4679
|
+
description: 'Capture a signal (customer feedback, market context, a support thread, a technical opportunity) as a Product Idea — '
|
|
4680
|
+
+ 'a work item that is NOT in the Backlog and that only a human can promote. Creating one starts no work and commits '
|
|
4681
|
+
+ 'nobody. '
|
|
4682
|
+
+ 'FIRST call list_product_ideas and read the relevant code. If an existing idea already covers the problem, add an '
|
|
4683
|
+
+ 'artifact to that idea instead of calling this. If the product ALREADY DOES what the signal asks for, create '
|
|
4684
|
+
+ 'nothing at all — say so and stop; do not substitute an adjacent gap you noticed. '
|
|
4685
|
+
+ 'ONE SIGNAL IS USUALLY AT MOST ONE IDEA: several people describing the same problem is one idea, not one per quote, '
|
|
4686
|
+
+ 'and a signal with two genuinely different problems in it is two. '
|
|
4687
|
+
+ 'Put your analysis in an artifact on the new idea (create_artifact) — the '
|
|
4688
|
+
+ "idea's DESCRIPTION is the human's own thinking, so write it only when it is empty and never edit one they wrote. "
|
|
4689
|
+
+ 'UPDATING BEATS CREATING, especially from client intake: when new client material discusses something an existing '
|
|
4690
|
+
+ 'idea already covers, do NOT create a second idea — add the newly discussed requirements, with their citations, to '
|
|
4691
|
+
+ 'the EXISTING idea as an artifact; a Product Ideas list with eleven near-identical checkout entries, one per '
|
|
4692
|
+
+ 'meeting, is noise. From a client intake run pass client_id: the idea must land in a project attached to THAT '
|
|
4693
|
+
+ 'client (get_client_context lists them under `projects` — one project resolves the choice itself, several means '
|
|
4694
|
+
+ "you choose and say why). Ground the idea in the client's own words in a CITED ARTIFACT on it "
|
|
4695
|
+
+ '(create_artifact) — the description is the human\'s plain-text surface and carries no citation lines. '
|
|
4696
|
+
+ CITATION_LINE_TEACHING,
|
|
4697
|
+
inputSchema: {
|
|
4698
|
+
project_id: z.string(),
|
|
4699
|
+
title: z
|
|
4700
|
+
.string()
|
|
4701
|
+
.min(1)
|
|
4702
|
+
.describe('One line naming the PROBLEM, in the vocabulary the user would use — not a quote from their message, and not your '
|
|
4703
|
+
+ 'summary of your own analysis. A later run has to recognise a duplicate from this line alone, so "Bulk archive is '
|
|
4704
|
+
+ 'too slow for CS" beats "I gave up halfway through and left the rest sitting on the board".'),
|
|
4705
|
+
description: z
|
|
4706
|
+
.string()
|
|
4707
|
+
.optional()
|
|
4708
|
+
.describe('Only for a brand-new idea whose description is empty. Never a summary of your own analysis — that is an '
|
|
4709
|
+
+ 'artifact — and never citation lines: the description is plain text, so quotes and source ids go in a '
|
|
4710
|
+
+ 'cited artifact on the idea instead.'),
|
|
4711
|
+
client_id: z
|
|
4712
|
+
.string()
|
|
4713
|
+
.optional()
|
|
4714
|
+
.describe('From a client intake run: the client this idea came from. The project must be attached to this client, '
|
|
4715
|
+
+ 'and the call is refused if it is not.'),
|
|
4716
|
+
},
|
|
4717
|
+
}, async (args) => {
|
|
4718
|
+
touchSession(connectionId);
|
|
4719
|
+
return createProductIdeaHandler(client, userId, args);
|
|
4720
|
+
});
|
|
1467
4721
|
server.registerTool('get_task', {
|
|
1468
4722
|
description: 'Read a full task — its tags, comments, artifacts, decisions (so you can read the answers to questions ' +
|
|
1469
4723
|
'you asked), and feedback (the cumulative wireframe-review rounds the user sent, each tagged with the ' +
|
|
@@ -1472,17 +4726,31 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1472
4726
|
"attention ('reopened' means the user flipped a round you resolved back to not resolved — treat it as " +
|
|
1473
4727
|
"open feedback on your next pass); 'resolved' rounds don't (resolved_in_revision names the revision that " +
|
|
1474
4728
|
'addressed them; mark rounds resolved with resolve_feedback after revising). ' +
|
|
1475
|
-
'Read-only; it has no status or presence side effects.'
|
|
4729
|
+
'Read-only; it has no status or presence side effects. '
|
|
4730
|
+
+ 'If the item is a Product Idea (pre-backlog, not agreed work), the result carries a product_idea_boundary block — follow it: explore and write artifacts, never implement.',
|
|
1476
4731
|
inputSchema: { id: z.string().describe('Task id') },
|
|
1477
4732
|
}, async ({ id }) => {
|
|
1478
4733
|
touchSession(connectionId);
|
|
1479
4734
|
return getTaskHandler(client, { id });
|
|
1480
4735
|
});
|
|
1481
4736
|
server.registerTool('create_artifact', {
|
|
1482
|
-
|
|
4737
|
+
// 31 Slice 2: the grounded pass is taught HERE (enforcement is manifest
|
|
4738
|
+
// only — Step −1 ruling 2: no warning, no refusal; 13a's caution
|
|
4739
|
+
// stands). The citation convention is the SAME constant the intake
|
|
4740
|
+
// tools teach, because a convention that lives in one description is a
|
|
4741
|
+
// convention half the flows never see.
|
|
4742
|
+
description: 'Create a titled analysis/plan/spec/diagram/mock/wireframe/user_story on a task. It appears in the web UI, attributed to you. ' +
|
|
4743
|
+
"A user_story artifact carries the work item's FULL story set — every \"As a … I want … so that …\" story with its acceptance criteria, in one markdown artifact. Never create one artifact per story. " +
|
|
4744
|
+
'GROUND WHAT YOU AUTHOR: before writing a user_story, spec or plan, fan out and READ what the ' +
|
|
4745
|
+
"product knows — the client's context (get_client_context, when the project serves a client), " +
|
|
4746
|
+
"the project's context documents (get_project_context), and the codebase — then pass " +
|
|
4747
|
+
'`grounding` naming exactly what you read. The manifest is stored with the artifact and its ' +
|
|
4748
|
+
'absence is visible to every reader: a user_story written WITHOUT reading available client ' +
|
|
4749
|
+
'context must say so in its opening line. ' +
|
|
4750
|
+
CITATION_LINE_TEACHING,
|
|
1483
4751
|
inputSchema: {
|
|
1484
4752
|
task_id: z.string(),
|
|
1485
|
-
type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe']),
|
|
4753
|
+
type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe', 'user_story']),
|
|
1486
4754
|
format: z.enum(['md', 'html', 'json', 'svg']).optional().describe('Defaults to md'),
|
|
1487
4755
|
title: z.string().min(1).optional(),
|
|
1488
4756
|
purpose_key: z
|
|
@@ -1491,13 +4759,48 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1491
4759
|
.optional()
|
|
1492
4760
|
.describe('Stable machine-readable identity for this artifact purpose'),
|
|
1493
4761
|
content: z.string(),
|
|
4762
|
+
grounding: z
|
|
4763
|
+
.object({
|
|
4764
|
+
client_context_record_ids: z
|
|
4765
|
+
.array(z.string())
|
|
4766
|
+
.optional()
|
|
4767
|
+
.describe('Ids of the client context records you read (get_client_context)'),
|
|
4768
|
+
project_document_ids: z
|
|
4769
|
+
.array(z.string())
|
|
4770
|
+
.optional()
|
|
4771
|
+
.describe('Ids of the project context documents you read (get_project_context)'),
|
|
4772
|
+
codebases: z
|
|
4773
|
+
.array(z.string())
|
|
4774
|
+
.optional()
|
|
4775
|
+
.describe('Names or remotes of the codebases you read'),
|
|
4776
|
+
})
|
|
4777
|
+
.optional()
|
|
4778
|
+
.describe('The manifest of what you READ before authoring — stored with the artifact. Omit it when ' +
|
|
4779
|
+
'you read nothing, and say so in the artifact instead of sending an empty manifest.'),
|
|
1494
4780
|
},
|
|
1495
4781
|
}, async (args) => {
|
|
1496
4782
|
touchSession(connectionId);
|
|
1497
4783
|
return createArtifactHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1498
4784
|
});
|
|
4785
|
+
server.registerTool('attach_screenshot', {
|
|
4786
|
+
description: 'Attach one real Web, iOS, or Android PNG screenshot to the work item in your open session. ' +
|
|
4787
|
+
'Playwright or Maestro captures the file first; pass its absolute local path, a clear title, the ' +
|
|
4788
|
+
'platform, and the exact browser/simulator/emulator target. The local path is never uploaded or stored. ' +
|
|
4789
|
+
'Requires an open session (begin_work).',
|
|
4790
|
+
inputSchema: {
|
|
4791
|
+
path: z.string().describe('Absolute local path to one existing PNG'),
|
|
4792
|
+
title: z.string().describe('Non-empty title shown on the work item'),
|
|
4793
|
+
platform: z.enum(['web', 'ios', 'android']),
|
|
4794
|
+
target: z.string().describe('Non-empty exact browser, simulator, or emulator name'),
|
|
4795
|
+
},
|
|
4796
|
+
}, async (args) => {
|
|
4797
|
+
touchSession(connectionId);
|
|
4798
|
+
return attachScreenshotHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
4799
|
+
});
|
|
1499
4800
|
server.registerTool('update_task', {
|
|
1500
|
-
description:
|
|
4801
|
+
description: "On a Product Idea the description is the human's own thinking: you may write it only while it is EMPTY, and never edit or overwrite one they wrote — put your analysis in an artifact (create_artifact) instead. "
|
|
4802
|
+
+ 'That is not a default you can be talked out of: if the user asks you to rewrite a description that has content, say you cannot and offer the artifact, rather than offering to do it anyway. '
|
|
4803
|
+
+ 'Update a task you own — its status (backlog / in_progress / done), name, description, or due_date. ' +
|
|
1501
4804
|
'Pass expected_revision from the most recent get_task for optimistic concurrency; on a conflict, ' +
|
|
1502
4805
|
'call get_task again to re-read and retry. The change appears in the web board.',
|
|
1503
4806
|
inputSchema: {
|
|
@@ -1531,7 +4834,7 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1531
4834
|
.describe('The artifact revision from the most recent get_task (optimistic concurrency)'),
|
|
1532
4835
|
fields: z.object({
|
|
1533
4836
|
title: z.string().min(1).optional(),
|
|
1534
|
-
type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe']).optional(),
|
|
4837
|
+
type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe', 'user_story']).optional(),
|
|
1535
4838
|
format: z.enum(['md', 'html', 'json', 'svg']).optional(),
|
|
1536
4839
|
content: z.string().optional(),
|
|
1537
4840
|
}),
|
|
@@ -1562,17 +4865,84 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1562
4865
|
return addCommentHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1563
4866
|
});
|
|
1564
4867
|
server.registerTool('create_task', {
|
|
1565
|
-
|
|
1566
|
-
|
|
4868
|
+
// Feature 32: the calibration rule lives HERE (teach-in-the-description
|
|
4869
|
+
// doctrine) — the bias is stated outright and the tests pin the text.
|
|
4870
|
+
description: 'Create a new task in one of your projects — for splitting work, leaving follow-ups, or laying ' +
|
|
4871
|
+
"out an Agile skeleton's work items. It appears on the web board, owned by you, in backlog: lay " +
|
|
4872
|
+
'out work, never declare progress. Optional epic_id / sprint_id place it under an epic and into ' +
|
|
4873
|
+
'a sprint at creation; both must be live (not archived) and belong to the SAME project. ' +
|
|
4874
|
+
'READ BEFORE YOU WRITE: list what exists (list_tasks) before adding structure, and cut items ' +
|
|
4875
|
+
'along real seams — separately buildable, separately testable — not one item per noun in the ' +
|
|
4876
|
+
'ask. Add NO duration estimates to names or descriptions unless the user asked: agents ' +
|
|
4877
|
+
'overestimate toward human timelines, and sequencing, not duration, is the value.',
|
|
1567
4878
|
inputSchema: {
|
|
1568
4879
|
project_id: z.string().describe('Project id the task belongs to'),
|
|
1569
4880
|
name: z.string().min(1),
|
|
1570
4881
|
description: z.string().optional(),
|
|
4882
|
+
epic_id: z
|
|
4883
|
+
.string()
|
|
4884
|
+
.optional()
|
|
4885
|
+
.describe('Epic to place the task under — a live epic of this SAME project (create_epic)'),
|
|
4886
|
+
sprint_id: z
|
|
4887
|
+
.string()
|
|
4888
|
+
.optional()
|
|
4889
|
+
.describe('Sprint to place the task into — a live sprint of this SAME project (create_sprint)'),
|
|
1571
4890
|
},
|
|
1572
4891
|
}, async (args) => {
|
|
1573
4892
|
touchSession(connectionId);
|
|
1574
4893
|
return createTaskHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1575
4894
|
});
|
|
4895
|
+
server.registerTool('create_epic', {
|
|
4896
|
+
// Feature 32, Slice 1. The description carries the calibration rule and
|
|
4897
|
+
// the read-before-write doctrine — both load-bearing, both pinned by
|
|
4898
|
+
// tests: the scenario evidence from 22b is that agents OBEY this text.
|
|
4899
|
+
description: "Create an Epic — the what-for container a feature's work items hang under, visible on the " +
|
|
4900
|
+
'project board and in Epic management. Use it to lay out an Agile skeleton from a feature ask: ' +
|
|
4901
|
+
'ONE epic for the feature, work items under it (create_task with epic_id), ordered into sprints ' +
|
|
4902
|
+
'(create_sprint). READ BEFORE YOU WRITE: list what exists first (list_tasks for the projects ' +
|
|
4903
|
+
'and board, get_task for detail) — if an epic already covers this feature, EXTEND it with new ' +
|
|
4904
|
+
'work items instead of creating a near-duplicate; a case-insensitive name match is refused for ' +
|
|
4905
|
+
'exactly that reason. CALIBRATE TO AGENT TIME, NOT HUMAN TIME: agents dramatically overestimate ' +
|
|
4906
|
+
'how long building takes because their training is saturated with human engineering timelines — ' +
|
|
4907
|
+
'this plan is executed by agents in sessions and days, not team-weeks and quarters. Do not pad ' +
|
|
4908
|
+
'the epic across a quarter, and add NO duration estimates to anything unless the user asked; ' +
|
|
4909
|
+
'sequencing is the value. Dates are optional — set them only when the user gave real calendar ' +
|
|
4910
|
+
'constraints. Writes as you under RLS; the skeleton lands in backlog — it lays out work, it ' +
|
|
4911
|
+
'never declares progress. ATTRIBUTION: your byline lands on created work items only while a ' +
|
|
4912
|
+
'work session is open — create the FIRST work item, begin_work on it, then create the rest, ' +
|
|
4913
|
+
'so the skeleton is legibly agent-drafted on the board.',
|
|
4914
|
+
inputSchema: {
|
|
4915
|
+
project_id: z.string().describe('Project id the epic belongs to'),
|
|
4916
|
+
name: z.string().min(1).describe('The feature this epic is for — refused if a live epic already carries the name'),
|
|
4917
|
+
description: z.string().optional().describe('What the epic is for and how the work under it is cut'),
|
|
4918
|
+
start_date: z.string().optional().describe('ISO date (YYYY-MM-DD) — only when the user gave one'),
|
|
4919
|
+
target_date: z.string().optional().describe('ISO date (YYYY-MM-DD) — a hope, not an estimate; only when the user gave one'),
|
|
4920
|
+
},
|
|
4921
|
+
}, async (args) => {
|
|
4922
|
+
touchSession(connectionId);
|
|
4923
|
+
return createEpicHandler(client, args);
|
|
4924
|
+
});
|
|
4925
|
+
server.registerTool('create_sprint', {
|
|
4926
|
+
description: 'Create a Sprint in a project. For agent-executed work a sprint is an ORDERED BATCH, not a ' +
|
|
4927
|
+
'time-box: the first sprint holds what unblocks everything else, the next builds on it — each ' +
|
|
4928
|
+
'sized to what agents deliver in a session or a day, not what a human team ships in a ' +
|
|
4929
|
+
'fortnight. Agents dramatically overestimate how long building takes (their training is ' +
|
|
4930
|
+
'saturated with human engineering timelines); counter the bias with fewer, tighter sprints and ' +
|
|
4931
|
+
'NO duration estimates on work items unless the user asked. Dates are optional and usually ' +
|
|
4932
|
+
'unnecessary — set them only for a real calendar constraint the user stated. READ BEFORE YOU ' +
|
|
4933
|
+
"WRITE: list what exists first and reuse the project's existing sprint sequence (a " +
|
|
4934
|
+
'case-insensitive name match is refused) rather than minting a parallel one. Place items with ' +
|
|
4935
|
+
"create_task's sprint_id.",
|
|
4936
|
+
inputSchema: {
|
|
4937
|
+
project_id: z.string().describe('Project id the sprint belongs to'),
|
|
4938
|
+
name: z.string().min(1).describe('The batch name — refused if a live sprint already carries it'),
|
|
4939
|
+
start_date: z.string().optional().describe('ISO date (YYYY-MM-DD) — only for a real calendar constraint'),
|
|
4940
|
+
end_date: z.string().optional().describe('ISO date (YYYY-MM-DD) — only for a real calendar constraint'),
|
|
4941
|
+
},
|
|
4942
|
+
}, async (args) => {
|
|
4943
|
+
touchSession(connectionId);
|
|
4944
|
+
return createSprintHandler(client, args);
|
|
4945
|
+
});
|
|
1576
4946
|
server.registerTool('begin_work', {
|
|
1577
4947
|
description: 'Open a work session on a task so the outputs you create afterward are attributed to this run. ' +
|
|
1578
4948
|
'Call it once, before create_artifact, for the task you are about to work. ' +
|
|
@@ -1591,10 +4961,72 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1591
4961
|
if (!provider) {
|
|
1592
4962
|
return errorResult('begin_work needs a supported agent (Claude or Codex); this client is not recognized as one.');
|
|
1593
4963
|
}
|
|
1594
|
-
// The task must exist and be live under the user's RLS.
|
|
1595
|
-
|
|
4964
|
+
// The task must exist and be live under the user's RLS. The project id
|
|
4965
|
+
// and name ride along on this SAME select (13a Phase 3b) so the context
|
|
4966
|
+
// manifest below costs no extra round-trip to find its project — the
|
|
4967
|
+
// embed is non-null (`tasks.project_id` is a NOT NULL FK), and a shape
|
|
4968
|
+
// that ever came back without it just means no manifest.
|
|
4969
|
+
const task = await must(client
|
|
4970
|
+
.from('tasks')
|
|
4971
|
+
.select('id, project_id, projects(name)')
|
|
4972
|
+
.eq('id', args.task_id)
|
|
4973
|
+
.is('archived_at', null)
|
|
4974
|
+
.maybeSingle());
|
|
1596
4975
|
if (!task)
|
|
1597
4976
|
return errorResult(`No task found for id "${args.task_id}".`);
|
|
4977
|
+
// PostgREST returns a to-one embed as an object but has returned an
|
|
4978
|
+
// array for the same shape across versions; both are unwrapped.
|
|
4979
|
+
const embeddedProject = Array.isArray(task.projects)
|
|
4980
|
+
? (task.projects[0] ?? null)
|
|
4981
|
+
: task.projects;
|
|
4982
|
+
const projectForManifest = embeddedProject?.name
|
|
4983
|
+
? { id: task.project_id, name: embeddedProject.name }
|
|
4984
|
+
: null;
|
|
4985
|
+
/** STARTED HERE, AWAITED LATER. The manifest needs only `task.project_id`
|
|
4986
|
+
* and the project name — both already in hand — and depends on nothing
|
|
4987
|
+
* the session insert/update produces. Sequencing it after that write put
|
|
4988
|
+
* up to `MANIFEST_TIMEOUT_MS` of OPTIONAL work on the critical path of
|
|
4989
|
+
* the one handshake every session opens with; kicking it off now
|
|
4990
|
+
* overlaps it with the write instead.
|
|
4991
|
+
*
|
|
4992
|
+
* The `.catch` is attached in the same expression that creates the
|
|
4993
|
+
* promise, so a rejection can never be unhandled during the window where
|
|
4994
|
+
* the session write is what is being awaited. */
|
|
4995
|
+
const manifestPromise = projectForManifest
|
|
4996
|
+
? projectContextManifest(client, projectForManifest, args.task_id).catch((err) => {
|
|
4997
|
+
warnQuietly(() => `begin_work: project context manifest unavailable: ${errorMessage(err)}`);
|
|
4998
|
+
return null;
|
|
4999
|
+
})
|
|
5000
|
+
: Promise.resolve(null);
|
|
5001
|
+
/** The session payload, plus the context manifest when there is one —
|
|
5002
|
+
* this can only ever ADD a key.
|
|
5003
|
+
*
|
|
5004
|
+
* `projectContextManifest` already contracts never to throw and never
|
|
5005
|
+
* to hang past `MANIFEST_TIMEOUT_MS`. This try does NOT duplicate that
|
|
5006
|
+
* guard, it makes it stop being a precondition: the session row has
|
|
5007
|
+
* ALREADY been inserted and registered by the time this runs, so a
|
|
5008
|
+
* throw escaping the callee would reach `begin_work`'s outer catch and
|
|
5009
|
+
* return `begin_work failed: …` for a session that exists — and the
|
|
5010
|
+
* retry would take the reuse path and fail identically, forever. The
|
|
5011
|
+
* never-fail property has to be a property of THIS call site, not of
|
|
5012
|
+
* the callee's internals.
|
|
5013
|
+
*
|
|
5014
|
+
* THE FALLBACK IS BUILT BEFORE ANYTHING IS LOGGED. A `console.warn` that
|
|
5015
|
+
* throws (EPIPE on a detached companion's stderr) used to escape from
|
|
5016
|
+
* inside this very catch — the one place whose entire job is to not let
|
|
5017
|
+
* that happen. `warnQuietly` swallows it, and the fallback exists before
|
|
5018
|
+
* the warn runs either way. */
|
|
5019
|
+
const withProjectContext = async (session) => {
|
|
5020
|
+
const fallback = textResult({ session });
|
|
5021
|
+
try {
|
|
5022
|
+
const manifest = await manifestPromise;
|
|
5023
|
+
return manifest ? textResult({ session, project_context: manifest }) : fallback;
|
|
5024
|
+
}
|
|
5025
|
+
catch (err) {
|
|
5026
|
+
warnQuietly(() => `begin_work: project context manifest unavailable: ${errorMessage(err)}`);
|
|
5027
|
+
return fallback;
|
|
5028
|
+
}
|
|
5029
|
+
};
|
|
1598
5030
|
// Already have a session on THIS connection for THIS task: refresh its
|
|
1599
5031
|
// liveness (and model, if newly provided) and reuse it — no second row.
|
|
1600
5032
|
const prev = openSessions.get(connectionId);
|
|
@@ -1610,7 +5042,9 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1610
5042
|
.single());
|
|
1611
5043
|
if (!updated)
|
|
1612
5044
|
throw new Error('Session update returned no row.');
|
|
1613
|
-
|
|
5045
|
+
// The REUSE path carries the manifest too: a resumed session opens on
|
|
5046
|
+
// the same handshake and has the same reason to know the context exists.
|
|
5047
|
+
return withProjectContext(updated);
|
|
1614
5048
|
}
|
|
1615
5049
|
// Open the NEW session FIRST (FIX 3): if this insert throws, the registry
|
|
1616
5050
|
// still points at the previous (valid) session rather than an ended one.
|
|
@@ -1648,7 +5082,7 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1648
5082
|
taskId: session.task_id,
|
|
1649
5083
|
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
1650
5084
|
});
|
|
1651
|
-
return
|
|
5085
|
+
return withProjectContext(session);
|
|
1652
5086
|
}
|
|
1653
5087
|
catch (err) {
|
|
1654
5088
|
return errorResult(`begin_work failed: ${err.message}`);
|
|
@@ -1901,6 +5335,212 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1901
5335
|
touchSession(connectionId);
|
|
1902
5336
|
return getCredentialHandler(client, args);
|
|
1903
5337
|
});
|
|
5338
|
+
server.registerTool('get_project_context', {
|
|
5339
|
+
// Verbatim from .implementations/13a-project-context/ux.md § Phase 3
|
|
5340
|
+
// "Tool description" — the approved text. Per the doctrine above, the
|
|
5341
|
+
// description is what TEACHES the agent when to reach for this (which
|
|
5342
|
+
// types serve which work), and the handler is what enforces.
|
|
5343
|
+
description: 'Read the context documents for the project you are working — the instructions, architecture, design ' +
|
|
5344
|
+
'and conventions the team keeps in CTRL+SPC instead of a repo root. Pass `codebase` to also get the ' +
|
|
5345
|
+
'documents that belong to that one repo; pass `types` to pull only what the work needs ' +
|
|
5346
|
+
'(`instructions` before you change anything, `design` for UI work, `conventions` at review). ' +
|
|
5347
|
+
"Uses the open session's task when `task_id` is omitted. Read-only.",
|
|
5348
|
+
inputSchema: {
|
|
5349
|
+
task_id: z
|
|
5350
|
+
.string()
|
|
5351
|
+
.optional()
|
|
5352
|
+
.describe("Task whose project to read; defaults to the open session's task"),
|
|
5353
|
+
codebase: z
|
|
5354
|
+
.string()
|
|
5355
|
+
.optional()
|
|
5356
|
+
.describe("Codebase name or git remote — also returns that codebase's own documents, after the project's"),
|
|
5357
|
+
// Constrained here, unlike present_mocks' deliberately unconstrained
|
|
5358
|
+
// schema: the five values are a closed set an agent cannot reason its
|
|
5359
|
+
// way to, and zod's enum refusal LISTS them, which is more useful than
|
|
5360
|
+
// silently returning nothing for a typo.
|
|
5361
|
+
types: z
|
|
5362
|
+
.array(z.enum(PROJECT_DOCUMENT_TYPES))
|
|
5363
|
+
.optional()
|
|
5364
|
+
.describe('Only these document types; omitted returns every type'),
|
|
5365
|
+
},
|
|
5366
|
+
}, async (args) => {
|
|
5367
|
+
touchSession(connectionId);
|
|
5368
|
+
return getProjectContextHandler(client, openSessions.get(connectionId) ?? null, args);
|
|
5369
|
+
});
|
|
5370
|
+
server.registerTool('propose_project_context', {
|
|
5371
|
+
// The description TEACHES: when to reach for this (after reading a repo),
|
|
5372
|
+
// what a good proposal is (content it actually read, one line of why),
|
|
5373
|
+
// and — the part an agent will otherwise get wrong — that this creates
|
|
5374
|
+
// NOTHING, so its job ends at telling the user where to review.
|
|
5375
|
+
description: 'Propose context documents for the repo you are working in, after reading it. You decide what counts ' +
|
|
5376
|
+
'as standing context — instructions, architecture, design, conventions — wherever it lives in the ' +
|
|
5377
|
+
'repo; there is no fixed list of filenames. Each proposal carries the content you read, its ' +
|
|
5378
|
+
'`source_path`, and one line of `reason` saying why it belongs, so the user can judge your judgement. ' +
|
|
5379
|
+
'Set each proposal\'s `scope`: "project" for context that applies to the whole project, "codebase" ' +
|
|
5380
|
+
'for context that belongs to this repo alone — one scan can send both. This CREATES NOTHING: it ' +
|
|
5381
|
+
'writes proposals the user accepts or rejects in the web app, under Project settings → Project ' +
|
|
5382
|
+
'context. Say so when you report back. Re-running replaces this codebase\'s pending proposals; send ' +
|
|
5383
|
+
'an empty `proposals` array to report that the repo holds no standing context, which clears them. ' +
|
|
5384
|
+
"Uses the open session's task when `task_id` is omitted.",
|
|
5385
|
+
inputSchema: {
|
|
5386
|
+
task_id: z
|
|
5387
|
+
.string()
|
|
5388
|
+
.optional()
|
|
5389
|
+
.describe("Task whose project to propose into; defaults to the open session's task"),
|
|
5390
|
+
codebase: z
|
|
5391
|
+
.string()
|
|
5392
|
+
.min(1)
|
|
5393
|
+
.describe('The codebase you scanned — its name or git remote, as get_project_context takes it'),
|
|
5394
|
+
proposals: z
|
|
5395
|
+
.array(z.object({
|
|
5396
|
+
title: z.string().min(1).describe('Document title, 100 characters or fewer'),
|
|
5397
|
+
// Constrained, as get_project_context's `types` is: a closed set
|
|
5398
|
+
// an agent cannot reason its way to, and zod's refusal lists it.
|
|
5399
|
+
type: z.enum(PROJECT_DOCUMENT_TYPES),
|
|
5400
|
+
content: z.string().min(1).describe('The content you read, verbatim — do not draft new material'),
|
|
5401
|
+
source_path: z
|
|
5402
|
+
.string()
|
|
5403
|
+
.min(1)
|
|
5404
|
+
.describe('Path of the file this came from, RELATIVE to the repo root — never absolute'),
|
|
5405
|
+
reason: z.string().min(1).describe('One line: why this belongs in the project context'),
|
|
5406
|
+
scope: z
|
|
5407
|
+
.enum(PROPOSAL_SCOPES)
|
|
5408
|
+
.describe('"project" for the whole project, "codebase" for this repo alone'),
|
|
5409
|
+
}))
|
|
5410
|
+
.describe('The proposals from this scan — send `[]` if the repo holds no standing context, which clears ' +
|
|
5411
|
+
'anything an earlier scan left waiting'),
|
|
5412
|
+
},
|
|
5413
|
+
}, async (args) => {
|
|
5414
|
+
touchSession(connectionId);
|
|
5415
|
+
return proposeProjectContextHandler(client, openSessions.get(connectionId) ?? null, args);
|
|
5416
|
+
});
|
|
5417
|
+
server.registerTool('list_clients', {
|
|
5418
|
+
description: 'List the clients you can see in CTRL+SPC, each with its attached projects. THE ENTRY POINT ' +
|
|
5419
|
+
'for a client-shaped ask ("build what <client name> asked for on the last call"): resolve the ' +
|
|
5420
|
+
'name to a client id here, then call get_client_context with that id to read what the client ' +
|
|
5421
|
+
'actually said before writing anything. Read-only.',
|
|
5422
|
+
inputSchema: {},
|
|
5423
|
+
}, async () => {
|
|
5424
|
+
touchSession(connectionId);
|
|
5425
|
+
return listClientsHandler(client);
|
|
5426
|
+
});
|
|
5427
|
+
server.registerTool('get_client_context', {
|
|
5428
|
+
// The description TEACHES the read-before-write non-negotiable: reading
|
|
5429
|
+
// what exists is required before proposing, every run — it is the
|
|
5430
|
+
// feature, not an optimization (ux.md non-negotiable 2).
|
|
5431
|
+
description: "Read a client's whole context surface in CTRL+SPC: every piece of raw material (sources — pasted " +
|
|
5432
|
+
'emails, transcripts, documents), whether each has been processed by an agent run, every accepted ' +
|
|
5433
|
+
"context record with its citation, the client's attached projects and their existing Product " +
|
|
5434
|
+
'Ideas, and your own pending proposal count. Call this FIRST, before propose_client_context or ' +
|
|
5435
|
+
'any idea write, every run — reading the context that already exists before writing is ' +
|
|
5436
|
+
'required, not an optimization: it is how you avoid proposing near-duplicates of records the ' +
|
|
5437
|
+
'client already has, and how you extend the Product Idea that already covers a problem instead ' +
|
|
5438
|
+
'of minting another. Takes the client_id from the instruction that sent you here. Read-only.',
|
|
5439
|
+
inputSchema: {
|
|
5440
|
+
client_id: z.string().min(1).describe('The client whose context to read, as given in your instruction'),
|
|
5441
|
+
},
|
|
5442
|
+
}, async (args) => {
|
|
5443
|
+
touchSession(connectionId);
|
|
5444
|
+
return getClientContextHandler(client, args);
|
|
5445
|
+
});
|
|
5446
|
+
server.registerTool('propose_client_context', {
|
|
5447
|
+
// Teaches the two things an agent will otherwise get wrong: the quote
|
|
5448
|
+
// must be VERBATIM, and this creates nothing — review is the human's.
|
|
5449
|
+
description: 'Hand over one intake run\'s results for a client, after reading its unprocessed sources with ' +
|
|
5450
|
+
'get_client_context: per source, either the durable context records you propose from it ' +
|
|
5451
|
+
'(facts, constraints, preferences, decisions) or found_nothing: true — finding nothing durable in ' +
|
|
5452
|
+
'a source is a correct outcome and must be stated, not skipped. EVERY proposal cites its source: ' +
|
|
5453
|
+
"`quote` must be a VERBATIM substring of that source's stored body, character for character — " +
|
|
5454
|
+
'never paraphrase, trim or normalize it. A proposal that re-quotes a span an ACCEPTED record ' +
|
|
5455
|
+
'already cites is refused whole-batch — updating beats creating; propose only what is NEW. ' +
|
|
5456
|
+
'This CREATES NOTHING: it writes proposals the user ' +
|
|
5457
|
+
"accepts or rejects on the client's page in the web app, and flags each source processed. Say so " +
|
|
5458
|
+
"when you report back. Re-running replaces your own pending proposals for the sources you send.",
|
|
5459
|
+
inputSchema: {
|
|
5460
|
+
client_id: z.string().min(1).describe('The client whose material you read'),
|
|
5461
|
+
results: z
|
|
5462
|
+
.array(z.object({
|
|
5463
|
+
source_id: z.string().min(1).describe('A source id from get_client_context'),
|
|
5464
|
+
found_nothing: z
|
|
5465
|
+
.boolean()
|
|
5466
|
+
.optional()
|
|
5467
|
+
.describe('true when this source holds nothing durable — send it with no proposals'),
|
|
5468
|
+
proposals: z
|
|
5469
|
+
.array(z.object({
|
|
5470
|
+
// Constrained, as get_project_context's `types` is: a
|
|
5471
|
+
// closed set an agent cannot reason its way to, and zod's
|
|
5472
|
+
// refusal lists it.
|
|
5473
|
+
kind: z.enum(CLIENT_RECORD_KINDS),
|
|
5474
|
+
title: z.string().min(1).describe('Record title, 100 characters or fewer'),
|
|
5475
|
+
body: z.string().min(1).describe('The durable fact, constraint, preference or decision'),
|
|
5476
|
+
reason: z.string().min(1).describe('One line: why this is durable client context'),
|
|
5477
|
+
quote: z
|
|
5478
|
+
.string()
|
|
5479
|
+
.min(1)
|
|
5480
|
+
.describe("The span this came from, VERBATIM from the source's body — the citation"),
|
|
5481
|
+
}))
|
|
5482
|
+
.optional()
|
|
5483
|
+
.describe('The records you propose from this source — 1 or more, or found_nothing instead'),
|
|
5484
|
+
}))
|
|
5485
|
+
.describe('One entry per source you read this run'),
|
|
5486
|
+
},
|
|
5487
|
+
}, async (args) => {
|
|
5488
|
+
touchSession(connectionId);
|
|
5489
|
+
return proposeClientContextHandler(client, args);
|
|
5490
|
+
});
|
|
5491
|
+
server.registerTool('raise_work_item_flags', {
|
|
5492
|
+
// Teaches non-negotiable 3 (never edit — flag), the verbatim citation,
|
|
5493
|
+
// and the dedup-skip semantics an agent will otherwise misread as an
|
|
5494
|
+
// error.
|
|
5495
|
+
description: "Flag a client's existing work items that new client material CONTRADICTS — after reading them " +
|
|
5496
|
+
'with get_client_context, in the same intake run. You NEVER edit a work item: a flag carries ' +
|
|
5497
|
+
'your suggested edit stated plainly plus the justification, and a human acts on it, hands it ' +
|
|
5498
|
+
"to their own agent, or dismisses it. Every flag cites the client's own words: `quote` must be " +
|
|
5499
|
+
"a VERBATIM substring of that source's stored body — never paraphrase, trim or normalize it. " +
|
|
5500
|
+
'A flag whose (work_item_id, quote) was already raised — even one since dismissed — is ' +
|
|
5501
|
+
'SKIPPED, not an error: a dismissal is permanent, and get_client_context shows you the ' +
|
|
5502
|
+
'existing flags so you can avoid re-deriving them. Finding no contradictions is a correct ' +
|
|
5503
|
+
'outcome: simply do not call this.',
|
|
5504
|
+
inputSchema: {
|
|
5505
|
+
client_id: z.string().min(1).describe('The client whose material you read'),
|
|
5506
|
+
flags: z
|
|
5507
|
+
.array(z.object({
|
|
5508
|
+
work_item_id: z
|
|
5509
|
+
.string()
|
|
5510
|
+
.min(1)
|
|
5511
|
+
.describe('The contradicted work item, from get_client_context'),
|
|
5512
|
+
source_id: z.string().min(1).describe('The source the quote lives in'),
|
|
5513
|
+
quote: z
|
|
5514
|
+
.string()
|
|
5515
|
+
.min(1)
|
|
5516
|
+
.describe("The contradicting span, VERBATIM from the source's body — the citation"),
|
|
5517
|
+
suggested_edit: z
|
|
5518
|
+
.string()
|
|
5519
|
+
.min(1)
|
|
5520
|
+
.describe('What should change on the item, stated plainly — guidance, not a patch'),
|
|
5521
|
+
}))
|
|
5522
|
+
.describe('One entry per contradiction you found'),
|
|
5523
|
+
},
|
|
5524
|
+
}, async (args) => {
|
|
5525
|
+
touchSession(connectionId);
|
|
5526
|
+
// WHO FLAGGED, resolved at raise time: this machine's effective name +
|
|
5527
|
+
// this connection's provider (best-effort, honest fallbacks).
|
|
5528
|
+
const agentName = await resolveFlagAgentName(client, machineId, attribution());
|
|
5529
|
+
return raiseWorkItemFlagsHandler(client, args, agentName);
|
|
5530
|
+
});
|
|
5531
|
+
server.registerTool('resolve_work_item_flags', {
|
|
5532
|
+
description: 'Mark every OPEN flag on one work item resolved — the LAST step of an "apply the flags" ' +
|
|
5533
|
+
'instruction a human handed you: make the edits each flag suggests first, then call this. ' +
|
|
5534
|
+
'Dismissed flags are untouched (a dismissal is the human\'s permanent judgement), and a work ' +
|
|
5535
|
+
'item with no open flags resolves to "nothing to resolve", which is a success. Never call ' +
|
|
5536
|
+
'this from an intake run — raising and resolving belong to different agents.',
|
|
5537
|
+
inputSchema: {
|
|
5538
|
+
work_item_id: z.string().min(1).describe('The work item whose flags you applied'),
|
|
5539
|
+
},
|
|
5540
|
+
}, async (args) => {
|
|
5541
|
+
touchSession(connectionId);
|
|
5542
|
+
return resolveWorkItemFlagsHandler(client, args);
|
|
5543
|
+
});
|
|
1904
5544
|
return server;
|
|
1905
5545
|
}
|
|
1906
5546
|
let handle = null;
|