agent-yes 1.226.1 → 1.227.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 (49) hide show
  1. package/dist/{SUPPORTED_CLIS-xM8S6xqo.js → SUPPORTED_CLIS-CFd1V2O5.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-CJiLdm01.js +11 -0
  3. package/dist/{agent-yes.config-DmW_TVFI.js → agent-yes.config-0G11aPWe.js} +2 -2
  4. package/dist/{agentShare-DSYm87vH.js → agentShare-C_rydTfo.js} +7 -7
  5. package/dist/{callback-DlzoC3fU.js → callback-BV3AhWeB.js} +5 -5
  6. package/dist/callback-BgWZmpqC.js +12 -0
  7. package/dist/{callbackCore-DSxbO_6a.js → callbackCore-B3DNe_SP.js} +1 -1
  8. package/dist/{callbackCore-CgnOT1Cp.js → callbackCore-BlNdDILy.js} +1 -1
  9. package/dist/cli.js +9 -9
  10. package/dist/{configShared-nnIwDYY-.js → configShared-YKfS6NMe.js} +1 -1
  11. package/dist/{cwdConflictWarn-CnxkLs7T.js → cwdConflictWarn-CJZRp4S0.js} +1 -1
  12. package/dist/{e2e-D5jaSHyY.js → e2e-DB7BDdEA.js} +1 -1
  13. package/dist/{expose-CKWXAkzK.js → expose-GWRXTGpj.js} +1 -1
  14. package/dist/{forkNested-DJ2QzBsc.js → forkNested-BWdv2mXC.js} +1 -1
  15. package/dist/index.js +3 -4
  16. package/dist/{nodeRuntime-CR0NwnnO.js → nodeRuntime-CgXWHe9p.js} +1 -1
  17. package/dist/{notifyDaemon-4qxS_FjO.js → notifyDaemon-CyLvaAwc.js} +6 -6
  18. package/dist/{openBrowser-D9VVE6cq.js → openBrowser-CSfI8zCG.js} +1 -1
  19. package/dist/{remotes-DZ_G2wkM.js → remotes-BCgowsiu.js} +3 -3
  20. package/dist/{remotes-BtXom7Rp.js → remotes-ZqD0FHl_.js} +3 -3
  21. package/dist/{rustBinary-KhrcXvB6.js → rustBinary-DHgMI1uK.js} +2 -2
  22. package/dist/{schedule-HHBT6eGE.js → schedule-0p4V_khb.js} +7 -7
  23. package/dist/{serve-BeHHkDIn.js → serve-C2KCUxTP.js} +29 -29
  24. package/dist/{setup-nAQJffmr.js → setup-CCKS4rtW.js} +3 -3
  25. package/dist/{share-BwEUEHfy.js → share-CrvxZO7o.js} +2 -2
  26. package/dist/{share-B7VP4_UF.js → share-ieE7D0C1.js} +2 -2
  27. package/dist/{spawnGate-CfcpmQmk.js → spawnGate-CIC7b7nm.js} +1 -1
  28. package/dist/{spawnGate-ChYCj6Dm.js → spawnGate-CeFYH-Ib.js} +2 -2
  29. package/dist/{subcommands-DYeJJ2Rh.js → subcommands-CsVEI376.js} +5 -5
  30. package/dist/{subcommands-D8zJxifk.js → subcommands-FTkZkqKy.js} +15 -15
  31. package/dist/{todoAutomation-fxtfT_44.js → todoAutomation-Cwnh4aWo.js} +300 -4
  32. package/dist/todoCli-6v1CYdxN.js +427 -0
  33. package/dist/{tray-BZqJtEmL.js → tray-DBt8X-Qj.js} +1 -1
  34. package/dist/{ts-yPc1x2-7.js → ts-CT75CpeM.js} +3 -3
  35. package/dist/{versionChecker-8h_rjT-C.js → versionChecker-Bd09mqkZ.js} +2 -2
  36. package/dist/{webrtcLink-DknpJQ0y.js → webrtcLink-CjckgIZe.js} +2 -2
  37. package/dist/{webrtcRemote-C974zjSt.js → webrtcRemote-BMzEt9Cp.js} +3 -3
  38. package/dist/{workspaceConfig-BRl-GwSN.js → workspaceConfig-De5tgjeO.js} +1 -1
  39. package/dist/{ws-YCNt3rNz.js → ws-B4zHUwc6.js} +7 -7
  40. package/package.json +1 -1
  41. package/ts/todoCli.spec.ts +80 -5
  42. package/ts/todoCli.ts +471 -336
  43. package/ts/todoStore.spec.ts +58 -0
  44. package/ts/todoStore.ts +42 -0
  45. package/dist/SUPPORTED_CLIS-C5vBin1r.js +0 -11
  46. package/dist/callback-BmgFE-2j.js +0 -12
  47. package/dist/todoCli-D8bL164c.js +0 -336
  48. package/dist/todoDigest-DSTG9ksk.js +0 -284
  49. package/dist/todoDigest-gKP1XibO.js +0 -3
package/ts/todoCli.ts CHANGED
@@ -15,25 +15,34 @@
15
15
  * invoking with an explicit `--root`, or by calling `openStore()` as a
16
16
  * library from its own code instead of through this CLI.
17
17
  *
18
- * Argument parsing: exactly ONE yargs pass per invocation, at the verb
19
- * level. `--root`/`--format` are declared as options on EVERY verb's own
20
- * `yargs(args)` call (via `commonOptions()` below) alongside that verb's own
21
- * options, rather than pre-scanned out of argv by a separate pass. An
22
- * earlier design tried a separate outer parse (swallowed every verb's own
23
- * flags — fixed) and then a "--root/--format only recognized at the very
24
- * end of argv" scan (broke completely ordinary invocations like
25
- * `ay todo ls --format json --owner alice`, where --format isn't the last
26
- * token — codex-review Important). A single parse per verb, with every
27
- * option — common and verb-specific — declared on the SAME yargs instance,
28
- * is how yargs is meant to be used and has neither problem: it correctly
29
- * separates recognized flags from positional text regardless of order.
18
+ * Argument parsing: a real yargs COMMAND TREE — one `yargs(argv)` instance
19
+ * with a `CommandModule` per verb registered via `.command()`, `--root`/
20
+ * `--format` declared once as `global: true` options on that same instance —
21
+ * matching the pattern `tools/symval-dev-cli`'s `sv` CLI already uses
22
+ * (`todoCommand`/`lsCmd` etc. in that repo's `commands/todo.ts`), per taku's
23
+ * explicit feedback that this gives better help behavior for free: a real
24
+ * `.command()` tree yields an auto-generated `ay todo --help` listing every
25
+ * verb with its description, and per-verb `--help`/usage, neither of which
26
+ * a hand-rolled switch dispatch provides out of the box.
27
+ *
28
+ * An EARLIER version of this file used a hand-rolled switch with a separate
29
+ * `yargs(args)` parse PER verb (declaring `--root`/`--format` on each one via
30
+ * a shared `commonOptions()` helper) specifically to avoid an even earlier
31
+ * bug where a single outer parse swallowed every verb's own flags. That
32
+ * workaround is no longer needed: the swallowing bug was a property of an
33
+ * outer parse with NO knowledge of subcommand-specific options, not of
34
+ * single-parse command trees in general — a genuine yargs `.command()` tree
35
+ * (this file, now) natively supports `global: true` options that behave
36
+ * correctly regardless of position, which is exactly the position-
37
+ * dependence class of bug the per-verb-parse workaround existed to avoid in
38
+ * the first place.
30
39
  */
31
40
 
32
- import yargs, { type Argv } from "yargs";
41
+ import yargs, { type Argv, type CommandModule } from "yargs";
33
42
  import { openStore, CycleError, type TodoRecord } from "./todoStore.ts";
34
43
  import { isKnownKind, LIFECYCLES, type LifecycleKind } from "./todoLifecycle.ts";
35
44
  import { describeBlock, type TodoBlock } from "./todoBlock.ts";
36
- import { renderDigest, renderTree } from "./todoDigest.ts";
45
+ import { renderDigest, renderTree, buildTreeJSON, unblockedTasks } from "./todoDigest.ts";
37
46
  import { reconcileTodos, type LiveAgent } from "./todoAutomation.ts";
38
47
  import { readGlobalPids } from "./globalPidIndex.ts";
39
48
 
@@ -53,13 +62,14 @@ function renderRecord(t: TodoRecord): string {
53
62
  `${t._id} [${t.state}] ${t.summary}`,
54
63
  `kind: ${t.kind}${t.targetTier ? ` tier:${t.targetTier}` : ""}`,
55
64
  ...(t.owner ? [`owner: ${t.owner}`] : []),
65
+ ...(t.acceptanceCriteria ? [`acceptanceCriteria: ${t.acceptanceCriteria}`] : []),
56
66
  ...(t.block ? [`block: ${describeBlock(t.block)}`] : []),
57
67
  ...(t.blockedBy.length ? [`blockedBy: ${t.blockedBy.join(", ")}`] : []),
58
68
  ...(t.tags.length ? [`tags: ${t.tags.join(", ")}`] : []),
59
69
  ...(t.satisfiedGates.length ? [`satisfiedGates: ${t.satisfiedGates.join(", ")}`] : []),
60
70
  ...(t.verifyEvidence.length
61
71
  ? [
62
- `evidence: ${t.verifyEvidence.map((e) => `${e.gate} by ${e.validator}${e.link ? ` (${e.link})` : ""}`).join("; ")}`,
72
+ `evidence: ${t.verifyEvidence.map((e) => `${e.gate} by ${e.validator}${e.link ? ` (${e.link})` : ""}${e.acceptanceCriteriaAtApproval ? ` [criteria: ${e.acceptanceCriteriaAtApproval}]` : ""}`).join("; ")}`,
63
73
  ]
64
74
  : []),
65
75
  `created: ${t.createdAt}`,
@@ -82,348 +92,473 @@ function renderList(tasks: TodoRecord[]): string {
82
92
  return [line(header), ...rows.map(line)].join("\n");
83
93
  }
84
94
 
85
- interface CommonOpts {
95
+ interface GlobalOpts {
86
96
  root: string;
87
97
  format: "table" | "json";
88
98
  }
89
99
 
90
- function emit(opts: CommonOpts, obj: unknown, human: string): void {
100
+ function emit(opts: GlobalOpts, obj: unknown, human: string): void {
91
101
  process.stdout.write((opts.format === "json" ? JSON.stringify(obj, null, 2) : human) + "\n");
92
102
  }
93
103
 
94
- /** Adds `--root`/`--format` to a verb's own yargs builder — see the module doc comment for why every verb declares these itself rather than a separate outer parse. */
95
- function commonOptions<T>(y: Argv<T>) {
96
- return y
97
- .option("root", {
98
- type: "string" as const,
99
- default: process.cwd(),
100
- describe: "project root holding .agent-yes/todos.jsonl",
101
- })
102
- .option("format", { choices: ["table", "json"] as const, default: "table" as const });
103
- }
104
+ const newCmd: CommandModule<
105
+ GlobalOpts,
106
+ GlobalOpts & {
107
+ summary: string[];
108
+ kind: string | undefined;
109
+ description: string | undefined;
110
+ tier: string | undefined;
111
+ "acceptance-criteria": string | undefined;
112
+ owner: string | undefined;
113
+ tag: string[] | undefined;
114
+ dep: string[] | undefined;
115
+ }
116
+ > = {
117
+ command: "new <summary..>",
118
+ describe: "create a new task",
119
+ builder: (y) =>
120
+ y
121
+ .positional("summary", {
122
+ type: "string",
123
+ array: true,
124
+ demandOption: true,
125
+ describe:
126
+ "task summary (unquoted words are joined — quote if it must contain a literal --flag)",
127
+ })
128
+ .option("kind", { type: "string", describe: `one of: ${Object.keys(LIFECYCLES).join(", ")}` })
129
+ .option("description", { type: "string" })
130
+ .option("tier", { type: "string", describe: "targetTier, e.g. canary-done / shipped-done" })
131
+ .option("acceptance-criteria", {
132
+ type: "string",
133
+ describe:
134
+ "definition of done for this task — what an independent validator should check before approving/verifying it",
135
+ })
136
+ .option("owner", { type: "string" })
137
+ .option("tag", { type: "string", array: true })
138
+ .option("dep", { type: "string", array: true, describe: "blocker task id(s)" }),
139
+ handler: async (argv) => {
140
+ const store = await openStore(argv.root);
141
+ // Join ALL summary words: an unquoted summary like
142
+ // `ay todo new write the spec --kind doc` arrives as multiple array
143
+ // entries under yargs' `<summary..>` variadic positional — using only
144
+ // the first would silently truncate it (codex-review Important, from
145
+ // the previous hand-rolled-positional design; the variadic positional
146
+ // here still needs the same join, it just collects the array for us).
147
+ const summary = argv.summary.map(String).join(" ");
148
+ if (!summary) {
149
+ fail(
150
+ "usage: ay todo new <summary> --kind <kind> [--description ...] [--tier ...] [--acceptance-criteria ...] [--owner ...] [--tag t]... [--dep id]...",
151
+ );
152
+ }
153
+ const rec = await store.create({
154
+ summary,
155
+ kind: parseKind(argv.kind),
156
+ description: argv.description,
157
+ targetTier: argv.tier,
158
+ acceptanceCriteria: argv["acceptance-criteria"],
159
+ owner: argv.owner,
160
+ tags: argv.tag ?? [],
161
+ blockedBy: argv.dep ?? [],
162
+ });
163
+ emit(argv, rec, `created ${rec._id}\n${renderRecord(rec)}`);
164
+ },
165
+ };
104
166
 
105
- function commonOptsOf(a: { root: string; format: "table" | "json" }): CommonOpts {
106
- if (a.root === "") fail("--root must not be empty");
107
- return { root: a.root, format: a.format };
108
- }
167
+ const lsCmd: CommandModule<
168
+ GlobalOpts,
169
+ GlobalOpts & {
170
+ kind: string | undefined;
171
+ state: string | undefined;
172
+ owner: string | undefined;
173
+ tag: string | undefined;
174
+ blocked: boolean;
175
+ }
176
+ > = {
177
+ command: "ls",
178
+ describe: "list tasks (filter by --kind, --state, --owner, --tag, --blocked)",
179
+ builder: (y) =>
180
+ y
181
+ .option("kind", { type: "string" })
182
+ .option("state", { type: "string" })
183
+ .option("owner", { type: "string" })
184
+ .option("tag", { type: "string" })
185
+ .option("blocked", { type: "boolean", default: false }),
186
+ handler: async (argv) => {
187
+ const store = await openStore(argv.root);
188
+ const tasks = store.list({
189
+ kind: argv.kind ? parseKind(argv.kind) : undefined,
190
+ state: argv.state,
191
+ owner: argv.owner,
192
+ tag: argv.tag,
193
+ blocked: argv.blocked,
194
+ });
195
+ emit(argv, tasks, renderList(tasks));
196
+ },
197
+ };
109
198
 
110
- export async function runTodoSubcommand(rest0: string[]): Promise<number> {
111
- const [verb, ...args] = rest0;
199
+ const getCmd: CommandModule<GlobalOpts, GlobalOpts & { id: string }> = {
200
+ command: "get <id>",
201
+ describe: "show one task",
202
+ builder: (y) => y.positional("id", { type: "string", demandOption: true }),
203
+ handler: async (argv) => {
204
+ const store = await openStore(argv.root);
205
+ const rec = store.get(argv.id);
206
+ if (!rec) fail(`no such task: ${argv.id}`);
207
+ emit(argv, rec, renderRecord(rec));
208
+ },
209
+ };
112
210
 
113
- switch (verb) {
114
- case "new": {
115
- const sub = commonOptions(yargs(args))
116
- .option("kind", { type: "string" })
117
- .option("description", { type: "string" })
118
- .option("tier", { type: "string" })
119
- .option("owner", { type: "string" })
120
- .option("tag", { type: "string", array: true })
121
- .option("dep", { type: "string", array: true })
122
- .help(false)
123
- .exitProcess(false);
124
- const a = await sub.parseAsync();
125
- const opts = commonOptsOf(a);
126
- const store = await openStore(opts.root);
127
- // Join ALL positional args, not just a._[0]: an unquoted summary like
128
- // `ay todo new write the spec --kind doc` splits into three separate
129
- // positionals under yargs, and using only the first silently stored
130
- // "write" as the entire summary (codex-review Important). A summary
131
- // that must itself contain a literal "--flag"-shaped word is a narrow,
132
- // separate edge case (yargs will consume it as a flag regardless of
133
- // which parser sees it) — quote the whole summary in that case.
134
- const summary = (a._ as (string | number)[]).map(String).join(" ");
135
- if (!summary) {
136
- fail(
137
- "usage: ay todo new <summary> --kind <kind> [--description ...] [--tier ...] [--owner ...] [--tag t]... [--dep id]...",
138
- );
139
- }
140
- const rec = await store.create({
141
- summary,
142
- kind: parseKind(a.kind as string | undefined),
143
- description: a.description as string | undefined,
144
- targetTier: a.tier as string | undefined,
145
- owner: a.owner as string | undefined,
146
- tags: (a.tag as string[] | undefined) ?? [],
147
- blockedBy: (a.dep as string[] | undefined) ?? [],
148
- });
149
- emit(opts, rec, `created ${rec._id}\n${renderRecord(rec)}`);
150
- return 0;
151
- }
152
- case "ls": {
153
- const sub = commonOptions(yargs(args))
154
- .option("kind", { type: "string" })
155
- .option("state", { type: "string" })
156
- .option("owner", { type: "string" })
157
- .option("tag", { type: "string" })
158
- .option("blocked", { type: "boolean", default: false })
159
- .help(false)
160
- .exitProcess(false);
161
- const a = await sub.parseAsync();
162
- const opts = commonOptsOf(a);
163
- const store = await openStore(opts.root);
164
- const tasks = store.list({
165
- kind: a.kind ? parseKind(a.kind as string) : undefined,
166
- state: a.state as string | undefined,
167
- owner: a.owner as string | undefined,
168
- tag: a.tag as string | undefined,
169
- blocked: a.blocked as boolean,
170
- });
171
- emit(opts, tasks, renderList(tasks));
172
- return 0;
173
- }
174
- case "get": {
175
- const sub = commonOptions(yargs(args)).help(false).exitProcess(false);
176
- const a = await sub.parseAsync();
177
- const opts = commonOptsOf(a);
178
- const store = await openStore(opts.root);
179
- const id = String(a._[0] ?? "");
180
- if (!id) fail("usage: ay todo get <id>");
181
- const rec = store.get(id);
182
- if (!rec) fail(`no such task: ${id}`);
183
- emit(opts, rec, renderRecord(rec));
184
- return 0;
211
+ const transitionCmd: CommandModule<GlobalOpts, GlobalOpts & { id: string; toState: string }> = {
212
+ command: "transition <id> <toState>",
213
+ describe: "move a task to a new state (fails naming the missing gate if one is required)",
214
+ builder: (y) =>
215
+ y
216
+ .positional("id", { type: "string", demandOption: true })
217
+ .positional("toState", { type: "string", demandOption: true }),
218
+ handler: async (argv) => {
219
+ const store = await openStore(argv.root);
220
+ const rec = await store.transition(argv.id, argv.toState);
221
+ emit(argv, rec, `transitioned ${rec._id} -> ${rec.state}\n${renderRecord(rec)}`);
222
+ },
223
+ };
224
+
225
+ const approveCmd: CommandModule<
226
+ GlobalOpts,
227
+ GlobalOpts & {
228
+ id: string;
229
+ gate: string;
230
+ validatorIdentity: string;
231
+ note: string | undefined;
232
+ link: string | undefined;
233
+ }
234
+ > = {
235
+ command: "approve <id> <gate> <validatorIdentity>",
236
+ describe: "manually satisfy a non-registered gate as a DIFFERENT identity from the task's owner",
237
+ builder: (y) =>
238
+ y
239
+ .positional("id", { type: "string", demandOption: true })
240
+ .positional("gate", { type: "string", demandOption: true })
241
+ .positional("validatorIdentity", { type: "string", demandOption: true })
242
+ .option("note", { type: "string" })
243
+ .option("link", { type: "string" }),
244
+ handler: async (argv) => {
245
+ const store = await openStore(argv.root);
246
+ const rec = await store.approve(argv.id, argv.gate, argv.validatorIdentity, {
247
+ note: argv.note,
248
+ link: argv.link,
249
+ });
250
+ emit(
251
+ argv,
252
+ rec,
253
+ `approved "${argv.gate}" on ${rec._id} (validator: ${argv.validatorIdentity})\n${renderRecord(rec)}`,
254
+ );
255
+ },
256
+ };
257
+
258
+ const verifyCmd: CommandModule<GlobalOpts, GlobalOpts & { id: string; gate: string | undefined }> =
259
+ {
260
+ command: "verify <id> [gate]",
261
+ describe: "re-run a registered gate for a task and apply its result",
262
+ builder: (y) =>
263
+ y
264
+ .positional("id", { type: "string", demandOption: true })
265
+ .positional("gate", { type: "string" }),
266
+ handler: async (argv) => {
267
+ const store = await openStore(argv.root);
268
+ const rec = await store.verify(argv.id, argv.gate || undefined);
269
+ emit(argv, rec, `verified ${rec._id} -> ${rec.state}\n${renderRecord(rec)}`);
270
+ },
271
+ };
272
+
273
+ const BLOCK_TYPES = [
274
+ "blocked-by-task",
275
+ "blocked-by-human",
276
+ "blocked-by-external",
277
+ "waiting-on-agent",
278
+ ] as const;
279
+
280
+ const blockCmd: CommandModule<
281
+ GlobalOpts,
282
+ GlobalOpts & {
283
+ id: string;
284
+ type: (typeof BLOCK_TYPES)[number] | undefined;
285
+ task: string | undefined;
286
+ who: string | undefined;
287
+ question: string | undefined;
288
+ options: string[] | undefined;
289
+ signal: string | undefined;
290
+ agent: string | undefined;
291
+ }
292
+ > = {
293
+ command: "block <id>",
294
+ describe:
295
+ "mark a task blocked (--type blocked-by-task|blocked-by-human|blocked-by-external|waiting-on-agent)",
296
+ builder: (y) =>
297
+ y
298
+ .positional("id", { type: "string", demandOption: true })
299
+ .option("type", { type: "string", choices: BLOCK_TYPES })
300
+ .option("task", { type: "string", describe: "required for --type blocked-by-task" })
301
+ .option("who", { type: "string", describe: "required for --type blocked-by-human" })
302
+ .option("question", { type: "string" })
303
+ .option("options", { type: "string", array: true })
304
+ .option("signal", { type: "string", describe: "required for --type blocked-by-external" })
305
+ .option("agent", { type: "string", describe: "required for --type waiting-on-agent" }),
306
+ handler: async (argv) => {
307
+ if (!argv.type) {
308
+ fail(
309
+ "usage: ay todo block <id> --type <blocked-by-task|blocked-by-human|blocked-by-external|waiting-on-agent> ...",
310
+ );
185
311
  }
186
- case "transition": {
187
- const sub = commonOptions(yargs(args)).help(false).exitProcess(false);
188
- const a = await sub.parseAsync();
189
- const opts = commonOptsOf(a);
190
- const store = await openStore(opts.root);
191
- const [id, toState] = (a._ as (string | number)[]).map(String);
192
- if (!id || !toState) fail("usage: ay todo transition <id> <toState>");
193
- const rec = await store.transition(id, toState);
194
- emit(opts, rec, `transitioned ${rec._id} -> ${rec.state}\n${renderRecord(rec)}`);
195
- return 0;
312
+ let block: TodoBlock;
313
+ switch (argv.type) {
314
+ case "blocked-by-task":
315
+ if (!argv.task) fail("--task <id> is required for --type blocked-by-task");
316
+ block = { type: "blocked-by-task", taskId: argv.task };
317
+ break;
318
+ case "blocked-by-human":
319
+ if (!argv.who) fail("--who <name> is required for --type blocked-by-human");
320
+ block = {
321
+ type: "blocked-by-human",
322
+ who: argv.who,
323
+ question: argv.question,
324
+ options: argv.options,
325
+ };
326
+ break;
327
+ case "blocked-by-external":
328
+ if (!argv.signal) fail("--signal <name> is required for --type blocked-by-external");
329
+ block = { type: "blocked-by-external", signal: argv.signal };
330
+ break;
331
+ case "waiting-on-agent":
332
+ if (!argv.agent) fail("--agent <id> is required for --type waiting-on-agent");
333
+ block = { type: "waiting-on-agent", agentId: argv.agent };
334
+ break;
196
335
  }
197
- case "approve": {
198
- const sub = commonOptions(yargs(args))
199
- .option("note", { type: "string" })
200
- .option("link", { type: "string" })
201
- .help(false)
202
- .exitProcess(false);
203
- const a = await sub.parseAsync();
204
- const opts = commonOptsOf(a);
205
- const store = await openStore(opts.root);
206
- const [id, gate, validator] = (a._ as (string | number)[]).map(String);
207
- if (!id || !gate || !validator) {
208
- fail("usage: ay todo approve <id> <gate> <validatorIdentity> [--note ...] [--link ...]");
209
- }
210
- const rec = await store.approve(id, gate, validator, {
211
- note: a.note as string | undefined,
212
- link: a.link as string | undefined,
213
- });
336
+ const store = await openStore(argv.root);
337
+ const rec = await store.setBlock(argv.id, block);
338
+ emit(argv, rec, `blocked ${rec._id}: ${describeBlock(block)}\n${renderRecord(rec)}`);
339
+ },
340
+ };
341
+
342
+ const unblockCmd: CommandModule<GlobalOpts, GlobalOpts & { id: string }> = {
343
+ command: "unblock <id>",
344
+ describe: "clear a task's block (does not touch blockedBy — see `dep`)",
345
+ builder: (y) => y.positional("id", { type: "string", demandOption: true }),
346
+ handler: async (argv) => {
347
+ const store = await openStore(argv.root);
348
+ const rec = await store.setBlock(argv.id, null);
349
+ emit(argv, rec, `unblocked ${rec._id}\n${renderRecord(rec)}`);
350
+ },
351
+ };
352
+
353
+ const setCriteriaCmd: CommandModule<GlobalOpts, GlobalOpts & { id: string; text: string[] }> = {
354
+ command: "set-criteria <id> <text..>",
355
+ describe: "set or update a task's acceptance criteria (definition of done)",
356
+ builder: (y) =>
357
+ y
358
+ .positional("id", { type: "string", demandOption: true })
359
+ .positional("text", { type: "string", array: true, demandOption: true }),
360
+ handler: async (argv) => {
361
+ const store = await openStore(argv.root);
362
+ const text = argv.text.map(String).join(" ");
363
+ const rec = await store.setAcceptanceCriteria(argv.id, text);
364
+ emit(argv, rec, `set acceptance criteria on ${rec._id}\n${renderRecord(rec)}`);
365
+ },
366
+ };
367
+
368
+ const depCmd: CommandModule<
369
+ GlobalOpts,
370
+ GlobalOpts & { verb: "add" | "rm"; id: string; blockerId: string }
371
+ > = {
372
+ command: "dep <verb> <id> <blockerId>",
373
+ describe:
374
+ "manage task dependencies: dep add T2 T1 (T2 waits for T1) / dep rm T2 T1. Cycles are rejected",
375
+ builder: (y) =>
376
+ y
377
+ .positional("verb", { type: "string", choices: ["add", "rm"] as const, demandOption: true })
378
+ .positional("id", {
379
+ type: "string",
380
+ demandOption: true,
381
+ describe: "the task that is blocked",
382
+ })
383
+ .positional("blockerId", {
384
+ type: "string",
385
+ demandOption: true,
386
+ describe: "task id it waits for",
387
+ }),
388
+ handler: async (argv) => {
389
+ const store = await openStore(argv.root);
390
+ try {
391
+ const rec =
392
+ argv.verb === "add"
393
+ ? await store.addDep(argv.id, argv.blockerId)
394
+ : await store.rmDep(argv.id, argv.blockerId);
214
395
  emit(
215
- opts,
396
+ argv,
216
397
  rec,
217
- `approved "${gate}" on ${rec._id} (validator: ${validator})\n${renderRecord(rec)}`,
398
+ `${argv.verb === "add" ? "added" : "removed"} dep ${argv.blockerId} on ${rec._id}\n${renderRecord(rec)}`,
218
399
  );
219
- return 0;
220
- }
221
- case "verify": {
222
- const sub = commonOptions(yargs(args)).help(false).exitProcess(false);
223
- const a = await sub.parseAsync();
224
- const opts = commonOptsOf(a);
225
- const store = await openStore(opts.root);
226
- const [id, gate] = (a._ as (string | number)[]).map(String);
227
- if (!id) fail("usage: ay todo verify <id> [gateName]");
228
- const rec = await store.verify(id, gate || undefined);
229
- emit(opts, rec, `verified ${rec._id} -> ${rec.state}\n${renderRecord(rec)}`);
230
- return 0;
231
- }
232
- case "block": {
233
- const sub = commonOptions(yargs(args))
234
- .option("type", {
235
- type: "string",
236
- choices: [
237
- "blocked-by-task",
238
- "blocked-by-human",
239
- "blocked-by-external",
240
- "waiting-on-agent",
241
- ] as const,
242
- })
243
- .option("task", { type: "string" })
244
- .option("who", { type: "string" })
245
- .option("question", { type: "string" })
246
- .option("options", { type: "string", array: true })
247
- .option("signal", { type: "string" })
248
- .option("agent", { type: "string" })
249
- .help(false)
250
- .exitProcess(false);
251
- const a = await sub.parseAsync();
252
- const opts = commonOptsOf(a);
253
- const store = await openStore(opts.root);
254
- const id = String(a._[0] ?? "");
255
- if (!id || !a.type) {
256
- fail(
257
- "usage: ay todo block <id> --type <blocked-by-task|blocked-by-human|blocked-by-external|waiting-on-agent> ...",
258
- );
259
- }
260
- let block: TodoBlock;
261
- switch (a.type) {
262
- case "blocked-by-task":
263
- if (!a.task) fail("--task <id> is required for --type blocked-by-task");
264
- block = { type: "blocked-by-task", taskId: a.task as string };
265
- break;
266
- case "blocked-by-human":
267
- if (!a.who) fail("--who <name> is required for --type blocked-by-human");
268
- block = {
269
- type: "blocked-by-human",
270
- who: a.who as string,
271
- question: a.question as string | undefined,
272
- options: a.options as string[] | undefined,
273
- };
274
- break;
275
- case "blocked-by-external":
276
- if (!a.signal) fail("--signal <name> is required for --type blocked-by-external");
277
- block = { type: "blocked-by-external", signal: a.signal as string };
278
- break;
279
- case "waiting-on-agent":
280
- if (!a.agent) fail("--agent <id> is required for --type waiting-on-agent");
281
- block = { type: "waiting-on-agent", agentId: a.agent as string };
282
- break;
283
- default:
284
- fail(`unknown block type: ${a.type}`);
285
- }
286
- const rec = await store.setBlock(id, block);
287
- emit(opts, rec, `blocked ${rec._id}: ${describeBlock(block)}\n${renderRecord(rec)}`);
288
- return 0;
289
- }
290
- case "unblock": {
291
- const sub = commonOptions(yargs(args)).help(false).exitProcess(false);
292
- const a = await sub.parseAsync();
293
- const opts = commonOptsOf(a);
294
- const store = await openStore(opts.root);
295
- const id = String(a._[0] ?? "");
296
- if (!id) fail("usage: ay todo unblock <id>");
297
- const rec = await store.setBlock(id, null);
298
- emit(opts, rec, `unblocked ${rec._id}\n${renderRecord(rec)}`);
299
- return 0;
400
+ } catch (err) {
401
+ if (err instanceof CycleError) fail(err.message);
402
+ throw err;
300
403
  }
301
- case "dep": {
302
- const sub = commonOptions(yargs(args)).help(false).exitProcess(false);
303
- const a = await sub.parseAsync();
304
- const opts = commonOptsOf(a);
305
- const store = await openStore(opts.root);
306
- const [verb2, id, blockerId] = (a._ as (string | number)[]).map(String);
307
- if (!verb2 || !id || !blockerId || (verb2 !== "add" && verb2 !== "rm")) {
308
- fail("usage: ay todo dep add|rm <id> <blockerId>");
309
- }
310
- try {
311
- const rec =
312
- verb2 === "add" ? await store.addDep(id, blockerId) : await store.rmDep(id, blockerId);
313
- emit(
314
- opts,
315
- rec,
316
- `${verb2 === "add" ? "added" : "removed"} dep ${blockerId} on ${rec._id}\n${renderRecord(rec)}`,
317
- );
318
- return 0;
319
- } catch (err) {
320
- if (err instanceof CycleError) fail(err.message);
321
- throw err;
322
- }
323
- }
324
- case "tree": {
325
- const sub = commonOptions(yargs(args)).help(false).exitProcess(false);
326
- const a = await sub.parseAsync();
327
- const opts = commonOptsOf(a);
328
- const store = await openStore(opts.root);
329
- const rootId = a._[0] !== undefined ? String(a._[0]) : undefined;
330
- const tasks = store.all();
331
- if (opts.format === "json") {
332
- const { buildTreeJSON } = await import("./todoDigest.ts");
333
- emit(opts, buildTreeJSON(tasks, rootId), "");
334
- } else {
335
- process.stdout.write(renderTree(tasks, rootId) + "\n");
336
- }
337
- return 0;
404
+ },
405
+ };
406
+
407
+ const treeCmd: CommandModule<GlobalOpts, GlobalOpts & { id: string | undefined }> = {
408
+ command: "tree [id]",
409
+ describe: "dependency tree (children = what a task waits for). Default: all dependency roots",
410
+ builder: (y) =>
411
+ y.positional("id", { type: "string", describe: "root task id (default: all roots)" }),
412
+ handler: async (argv) => {
413
+ const store = await openStore(argv.root);
414
+ const tasks = store.all();
415
+ if (argv.format === "json") {
416
+ emit(argv, buildTreeJSON(tasks, argv.id), "");
417
+ } else {
418
+ process.stdout.write(renderTree(tasks, argv.id) + "\n");
338
419
  }
339
- case "digest": {
340
- const sub = commonOptions(yargs(args)).help(false).exitProcess(false);
341
- const a = await sub.parseAsync();
342
- const opts = commonOptsOf(a);
343
- const store = await openStore(opts.root);
344
- const tasks = store.all();
345
- if (opts.format === "json") {
346
- const { unblockedTasks } = await import("./todoDigest.ts");
347
- emit(opts, { tasks, unblocked: unblockedTasks(tasks).map((t) => t._id) }, "");
348
- } else {
349
- process.stdout.write(renderDigest(tasks) + "\n");
350
- }
351
- return 0;
420
+ },
421
+ };
422
+
423
+ const digestCmd: CommandModule<GlobalOpts, GlobalOpts> = {
424
+ command: "digest",
425
+ describe: "per-tag board: state counts, blockers, unblocked tasks",
426
+ handler: async (argv) => {
427
+ const store = await openStore(argv.root);
428
+ const tasks = store.all();
429
+ if (argv.format === "json") {
430
+ emit(argv, { tasks, unblocked: unblockedTasks(tasks).map((t) => t._id) }, "");
431
+ } else {
432
+ process.stdout.write(renderDigest(tasks) + "\n");
352
433
  }
353
- case "reconcile": {
354
- const sub = commonOptions(yargs(args)).help(false).exitProcess(false);
355
- const a = await sub.parseAsync();
356
- const opts = commonOptsOf(a);
357
- const store = await openStore(opts.root);
358
- // `liveOnly: false`: a task's owner needs to be checked against a KNOWN
359
- // agent whose latest record says `exited`, not just the currently-live
360
- // set — an exited-but-recorded agent is exactly the orphan signal (see
361
- // todoAutomation.ts's `deadOwnerAgent`).
362
- const agents: LiveAgent[] = await readGlobalPids({ liveOnly: false });
363
- const registeredGates = new Set(store.registeredGateNames());
364
- const actions = reconcileTodos(store.all(), agents, registeredGates);
434
+ },
435
+ };
436
+
437
+ const reconcileCmd: CommandModule<GlobalOpts, GlobalOpts> = {
438
+ command: "reconcile",
439
+ describe:
440
+ "apply automation: orphan dead-owned tasks, clear stale waiting-on-agent blocks, auto-verify, report unblocked tasks",
441
+ handler: async (argv) => {
442
+ const store = await openStore(argv.root);
443
+ // `liveOnly: false`: a task's owner needs to be checked against a KNOWN
444
+ // agent whose latest record says `exited`, not just the currently-live
445
+ // set — an exited-but-recorded agent is exactly the orphan signal (see
446
+ // todoAutomation.ts's `deadOwnerAgent`).
447
+ const agents: LiveAgent[] = await readGlobalPids({ liveOnly: false });
448
+ const registeredGates = new Set(store.registeredGateNames());
449
+ const actions = reconcileTodos(store.all(), agents, registeredGates);
365
450
 
366
- // Every action is applied against a FRESH record inside the store
367
- // (see `markOrphaned`/`clearWaitingOnAgentBlock`), since the snapshot
368
- // `reconcileTodos` decided from can be stale by the time we get here
369
- // (another process may have reassigned the task, changed its block,
370
- // etc. — codex-review Important). A per-action failure is reported as
371
- // a skip, not an aborted reconcile: the remaining actions still apply.
372
- const applied: string[] = [];
373
- for (const action of actions) {
374
- try {
375
- switch (action.type) {
376
- case "orphan": {
377
- await store.markOrphaned(action.taskId, action.expectedOwner, action.candidates);
378
- applied.push(
379
- `orphaned ${action.taskId} (was ${action.from}) — reassign candidates: ${action.candidates.join(", ") || "(none idle)"}`,
380
- );
381
- break;
382
- }
383
- case "clear-waiting-on-agent": {
384
- await store.clearWaitingOnAgentBlock(action.taskId, action.expectedAgentId);
385
- applied.push(
386
- `cleared waiting-on-agent block on ${action.taskId} (agent ${action.expectedAgentId})`,
387
- );
388
- break;
389
- }
390
- case "auto-verify": {
391
- // A registered gate reporting not-passed (or throwing for any
392
- // other reason, e.g. a concurrent state change) is a real
393
- // "not verified yet" outcome, reported as such rather than
394
- // silently claimed as success — reconcile just tries again
395
- // next call (codex-review Important: the previous version
396
- // swallowed every failure and still reported "auto-verified").
397
- const result = await store.verify(action.taskId);
398
- applied.push(`auto-verified ${action.taskId} -> ${result.state}`);
399
- break;
400
- }
401
- case "notify-unblocked": {
402
- // Delivery to the owning agent's own inbox is not wired yet
403
- // (the notify system addresses parent<->child pid trees, a
404
- // different relationship than an arbitrary task owner) —
405
- // surfaced here, on every call (see todoAutomation.ts), so it
406
- // is visible rather than silently dropped or falsely retired.
407
- applied.push(`${action.taskId} is now unblocked (owner: ${action.owner})`);
408
- break;
409
- }
451
+ // Every action is applied against a FRESH record inside the store (see
452
+ // `markOrphaned`/`clearWaitingOnAgentBlock`), since the snapshot
453
+ // `reconcileTodos` decided from can be stale by the time we get here
454
+ // (another process may have reassigned the task, changed its block,
455
+ // etc. — codex-review Important). A per-action failure is reported as a
456
+ // skip, not an aborted reconcile: the remaining actions still apply.
457
+ const applied: string[] = [];
458
+ for (const action of actions) {
459
+ try {
460
+ switch (action.type) {
461
+ case "orphan": {
462
+ await store.markOrphaned(action.taskId, action.expectedOwner, action.candidates);
463
+ applied.push(
464
+ `orphaned ${action.taskId} (was ${action.from}) — reassign candidates: ${action.candidates.join(", ") || "(none idle)"}`,
465
+ );
466
+ break;
467
+ }
468
+ case "clear-waiting-on-agent": {
469
+ await store.clearWaitingOnAgentBlock(action.taskId, action.expectedAgentId);
470
+ applied.push(
471
+ `cleared waiting-on-agent block on ${action.taskId} (agent ${action.expectedAgentId})`,
472
+ );
473
+ break;
474
+ }
475
+ case "auto-verify": {
476
+ // A registered gate reporting not-passed (or throwing for any
477
+ // other reason, e.g. a concurrent state change) is a real "not
478
+ // verified yet" outcome, reported as such rather than silently
479
+ // claimed as success — reconcile just tries again next call
480
+ // (codex-review Important: an earlier version swallowed every
481
+ // failure and still reported "auto-verified").
482
+ const result = await store.verify(action.taskId);
483
+ applied.push(`auto-verified ${action.taskId} -> ${result.state}`);
484
+ break;
485
+ }
486
+ case "notify-unblocked": {
487
+ // Delivery to the owning agent's own inbox is not wired yet (the
488
+ // notify system addresses parent<->child pid trees, a different
489
+ // relationship than an arbitrary task owner) — surfaced here, on
490
+ // every call (see todoAutomation.ts), so it is visible rather
491
+ // than silently dropped or falsely retired.
492
+ applied.push(`${action.taskId} is now unblocked (owner: ${action.owner})`);
493
+ break;
410
494
  }
411
- } catch (err) {
412
- applied.push(
413
- `skipped ${action.type} on ${action.taskId}: ${err instanceof Error ? err.message : String(err)}`,
414
- );
415
495
  }
496
+ } catch (err) {
497
+ applied.push(
498
+ `skipped ${action.type} on ${action.taskId}: ${err instanceof Error ? err.message : String(err)}`,
499
+ );
416
500
  }
417
- emit(
418
- opts,
419
- { actions, applied },
420
- applied.length ? applied.join("\n") : "(nothing to reconcile)",
421
- );
422
- return 0;
423
501
  }
424
- default:
425
- fail(
426
- `unknown "ay todo" verb: "${verb ?? ""}" (expected: new/ls/get/transition/approve/verify/block/unblock/dep/tree/digest/reconcile)`,
427
- );
428
- }
502
+ emit(
503
+ argv,
504
+ { actions, applied },
505
+ applied.length ? applied.join("\n") : "(nothing to reconcile)",
506
+ );
507
+ },
508
+ };
509
+
510
+ export async function runTodoSubcommand(rest0: string[]): Promise<number> {
511
+ await yargs(rest0)
512
+ .scriptName("ay todo")
513
+ .option("root", {
514
+ type: "string",
515
+ default: process.cwd(),
516
+ describe: "project root holding .agent-yes/todos.jsonl",
517
+ global: true,
518
+ })
519
+ .option("format", {
520
+ choices: ["table", "json"] as const,
521
+ default: "table" as const,
522
+ describe: "output format",
523
+ global: true,
524
+ })
525
+ .check((argv) => {
526
+ if ((argv.root as string) === "") fail("--root must not be empty");
527
+ return true;
528
+ })
529
+ .command(newCmd)
530
+ .command(lsCmd)
531
+ .command(getCmd)
532
+ .command(transitionCmd)
533
+ .command(approveCmd)
534
+ .command(verifyCmd)
535
+ .command(setCriteriaCmd)
536
+ .command(blockCmd)
537
+ .command(unblockCmd)
538
+ .command(depCmd)
539
+ .command(treeCmd)
540
+ .command(digestCmd)
541
+ .command(reconcileCmd)
542
+ .demandCommand(
543
+ 1,
544
+ 'unknown "ay todo" verb (expected: new/ls/get/transition/approve/verify/set-criteria/block/unblock/dep/tree/digest/reconcile)',
545
+ )
546
+ .strict()
547
+ .help()
548
+ .version(false)
549
+ .exitProcess(false)
550
+ .fail((msg, err) => {
551
+ // Default yargs behavior on a validation failure (unknown/missing
552
+ // command, failed .check()) is to print usage and, with
553
+ // exitProcess(false), silently resolve rather than reject — the exact
554
+ // opposite of every existing caller's expectation (they `await` this
555
+ // function and `.rejects.toThrow(...)` in tests, matching the rest of
556
+ // this codebase's convention of surfacing errors as thrown Errors, not
557
+ // silent exit codes). Re-throwing here is what makes that true for
558
+ // yargs' own validation errors, not just for `fail()` calls inside a
559
+ // handler body.
560
+ throw err ?? new Error(msg);
561
+ })
562
+ .parseAsync();
563
+ return 0;
429
564
  }