@pmelab/gtd 59.0.0 → 59.0.1

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.
@@ -1,9 +1,66 @@
1
- import { n as testRepo, t as runsItsScript } from "../chunks/testkit-CMBAAyRK.mjs";
1
+ import { n as testRepo, t as runsItsScript } from "../chunks/testkit-07Z_ENch.mjs";
2
2
  import { resolve } from "node:path";
3
3
  import assert from "node:assert";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
6
+ //#region src/gherkin/messages.ts
7
+ const CONTENT_LINES = 5;
8
+ const reason = (error) => error instanceof Error ? error.message : String(error);
9
+ const beatLines = async (repo) => {
10
+ try {
11
+ const beat = await repo.next();
12
+ const content = beat.content.trimEnd().split("\n");
13
+ const more = content.length - CONTENT_LINES;
14
+ return [
15
+ `step "${beat.state}" (${beat.kind}, ${beat.actor}${beat.idle ? ", idle" : ""})`,
16
+ "content:",
17
+ ...content.slice(0, CONTENT_LINES).map((line) => ` | ${line}`),
18
+ ...more > 0 ? [` | … ${more} more lines`] : []
19
+ ];
20
+ } catch (error) {
21
+ return [`unknown — gtd next failed: ${reason(error)}`];
22
+ }
23
+ };
24
+ /** Where the process rests, for a failed outcome to end with. Never throws: it describes a failure. */
25
+ const restDescription = async (repo, neverUsed) => {
26
+ let lines;
27
+ try {
28
+ const r = repo();
29
+ lines = [
30
+ ...await beatLines(r),
31
+ "last commits:",
32
+ ...r.subjects().slice(-5).map((subject) => ` ${subject}`)
33
+ ];
34
+ } catch (error) {
35
+ lines = [`unknown — ${reason(error)}`];
36
+ }
37
+ if (neverUsed.length > 0) lines.push(`actors never used: ${neverUsed.map((step) => `"${step}"`).join(", ")}`);
38
+ return ["where the process rests:", ...lines.map((line) => ` ${line}`)].join("\n");
39
+ };
40
+ /** Both sides of a failed `contains`: the text looked for, and what was there. */
41
+ const containsFailure = (where, text, content, expected) => content === void 0 ? `${where} does not exist${expected ? `, so it cannot contain "${text}"` : ""}` : `${where} ${expected ? "does not contain" : "contains"} "${text}"; it holds:\n${content}`;
42
+ /** What `attempt` was refused with; fails when it was not refused at all. */
43
+ const refusal = async (attempt, expectation) => {
44
+ try {
45
+ await attempt;
46
+ } catch (error) {
47
+ return error instanceof Error ? error.message : String(error);
48
+ }
49
+ assert.fail(`expected a refusal ${expectation}, but nothing was refused`);
50
+ };
51
+ const refusedWith = async (attempt, message) => {
52
+ const got = await refusal(attempt, `with "${message}"`);
53
+ if (!got.includes(message)) assert.fail(`expected a refusal with "${message}", but it was refused with:\n${got}`);
54
+ };
55
+ const assertContains = (where, text, content) => {
56
+ if (content === void 0 || !content.includes(text)) assert.fail(containsFailure(where, text, content, true));
57
+ };
58
+ const assertLacks = (where, text, content) => {
59
+ if (content === void 0 || content.includes(text)) assert.fail(containsFailure(where, text, content, false));
60
+ };
61
+ //#endregion
6
62
  //#region src/gherkin/index.ts
63
+ const usedActors = /* @__PURE__ */ new WeakMap();
7
64
  var TestkitWorld = class extends QuickPickleWorld {
8
65
  constructor(context, info) {
9
66
  super(context, info);
@@ -51,6 +108,8 @@ var TestkitWorld = class extends QuickPickleWorld {
51
108
  }
52
109
  async drive() {
53
110
  const actors = {};
111
+ const used = usedActors.get(this) ?? /* @__PURE__ */ new Set();
112
+ usedActors.set(this, used);
54
113
  for (const [step, actions] of this.actions) actors[step] = async (beat, repo) => {
55
114
  for (const action of actions) await action(beat, repo);
56
115
  };
@@ -67,12 +126,35 @@ var TestkitWorld = class extends QuickPickleWorld {
67
126
  return hit[1];
68
127
  };
69
128
  }
70
- await this.repo().drive(actors);
129
+ const tracked = {};
130
+ for (const [step, actor] of Object.entries(actors)) tracked[step] = (beat, repo) => {
131
+ used.add(step);
132
+ return actor(beat, repo);
133
+ };
134
+ await this.repo().drive(tracked);
71
135
  }
72
136
  };
