@pmelab/gtd 64.1.1 → 64.2.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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +27 -14
  3. package/dist/chunks/{flows-B7ZRCe-B.mjs → flows-dnIE_qbe.mjs} +7 -13
  4. package/dist/chunks/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-D8jQpDG7.mjs} +1 -2
  5. package/dist/chunks/{steering-Cem5i6Xl.mjs → steering-DEh_MS40.mjs} +181 -187
  6. package/dist/chunks/{testkit-DfWZQCWQ.mjs → testkit-BwCH8ky6.mjs} +412 -512
  7. package/dist/chunks/{wire-CPNSm_9N.mjs → wire-CaPJ9Tme.mjs} +12 -24
  8. package/dist/flows.mjs +1 -2
  9. package/dist/gtd.bundle.mjs +1010 -1785
  10. package/dist/gtd.core.mjs +191 -182
  11. package/dist/steering.mjs +1 -2
  12. package/dist/testkit/gherkin.mjs +3 -5
  13. package/dist/testkit.mjs +2 -2
  14. package/dist/web/main.css +1 -1
  15. package/dist/web/main.js +438 -413
  16. package/dist/wire.mjs +1 -2
  17. package/dist/workflow-kit.mjs +6 -10
  18. package/package.json +19 -32
  19. package/src/cli/Cli.test.ts +1 -0
  20. package/src/cli/Cli.ts +16 -1
  21. package/src/cli/Program.ts +6 -0
  22. package/src/cli/ToolCommands.ts +1 -0
  23. package/src/edge/Edge.test.ts +23 -1
  24. package/src/edge/Edge.ts +23 -1
  25. package/src/edge/index.ts +1 -0
  26. package/src/emit/GitScript.test.ts +1 -1
  27. package/src/flows/runtime.test.ts +1 -1
  28. package/src/platform/Git.ts +33 -10
  29. package/src/replay/Replay.test.ts +21 -7
  30. package/src/steering/Blocks.test.ts +3 -1
  31. package/src/steering/Blocks.ts +14 -6
  32. package/src/steering/SteeringFormat.ts +9 -1
  33. package/src/steering/freeform.test.ts +51 -1
  34. package/src/steering/freeform.ts +38 -0
  35. package/src/steering/qa.test.ts +3 -3
  36. package/src/steering/qa.ts +1 -1
  37. package/src/steering/review.test.ts +26 -1
  38. package/src/step/LandStep.ts +1 -1
  39. package/src/twins/FakeGitOperations.ts +2 -0
  40. package/src/twins/GitTiers.ts +27 -2
  41. package/src/twins/InMemRepo.ts +5 -0
  42. package/src/wire/BeatPlain.ts +3 -3
  43. package/src/wire/Select.ts +1 -1
