@pmelab/gtd 26.0.0 → 27.0.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 (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -3
  3. package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
  4. package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
  5. package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
  6. package/dist/flows.mjs +15 -1
  7. package/dist/gtd.bundle.mjs +219 -34
  8. package/dist/testkit.mjs +447 -72
  9. package/dist/wire.mjs +2 -2
  10. package/dist/workflows/feature.mjs +1 -1
  11. package/dist/workflows/fix.mjs +1 -1
  12. package/dist/workflows/review.mjs +1 -1
  13. package/package.json +2 -2
  14. package/src/cli/Cli.test.ts +56 -4
  15. package/src/cli/Cli.ts +48 -6
  16. package/src/cli/History.test.ts +2 -2
  17. package/src/cli/Output.ts +3 -1
  18. package/src/cli/Program.test.ts +11 -12
  19. package/src/cli/Program.ts +28 -7
  20. package/src/cli/Strays.test.ts +88 -0
  21. package/src/cli/Strays.ts +56 -0
  22. package/src/edge/Edge.test.ts +113 -16
  23. package/src/edge/Edge.ts +22 -6
  24. package/src/emit/GitScript.test.ts +65 -2
  25. package/src/emit/GitScript.ts +34 -1
  26. package/src/emit/index.ts +1 -0
  27. package/src/flags/index.ts +0 -1
  28. package/src/flows/runtime.test.ts +33 -0
  29. package/src/flows/runtime.ts +31 -0
  30. package/src/platform/Git.test.ts +96 -1
  31. package/src/platform/Git.ts +24 -0
  32. package/src/platform/RetainedHistory.test.ts +9 -0
  33. package/src/platform/RetainedHistory.ts +3 -0
  34. package/src/platform/Workspace.test.ts +1 -1
  35. package/src/platform/index.ts +1 -0
  36. package/src/replay/RefName.test.ts +40 -0
  37. package/src/replay/RefName.ts +19 -0
  38. package/src/replay/Replay.test.ts +193 -2
  39. package/src/replay/Replay.ts +51 -6
  40. package/src/replay/Trailers.test.ts +26 -0
  41. package/src/replay/Trailers.ts +8 -0
  42. package/src/replay/index.ts +1 -0
  43. package/src/step/LandStep.ts +1 -0
  44. package/src/step/RepoSnapshot.ts +2 -0
  45. package/src/step/index.ts +1 -0
  46. package/src/step/planStart.test.ts +5 -5
  47. package/src/step/planStep.test.ts +53 -0
  48. package/src/step/planStep.ts +7 -1
  49. package/src/step/strayPaths.test.ts +17 -0
  50. package/src/step/strayPaths.ts +8 -0
  51. package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
  52. package/src/testkit/EmittedScriptRecognizer.ts +26 -4
  53. package/src/testkit/EmittedScripts.test.ts +182 -0
  54. package/src/testkit/FakeGitOperations.ts +10 -2
  55. package/src/testkit/GitTiers.ts +170 -25
  56. package/src/testkit/InMemRepo.ts +64 -12
  57. package/src/testkit/Layers.test.ts +34 -1
  58. package/src/testkit/Layers.ts +21 -1
  59. package/src/testkit/TestRepo.ts +3 -3
  60. package/src/wire/ExitCodes.test.ts +11 -3
  61. package/src/wire/ExitCodes.ts +3 -0
  62. package/src/wire/Glob.test.ts +35 -0
  63. package/src/wire/Glob.ts +1 -1
  64. package/src/workflow/Workflow.ts +1 -0
  65. package/src/workflow/discovery.test.ts +1 -1
  66. package/src/workflows/shared/text.fixture.ts +1 -0
package/dist/wire.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { C as EXIT_SIGTERM, D as WIRE_SCHEMA, E as UNATTRIBUTED_MODEL, O as renderFormat, S as EXIT_SIGINT, T as ACCESS_REFUSAL, _ 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, 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_USAGE_ERROR, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-DA7q2ikx.mjs";
2
- export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, resolveSession, selectPath, stallDiagnosis, statusOf };
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-BY1S-obW.mjs";
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 };
@@ -1,6 +1,6 @@
1
1
  import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, numeric, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars } from "../flows.mjs";