73
137
  setWorldConstructor(TestkitWorld);
74
138
  const withNewline = (content) => content.endsWith("\n") ? content : `${content}\n`;
75
139
  const args = (list) => list.split(" ").filter((arg) => arg !== "");
140
+ const neverUsed = (world) => {
141
+ const used = usedActors.get(world) ?? /* @__PURE__ */ new Set();
142
+ return [.../* @__PURE__ */ new Set([
143
+ ...world.actions.keys(),
144
+ ...world.verdicts.keys(),
145
+ ...world.conditionalVerdicts.keys()
146
+ ])].filter((step) => !used.has(step));
147
+ };
148
+ /** An outcome step: a failure ends with where the process rests. */
149
+ const outcome = (check) => async (world, ...rest) => {
150
+ try {
151
+ await check(world, ...rest);
152
+ } catch (error) {
153
+ const failure = error instanceof Error ? error : new Error(String(error));
154
+ failure.message += `\n\n${await restDescription(() => world.repo(), neverUsed(world))}`;
155
+ throw failure;
156
+ }
157
+ };
76
158
  Given("a gtd config with:", (world, source) => {
77
159
  world.configure(withNewline(source));
78
160
  });
@@ -197,87 +279,85 @@ When("gtd lands", async (world) => {
197
279
  When("the process is driven", async (world) => {
198
280
  await world.drive();
199
281
  });
200
- Then("opening door {string} is refused with {string}", async (world, door, message) => {
201
- await assert.rejects(world.repo().door(door), (error) => error.message.includes(message));
202
- });
203
- Then("starting workflow {string} with {string} is refused with {string}", async (world, workflow, list, message) => {
204
- await assert.rejects(world.repo().start(workflow, ...args(list)), (error) => error.message.includes(message));
205
- });
206
- Then("landing is refused with {string}", async (world, message) => {
207
- await assert.rejects(world.repo().land(), (error) => error.message.includes(message));
208
- });
209
- Then("driving the process is refused with {string}", async (world, message) => {
210
- await assert.rejects(world.drive(), (error) => error.message.includes(message));
211
- });
212
- Then("driving the process is refused without {string}", async (world, message) => {
213
- await assert.rejects(world.drive(), (error) => !error.message.includes(message));
214
- });
215
- Then("the process rests at {string}", async (world, step) => {
282
+ Then("opening door {string} is refused with {string}", outcome(async (world, door, message) => {
283
+ await refusedWith(world.repo().door(door), message);
284
+ }));
285
+ Then("starting workflow {string} with {string} is refused with {string}", outcome(async (world, workflow, list, message) => {
286
+ await refusedWith(world.repo().start(workflow, ...args(list)), message);
287
+ }));
288
+ Then("landing is refused with {string}", outcome(async (world, message) => {
289
+ await refusedWith(world.repo().land(), message);
290
+ }));
291
+ Then("driving the process is refused with {string}", outcome(async (world, message) => {
292
+ await refusedWith(world.drive(), message);
293
+ }));
294
+ Then("driving the process is refused without {string}", outcome(async (world, message) => {
295
+ const got = await refusal(world.drive(), `without "${message}"`);
296
+ if (got.includes(message)) assert.fail(`expected a refusal without "${message}", but it was refused with:\n${got}`);
297
+ }));
298
+ Then("the process rests at {string}", outcome(async (world, step) => {
216
299
  assert.strictEqual((await world.repo().next()).state, step);
217
- });
218
- Then("the resting step's content contains {string}", async (world, text) => {
219
- const { content } = await world.repo().next();
220
- assert.ok(content.includes(text), content);
221
- });
222
- Then("the resting step's content contains the hash of the commit {string}", async (world, subject) => {
300
+ }));
301
+ Then("the resting step's content contains {string}", outcome(async (world, text) => {
302
+ assertContains("the resting step's content", text, (await world.repo().next()).content);
303
+ }));
304
+ Then("the resting step's content contains the hash of the commit {string}", outcome(async (world, subject) => {
223
305
  const repo = world.repo();
224
306
  const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)];
225
307
  assert.ok(hash !== void 0, `no commit "${subject}"`);
226
- const { content } = await repo.next();
227
- assert.ok(content.includes(hash), content);
228
- });
229
- Then("the resting step's content does not contain {string}", async (world, text) => {
230
- const { content } = await world.repo().next();
231
- assert.ok(!content.includes(text), content);
232
- });
233
- Then("the last commit subject is {string}", (world, subject) => {
308
+ assertContains("the resting step's content", hash, (await repo.next()).content);
309
+ }));
310
+ Then("the resting step's content does not contain {string}", outcome(async (world, text) => {
311
+ assertLacks("the resting step's content", text, (await world.repo().next()).content);
312
+ }));
313
+ Then("the last commit subject is {string}", outcome((world, subject) => {
234
314
  assert.strictEqual(world.repo().subjects().at(-1), subject);
235
- });
236
- Then("the commit subjects end with:", (world, list) => {
315
+ }));
316
+ Then("the commit subjects end with:", outcome((world, list) => {
237
317
  const expected = list.split("\n").filter((line) => line.trim() !== "");
238
318
  assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected);
239
- });
319
+ }));
240
320
  const outputOf = async (world, list) => {
241
321
  const result = await world.repo().gtd(...args(list));
242
322
  assert.strictEqual(result.exitCode, 0, result.stderr);
243
323
  return result.stdout;
244
324
  };
