@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -14
- package/dist/chunks/{flows-B7ZRCe-B.mjs → flows-dnIE_qbe.mjs} +7 -13
- package/dist/chunks/{rolldown-runtime-D7D4PA-g.mjs → rolldown-runtime-D8jQpDG7.mjs} +1 -2
- package/dist/chunks/{steering-Cem5i6Xl.mjs → steering-DEh_MS40.mjs} +181 -187
- package/dist/chunks/{testkit-DfWZQCWQ.mjs → testkit-BwCH8ky6.mjs} +412 -512
- package/dist/chunks/{wire-CPNSm_9N.mjs → wire-CaPJ9Tme.mjs} +12 -24
- package/dist/flows.mjs +1 -2
- package/dist/gtd.bundle.mjs +1010 -1785
- package/dist/gtd.core.mjs +191 -182
- package/dist/steering.mjs +1 -2
- package/dist/testkit/gherkin.mjs +3 -5
- package/dist/testkit.mjs +2 -2
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +438 -413
- package/dist/wire.mjs +1 -2
- package/dist/workflow-kit.mjs +6 -10
- package/package.json +19 -32
- package/src/cli/Cli.test.ts +1 -0
- package/src/cli/Cli.ts +16 -1
- package/src/cli/Program.ts +6 -0
- package/src/cli/ToolCommands.ts +1 -0
- package/src/edge/Edge.test.ts +23 -1
- package/src/edge/Edge.ts +23 -1
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.test.ts +1 -1
- package/src/flows/runtime.test.ts +1 -1
- package/src/platform/Git.ts +33 -10
- package/src/replay/Replay.test.ts +21 -7
- package/src/steering/Blocks.test.ts +3 -1
- package/src/steering/Blocks.ts +14 -6
- package/src/steering/SteeringFormat.ts +9 -1
- package/src/steering/freeform.test.ts +51 -1
- package/src/steering/freeform.ts +38 -0
- package/src/steering/qa.test.ts +3 -3
- package/src/steering/qa.ts +1 -1
- package/src/steering/review.test.ts +26 -1
- package/src/step/LandStep.ts +1 -1
- package/src/twins/FakeGitOperations.ts +2 -0
- package/src/twins/GitTiers.ts +27 -2
- package/src/twins/InMemRepo.ts +5 -0
- package/src/wire/BeatPlain.ts +3 -3
- 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
|
|
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
|
+
})
|
package/src/steering/freeform.ts
CHANGED
|
@@ -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/)
|
package/src/steering/qa.test.ts
CHANGED
|
@@ -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
|
|
package/src/steering/qa.ts
CHANGED
|
@@ -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 `
|
|
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",
|
package/src/step/LandStep.ts
CHANGED
|
@@ -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:
|
|
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)),
|
package/src/twins/GitTiers.ts
CHANGED
|
@@ -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 `
|
|
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(" ")}`, {
|
|
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")
|
package/src/twins/InMemRepo.ts
CHANGED
|
@@ -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)
|
package/src/wire/BeatPlain.ts
CHANGED
|
@@ -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,
|
|
36
|
-
): Record<string,
|
|
37
|
-
const result: Record<string,
|
|
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
|
}
|
package/src/wire/Select.ts
CHANGED
|
@@ -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`. */
|