@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
@@ -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/)
@@ -1172,7 +1172,7 @@ describe("qa.view — block nodes (package 02, T1/T2)", () => {
1172
1172
  expect(topItem?.text).not.toContain("Nested")
1173
1173
  expect(topItem?.text).not.toContain("-")
1174
1174
  expect(topItem?.items).toEqual([
1175
- { text: "Nested", inline: [{ kind: "text", value: "Nested" }] },
1175
+ { text: "Nested", inline: [{ kind: "text", value: "Nested" }], line: 2 },
1176
1176
  ])
1177
1177
  })
1178
1178
 
@@ -1180,8 +1180,8 @@ describe("qa.view — block nodes (package 02, T1/T2)", () => {
1180
1180
  const content = ["- [ ] Not done", "- [x] Done", ""].join("\n")
1181
1181
  const view = qa.view(content)
1182
1182
  expect(view.nodes[0]?.block?.items).toEqual([
1183
- { text: "Not done", checked: false, inline: [{ kind: "text", value: "Not done" }] },
1184
- { text: "Done", checked: true, inline: [{ kind: "text", value: "Done" }] },
1183
+ { text: "Not done", checked: false, inline: [{ kind: "text", value: "Not done" }], line: 0 },
1184
+ { text: "Done", checked: true, inline: [{ kind: "text", value: "Done" }], line: 1 },
1185
1185
  ])
1186
1186
  })
1187
1187
 
