agent-yes 1.228.0 → 1.229.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 (45) hide show
  1. package/dist/{JsonlStore-Bm5Nj6v_.js → JsonlStore-CWOA3aDH.js} +1 -1
  2. package/dist/{SUPPORTED_CLIS-C5d64Xqj.js → SUPPORTED_CLIS-CvLtp6LS.js} +2 -2
  3. package/dist/SUPPORTED_CLIS-DJEnqsp2.js +11 -0
  4. package/dist/{agentShare-CpglhRYb.js → agentShare-CQwvWWfd.js} +4 -4
  5. package/dist/{callback-B3N2VJ1i.js → callback-BFZDARQq.js} +4 -4
  6. package/dist/{callback-CCEQtwXX.js → callback-BN-tH9dB.js} +3 -3
  7. package/dist/cli.js +9 -9
  8. package/dist/{cwdConflictWarn-CJZRp4S0.js → cwdConflictWarn-CKUsQ5_o.js} +2 -2
  9. package/dist/{globalPidIndex-CoNr7tS8.js → globalPidIndex-Ca4sTr1b.js} +1 -1
  10. package/dist/index.js +8 -8
  11. package/dist/{messageLog-Bb_Ie3Ck.js → messageLog-zVGWLX7w.js} +1 -1
  12. package/dist/{notifyDaemon-CfQN5blR.js → notifyDaemon-CENKHs1M.js} +4 -4
  13. package/dist/pidStore-Cp3HekUh.js +6 -0
  14. package/dist/{pidStore-DCqpU_bI.js → pidStore-Hjba3K6Q.js} +3 -3
  15. package/dist/{reaper-BNKvujYy.js → reaper-iQXyufgp.js} +1 -1
  16. package/dist/{reaper-BBc2AIKA.js → reaper-pJpMYKmj.js} +1 -1
  17. package/dist/{runningLock-CNMl13dC.js → runningLock-BFk3Mo8K.js} +1 -1
  18. package/dist/{rustBinary-B8IcqSw1.js → rustBinary-BjApGser.js} +2 -2
  19. package/dist/{schedule-B-RjgOOR.js → schedule-D0O3sb5A.js} +9 -9
  20. package/dist/{serve-DwF03sgB.js → serve-m-WChRrO.js} +67 -21
  21. package/dist/{setup-cZFxfS89.js → setup-fuwC5wlF.js} +2 -2
  22. package/dist/spawnGate-5jC6eiqk.js +5 -0
  23. package/dist/{spawnGate-CeFYH-Ib.js → spawnGate-CMlGqVN4.js} +2 -2
  24. package/dist/{subcommands-DSyCT0vl.js → subcommands-DzJbnYAx.js} +12 -12
  25. package/dist/{subcommands-UseeaeYM.js → subcommands-lwgJxJex.js} +3 -3
  26. package/dist/{todoAutomation-Cwnh4aWo.js → todoAutomation-x6guaQXc.js} +115 -6
  27. package/dist/{todoCli-6v1CYdxN.js → todoCli-Cjhx3DqQ.js} +38 -6
  28. package/dist/{tray-DBt8X-Qj.js → tray-C8VfnS7s.js} +2 -2
  29. package/dist/{ts-Cl3CmLH9.js → ts-oYrTk4Z_.js} +103 -20
  30. package/dist/{versionChecker-Dhjyjxs_.js → versionChecker-CgCXc8CJ.js} +2 -2
  31. package/dist/{ws-Dw2fGxTI.js → ws-D5q3R1kY.js} +4 -4
  32. package/package.json +1 -1
  33. package/ts/askApi.spec.ts +358 -0
  34. package/ts/askApi.ts +255 -0
  35. package/ts/index.ts +2 -0
  36. package/ts/serve.ts +131 -0
  37. package/ts/todoBlock.spec.ts +10 -0
  38. package/ts/todoBlock.ts +14 -2
  39. package/ts/todoCli.spec.ts +101 -0
  40. package/ts/todoCli.ts +69 -1
  41. package/ts/todoStore.spec.ts +126 -0
  42. package/ts/todoStore.ts +130 -2
  43. package/dist/SUPPORTED_CLIS-Bfresz1n.js +0 -11
  44. package/dist/pidStore-CatWvn5W.js +0 -6
  45. package/dist/spawnGate-CIC7b7nm.js +0 -5
