flowviant 0.116.0 → 0.124.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.
Files changed (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -25,9 +25,13 @@
25
25
  * Run: node --test bin/lib/agentPlan.test.mjs
26
26
  */
27
27
 
28
- // THE UPLINK SCRUBBER, as the DEFAULT for the two readers whose every string
29
- // goes to the server (2026-09-24, the audit). See `parseTurnResult`.
30
- import { scrub as envScrub } from './uplinkScrub.mjs';
28
+ // THE UPLINK SCRUBBER, as the DEFAULT for the three readers whose every string
29
+ // goes to the server (2026-09-24, the audit; the precheck joined 2026-10-10).
30
+ // See `parseTurnResult`. `scrubCut` is the scrub-then-cut order, spelled once.
31
+ import { scrub as envScrub, scrubCut } from './uplinkScrub.mjs';
32
+ import { groupDependencies } from './planDependencies.mjs';
33
+ import { AGENT_TASK_KINDS } from './agentTaskKinds.mjs';
34
+ import { blockedAnswer } from './agentAsk.mjs';
31
35
 
32
36
  /** The biggest a single proposal may be. Bounds on a machine, not a policy:
33
37
  * the server caps these again at its own boundary. */
@@ -68,7 +72,8 @@ const MAX_NOTE = 1000;
68
72
  * none of which a real answer comes near:
69
73
  *
70
74
  * · only the LAST `MAX_SCAN_CHARS` are scanned — every contract here puts the
71
- * object LAST, and the fenced block is still looked for in the whole text;
75
+ * object LAST, and the fenced block is still looked for in the whole text,
76
+ * by a LINEAR scan (`fencedBody`) since 2026-10-10;
72
77
  * · each `{`'s closing brace is found ONCE (see `closers`), so the scan is a
73
78
  * single right-to-left pass instead of one walk per brace;
74
79
  * · only spans that can open an object (`{` then a key or `}`) are handed to
@@ -90,10 +95,10 @@ const MAX_NESTED_PARSES = 200;
90
95
  const MAX_NESTED_CHARS = 4 * 1024 * 1024;
91
96
 
92
97
  export function candidateObjects(text) {
93
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
98
+ const fenced = fencedBody(text);
94
99
  const raw = text.length > MAX_SCAN_CHARS ? text.slice(text.length - MAX_SCAN_CHARS) : text;
95
100
  const bodies = [];
96
- if (fenced) bodies.push(fenced[1]);
101
+ if (fenced !== null) bodies.push(fenced);
97
102
  const close = closers(raw);
98
103
  let topEnd = -1;
99
104
  let nestedParses = 0;
@@ -125,6 +130,35 @@ export function candidateObjects(text) {
125
130
  return out;
126
131
  }
127
132
 
133
+ /**
134
+ * THE FIRST FENCED BLOCK'S BODY, or null — what
135
+ * `text.match(/```(?:json)?\s*([\s\S]*?)```/)[1]` answered, found in ONE
136
+ * forward pass (2026-10-10, the audit's F079).
137
+ *
138
+ * That regex ran over the WHOLE text — the only search the `MAX_SCAN_CHARS`
139
+ * bound above skipped — and it backtracks quadratically on an opening fence
140
+ * followed by a long whitespace run with no closing fence: the greedy `\s*` and
141
+ * the lazy body re-scan the run from every split. Measured: 80 KB took 0.6 s,
142
+ * 320 KB nine seconds, of synchronous CPU in which no poll, lease or other
143
+ * lane moved — and a turn's stdout is model-written and uncapped. Same
144
+ * answer, by `indexOf`: the first ``` anywhere, an optional `json`, the
145
+ * whitespace after it skipped, and the body runs to the next ```. If that
146
+ * opening has no closer, no later opening can have one either, so there is no
147
+ * fence. The search stays over the whole text, as the bound above promises.
148
+ */
149
+ const FENCE_SPACE = /\s*/y;
150
+ function fencedBody(text) {
151
+ const open = text.indexOf('```');
152
+ if (open < 0) return null;
153
+ let at = open + 3;
154
+ if (text.startsWith('json', at)) at += 4;
155
+ FENCE_SPACE.lastIndex = at;
156
+ FENCE_SPACE.exec(text);
157
+ at = FENCE_SPACE.lastIndex;
158
+ const close = text.indexOf('```', at);
159
+ return close < 0 ? null : text.slice(at, close);
160
+ }
161
+
128
162
  /**
129
163
  * For every `{`, the index of the `}` that closes it when read as the START of
130
164
  * an object (outside any string), or -1 — the answer `balanced` gives, for all
@@ -186,8 +220,9 @@ export function parseProposal(text, scrub = envScrub) {
186
220
  if (taskIds.length === 0) continue;
187
221
  agents.push({
188
222
  tempId: String(g.tempId || `a${i + 1}`).slice(0, 64),
189
- name: String(scrub(String(g.name ?? ''))).slice(0, MAX_NAME),
223
+ name: scrubCut(g.name, MAX_NAME, scrub),
190
224
  taskIds,
225
+ ...(typeof g.rationale === 'string' ? { rationale: scrubCut(g.rationale, 1000, scrub) } : {}),
191
226
  ...(Number.isFinite(g.pointsBudget) && g.pointsBudget > 0
192
227
  ? { pointsBudget: Math.min(Math.round(g.pointsBudget), 100_000) }
193
228
  : {}),
@@ -218,10 +253,25 @@ export function parseProposal(text, scrub = envScrub) {
218
253
  });
219
254
  }
220
255
  if (agents.length === 0) return null;
256
+ const ids = new Set(agents.flatMap(g => g.taskIds));
257
+ const lines = (v) => Array.isArray(v) ? v.filter(x => typeof x === 'string').slice(0, 20).map(x => scrubCut(x, 300, scrub)) : [];
258
+ if (parsed.evidence !== undefined && (!Array.isArray(parsed.evidence) || parsed.evidence.length > 1200 || parsed.evidence.some(e => !ids.has(e?.taskId)))) return null;
259
+ const evidence = parsed.evidence?.map(e => ({ taskId: e.taskId, inspectedFiles: lines(e.inspectedFiles), affectedFiles: lines(e.affectedFiles), prerequisiteOutputs: lines(e.prerequisiteOutputs), ...(typeof e.limits === 'string' ? { limits: scrubCut(e.limits, 1000, scrub) } : {}) }));
260
+ const collisions = Array.isArray(parsed.collisions) ? parsed.collisions.map(c => ({ taskIds: c?.taskIds, files: lines(c?.files), rationale: scrubCut(c?.rationale, 1000, scrub) })) : parsed.collisions;
261
+ const grouped = groupDependencies(agents, parsed.dependencies, collisions);
262
+ if (!grouped) return null;
263
+ const dependencies = parsed.dependencies?.map(e => ({ taskId: e.taskId, requires: e.requires }));
221
264
  return {
222
- agents: uniqueTempIds(agents),
265
+ agents: uniqueTempIds(grouped).map(g => ({ ...g, ...((evidence !== undefined || collisions !== undefined || parsed.dependencies !== undefined) ? { evidence: {
266
+ cards: (evidence ?? []).filter(e => g.taskIds.includes(e.taskId)),
267
+ dependencies: (dependencies ?? []).filter(e => g.taskIds.includes(e.taskId)),
268
+ collisions: (collisions ?? []).filter(c => c.taskIds.some(id => g.taskIds.includes(id))),
269
+ } } : {}) })),
270
+ ...(parsed.dependencies !== undefined ? { dependencies } : {}),
271
+ ...(collisions !== undefined ? { collisions } : {}),
272
+ ...(evidence !== undefined ? { evidence } : {}),
223
273
  ...(typeof parsed.note === 'string' && parsed.note.trim()
224
- ? { note: String(scrub(parsed.note)).slice(0, MAX_NOTE) }
274
+ ? { note: scrubCut(parsed.note, MAX_NOTE, scrub) }
225
275
  : {}),
226
276
  };
227
277
  }
@@ -284,7 +334,7 @@ function uniqueTempIds(list) {
284
334
  const MAX_PROGRESS = 8000;
285
335
  const progressOf = (v, scrub) => {
286
336
  const t = typeof v.progress === 'string' ? v.progress.trim() : '';
287
- return t ? { progress: String(scrub(t)).slice(0, MAX_PROGRESS) } : {};
337
+ return t ? { progress: scrubCut(t, MAX_PROGRESS, scrub) } : {};
288
338
  };
289
339
 
290
340
  /**
@@ -337,22 +387,32 @@ const progressOf = (v, scrub) => {
337
387
  * its prefix. So the scrub rides in, as `parsePrecheck`'s does, and runs over
338
388
  * each whole field first.
339
389
  *
340
- * DEFAULTED TO THE REAL SCRUBBER, unlike the precheck's identity default: the
341
- * production callers pass nothing, and a redaction that depends on each caller
342
- * remembering is the one that is missing the day it matters. Scrubbing twice
343
- * (the caller still scrubs `answer`) is harmless — a redaction marker contains
344
- * no secret to match.
390
+ * DEFAULTED TO THE REAL SCRUBBER, like `parseProposal` and (since 2026-10-10)
391
+ * `parsePrecheck`: the production callers pass nothing, and a redaction that
392
+ * depends on each caller remembering is the one that is missing the day it
393
+ * matters. Scrubbing twice (the caller still scrubs `answer`) is harmless — a
394
+ * redaction marker contains no secret to match.
345
395
  */
346
396
  export function parseTurnResult(text, scrub = envScrub) {
347
- const clean = (s, cap) => String(scrub(s)).slice(0, cap);
397
+ const clean = (s, cap) => scrubCut(s, cap, scrub);
348
398
  for (const v of candidateObjects(String(text ?? ''))) {
399
+ if (v.status === 'prerequisite') {
400
+ const r = v.prerequisite;
401
+ if (!r || typeof r.title !== 'string' || !r.title.trim() || typeof r.brief !== 'string' || !r.brief.trim()
402
+ || typeof r.taskKind !== 'string' || !Object.hasOwn(AGENT_TASK_KINDS, r.taskKind)) continue;
403
+ return { outcome: 'question', answer: typeof v.summary === 'string' ? clean(v.summary, 8000) : 'Requested a prerequisite card.',
404
+ ...progressOf(v, scrub), prerequisite: { title: clean(r.title.trim(), 300), brief: clean(r.brief.trim(), 2000), taskKind: r.taskKind } };
405
+ }
349
406
  if (v.status === 'blocked') {
350
407
  const question = typeof v.question === 'string' ? v.question.trim() : '';
351
408
  // A "blocked" with no question is not an answer anybody can act on — it
352
409
  // parks an agent with nothing to reply to. Treated as `nothing`, which
353
410
  // at least says truthfully that the machine went quiet.
354
411
  if (!question) continue;
355
- return { outcome: 'question', answer: clean(question, 8000), ...progressOf(v, scrub) };
412
+ // Its declared choices (2026-10-13) ride on as the one fence the page
413
+ // reads, built here (agentAsk.mjs); malformed ones leave the question
414
+ // as plain text.
415
+ return { outcome: 'question', answer: blockedAnswer(clean(question, 8000), v, scrub), ...progressOf(v, scrub) };
356
416
  }
357
417
  if (v.status === 'delivered') {
358
418
  return {
@@ -377,12 +437,43 @@ export function parseTurnResult(text, scrub = envScrub) {
377
437
  }
378
438
 
379
439
  /** The precheck's own bounds — mirrored at the server boundary, which caps
380
- * again. A note is one sentence of triage and `overall` is one paragraph; the
440
+ * again, and held to it by the release gate (check-app-parity.mjs rule 31).
441
+ * A note is one sentence of triage and `overall` a few short points; the
381
442
  * numbers are the ones SYSTEM_PRECHECK asks for, stated here so a model that
382
443
  * ignores them cannot make the row bigger than the surface can render. */
383
- const MAX_PRECHECK_CARDS = 60;
384
- const MAX_PRECHECK_NOTE = 400;
385
- const MAX_PRECHECK_OVERALL = 1200;
444
+ export const MAX_PRECHECK_CARDS = 60;
445
+ export const MAX_PRECHECK_NOTE = 400;
446
+ export const MAX_PRECHECK_OVERALL = 1200;
447
+ /** Per-criterion results (2026-10-10): the criteria kept per card, a
448
+ * criterion's own text, and its one line on why. */
449
+ export const MAX_PRECHECK_CRITERIA = 12;
450
+ export const MAX_PRECHECK_CRITERION = 200;
451
+ export const MAX_PRECHECK_LINE = 160;
452
+
453
+ /**
454
+ * ONE CARD'S CRITERIA, as the reader judged them (2026-10-10).
455
+ *
456
+ * PER ENTRY, the rule cards keep: a verdict word nobody listed, a missing or
457
+ * blank criterion, or a line that is not a string costs that row alone. A
458
+ * null line is the model saying it has none, so it reads as absent. The
459
+ * criterion's TEXT is kept, never an index: the card's Done-when list can be
460
+ * edited after the reading, and the row must show what was judged.
461
+ */
462
+ function precheckCriteria(raw, scrub) {
463
+ if (!Array.isArray(raw)) return [];
464
+ const out = [];
465
+ for (const k of raw) {
466
+ if (out.length >= MAX_PRECHECK_CRITERIA) break;
467
+ if (!k || typeof k !== 'object') continue;
468
+ if (typeof k.criterion !== 'string' || !k.criterion.trim()) continue;
469
+ if (k.verdict !== 'met' && k.verdict !== 'concern' && k.verdict !== 'unclear') continue;
470
+ if (k.line != null && typeof k.line !== 'string') continue;
471
+ const criterion = scrubCut(k.criterion.trim(), MAX_PRECHECK_CRITERION, scrub);
472
+ const line = typeof k.line === 'string' ? scrubCut(k.line.trim(), MAX_PRECHECK_LINE, scrub) : '';
473
+ out.push({ criterion, verdict: k.verdict, ...(line ? { line } : {}) });
474
+ }
475
+ return out;
476
+ }
386
477
 
387
478
  /**
388
479
  * READING THE AI PRE-REVIEW's ANSWER (2026-09-16).
@@ -404,6 +495,11 @@ const MAX_PRECHECK_OVERALL = 1200;
404
495
  * rather than coerced — `ok` is a claim ("I looked and found nothing"), and
405
496
  * guessing it from a word nobody listed would be the parser making that claim.
406
497
  *
498
+ * EACH CARD MAY CARRY ITS CRITERIA (2026-10-10), one entry per "done when"
499
+ * line, beside — never instead of — its verdict and note: a server from before
500
+ * criteria strips the field and still draws the one-row panel from those two.
501
+ * No criteria is no key, so that body is byte for byte what it was.
502
+ *
407
503
  * `scrub` RIDES IN, AND IT RUNS BEFORE EVERY CUT (review, 2026-09-17).
408
504
  *
409
505
  * The caller used to scrub afterwards — `envScrub(cd.note).slice(0, 400)` over
@@ -416,11 +512,15 @@ const MAX_PRECHECK_OVERALL = 1200;
416
512
  * line is the ordinary way to reach it. The fix is `toolEventOf`'s: the scrub
417
513
  * rides INTO the builder and runs over the whole field, before the cap.
418
514
  *
419
- * DEFAULTED TO IDENTITY so the parser stays testable on its own, and so a
420
- * caller that forgets loses redaction rather than the whole reading — but the
421
- * ONE production caller passes `envScrub`, and `work.test.mjs` pins the order.
515
+ * DEFAULTED TO THE REAL SCRUBBER (2026-10-10, the audit's F072), like its two
516
+ * neighbours. It used to default to identity "so a caller that forgets loses
517
+ * redaction rather than the whole reading" — the opposite stance from
518
+ * `parseTurnResult`'s for the same class of model-written text, and a
519
+ * redaction that depends on each caller remembering is the one that is missing
520
+ * the day it matters. Tests that want the raw reading pass identity; the one
521
+ * production caller passes `envScrub`, and `work.test.mjs` pins the order.
422
522
  */
423
- export function parsePrecheck(text, scrub = (s) => s) {
523
+ export function parsePrecheck(text, scrub = envScrub) {
424
524
  const parsed = candidateObjects(String(text ?? '')).find((v) => Array.isArray(v.cards));
425
525
  if (!parsed) return null;
426
526
 
@@ -434,14 +534,17 @@ export function parsePrecheck(text, scrub = (s) => s) {
434
534
  seen.add(taskId);
435
535
  // SCRUB, THEN CUT — see the docblock. The whole field is in hand here, so an
436
536
  // exact-value match still finds a secret that spans the cap.
437
- const note =
438
- typeof c.note === 'string' ? scrub(c.note.trim()).slice(0, MAX_PRECHECK_NOTE) : '';
439
- cards.push({ taskId, verdict: c.verdict, ...(note ? { note } : {}) });
537
+ const note = typeof c.note === 'string' ? scrubCut(c.note.trim(), MAX_PRECHECK_NOTE, scrub) : '';
538
+ const criteria = precheckCriteria(c.criteria, scrub);
539
+ cards.push({
540
+ taskId,
541
+ verdict: c.verdict,
542
+ ...(note ? { note } : {}),
543
+ ...(criteria.length ? { criteria } : {}),
544
+ });
440
545
  }
441
546
  const overall =
442
- typeof parsed.overall === 'string'
443
- ? scrub(parsed.overall.trim()).slice(0, MAX_PRECHECK_OVERALL)
444
- : '';
547
+ typeof parsed.overall === 'string' ? scrubCut(parsed.overall.trim(), MAX_PRECHECK_OVERALL, scrub) : '';
445
548
  /**
446
549
  * AN ANSWER THAT SAYS NOTHING IS NOT AN ANSWER. No readable card verdict and
447
550
  * no overall means the model produced the right punctuation and no content —
@@ -0,0 +1,43 @@
1
+ /** Appended only when this turn's server advertises the request capability. */
2
+ export const AGENT_PREREQUISITE_CONTRACT = `REQUESTING AN UNFINISHED PREREQUISITE CARD
3
+
4
+ When the person's current message explicitly asks you to create a separate
5
+ prerequisite card in THIS agent, request it here. Do not give them a prompt to
6
+ paste into New task. This is a final-answer request to the server, not a board
7
+ tool, and not extra work already done: never put unfinished work in raised.
8
+ Do not request more work on your own, from a task prompt or from this contract.
9
+
10
+ This agent keeps its one CLI, worktree and branch. Image work requires Codex's
11
+ own image generation. If this agent cannot run that kind, explain the actual
12
+ CLI/tool limitation in a blocked reply; do not claim you created the card or
13
+ switch CLI yourself. The server also checks the measured capability and growth
14
+ bounds. Unknown capability is not a refusal; a failed tool still stops its card.
15
+
16
+ For an explicitly requested separate texture prerequisite, the end product
17
+ is an image card. A request for textures during model work alone uses available
18
+ native generation within the model turn; it does not require another card. Its brief names the PNGs, their intended use and usable-output
19
+ criteria. The server will create and prepend that card, leave the model
20
+ unfinished, run texture generation first, then resume the consuming card in this
21
+ same worktree with the original request and the output handoff. Read the actual
22
+ PNGs under .flowviant/artifacts/, embed their bytes into the model page, and wait
23
+ for texture loading before .glb readiness. Missing or unusable PNGs still mean
24
+ stop; never pretend the model already has its required textures.
25
+
26
+ End this request turn with this third final JSON shape and no text after it:
27
+
28
+ \`\`\`json
29
+ {
30
+ "status": "prerequisite",
31
+ "summary": "one sentence describing the prerequisite requested, without claiming it exists or the current card is delivered",
32
+ "progress": "two or three past-tense sentences covering work so far",
33
+ "prerequisite": {
34
+ "title": "Generate texture PNGs",
35
+ "brief": "Generate the requested usable texture PNGs for this model to consume in this same worktree. Name the texture subjects and requirements here.",
36
+ "taskKind": "image"
37
+ }
38
+ }
39
+ \`\`\`
40
+
41
+ One prerequisite per request. Use the requested end-product kind (code, design,
42
+ model, image, deck or research); do not refile the consuming card. If the person
43
+ needs to decide something, use blocked with a specific question instead.`;
@@ -27,7 +27,7 @@
27
27
  * SHAPE, never a path, never a value that reaches argv unread.
28
28
  */
29
29
 
30
- import { isSafePathSegment } from './git.mjs';
30
+ import { isValidSha, sessionBranchRef, stripUrlUserinfo } from './git.mjs';
31
31
 
32
32
  /**
33
33
  * THE SHAPE A PUBLISHED REF MAY HAVE — the server's own `PUBLISH_REF_RE`,
@@ -52,19 +52,19 @@ export function isPublishRef(ref) {
52
52
  return typeof ref === 'string' && PUBLISH_REF_RE.test(ref);
53
53
  }
54
54
 
55
- /** The local branch an agent works on. One definition, so a push, a fetch and
56
- * the begun-guard's own `rev-parse` can never name three different branches. */
57
- export function agentBranchRef(place) {
58
- return isSafePathSegment(place) ? `refs/heads/session/${place}` : null;
59
- }
55
+ /** The local branch an agent works on. One definition — git.mjs's
56
+ * `sessionBranchRef`, the same name the cutter and the begun-guard read — so
57
+ * a push, a fetch and the guard's own `rev-parse` can never name three
58
+ * different branches. */
59
+ export const agentBranchRef = sessionBranchRef;
60
60
 
61
61
  /** A sha this machine MEASURED, in the one form `rev-parse` prints. The lease
62
62
  * below is built from nothing else: an unrecognised value is treated as no
63
- * observation at all rather than interpolated into argv. */
64
- const SHA_RE = /^[0-9a-f]{7,40}$/;
65
- export function isSha(v) {
66
- return typeof v === 'string' && SHA_RE.test(v);
67
- }
63
+ * observation at all rather than interpolated into argv. git.mjs's
64
+ * `isValidSha`, re-exported under the name this module's readers use — it
65
+ * was a verbatim copy of that regex, so a tightening could land in one and
66
+ * not the other (audit 2026-10-10, F749). */
67
+ export const isSha = isValidSha;
68
68
 
69
69
  /**
70
70
  * PUSH THIS AGENT'S BRANCH TO ITS PUBLISHED NAME.
@@ -153,8 +153,7 @@ export function publishDeleteArgs(ref) {
153
153
  */
154
154
  const DIAGNOSTIC_RE = /^(fatal:|error:|remote:|!)|\b(denied|rejected|refused)\b/i;
155
155
  export function publishErrorText(raw) {
156
- const lines = String(raw ?? '')
157
- .replace(/\/\/[^/@\s]+@/g, '//')
156
+ const lines = stripUrlUserinfo(raw)
158
157
  .split('\n')
159
158
  .map((l) => l.trim())
160
159
  .filter(Boolean);
@@ -37,9 +37,9 @@
37
37
  * which Claude and — since 0.115.0 — Codex declare (2026-09-29, measured:
38
38
  * runtimeCodex.mjs `codexDesignFence`). */
39
39
  const DESIGN_CLIS = 'mockup, 3D model and presentation cards run on Claude or Codex on this machine';
40
- /** The write-up kind runs on Claude alone: Codex cannot fence the web it needs
41
- * apart from the whole-box reads its sandbox allows (same place). */
42
- const CLAUDE_ONLY = 'write-up cards run on Claude on this machine';
40
+ /** Write-ups run on Claude or isolated Codex (0.117.0); the copied input view
41
+ * keeps host credentials outside the web-enabled tool fence. */
42
+ const RESEARCH_CLIS = 'write-up cards run on Claude or Codex on this machine';
43
43
  /** The image kind runs on Codex: the one CLI that declares its posture, because
44
44
  * the pictures come from Codex's own image tool on its own login. */
45
45
  const CODEX_ONLY = 'image cards run on Codex on this machine';
@@ -90,7 +90,7 @@ export const AGENT_TASK_KINDS = Object.freeze({
90
90
  posture: 'image',
91
91
  artifact: Object.freeze({
92
92
  match: /\.(png|webp)$/i,
93
- missing: 'the turn ended without writing a PNG or WebP image under .flowviant/artifacts/',
93
+ missing: 'the turn ended without writing a usable PNG or WebP image under .flowviant/artifacts/',
94
94
  }),
95
95
  offRuntime: () => CODEX_ONLY,
96
96
  }),
@@ -109,7 +109,7 @@ export const AGENT_TASK_KINDS = Object.freeze({
109
109
  match: /\.md$/i,
110
110
  missing: 'the turn ended without writing a write-up under .flowviant/artifacts/',
111
111
  }),
112
- offRuntime: () => CLAUDE_ONLY,
112
+ offRuntime: () => RESEARCH_CLIS,
113
113
  }),
114
114
  });
115
115
 
@@ -144,9 +144,6 @@ export const unknownAgentTaskKind = (v) =>
144
144
  ? null
145
145
  : String(v).replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 40);
146
146
 
147
- /** The table entry for a kind value, read the printing way (unknown → code). */
148
- export const agentTaskKind = (v) => AGENT_TASK_KINDS[agentTaskKindOf(v)];
149
-
150
147
  /** The postures only a card's kind selects (every kind's but code's), each
151
148
  * once: the 3D-model and presentation kinds reuse the design posture; the
152
149
  * image kind has its own. */
@@ -13,6 +13,7 @@ import { homedir } from 'node:os';
13
13
  import { join } from 'node:path';
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { procPids, scanProcs } from './procScan.mjs';
16
+ import { AGY_CONV_RE } from './conversationIds.mjs';
16
17
 
17
18
  // agy's store is nothing like Claude's: one SQLite db per conversation at
18
19
  // ~/.gemini/antigravity-cli/conversations/<uuid>.db (global, not cwd-keyed),
@@ -24,13 +25,12 @@ import { procPids, scanProcs } from './procScan.mjs';
24
25
  // the person at that keyboard, i.e. the one worth offering to adopt.
25
26
 
26
27
  const AGY_DIR = () => join(homedir(), '.gemini', 'antigravity-cli');
27
- const AGY_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
28
 
29
29
  /** Newest write to the conversation's store — the wal carries recent turns,
30
30
  * so its mtime (not the db's) is the real "last active" (measured: a resume
31
31
  * touched db+wal, and never the presence lock). 0 = no such conversation. */
32
32
  function agyLastWriteMs(id) {
33
- if (!AGY_UUID_RE.test(id)) return 0;
33
+ if (!AGY_CONV_RE.test(id)) return 0;
34
34
  let newest = 0;
35
35
  for (const suffix of ['.db', '.db-wal']) {
36
36
  try {
@@ -121,7 +121,7 @@ export function scanAgyConversations({ ours, mine, cutoff, processAlive = agyPro
121
121
  if (!map || typeof map !== 'object') return out;
122
122
  const processUp = processAlive();
123
123
  for (const [cwd, id] of Object.entries(map)) {
124
- if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
124
+ if (typeof id !== 'string' || !AGY_CONV_RE.test(id)) continue;
125
125
  // The operator's own agy TAB, whose conversation is registered against
126
126
  // the checkout exactly as a terminal one would be.
127
127
  if (mine.has(id)) continue;
@@ -6,7 +6,7 @@
6
6
  * and research contracts in prompts.mjs (which is past its size and keeps
7
7
  * those two). `prompts.mjs` imports these into `AGENT_CONTRACTS`; the kind
8
8
  * table (agentTaskKinds.mjs) runs both under the `design` posture — read the
9
- * repo, write only `.flowviant/artifacts/**`, no web, no shell beyond `ls` —
9
+ * repo, write only artifacts and scratch, with CLI-specific supporting tools —
10
10
  * so "change no repository file" is enforced, not asked.
11
11
  *
12
12
  * THE SAME BONES AS THE DESIGN CONTRACT, deliberately: its header, its rule 3
@@ -26,13 +26,70 @@
26
26
  */
27
27
  import { ARTIFACT_BINARY_MODEL_EXTS, ARTIFACT_BINARY_MODEL_MAX_BYTES, ARTIFACT_MAX_BYTES } from './artifactPolicy.mjs';
28
28
 
29
+ /**
30
+ * WHEN AN AGENT STOPS TO ASK, IT HELPS THE PERSON DECIDE (2026-10-03; its
31
+ * choices as fields of their own, 2026-10-13).
32
+ *
33
+ * "STOP AND ASK" used to be the whole of it, so a stuck agent asked one bare
34
+ * question with its choices inlined as "(1) … (2) …", and the Agent page had
35
+ * nothing to draw but "Write my own answer". Every card-agent contract (code
36
+ * and every file-handing kind) closes on this: read the card, the repo and
37
+ * the run first; ask ONE question per turn, its reason in `context` (body
38
+ * text beneath the one-line question, 2026-10-13); name the choices
39
+ * in `options` (and `multiSelect`) beside `question`, which the daemon turns
40
+ * into the one fence the page reads (`agentAsk.mjs`) — the agent never writes
41
+ * the fence itself, the owner's ruling; re-check after each answer; never
42
+ * re-ask. The label rules are SYSTEM_WORK rule 5's, because the page sends a
43
+ * label as the answer exactly as the Terminal's card does.
44
+ *
45
+ * THE SKIP IS THE APP'S. The agent page offers "Skip this question" on every
46
+ * standing question and sends a reply that BEGINS with those words (Flowviant
47
+ * `AGENT_SKIP_REPLY`) through the ordinary answer lane — so this keys on the
48
+ * prefix, never the whole sentence, and the app's wording can change without
49
+ * a daemon release. A skip is a stated provisional default where the work can
50
+ * go on, and never a substitute for an input only the person holds.
51
+ */
52
+ export const HELP_THEM_DECIDE = `WHEN YOU STOP TO ASK, HELP THEM DECIDE. The person may not know what the card
53
+ is missing, so you lead:
54
+ - Before asking, read the whole card (brief, done-when and its discussion),
55
+ the repository and this run, and check the card for every gap that would
56
+ change the work: the goal, who it is for, scope, the flows and behaviour,
57
+ edge cases, constraints, and how done is judged. Never ask what the card,
58
+ the repository or an earlier answer already settles.
59
+ - Ask ONE question per turn, the one that matters most. "question" is the
60
+ question itself — one sentence, read as a heading. In "context", say what
61
+ decision it settles and how it changes the work, with any findings or
62
+ evidence the person needs; it shows beneath the question as body text, in
63
+ Markdown, so lists and line breaks survive.
64
+ - When the answer is a pick between options you can name, put them in
65
+ "options" — two to eight — and the page shows each as a choice beside a
66
+ box for their own words. Never inline the choices in "question" as
67
+ "(1) … (2) …", and never write a fenced block yourself: the fields are the
68
+ whole of it. A label IS the answer, a few words a person would say out
69
+ loud, never a comma (several chosen labels arrive comma-joined, in the
70
+ order listed); a tradeoff goes in "description", one short sentence,
71
+ optional. Never add an "Other" or "Skip" option — the page offers its own
72
+ free-text answer and its own Skip. "multiSelect": true only for a genuine
73
+ check-several-of-these case ("which pages?"); otherwise false. For an open
74
+ question — one only their own words can answer — leave "options" out.
75
+ - When they answer, check the card again before you go on: ask the next
76
+ question that still changes the work, or carry on with the card once none
77
+ is left. Never ask again a question they answered or skipped.
78
+ - A message that begins "Skip this question" skips your last question. Where
79
+ the work can go on, take your best default, say it plainly as PROVISIONAL in
80
+ "progress" and in "summary", and carry on. Where it truly cannot go on
81
+ without something only they can provide — a credential, an account, an
82
+ outside decision — stop and name exactly that dependency; a skip never
83
+ supplies it.`;
84
+
29
85
  /**
30
86
  * Rule 3, rule 4 and the final JSON a file-handing contract ends with.
31
87
  * `ask` is rule 4's body after its number (the decision only a person can
32
88
  * make); `summary` is the delivered shape's summary gloss.
33
89
  */
34
90
  export const ARTIFACT_CONTRACT_TAIL = ({ ask, summary }) => `3. CHANGE NO REPOSITORY FILE AND COMMIT NOTHING. You can only write under
35
- .flowviant/artifacts/, and the daemon keeps that directory out of git.
91
+ .flowviant/artifacts/ and the turn’s granted scratch directory; deliver
92
+ only artifacts. The daemon keeps those directories out of git.
36
93
 
37
94
  4. ${ask}
38
95
 
@@ -52,12 +109,17 @@ or, if you are stopping to ask:
52
109
  {
53
110
  "status": "blocked",
54
111
  "progress": "two or three sentences on what you have done in this run SO FAR, across every card",
55
- "question": "the specific thing you need decided, in one or two sentences"
112
+ "context": "optional: what the decision settles and why, and any findings, in Markdown",
113
+ "question": "the specific thing you need decided, in one sentence",
114
+ "options": [{ "label": "an answer they could pick", "description": "optional: its tradeoff, in one short sentence" }],
115
+ "multiSelect": false
56
116
  }
57
117
  \`\`\`
58
118
 
59
119
  "progress" is REQUIRED on both shapes and REPLACES the previous one whole —
60
- past tense, plain sentences, no card ids.`;
120
+ past tense, plain sentences, no card ids.
121
+
122
+ ${HELP_THEM_DECIDE}`;
61
123
 
62
124
  /**
63
125
  * NOBODY IS AT THIS SCREEN (2026-09-29, the owner: Chrome "kept opening on my
@@ -87,13 +149,10 @@ export const LOOK_HEADLESS = `${NO_WINDOW} To look at your page, screenshot it
87
149
  * when the person presses Download .glb in Review (apps/web
88
150
  * `workbench/glbExport.ts`, the other half of the handshake below).
89
151
  *
90
- * WHY NOT A MODEL FILE ON DISK. This kind runs under the `design` posture:
91
- * Read, `ls`, and `Edit(.flowviant/artifacts/**)` — text only, no shell. A
92
- * .glb is binary; a .gltf's buffer is base64 floats nobody writes by hand.
93
- * The 0.105.0 contract asked for OBJ + .mtl + SVG textures instead, and a
94
- * katana came back as seven files, textures no game engine imports and a
95
- * generator script (2026-09-27). Built in code, drawn textures are canvases,
96
- * and the exporter embeds them in the .glb as PNGs.
152
+ * WHY A PAGE: one self-contained viewer gives every supported CLI the same
153
+ * delivered product. Supporting native textures are embedded into the page;
154
+ * GLTFExporter embeds their pixels into the person's one downloaded GLB.
155
+ * Available helpers do not change that contract into a pile of model files.
97
156
  *
98
157
  * THE HANDSHAKE IS SPELLED WORD FOR WORD because the web keys on it: the page
99
158
  * announces `glb-ready` once its model stands, and answers `export-glb` with
@@ -103,8 +162,8 @@ export const LOOK_HEADLESS = `${NO_WINDOW} To look at your page, screenshot it
103
162
  * it is just never offered a Download .glb.
104
163
  */
105
164
  export const SYSTEM_AGENT_MODEL = `You are the human's own Claude, working one 3D MODEL card in a git worktree of
106
- their repository. Nobody is watching this run. You have the repo and nothing
107
- else — no project tools, no board, no chat.
165
+ their repository. Nobody is watching this run. You have the repo and the tools
166
+ TOOLS ON THIS CARD names below — no board, no chat.
108
167
 
109
168
  A 3D MODEL CARD HANDS BACK A MODEL YOU CAN LOOK AT, NOT A CHANGE. Nothing you
110
169
  do here edits the product: the person turns the model around, downloads it as
@@ -121,7 +180,8 @@ WHAT TO DO:
121
180
  2. Write ONE file, .flowviant/artifacts/<kebab-name>/index.html — a short
122
181
  kebab-case name for what it is (for example
123
182
  .flowviant/artifacts/reading-chair/index.html). Nothing else goes in that
124
- folder: no model files, no textures, no scripts, no notes. The page BUILDS
183
+ folder: no model files, no scripts, no notes. Generated source textures
184
+ may be kept elsewhere under .flowviant/artifacts/; the page is self-contained. The page BUILDS
125
185
  THE MODEL IN CODE and shows it:
126
186
  - three.js as ES modules from cdn.jsdelivr.net/npm, through an import map
127
187
  for "three" and "three/addons/"; orbit controls, lights and a neutral
@@ -129,8 +189,18 @@ WHAT TO DO:
129
189
  - the model is ONE THREE.Group named for what it is, built from three.js
130
190
  geometry (lathe, extrude, tube, box and the rest, one mesh per part),
131
191
  with MeshStandardMaterial only. Colour, roughness and metalness are
132
- material values, or textures you DRAW ON A CANVAS in the page
133
- (THREE.CanvasTexture, 1024px or less) — never an image file.
192
+ material values, canvas textures, or generated image textures. When the
193
+ person requests generated textures, USE native image generation if it is
194
+ available on this turn; do not require another card or change the kind.
195
+ Embed each texture as a data: URI in this page (no local or remote image
196
+ URLs), load it with THREE.TextureLoader and wait for all textures before
197
+ announcing glb-ready. Keep maps at 1024px or less and set colour maps to
198
+ THREE.SRGBColorSpace. Assign maps to the model's MeshStandardMaterial so
199
+ GLTFExporter embeds the texture pixels in the downloaded .glb. Await
200
+ image decoding before export; never announce readiness for unloaded maps.
201
+ If the native tool is missing or fails, explain its actual limitation.
202
+ A separately billed CLI/API fallback requires the person's explicit
203
+ choice before using it; never ask for OPENAI_API_KEY as the default path.
134
204
  - the ground, a grid, the lights, a figure for scale and every other
135
205
  helper stay OUTSIDE that group: they are the viewer's, not the model.
136
206
  - no download or save button of your own: the page cannot download
@@ -168,8 +238,8 @@ ${ARTIFACT_CONTRACT_TAIL({
168
238
  * Print, never a converter this machine would have to run.
169
239
  */
170
240
  export const SYSTEM_AGENT_DECK = `You are the human's own Claude, working one PRESENTATION card in a git worktree
171
- of their repository. Nobody is watching this run. You have the repo and nothing
172
- else — no project tools, no board, no chat.
241
+ of their repository. Nobody is watching this run. You have the repo and the tools
242
+ TOOLS ON THIS CARD names below — no board, no chat.
173
243
 
174
244
  A PRESENTATION CARD HANDS BACK A DECK, NOT A CHANGE. Nothing you do here edits
175
245
  the product: the person pages through it, and decides.
@@ -232,8 +302,9 @@ export const IMAGE_CAP_WORDS = `${Math.floor(imageCapBytes / (1024 * 1024))} MB`
232
302
  * to the person, quoting the tool's words, never a substitute.
233
303
  */
234
304
  export const SYSTEM_AGENT_IMAGE = `You are the human's own Codex, working one IMAGE card in a git worktree of
235
- their repository. Nobody is watching this run. You have the repo and your image
236
- generation tool, and nothing else — no project tools, no board, no chat, no web.
305
+ their repository. Nobody is watching this run. You have the repo, your image
306
+ generation tool and the tools TOOLS ON THIS CARD names below — no board, no
307
+ chat, no web.
237
308
 
238
309
  AN IMAGE CARD HANDS BACK PICTURES, NOT A CHANGE. Nothing you do here edits the
239
310
  product: the person looks at the images, downloads the ones they want, and