agent-yes 1.287.0 → 1.288.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 (111) hide show
  1. package/dist/{SUPPORTED_CLIS-DKdFELZL.js → SUPPORTED_CLIS-BHowMgMY.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-5YPYQ8Iw.js → SUPPORTED_CLIS-ChhJyq4h.js} +3 -3
  3. package/dist/{agentShare-B525ly6v.js → agentShare-T3RskvDT.js} +2 -2
  4. package/dist/{callback-C-awM4qn.js → callback-CuohbCJA.js} +3 -3
  5. package/dist/{callback-BLTW4Mgp.js → callback-DeaETuHt.js} +2 -2
  6. package/dist/cli.js +5 -5
  7. package/dist/{cmdPs-BRtmnpsM.js → cmdPs-Bm4JG7hi.js} +2 -2
  8. package/dist/index.js +2 -2
  9. package/dist/{notifyDaemon-DshQvO-4.js → notifyDaemon-BORqPeAl.js} +2 -2
  10. package/dist/{parentPingLoop-Bjw6hKoZ.js → parentPingLoop-COePRYr9.js} +2 -2
  11. package/dist/{rustBinary-C57KWEtX.js → rustBinary-BmXzqj5z.js} +2 -2
  12. package/dist/{rustBinary-BXhp8yBp.js → rustBinary-D12k7iOD.js} +2 -2
  13. package/dist/{schedule-Boa0nE0V.js → schedule-DLoUmyJh.js} +4 -4
  14. package/dist/{serve-7mcHebep.js → serve-DaDlOtss.js} +17 -17
  15. package/dist/{setup-DXRL-Mdo.js → setup-Cqy8nRrq.js} +2 -2
  16. package/dist/{subcommands-BftthBK7.js → subcommands-BfiOyfz7.js} +15 -15
  17. package/dist/{subcommands-BQJ-Uqtl.js → subcommands-DKTa3E6O.js} +1 -1
  18. package/dist/{terminal-DrYf3HP0.js → terminal-C0tnGnrZ.js} +2 -2
  19. package/dist/trayApp-BrAMlCY7.js +5 -0
  20. package/dist/{trayApp-DG2fQnhx.js → trayApp-DvL9qW6V.js} +2 -2
  21. package/dist/{ts-LDnoqCV2.js → ts-DJLp2_bv.js} +3 -3
  22. package/dist/{versionChecker-CGmyt2qE.js → versionChecker-CkO7wYyV.js} +2 -2
  23. package/dist/{widget-B2SluyG1.js → widget-CJqpScCG.js} +3 -3
  24. package/dist/{ws-DWA90-uE.js → ws-DGENGNrr.js} +2 -2
  25. package/dist/{ws-CTOnVXg8.js → ws-DzCKvzWD.js} +2 -2
  26. package/lab/ui/index.html +95 -67
  27. package/package.json +2 -1
  28. package/ts/JsonlStore.bun.spec.ts +204 -0
  29. package/ts/ReadyManager.bun.spec.ts +72 -0
  30. package/ts/agentNice.bun.spec.ts +24 -0
  31. package/ts/agentPermissions.bun.spec.ts +117 -0
  32. package/ts/agentShare.bun.spec.ts +240 -0
  33. package/ts/agentTree.bun.spec.ts +92 -0
  34. package/ts/agentYesHome.bun.spec.ts +25 -0
  35. package/ts/askApi.bun.spec.ts +358 -0
  36. package/ts/askCli.bun.spec.ts +304 -0
  37. package/ts/autoRetry.bun.spec.ts +123 -0
  38. package/ts/badges.bun.spec.ts +187 -0
  39. package/ts/buildRustArgs.bun.spec.ts +351 -0
  40. package/ts/callbackCore.bun.spec.ts +139 -0
  41. package/ts/channels/hlc.bun.spec.ts +56 -0
  42. package/ts/channels/link.bun.spec.ts +72 -0
  43. package/ts/channels/op.bun.spec.ts +78 -0
  44. package/ts/channels/store.browser.bun.spec.ts +60 -0
  45. package/ts/channels/store.bun.spec.ts +145 -0
  46. package/ts/channels/store.node.bun.spec.ts +76 -0
  47. package/ts/channels/trust.bun.spec.ts +86 -0
  48. package/ts/channels.bun.spec.ts +212 -0
  49. package/ts/cli-idle.bun.spec.ts +20 -0
  50. package/ts/cmdDsh.bun.spec.ts +90 -0
  51. package/ts/configLoader.bun.spec.ts +273 -0
  52. package/ts/cwdConflictWarn.bun.spec.ts +60 -0
  53. package/ts/cwdPassthroughHint.bun.spec.ts +74 -0
  54. package/ts/e2e-crypto.bun.spec.ts +235 -0
  55. package/ts/forkNested.bun.spec.ts +240 -0
  56. package/ts/histStore.bun.spec.ts +355 -0
  57. package/ts/identity.bun.spec.ts +190 -0
  58. package/ts/idleWaiter.bun.spec.ts +91 -0
  59. package/ts/initMsg.bun.spec.ts +132 -0
  60. package/ts/invokedCli.bun.spec.ts +30 -0
  61. package/ts/ipcLock.bun.spec.ts +107 -0
  62. package/ts/logger.bun.spec.ts +27 -0
  63. package/ts/lsWatch.bun.spec.ts +61 -0
  64. package/ts/messageLog.bun.spec.ts +145 -0
  65. package/ts/needsInput.bun.spec.ts +157 -0
  66. package/ts/nodeRuntime.bun.spec.ts +99 -0
  67. package/ts/notifyDaemon.bun.spec.ts +297 -0
  68. package/ts/notifyInbox.bun.spec.ts +277 -0
  69. package/ts/notifyRouter.bun.spec.ts +351 -0
  70. package/ts/notifyStore.bun.spec.ts +403 -0
  71. package/ts/ownerHeartbeat.bun.spec.ts +175 -0
  72. package/ts/parentLink.bun.spec.ts +64 -0
  73. package/ts/parentPing.bun.spec.ts +176 -0
  74. package/ts/parentPingLoop.bun.spec.ts +79 -0
  75. package/ts/parentPingSend.bun.spec.ts +195 -0
  76. package/ts/parentWatching.bun.spec.ts +61 -0
  77. package/ts/pidStore.bun.spec.ts +372 -0
  78. package/ts/procStartTime.bun.spec.ts +117 -0
  79. package/ts/reaper.bun.spec.ts +86 -0
  80. package/ts/removeControlCharacters.bun.spec.ts +94 -0
  81. package/ts/resolveBinary.bun.spec.ts +133 -0
  82. package/ts/resultEnvelope.bun.spec.ts +46 -0
  83. package/ts/runningLock.bun.spec.ts +485 -0
  84. package/ts/rustBinary.bun.spec.ts +81 -0
  85. package/ts/schedule.bun.spec.ts +30 -0
  86. package/ts/serve.bun.spec.ts +166 -0
  87. package/ts/serveLock.bun.spec.ts +220 -0
  88. package/ts/session-integration.bun.spec.ts +93 -0
  89. package/ts/setup.bun.spec.ts +42 -0
  90. package/ts/share.bun.spec.ts +36 -0
  91. package/ts/sizeNego.bun.spec.ts +56 -0
  92. package/ts/statusText.bun.spec.ts +17 -0
  93. package/ts/strayServe.bun.spec.ts +50 -0
  94. package/ts/subcommandMirror.bun.spec.ts +76 -0
  95. package/ts/systemPathLink.bun.spec.ts +79 -0
  96. package/ts/termToken.bun.spec.ts +74 -0
  97. package/ts/terminal.bun.spec.ts +50 -0
  98. package/ts/terminalReply.bun.spec.ts +36 -0
  99. package/ts/titleScanner.bun.spec.ts +65 -0
  100. package/ts/todoAutomation.bun.spec.ts +253 -0
  101. package/ts/todoBlock.bun.spec.ts +73 -0
  102. package/ts/todoDigest.bun.spec.ts +184 -0
  103. package/ts/todoIdentity.bun.spec.ts +85 -0
  104. package/ts/todoLifecycle.bun.spec.ts +79 -0
  105. package/ts/todoParse.bun.spec.ts +98 -0
  106. package/ts/todoRoot.bun.spec.ts +117 -0
  107. package/ts/todoStore.bun.spec.ts +727 -0
  108. package/ts/tryCatch.bun.spec.ts +275 -0
  109. package/ts/webrtcLink.bun.spec.ts +45 -0
  110. package/ts/workspaceConfig.bun.spec.ts +319 -0
  111. package/dist/trayApp-1AQ6X2V9.js +0 -5
