@ctrl-spc/cs 0.7.7 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ /** One design presentation format and review record for both harnesses. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { must, textResult, errorResult } from './product-tools.js';
4
+ const ARTIFACT_COLUMNS = 'id,task_id,type,format,title,purpose_key,system_kind,content,storage_path,created_by,from_agent,created_at,revision';
5
+ const DECISION_COLUMNS = 'id,task_id,state,category,context,question,answer_mode,options,answer,selected_options,answer_note,question_comment_id,related_artifact_id,asked_by,asked_by_agent_run_id,asked_at,decided_by,decided_at';
6
+ const RESERVED_OPTION_LABELS = ['approve', 'request changes'];
7
+ export async function presentDesign(client, userId, workItemId, args, spec, attribute, askReview) {
8
+ try {
9
+ if (!args.title || !args.title.trim())
10
+ return errorResult(`${spec.tool} requires a non-empty title.`);
11
+ const extra = spec.extraValidation?.();
12
+ if (extra)
13
+ return errorResult(extra);
14
+ // Exactly ONE of the two shapes: html (a single render, exactly Phase 1)
15
+ // or options (2..11 competing renders).
16
+ const hasHtml = args.html !== undefined;
17
+ const hasOptions = args.options !== undefined;
18
+ if (hasHtml && hasOptions) {
19
+ return errorResult(`${spec.tool} takes either html (one ${spec.unit}) or options (2..11 competing ${spec.unit}s), not both.`);
20
+ }
21
+ if (!hasHtml && !hasOptions) {
22
+ return errorResult(`${spec.tool} requires html (one ${spec.unit}) or options (2..11 competing ${spec.unit}s).`);
23
+ }
24
+ const multi = hasOptions;
25
+ let opts = [];
26
+ if (multi) {
27
+ if (!Array.isArray(args.options) || args.options.length < 2 || args.options.length > 11) {
28
+ return errorResult(`${spec.tool} requires 2..11 options (use html for a single ${spec.unit}; a review decision is capped at 12 options including the reserved one).`);
29
+ }
30
+ opts = args.options.map((option) => ({ label: (option.label ?? '').trim(), html: option.html ?? '' }));
31
+ if (opts.some((o) => !o.label)) {
32
+ return errorResult(`${spec.tool} requires a non-empty label for every option.`);
33
+ }
34
+ if (opts.some((o) => !o.html.trim())) {
35
+ return errorResult(`${spec.tool} requires non-empty html for every option (${spec.htmlSpec}).`);
36
+ }
37
+ const lowered = opts.map((o) => o.label.toLowerCase());
38
+ if (new Set(lowered).size !== lowered.length) {
39
+ return errorResult(`${spec.tool} requires distinct option labels (case-insensitive).`);
40
+ }
41
+ if (lowered.some((label) => RESERVED_OPTION_LABELS.includes(label))) {
42
+ return errorResult(`${spec.tool} option labels 'approve' and 'request changes' are reserved review outcomes — rename the option.`);
43
+ }
44
+ }
45
+ else if (!args.html || !args.html.trim()) {
46
+ return errorResult(`${spec.tool} requires non-empty html (${spec.htmlSpec}).`);
47
+ }
48
+ const title = args.title.trim();
49
+ // Sentence-initial form of the artifact noun, for the attribution warning.
50
+ const capitalNoun = spec.artifactNoun.charAt(0).toUpperCase() + spec.artifactNoun.slice(1);
51
+ // The review decision's question — the recovery message below quotes it
52
+ // LITERALLY, so both must come from this one string.
53
+ const question = `Review the ${spec.artifactNoun} and leave feedback.`;
54
+ // One entry per artifact to create, in presentation order (idx 0 = the
55
+ // primary). Multi tags EVERY entry with the option ↔ artifact mapping key;
56
+ // single stays untagged, exactly Phase 1.
57
+ const presentationId = multi ? randomUUID() : null;
58
+ const renders = multi
59
+ ? opts.map((option, idx) => ({
60
+ title: idx === 0 ? title : `${title} — ${option.label}`,
61
+ html: option.html,
62
+ purpose_key: `wireframe_option:${presentationId}:${idx}`,
63
+ }))
64
+ : [{ title, html: args.html, purpose_key: null }];
65
+ // The review decision's options: the approval outcome(s) first, 'request
66
+ // changes' always last (the DB caps decision options at 12, hence ≤11 labels).
67
+ const reviewOptions = multi ? [...opts.map((o) => o.label), 'request changes'] : ['approve', 'request changes'];
68
+ // The renders land on the session's task. Confirm it is live under the
69
+ // user's RLS first (mirrors create_artifact) so a task archived after
70
+ // begin_work gets a clean error instead of an opaque insert failure.
71
+ const task = await must(client.from('tasks').select('id').eq('id', workItemId).is('archived_at', null).maybeSingle());
72
+ if (!task)
73
+ return errorResult(`No task found for id "${workItemId}".`);
74
+ // 1. Each render as a normal artifact — the SAME insert create_artifact
75
+ // does, one row per render, in presentation order.
76
+ const created = [];
77
+ const attributionWarnings = [];
78
+ for (const render of renders) {
79
+ let row = null;
80
+ try {
81
+ row = await must(client
82
+ .from('artifacts')
83
+ .insert({
84
+ task_id: workItemId,
85
+ type: spec.artifactType,
86
+ format: 'html',
87
+ // 18d Slice 3, gate A: a mock or wireframe IS "a presented
88
+ // document" in story 2's list, and its body is agent-authored
89
+ // HTML the user opens fullscreen.
90
+ title: render.title,
91
+ ...(render.purpose_key ? { purpose_key: render.purpose_key } : {}),
92
+ content: render.html,
93
+ created_by: userId,
94
+ from_agent: null,
95
+ agent_run_id: null,
96
+ })
97
+ .select(ARTIFACT_COLUMNS)
98
+ .single());
99
+ if (!row)
100
+ throw new Error('Artifact insert returned no row.');
101
+ }
102
+ catch (err) {
103
+ // Nothing created yet → fatal and generic, exactly the Phase 1 path.
104
+ if (created.length === 0)
105
+ throw err;
106
+ // A later option insert failing orphans the already-created options:
107
+ // return them (ids included) with guidance the agent can actually act
108
+ // on. No tool can delete an artifact (update_artifact has no deleted_at
109
+ // and there is no delete tool), so "clean up" is not an instruction the
110
+ // agent can execute — removal is a manual web action, and the agent's
111
+ // job is to surface it and hold off re-presenting (a blind retry would
112
+ // duplicate the created options).
113
+ const createdIds = created.map((row) => row.id).join(', ');
114
+ const failure = {
115
+ artifacts: created,
116
+ error: `${spec.tool}: created ${created.length} of ${renders.length} option artifacts, then the insert for ` +
117
+ `'${render.title}' failed: ${err.message}. No review request was opened. There is no delete ` +
118
+ `tool: the created artifacts (${createdIds}) are inert until removed manually in the web. Report this ` +
119
+ `failure to the user with those ids, and do NOT call ${spec.tool} again (it would duplicate the ` +
120
+ 'created options) until the user confirms.',
121
+ };
122
+ if (attributionWarnings.length)
123
+ failure.attribution_warning = attributionWarnings.join(' ');
124
+ return { ...textResult(failure), isError: true };
125
+ }
126
+ created.push(row);
127
+ // Best-effort session attribution per artifact (mirrors create_artifact:
128
+ // the session's task matches by construction). The artifact already exists
129
+ // and is part of the real return value, so a failed attribution write must
130
+ // NOT fail the tool — it is surfaced in the returned text, never dropped.
131
+ //
132
+ /* 18c SLICE 6: `todo_id` records WHICH RUN produced this render, off the
133
+ connection rather than the session. See createArtifactHandler's
134
+ attribution block (ux.md § "Slice 6"). This tool refuses outright
135
+ without a session AND without a work item (requiresWorkItem above), so
136
+ its session is always work-item-anchored and its `todoId` is always
137
+ null: this is the exact case gap 13 names, and the connection is the
138
+ only place the request can be read from. */
139
+ try {
140
+ await attribute('artifact', row.id);
141
+ }
142
+ catch (err) {
143
+ attributionWarnings.push(`${capitalNoun} artifact created, but recording session attribution failed: ${err.message}`);
144
+ }
145
+ }
146
+ const primary = created[0];
147
+ // 2. The review request as a single_select decision — the SAME RPC
148
+ // ask_question uses; 'wireframe_review' is the sentinel category the web
149
+ // keys on (feature 09). The artifacts above already exist, so from here a
150
+ // failure must NOT be the generic fatal error: swallowing them would orphan
151
+ // them in the DB (plain cards with no "feedback requested"), leave the
152
+ // agent with no ids to recover with, and invite a blind retry that
153
+ // duplicates artifacts. So this step gets its OWN try/catch (the FIRST
154
+ // artifact-insert failure path above stays fatal and generic), and on
155
+ // failure the error result STILL carries the created artifact(s) plus a
156
+ // message telling the agent to retry the review step — via ask_question
157
+ // with related_artifact_id — or clean up, not re-present.
158
+ let decision;
159
+ try {
160
+ const decisionId = await askReview({ title, question, options: reviewOptions, artifactId: primary.id });
161
+ if (!decisionId)
162
+ throw new Error('Review request insert returned no decision id.');
163
+ // Best-effort attribution for the decision (mirrors ask_question exactly),
164
+ // 18c Slice 6 `todo_id` included.
165
+ try {
166
+ await attribute('decision', decisionId);
167
+ }
168
+ catch (err) {
169
+ attributionWarnings.push(`Review request created, but recording session attribution failed: ${err.message}`);
170
+ }
171
+ // Re-fetch the created decision with the SAME columns get_task returns, so
172
+ // the agent sees what it made and can later read the feedback back with
173
+ // get_task.
174
+ const fetched = await must(client.from('decisions').select(DECISION_COLUMNS).eq('id', decisionId).maybeSingle());
175
+ if (!fetched)
176
+ throw new Error('Created review decision could not be re-fetched.');
177
+ decision = fetched;
178
+ }
179
+ catch (err) {
180
+ // The recovery instruction must be LITERAL and complete: the web's review
181
+ // surface matches ONLY category === 'wireframe_review' (a sentinel that is
182
+ // deliberately not in any tool description, and that mocks reuse verbatim
183
+ // — there is no 'mock_review'), and the decision-consistency
184
+ // guard makes category immutable — so a plausible-but-wrong retry category
185
+ // would leave the review permanently stuck as a generic question. Spell out
186
+ // the exact ask_question call, with the real title, the full literal
187
+ // options array, and the primary artifact id. The multi failure payload
188
+ // carries ALL created artifacts (ids included).
189
+ const optionsLiteral = `[${reviewOptions.map((label) => `'${label}'`).join(', ')}]`;
190
+ const failure = {
191
+ ...(multi ? { artifacts: created } : { artifact: primary }),
192
+ error: `${spec.tool}: the ${spec.artifactNoun} artifact${multi ? 's were' : ' was'} created, but opening the review request failed: ` +
193
+ `${err.message}. Do NOT call ${spec.tool} again (it would duplicate the artifact${multi ? 's' : ''}) — ` +
194
+ `retry the review step with ask_question({ category: 'wireframe_review', context: '${title}', ` +
195
+ `question: '${question}', answer_mode: 'single_select', ` +
196
+ `options: ${optionsLiteral}, related_artifact_id: '${primary.id}' }) — the category string must be exactly ` +
197
+ "'wireframe_review' or the web will not show the review UI — or clean up.",
198
+ };
199
+ if (attributionWarnings.length)
200
+ failure.attribution_warning = attributionWarnings.join(' ');
201
+ return { ...textResult(failure), isError: true };
202
+ }
203
+ const payload = {
204
+ ...(multi ? { artifacts: created } : { artifact: primary }),
205
+ decision,
206
+ instruction: multi
207
+ ? `The user has been asked to review ${created.length} competing options. They send CUMULATIVE feedback ` +
208
+ "rounds — any number, any time, without waiting for you: poll get_task and read them from its `feedback` " +
209
+ 'list (oldest first). Every round is tagged with the presentation PRIMARY artifact (the first option) and ' +
210
+ 'its artifact_revision at send time — the version stamp of the presentation, NOT a per-option pointer. ' +
211
+ "Which OPTION a note concerns is named inline in the note's \"(…)\" option label within the round's body. " +
212
+ 'This decision is the approval gate only: it stays open across rounds and decides ONLY when the user ' +
213
+ 'approves — once its state is decided, selected_options[0] is the approved option label and answer_note ' +
214
+ 'may carry a final comment. To act on feedback, revise the option artifact each note names IN PLACE with ' +
215
+ 'update_artifact (its revision bumps automatically), then call resolve_feedback with the ids of the ' +
216
+ "rounds that revision actually addressed and the revised OPTION artifact's NEW revision number — only " +
217
+ 'rounds you genuinely acted on. Option revisions are their own number-space (not the primary v-tags): ' +
218
+ 'the review surface shows a multi round as resolved WITHOUT a version number, so name WHICH option you ' +
219
+ 'revised in any accompanying update, and never expect the primary revision to move when only options ' +
220
+ "changed. A round the user flips back to 'reopened' is open feedback again: address it on the next pass " +
221
+ `and re-resolve. Keep polling. Do NOT call ${spec.tool} ` +
222
+ 'again for iterations of this same design.'
223
+ : `The user has been asked to review the ${spec.artifactNoun}. They send CUMULATIVE feedback rounds — any number, any ` +
224
+ "time, without waiting for you: poll get_task and read them from its `feedback` list (oldest first), each " +
225
+ 'tagged with the artifact_revision it critiques so you know which version the notes are about. This ' +
226
+ 'decision is the approval gate only: it stays open across rounds and decides ONLY when the user approves ' +
227
+ "— once its state is decided, selected_options[0] is 'approve' and answer_note may carry a final comment. " +
228
+ `To act on feedback, revise this ${spec.artifactNoun} IN PLACE with update_artifact (its revision bumps ` +
229
+ 'automatically, so later rounds name the new version), then call resolve_feedback with the ids of the ' +
230
+ 'rounds that revision actually addressed and the NEW revision number — only rounds you genuinely acted ' +
231
+ "on. A round the user flips back to 'reopened' is open feedback again: address it on the next pass and " +
232
+ `re-resolve. Keep polling. Do NOT call ${spec.tool} ` +
233
+ 'again for iterations of this same design.',
234
+ };
235
+ if (attributionWarnings.length)
236
+ payload.attribution_warning = attributionWarnings.join(' ');
237
+ return textResult(payload);
238
+ }
239
+ catch (err) {
240
+ return errorResult(`${spec.tool} failed: ${err.message}`);
241
+ }
242
+ }