245
- Then("the output of gtd {string} contains {string}", async (world, list, text) => {
246
- const out = await outputOf(world, list);
247
- assert.ok(out.includes(text), out);
248
- });
249
- Then("the output of gtd {string} does not contain {string}", async (world, list, text) => {
250
- const out = await outputOf(world, list);
251
- assert.ok(!out.includes(text), out);
252
- });
253
- Then("the output lines of gtd {string} start with:", async (world, list, prefixes) => {
325
+ Then("the output of gtd {string} contains {string}", outcome(async (world, list, text) => {
326
+ assertContains(`the output of gtd ${list}`, text, await outputOf(world, list));
327
+ }));
328
+ Then("the output of gtd {string} does not contain {string}", outcome(async (world, list, text) => {
329
+ assertLacks(`the output of gtd ${list}`, text, await outputOf(world, list));
330
+ }));
331
+ Then("the output lines of gtd {string} start with:", outcome(async (world, list, prefixes) => {
254
332
  const out = (await outputOf(world, list)).split("\n").filter((line) => line !== "");
255
333
  const expected = prefixes.split("\n").filter((line) => line.trim() !== "");
256
- assert.strictEqual(out.length, expected.length, out.join("\n"));
257
- out.forEach((line, i) => assert.ok(line.startsWith(expected[i]), out.join("\n")));
258
- });
259
- Then("{string} contains {string}", (world, path, text) => {
260
- assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`);
261
- });
262
- Then("{string} contains:", (world, path, text) => {
263
- assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`);
264
- });
265
- Then("{string} contains the short hash of the commit {string}", (world, path, subject) => {
334
+ const sides = `expected lines starting with:\n${expected.join("\n")}\ngot:\n${out.join("\n")}`;
335
+ assert.strictEqual(out.length, expected.length, sides);
336
+ out.forEach((line, i) => assert.ok(line.startsWith(expected[i]), sides));
337
+ }));
338
+ Then("{string} contains {string}", outcome((world, path, text) => {
339
+ assertContains(`"${path}"`, text, world.repo().read(path));
340
+ }));
341
+ Then("{string} contains:", outcome((world, path, text) => {
342
+ assertContains(`"${path}"`, text, world.repo().read(path));
343
+ }));
344
+ Then("{string} contains the short hash of the commit {string}", outcome((world, path, subject) => {
266
345
  const repo = world.repo();
267
346
  const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)];
268
347
  assert.ok(hash !== void 0, `no commit "${subject}"`);
269
- assert.ok(repo.read(path)?.includes(hash.slice(0, 7)), `${path}: ${repo.read(path)}`);
270
- });
271
- Then("{string} contains {string} exactly once", (world, path, text) => {
348
+ assertContains(`"${path}"`, hash.slice(0, 7), repo.read(path));
349
+ }));
350
+ Then("{string} contains {string} exactly once", outcome((world, path, text) => {
272
351
  const content = world.repo().read(path) ?? "";
273
- assert.strictEqual(content.split(text).length - 1, 1, `${path}: ${content}`);
274
- });
275
- Then("{string} does not contain {string}", (world, path, text) => {
352
+ const times = content.split(text).length - 1;
353
+ assert.strictEqual(times, 1, `"${path}" contains "${text}" ${times} times; it holds:\n${content}`);
354
+ }));
355
+ Then("{string} does not contain {string}", outcome((world, path, text) => {
356
+ assertLacks(`"${path}"`, text, world.repo().read(path));
357
+ }));
358
+ Then("{string} does not exist", outcome((world, path) => {
276
359
  const content = world.repo().read(path);
277
- assert.ok(content !== void 0 && !content.includes(text), `${path}: ${content}`);
278
- });
279
- Then("{string} does not exist", (world, path) => {
280
- assert.strictEqual(world.repo().read(path), void 0);
281
- });
360
+ assert.strictEqual(content, void 0, `"${path}" exists; it holds:\n${content}`);
361
+ }));
282
362
  //#endregion