2
2
  import { review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as unwindFailedMessage, A as architectureDecomposePrompt, B as designSystem, C as fixRisks, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as packagesItemBuildingPrompt, I as buildReviewTriageMessage, J as reviewEditsCapture, K as packagesItemFixSuitePrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as startGateBlockedMessage, R as deferNotesScript, S as fixCheck, T as reviewMissing, V as designTriagePrompt, W as idleMessage, X as reviewNotesCapture, Z as reviewerSystem, _ as FEEDBACK, b as awaitReview, c as refusePendingHandOff, d as defaults, et as unwindFailure, f as envDefaults, g as ARCHITECTURE, h as healthy, i as skills, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as tail, m as gate, n as summary, o as closeReview, q as reviewEditNotesCapture, r as access, t as steering, u as shipped, v as REQUIREMENTS, w as planner, x as coder, y as REVIEW, z as designGateAnswerMessage } from "../chunks/reserved-6pAgFGgB.mjs";
3
+ import { $ as unwindFailedMessage, A as architectureDecomposePrompt, B as designSystem, C as fixRisks, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as packagesItemBuildingPrompt, I as buildReviewTriageMessage, J as reviewEditsCapture, K as packagesItemFixSuitePrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as startGateBlockedMessage, R as deferNotesScript, S as fixCheck, T as reviewMissing, V as designTriagePrompt, W as idleMessage, X as reviewNotesCapture, Z as reviewerSystem, _ as FEEDBACK, b as awaitReview, c as refusePendingHandOff, d as defaults, et as unwindFailure, f as envDefaults, g as ARCHITECTURE, h as healthy, i as skills, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as tail, m as gate, n as summary, o as closeReview, q as reviewEditNotesCapture, r as access, t as steering, u as shipped, v as REQUIREMENTS, w as planner, x as coder, y as REVIEW, z as designGateAnswerMessage } from "../chunks/reserved-BL9krPao.mjs";
4
4
  //#region src/workflows/feature/steps.ts
5
5
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
6
6
  const triage = (base) => agentWithSkills("triage", designTriagePrompt(base), {
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
2
  import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
3
- import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, a as REVIEW_ORIG, d as defaults, f as envDefaults, i as skills, n as summary, p as baseline, r as access, s as handOffTail, t as steering, u as shipped, x as coder, y as REVIEW } from "../chunks/reserved-6pAgFGgB.mjs";
3
+ import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, a as REVIEW_ORIG, d as defaults, f as envDefaults, i as skills, n as summary, p as baseline, r as access, s as handOffTail, t as steering, u as shipped, x as coder, y as REVIEW } from "../chunks/reserved-BL9krPao.mjs";
4
4
  //#region src/workflows/fix/fix.ts
