@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 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
- async function getTaskHandler(client, args) {
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
- must(client
164
- .from('artifacts')
165
- .select(ARTIFACT_COLUMNS)
166
- .eq('task_id', row.id)
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: 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
- async function createArtifactHandler(client, userId, currentSession, args) {
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
- const task = await must(client.from('tasks').select('id').eq('id', args.task_id).is('archived_at', null).maybeSingle());
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(attributionWarning ? { artifact: row, attribution_warning: attributionWarning } : { artifact: row });
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
- const task = await fetchTask(client, taskId);
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(attributionWarning ? { task, attribution_warning: attributionWarning } : { task });
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
- // The twenty-two tools this server exposes. Exported for the self-check.
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
- export const TOOL_NAMES = [
1420
- 'list_tasks',
1421
- 'get_task',
1422
- 'create_artifact',
1423
- 'update_task',
1424
- 'update_artifact',
1425
- 'set_task_role_slugs',
1426
- 'add_comment',
1427
- 'create_task',
1428
- 'begin_work',
1429
- 'end_work',
1430
- 'ask_question',
1431
- 'present_wireframes',
1432
- 'present_mocks',
1433
- 'resolve_feedback',
1434
- 'record_user_input',
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 twenty-two tools. A fresh instance per
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
- description: 'Create a titled analysis/plan/spec/diagram/mock/wireframe on a task. It appears in the web UI, attributed to you.',
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: 'Update a task you own its status (backlog / in_progress / done), name, description, or due_date. ' +
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
- description: 'Create a new task in one of your projects — for splitting work or leaving follow-ups. ' +
1566
- 'It appears on the web board, owned by you.',
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
- const task = await must(client.from('tasks').select('id').eq('id', args.task_id).is('archived_at', null).maybeSingle());
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
- return textResult({ session: updated });
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 textResult({ session });
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;