@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.
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { closest, definition, wrap } from "./Help.js"
3
+
4
+ describe("wrap", () => {
5
+ it("fills lines up to the width, never past it", () => {
6
+ expect(wrap("aaa bbb ccc ddd", 7)).toEqual(["aaa bbb", "ccc ddd"])
7
+ })
8
+
9
+ it("keeps a code span on one line", () => {
10
+ expect(wrap("run `gtd help land` now", 12)).toEqual(["run", "`gtd help land`", "now"])
11
+ })
12
+
13
+ it("keeps an indented example paragraph verbatim", () => {
14
+ expect(wrap('text\n\n s=$(x) && sh -c "$s"', 4)).toEqual([
15
+ "text",
16
+ "",
17
+ ' s=$(x) && sh -c "$s"',
18
+ ])
19
+ })
20
+ })
21
+
22
+ describe("definition", () => {
23
+ it("puts a short term beside its description", () => {
24
+ expect(definition("next", "Print it", 10)).toEqual([" next Print it"])
25
+ })
26
+
27
+ it("gives a term too wide for the column a line of its own", () => {
28
+ expect(definition("a-very-long-term", "Text", 10)).toEqual([
29
+ " a-very-long-term",
30
+ " Text",
31
+ ])
32
+ })
33
+ })
34
+
35
+ describe("closest", () => {
36
+ const commands = ["next", "land", "history", "help"]
37
+
38
+ it("finds a dropped letter and a swapped pair", () => {
39
+ expect(closest("nxt", commands)).toBe("next")
40
+ expect(closest("lnad", commands)).toBe("land")
41
+ expect(closest("histroy", commands)).toBe("history")
42
+ })
43
+
44
+ it("suggests nothing for a word unlike every candidate", () => {
45
+ expect(closest("frobnicate", commands)).toBeUndefined()
46
+ })
47
+ })
@@ -0,0 +1,70 @@
1
+ const HELP_WIDTH = 80
2
+
3
+ /**
4
+ * Word-wraps prose to `width` columns, every line prefixed by `indent`.
5
+ * Paragraphs are separated by a blank line; a paragraph whose lines start
6
+ * with two spaces is an example and is kept verbatim (indented, not wrapped).
7
+ */
8
+ const wrapParagraph = (paragraph: string, width: number, indent: string): string[] => {
9
+ const lines: string[] = []
10
+ let line = ""
11
+ // A `code span` is one word: a command split across lines can't be copied.
12
+ for (const word of paragraph.match(/\S*`[^`]*`\S*|\S+/g) ?? []) {
13
+ if (line !== "" && indent.length + line.length + 1 + word.length > width) {
14
+ lines.push(indent + line)
15
+ line = word
16
+ } else {
17
+ line = line === "" ? word : `${line} ${word}`
18
+ }
19
+ }
20
+ if (line !== "") lines.push(indent + line)
21
+ return lines
22
+ }
23
+
24
+ export const wrap = (text: string, width = HELP_WIDTH, indent = ""): string[] =>
25
+ text
26
+ .split("\n\n")
27
+ .flatMap((paragraph, i) => [
28
+ ...(i > 0 ? [""] : []),
29
+ ...(paragraph.startsWith(" ")
30
+ ? paragraph.split("\n").map((l) => indent + l)
31
+ : wrapParagraph(paragraph, width, indent)),
32
+ ])
33
+
34
+ /** `term` and `description` side by side, the description wrapped into the column after `column`; a term too wide for it gets a line of its own. */
35
+ export const definition = (term: string, description: string, column: number): string[] => {
36
+ const cell = ` ${term}`
37
+ const body = wrap(description, HELP_WIDTH, " ".repeat(column))
38
+ if (cell.length + 1 >= column) return [cell, ...body]
39
+ const [first = "", ...rest] = body
40
+ return [cell.padEnd(column) + first.trimStart(), ...rest]
41
+ }
42
+
43
+ /** Optimal-string-alignment distance: Levenshtein plus adjacent transpositions, so `nxet` is one edit from `next`. */
44
+ const distance = (a: string, b: string): number => {
45
+ const d: number[][] = Array.from({ length: a.length + 1 }, (_, i) =>
46
+ Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
47
+ )
48
+ for (let i = 1; i <= a.length; i++) {
49
+ for (let j = 1; j <= b.length; j++) {
50
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
51
+ let best = Math.min(d[i - 1]![j]! + 1, d[i]![j - 1]! + 1, d[i - 1]![j - 1]! + cost)
52
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
53
+ best = Math.min(best, d[i - 2]![j - 2]! + 1)
54
+ }
55
+ d[i]![j] = best
56
+ }
57
+ }
58
+ return d[a.length]![b.length]!
59
+ }
60
+
61
+ /** The candidate closest to `word`, when it is a plausible typo of it: at most one edit per three characters, and at least one. */
62
+ export const closest = (word: string, candidates: readonly string[]): string | undefined => {
63
+ const limit = Math.max(1, Math.floor(word.length / 3))
64
+ let best: { readonly name: string; readonly d: number } | undefined
65
+ for (const name of candidates) {
66
+ const d = distance(word, name)
67
+ if (d <= limit && (best === undefined || d < best.d)) best = { name, d }
68
+ }
69
+ return best?.name
70
+ }
@@ -4,7 +4,7 @@
4
4
  * e2e tier observes. Argv parsing is pinned in `src/cli/Cli.test.ts`.
5
5
  */
6
6
 
7
- import { Cause, Effect, Exit } from "effect"
7
+ import { Cause, Context, Effect, Exit } from "effect"
8
8
  import { PassThrough } from "node:stream"
9
9
  import { afterEach, describe, expect, it, vi } from "vitest"
10
10
 
@@ -17,7 +17,7 @@ vi.mock("node:os", async (importOriginal) => ({
17
17
 
18
18
  import { runCli, type Command } from "./index.js"
19
19
  import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
20
- import { needsOf, runCommand, SelectorUsageError } from "./Program.js"
20
+ import { needsOf, runCommand, SelectorUsageError, type CommandRequirements } from "./Program.js"
21
21
  import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../twins/index.js"
22
22
  import {
23
23
  commitAll,
@@ -26,7 +26,7 @@ import {
26
26
  noteOutcome,
27
27
  restoredOutcome,
28
28
  } from "../emit/index.js"
29
- import { HISTORY_REF } from "../platform/index.js"
29
+ import { GitService, HISTORY_REF, Host } from "../platform/index.js"
30
30
  import { contentHash } from "../workflow/index.js"
31
31
  import { WORKFLOWS_REPO } from "../install/index.js"
32
32
 
@@ -119,6 +119,19 @@ export const defaults = { greeting: "hello" }
119
119
  expect(stderr).toContain("startable: hotfix")
120
120
  })
121
121
 
122
+ it("lists the paths its script commits as comments a path cannot break out of", async () => {
123
+ const repo = seed(WORKFLOW)
124
+ repo.writeFile("NOTE.md", "a note\n")
125
+ repo.writeFile("evil\nrm -rf x", "")
126
+ const { stdout, exitCode } = await run(repo, "--workflow", "hotfix")
127
+ expect(exitCode).toBe(0)
128
+ expect(stdout).toContain("# commits every change in the working tree (git add -A):\n")
129
+ expect(stdout).toContain("# A NOTE.md\n")
130
+ expect(stdout).toContain("evil?rm -rf x")
131
+ expect(stdout).not.toMatch(/^rm -rf x/m)
132
+ expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
133
+ })
134
+
122
135
  it("`default` is not startable by name", async () => {
123
136
  const repo = seed(WORKFLOW)
124
137
  const { exitCode, stderr } = await run(repo, "--workflow", "default")
@@ -1733,7 +1746,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1733
1746
  )
1734
1747
 
1735
1748
  const NO_COMMITS_MESSAGE =
1736
- "gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
1749
+ "gtd: the repository has no commits yet — make an initial commit, then run gtd again"
1737
1750
 
1738
1751
  it("derives exactly the eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
1739
1752
  expect(stateKinds.sort()).toEqual(
@@ -2294,3 +2307,53 @@ export const defaults = ${vars}
2294
2307
  expect(repo.lastCommitMessage()).not.toContain("Gtd-Payload:")
2295
2308
  })
2296
2309
  })
2310
+
2311
+ describe("runCommand — the repository-root guard's messages", () => {
2312
+ const guarded = async (
2313
+ override: (
2314
+ git: Context.Tag.Service<GitService>,
2315
+ host: Context.Tag.Service<Host>,
2316
+ ) => Effect.Effect<unknown, Error, CommandRequirements>,
2317
+ ): Promise<string> => {
2318
+ const exit = await Effect.runPromiseExit(
2319
+ Effect.gen(function* () {
2320
+ return yield* override(yield* GitService, yield* Host)
2321
+ }).pipe(Effect.provide(testLayers(seedIdle()))),
2322
+ )
2323
+ return Exit.isFailure(exit) ? (Cause.squash(exit.cause) as Error).message : ""
2324
+ }
2325
+ const out = { write: () => {}, flush: () => {} }
2326
+ const next = runCommand({ kind: "next" }, { kind: "off" }, out)
2327
+
2328
+ it("outside a git repository says so instead of quoting git", async () => {
2329
+ const message = await guarded((git) =>
2330
+ next.pipe(
2331
+ Effect.provideService(GitService, {
2332
+ ...git,
2333
+ topLevel: () =>
2334
+ Effect.fail(
2335
+ new Error(
2336
+ "git rev-parse --show-toplevel failed (exit 128): fatal: not a git repository (or any of the parent directories): .git",
2337
+ ),
2338
+ ),
2339
+ }),
2340
+ ),
2341
+ )
2342
+ expect(message).toBe("gtd: not a git repository — run gtd inside one (`git init` makes one)")
2343
+ })
2344
+
2345
+ it("from a subdirectory names the root to cd to", async () => {
2346
+ const message = await guarded((_git, host) =>
2347
+ next.pipe(
2348
+ Effect.provideService(Host, {
2349
+ ...host,
2350
+ root: "/repo/my sub",
2351
+ realPath: (path: string) => Effect.succeed(path),
2352
+ }),
2353
+ ),
2354
+ )
2355
+ expect(message).toBe(
2356
+ "gtd: run gtd from the repository root — cd /repo (the current directory is /repo/my sub)",
2357
+ )
2358
+ })
2359
+ })
@@ -33,6 +33,7 @@ import {
33
33
  landFields,
34
34
  noopText,
35
35
  renderBeatJson,
36
+ DEMAND_BRIEFING,
36
37
  renderBeatPlain,
37
38
  renderLandJson,
38
39
  renderLandPlain,
@@ -143,7 +144,7 @@ export type Command =
143
144
  readonly vars: Readonly<Record<string, string>>
144
145
  readonly label: string
145
146
  }
146
- | { readonly kind: "door"; readonly name: string; readonly args: readonly string[] }
147
+ | { readonly kind: "door"; readonly name?: string; readonly args: readonly string[] }
147
148
  | { readonly kind: "doors" }
148
149
  | { readonly kind: "abandon" }
149
150
  | { readonly kind: "restore" }
@@ -566,10 +567,12 @@ const runStartCommand = (
566
567
  const config = yield* (yield* ConfigService).load
567
568
  const resolved = config.workflowNamed(workflow)
568
569
  if (resolved === undefined) {
570
+ const startable =
571
+ config.workflowNames.length > 0
572
+ ? `startable: ${config.workflowNames.join(", ")}`
573
+ : "this config exports no workflow besides its default, which an ordinary start runs"
569
574
  return yield* Effect.fail(
570
- new GtdUsageError(
571
- `gtd --workflow: unknown workflow "${workflow}" — startable: ${config.workflowNames.join(", ")}`,
572
- ),
575
+ new GtdUsageError(`gtd --workflow: unknown workflow "${workflow}" — ${startable}`),
573
576
  )
574
577
  }
575
578
  const rest = yield* restAt(undefined, varOverrides)
@@ -591,35 +594,57 @@ const runStartCommand = (
591
594
  if (plan.kind === "refusal") {
592
595
  return yield* Effect.fail(new Error(plan.message))
593
596
  }
594
- out.write(landingScript(ScriptSurface.render(plan.steps)))
597
+ out.write(listingCommitted(landingScript(ScriptSurface.render(plan.steps)), rest.changes))
595
598
  })
596
599
 
600
+ /**
601
+ * A start commits the whole tree (`git add -A`), so its script says up front
602
+ * which paths that is. Comment lines only: control characters are masked so a
603
+ * path can never end the comment and become a command.
604
+ */
605
+ const listingCommitted = (script: string, changes: readonly PendingChange[]): string => {
606
+ if (changes.length === 0) return script
607
+ const listing = [
608
+ "# commits every change in the working tree (git add -A):",
609
+ ...changes.map((change) => `# ${change.status} ${change.path.replace(/\p{Cc}/gu, "?")}`),
610
+ ].join("\n")
611
+ const at = script.indexOf("\n\n")
612
+ return at === -1 ? script : `${script.slice(0, at)}\n\n${listing}${script.slice(at)}`
613
+ }
614
+
597
615
  const doorSynopsis = (name: string, door: Door): string =>