package/dist/wire.mjs CHANGED
@@ -1,2 +1 @@
1
- import { A as UNATTRIBUTED_MODEL, C as EXIT_RUNTIME_ERROR, D as EXIT_USAGE_ERROR, E as EXIT_STRAYS_FOUND, M as renderFormat, O as ACCESS_REFUSAL, S as EXIT_OK, T as EXIT_SIGTERM, _ as BEAT_KINDS, a as renderScriptJson, b as stallDiagnosis, c as landProseText, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, j as WIRE_SCHEMA, k as PIN_PATH, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, w as EXIT_SIGINT, x as EXIT_CODES, y as demandOf } from "./chunks/wire-CPNSm_9N.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, PIN_PATH, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, renderScriptJson, resolveSession, scriptFields, selectPath, stallDiagnosis, statusOf };
1
+ export { O as ACCESS_REFUSAL, _ as BEAT_KINDS, f as DEMAND_BRIEFING, x as EXIT_CODES, S as EXIT_OK, C as EXIT_RUNTIME_ERROR, w as EXIT_SIGINT, T as EXIT_SIGTERM, E as EXIT_STRAYS_FOUND, D as EXIT_USAGE_ERROR, k as PIN_PATH, A as UNATTRIBUTED_MODEL, j as WIRE_SCHEMA, m as beatDocument, v as beatKindOf, y as demandOf, t as globMatches, i as judgeJson, s as landFields, c as landProseText, l as noopText, h as renderBeatJson, p as renderBeatPlain, M as renderFormat, u as renderLandJson, d as renderLandPlain, a as renderScriptJson, n as resolveSession, o as scriptFields, r as selectPath, b as stallDiagnosis, g as statusOf } from "./chunks/wire-CaPJ9Tme.mjs";
@@ -1,6 +1,6 @@
1
- import { B as head, F as changesSince, G as mark, P as changes, X as read, Z as refuse, d as hasThreadFor, et as scope, g as requireReplies, v as requireThreadsClosed } from "./chunks/flows-B7ZRCe-B.mjs";
2
- import { C as reviewRisks, N as parseThreads, S as reviewNotes, T as unansweredNotes, n as stripCodeThreads, x as reviewDecisions } from "./chunks/steering-Cem5i6Xl.mjs";
3
- //#region src/workflow-kit/health.ts
1
+ import { B as head, F as changesSince, G as mark, P as changes, X as read, Z as refuse, d as hasThreadFor, et as scope, g as requireReplies, v as requireThreadsClosed } from "./chunks/flows-dnIE_qbe.mjs";
2
+ import { C as reviewRisks, N as parseThreads, S as reviewNotes, T as unansweredNotes, n as stripCodeThreads, x as reviewDecisions } from "./chunks/steering-DEh_MS40.mjs";
3
+ // src/workflow-kit/health.ts
4
4
  /** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
5
5
  const escalation = async (count, steps) => {
6
6
  if (count.rounds >= steps.limit) return steps.exhausted();
@@ -74,8 +74,7 @@ const healthy = async (fix, health) => {
74
74
  const gate = (name, passes, blocked) => scope(name, async () => {
75
75
  while (!await passes()) await blocked();
76
76
  });
77
- //#endregion
78
- //#region src/workflow-kit/quality.ts
77
+ // src/workflow-kit/quality.ts
79
78
  const lensOf = (entry) => {
80
79
  const at = entry.indexOf("=");
81
80
  return at < 0 ? {
@@ -106,8 +105,7 @@ const fixFindings = async (f) => {
106
105
  await f.escalate();
107
106
  return false;
108
107
  };
109
- //#endregion
110
- //#region src/workflow-kit/scenarios.ts
108
+ // src/workflow-kit/scenarios.ts
111
109
  const DOC_STRING = /^("""|```)/;
112
110
  const THEN = /^Then\s/;
113
111
  const CONTINUATION = /^(And|But|\*)\s/;
@@ -161,8 +159,7 @@ const removedAssertions = (changes) => changes.filter((c) => c.path.endsWith(".f
161
159
  lines
162
160
  };
163
161
  }).filter((r) => r.lines.length > 0);
164
- //#endregion
165
- //#region src/workflow-kit/review.ts
162
+ // src/workflow-kit/review.ts
166
163
  const collect = async (p, capture, round, requested) => {
167
164
  await p.collecting(capture, round);
168
165
  if (read(p.feedback) !== void 0) return "feedback";
@@ -426,5 +423,4 @@ const reviewToHandOff = async (base, p) => {
426
423
  return;
427
424
  }
428
425
  };
429
- //#endregion
430
426
  export { assertionLines, escalation, fixFindings, gate, healthy, lensEntriesOf, lensOf, lensScopes, lensesOf, qualityLap, removedAssertions, review, reviewToHandOff, untilSettled };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "64.1.1",
3
+ "version": "64.2.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": {
@@ -83,33 +83,17 @@
83
83
  "scripts": {
84
84
  "prepare": "husky",
85
85
  "dev": "node dev/run.mjs",
86
- "build": "tsdown --filter web && npx @tailwindcss/cli -i src/web/styles.css -o dist/web/main.css --minify && node scripts/inline-web-client.mjs && tsdown --filter gtd --filter core && tsdown --filter entrypoints",
87
- "postbuild": "node scripts/assert-no-test-doubles.mjs",
88
- "watch": "tsdown --watch",
86
+ "watch": "vp pack --watch",
89
87
  "bench:replay": "node scripts/bench-replay.mjs",
90
88
  "test": "jiti scripts/test-states.main.ts",
91
- "test:unit": "vitest run --project unit",
92
- "test:workflows": "vitest run --project workflows",
93
89
  "test:watch": "vitest",
94
90
  "test:changed": "vitest run --project unit --project e2e-inmem --project workflows --changed HEAD",
95
- "test:e2e": "npm run test:e2e:inmem && npm run test:e2e:live",
96
- "typecheck": "tsc --noEmit && tsc --noEmit -p src/web/tsconfig.json",
97
- "lint": "oxlint .",
98
- "lint:sh": "jiti scripts/generate-shell-corpus.ts --check && shellcheck -s sh tests/shell/corpus/*.sh",
99
- "lint:boundaries": "depcruise src tests claude",
100
- "format": "oxfmt --write .",
101
- "format:check": "oxfmt --check .",
91
+ "test:e2e": "vp run test:e2e:inmem && vp run test:e2e:live",
92
+ "format": "vp fmt",
102
93
  "test:mutation": "stryker run",
103
- "test:e2e:inmem": "vitest run --project e2e-inmem",
104
- "test:e2e:live": "vitest run --project e2e-live --no-file-parallelism",
105
- "analyze": "fallow --summary --quiet",
106
- "test:web": "vitest run --project storybook",
107
94
  "storybook": "storybook dev --host 0.0.0.0",
108
95
  "coverage": "vitest run --project unit --coverage"
109
96
  },
110
- "lint-staged": {
111
- "*": "oxfmt --no-error-on-unmatched-pattern --write"
112
- },
113
97
  "devDependencies": {
114
98
  "@commitlint/cli": "^21.2.3",
115
99
  "@commitlint/config-conventional": "^21.2.3",
@@ -119,8 +103,8 @@
119
103
  "@semantic-release/git": "^10.0.1",
120
104
  "@semantic-release/github": "^12.0.8",
121
105
  "@semantic-release/npm": "^12.0.0",
122
- "@storybook/addon-vitest": "^10.6.0",
123
- "@storybook/react-vite": "^10.6.0",
106
+ "@storybook/addon-vitest": "^10.6.5",
107
+ "@storybook/react-vite": "^10.6.5",
124
108
  "@stryker-mutator/core": "^9.6.1",
125
109
  "@stryker-mutator/typescript-checker": "^9.6.1",
126
110
  "@stryker-mutator/vitest-runner": "^9.6.1",
@@ -134,29 +118,27 @@
134
118
  "@types/react": "^19.2.7",
135
119
  "@types/react-dom": "^19.2.3",
136
120
  "@vitejs/plugin-react": "^5.2.0",
137
- "@vitest/browser": "^4.1.12",
138
- "@vitest/browser-playwright": "^4.1.12",
139
- "@vitest/coverage-v8": "^4.1.12",
121
+ "@vitest/browser": "5.0.5",
122
+ "@vitest/browser-playwright": "5.0.5",
123
+ "@vitest/coverage-v8": "5.0.5",
140
124
  "dependency-cruiser": "^18.3.1",
141
125
  "fallow": "3.25.0",
142
126
  "fast-check": "^4.8.0",
143
127
  "husky": "^9.1.7",
144
128
  "jiti": "^2.7.0",
145
- "lint-staged": "^17.0.8",
146
- "oxfmt": "^0.59.0",
147
- "oxlint": "^1.74.0",
129
+ "oxfmt": "0.59.0",
130
+ "oxlint": "1.74.0",
148
131
  "playwright": "^1.62.1",
149
132
  "prettier": "^3.8.1",
150
133
  "quickpickle": "1.11.2",
151
134
  "react": "^19.2.0",
152
135
  "react-dom": "^19.2.0",
153
136
  "semantic-release": "^25.0.5",
154
- "storybook": "^10.6.0",
137
+ "storybook": "^10.6.5",
155
138
  "tailwind-merge": "^3.7.0",
156
139
  "tailwindcss": "^4.3.3",
157
- "tsdown": "^0.22.8",
158
- "turbo": "^2.10.9",
159
- "vitest": "^4.1.12",
140
+ "vite-plus": "1.4.0",
141
+ "vitest": "5.0.5",
160
142
  "yaml": "^2.8.2",
161
143
  "zustand": "^5.0.15"
162
144
  },
@@ -190,5 +172,10 @@
190
172
  "rolldown": "^1.1.5",
191
173
  "vscode-languageserver": "^10.1.0",
192
174
  "vscode-languageserver-textdocument": "^1.0.13"
175
+ },
176
+ "overrides": {
177
+ "storybook": {
178
+ "vite-plus": "$vite-plus"
179
+ }
193
180
  }
194
181
  }
@@ -32,6 +32,7 @@ const FLAG_NAMES = [
32
32
  "--port",
33
33
  "--host",
34
34
  "--self-signed",
35
+ "--http",
35
36
  "--dev",
36
37
  "--cost",
37
38
  "--model",
package/src/cli/Cli.ts CHANGED
@@ -184,7 +184,7 @@ const FLAGS: readonly FlagRow[] = [
184
184
  },
185
185
  scopeError: "gtd: --port is only valid for `gtd ui`",
186
186
  valueHint: "<n>",
187
- help: "The tailscale serve port (default: the first free of 8443, 10000, 443), or with --host the port to bind (default: any free one)",
187
+ help: "The tailscale serve port (default: the first free of 8443, 10000, 443), or with --host or --http the port to bind (default: any free one)",
188
188
  },
189
189
  {
190
190
  name: "--host",
@@ -209,6 +209,16 @@ const FLAGS: readonly FlagRow[] = [
209
209
  valueHint: "",
210
210
  help: "Bind directly with a throwaway self-signed certificate instead of the configured ui.cert/ui.key",
211
211
  },
212
+ {
213
+ name: "--http",
214
+ arity: 0,
215
+ repeatable: false,
216
+ scope: (kind) => kind === "ui",
217
+ decode: () => Result.succeed(true),
218
+ scopeError: "gtd: --http is only valid for `gtd ui`",
219
+ valueHint: "",
220
+ help: "Serve plain HTTP for a local TLS-terminating proxy; binds 127.0.0.1 unless a loopback --host is given",
221
+ },
212
222
  {
213
223
  name: "--dev",
214
224
  arity: 0,
@@ -1137,6 +1147,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1137
1147
  readonly "--spec"?: string
1138
1148
  readonly "--host"?: string
1139
1149
  readonly "--self-signed"?: boolean
1150
+ readonly "--http"?: boolean
1140
1151
  readonly "--dev"?: boolean
1141
1152
  readonly "--provider"?: "fixed" | "jev" | "llm"
1142
1153
  readonly "--answers"?: string
@@ -1185,6 +1196,9 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1185
1196
  }
1186
1197
 
1187
1198
  if (kind === "ui") {
1199
+ if (bag["--http"] === true && bag["--self-signed"] === true) {
1200
+ return usagePlan("gtd: --http and --self-signed cannot be combined", jsonSeen)
1201
+ }
1188
1202
  return {
1189
1203
  kind: "command",
1190
1204
  command: {
@@ -1192,6 +1206,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1192
1206
  ...(bag["--host"] !== undefined ? { host: bag["--host"] } : {}),
1193
1207
  ...(bag["--port"] !== undefined ? { port: bag["--port"] } : {}),
1194
1208
  selfSigned: bag["--self-signed"] ?? false,
1209
+ ...(bag["--http"] === true ? { http: true } : {}),
1195
1210
  dev: bag["--dev"] ?? false,
1196
1211
  },
1197
1212
  json,
@@ -51,6 +51,7 @@ import {
51
51
  } from "../wire/index.js"
52
52
  import {
53
53
  currentRest,
54
+ refuseHugeUntracked,
54
55
  repinAnchor,
55
56
  startRefusal,
56
57
  currentRun,
@@ -144,6 +145,7 @@ export type Command =
144
145
  readonly host?: string
145
146
  readonly port?: number
146
147
  readonly selfSigned: boolean
148
+ readonly http?: boolean
147
149
  readonly dev: boolean
148
150
  }
149
151
  | {
@@ -310,6 +312,7 @@ const planLanding = (
310
312
  current?: Rest,
311
313
  ): Effect.Effect<LandResult, Error, CommandRequirements> =>
312
314
  Effect.gen(function* () {
315
+ yield* refuseHugeUntracked
313
316
  const rest = current ?? (yield* currentRest)
314
317
  const snapshot = yield* snapshotFromRest(rest, opts.judge)
315
318
  const outcome = planStepPure(snapshot, opts)
@@ -596,6 +599,7 @@ const startWorkflow = (
596
599
  new GtdUsageError(`${commandLabel}: unknown workflow "${workflow}" — ${startable}`),
597
600
  )
598
601
  }
602
+ yield* refuseHugeUntracked
599
603
  const rest = yield* restAt(undefined, varOverrides)
600
604
  const started = yield* startRefusal(rest, resolved, varOverrides)
601
605
  const plan = yield* planStart(
@@ -967,6 +971,7 @@ const runNextCommand = (
967
971
  out: ArtifactOut,
968
972
  ): Effect.Effect<void, Error, CommandRequirements> =>
969
973
  Effect.gen(function* () {
974
+ yield* refuseHugeUntracked
970
975
  const rest = yield* currentRest
971
976
  const rendered = yield* renderRest(rest)
972
977
  const fields = yield* gatherBeatDocument(rest, rendered)
@@ -1260,6 +1265,7 @@ const runUiCliCommand = (
1260
1265
  ...(command.host !== undefined ? { host: command.host } : {}),
1261
1266
  ...(command.port !== undefined ? { port: command.port } : {}),
1262
1267
  selfSigned: command.selfSigned,
1268
+ ...(command.http === true ? { http: true } : {}),
1263
1269
  dev: command.dev,
1264
1270
  },
1265
1271
  config: config.ui,
@@ -8,6 +8,7 @@ export interface UiRequest {
8
8
  readonly host?: string
9
9
  readonly port?: number
10
10
  readonly selfSigned: boolean
11
+ readonly http?: boolean
11
12
  readonly dev: boolean
12
13
  }
13
14
  readonly config: UiSettings | undefined
@@ -6,6 +6,7 @@ import {
6
6
  currentRun,
7
7
  historyTrail,
8
8
  noProcessUnderway,
9
+ refuseHugeUntracked,
9
10
  renderRest,
10
11
  restAt,
11
12
  restIsIdle,
@@ -757,7 +758,7 @@ describe("restAt", () => {
757
758
  `--model=${model}`,
758
759
  )
759
760
  expect(exitCode).toBe(0)
760
- await applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
761
+ applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
761
762
  await takePinBeat(repo, "add")
762
763
  }
763
764
  const { cost } = await provide(currentRest, repo)
@@ -768,6 +769,27 @@ describe("restAt", () => {
768
769
  ])
769
770
  })
770
771
 
772
+ it("refuses over 1000 untracked files and proceeds at exactly 1000", async () => {
773
+ const repo = repoWith(LINEAR)
774
+ const files = (n: number) =>
775
+ Object.fromEntries(Array.from({ length: n }, (_, i) => [`big/f${i}.txt`, "x\n"]))
776
+ write(repo, files(1000))
777
+ await provide(refuseHugeUntracked, repo)
778
+ write(repo, files(1001))
779
+ await expect(provide(refuseHugeUntracked, repo)).rejects.toThrow(
780
+ /1001 untracked files.*big \(1001\).*\.gitignore/,
781
+ )
782
+ })
783
+
784
+ it("resolving the current rest never refuses a huge untracked tree", async () => {
785
+ const repo = repoWith(LINEAR)
786
+ write(
787
+ repo,
788
+ Object.fromEntries(Array.from({ length: 1001 }, (_, i) => [`big/f${i}.txt`, "x\n"])),
789
+ )
790
+ expect((await provide(currentRest, repo)).changes).toHaveLength(1001)
791
+ })
792
+
771
793
  it("restAt(ref) resolves where the process rested at that commit", async () => {
772
794
  const repo = repoWith(LINEAR)
773
795
  const boundary = headOf(repo)
package/src/edge/Edge.ts CHANGED
@@ -238,7 +238,7 @@ const historyFormatFault = (history: History, location: EpisodeLocation): string
238
238
  const runOf = (
239
239
  history: History,
240
240
  location: EpisodeLocation,
241
- closingHash: string | undefined = undefined,
241
+ closingHash?: string,
242
242
  ): Effect.Effect<ProcessRun, Error> => {
243
243
  const fault = historyFormatFault(history, location)
244
244
  return fault === undefined
@@ -971,6 +971,28 @@ const setupFor = (
971
971
  return { resolved, def, vars, env, setup }
972
972
  })
973
973
 
974
+ const UNTRACKED_LIMIT = 1000
975
+
976
+ // `git add -A` would commit every untracked file, so a huge tree is refused —
977
+ // only by the commands that land (next, land, judge answer, start). Read-only
978
+ // rest readers never refuse.
979
+ export const refuseHugeUntracked = Effect.gen(function* () {
980
+ const paths = yield* (yield* GitService).untrackedPaths()
981
+ if (paths.length <= UNTRACKED_LIMIT) return
982
+ const dirs = new Map<string, number>()
983
+ for (const p of paths) {
984
+ const i = p.lastIndexOf("/")
985
+ const d = i === -1 ? "." : p.slice(0, i)
986
+ dirs.set(d, (dirs.get(d) ?? 0) + 1)
987
+ }
988
+ const top = [...dirs].sort((a, b) => b[1] - a[1]).slice(0, 3)
989
+ return yield* Effect.fail(
990
+ new Error(
991
+ `gtd: ${paths.length} untracked files (limit ${UNTRACKED_LIMIT}); largest untracked directories: ${top.map(([d, n]) => `${d} (${n})`).join(", ")}. Add them to .gitignore or delete them.`,
992
+ ),
993
+ )
994
+ })
995
+
974
996
  // Only an approval gate counts an index-only edit (one staged, then reverted
975
997
  // in the worktree): its approval must cover exactly the judged diff. Every
976
998
  // other rest keeps the worktree's view, so a net-zero turn stays an attempt.
package/src/edge/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  summaryRun,
4
4
  restAt,
5
5
  currentRest,
6
+ refuseHugeUntracked,
6
7
  repinAnchor,
7
8
  startRefusal,
8
9
  reviewBaseFor,
@@ -234,7 +234,7 @@ describe("shellQuote", () => {
234
234
  })
235
235
 
236
236
  // 500 samples through 500 bash spawns starved the 30s test timeout whenever
237
- // the suite ran under turbo's parallel task load. The property is unchanged;
237
+ // the suite ran under `npm test`'s parallel task load. The property is unchanged;
238
238
  // all samples now round-trip through a SINGLE bash, NUL-delimited on the way
239
239
  // back out (NUL is filtered from the inputs, so it can't collide).
240
240
  it("round-trips arbitrary strings — including newlines and non-ASCII — through a real bash printf", () => {
@@ -146,7 +146,7 @@ describe("kept()", () => {
146
146
  })
147
147
 
148
148
  /** A context that records every step request and resolves it with `reply`. */