package/ts/serve.ts CHANGED
@@ -37,11 +37,13 @@ import {
37
37
  writeToIpc,
38
38
  type CommonOpts,
39
39
  } from "./subcommands.ts";
40
+ import { answerAsk, listAsks } from "./askApi.ts";
40
41
  import { TYPING_BADGE } from "./badges.ts";
41
42
  import { isCallbackRevoked, loadCallbackSecretReadOnly } from "./callback.ts";
42
43
  import { CLAUDE_SESSION_PIN_ENV } from "./sessionEnv.ts";
43
44
  import { MAX_CALLBACK_MSG_BYTES, frameVisitorMessage, verifyCapability } from "./callbackCore.ts";
44
45
  import { isTerminalReply } from "./terminalReply.ts";
46
+ import { removeControlCharacters } from "./removeControlCharacters.ts";
45
47
  import { parseStatusText } from "./statusText.ts";
46
48
  import { ensureNodeRuntime, liveEnv } from "./nodeRuntime.ts";
47
49
  import { acquireWebrtcHostLock, type ServeLockOwner } from "./serveLock.ts";
@@ -2053,6 +2055,135 @@ export async function cmdServe(rest: string[]): Promise<number> {
2053
2055
  }
2054
2056
  }
2055
2057
 
2058
+ // Both /api/asks routes below share this: "every project" is derived the
2059
+ // same way `ay ls` aggregates across projects — the distinct `cwd`s in
2060
+ // the live-agent registry — since there is no separate "registered
2061
+ // projects" list to read; a project this host has never run an agent in
2062
+ // has nothing to aggregate anyway.
2063
+ const liveProjectRoots = async (): Promise<string[]> => {
2064
+ const records = await listRecords(undefined, defaultOpts({ all: true }));
2065
+ return [...new Set(records.map((r) => r.cwd).filter(Boolean))];
2066
+ };
2067
+
2068
+ // GET /api/asks — the `/ask` decision panel (A7): every `blocked-by-human`
2069
+ // task across every project this host's agents have registered, one
2070
+ // human-facing list.
2071
+ if (req.method === "GET" && p === "/api/asks") {
2072
+ try {
2073
+ const asks = await listAsks(await liveProjectRoots());
2074
+ return Response.json(asks);
2075
+ } catch (e) {
2076
+ return new Response((e as Error).message, { status: 500 });
2077
+ }
2078
+ }
2079
+
2080
+ // POST /api/asks/answer body {projectRoot, taskId, choice?, acknowledged?}
2081
+ // — the closed loop's single request: `answerAsk()` clears the block and
2082
+ // (for human/decision-kind tasks) advances the transition atomically, so
2083
+ // there is no intermediate state visible to a second request. As a
2084
+ // best-effort follow-up (never blocks the response on it), if the task's
2085
+ // owner resolves to a currently-live agent IN THIS SAME PROJECT, the
2086
+ // answer is written straight into that agent's terminal via the exact
2087
+ // same FIFO mechanism `/api/send` already uses — the real-time half of
2088
+ // the closed loop, for whichever agent happens to be alive and watching
2089
+ // right now.
2090
+ if (req.method === "POST" && p === "/api/asks/answer") {
2091
+ let raw: unknown;
2092
+ try {
2093
+ raw = await req.json();
2094
+ } catch {
2095
+ return new Response("invalid JSON body", { status: 400 });
2096
+ }
2097
+ // Type-validate every field explicitly — an untyped JS client (or a
2098
+ // bug) can send a non-string projectRoot/taskId or a truthy
2099
+ // non-boolean `acknowledged`, which would otherwise surface as an
2100
+ // opaque 500 deep inside answerAsk() instead of a clean 400
2101
+ // (codex-review nitpick).
2102
+ if (typeof raw !== "object" || raw === null) {
2103
+ return new Response("body must be a JSON object", { status: 400 });
2104
+ }
2105
+ const body = raw as Record<string, unknown>;
2106
+ const { projectRoot, taskId, choice, acknowledged, expectedBlockRev } = body;
2107
+ if (typeof projectRoot !== "string" || !projectRoot) {
2108
+ return new Response("projectRoot must be a non-empty string", { status: 400 });
2109
+ }
2110
+ if (typeof taskId !== "string" || !taskId) {
2111
+ return new Response("taskId must be a non-empty string", { status: 400 });
2112
+ }
2113
+ if (choice !== undefined && typeof choice !== "string") {
2114
+ return new Response("choice must be a string", { status: 400 });
2115
+ }
2116
+ if (acknowledged !== undefined && typeof acknowledged !== "boolean") {
2117
+ return new Response("acknowledged must be a boolean", { status: 400 });
2118
+ }
2119
+ if (expectedBlockRev !== undefined && !Number.isSafeInteger(expectedBlockRev)) {
2120
+ // A boundary route must never forward NaN/Infinity/fractional values
2121
+ // into the store's blockRev comparison — those can never legitimately
2122
+ // equal a real blockRev (always a safe, non-negative integer), so
2123
+ // letting them through just produces a confusing rejection deep
2124
+ // inside answerAsk() instead of a clean 400 here (codex-review
2125
+ // Important).
2126
+ return new Response("expectedBlockRev must be a safe integer", { status: 400 });
2127
+ }
2128
+ // Scoped to the SAME live-agent-derived project set GET/api/asks
2129
+ // aggregates over — not merely "a store happens to exist here"
2130
+ // (codex-review Important: hasTodoStore() alone still let a caller
2131
+ // target any readable/writable ay todo store path on the host, not
2132
+ // just ones actually surfaced by this API).
2133
+ const registeredRoots = await liveProjectRoots();
2134
+ if (!registeredRoots.includes(projectRoot)) {
2135
+ return new Response(`no registered project at ${projectRoot}`, { status: 404 });
2136
+ }
2137
+ let result: Awaited<ReturnType<typeof answerAsk>>;
2138
+ try {
2139
+ result = await answerAsk(projectRoot, taskId, { choice, acknowledged, expectedBlockRev });
2140
+ } catch (e) {
2141
+ return new Response((e as Error).message, { status: 400 });
2142
+ }
2143
+ const { record: rec, answerText } = result;
2144
+ // Best-effort, fire-and-forget: the task is already correctly updated
2145
+ // above regardless of whether this succeeds. Deliberately NOT awaited —
2146
+ // a stalled FIFO write must never hang the response, which is the
2147
+ // whole point of "best-effort" (codex-review Important: an earlier
2148
+ // version awaited this, so a stuck agent could hang answerAsk's caller
2149
+ // even though the todo state had already been updated). This is a real,
2150
+ // accepted limitation, not a bug: if owner resolution or the FIFO
2151
+ // write fails, there is no retry queue here — the durable, correct
2152
+ // outcome is that the task's stored state already reflects the
2153
+ // answer (see answerAsk's own doc comment), and the owning agent will
2154
+ // pick it up on its own next normal check (`ay todo get`, a reconcile
2155
+ // pass, etc.) rather than through a guaranteed real-time push. A
2156
+ // persistent notification-retry mechanism would be new scope well
2157
+ // beyond this route.
2158
+ if (rec.owner) {
2159
+ // Scoped to THIS project's cwd — an unscoped search could match a
2160
+ // same-named owner in a DIFFERENT project and write the answer into
2161
+ // the wrong agent's terminal (codex-review Important).
2162
+ resolveOne(rec.owner, defaultOpts({ all: true, cwdScope: projectRoot }))
2163
+ .then((owner) => {
2164
+ if (!owner.fifo_file) return;
2165
+ // `answerText` comes from answerAsk()'s own validated result, NOT
2166
+ // re-derived from the raw request body here — the earlier
2167
+ // version recomputed `choice ?? "acknowledged"` independently,
2168
+ // which had the SAME unvalidated-choice gap answerAsk() itself
2169
+ // just fixed (codex-review Important). Also strip terminal
2170
+ // control sequences AND collapse newlines: even a genuinely
2171
+ // validated choice option is free text set at ask-creation time
2172
+ // and could still contain control bytes or embedded newlines
2173
+ // that inject extra "typed" lines/escape sequences into the
2174
+ // live agent's terminal via this raw IPC write (codex-review
2175
+ // Important).
2176
+ const safeAnswerText = removeControlCharacters(answerText).replace(/[\r\n]+/g, " ");
2177
+ return writeToIpc(owner.fifo_file, `[/ask] ${taskId} answered: ${safeAnswerText}\n`);
2178
+ })
2179
+ .catch(() => {
2180
+ // owner not currently live / not resolvable / FIFO write failed —
2181
+ // best-effort only, nothing to report back to this request.
2182
+ });
2183
+ }
2184
+ return Response.json(rec);
2185
+ }
2186
+
2056
2187
  // GET /api/search?q= — chat-history search across every agent's log.
