@pmelab/gtd 64.1.2 → 64.3.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.
package/dist/steering.mjs CHANGED
@@ -1 +1 @@
1
- export { g as ANSWER_FIX, _ as ANSWER_LEAD, v as ANSWER_WONT_FIX, O as FIX_BOX, E as FREE_TEXT_PLACEHOLDER, k as NOISE_HEADING, y as REVIEW_SECTIONS, h as applySteeringEdits, A as findingEntries, I as formatFinding, m as freeFormFormat, D as isAnswered, f as listFormat, r as openQuestions, j as openThreadFindings, c as openThreads, t as parseCodeThreads, M as parseFootnotes, N as parseThreads, P as parseThreadsWithFindings, b as reviewChunks, x as reviewDecisions, S as reviewNotes, C as reviewRisks, w as reviewSections, i as sectionBodies, p as sectionFormat, a as sections, l as steeringFormatFor, n as stripCodeThreads, o as threads, T as unansweredNotes } from "./chunks/steering-CQzL-lX1.mjs";
1
+ export { g as ANSWER_FIX, _ as ANSWER_LEAD, v as ANSWER_WONT_FIX, O as FIX_BOX, E as FREE_TEXT_PLACEHOLDER, k as NOISE_HEADING, y as REVIEW_SECTIONS, h as applySteeringEdits, A as findingEntries, I as formatFinding, m as freeFormFormat, D as isAnswered, f as listFormat, r as openQuestions, j as openThreadFindings, c as openThreads, t as parseCodeThreads, M as parseFootnotes, N as parseThreads, P as parseThreadsWithFindings, b as reviewChunks, x as reviewDecisions, S as reviewNotes, C as reviewRisks, w as reviewSections, i as sectionBodies, p as sectionFormat, a as sections, l as steeringFormatFor, n as stripCodeThreads, o as threads, T as unansweredNotes } from "./chunks/steering-DEh_MS40.mjs";
@@ -1,4 +1,4 @@
1
- import { n as runsItsScript, r as testRepo } from "../chunks/testkit-D539mnDI.mjs";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-BUX-47TD.mjs";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { resolve } from "node:path";
4
4
  import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
package/dist/testkit.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { H as installContext } from "./chunks/flows-dnIE_qbe.mjs";
2
- import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-D539mnDI.mjs";
2
+ import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-BUX-47TD.mjs";
3
3
  export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
package/dist/web/main.js CHANGED
@@ -33541,16 +33541,43 @@ const headingTagFor = (depth) => {
33541
33541
  if (depth === 3) return "h3";
33542
33542
  return "h4";
33543
33543
  };
33544
+ const TickContext = (0, import_react.createContext)(void 0);
33545
+ /** A task item's box: tappable when tick wiring exists, else read-only. `picked` is the group's optimistic pick. */
33546
+ const TaskBox = ({ item, groupLine, picked }) => {
33547
+ const ticks = (0, import_react.useContext)(TickContext);
33548
+ const on = picked === void 0 ? item.checked === true : item.line === picked;
33549
+ const line = item.line;
33550
+ const tappable = ticks !== void 0 && groupLine !== void 0 && line !== void 0;
33551
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
33552
+ type: "checkbox",
33553
+ checked: on,
33554
+ readOnly: !tappable,
33555
+ onChange: () => {
33556
+ if (tappable && !on) ticks.onTick(groupLine, line);
33557
+ },
33558
+ className: "mr-2 align-middle"
33559
+ });
33560
+ };
33561
+ /** A list's pick-one group: its first task item's line, and the optimistic pick there. */
33562
+ const useGroupPick = (items) => {
33563
+ const ticks = (0, import_react.useContext)(TickContext);
33564
+ const groupLine = items.find((i) => i.checked !== void 0 && i.line !== void 0)?.line;
33565
+ return {
33566
+ groupLine,
33567
+ picked: groupLine === void 0 ? void 0 : ticks?.pick(groupLine)
33568
+ };
33569
+ };
33544
33570
  /** One `list` block's own items, recursively — a nested `items` array renders as a nested `ul`/`ol` inside its parent's `li`. */