598
616
  [
599
617
  name,
600
618
  ...(door.args ?? []).map((arg) => (arg.optional === true ? `[${arg.name}]` : `<${arg.name}>`)),
601
619
  ].join(" ")
602
620
 
621
+ const noSuchDoor = (name: string | undefined, doors: ReadonlyMap<string, Door>): GtdUsageError => {
622
+ const listing =
623
+ doors.size > 0
624
+ ? `doors: ${[...doors].map(([n, d]) => doorSynopsis(n, d)).join(", ")}`
625
+ : "this config has no doors"
626
+ return new GtdUsageError(
627
+ name === undefined
628
+ ? `gtd door: missing door name — ${listing}`
629
+ : `gtd door: unknown door "${name}" — ${listing}`,
630
+ )
631
+ }
632
+
603
633
  /**
604
634
  * `gtd door <name> [args…]`: `gtd --workflow` with the door's workflow and the
605
635
  * process settings its `vars` derive from the positional args. A wrong name or
606
636
  * arity is a usage error naming what would have worked.
607
637
  */
608
638
  const runDoorCommand = (
609
- name: string,
639
+ name: string | undefined,
610
640
  args: readonly string[],
611
641
  out: ArtifactOut,
612
642
  ): Effect.Effect<void, Error, CommandRequirements> =>