283
363
  export { TestkitWorld };
package/dist/testkit.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as testRepo, r as InMemRepo, t as runsItsScript } from "./chunks/testkit-CMBAAyRK.mjs";
1
+ import { n as testRepo, r as InMemRepo, t as runsItsScript } from "./chunks/testkit-07Z_ENch.mjs";
2
2
  export { InMemRepo, runsItsScript, testRepo };
package/dist/wire.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-C12dahrH.mjs";
1
+ import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-BTUhRGXj.mjs";
2
2
  export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, resolveSession, selectPath, stallDiagnosis, statusOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "59.0.0",
3
+ "version": "59.0.1",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -139,7 +139,7 @@ inside. Skills are declared per scope, never on `agent()`, and a driver
139
139
  restricts each turn to its scope's list where it can. `refuse(message)` refuses
140
140
  the pending landing — call it right after the step whose turn you reject.
141
141
  `@pmelab/gtd/flows` exports `requireProgress(file)`, `requireAnswers(file)` and
142
- `requireRevert(edited, base)`, three such checks ready-made.
142
+ `requireRevert(edited, base)` among its ready-made checks.
143
143
 
144
144
  ## Names, commits and history
145
145
 
@@ -206,7 +206,8 @@ divergence later.
206
206
  network or filesystem. Read the tree through the helpers; do IO inside a
207
207
  `run()` body.
208
208
  - Await only a step, `scope()`, or a function that steps. Anything else fails
209
- with `the flow awaited something that is not a step`.
209
+ with `the flow awaited something that is not a step`. gtd does not flag a step
210
+ you forget to await — turn on a `no-floating-promises` lint rule.
210
211
  - One call site per step name — wrap a reused helper in two different
211
212
  `scope()`s.
212
213
  - No `try`/`catch` around a step: `restart()` and refusals travel as exceptions.
@@ -71,7 +71,8 @@ describe("parseArgv — unknown options", () => {
71
71
  expect(plan).toEqual({
72
72
  kind: "usage",
73
73
  stdout: "",
74
- message: "gtd: unknown option '--jsn' — see `gtd --help`",
74
+ message:
75
+ "gtd: unknown option '--jsn' — did you mean `--json`? Run `gtd help <command>` for its options",
75
76
  json: false,
76
77
  })
77
78
  })
@@ -369,17 +370,52 @@ describe("parseArgv — bare/unknown command under --json", () => {
369
370
  }
370
371
  })
371
372
 