@@ -0,0 +1,358 @@
1
+ import { describe, expect, it, beforeEach, afterEach, vi } from "bun:test";
2
+ import { rm, mkdir } from "fs/promises";
3
+ import path from "path";
4
+ import { listAsks, listAsksForProject, answerAsk, hasTodoStore } from "./askApi";
5
+ import { TodoStore, openStore } from "./todoStore";
6
+ import type { TodoBlock } from "./todoBlock";
7
+
8
+ const isWindows = process.platform === "win32";
9
+ const ROOT_A = isWindows
10
+ ? path.join(process.env.TEMP || "C:\\Temp", "askapi-a-" + process.pid)
11
+ : "/tmp/askapi-a-" + process.pid;
12
+ const ROOT_B = isWindows
13
+ ? path.join(process.env.TEMP || "C:\\Temp", "askapi-b-" + process.pid)
14
+ : "/tmp/askapi-b-" + process.pid;
15
+ const ROOT_NONE = isWindows
16
+ ? path.join(process.env.TEMP || "C:\\Temp", "askapi-none-" + process.pid)
17
+ : "/tmp/askapi-none-" + process.pid;
18
+
19
+ describe("askApi", () => {
20
+ beforeEach(async () => {
21
+ await rm(ROOT_A, { recursive: true, force: true });
22
+ await rm(ROOT_B, { recursive: true, force: true });
23
+ await rm(ROOT_NONE, { recursive: true, force: true });
24
+ });
25
+ afterEach(async () => {
26
+ await rm(ROOT_A, { recursive: true, force: true });
27
+ await rm(ROOT_B, { recursive: true, force: true });
28
+ await rm(ROOT_NONE, { recursive: true, force: true });
29
+ });
30
+
31
+ it("hasTodoStore is false until a store has actually persisted a task at that root (openStore alone doesn't create the file)", async () => {
32
+ expect(hasTodoStore(ROOT_A)).toBe(false);
33
+ const s = await openStore(ROOT_A);
34
+ expect(hasTodoStore(ROOT_A)).toBe(false); // still nothing written to disk
35
+ await s.create({ summary: "x", kind: "code" });
36
+ expect(hasTodoStore(ROOT_A)).toBe(true);
37
+ });
38
+
39
+ it("listAsksForProject surfaces only blocked-by-human tasks, classified by shape", async () => {
40
+ const s = await openStore(ROOT_A);
41
+ const choice = await s.create({ summary: "pick a channel", kind: "decision" });
42
+ await s.setBlock(choice._id, {
43
+ type: "blocked-by-human",
44
+ who: "alice",
45
+ question: "canary or beta?",
46
+ options: ["canary", "beta"],
47
+ });
48
+ const action = await s.create({ summary: "finish oauth", kind: "human" });
49
+ await s.setBlock(action._id, {
50
+ type: "blocked-by-human",
51
+ who: "alice",
52
+ actionLink: "https://example/oauth",
53
+ });
54
+ const bare = await s.create({ summary: "just fyi", kind: "human" });
55
+ await s.setBlock(bare._id, { type: "blocked-by-human", who: "alice" });
56
+ const notHuman = await s.create({ summary: "not an ask", kind: "code" });
57
+ await s.setBlock(notHuman._id, { type: "blocked-by-external", signal: "ci" });
58
+ const unblocked = await s.create({ summary: "no block at all", kind: "code" });
59
+
60
+ const asks = await listAsksForProject(ROOT_A);
61
+ expect(asks.map((a) => a.taskId).sort()).toEqual([choice._id, action._id, bare._id].sort());
62
+ expect(asks.find((a) => a.taskId === choice._id)).toMatchObject({
63
+ shape: "choice",
64
+ options: ["canary", "beta"],
65
+ question: "canary or beta?",
66
+ });
67
+ expect(asks.find((a) => a.taskId === action._id)).toMatchObject({
68
+ shape: "action",
69
+ actionLink: "https://example/oauth",
70
+ });
71
+ expect(asks.find((a) => a.taskId === bare._id)).toMatchObject({ shape: "acknowledge" });
72
+ });
73
+
74
+ it("listAsks aggregates across multiple project roots, skipping ones with no store at all", async () => {
75
+ const sA = await openStore(ROOT_A);
76
+ const tA = await sA.create({ summary: "a", kind: "human" });
77
+ await sA.setBlock(tA._id, { type: "blocked-by-human", who: "alice" });
78
+ const sB = await openStore(ROOT_B);
79
+ const tB = await sB.create({ summary: "b", kind: "human" });
80
+ await sB.setBlock(tB._id, { type: "blocked-by-human", who: "cto" });
81
+
82
+ const asks = await listAsks([ROOT_A, ROOT_B, ROOT_NONE]);
83
+ expect(asks.map((a) => `${a.projectRoot === ROOT_A ? "A" : "B"}:${a.taskId}`).sort()).toEqual(
84
+ [`A:${tA._id}`, `B:${tB._id}`].sort(),
85
+ );
86
+ });
87
+
88
+ it("listAsks isolates a per-project failure — one unreadable/broken store must not take down the whole cross-project panel (codex-review round-14 Important)", async () => {
89
+ const sA = await openStore(ROOT_A);
90
+ const tA = await sA.create({ summary: "healthy ask", kind: "human" });
91
+ await sA.setBlock(tA._id, { type: "blocked-by-human", who: "alice" });
92
+
93
+ // ROOT_B's todos.jsonl is a DIRECTORY, not a file — a real, easy way to
94
+ // force listAsksForProject(ROOT_B) to genuinely reject (EISDIR) rather
95
+ // than mocking anything.
96
+ await mkdir(path.join(ROOT_B, ".agent-yes", "todos.jsonl"), { recursive: true });
97
+
98
+ const asks = await listAsks([ROOT_A, ROOT_B]);
99
+ expect(asks.map((a) => a.taskId)).toEqual([tA._id]);
100
+ });
101
+
102
+ it("answerAsk on a bare acknowledge-shape ask: clears the block, advances the human kind's pending->decided gate as the asked human, then decided->done is ungated", async () => {
103
+ const s = await openStore(ROOT_A);
104
+ const t = await s.create({ summary: "just fyi", kind: "human" });
105
+ await s.setBlock(t._id, { type: "blocked-by-human", who: "alice" });
106
+ const { record: answered } = await answerAsk(ROOT_A, t._id, { acknowledged: true });
107
+ expect(answered.block).toBeNull();
108
+ expect(answered.state).toBe("decided"); // human-replied gate satisfied, advanced
109
+ expect(answered.verifyEvidence[0]).toMatchObject({ gate: "human-replied", validator: "alice" });
110
+ });
111
+
112
+ it("answerAsk on a choice-shape ask records the choice text as evidence note, and rejects a choice not among the offered options", async () => {
113
+ const s = await openStore(ROOT_A);
114
+ const t = await s.create({ summary: "pick a channel", kind: "decision" });
115
+ await s.setBlock(t._id, {
116
+ type: "blocked-by-human",
117
+ who: "alice",
118
+ options: ["canary", "beta"],
119
+ });
120
+ await expect(answerAsk(ROOT_A, t._id, { choice: "stable" })).rejects.toThrow(
121
+ /not one of the offered options/,
122
+ );
123
+ const { record: answered } = await answerAsk(ROOT_A, t._id, { choice: "canary" });
124
+ expect(answered.state).toBe("decided");
125
+ expect(answered.verifyEvidence[0]).toMatchObject({ gate: "human-decided", note: "canary" });
126
+ });
127
+
128
+ it("when a block has BOTH options and actionLink set (a direct library caller bypassing the CLI's mutual-exclusivity guard), answerAsk treats it as action-shape — matching listAsksForProject's own precedence, so an ask is never listed as one shape but answerable only as another (codex-review round-9 Important)", async () => {
129
+ const s = await openStore(ROOT_A);
130
+ const t = await s.create({ summary: "x", kind: "human" });
131
+ await s.setBlock(t._id, {
132
+ type: "blocked-by-human",
133
+ who: "alice",
134
+ options: ["canary", "beta"],
135
+ actionLink: "https://example/oauth",
136
+ });
137
+ const asks = await listAsksForProject(ROOT_A);
138
+ expect(asks[0]?.shape).toBe("action");
139
+ // a choice alone (matching the listed shape's own actionLink requirement
140
+ // being ignored) must NOT satisfy it — only acknowledged does, matching
141
+ // the action-shape UI (open link, then confirm)
142
+ await expect(answerAsk(ROOT_A, t._id, { choice: "canary" })).rejects.toThrow(
143
+ /requires \{ acknowledged: true \}/,
144
+ );
145
+ const { record: answered } = await answerAsk(ROOT_A, t._id, { acknowledged: true });
146
+ expect(answered.block).toBeNull();
147
+ });
148
+
149
+ it("answerAsk leaves the task's block INTACT (not silently cleared) if the gate/transition step fails — atomicity means a failure never strands the task in limbo (codex-review round-9 Important)", async () => {
150
+ const s = await openStore(ROOT_A);
151
+ const t = await s.create({ summary: "pick a channel", kind: "decision", owner: "alice" });
152
+ await s.setBlock(t._id, { type: "blocked-by-human", who: "alice", options: ["a", "b"] });
153
+ // "alice" is both the task's owner AND the asked human — approve() will
154
+ // throw "independent verification required" for this specific task,
155
+ // simulating a mid-sequence failure without needing to mock anything.
156
+ await expect(answerAsk(ROOT_A, t._id, { choice: "a" })).rejects.toThrow(
157
+ /independent verification required/,
158
+ );
159
+ // Re-open fresh rather than reusing `s`: `answerAsk()` opens its OWN
160
+ // separate store instance internally, so `s`'s in-memory cache is never
161
+ // reloaded by that call — reading `s` directly here would silently pass
162
+ // even if answerAsk actually cleared the block on disk before throwing
163
+ // (exactly the bug this test exists to catch).
164
+ const stillBlocked = (await openStore(ROOT_A)).get(t._id)!;
165
+ expect(stillBlocked.block).toEqual({
166
+ type: "blocked-by-human",
167
+ who: "alice",
168
+ options: ["a", "b"],
169
+ });
170
+ expect(stillBlocked.state).toBe("deciding"); // unchanged
171
+ });
172
+
173
+ it("answerAsk NEVER erases a block that was replaced by a DIFFERENT one WHILE the answer was being processed — the final clear only removes the SPECIFIC block this call decided to answer, not whatever block happens to be there by the time it gets around to clearing (codex-review round-15 Important)", async () => {
174
+ const s = await openStore(ROOT_A);
175
+ const t = await s.create({ summary: "x", kind: "human" });
176
+ const original = { type: "blocked-by-human", who: "alice", question: "original ask" } as const;
177
+ await s.setBlock(t._id, original);
178
+ const newerBlock = {
179
+ type: "blocked-by-human",
180
+ who: "alice",
181
+ question: "a brand new ask",
182
+ } as const;
183
+
184
+ // Deterministically inject a concurrent block replacement between
185
+ // answerAsk's initial read (which captures the blockRev it expects to
186
+ // still be current) and its atomic answerHumanBlock() write — patching
187
+ // TodoStore.prototype.answerHumanBlock (which every openStore()
188
+ // instance, including the one answerAsk creates internally, shares) so
189
+ // a SECOND, independent store instance replaces the block right BEFORE
190
+ // the real atomic write runs, simulating a genuinely different process
191
+ // racing this exact window. A real race would be flaky to reproduce
192
+ // reliably; this reproduces it deterministically every run.
193
+ const originalAnswerHumanBlock = TodoStore.prototype.answerHumanBlock;
194
+ const spy = vi
195
+ .spyOn(TodoStore.prototype, "answerHumanBlock")
196
+ .mockImplementation(async function (
197
+ this: TodoStore,
198
+ id: string,
199
+ expectedBlockRev: number,
200
+ gate: Parameters<TodoStore["answerHumanBlock"]>[2],
201
+ ) {
202
+ const other = await openStore(ROOT_A);
203
+ await other.setBlock(id, newerBlock);
204
+ return originalAnswerHumanBlock.call(this, id, expectedBlockRev, gate);
205
+ });
206
+ try {
207
+ await expect(answerAsk(ROOT_A, t._id, { acknowledged: true })).rejects.toThrow(
208
+ /this ask has changed since it was loaded/,
209
+ );
210
+ } finally {
211
+ spy.mockRestore();
212
+ }
213
+ // The newer block survives untouched — not silently wiped by the stale
214
+ // "clear the original block" decision.
215
+ const fresh = (await openStore(ROOT_A)).get(t._id)!;
216
+ expect(fresh.block).toEqual(newerBlock);
217
+ });
218
+
219
+ it("a naive retry after answerAsk already fully succeeded is cleanly refused — it never duplicates evidence or re-transitions, because the gate/transition/clear are now ONE atomic write with no partial-completion state for a retry to resume from (codex-review round-18: replaces the old approve()+transition()+clearBlockIfMatches composition's retry-idempotency workaround, which is no longer needed)", async () => {
220
+ const s = await openStore(ROOT_A);
221
+ const t = await s.create({ summary: "pick a channel", kind: "decision" });
222
+ await s.setBlock(t._id, { type: "blocked-by-human", who: "alice", options: ["a", "b"] });
223
+ const { record: first } = await answerAsk(ROOT_A, t._id, { choice: "a" });
224
+ expect(first.state).toBe("decided");
225
+ expect(first.verifyEvidence).toHaveLength(1);
226
+ // A retry (e.g. the client never saw the first response and resubmits)
227
+ // sees the block already cleared — a clear, accurate rejection, not a
228
+ // silent no-op and not a duplicate evidence entry.
229
+ await expect(answerAsk(ROOT_A, t._id, { choice: "a" })).rejects.toThrow(
230
+ /not currently blocked-by-human/,
231
+ );
232
+ const stillDone = (await openStore(ROOT_A)).get(t._id)!;
233
+ expect(stillDone.verifyEvidence).toHaveLength(1); // never duplicated
234
+ expect(stillDone.state).toBe("decided"); // never re-transitioned
235
+ });
236
+
237
+ it("answerAsk IGNORES an unvalidated `choice` sent alongside `acknowledged: true` on a non-choice-shape ask — an arbitrary attacker-supplied string must never become the recorded/relayed answer just because it rode along with a valid acknowledgement (codex-review round-14 Important)", async () => {
238
+ const s = await openStore(ROOT_A);
239
+ const bare = await s.create({ summary: "just fyi", kind: "human" });
240
+ await s.setBlock(bare._id, { type: "blocked-by-human", who: "alice" });
241
+ const bareResult = await answerAsk(ROOT_A, bare._id, {
242
+ acknowledged: true,
243
+ choice: "attacker-controlled garbage\nrm -rf /",
244
+ });
245
+ expect(bareResult.answerText).toBe("acknowledged");
246
+ expect(bareResult.record.verifyEvidence[0]).toMatchObject({ note: "acknowledged" });
247
+
248
+ const action = await s.create({ summary: "finish oauth", kind: "human" });
249
+ await s.setBlock(action._id, {
250
+ type: "blocked-by-human",
251
+ who: "alice",
252
+ actionLink: "https://example/oauth",
253
+ });
254
+ const actionResult = await answerAsk(ROOT_A, action._id, {
255
+ acknowledged: true,
256
+ choice: "also attacker-controlled",
257
+ });
258
+ expect(actionResult.answerText).toBe("acknowledged");
259
+ });
260
+
261
+ it("answerAsk requires an actual answer for a choice-shape ask (no bare acknowledged)", async () => {
262
+ const s = await openStore(ROOT_A);
263
+ const t = await s.create({ summary: "pick a channel", kind: "decision" });
264
+ await s.setBlock(t._id, { type: "blocked-by-human", who: "alice", options: ["a", "b"] });
265
+ await expect(answerAsk(ROOT_A, t._id, { acknowledged: true })).rejects.toThrow(
266
+ /requires a choice/,
267
+ );
268
+ });
269
+
270
+ it("answerAsk refuses a task that isn't currently blocked-by-human (already answered, or a different block type)", async () => {
271
+ const s = await openStore(ROOT_A);
272
+ const t = await s.create({ summary: "x", kind: "human" });
273
+ await expect(answerAsk(ROOT_A, t._id, { acknowledged: true })).rejects.toThrow(
274
+ /not currently blocked-by-human/,
275
+ );
276
+ await s.setBlock(t._id, { type: "blocked-by-external", signal: "ci" });
277
+ await expect(answerAsk(ROOT_A, t._id, { acknowledged: true })).rejects.toThrow(
278
+ /not currently blocked-by-human/,
279
+ );
280
+ });
281
+
282
+ it("answerAsk on a task whose current state has NO gated outgoing edge just clears the block (no transition to apply)", async () => {
283
+ const s = await openStore(ROOT_A);
284
+ const t = await s.create({ summary: "x", kind: "code" }); // doing -> merged is ungated
285
+ await s.setBlock(t._id, {
286
+ type: "blocked-by-human",
287
+ who: "alice",
288
+ question: "still working on this?",
289
+ });
290
+ const { record: answered } = await answerAsk(ROOT_A, t._id, { acknowledged: true });
291
+ expect(answered.block).toBeNull();
292
+ expect(answered.state).toBe("doing"); // unchanged — nothing gated to advance
293
+ expect(answered.verifyEvidence).toEqual([]);
294
+ });
295
+
296
+ it("answerAsk NEVER auto-transitions a code/doc/investigation task, even if its current gated edge would (wrongly) look unregistered to a fresh store instance — only human/decision kinds auto-advance", async () => {
297
+ // Register "verify-green" on THIS process's own store instance, exactly
298
+ // as a real project would — answerAsk() opens its OWN fresh instance
299
+ // internally and has no visibility into this registration, which is
300
+ // exactly the ambiguity that makes auto-transition unsafe for any kind
301
+ // other than human/decision (see answerAsk's doc comment).
302
+ const s = await openStore(ROOT_A);
303
+ s.registerGate({ name: "verify-green", check: async () => ({ passed: true }) });
304
+ const t = await s.create({ summary: "x", kind: "code", owner: "worker" });
305
+ await s.transition(t._id, "merged");
306
+ await s.transition(t._id, "shipped");
307
+ await s.transition(t._id, "verifying");
308
+ await s.setBlock(t._id, { type: "blocked-by-human", who: "alice", question: "any concerns?" });
309
+ const { record: answered } = await answerAsk(ROOT_A, t._id, { acknowledged: true });
310
+ expect(answered.block).toBeNull();
311
+ expect(answered.state).toBe("verifying"); // unchanged — code kind never auto-transitions on a human answer
312
+ expect(answered.verifyEvidence).toEqual([]);
313
+ });
314
+
315
+ it("listAsksForProject reports blockRev, and answerAsk refuses an expectedBlockRev that no longer matches — even when the CURRENT block has byte-for-byte identical text to what the caller last saw, since a content-only check could not distinguish 'still the same ask' from 'a new, identical-looking one' (codex-review round-17 Important)", async () => {
316
+ const s = await openStore(ROOT_A);
317
+ const t = await s.create({ summary: "pick a channel", kind: "decision" });
318
+ const identicalBlock: TodoBlock = {
319
+ type: "blocked-by-human",
320
+ who: "alice",
321
+ question: "canary or beta?",
322
+ options: ["canary", "beta"],
323
+ };
324
+ await s.setBlock(t._id, identicalBlock);
325
+ const [ask] = await listAsksForProject(ROOT_A);
326
+ const staleRev = ask!.blockRev;
327
+
328
+ // Someone re-blocks with the EXACT same content before the human's
329
+ // (now-stale) view gets answered.
330
+ await s.setBlock(t._id, identicalBlock);
331
+
332
+ await expect(
333
+ answerAsk(ROOT_A, t._id, { choice: "canary", expectedBlockRev: staleRev }),
334
+ ).rejects.toThrow(/this ask has changed since it was loaded/);
335
+ // refused, not silently answered against the newer (identical-looking)
336
+ // block instance
337
+ expect(s.get(t._id)?.block).toEqual(identicalBlock);
338
+ expect(s.get(t._id)?.state).toBe("deciding"); // unchanged — refused before any gate/transition
339
+
340
+ // The CURRENT blockRev succeeds normally.
341
+ const currentRev = (await listAsksForProject(ROOT_A))[0]!.blockRev;
342
+ const { record: answered } = await answerAsk(ROOT_A, t._id, {
343
+ choice: "canary",
344
+ expectedBlockRev: currentRev,
345
+ });
346
+ expect(answered.block).toBeNull();
347
+ expect(answered.state).toBe("decided");
348
+ });
349
+
350
+ it("answerAsk without expectedBlockRev (a direct/programmatic caller that doesn't track it) still works exactly as before — the check is opt-in, not required", async () => {
351
+ const s = await openStore(ROOT_A);
352
+ const t = await s.create({ summary: "x", kind: "human" });
353
+ await s.setBlock(t._id, { type: "blocked-by-human", who: "alice" });
354
+ const { record: answered } = await answerAsk(ROOT_A, t._id, { acknowledged: true });
355
+ expect(answered.block).toBeNull();
356
+ expect(answered.state).toBe("decided");
357
+ });
358
+ });
@@ -0,0 +1,304 @@
1
+ import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test";
2
+ import { rm, mkdir } from "fs/promises";
3
+ import path from "path";
4
+ import { runAskSubcommand, runAnswerSubcommand, buildAskEnvelope, type AskDeps } from "./askCli.ts";
5
+ import { openStore } from "./todoStore.ts";
6
+ import type { GlobalPidRecord } from "./globalPidIndex.ts";
7
+ import type { SelfIdentity } from "./todoIdentity.ts";
8
+
9
+ const isWindows = process.platform === "win32";
10
+ const TEST_ROOT = isWindows
11
+ ? path.join(process.env.TEMP || "C:\\Temp", "askcli-test-" + process.pid)
12
+ : "/tmp/askcli-test-" + process.pid;
13
+
14
+ function captureStdout(): { text: () => string; restore: () => void } {
15
+ let buf = "";
16
+ const spy = spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
17
+ buf += String(chunk);
18
+ return true;
19
+ });
20
+ return { text: () => buf, restore: () => spy.mockRestore() };
21
+ }
22
+
23
+ const agentRec = (over: Partial<GlobalPidRecord>): GlobalPidRecord =>
24
+ ({
25
+ pid: 100,
26
+ cli: "claude",
27
+ prompt: null,
28
+ cwd: "/repo",
29
+ log_file: null,
30
+ status: "active",
31
+ exit_code: null,
32
+ exit_reason: null,
33
+ started_at: 0,
34
+ ...over,
35
+ }) as GlobalPidRecord;
36
+
37
+ const ASKER: SelfIdentity = { agentId: "lane-a", pid: 11, cwd: "/repo/lane-a", cli: "codex" };
38
+ const ANSWERER: SelfIdentity = { agentId: "lane-b", pid: 21, cwd: "/repo/lane-b", cli: "claude" };
39
+
40
+ /** Deps with a recording `send` and a registry of two agents. */
41
+ function mkDeps(self: SelfIdentity | null, over: Partial<AskDeps> = {}) {
42
+ const sent: string[][] = [];
43
+ const deps: AskDeps = {
44
+ resolveAgent: async (keyword) => {
45
+ const known: Record<string, GlobalPidRecord> = {
46
+ "lane-a": agentRec({ pid: 11, agent_id: "lane-a", cwd: "/repo/lane-a" }),
47
+ "lane-b": agentRec({ pid: 21, agent_id: "lane-b", cwd: "/repo/lane-b" }),
48
+ "11": agentRec({ pid: 11, agent_id: "lane-a", cwd: "/repo/lane-a" }),
49
+ "21": agentRec({ pid: 21, agent_id: "lane-b", cwd: "/repo/lane-b" }),
50
+ legacy: agentRec({ pid: 31, agent_id: null }),
51
+ };
52
+ const rec = known[keyword];
53
+ if (!rec) throw new Error(`no agent matched "${keyword}"`);
54
+ return rec;
55
+ },
56
+ send: async (argv) => {
57
+ sent.push(argv);
58
+ return 0;
59
+ },
60
+ self: async () => self,
61
+ ...over,
62
+ };
63
+ return { deps, sent };
64
+ }
65
+
66
+ async function ask(deps: AskDeps, ...args: string[]) {
67
+ const cap = captureStdout();
68
+ try {
69
+ const code = await runAskSubcommand([...args, "--root", TEST_ROOT], deps);
70
+ return { code, out: cap.text() };
71
+ } finally {
72
+ cap.restore();
73
+ }
74
+ }
75
+
76
+ async function answer(deps: AskDeps, ...args: string[]) {
77
+ const cap = captureStdout();
78
+ try {
79
+ const code = await runAnswerSubcommand([...args, "--root", TEST_ROOT], deps);
80
+ return { code, out: cap.text() };
81
+ } finally {
82
+ cap.restore();
83
+ }
84
+ }
85
+
86
+ describe("ay ask", () => {
87
+ beforeEach(async () => {
88
+ await rm(TEST_ROOT, { recursive: true, force: true });
89
+ await mkdir(TEST_ROOT, { recursive: true });
90
+ });
91
+ afterEach(async () => {
92
+ await rm(TEST_ROOT, { recursive: true, force: true });
93
+ });
94
+
95
+ it("records a task carrying BOTH parties: owner = asker, block = the answerer", async () => {
96
+ const { deps } = mkDeps(ASKER);
97
+ await ask(deps, "lane-b", "does", "the", "cache", "need", "invalidating?");
98
+
99
+ const store = await openStore(TEST_ROOT);
100
+ const t = store.get("T1")!;
101
+ expect(t.kind).toBe("question");
102
+ expect(t.state).toBe("pending");
103
+ expect(t.owner).toBe("lane-a"); // asker — reconcile orphans this if lane-a dies
104
+ expect(t.block).toMatchObject({ type: "waiting-on-answer", agentId: "lane-b" });
105
+ // The whole question is kept, not just the one-line summary.
106
+ expect(t.description).toBe("does the cache need invalidating?");
107
+ });
108
+
109
+ it("delivers an <ay-ask-msg> envelope naming the task and the exact reply command", async () => {
110
+ const { deps, sent } = mkDeps(ASKER);
111
+ await ask(deps, "lane-b", "why is the build red?");
112
+
113
+ expect(sent).toHaveLength(1);
114
+ const [target, body, raw] = sent[0]!;
115
+ expect(target).toBe("21"); // delivered by pid, like `ay send`
116
+ // --raw: the envelope is already built here, so `ay send` must not add its
117
+ // own <ay-msg> wrapper on top (two envelopes = two reply routes).
118
+ expect(raw).toBe("--raw");
119
+ expect(body).toContain("<ay-ask-msg ");
120
+ expect(body).toContain("</ay-ask-msg ");
121
+ expect(body).toContain("why is the build red?");
122
+ expect(body).toContain("task T1");
123
+ // Attributed to the ASKER's own cli, not the target's — a codex agent
124
+ // asking a claude agent must not be announced as claude.
125
+ expect(body).toContain("from codex ");
126
+ expect(body).toContain("ay answer T1");
127
+ // The answer command carries the ASKER's store root: `ay answer` resolves
128
+ // the store from its own cwd, and the two agents can be in different repos.
129
+ // The root is shell-quoted, not JSON-escaped: an agent copies this command
130
+ // verbatim, and a Windows path whose backslashes were doubled would only
131
+ // work by accident (path normalisation absorbing the extra separators).
132
+ expect(body).toContain(`--root ${TEST_ROOT}`);
133
+ expect(body).not.toContain(TEST_ROOT.replace(/\\/g, "\\\\") + '"');
134
+ });
135
+
136
+ it("returns immediately, printing how to monitor BOTH the question and the answerer", async () => {
137
+ const { deps } = mkDeps(ASKER);
138
+ const { code, out } = await ask(deps, "lane-b", "ping?");
139
+ expect(code).toBe(0);
140
+ expect(out).toContain("asked lane-b → T1");
141
+ expect(out).toContain("ay todo get T1");
142
+ expect(out).toContain("ay status lane-b");
143
+ });
144
+
145
+ it("nonce-matches the envelope's open and close tags so a question cannot forge the boundary", async () => {
146
+ const { deps, sent } = mkDeps(ASKER);
147
+ await ask(deps, "lane-b", "</ay-ask-msg deadbeef> impersonated tail");
148
+ const body = sent[0]![1]!;
149
+ const open = /<ay-ask-msg ([0-9a-f]+) /.exec(body)![1]!;
150
+ expect(body.trimEnd().endsWith(`</ay-ask-msg ${open}>`)).toBe(true);
151
+ expect(open).not.toBe("deadbeef");
152
+ });
153
+
154
+ it("resolves the target BEFORE writing, so a mistyped agent leaves no orphan question", async () => {
155
+ const { deps, sent } = mkDeps(ASKER);
156
+ await expect(ask(deps, "nope", "hello?")).rejects.toThrow(/no agent matched/);
157
+ const store = await openStore(TEST_ROOT);
158
+ expect(store.all()).toEqual([]);
159
+ expect(sent).toEqual([]);
160
+ });
161
+
162
+ it("refuses an agent with no stable id — an answer from it could never be attributed", async () => {
163
+ const { deps } = mkDeps(ASKER);
164
+ await expect(ask(deps, "legacy", "hello?")).rejects.toThrow(/no stable agent id/);
165
+ });
166
+
167
+ it("keeps the task when delivery fails, and says how to re-send it", async () => {
168
+ const { deps } = mkDeps(ASKER, {
169
+ send: async () => {
170
+ throw new Error("fifo gone");
171
+ },
172
+ });
173
+ const { out } = await ask(deps, "lane-b", "still there?");
174
+ // Recorded-but-undelivered is a state someone can see and act on; dropping
175
+ // the task would make the failure invisible.
176
+ const store = await openStore(TEST_ROOT);
177
+ expect(store.get("T1")).not.toBeNull();
178
+ expect(out).toContain("NOT DELIVERED");
179
+ });
180
+
181
+ it("quotes a root containing spaces, and leaves a plain one bare", async () => {
182
+ // Exercised directly: TEST_ROOT has no spaces, so the branch that matters
183
+ // for a real user (`/Users/me/My Repos/x`) would otherwise go untested.
184
+ expect(
185
+ buildAskEnvelope({ question: "q", taskId: "T1", root: "/plain/root", asker: null }),
186
+ ).toContain("--root /plain/root ");
187
+ expect(
188
+ buildAskEnvelope({ question: "q", taskId: "T1", root: "/has space/root", asker: null }),
189
+ ).toContain('--root "/has space/root"');
190
+ });
191
+
192
+ it("does NOT bypass `ay send`'s recency guard by default, but --force passes through", async () => {
193
+ // The target is a keyword the caller typed, so the guard's own rationale
194
+ // (a fuzzy keyword resolving to an agent you never looked at) applies
195
+ // unchanged — asking the wrong agent is that same mistake.
196
+ const { deps, sent } = mkDeps(ASKER);
197
+ await ask(deps, "lane-b", "plain");
198
+ expect(sent[0]).not.toContain("--force");
199
+
200
+ await ask(deps, "lane-b", "urgent", "--force");
201
+ expect(sent[1]).toContain("--force");
202
+ });
203
+
204
+ it("a human shell can ask too — the task is simply unowned", async () => {
205
+ const { deps } = mkDeps(null);
206
+ await ask(deps, "lane-b", "anyone home?");
207
+ const store = await openStore(TEST_ROOT);
208
+ expect(store.get("T1")?.owner).toBeUndefined();
209
+ expect(store.get("T1")?.block).toMatchObject({ agentId: "lane-b" });
210
+ });
211
+ });
212
+
213
+ describe("ay answer", () => {
214
+ beforeEach(async () => {
215
+ await rm(TEST_ROOT, { recursive: true, force: true });
216
+ await mkdir(TEST_ROOT, { recursive: true });
217
+ });
218
+ afterEach(async () => {
219
+ await rm(TEST_ROOT, { recursive: true, force: true });
220
+ });
221
+
222
+ it("closes the loop: satisfies the gate, moves to answered, and tells the asker", async () => {
223
+ const { deps: askDeps } = mkDeps(ASKER);
224
+ await ask(askDeps, "lane-b", "why is the build red?");
225
+
226
+ const { deps: ansDeps, sent } = mkDeps(ANSWERER);
227
+ const { out } = await answer(ansDeps, "T1", "a", "stale", "lockfile");
228
+
229
+ const store = await openStore(TEST_ROOT);
230
+ const t = store.get("T1")!;
231
+ expect(t.state).toBe("answered");
232
+ // The block is cleared — an answered question must stop advertising that
233
+ // an answer is owed, or `ls` shows it as still waiting and reconcile
234
+ // reports the (finished) answerer as having died owing one.
235
+ expect(t.block).toBeNull();
236
+ // ...but the answerer is still on the record, as the gate's validator.
237
+ expect(t.verifyEvidence[0]).toMatchObject({
238
+ gate: "answer-received",
239
+ validator: "lane-b",
240
+ note: "a stale lockfile",
241
+ });
242
+ // The answer goes back to the ASKER, wrapped so the recipient can tell it
243
+ // apart from an ordinary message.
244
+ const body = sent[0]![1]!;
245
+ expect(sent[0]![0]).toBe("11");
246
+ // Forced, unlike `ask`: the recipient came off the task record rather than
247
+ // from a typed keyword, and an answerer has no reason to have tailed
248
+ // whoever asked it something.
249
+ expect(sent[0]).toContain("--force");
250
+ expect(body).toContain("<ay-answer-msg ");
251
+ expect(body).toContain("a stale lockfile");
252
+ expect(out).toContain("answered T1");
253
+ });
254
+
255
+ // The store's independence rule does this, so it holds no matter how the
256
+ // command is invoked — an asker cannot quietly close its own question.
257
+ it("refuses an answer from the asker itself", async () => {
258
+ const { deps: askDeps } = mkDeps(ASKER);
259
+ await ask(askDeps, "lane-b", "am I right?");
260
+ const { deps: selfDeps } = mkDeps(ASKER);
261
+ await expect(answer(selfDeps, "T1", "yes obviously")).rejects.toThrow(
262
+ /independent verification required/,
263
+ );
264
+ });
265
+
266
+ it("still records the answer when the asker can no longer be reached", async () => {
267
+ const { deps: askDeps } = mkDeps(ASKER);
268
+ await ask(askDeps, "lane-b", "are you there?");
269
+
270
+ const { deps: ansDeps } = mkDeps(ANSWERER, {
271
+ resolveAgent: async (keyword) => {
272
+ if (keyword === "lane-a") throw new Error("no agent matched");
273
+ return agentRec({ pid: 21, agent_id: "lane-b" });
274
+ },
275
+ });
276
+ const { out } = await answer(ansDeps, "T1", "yes");
277
+
278
+ const store = await openStore(TEST_ROOT);
279
+ expect(store.get("T1")?.state).toBe("answered");
280
+ expect(store.get("T1")?.verifyEvidence[0]?.note).toBe("yes");
281
+ expect(out).toContain("could NOT reach the asker");
282
+ });
283
+
284
+ it("refuses a task that is not an `ay ask` question", async () => {
285
+ const store = await openStore(TEST_ROOT);
286
+ await store.create({ summary: "ordinary work", kind: "code" });
287
+ const { deps } = mkDeps(ANSWERER);
288
+ await expect(answer(deps, "T1", "hi")).rejects.toThrow(/not a question/);
289
+ });
290
+
291
+ it("names the store it looked in when the task id is unknown there", async () => {
292
+ const { deps } = mkDeps(ANSWERER);
293
+ await expect(answer(deps, "T9", "hi")).rejects.toThrow(/no such task in .*T9/);
294
+ });
295
+
296
+ it("a human at a shell can answer — validated as `human`, never colliding with an agent id", async () => {
297
+ const { deps: askDeps } = mkDeps(ASKER);
298
+ await ask(askDeps, "lane-b", "ok?");
299
+ const { deps } = mkDeps(null);
300
+ await answer(deps, "T1", "fine");
301
+ const store = await openStore(TEST_ROOT);
302
+ expect(store.get("T1")?.verifyEvidence[0]?.validator).toBe("human");
303
+ });
304
+ });