5
5
  /**
6
6
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,5 +1,5 @@
1
1
  import { start } from "../flows.mjs";
2
- import { Y as reviewGateBlockedMessage, c as refusePendingHandOff, d as defaults, f as envDefaults, i as skills, m as gate, n as summary, r as access, s as handOffTail, t as steering, u as shipped } from "../chunks/reserved-6pAgFGgB.mjs";
2
+ import { Y as reviewGateBlockedMessage, c as refusePendingHandOff, d as defaults, f as envDefaults, i as skills, m as gate, n as summary, r as access, s as handOffTail, t as steering, u as shipped } from "../chunks/reserved-BL9krPao.mjs";
3
3
  //#region src/workflows/review/review.ts
4
4
  /**
5
5
  * Pure review of everything since `reviewBase`: a green gate, the quality
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "26.0.0",
3
+ "version": "27.0.0",
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": {
@@ -106,7 +106,7 @@
106
106
  "postbuild": "node scripts/assert-no-test-doubles.mjs",
107
107
  "watch": "tsdown --watch",
108
108
  "bench:replay": "node scripts/bench-replay.mjs",
109
- "test": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:workflows test:e2e:inmem test:e2e:live test:web analyze",
109
+ "test": "jiti scripts/test-states.main.ts",
110
110
  "test:unit": "vitest run --project unit",
111
111
  "test:workflows": "vitest run --project workflows",
112
112
  "test:watch": "vitest",
@@ -981,9 +981,60 @@ describe("parseArgv — gtd history", () => {
981
981
  })
982
982
  })
983
983
 
984
+ describe("parseArgv — gtd strays", () => {
985
+ const strays = (...args: string[]): CliPlan => parseArgv(["node", "gtd.js", "strays", ...args])
986
+
987
+ it("splits the range at '..' and carries the globs", () => {
988
+ const plan = strays("main..feature", "src/**", "*.md")
989
+ expect(plan).toMatchObject({
990
+ kind: "command",
991
+ command: { kind: "strays", base: "main", tip: "feature", globs: ["src/**", "*.md"] },
992
+ })
993
+ })
994
+
995
+ it("an empty side becomes HEAD; no globs is an empty list", () => {
996
+ expect(strays("main..")).toMatchObject({
997
+ command: { base: "main", tip: "HEAD", globs: [] },
998
+ })
999
+ expect(strays("..main")).toMatchObject({ command: { base: "HEAD", tip: "main" } })
1000
+ expect(strays("..")).toMatchObject({ command: { base: "HEAD", tip: "HEAD" } })
1001
+ })
1002
+
1003
+ it("a missing range, a range without '..', a '...' range and a second '..' are usage errors", () => {
1004
+ for (const args of [[], ["main"], ["main...feature"], ["a..b..c"]]) {
1005
+ expect(strays(...args).kind).toBe("usage")
1006
+ }
1007
+ const plan = strays("main")
1008
+ if (plan.kind === "usage") expect(plan.message).toContain("<base>..<tip>")
1009
+ })
1010
+
1011
+ it("--json is in scope: bare and selected", () => {
1012
+ expect(strays("a..b", "--json")).toMatchObject({ kind: "command", json: { kind: "document" } })
1013
+ expect(strays("a..b", "--json=0")).toMatchObject({
1014
+ kind: "command",
1015
+ json: { kind: "select", path: "0" },
1016
+ })
1017
+ })
1018
+
1019
+ it("--json's scope error and help name strays", () => {
1020
+ const plan = parseArgv(["node", "gtd.js", "lsp", "--json"])
1021
+ if (plan.kind === "usage") expect(plan.message).toContain("`gtd strays`")
1022
+ expect(renderHelp()).toContain("strays <range> [glob...]")
1023
+ expect(renderHelp()).toContain("gtd strays only")
1024
+ })
1025
+ })
1026
+
984
1027
  describe("standaloneKinds / needsOf", () => {
985
1028
  it("pins the standalone kinds", () => {
986
- expect(standaloneKinds()).toEqual(["lsp", "check", "uncheck", "install", "loop", "judgeRun"])
1029
+ expect(standaloneKinds()).toEqual([
1030
+ "lsp",
1031
+ "check",
1032
+ "uncheck",
1033
+ "install",
1034
+ "loop",
1035
+ "judgeRun",
1036
+ "strays",
1037
+ ])
987
1038
  })
988
1039
 
989
1040
  it("needsOf matches none/fs for the standalone kinds and state for everything else", () => {
@@ -993,6 +1044,7 @@ describe("standaloneKinds / needsOf", () => {
993
1044
  expect(needsOf("install")).toBe("none")
994
1045
  expect(needsOf("loop")).toBe("none")
995
1046
  expect(needsOf("judgeRun")).toBe("none")
1047
+ expect(needsOf("strays")).toBe("none")
996
1048
  for (const kind of [
997
1049
  "land",
998
1050
  "start",
@@ -1231,7 +1283,7 @@ describe("runCli — exit codes", () => {
1231
1283
  const repo = new InMemRepo()
1232
1284
  repo.writeFile("NOTE.md", "a note\n")
1233
1285
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
1234
- repo.commitAllWithPrefix("gtd: init")
1286
+ repo.seedCommit("gtd: init")
1235
1287
  const { io, captured } = capturingIo(() => testLayers(repo))
1236
1288
  await Effect.runPromise(runCli(["node", "gtd.js", "next", "--json=does.not.exist"], io))
1237
1289
  const { stdout, stderr, exitCode } = captured()
@@ -1316,7 +1368,7 @@ describe("runCli — stdout stays byte-empty on every failing surface", () => {
1316
1368
  // produce zero calls, not merely an empty joined string.
1317
1369
  const repo = new InMemRepo()
1318
1370
  repo.writeFile("README.md", "x\n")
1319
- repo.commitAllWithPrefix("chore: init gtd workflow")
1371
+ repo.seedCommit("chore: init gtd workflow")
1320
1372
  repo.writeFile(".gtd/TODO.md", "## Open Questions\n\n###\n\nno question text.\n")
1321
1373
  const stdoutCalls: string[] = []
1322
1374
  const io: CliIo = {
@@ -1396,7 +1448,7 @@ describe("runCli — flush normalizes the artifact's trailing newline", () => {
1396
1448
  const repo = new InMemRepo()
1397
1449
  repo.writeFile(".gitignore", "node_modules\n")
1398
1450
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
1399
- repo.commitAllWithPrefix("chore: initial commit")
1451
+ repo.seedCommit("chore: initial commit")
1400
1452
  const { io, captured } = capturingIo(() => testLayers(repo))
1401
1453
  await Effect.runPromise(runCli(["node", "gtd.js", "land"], io))
1402
1454
  const { stdout } = captured()
package/src/cli/Cli.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  type JsonMode,
30
30
  } from "./Program.js"
31
31
  import type { ToolCommands } from "./ToolCommands.js"
32
- import { EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
32
+ import { EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
33
33
 
34
34
  export type { Command, CommandRequirements } from "./Program.js"
35
35
  export { needsOf, standaloneKinds } from "./Program.js"
@@ -96,15 +96,16 @@ const FLAGS: readonly FlagRow[] = [
96
96
  kind === "judgeAnswer" ||
97
97
  kind === "doors" ||
98
98
  kind === "historyList" ||
99
- kind === "historyShow",
99
+ kind === "historyShow" ||
100
+ kind === "strays",
100
101
  decode: ([raw]) => Either.right(raw ?? ""),
101
102
  scopeError:
102
- "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show` — " +
103
+ "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show`/`gtd strays` — " +
103
104
  "every other command prints plain text; see `gtd install` for the driver protocol briefing",
104
105
  valueHint: "<path>",
105
106
  help: [
106
107
  "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
107
- "list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
108
+ "list/gtd history show/gtd strays only) output structured JSON. Bare --json prints the whole document;",
108
109
  "--json=<path> (a dotted key path into that document, e.g.",
109
110
  "kind, content, session.id) prints just that value: a",
110
111
  "scalar raw and unquoted, a boolean as true/false, a list",
@@ -667,6 +668,25 @@ const COMMAND_ROWS: readonly CommandRow[] = [
667
668
  "every question, with nothing on stdout",
668
669
  ],
669
670
  },
671
+ {
672
+ token: "strays",
673
+ kind: "strays",
674
+ arity: { name: "range", variadic: "glob" },
675
+ details: [
676
+ "List the files <range> (<base>..<tip>, an empty side is HEAD)",
677
+ "touched in its committed net diff that match none of the",
678
+ "globs. Anchored at the repo root: * stays within one path",
679
+ "segment, ** crosses them, everything else is literal — quote",
680
+ "globs in the shell. stdout: a sorted JSON array, [] when",
681
+ "clean; stderr: a summary, plus one stray: line per path with",
682
+ "--verbose. --json=<n> picks one entry, nothing past the end;",
683
+ "a symlink is a path like any other, never followed; a",
684
+ "submodule counts as its path, whatever diff.ignoreSubmodules",
685
+ "or .gitmodules ignore say. Runs in any git repository, no",
686
+ "process needed. Exits 10 when",
687
+ "strays were found, 1 on an unknown revision, 2 on a bad range",
688
+ ],
689
+ },
670
690
  {
671
691
  token: "history list",
672
692
  kind: "historyList",
@@ -1228,6 +1248,28 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1228
1248
  }
1229
1249
  }
1230
1250
 
1251
+ if (kind === "strays") {
1252
+ const [range, ...globs] = restPositionals as [string, ...string[]]
1253
+ const at = range.indexOf("..")
1254
+ if (at < 0 || range.includes("...") || range.indexOf("..", at + 1) >= 0) {
1255
+ return usagePlan(
1256
+ `gtd strays: <range> must be <base>..<tip> (an empty side is HEAD) — got '${range}'`,
1257
+ jsonSeen,
1258
+ )
1259
+ }
1260
+ return {
1261
+ kind: "command",
1262
+ command: {
1263
+ kind: "strays",
1264
+ base: range.slice(0, at) || "HEAD",
1265
+ tip: range.slice(at + 2) || "HEAD",
1266
+ globs,
1267
+ },
1268
+ json,
1269
+ verbose,
1270
+ }
1271
+ }
1272
+
1231
1273
  if (kind === "uncheck") {
1232
1274
  return {
1233
1275
  kind: "command",
@@ -1419,9 +1461,9 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
1419
1461
  // where the whole buffered artifact is known-complete and safe to hand
1420
1462
  // to stdout (a failure discards the buffer instead — see
1421
1463
  // `bufferedArtifactOut`).
1422
- Effect.map(() => {
1464
+ Effect.map((code) => {
1423
1465
  out.flush()
1424
- io.exit(EXIT_OK)
1466
+ io.exit(code)
1425
1467
  }),
1426
1468
  Effect.provide(io.layers(plan.verbose)),
1427
1469
  Effect.sandbox,
@@ -36,7 +36,7 @@ const finishedProcess = async (repo: InMemRepo, file: string) => {
36
36
  const repoWithConfig = (): InMemRepo => {
37
37
  const repo = new InMemRepo()
38
38
  repo.writeFile("gtd.config.ts", CONFIG)
39
- repo.commitAllWithPrefix("chore: add workflow")
39
+ repo.seedCommit("chore: add workflow")
40
40
  return repo
41
41
  }
42
42
 
@@ -90,7 +90,7 @@ describe("historyEntries", () => {
90
90
  const repo = repoWithConfig()
91
91
  const { entry: hash } = await finishedProcess(repo, "a.txt")
92
92
  repo.writeFile("x.txt", "x\n")
93
- repo.commitAllWithPrefix("chore: bare tip")
93
+ repo.seedCommit("chore: bare tip")
94
94
  repo.updateRef(`refs/gtd/history/${hash}`, "HEAD")
95
95
  expect((await entries(repo))[0]?.branch).toBeNull()
96
96
  })
package/src/cli/Output.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Effect } from "effect"
2
2
  import { CommandRunner, GitService, Host, Narrator, Workspace } from "../platform/index.js"
3
- import { selectPath } from "../wire/index.js"
3
+ import { selectPath, type EXIT_OK, type EXIT_STRAYS_FOUND } from "../wire/index.js"
4
4
  import { ConfigDiscovery, ConfigService } from "../workflow/index.js"
5
5
  import type { ToolCommands } from "./ToolCommands.js"
6
6
 
@@ -66,3 +66,5 @@ export type CommandRequirements =
66
66
  | Narrator
67
67
  | CommandRunner
68
68
  | ToolCommands
69
+
70
+ export type SuccessExit = typeof EXIT_OK | typeof EXIT_STRAYS_FOUND
@@ -60,7 +60,7 @@ const seed = (source: string, extra: Record<string, string> = {}): InMemRepo =>
60
60
  const repo = new InMemRepo()
61
61
  repo.writeFile("gtd.config.ts", source)
62
62
  for (const [path, content] of Object.entries(extra)) repo.writeFile(path, content)
63
- repo.commitAllWithPrefix("chore: add custom workflow")
63
+ repo.seedCommit("chore: add custom workflow")
64
64
  return repo
65
65
  }
66
66
 
@@ -69,7 +69,7 @@ const seedBundled = (): InMemRepo => {
69
69
  const repo = new InMemRepo()
70
70
  repo.writeFile("README.md", "# test project\n")
71
71
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
72
- repo.commitAllWithPrefix("chore: init")
72
+ repo.seedCommit("chore: init")
73
73
  return repo
74
74
  }
75
75
 
@@ -199,7 +199,7 @@ describe("gtd --workflow <name> — the shipped workflows", () => {
199
199
  const repo = seedBundled()
200
200
  const base = repo.commitHistory().at(-1)!.hash
201
201
  repo.writeFile("scratch.txt", "more work\n")
202
- repo.commitAllWithPrefix("chore: more work")
202
+ repo.seedCommit("chore: more work")
203
203
  const before = repo.commitHistory().length
204
204
  const { stdout, exitCode } = await run(
205
205
  repo,
@@ -288,7 +288,7 @@ describe("gtd next --json — log path emission", () => {
288
288
  const repo = new InMemRepo()
289
289
  repo.writeFile("NOTE.md", "a note\n")
290
290
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
291
- repo.commitAllWithPrefix("gtd: init")
291
+ repo.seedCommit("gtd: init")
292
292
  return repo
293
293
  }
294
294
 
@@ -703,7 +703,7 @@ describe("gtd next — refuses when history diverges from what replay reaches",
703
703
  const diverged = async (): Promise<InMemRepo> => {
704
704
  const repo = await atWorking()
705
705
  repo.writeFile("WORK.md", "done\n")
706
- repo.commitAllWithPrefix("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1")
706
+ repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1")
707
707
  return repo
708
708
  }
709
709
 
@@ -752,9 +752,7 @@ describe("a history written in a format this gtd does not read", () => {
752
752
  const repo = seed(IDLE_THEN_WORKING)
753
753
  await landTurn(repo, { "NOTE.md": "a note\n" })
754
754
  repo.writeFile("WORK.md", "done\n")
755
- repo.commitAllWithPrefix(
756
- "gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2",
757
- )
755
+ repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2")
758
756
  return repo
759
757
  }
760
758
 
@@ -779,7 +777,7 @@ describe("a history written in a format this gtd does not read", () => {
779
777
  it("refuses even when a foreign subject hides the commit from the episode", async () => {
780
778
  const repo = seed(IDLE_THEN_WORKING)
781
779
  repo.writeFile("NOTE.md", "a note\n")
782
- repo.commitAllWithPrefix("gtd2[human] idle -> working\n\nGtd-Format: 2")
780
+ repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 2")
783
781
  for (const command of ["next", "abandon"]) {
784
782
  const { exitCode, stderr } = await run(repo, command)
785
783
  expect(exitCode).toBe(1)
@@ -790,7 +788,7 @@ describe("a history written in a format this gtd does not read", () => {
790
788
  it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
791
789
  const repo = seed(IDLE_THEN_WORKING)
792
790
  repo.writeFile("NOTE.md", "a note\n")
793
- repo.commitAllWithPrefix("feat: x\n\nGtd-Format: 2")
791
+ repo.seedCommit("feat: x\n\nGtd-Format: 2")
794
792
  expect((await run(repo, "next")).exitCode).toBe(0)
795
793
  })
796
794
 
@@ -798,7 +796,7 @@ describe("a history written in a format this gtd does not read", () => {
798
796
  const repo = seed(IDLE_THEN_WORKING)
799
797
  const base = repo.resolveRef("HEAD")!
800
798
  repo.writeFile("NOTE.md", "a note\n")
801
- repo.commitAllWithPrefix("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 2")
799
+ repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 2")
802
800
  repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
803
801
  repo.hardResetTo(base)
804
802
  const { exitCode, stderr } = await run(repo, "restore")
@@ -1695,6 +1693,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1695
1693
  historyList: { kind: "historyList" },
1696
1694
  historyShow: { kind: "historyShow", ref: "x" },
1697
1695
  historyFetch: { kind: "historyFetch", remote: "origin" },
1696
+ strays: { kind: "strays", base: "HEAD", tip: "HEAD", globs: [] },
1698
1697
  }
1699
1698
 
1700
1699
  const stateKinds = (Object.keys(commandFor) as Command["kind"][]).filter(
@@ -1949,7 +1948,7 @@ describe("gtd next/land --json=<path> — the select branch", () => {
1949
1948
  const repo = new InMemRepo()
1950
1949
  repo.writeFile("NOTE.md", "a note\n")
1951
1950
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
1952
- repo.commitAllWithPrefix("gtd: init")
1951
+ repo.seedCommit("gtd: init")
1953
1952
  return repo
1954
1953
  }
1955
1954
 
@@ -42,6 +42,7 @@ import {
42
42
  type NextMatch,
43
43
  type StatusChange,
44
44
  selectPath,
45
+ EXIT_OK,
45
46
  } from "../wire/index.js"
46
47
  import {
47
48
  currentRest,
@@ -94,13 +95,16 @@ import {
94
95
  type ArtifactOut,
95
96
  type CommandRequirements,
96
97
  type JsonMode,
98
+ type SuccessExit,
97
99
  } from "./Output.js"
98
100
  export {
99
101
  SelectorUsageError,
100
102
  type ArtifactOut,
101
103
  type CommandRequirements,
102
104
  type JsonMode,
105
+ type SuccessExit,
103
106
  } from "./Output.js"
107
+ import { runStraysCommand } from "./Strays.js"
104
108
  import { runHistoryFetchCommand, runHistoryListCommand, runHistoryShowCommand } from "./History.js"
105
109
 
106
110
  /**
@@ -161,6 +165,13 @@ export type Command =
161
165
  | { readonly kind: "historyList" }
162
166
  | { readonly kind: "historyShow"; readonly ref: string }
163
167
  | { readonly kind: "historyFetch"; readonly remote: string }
168
+ | {
169
+ readonly kind: "strays"
170
+ /** Each side of `<base>..<tip>`; an empty side is `HEAD`, as git reads it. */
171
+ readonly base: string
172
+ readonly tip: string
173
+ readonly globs: readonly string[]
174
+ }
164
175
  | {
165
176
  readonly kind: "judgeRun"
166
177
  readonly provider?: "fixed" | "jev" | "llm"
@@ -1149,6 +1160,7 @@ export const needsOf = (kind: Command["kind"]): Needs => {
1149
1160
  case "install":
1150
1161
  case "loop":
1151
1162
  case "judgeRun":
1163
+ case "strays":
1152
1164
  return "none"
1153
1165
  case "check":
1154
1166
  case "uncheck":
@@ -1166,18 +1178,27 @@ export const standaloneKinds = (): readonly Command["kind"][] => [
1166
1178
  "install",
1167
1179
  "loop",
1168
1180
  "judgeRun",
1181
+ "strays",
1169
1182
  ]
1170
1183
 
1171
1184
  /**
1172
1185
  * Dispatches to the named `run*Command` handler for every `Command.kind` —
1173
1186
  * the counterpart of `Cli.ts`'s `parseArgv`, which has already validated
1174
- * every field a handler receives. A command choosing its own exit code is
1175
- * unrepresentable: every branch returns `Effect<void>`, and `Cli.ts`'s
1176
- * `runCli` supplies `EXIT_OK` once, after this Effect succeeds.
1187
+ * every field a handler receives. Only `strays` chooses its exit code; every
1188
+ * other branch returns `Effect<void>` and resolves to `EXIT_OK`.
1177
1189
  */
1190
+ const dispatchCommand = (
1191
+ command: Command,
1192
+ json: JsonMode,
1193
+ out: ArtifactOut,
1194
+ ): Effect.Effect<SuccessExit, Error, CommandRequirements> => {
1195
+ if (command.kind === "strays") return runStraysCommand(command, json, out)
1196
+ return Effect.as(dispatchVoidCommand(command, json, out), EXIT_OK)
1197
+ }
1198
+
1178
1199
  // fallow-ignore-next-line complexity
1179
1200
  const dispatchVoidCommand = (
1180
- command: Command,
1201
+ command: Exclude<Command, { kind: "strays" }>,
1181
1202
  json: JsonMode,
1182
1203
  out: ArtifactOut,
1183
1204
  ): Effect.Effect<void, Error, CommandRequirements> => {
@@ -1255,8 +1276,8 @@ export const runCommand = (
1255
1276
  command: Command,
1256
1277
  json: JsonMode,
1257
1278
  out: ArtifactOut,
1258
- ): Effect.Effect<void, Error, CommandRequirements> => {
1259
- const dispatch = dispatchVoidCommand(command, json, out)
1279
+ ): Effect.Effect<SuccessExit, Error, CommandRequirements> => {
1280
+ const dispatch = dispatchCommand(command, json, out)
1260
1281
  if (needsOf(command.kind) !== "state") return dispatch
1261
1282
  return Effect.gen(function* () {
1262
1283
  const git = yield* GitService
@@ -1278,6 +1299,6 @@ export const runCommand = (
1278
1299
  for (const warning of config.warnings) {
1279
1300
  yield* narrator.warn(`gtd: warning: ${formatDiagnostic(warning)}`)
1280
1301
  }
1281
- yield* dispatch
1302
+ return yield* dispatch
1282
1303
  })
1283
1304
  }
@@ -0,0 +1,88 @@
1
+ import { Effect } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_STRAYS_FOUND } from "../wire/index.js"
4
+ import { InMemRepo, makeCapturingCliIo } from "../testkit/index.js"
5
+ import { runCli } from "./index.js"
6
+
7
+ const seed = (): InMemRepo => {
8
+ const repo = new InMemRepo()
9
+ repo.writeFile("README.md", "x\n")
10
+ repo.commitAllWithPrefix("base")
11
+ repo.updateRef("base", "HEAD")
12
+ repo.writeFile("src/a.ts", "a\n")
13
+ repo.writeFile("notes/b.md", "b\n")
14
+ repo.writeFile("notes/c.md", "c\n")
15
+ repo.commitAllWithPrefix("work")
16
+ return repo
17
+ }
18
+
19
+ const strays = async (repo: InMemRepo, ...args: string[]) => {
20
+ const { io, result } = makeCapturingCliIo(repo)
21
+ await Effect.runPromise(runCli(["node", "gtd.js", "strays", ...args], io))
22
+ return result()
23
+ }
24
+
25
+ describe("gtd strays — stderr commentary", () => {
26
+ it("quiet: only the N of M summary", async () => {
27
+ const { stderr, exitCode } = await strays(seed(), "base..HEAD", "src/**")
28
+ expect(stderr).toBe("gtd strays: 2 of 3 touched files outside the allowlist\n")
29
+ expect(exitCode).toBe(EXIT_STRAYS_FOUND)
30
+ })
31
+
32
+ it("--verbose: one stray: line per path, then the summary", async () => {
33
+ const { stderr } = await strays(seed(), "base..HEAD", "src/**", "--verbose")
34
+ expect(stderr).toBe(
35
+ 'stray: "notes/b.md"\nstray: "notes/c.md"\ngtd strays: 2 of 3 touched files outside the allowlist\n',
36
+ )
37
+ })
38
+
39
+ it("a path with a line break and an escape reaches stderr JSON-quoted", async () => {
40
+ const repo = seed()
41
+ repo.writeFile("x\n\x1b[2J", "x\n")
42
+ repo.commitAllWithPrefix("odd")
43
+ const { stderr } = await strays(repo, "base..HEAD", "src/**", "--verbose")
44
+ expect(stderr).toContain('stray: "x\\n\\u001b[2J"\n')
45
+ expect(stderr).not.toContain("\x1b")
46
+ })
47
+
48
+ it("clean run: the single all-inside line", async () => {
49
+ const { stderr, exitCode } = await strays(seed(), "base..HEAD", "src/**", "notes/*")
50
+ expect(stderr).toBe("gtd strays: no strays — 3 touched files, all inside the allowlist\n")
51
+ expect(exitCode).toBe(EXIT_OK)
52
+ })
53
+ })
54
+
55
+ describe("gtd strays --json=<index>", () => {
56
+ it("--json=0 prints the first stray, exit 10", async () => {
57
+ const { stdout, exitCode } = await strays(seed(), "base..HEAD", "src/**", "--json=0")
58
+ expect(stdout).toBe("notes/b.md\n")
59
+ expect(exitCode).toBe(EXIT_STRAYS_FOUND)
60
+ })
61
+
62
+ it("--json=0 on a clean range prints nothing, exit 0", async () => {
63
+ const { stdout, exitCode } = await strays(seed(), "base..HEAD", "src/**", "notes/*", "--json=0")
64
+ expect(stdout).toBe("")
65
+ expect(exitCode).toBe(EXIT_OK)
66
+ })
67
+
68
+ it("an index past the end prints nothing and keeps exit 10", async () => {
69
+ const { stdout, exitCode } = await strays(seed(), "base..HEAD", "src/**", "--json=5")
70
+ expect(stdout).toBe("")
71
+ expect(exitCode).toBe(EXIT_STRAYS_FOUND)
72
+ })
73
+ })
74
+
75
+ describe("gtd strays — unknown revisions", () => {
76
+ it.each([
77
+ ["nope..HEAD", "nope"],
78
+ ["HEAD..nope", "nope"],
79
+ ["HEAD..--output=x", "--output=x"],
80
+ ])("%s: exit 1, empty stdout, no file written", async (range, side) => {
81
+ const repo = seed()
82
+ const { stdout, stderr, exitCode } = await strays(repo, range)
83
+ expect(stdout).toBe("")
84
+ expect(stderr).toContain(`unknown revision '${side}'`)
85
+ expect(exitCode).toBe(EXIT_RUNTIME_ERROR)
86
+ expect(repo.readFile("x")).toBeUndefined()
87
+ })
88
+ })
@@ -0,0 +1,56 @@
1
+ import { Effect } from "effect"
2
+ import { GitService, GtdError, Narrator, type GitOperations } from "../platform/index.js"
3
+ import { strayPaths } from "../step/index.js"
4
+ import { EXIT_OK, EXIT_STRAYS_FOUND } from "../wire/index.js"
5
+ import {
6
+ SelectorUsageError,
7
+ writeSelection,
8
+ type ArtifactOut,
9
+ type JsonMode,
10
+ type SuccessExit,
11
+ } from "./Output.js"
12
+
13
+ // `^{commit}` peels to a commit: a tree or blob would make `git diff` print paths not anchored at the repo root.
14
+ const resolveSide = (git: GitOperations, side: string): Effect.Effect<string, Error> =>
15
+ git
16
+ .resolveRef(`${side}^{commit}`)
17
+ .pipe(Effect.mapError(() => new GtdError(`gtd strays: unknown revision '${side}'`)))
18
+
19
+ /** stdout carries the array alone; the summary is ungated stderr so a quiet run still explains its exit code, the per-path lines are narration. */
20
+ export const runStraysCommand = (
21
+ command: {
22
+ readonly base: string
23
+ readonly tip: string
24
+ readonly globs: readonly string[]
25
+ },
26
+ json: JsonMode,
27
+ out: ArtifactOut,
28
+ ): Effect.Effect<SuccessExit, Error, GitService | Narrator> =>
29
+ Effect.gen(function* () {
30
+ const git = yield* GitService
31
+ const narrator = yield* Narrator
32
+ const base = yield* resolveSide(git, command.base)
33
+ const tip = yield* resolveSide(git, command.tip)
34
+ const touched = yield* git.diffPaths(base, tip)
35
+ const strays = strayPaths(touched, command.globs)
36
+ const total = touched.length
37
+
38
+ if (json.kind === "select") {
39
+ // An index past the end is absent here, not a usage error: it must not mask the exit 0/10 verdict.
40
+ yield* writeSelection(out, strays, json.path).pipe(
41
+ Effect.catchIf(
42
+ (error) => error instanceof SelectorUsageError && /^\d+$/.test(json.path),
43
+ () => Effect.void,
44
+ ),
45
+ )
46
+ } else out.write(`${JSON.stringify(strays)}\n`)
47
+
48
+ // JSON-quoted: a path is author-controlled and must not carry terminal escapes.
49
+ for (const path of strays) yield* narrator.narrate(`stray: ${JSON.stringify(path)}`)
50
+ yield* narrator.warn(
51
+ strays.length === 0
52
+ ? `gtd strays: no strays — ${total} touched files, all inside the allowlist`
53
+ : `gtd strays: ${strays.length} of ${total} touched files outside the allowlist`,
54
+ )
55
+ return strays.length === 0 ? EXIT_OK : EXIT_STRAYS_FOUND
56
+ })