372
- it("bare gtd's missing-command message (no --json) points at gtd install, not a bundled loop", () => {
373
+ it("bare gtd prints a short usage, not the overview, and points at gtd help and gtd install", () => {
373
374
  const plan = parseArgv(["node", "gtd.js"])
374
375
  expect(plan.kind).toBe("usage")
375
376
  if (plan.kind === "usage") {
376
377
  expect(plan.message).toContain("missing command")
377
- expect(plan.message).toContain("gtd decides and prints")
378
+ expect(plan.message).toContain("`gtd help`")
378
379
  expect(plan.message).toContain("gtd install")
379
- expect(plan.message).toContain("A complete minimal driver")
380
+ expect(plan.stdout).toMatch(/^Usage: gtd <command>/)
381
+ expect(plan.stdout.split("\n").length).toBeLessThan(10)
380
382
  }
381
383
  })
382
384
 
385
+ it("an unknown command that looks like a typo names the nearest command", () => {
386
+ expect(parseArgv(["node", "gtd.js", "nxt"])).toEqual({
387
+ kind: "usage",
388
+ stdout: "",
389
+ message:
390
+ "gtd: unknown command 'nxt' — did you mean `gtd next`? Run `gtd help` for every command",
391
+ json: false,
392
+ })
393
+ })
394
+
395
+ it("an unknown command nothing resembles points at gtd help only", () => {
396
+ const plan = parseArgv(["node", "gtd.js", "frobnicate"])
397
+ expect(plan.kind === "usage" && plan.message).toBe(
398
+ "gtd: unknown command 'frobnicate' — run `gtd help` for every command",
399
+ )
400
+ })
401
+
402
+ it("a mistyped history subcommand names the nearest one", () => {
403
+ const plan = parseArgv(["node", "gtd.js", "history", "shwo"])
404
+ expect(plan.kind === "usage" && plan.message).toContain("did you mean `gtd history show`?")
405
+ })
406
+
407
+ it("a bare --json followed by a word suggests the = form", () => {
408
+ const plan = parseArgv(["node", "gtd.js", "next", "--json", "kind"])
409
+ expect(plan.kind === "usage" && plan.message).toBe(
410
+ "gtd next: too many arguments — expected none, got: kind — to print one field, write --json=kind",
411
+ )
412
+ })
413
+
414
+ it("an unknown single-dash option is an unknown option, not an extra argument", () => {
415
+ const plan = parseArgv(["node", "gtd.js", "-j", "next"])
416
+ expect(plan.kind === "usage" && plan.message).toContain("unknown option '-j'")
417
+ })
418
+
383
419
  it("an unknown command under --json carries json:true, naming --json's own scope error over the subcommand", () => {
384
420
  const plan = parseArgv(["node", "gtd.js", "bogus", "--json"])
385
421
  expect(plan.kind).toBe("usage")
@@ -891,10 +927,8 @@ describe("parseArgv — doors", () => {
891
927
  })
892
928
  })
893
929
 
894
- it("`gtd door` with no name is a usage error", () => {
895
- const plan = parse("door")
896
- expect(plan.kind).toBe("usage")
897
- if (plan.kind === "usage") expect(plan.message).toContain("gtd door: missing name argument")
930
+ it("`gtd door` with no name parses, so the loaded config can list its doors", () => {
931
+ expect(parse("door")).toMatchObject({ kind: "command", command: { kind: "door", args: [] } })
898
932
  })
899
933
 
900
934
  it("`gtd doors` parses with every --json shape", () => {
@@ -1061,7 +1095,8 @@ describe("parseArgv — gtd strays", () => {
1061
1095
  const plan = parseArgv(["node", "gtd.js", "lsp", "--json"])
1062
1096
  if (plan.kind === "usage") expect(plan.message).toContain("`gtd strays`")
1063
1097
  expect(renderHelp()).toContain("strays <range> [glob...]")
1064
- expect(renderHelp()).toContain("gtd strays only")
1098
+ const help = parseArgv(["node", "gtd.js", "help", "strays"])
1099
+ expect(help.kind === "output" && help.stdout).toContain("--json[=<path>]")
1065
1100
  })
1066
1101
  })
1067
1102
 
@@ -1100,56 +1135,93 @@ describe("standaloneKinds / needsOf", () => {
1100
1135
  })
1101
1136
  })
1102
1137
 
1138
+ const helpFor = (...args: string[]): string => {
1139
+ const plan = parseArgv(["node", "gtd.js", ...args])
1140
+ if (plan.kind !== "output") throw new Error(`no help for ${args.join(" ")}`)
1141
+ return plan.stdout
1142
+ }
1143
+
1144
+ /** The command names the overview lists — ` name ` lines, as the docs pins read them. */
1145
+ const listedCommands = (): string[] =>
1146
+ [...renderHelp().matchAll(/^ {2}([a-z]+(?:-[a-z]+)?(?: [a-z]+)?)(?= )/gm)].map(([, n]) => n!)
1147
+
1103
1148
  describe("renderHelp", () => {
1104
- it("mentions every command and flag, and no removed surface", () => {
1149
+ it("groups the commands and mentions no removed surface", () => {
1105
1150
  const help = renderHelp()
1106
- expect(help).toContain("Usage")
1107
- expect(help).toContain("land")
1151
+ for (const heading of ["Core loop:", "Start and stop:", "Inspect:", "Driver plumbing:"]) {
1152
+ expect(help).toContain(`\n${heading}\n`)
1153
+ }
1108
1154
  expect(help).toContain("--workflow <name>")
1109
1155
  expect(help).toContain("--var")
1110
- expect(help).toContain("abandon")
1111
- expect(help).toContain("restore")
1112
- expect(help).toContain("next")
1113
- expect(help).toContain("validate")
1114
- expect(help).toContain("lsp")
1115
- expect(help).not.toContain("visualize")
1116
- expect(help).not.toMatch(/^ {2}init\b/m)
1117
- expect(help).not.toContain(".gtdrc")
1118
- expect(help).toMatch(/^ {2}ui\b/m)
1119
1156
  expect(help).toContain("check <mode> <file>")
1120
- expect(help).toContain("install")
1121
- expect(help).toContain("base ")
1122
- expect(help).toMatch(/^ {2}judge\b/m)
1123
- expect(help).toContain("judge answer")
1124
- expect(help).toContain("version")
1125
- expect(help).toContain("help")
1126
- expect(help).toContain("--json")
1127
- expect(help).toContain("--port")
1128
- expect(help).not.toContain("--no-open")
1129
- expect(help).toContain("--host")
1130
- expect(help).toContain("--self-signed")
1131
- expect(help).toContain("--dev")
1132
- expect(help).toContain("--cost")
1133
- expect(help).toContain("--model")
1134
- expect(help).toContain("--open-questions")
1135
- expect(help).toContain("--verbose")
1136
- expect(help).toContain("--version, -V")
1137
- expect(help).toContain("--help, -h")
1138
- expect(help).not.toContain("review <commitish>")
1139
- expect(help).not.toContain("format <file>")
1140
- expect(help).not.toContain("--debug")
1141
- expect(help).not.toContain("bin/gtd")
1142
- expect(help).not.toContain("(no command), loop")
1143
- expect(help).not.toContain("--dispatch")
1144
- expect(help).not.toContain("--if-resting")
1145
- expect(help).not.toMatch(/^ {2}serve\b/m)
1157
+ expect(help).toContain("door <name> [args...]")
1158
+ expect(help).toContain("-V, --version")
1159
+ expect(help).toContain("-h, --help")
1160
+ expect(help).toContain("Examples:")
1161
+ for (const removed of [
1162
+ "visualize",
1163
+ ".gtdrc",
1164
+ "--no-open",
1165
+ "--debug",
1166
+ "--dispatch",
1167
+ "bin/gtd",
1168
+ ]) {
1169
+ expect(help).not.toContain(removed)
1170
+ }
1171
+ expect(help).not.toMatch(/^ {2}(init|serve)\b/m)
1146
1172
  expect(help).toMatch(/\n$/)
1147
1173
  })
1148
1174
 
1149
- it("every flag row's name appears in the Options: block and vice versa", () => {
1150
- const help = renderHelp()
1151
- const optionsBlock = help.slice(help.indexOf("Options:"))
1152
- for (const name of FLAG_NAMES) expect(optionsBlock).toContain(name)
1175
+ it("keeps every line of every help text within 80 columns", () => {
1176
+ for (const text of [renderHelp(), ...listedCommands().map((name) => helpFor("help", name))]) {
1177
+ for (const line of text.split("\n")) expect(line.length, line).toBeLessThanOrEqual(80)
1178
+ }
1179
+ })
1180
+
1181
+ it("names no internals", () => {
1182
+ const all = [renderHelp(), ...listedCommands().map((name) => helpFor("help", name))].join("")
1183
+ for (const internal of ["it.processCost", "refs/worktree/gtd/history", "Effect Schema"]) {
1184
+ expect(all).not.toContain(internal)
1185
+ }
1186
+ })
1187
+
1188
+ it("every flag is in the help of a command it is valid for", () => {
1189
+ const all = [renderHelp(), ...listedCommands().map((name) => helpFor("help", name))].join("")
1190
+ for (const name of FLAG_NAMES) expect(all).toContain(name)
1191
+ })
1192
+
1193
+ it("gtd <cmd> --help, gtd <cmd> -h and gtd help <cmd> print that command's own help", () => {
1194
+ const help = helpFor("help", "land")
1195
+ expect(help).toMatch(/^Usage: gtd land \[options\]\n/)
1196
+ expect(help).toContain("--cost=<n>")
1197
+ expect(help).not.toContain("--port")
1198
+ expect(helpFor("land", "--help")).toBe(help)
1199
+ expect(helpFor("land", "-h")).toBe(help)
1200
+ })
1201
+
1202
+ it("a two-word command's help is its own", () => {
1203
+ expect(helpFor("judge", "run", "--help")).toMatch(/^Usage: gtd judge run \[options\]/)
1204
+ expect(helpFor("help", "history", "show")).toMatch(/^Usage: gtd history show <ref>/)
1205
+ })
1206
+
1207
+ it("a flag that means something else per command is described per command", () => {
1208
+ expect(helpFor("help", "land")).toContain("With --cost: the model")
1209
+ expect(helpFor("help", "judge", "run")).toContain("The claude model")
1210
+ })
1211
+
1212
+ it("--help after an unknown command still prints the overview", () => {
1213
+ expect(helpFor("bogus", "--help")).toBe(renderHelp())
1214
+ })
1215
+
1216
+ it("gtd help <unknown> is a usage error naming the nearest command", () => {
1217
+ const plan = parseArgv(["node", "gtd.js", "help", "lnad"])
1218
+ expect(plan).toEqual({
1219
+ kind: "usage",
1220
+ stdout: "",
1221
+ message:
1222
+ "gtd: help: no command 'lnad' — did you mean `gtd help land`? Run `gtd help` for every command",
1223
+ json: false,
1224
+ })
1153
1225
  })
1154
1226
 
1155
1227
  it("the docs/cli.md Commands fenced block equals renderHelp()", () => {
@@ -1159,6 +1231,15 @@ describe("renderHelp", () => {
1159
1231
  expect(match![1] + "\n").toBe(renderHelp())
1160
1232
  })
1161
1233
 
1234
+ it("docs/cli.md's Every command section is every command's help, in the overview's order", () => {
1235
+ const doc = readFileSync(resolve(import.meta.dirname, "../../docs/cli.md"), "utf8")
1236
+ const section = doc.slice(doc.indexOf("\n## Every command\n"))
1237
+ const documented = [...section.matchAll(/^### `gtd ([^`]+)`\n\n```\n([\s\S]*?)```\n/gm)].map(
1238
+ ([, name, help]) => [name, help],
1239
+ )
1240
+ expect(documented).toEqual(listedCommands().map((name) => [name, helpFor("help", name)]))
1241
+ })
1242
+
1162
1243
  it("README.md and COMPATIBILITY.md list exactly the commands", () => {
1163
1244
  const commands = new Set(
1164
1245
  [...renderHelp().matchAll(/^ {2}([a-z]+(?:-[a-z]+)?(?: [a-z]+)?)(?= )/gm)].map(
@@ -1419,7 +1500,7 @@ describe("runCli — stdout stays byte-empty on every failing surface", () => {
1419
1500
  const { stdout, stderr, exitCode } = captured()
1420
1501
  expect(exitCode).toBe(EXIT_RUNTIME_ERROR)
1421
1502
  expect(stdout).toBe("")
1422
- expect(stderr).toContain("gtd requires a repository with at least one commit")
1503
+ expect(stderr).toContain("gtd: the repository has no commits yet")
1423
1504
  })
1424
1505
 
1425
1506
  it("runtime error: a handler's own Effect.fail (not the shared repo-root/commit guard) leaves stdout byte-empty", async () => {