2057
2188
  // v1 ("simple" scope, by design): no sidecar index yet — each agent's raw
2058
2189
  // log TAIL (SEARCH_TAIL_BYTES) is rendered through the same headless-xterm
@@ -30,4 +30,14 @@ describe("todoBlock", () => {
30
30
  expect(rendered[3]).toContain("xyz789");
31
31
  expect(new Set(rendered).size).toBe(4); // all distinct
32
32
  });
33
+
34
+ it("describeBlock includes the actionLink when a blocked-by-human ask is action-shaped (A7)", () => {
35
+ const rendered = describeBlock({
36
+ type: "blocked-by-human",
37
+ who: "taku",
38
+ actionLink: "https://example/oauth",
39
+ });
40
+ expect(rendered).toContain("taku");
41
+ expect(rendered).toContain("https://example/oauth");
42
+ });
33
43
  });
package/ts/todoBlock.ts CHANGED
@@ -23,7 +23,19 @@
23
23
 
24
24
  export type TodoBlock =
25
25
  | { type: "blocked-by-task"; taskId: string }
26
- | { type: "blocked-by-human"; who: string; question?: string; options?: string[] }
26
+ // `options` (choice-shape, A7): the human picks one via /ask's buttons.
27
+ // `actionLink` (action-shape, A7): the human must personally complete
28
+ // something at that URL (e.g. an OAuth/CAPTCHA flow) — /ask renders an
29
+ // "open link, then confirm" button instead of a choice list. At most one
30
+ // of the two is expected to be set; neither is required (a bare question
31
+ // with no options/actionLink just needs an acknowledgement).
32
+ | {
33
+ type: "blocked-by-human";
34
+ who: string;
35
+ question?: string;
36
+ options?: string[];
37
+ actionLink?: string;
38
+ }
27
39
  | { type: "blocked-by-external"; signal: string; checkFn?: string }