33545
33571
  const BlockList = ({ items, ordered }) => {
33546
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ordered ? "ol" : "ul", {
33572
+ const ListTag = ordered ? "ol" : "ul";
33573
+ const { groupLine, picked } = useGroupPick(items);
33574
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ListTag, {
33547
33575
  className: `m-0 list-outside pl-5 ${ordered ? "list-decimal" : "list-disc"}`,
33548
33576
  children: items.map((item, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { children: [
33549
- item.checked !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
33550
- type: "checkbox",
33551
- checked: item.checked,
33552
- readOnly: true,
33553
- className: "mr-2 align-middle"
33577
+ item.checked !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TaskBox, {
33578
+ item,
33579
+ groupLine,
33580
+ picked
33554
33581
  }),
33555
33582
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Inline, {
33556
33583
  inline: item.inline,
@@ -34021,7 +34048,7 @@ const FreeFormDoneRow = ({ onDone, busy }) => /* @__PURE__ */ (0, import_jsx_run
34021
34048
  children: "Done"
34022
34049
  })
34023
34050
  });
34024
- const FreeFormView = ({ view, filePath, isLoading, readError, mode, onSave, onDone, onSaveNote, busy }) => {
34051
+ const FreeFormView = ({ view, filePath, isLoading, readError, mode, onSave, onDone, onTick, onSaveNote, busy }) => {
34025
34052
  const [noteSheetAnchor, setNoteSheetAnchor] = (0, import_react.useState)(void 0);
34026
34053
  const overlay = useWriteStore((s) => s.overlay);
34027
34054
  const save = useWriteStore((s) => s.save);
@@ -34052,6 +34079,25 @@ const FreeFormView = ({ view, filePath, isLoading, readError, mode, onSave, onDo
34052
34079
  write: onSave === void 0 ? void 0 : onSave(APPEND_LINE, text)
34053
34080
  }).catch(() => {});
34054
34081
  };
34082
+ const tickWiring = {
34083
+ pick: (groupLine) => {
34084
+ const v = overlay[cellKey(filePath, {
34085
+ kind: "paragraph",
34086
+ line: groupLine
34087
+ }, "tick")]?.value;
34088
+ return typeof v === "number" ? v : void 0;
34089
+ },
34090
+ onTick: (groupLine, line) => {
34091
+ save({
34092
+ cell: cellKey(filePath, {
34093
+ kind: "paragraph",
34094
+ line: groupLine
34095
+ }, "tick"),
34096
+ optimistic: line,
34097
+ write: onTick === void 0 ? void 0 : onTick(line)
34098
+ }).catch(() => {});
34099
+ }
34100
+ };
34055
34101
  const runDone = () => {
34056
34102
  if (onDone === void 0) return;
34057
34103
  save({
@@ -34071,10 +34117,13 @@ const FreeFormView = ({ view, filePath, isLoading, readError, mode, onSave, onDo
34071
34117
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
34072
34118
  "data-testid": "freeform-scroll",
34073
34119
  className: "min-h-0 flex-1 overflow-auto",
34074
- children: view.nodes.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EmptyDocumentCapture, { onSave: saveEmpty }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ProseBlocks, {
34075
- nodes: view.nodes,
34076
- noteOverrides,
34077
- onOpenNote: (n) => setNoteSheetAnchor(n.anchor)
34120
+ children: view.nodes.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EmptyDocumentCapture, { onSave: saveEmpty }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TickContext.Provider, {
34121
+ value: onTick === void 0 ? void 0 : tickWiring,
34122
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ProseBlocks, {
34123
+ nodes: view.nodes,
34124
+ noteOverrides,
34125
+ onOpenNote: (n) => setNoteSheetAnchor(n.anchor)
34126
+ })
34078
34127
  })
34079
34128
  }),
34080
34129
  onDone !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FreeFormDoneRow, {
@@ -34125,6 +34174,22 @@ const useFreeFormMutations = (filePath, mode, onWriteSuccess) => {
34125
34174
  return result;
34126
34175
  });
34127
34176
  };
34177
+ const onTick = (line) => (cas) => {
34178
+ const anchor = {
34179
+ kind: "paragraph",
34180
+ line
34181
+ };
34182
+ return setValue.mutateAsync({
34183
+ filePath,
34184
+ ...cas,
34185
+ mode,
34186
+ anchor,
34187
+ checked: true
34188
+ }).then((result) => {
34189
+ onWriteSuccess(result.formatNotice);
34190
+ return result;
34191
+ });
34192
+ };
34128
34193
  const onSaveNote = (anchor, text) => (cas) => writeNote.mutateAsync({
34129
34194
  filePath,
34130
34195
  ...cas,
@@ -34138,6 +34203,7 @@ const useFreeFormMutations = (filePath, mode, onWriteSuccess) => {
34138
34203
  const onDone = () => done.mutateAsync({});
34139
34204
  return {
34140
34205
  onSave,
34206
+ onTick,
34141
34207
  onSaveNote,
34142
34208
  onDone,
34143
34209
  isDone: done.isSuccess
@@ -34148,7 +34214,7 @@ const FreeFormInner = ({ filePath, mode, data, isLoading, error }) => {
34148
34214
  const indicatorProps = useSaveIndicatorProps();
34149
34215
  const pending = useWriteStore((s) => s.pendingCount);
34150
34216
  const [formatNotice, setFormatNotice] = (0, import_react.useState)(void 0);
34151
- const { onSave, onSaveNote, onDone, isDone } = useFreeFormMutations(filePath, mode, setFormatNotice);
34217
+ const { onSave, onTick, onSaveNote, onDone, isDone } = useFreeFormMutations(filePath, mode, setFormatNotice);
34152
34218
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
34153
34219
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaveIndicator, { ...indicatorProps }),
34154
34220
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FormatNoticeBanner, {
@@ -34163,6 +34229,7 @@ const FreeFormInner = ({ filePath, mode, data, isLoading, error }) => {
34163
34229
  mode,
34164
34230
  onSave,
34165
34231
  onDone,
34232
+ onTick,
34166
34233
  onSaveNote,
34167
34234
  busy: pending > 0
34168
34235
  })
@@ -35882,8 +35949,8 @@ const openable = (step) => step?.status === "ok" && step.file !== void 0;
35882
35949
  * `"qa"` means `Plan`; `"findings"` means `Findings`; anything else (absent, or an unregistered mode name)
35883
35950
  * falls through to `FreeForm`, which reads the file as plain markdown and
35884
35951
  * offers only a note per block, plus a textfield when it's empty. Neither
35885
- * screen offers a way back to a list — `done` is the only
35886
- * thing that ends the turn, and it exits the process.
35952
+ * screen offers a way back to a list — `done` hands the turn back (the
35953
+ * server keeps running until the outer loop moves on).
35887
35954
  *
35888
35955
  * Never returns `null`: a blank white screen was package 03's own regression
35889
35956
  * (a phone over a tailnet with nothing on-screen to explain why) — every
@@ -1,5 +1,5 @@
1
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-CQzL-lX1.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
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "64.1.2",
3
+ "version": "64.3.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": {
@@ -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,
@@ -547,7 +557,7 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
547
557
  arity: "none",
548
558
  group: "Inspect",
549
559
  summary: "Serve the web and phone client",
550
- details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing.`,
560
+ details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing. Done saves the steering file and keeps serving; the server exits when the worktree moves on to another step, or on a signal.`,
551
561
  },
552
562
  {
553
563
  token: "guide",
@@ -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,
@@ -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
@@ -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,
@@ -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("")),
@@ -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 {
@@ -234,7 +234,7 @@ describe("freeFormFormat.annotate", () => {
234
234
  const attach = (content: string, text: string): string => {
235
235
  const r = freeFormFormat.annotate(content, anchor, text)
236
236
  if (!r.ok) throw new Error("refused")
237
- return applyEdits(content, r.edits)
237
+ return applyEditsExact(content, r.edits)
238
238
  }
239
239
  const created = attach("Paragraph one.\n", "first")
240
240
  expect(created).toMatch(/\[\^na\w+\]:\n - H: first\n/)
@@ -309,3 +309,53 @@ describe("freeFormFormat — thread outline", () => {
309
309
  ])
310
310
  })
311
311
  })
312
+
313
+ describe("freeFormFormat — task tick", () => {
314
+ const tick = (content: string, line: number): string => {
315
+ const r = freeFormFormat.apply(content, { kind: "paragraph", line }, { checked: true })
316
+ if (!r.ok) throw new Error(r.reason)
317
+ return applyEditsExact(content, r.edits)
318
+ }
319
+
320
+ it("ticks one box, leaving every other line byte-identical", () => {
321
+ const doc = "Tick exactly one event:\n\n- [ ] APPROVE\n- [ ] REJECT\n- [ ] CANCEL\n"
322
+ expect(tick(doc, 2)).toBe(doc.replace("- [ ] APPROVE", "- [x] APPROVE"))
323
+ })
324
+
325
+ it("moves the pick within one list only, leaving nested and other lists alone", () => {
326
+ const doc = "- [x] a\n - [x] n1\n - [ ] n2\n- [ ] b\n\n1. [x] other\n2. [ ] other2\n"
327
+ expect(tick(doc, 2)).toBe(
328
+ "- [x] a\n - [ ] n1\n - [x] n2\n- [ ] b\n\n1. [x] other\n2. [ ] other2\n",
329
+ )
330
+ expect(tick(doc, 3)).toBe(
331
+ "- [ ] a\n - [x] n1\n - [ ] n2\n- [x] b\n\n1. [x] other\n2. [ ] other2\n",
332
+ )
333
+ })
334
+
335
+ it("ticks a box that sits on the line after a bare marker", () => {
336
+ const doc = "-\n [ ] a\n-\n [x] b\n"
337
+ expect(tick(doc, 0)).toBe("-\n [x] a\n-\n [ ] b\n")
338
+ })
339
+
340
+ it("ticks an ordered item and keeps CRLF; a ticked box is a no-op; a fenced box refuses", () => {
341
+ expect(tick("1. [ ] a\r\n2. [ ] b\r\n", 1)).toBe("1. [ ] a\r\n2. [x] b\r\n")
342
+ const done = "- [x] a\n- [ ] b\n"
343
+ expect(tick(done, 0)).toBe(done)
344
+ expect(
345
+ freeFormFormat.apply("```\n- [ ] a\n```\n", { kind: "paragraph", line: 1 }, { checked: true })
346
+ .ok,
347
+ ).toBe(false)
348
+ })
349
+
350
+ it("refuses to clear a box, whatever the anchor, and a tick round-trips byte-exact", () => {
351
+ const doc = "- [x] a\n- [ ] b\n"
352
+ expect(freeFormFormat.apply(doc, { kind: "paragraph", line: 0 }, { checked: false })).toEqual({
353
+ ok: false,
354
+ reason: "pick-never-clears",
355
+ })
356
+ const moved = tick(doc, 1)
357
+ expect(moved).toBe("- [ ] a\n- [x] b\n")
358
+ const back = freeFormFormat.apply(moved, { kind: "paragraph", line: 0 }, { checked: true })
359
+ expect(back.ok && applyEditsExact(moved, back.edits)).toBe(doc)
360
+ })
361
+ })
@@ -1,3 +1,4 @@
1
+ import type { ListItem } from "mdast"
1
2
  import { blockNodesOf } from "./Blocks.js"
2
3
  import { eolOf } from "./Eol.js"
3
4
  import {
@@ -93,6 +94,41 @@ const appendEdit = (content: string, text: string): SteeringEdit => {
93
94
  return wholeDocumentEdit(content, newText)
94
95
  }
95
96
 
97
+ /** Every task-list item with its sibling task items (direct children of one list only). */
98
+ const taskGroups = (node: unknown, out: ListItem[][]): void => {
99
+ const n = node as { type?: string; children?: unknown[] }
100
+ if (n.type === "list") {
101
+ out.push((n.children as ListItem[]).filter((i) => i.checked === true || i.checked === false))
102
+ }
103
+ for (const c of n.children ?? []) taskGroups(c, out)
104
+ }
105
+
106
+ /** The box sits on the first child's line, which is not the marker line when a bare `-` precedes it. */
107
+ const boxEdit = (lines: string[], item: ListItem, newText: string): SteeringEdit | undefined => {
108
+ const line = toLspPosition((item.children[0]?.position ?? item.position!).start).line
109
+ const m = /\[( |x|X)\]/.exec(lines[line] ?? "")
110
+ if (!m) return undefined
111
+ const c = m.index + 1
112
+ return { range: { start: { line, character: c }, end: { line, character: c + 1 } }, newText }
113
+ }
114
+
115
+ /** Pick-one tick: sets the box on `line` and unticks its ticked siblings, in one edit set. Ticking an already-ticked box is an empty edit set. */
116
+ const tickEdits = (content: string, line: number): SteeringAnnotateResult => {
117
+ const lines = content.split("\n")
118
+ const groups: ListItem[][] = []
119
+ taskGroups(parseMarkdown(content), groups)
120
+ const startLine = (i: ListItem): number => toLspPosition(i.position!.start).line
121
+ const group = groups.find((g) => g.some((i) => startLine(i) === line))
122
+ if (!group) return { ok: false, reason: "anchor-not-found" }
123
+ const target = group.find((i) => startLine(i) === line)!
124
+ if (target.checked === true) return { ok: true, edits: [] }
125
+ const edits = group
126
+ .filter((i) => i === target || i.checked === true)
127
+ .map((i) => boxEdit(lines, i, i === target ? "x" : " "))
128
+ if (edits.some((e) => e === undefined)) return { ok: false, reason: "anchor-not-found" }
129
+ return { ok: true, edits: edits as SteeringEdit[] }
130
+ }
131
+
96
132
  /**
97
133
  * Free-form's `apply`: only a `paragraph` anchor resolves (the only kind
98
134
  * `view` ever reports), and it only ever APPENDS, at or past the document's
@@ -104,6 +140,8 @@ const appendEdit = (content: string, text: string): SteeringEdit => {
104
140
  */
105
141
  const freeFormApply: SteeringFormat["apply"] = (content, anchor, opts): SteeringAnnotateResult => {
106
142
  if (anchor.kind !== "paragraph") return { ok: false, reason: "anchor-not-found" }
143
+ if (opts.checked === true) return tickEdits(content, anchor.line)
144
+ if (opts.checked === false) return { ok: false, reason: "pick-never-clears" }
107
145
  const text = opts.text ?? ""
108
146
  const tree = parseMarkdown(content)
109
147
  const lines = content.split(/\r?\n/)
@@ -77,6 +77,8 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
77
77
 
78
78
  changedPaths: (base?: string) => Effect.succeed(repo.changedPathsWorktree(base)),
79
79
 
80
+ untrackedPaths: () => Effect.succeed(repo.untrackedPaths()),
81
+
80
82
  stagedPaths: () => Effect.succeed(repo.stagedPaths()),
81
83
 
82
84
  diffPaths: (base: string, tip: string) => tryCatch(() => repo.diffPaths(base, tip)),