149
- const recording = (reply: unknown = undefined) => {
149
+ const recording = (reply?: unknown) => {
150
150
  const requests: StepRequest[] = []
151
151
  installContext({
152
152
  step: async (request: StepRequest) => {
@@ -94,6 +94,8 @@ export interface GitReaderOperations {
94
94
  readonly changedPaths: (
95
95
  base?: string,
96
96
  ) => Effect.Effect<ReadonlyArray<{ readonly path: string; readonly status: string }>, Error>
97
+ /** Untracked, non-ignored paths. */
98
+ readonly untrackedPaths: () => Effect.Effect<ReadonlyArray<string>, Error>
97
99
  /**
98
100
  * The index vs `HEAD`, as `{path, status}` pairs — what a commit would carry
99
101
  * that `changedPaths` misses when the worktree reverted a staged edit. Empty
@@ -205,6 +207,14 @@ const stripCommitSeam = (tail: string): string => {
205
207
 
206
208
  type GitExec = (...args: [string, ...Array<string>]) => Effect.Effect<string, Error>
207
209
 
210
+ // Unbounded argv throws E2BIG synchronously (a defect), so path lists are chunked.
211
+ const ARGV_CHUNK = 500
212
+ const chunked = <T>(items: ReadonlyArray<T>): Array<ReadonlyArray<T>> => {
213
+ const out: Array<ReadonlyArray<T>> = []
214
+ for (let i = 0; i < items.length; i += ARGV_CHUNK) out.push(items.slice(i, i + ARGV_CHUNK))
215
+ return out
216
+ }
217
+
208
218
  /**
209
219
  * `path → blob object id` at `ref`; a path absent from the map doesn't exist
210
220
  * there. An unresolvable `ref` (an empty repo) yields an empty map, so every
@@ -215,11 +225,17 @@ const blobsAtRef = (
215
225
  ref: string,
216
226
  paths: ReadonlyArray<string>,
217
227
  ): Effect.Effect<Map<string, string>, Error> =>
218
- exec("git", "ls-tree", "-r", "-z", ref, "--", ...paths).pipe(
219
- Effect.catch(() => Effect.succeed("")),
220
- Effect.map((out) => {
228
+ Effect.forEach(
229
+ chunked(paths),
230
+ (chunk) =>
231
+ exec("git", "ls-tree", "-r", "-z", ref, "--", ...chunk).pipe(
232
+ Effect.catch(() => Effect.succeed("")),
233
+ ),
234
+ { concurrency: 1 },
235
+ ).pipe(
236
+ Effect.map((outs) => {
221
237
  const blobs = new Map<string, string>()
222
- for (const entry of splitNul(out)) {
238
+ for (const entry of outs.flatMap(splitNul)) {
223
239
  const tab = entry.indexOf("\t")
224
240
  if (tab === -1) continue
225
241
  const [, type, oid] = entry.slice(0, tab).split(" ")
@@ -246,16 +262,20 @@ const hashObjects = (
246
262
  exec: GitExec,
247
263
  paths: ReadonlyArray<string>,
248
264
  ): Effect.Effect<Map<string, string>, Error> =>
249
- exec("git", "hash-object", "--", ...paths).pipe(
250
- Effect.map(
251
- (out) =>
252
- new Map(
265
+ Effect.forEach(
266
+ chunked(paths),
267
+ (chunk) =>
268
+ exec("git", "hash-object", "--", ...chunk).pipe(
269
+ Effect.map((out) =>
253
270
  out
254
271
  .trim()
255
272
  .split("\n")
256
- .map((oid, i) => [paths[i] ?? "", oid.trim()] as const),
273
+ .map((oid, i) => [chunk[i] ?? "", oid.trim()] as const),
257
274
  ),
258
- ),
275
+ ),
276
+ { concurrency: 1 },
277
+ ).pipe(
278
+ Effect.map((entries) => new Map(entries.flat())),
259
279
  Effect.catch(() => Effect.succeed(new Map<string, string>())),
260
280
  )
261
281
 
@@ -471,6 +491,9 @@ const makeGitImpl = (
471
491
  return all
472
492
  }),
473
493
 
494
+ untrackedPaths: () =>
495
+ exec("git", "ls-files", "--others", "--exclude-standard", "-z").pipe(Effect.map(splitNul)),
496
+
474
497
  stagedPaths: () =>
475
498
  exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(
476
499
  Effect.catch(() => Effect.succeed("")),
@@ -370,7 +370,9 @@ describe("replay", () => {
370
370
  })
371
371
 
372
372
  it("is reported when history continues past the end of the flow", async () => {
373
- const short = async () => void (await human("only"))
373
+ const short = async () => {
374
+ await human("only")
375
+ }
374
376
  const h = new History().land("human", "only", 1, "only").land("human", "only", 2, "only")
375
377
  expect((await replayOf(short, h)).kind).toBe("divergence")
376
378
  })
@@ -606,7 +608,9 @@ describe("replay", () => {
606
608
  })
607
609
 
608
610
  it("ends the episode on return and on restart", async () => {
609
- const returning = async () => void (await human("only"))
611
+ const returning = async () => {
612
+ await human("only")
613
+ }
610
614
  const h = new History().land("human", "only", 1, "only")
611
615
  expect(await replayOf(returning, h)).toMatchObject({ kind: "ended", via: "return" })
612
616
 
@@ -1337,7 +1341,9 @@ describe("replay: a repin's anchor", () => {
1337
1341
  })
1338
1342
 
1339
1343
  it("diverges when the flow ends where it names a step", async () => {
1340
- const outcome = await replayOf(async () => void (await human("idle")), anchoredHistory())
1344
+ const outcome = await replayOf(async () => {
1345
+ await human("idle")
1346
+ }, anchoredHistory())
1341
1347
  expect(outcome).toMatchObject({ kind: "divergence", at: hashOf(3), reached: undefined })
1342
1348
  })
1343
1349
 
@@ -1346,7 +1352,9 @@ describe("replay: a repin's anchor", () => {
1346
1352
  .land("human", "idle", 1, "build", { "a.txt": "a" })
1347
1353
  .land("agent", "build", 1, "old")
1348
1354
  .raw(anchorTo("gtd:unpin", 1))
1349
- const outcome = await replayOf(async () => void (await human("idle")), h)
1355
+ const outcome = await replayOf(async () => {
1356
+ await human("idle")
1357
+ }, h)
1350
1358
  expect(outcome.kind).toBe("ended")
1351
1359
  })
1352
1360
 
@@ -1355,13 +1363,17 @@ describe("replay: a repin's anchor", () => {
1355
1363
  .land("human", "idle", 1, "build", { "a.txt": "a" })
1356
1364
  .land("agent", "build", 1, "old")
1357
1365
  .raw(`gtd(check): pin → unpin\n\nGtd-Pin: add\nGtd-Anchor: ${hashOf(1)}\nGtd-Format: 3`)
1358
- const outcome = await replayOf(async () => void (await human("idle")), h)
1366
+ const outcome = await replayOf(async () => {
1367
+ await human("idle")
1368
+ }, h)
1359
1369
  expect(outcome.kind).toBe("ended")
1360
1370
  })
1361
1371
 
1362
1372
  it("reads a format 4 anchor naming unpin as a step", async () => {
1363
1373
  const outcome = await replayOf(
1364
- async () => void (await human("idle")),
1374
+ async () => {
1375
+ await human("idle")
1376
+ },
1365
1377
  new History()
1366
1378
  .land("human", "idle", 1, "build", { "a.txt": "a" })
1367
1379
  .land("agent", "build", 1, "old")
@@ -1372,7 +1384,9 @@ describe("replay: a repin's anchor", () => {
1372
1384
 
1373
1385
  it("fails when it names no commit replay reads", async () => {
1374
1386
  const h = anchoredHistory().raw(anchorTo("build", 2))
1375
- const outcome = await replayOf(async () => void (await human("idle")), h)
1387
+ const outcome = await replayOf(async () => {
1388
+ await human("idle")
1389
+ }, h)
1376
1390
  expect(outcome.kind === "failed" && outcome.message).toContain(
1377
1391
  "which is no commit replay reads",
1378
1392
  )
@@ -35,7 +35,9 @@ describe("blockRunInline — spec-feedback regression: a nested list item's own
35
35
  const nodes = blockNodesOf(content, tree)
36
36
  const topItem = nodes[0]?.block?.items?.[0]
37
37
  expect(topItem?.inline).toEqual([{ kind: "text", value: "outer" }])
38
- expect(topItem?.items).toEqual([{ text: "inner", inline: [{ kind: "text", value: "inner" }] }])
38
+ expect(topItem?.items).toEqual([
39
+ { text: "inner", inline: [{ kind: "text", value: "inner" }], line: 1 },
40
+ ])
39
41
  })
40
42
  })
41
43
 
@@ -128,21 +128,28 @@ const nodeInline = (
128
128
  return title.length > 0 ? [{ kind: "text", value: title }] : []
129
129
  }
130
130
 
131
+ type NoteAt = ReturnType<typeof noteLookup>
132
+
131
133
  /** One list item as a `BlockListItem`, recursing into a nested `list` child (there is at most one, CommonMark's own shape) as its own `items`. */
132
- const blockListItemOf = (content: string, item: ListItem): BlockListItem => {
134
+ const blockListItemOf = (content: string, item: ListItem, noteAt: NoteAt): BlockListItem => {
133
135
  const nested = item.children.filter((c): c is List => c.type === "list")
134
- const items = nested.flatMap((list) => blockListItemsOf(content, list.children))
136
+ const items = nested.flatMap((list) => blockListItemsOf(content, list.children, noteAt))
137
+ const line = item.position === undefined ? undefined : toLspPosition(item.position.start).line
135
138
  return {
136
139
  text: listItemText(content, item),
137
140
  inline: listItemInline(content, item),
138
141
  ...(item.checked === true || item.checked === false ? { checked: item.checked } : {}),
139
142
  ...(items.length > 0 ? { items } : {}),
143
+ ...(line === undefined ? {} : { line, ...noteAt(line) }),
140
144
  }
141
145
  }
142
146
 
143
147
  /** Every item of a `list` node's own `children`, as `BlockListItem`s, in document order. */
144
- const blockListItemsOf = (content: string, items: readonly ListItem[]): readonly BlockListItem[] =>
145
- items.map((item) => blockListItemOf(content, item))
148
+ const blockListItemsOf = (
149
+ content: string,
150
+ items: readonly ListItem[],
151
+ noteAt: NoteAt,
152
+ ): readonly BlockListItem[] => items.map((item) => blockListItemOf(content, item, noteAt))
146
153
 
147
154
  /** The `title` an empty fenced code block (a `` ``` ``/`` ``` `` pair with nothing between them) falls back to — its real body is `""`, and every node still carries a non-empty title. */
148
155
  const EMPTY_CODE_BLOCK_TITLE = "(empty code block)"
@@ -184,6 +191,7 @@ const blockTitle = (content: string, node: RootContent, options?: BlockWalkOptio
184
191
  const blockOf = (
185
192
  content: string,
186
193
  node: RootContent,
194
+ noteAt: NoteAt,
187
195
  options?: BlockWalkOptions,
188
196
  ): SteeringViewNode["block"] | undefined => {
189
197
  switch (node.type) {
@@ -197,7 +205,7 @@ const blockOf = (
197
205
  return {
198
206
  kind: "list",
199
207
  ordered: node.ordered === true,
200
- items: blockListItemsOf(content, visibleListItems(node, options)),
208
+ items: blockListItemsOf(content, visibleListItems(node, options), noteAt),
201
209
  }
202
210
  case "code":
203
211
  return {
@@ -285,7 +293,7 @@ export const blockNodesOfRun = (
285
293
  const noteAt = noteLookup(content, options?.footnotes)
286
294
  return visibleNodes(content, nodes, options).map((node) => {
287
295
  const startLine = toLspPosition(node.position!.start).line
288
- const block = blockOf(content, node, options)
296
+ const block = blockOf(content, node, noteAt, options)
289
297
  return {
290
298
  title: blockTitle(content, node, options),
291
299
  anchor: { kind: "paragraph" as const, line: startLine },
@@ -74,10 +74,13 @@ export type SteeringAnchor =
74
74
  | { readonly kind: "option"; readonly questionIndex: number; readonly index: number }
75
75
  | { readonly kind: "paragraph"; readonly line: number }
76
76
 
77
+ /** `pick-never-clears`: a free-form pick-one tick only moves, so `checked: false` is refused by policy, not for a bad anchor. */
78
+ type SteeringRefusalReason = "anchor-not-found" | "id-collision" | "pick-never-clears"
79
+
77
80
  /** `annotate`'s result: the edits to splice in, or a typed refusal — a stale/unresolvable `anchor`, or a derived note id that already names a definition. */
78
81
  export type SteeringAnnotateResult =
79
82
  | { readonly ok: true; readonly edits: readonly SteeringEdit[] }
80
- | { readonly ok: false; readonly reason: "anchor-not-found" | "id-collision" }
83
+ | { readonly ok: false; readonly reason: SteeringRefusalReason }
81
84
 
82
85
  /** One item of a `list` block's `items` tree — recursive to whatever depth the source markdown nests. */
83
86
  export interface BlockListItem {
@@ -89,6 +92,11 @@ export interface BlockListItem {
89
92
  readonly items?: readonly BlockListItem[]
90
93
  /** `text`'s own inline structure — the shared renderer walks this instead of `text` when present. Populated at every nesting depth. */
91
94
  readonly inline?: readonly InlineNode[]
95
+ /** The item's own 0-based source line: a `paragraph` anchor at it notes this item alone. */
96
+ readonly line?: number
97
+ /** The note or thread whose marker sits on `line`, as on a view node. */
98
+ readonly note?: string
99
+ readonly thread?: SteeringViewThread
92
100
  }
93
101
 
94
102
  export interface SteeringViewThread {