@@ -822,7 +822,7 @@ const replaceOptionTextEdit = (
822
822
  * Collapses a typed answer to the single-line shape a list-item label must
823
823
  * be. A raw textarea value's interior newlines would break `.gtd/`'s oxfmt
824
824
  * fixed point the moment they are spliced onto a `- [x] ` line, and every
825
- * steering file — server-written ones included — is covered by `format:check`.
825
+ * steering file — server-written ones included — is covered by the `check` task.
826
826
  */
827
827
  /**
828
828
  * Empty normalizes to `FREE_TEXT_PLACEHOLDER`, never `""`: an empty label
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { FOOTNOTE_ACTION_TITLE, steeringFormatFor, viewOf } from "./internal.js"
2
+ import { applySteeringEdits, FOOTNOTE_ACTION_TITLE, steeringFormatFor, viewOf } from "./internal.js"
3
3
  import { parseReviewDoc, unansweredNotes } from "./review.js"
4
4
  import { getParseCount } from "./internal.js"
5
5
 
@@ -1370,6 +1370,31 @@ describe("review.annotate", () => {
1370
1370
  expect(result.ok).toBe(true)
1371
1371
  })
1372
1372
 
1373
+ it("a paragraph anchor on one item of a chunk's list notes that item alone", () => {
1374
+ const summary = [
1375
+ "# Review: abc1234",
1376
+ "<!-- base: abc1234def5678901234567890123456789abcd -->",
1377
+ "",
1378
+ "## Quality lap",
1379
+ "",
1380
+ "Fixed automatically:",
1381
+ "",
1382
+ "- Rounds wrong (lens correctness)",
1383
+ "- Missing test (lens tests)",
1384
+ "",
1385
+ ].join("\n")
1386
+ const before = review.view(summary).nodes[0]!.body![1]!.block!.items!
1387
+ expect(before.map((item) => item.line)).toEqual([7, 8])
1388
+ const result = review.annotate(summary, { kind: "paragraph", line: 8 }, "reopen it")
1389
+ if (!result.ok) throw new Error(result.reason)
1390
+ const after = applySteeringEdits(summary, result.edits)
1391
+ expect(review.validate(after)).toEqual([])
1392
+ const items = review.view(after).nodes[0]!.body![1]!.block!.items!
1393
+ expect(items[0]).not.toHaveProperty("thread")
1394
+ expect(items[0]).not.toHaveProperty("note")
1395
+ expect(items[1]!.thread?.entries).toEqual([{ author: "me", text: "reopen it" }])
1396
+ })
1397
+
1373
1398
  it("accepts a paragraph anchor in a prose-only document", () => {
1374
1399
  const result = review.annotate(
1375
1400
  "Just some prose.\n",
@@ -16,7 +16,7 @@ export type GitWrite =
16
16
  /** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
17
17
  export type Outcome =
18
18
  | { readonly kind: "transition"; readonly from: StateName; readonly to: StateName }
19
- | { readonly kind: "commit"; readonly subject: StateName | string }
19
+ | { readonly kind: "commit"; readonly subject: string }
20
20
  | { readonly kind: "note"; readonly text: string }
21
21
 
22
22
  /**
@@ -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)),
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os"
14
14
  import { execFileSync, execSync, spawnSync } from "node:child_process"
15
15
  // Imported directly (not just from *.test.ts) — a leak of this module into
16
16
  // the shipped CLI bundle would try to inline `vitest` and fail the build
17
- // loudly (`deps.alwaysBundle` in `tsdown.config.ts`), a guard alongside the
17
+ // loudly (`deps.alwaysBundle` in `vite.config.ts`), a guard alongside the
18
18
  // `TEST_DOUBLE_SENTINEL` check.
19
19
  import { afterEach, beforeEach, describe, expect, it } from "vitest"
20
20
  import { Effect, Exit, Option } from "effect"
@@ -131,7 +131,12 @@ export interface GitTier {
131
131
  const noopNarratorLayer = Narrator.layer(() => {}, false)
132
132
 
133
133
  const gitExecIn = (dir: string, ...args: string[]): string =>
134
- execSync(`git ${args.join(" ")}`, { cwd: dir, encoding: "utf8", stdio: "pipe" }).trim()
134
+ execSync(`git ${args.join(" ")}`, {
135
+ cwd: dir,
136
+ encoding: "utf8",
137
+ stdio: "pipe",
138
+ maxBuffer: 1 << 28,
139
+ }).trim()
135
140
 
136
141
  const makeLiveTier = (initialCommit = true): GitTier => {
137
142
  // realpath'd once here so `t.root` matches `git rev-parse --show-toplevel`'s
@@ -150,6 +155,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
150
155
  cwd: root,
151
156
  env: commitEnv(),
152
157
  stdio: "pipe",
158
+ maxBuffer: 1 << 28,
153
159
  })
154
160
  gitExec("init")
155
161
  gitExec(`config user.email "test@test.com"`)
@@ -432,6 +438,7 @@ export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new
432
438
  "subjectHistory",
433
439
  "readFileAtRef",
434
440
  "changedPaths",
441
+ "untrackedPaths",
435
442
  "stagedPaths",
436
443
  "diffPaths",
437
444
  "authorIdent",
@@ -1098,6 +1105,24 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
1098
1105
  expect(await runGit(t, (g) => g.changedPaths())).toEqual([])
1099
1106
  })
1100
1107
 
1108
+ it("classifies an untracked set far past the argument limit, against an older base", async () => {
1109
+ const long = "d".repeat(200)
1110
+ const paths = Array.from({ length: 4000 }, (_, i) => `${long}/${long}/${long}/f${i}.txt`)
1111
+ t.seed.commit("chore: seed", { "kept.txt": "seed" })
1112
+ const seed = t.observe.resolveRef("HEAD")
1113
+ t.seed.commit(
1114
+ "chore: files",
1115
+ Object.fromEntries(paths.map((p, i) => [p, i % 2 === 0 ? "same" : "old"])),
1116
+ )
1117
+ const head = t.observe.resolveRef("HEAD")
1118
+ t.seed.mixedReset(seed)
1119
+ paths.forEach((p, i) => t.seed.writeFile(p, i % 2 === 0 ? "same" : "new"))
1120
+ const changed = await runGit(t, (g) => g.changedPaths(head))
1121
+ expect(changed.length).toBe(2000)
1122
+ expect(changed.every((c) => c.status === "M")).toBe(true)
1123
+ expect((await runGit(t, (g) => g.untrackedPaths())).length).toBe(4000)
1124
+ })
1125
+
1101
1126
  it("ignores an edit staged and then reverted in the worktree", async () => {
1102
1127
  t.seed.commit("chore: seed", { "a.txt": "1" })
1103
1128
  t.seed.writeFile("a.txt", "2")
@@ -298,6 +298,11 @@ export class InMemRepo {
298
298
  return diffTrees(baseTree, worktreeTree)
299
299
  }
300
300
 
301
+ /** Worktree paths the index does not carry. */
302
+ untrackedPaths(): string[] {
303
+ return [...this.worktree.keys()].filter((k) => !this.index.has(k))
304
+ }
305
+
301
306
  /** The index vs HEAD — the port's `stagedPaths`. */
302
307
  stagedPaths(): Array<{ path: string; status: string }> {
303
308
  return diffTrees(this.treeAt(undefined), this.index)
@@ -32,9 +32,9 @@ const nextStatusLine = (next: BeatDocument["next"]): string =>
32
32
 
33
33
  /** Builds `{[key]: value}` for each entry whose value isn't `undefined` — the shared "omit absent optional fields" shape the header uses. */
34
34
  const definedFields = (
35
- entries: readonly (readonly [string, unknown])[],
36
- ): Record<string, unknown> => {
37
- const result: Record<string, unknown> = {}
35
+ entries: readonly (readonly [string, string | undefined])[],
36
+ ): Record<string, string> => {
37
+ const result: Record<string, string> = {}
38
38
  for (const [key, value] of entries) if (value !== undefined) result[key] = value
39
39
  return result
40
40
  }
@@ -18,7 +18,7 @@ const toSelection = (value: unknown): Selection => {
18
18
  if (typeof value === "object") {
19
19
  return { kind: "value", text: JSON.stringify(value) }
20
20
  }
21
- return { kind: "value", text: String(value) }
21
+ return { kind: "value", text: String(value as string | number | boolean | bigint | undefined) }
22
22
  }
23
23
 
24
24
  /** A segment that resolves to no key at all (missing from an object, out-of-range or non-numeric against an array) — distinct from a present key holding `undefined`. */