613
643
  Effect.gen(function* () {
614
644
  const config = yield* (yield* ConfigService).load
615
- const door = config.doors.get(name)
616
- if (door === undefined) {
617
- const known = [...config.doors]
618
- return yield* Effect.fail(
619
- new GtdUsageError(
620
- `gtd door: unknown door "${name}" — doors: ${known.map(([n, d]) => doorSynopsis(n, d)).join(", ")}`,
621
- ),
622
- )
645
+ const door = name === undefined ? undefined : config.doors.get(name)
646
+ if (name === undefined || door === undefined) {
647
+ return yield* Effect.fail(noSuchDoor(name, config.doors))
623
648
  }
624
649
  const declared = door.args ?? []
625
650
  const required = declared.filter((arg) => arg.optional !== true).length
@@ -650,6 +675,11 @@ const runDoorsCommand = (
650
675
  const config = yield* (yield* ConfigService).load
651
676
  const doors = [...config.doors]
652
677
  if (json.kind === "off") {
678
+ if (doors.length === 0) {
679
+ yield* (yield* Narrator).warn(
680
+ "gtd doors: this config has no doors — a workflow factory's `doors` option or the config's `doors` export adds one",
681
+ )
682
+ }
653
683
  out.write(doors.map(([n, d]) => `${doorSynopsis(n, d)} → ${d.workflow}\n`).join(""))
654
684
  return
655
685
  }
@@ -849,6 +879,12 @@ const runNextCommand = (
849
879
  const validateCommand = emitsValidatablePrompt(rendered)
850
880
  ? selfValidateCommand(rest.def, rest.state, rendered.mode!, rendered.file!)
851
881
  : undefined
882
+ if (DEMAND_BRIEFING[fields.kind].suppressHeader) {
883
+ // A prompt's stdout is the agent's input, so its header goes to stderr;
884
+ // rendered as a header-showing kind to reuse the one header layout.
885
+ const shown = renderBeatPlain({ ...fields, kind: "message" })
886
+ yield* narrator.warn(shown.slice(0, shown.indexOf("\n\n")))
887
+ }
852
888
  out.write(renderBeatPlain(fields, validateCommand))
853
889
  }
854
890
  })
@@ -1125,19 +1161,30 @@ const runUiCliCommand = (
1125
1161
  */
1126
1162
  const assertRunningFromRepoRoot = (git: GitOperations, host: HostOps): Effect.Effect<void, Error> =>
1127
1163
  Effect.gen(function* () {
1128
- const topLevel = yield* git.topLevel()
1164
+ const topLevel = yield* git
1165
+ .topLevel()
1166
+ .pipe(
1167
+ Effect.mapError((e) =>
1168
+ /not a git repository/.test(e.message)
1169
+ ? new Error("gtd: not a git repository — run gtd inside one (`git init` makes one)")
1170
+ : e,
1171
+ ),
1172
+ )
1129
1173
  const topReal = yield* host.realPath(topLevel)
1130
1174
  const cwdReal = yield* host.realPath(host.root)
1131
1175
  if (topReal !== cwdReal) {
1132
1176
  return yield* Effect.fail(
1133
1177
  new Error(
1134
- `gtd must be run from the repository root (${topLevel}); ` +
1135
- `the current directory is ${host.root}`,
1178
+ `gtd: run gtd from the repository root — cd ${shellWord(topLevel)} ` +
1179
+ `(the current directory is ${host.root})`,
1136
1180
  ),
1137
1181
  )
1138
1182
  }
1139
1183
  })
1140
1184
 
1185
+ const shellWord = (path: string): string =>
1186
+ /^[\w@%+=:,./-]+$/.test(path) ? path : `'${path.replace(/'/g, "'\\''")}'`
1187
+
1141
1188
  /** A command that derives workflow state needs at least one commit too — a commitless repository has no HEAD to resolve a `Rest` at. */
1142
1189
  const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Error> =>
1143
1190
  Effect.gen(function* () {
@@ -1145,7 +1192,7 @@ const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Err
1145
1192
  if (!hasCommits) {
1146
1193
  return yield* Effect.fail(
1147
1194
  new Error(
1148
- "gtd requires a repository with at least one commit — make an initial commit, then run gtd again",
1195
+ "gtd: the repository has no commits yet — make an initial commit, then run gtd again",
1149
1196
  ),
1150
1197
  )
1151
1198
  }
package/src/edge/Edge.ts CHANGED
@@ -995,7 +995,7 @@ export const restAt = (
995
995
  if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
996
996
  return yield* Effect.fail(
997
997
  new Error(
998
- `gtd config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
998
+ `gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
999
999
  ),
1000
1000
  )
1001
1001
  }
@@ -359,7 +359,7 @@ describe("real repo — an assembled script end to end", () => {
359
359
  describe("combinedScript — the plain-text write commands' single pasteable script", () => {
360
360
  it("DID_NOT_RUN_COMMENT points the reader at sh, not bash", () => {
361
361
  expect(DID_NOT_RUN_COMMENT).toBe(
362
- "# gtd emitted this and did NOT run it — pipe it into `sh` to land the turn",
362
+ "# gtd emitted this and did NOT run it — run it with `sh` to apply it",
363
363
  )
364
364
  expect(DID_NOT_RUN_COMMENT).not.toContain("bash")
365
365
  })
package/src/emit/Emit.ts CHANGED
@@ -112,7 +112,7 @@ export const emitScripts = (
112
112
 
113
113
  /** Exported so `src/twins/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
114
114
  export const DID_NOT_RUN_COMMENT =
115
- "# gtd emitted this and did NOT run it — pipe it into `sh` to land the turn"
115
+ "# gtd emitted this and did NOT run it — run it with `sh` to apply it"
116
116
 
117
117
  export const PRESENTATION_ONLY_COMMENT = "# presentation only — safe to skip"
118
118
 
@@ -52,7 +52,9 @@ export const defineWorkflow = <S extends StandardSchemaV1>(
52
52
  // A factory call used to be a flow; awaiting one in a composed flow must fail, not no-op.
53
53
  // oxlint-disable-next-line unicorn/no-thenable -- awaiting a call must fail
54
54
  then: () => {
55
- throw new Error("a configured workflow is not a flow — compose fixFlow/reviewFlow instead")
55
+ throw new Error(
56
+ "a configured workflow is not a flow — await the flow functions it is built from instead",
57
+ )
56
58
  },
57
59
  })
58
60
  return Object.assign(factory, { [FACTORY]: internals }) as never