28
40
  | { type: "waiting-on-agent"; agentId: string };
29
41
 
@@ -52,7 +64,7 @@ export function describeBlock(block: TodoBlock): string {
52
64
  case "blocked-by-task":
53
65
  return `blocked by task ${block.taskId}`;
54
66
  case "blocked-by-human":
55
- return `waiting on ${block.who}${block.question ? `: ${block.question}` : ""}`;
67
+ return `waiting on ${block.who}${block.question ? `: ${block.question}` : ""}${block.actionLink ? ` (action: ${block.actionLink})` : ""}`;
56
68
  case "blocked-by-external":
57
69
  return `waiting on external signal: ${block.signal}`;
58
70
  case "waiting-on-agent":
@@ -221,6 +221,107 @@ describe("ay todo CLI", () => {
221
221
  await expect(run("block", "T1")).rejects.toThrow(/usage: ay todo block/);
222
222
  });
223
223
 
224
+ it("block --type blocked-by-human --action-link produces an action-shaped ask (A7)", async () => {
225
+ await run("new", "e", "--kind", "code");
226
+ const action = await run(
227
+ "block",
228
+ "T1",
229
+ "--type",
230
+ "blocked-by-human",
231
+ "--who",
232
+ "taku",
233
+ "--action-link",
234
+ "https://example/oauth",
235
+ );
236
+ expect(action.out).toContain("taku");
237
+ expect(action.out).toContain("https://example/oauth");
238
+ const got = await run("get", "T1", "--format", "json");
239
+ expect(JSON.parse(got.out).block).toEqual({
240
+ type: "blocked-by-human",
241
+ who: "taku",
242
+ actionLink: "https://example/oauth",
243
+ });
244
+ });
245
+
246
+ it("block --type blocked-by-human refuses --options together with --action-link (mutually exclusive ask shapes, codex-review round-9 Important)", async () => {
247
+ await run("new", "f", "--kind", "code");
248
+ await expect(
249
+ run(
250
+ "block",
251
+ "T1",
252
+ "--type",
253
+ "blocked-by-human",
254
+ "--who",
255
+ "taku",
256
+ "--options",
257
+ "a",
258
+ "b",
259
+ "--action-link",
260
+ "https://example/oauth",
261
+ ),
262
+ ).rejects.toThrow(/mutually exclusive/);
263
+ });
264
+
265
+ it("block --action-link refuses a non-http(s) URL scheme (javascript:/data: would be an XSS vector when /ask renders it into an <a href>, codex-review round-10 Important)", async () => {
266
+ await run("new", "g", "--kind", "code");
267
+ await expect(
268
+ run(
269
+ "block",
270
+ "T1",
271
+ "--type",
272
+ "blocked-by-human",
273
+ "--who",
274
+ "taku",
275
+ "--action-link",
276
+ "javascript:alert(1)",
277
+ ),
278
+ ).rejects.toThrow(/must be a valid http/);
279
+ await expect(
280
+ run(
281
+ "block",
282
+ "T1",
283
+ "--type",
284
+ "blocked-by-human",
285
+ "--who",
286
+ "taku",
287
+ "--action-link",
288
+ "data:text/html,x",
289
+ ),
290
+ ).rejects.toThrow(/must be a valid http/);
291
+ const ok = await run(
292
+ "block",
293
+ "T1",
294
+ "--type",
295
+ "blocked-by-human",
296
+ "--who",
297
+ "taku",
298
+ "--action-link",
299
+ "https://example/oauth",
300
+ );
301
+ expect(ok.out).toContain("https://example/oauth");
302
+ });
303
+
304
+ it("block --action-link stores the URL PARSER's normalized href, not the raw input — a value with embedded control characters that WHATWG URL parsing tolerates/strips must never persist those bytes verbatim, since describeBlock() later renders this as terminal/log text (codex-review round-16 Important)", async () => {
305
+ await run("new", "h", "--kind", "code");
306
+ const withTab = await run(
307
+ "block",
308
+ "T1",
309
+ "--type",
310
+ "blocked-by-human",
311
+ "--who",
312
+ "taku",
313
+ "--action-link",
314
+ "https://example/oauth\tinjected",
315
+ );
316
+ const got = JSON.parse((await run("get", "T1", "--format", "json")).out);
317
+ // whatever the parser accepted, it must be exactly what's stored — never
318
+ // the raw argv string re-used after validation passed on a DIFFERENT
319
+ // (parser-normalized) value
320
+ expect(got.block.actionLink).toBe(new URL("https://example/oauth\tinjected").href);
321
+ expect(got.block.actionLink).not.toContain("\t");
322
+ expect(withTab.out).not.toContain("\t");
323
+ });
324
+
224
325
  it("dep add/rm, tree, and digest render real output and surface cycles as a clean error", async () => {
225
326
  await run("new", "a", "--kind", "code");
226
327
  await run("new", "b", "--kind", "code");
package/ts/todoCli.ts CHANGED
@@ -45,11 +45,33 @@ import { describeBlock, type TodoBlock } from "./todoBlock.ts";
45
45
  import { renderDigest, renderTree, buildTreeJSON, unblockedTasks } from "./todoDigest.ts";
46
46
  import { reconcileTodos, type LiveAgent } from "./todoAutomation.ts";
47
47
  import { readGlobalPids } from "./globalPidIndex.ts";
48
+ import { removeControlCharacters } from "./removeControlCharacters.ts";
48
49
 
49
50
  function fail(message: string): never {
50
51
  throw new Error(message);
51
52
  }
52
53
 
54
+ /**
55
+ * Same check ask.html's own render-time guard uses — a real URL parse, not
56
+ * just a scheme-prefix regex, which could still let a malformed string
57
+ * through. Returns the parser's own normalized `.href` (not the raw input)
58
+ * on success, `null` otherwise: WHATWG URL parsing tolerates/strips some
59
+ * control characters (e.g. embedded tabs/newlines) from the input while
60
+ * still returning a valid http(s) URL, so validating against `s` but then
61
+ * STORING `s` verbatim could still persist those raw control characters
62
+ * into the store — later rendered as terminal/log text by `describeBlock()`
63
+ * (codex-review Important). Storing `.href` instead guarantees the store
64
+ * only ever holds what the parser actually validated.
65
+ */
66
+ function normalizeHttpUrl(s: string): string | null {
67
+ try {
68
+ const u = new URL(s);
69
+ return u.protocol === "http:" || u.protocol === "https:" ? u.href : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
53
75
  function parseKind(raw: string | undefined): LifecycleKind {
54
76
  if (!raw) fail(`--kind is required (one of: ${Object.keys(LIFECYCLES).join(", ")})`);
55
77
  if (!isKnownKind(raw))
@@ -286,6 +308,7 @@ const blockCmd: CommandModule<
286
308
  who: string | undefined;
287
309
  question: string | undefined;
288
310
  options: string[] | undefined;
311
+ "action-link": string | undefined;
289
312
  signal: string | undefined;
290
313
  agent: string | undefined;
291
314
  }
@@ -300,7 +323,16 @@ const blockCmd: CommandModule<
300
323
  .option("task", { type: "string", describe: "required for --type blocked-by-task" })
301
324
  .option("who", { type: "string", describe: "required for --type blocked-by-human" })
302
325
  .option("question", { type: "string" })
303
- .option("options", { type: "string", array: true })
326
+ .option("options", {
327
+ type: "string",
328
+ array: true,
329
+ describe: "choice-shape ask (/ask renders buttons)",
330
+ })
331
+ .option("action-link", {
332
+ type: "string",
333
+ describe:
334
+ "action-shape ask (/ask renders an 'open link, then confirm' button) — e.g. an OAuth/CAPTCHA URL",
335
+ })
304
336
  .option("signal", { type: "string", describe: "required for --type blocked-by-external" })
305
337
  .option("agent", { type: "string", describe: "required for --type waiting-on-agent" }),
306
338
  handler: async (argv) => {
@@ -317,11 +349,47 @@ const blockCmd: CommandModule<
317
349
  break;
318
350
  case "blocked-by-human":
319
351
  if (!argv.who) fail("--who <name> is required for --type blocked-by-human");
352
+ // choice-shape (--options) and action-shape (--action-link) are
353
+ // mutually exclusive — reject the combination outright here rather
354
+ // than let it depend on askApi.ts's own tie-breaking precedence
355
+ // (which, as of codex-review round-9, checks actionLink first in
356
+ // BOTH listAsksForProject() and answerAsk(), so the two now agree
357
+ // with each other even if this guard were bypassed by a direct
358
+ // library caller — but a block should simply never be created with
359
+ // both set in the first place).
360
+ if (argv.options?.length && argv["action-link"]) {
361
+ fail(
362
+ "--options and --action-link are mutually exclusive (choice-shape vs action-shape ask)",
363
+ );
364
+ }
365
+ let normalizedActionLink: string | undefined;
366
+ if (argv["action-link"]) {
367
+ const normalized = normalizeHttpUrl(argv["action-link"]);
368
+ if (!normalized) {
369
+ // The /ask page renders this straight into an <a href>. HTML-
370
+ // escaping the text does NOT block dangerous URL schemes
371
+ // (`javascript:`, `data:`) — only the scheme itself does. Reject
372
+ // at write time so a non-http(s) link can never reach the store
373
+ // at all (codex-review Important). Uses the same `URL` parser
374
+ // ask.html's own render-time check uses (codex-review nitpick: a
375
+ // regex prefix match alone would still store a malformed string
376
+ // like "https:/notreallyaurl" that happens to match the prefix).
377
+ // The rejected input is echoed for the operator's benefit, but
378
+ // never verbatim — an invalid value can still contain control
379
+ // characters/newlines, and this string reaches a terminal
380
+ // (codex-review round-18 nitpick).
381
+ fail(
382
+ `--action-link must be a valid http:// or https:// URL (got "${removeControlCharacters(argv["action-link"]).replace(/[\r\n]+/g, " ")}")`,
383
+ );
384
+ }
385
+ normalizedActionLink = normalized;
386
+ }
320
387
  block = {
321
388
  type: "blocked-by-human",
322
389
  who: argv.who,
323
390
  question: argv.question,
324
391
  options: argv.options,
392
+ actionLink: normalizedActionLink,
325
393
  };
326
394
  break;
327
395
  case "blocked-by-external":
@@ -573,4 +573,130 @@ describe("TodoStore", () => {
573
573
  // refused, not erased: the newer block is still exactly what it was
574
574
  expect(s.get(t._id)?.block).toEqual({ type: "blocked-by-human", who: "taku" });
575
575
  });
576
+
577
+ it("clearBlockIfMatches() — the generalized guard — clears only when the FRESH record's blockRev still equals the snapshot, and refuses (without erasing) a block that changed since decided (codex-review round-15 Important)", async () => {
578
+ const s = await openStore(TEST_ROOT);
579
+ const t = await s.create({ summary: "x", kind: "code" });
580
+ const original = {
581
+ type: "blocked-by-human",
582
+ who: "taku",
583
+ question: "canary or beta?",
584
+ } as const;
585
+ const afterSet = await s.setBlock(t._id, original);
586
+ const cleared = await s.clearBlockIfMatches(t._id, afterSet.blockRev ?? 0);
587
+ expect(cleared.block).toBeNull();
588
+
589
+ const afterReplace = await s.setBlock(t._id, {
590
+ type: "blocked-by-human",
591
+ who: "taku",
592
+ question: "a NEW question",
593
+ });
594
+ await expect(s.clearBlockIfMatches(t._id, (afterReplace.blockRev ?? 0) - 1)).rejects.toThrow(
595
+ /block changed since this was decided/,
596
+ );
597
+ // refused, not erased: the newer block survives untouched
598
+ expect(s.get(t._id)?.block).toEqual({
599
+ type: "blocked-by-human",
600
+ who: "taku",
601
+ question: "a NEW question",
602
+ });
603
+ });
604
+
605
+ it("clearBlockIfMatches() catches an ABA race — a block replaced with BYTE-FOR-BYTE IDENTICAL content still gets a fresh blockRev, so a stale caller is refused rather than erasing that distinct, newer block instance (codex-review round-17 Important)", async () => {
606
+ const s = await openStore(TEST_ROOT);
607
+ const t = await s.create({ summary: "x", kind: "code" });
608
+ const identicalBlock = {
609
+ type: "blocked-by-human",
610
+ who: "taku",
611
+ question: "canary or beta?",
612
+ } as const;
613
+ const firstSet = await s.setBlock(t._id, identicalBlock);
614
+ const capturedRev = firstSet.blockRev ?? 0;
615
+ // Someone re-sets the EXACT same block content — a content-only
616
+ // comparison would see no difference at all.
617
+ await s.setBlock(t._id, identicalBlock);
618
+ await expect(s.clearBlockIfMatches(t._id, capturedRev)).rejects.toThrow(
619
+ /block changed since this was decided/,
620
+ );
621
+ // refused, not erased: the (identical-looking but distinct, newer)
622
+ // block instance survives
623
+ expect(s.get(t._id)?.block).toEqual(identicalBlock);
624
+ });
625
+
626
+ it("answerHumanBlock() applies a gate's evidence, its transition, AND the block clear in ONE atomic write — never a partial application (codex-review round-18 Important, replacing the old approve()+transition()+clearBlockIfMatches() composition)", async () => {
627
+ const s = await openStore(TEST_ROOT);
628
+ const t = await s.create({ summary: "pick a channel", kind: "decision" });
629
+ const afterSet = await s.setBlock(t._id, {
630
+ type: "blocked-by-human",
631
+ who: "taku",
632
+ options: ["canary", "beta"],
633
+ });
634
+ const answered = await s.answerHumanBlock(t._id, afterSet.blockRev ?? 0, {
635
+ name: "human-decided",
636
+ toState: "decided",
637
+ validator: "taku",
638
+ note: "canary",
639
+ });
640
+ expect(answered.block).toBeNull();
641
+ expect(answered.state).toBe("decided");
642
+ expect(answered.verifyEvidence).toEqual([
643
+ expect.objectContaining({ gate: "human-decided", validator: "taku", note: "canary" }),
644
+ ]);
645
+ });
646
+
647
+ it("answerHumanBlock() refuses (without applying anything) a stale expectedBlockRev, and separately refuses independent-verification violations (validator === owner) — matching approve()'s own unconditional rule (codex-review round-18 Important)", async () => {
648
+ const s = await openStore(TEST_ROOT);
649
+ const t = await s.create({ summary: "x", kind: "decision" });
650
+ const afterSet = await s.setBlock(t._id, { type: "blocked-by-human", who: "taku" });
651
+ await s.setBlock(t._id, { type: "blocked-by-human", who: "taku" }); // bumps blockRev again
652
+ await expect(
653
+ s.answerHumanBlock(t._id, afterSet.blockRev ?? 0, {
654
+ name: "human-decided",
655
+ toState: "decided",
656
+ validator: "taku",
657
+ note: "acknowledged",
658
+ }),
659
+ ).rejects.toThrow(/this ask has changed since it was loaded/);
660
+ expect(s.get(t._id)?.state).toBe("deciding"); // unchanged — refused before any write
661
+
662
+ const owned = await s.create({ summary: "y", kind: "decision", owner: "taku" });
663
+ const ownedBlock = await s.setBlock(owned._id, { type: "blocked-by-human", who: "taku" });
664
+ await expect(
665
+ s.answerHumanBlock(owned._id, ownedBlock.blockRev ?? 0, {
666
+ name: "human-decided",
667
+ toState: "decided",
668
+ validator: "taku", // same identity as owner
669
+ note: "acknowledged",
670
+ }),
671
+ ).rejects.toThrow(/independent verification required/);
672
+ expect(s.get(owned._id)?.state).toBe("deciding"); // unchanged
673
+ expect(s.get(owned._id)?.block).not.toBeNull(); // NOT cleared either — refused, not partially applied
674
+ });
675
+
676
+ it("answerHumanBlock() re-validates canTransition against the FRESH state, not a value captured before the call — a state change UNRELATED to block (so invisible to the blockRev check alone) must still be caught, matching transition()'s own concurrent-state-drift defense (codex-review round-18 Important)", async () => {
677
+ const s = await openStore(TEST_ROOT);
678
+ const t = await s.create({ summary: "x", kind: "decision" });
679
+ const afterSet = await s.setBlock(t._id, { type: "blocked-by-human", who: "taku" });
680
+ // Simulate a concurrent writer moving the task's STATE (not its block)
681
+ // to "done" — the same append-only-merge-line technique the
682
+ // markOrphaned() test above uses. blockRev is untouched, so the
683
+ // blockRev check alone would see nothing wrong; only re-validating
684
+ // canTransition against the fresh state catches this.
685
+ await appendFile(
686
+ path.join(TEST_ROOT, ".agent-yes", "todos.jsonl"),
687
+ JSON.stringify({ _id: t._id, state: "done" }) + "\n",
688
+ );
689
+ await expect(
690
+ s.answerHumanBlock(t._id, afterSet.blockRev ?? 0, {
691
+ name: "human-decided",
692
+ toState: "decided",
693
+ validator: "taku",
694
+ note: "acknowledged",
695
+ }),
696
+ ).rejects.toThrow(/no transition done -> decided.*state changed concurrently/);
697
+ // refused, not partially applied: block survives untouched, state
698
+ // stays exactly what the concurrent writer set it to
699
+ expect(s.get(t._id)?.block).not.toBeNull();
700
+ expect(s.get(t._id)?.state).toBe("done");
701
+ });
576
702
  });