@pmelab/gtd 51.0.0 → 52.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +41 -38
  3. package/dist/chunks/{reserved-CUkk9XgL.mjs → reserved-D0MKVFFa.mjs} +28 -34
  4. package/dist/chunks/{review-BaCXlIT2.mjs → review-ZzUQH1KZ.mjs} +11 -9
  5. package/dist/chunks/{steering-DtUVUz9l.mjs → steering-CLhu8C7q.mjs} +262 -52
  6. package/dist/chunks/{tail-CX6Pdsql.mjs → tail-I1-vVq0P.mjs} +1 -1
  7. package/dist/gtd.bundle.mjs +224 -68
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +27 -26
  10. package/dist/web/main.css +1 -1
  11. package/dist/web/main.js +510 -147
  12. package/dist/workflow-kit.mjs +1 -1
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +181 -222
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +3 -3
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +478 -313
  19. package/package.json +1 -1
  20. package/src/edge/Edge.ts +1 -30
  21. package/src/replay/Replay.test.ts +4 -8
  22. package/src/replay/Replay.ts +35 -3
  23. package/src/replay/index.ts +7 -1
  24. package/src/steering/FORMATS.md +4 -1
  25. package/src/steering/SteeringFormat.ts +7 -1
  26. package/src/steering/contract.test.ts +2 -1
  27. package/src/steering/fixtures/review/sectioned.md +31 -0
  28. package/src/steering/fixtures/review/sectioned.read.json +119 -0
  29. package/src/steering/index.ts +4 -0
  30. package/src/steering/qa.ts +1 -1
  31. package/src/steering/review.test.ts +270 -4
  32. package/src/steering/review.ts +328 -69
  33. package/src/workflows/feature/criteria.test.ts +39 -1
  34. package/src/workflows/feature/criteria.ts +21 -7
  35. package/src/workflows/feature/fast-path.feature +1 -6
  36. package/src/workflows/feature/feature.ts +1 -2
  37. package/src/workflows/feature/flag-mode.feature +1 -6
  38. package/src/workflows/feature/green.ts +22 -0
  39. package/src/workflows/feature/hands-on-skip.feature +344 -0
  40. package/src/workflows/feature/lens-models.feature +0 -4
  41. package/src/workflows/feature/lens-review.feature +6 -8
  42. package/src/workflows/feature/one-step-land.feature +1 -6
  43. package/src/workflows/feature/prototype-rounds.feature +1 -1
  44. package/src/workflows/feature/prototype.feature +10 -9
  45. package/src/workflows/feature/prototype.ts +48 -17
  46. package/src/workflows/feature/refactor-absorbs.feature +3 -8
  47. package/src/workflows/feature/refactor-first.feature +3 -8
  48. package/src/workflows/feature/refactor-rebuild.feature +7 -12
  49. package/src/workflows/feature/refactor-review.feature +52 -72
  50. package/src/workflows/feature/review-answers.feature +90 -5
  51. package/src/workflows/feature/review-gate.feature +11 -53
  52. package/src/workflows/feature/reviewer-walkthrough.feature +29 -12
  53. package/src/workflows/feature/seam-rejection.feature +1 -6
  54. package/src/workflows/feature/seam.feature +1 -6
  55. package/src/workflows/feature/seam.test.ts +1 -1
  56. package/src/workflows/feature/seam.ts +2 -3
  57. package/src/workflows/feature/severity-budget.feature +7 -7
  58. package/src/workflows/feature/severity-sorted-review.feature +50 -54
  59. package/src/workflows/feature/slice-findings.feature +5 -15
  60. package/src/workflows/feature/slicing.feature +4 -24
  61. package/src/workflows/feature/standards.feature +0 -4
  62. package/src/workflows/shared/review.ts +26 -6
  63. package/src/workflows/shared/text.test.ts +45 -11
  64. package/src/workflows/shared/text.ts +45 -38
  65. package/src/workflows/feature/coverage.feature +0 -165
  66. package/src/workflows/feature/coverage.ts +0 -120
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "51.0.0",
3
+ "version": "52.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
package/src/edge/Edge.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  isRecordableApproval,
38
38
  parseCommitMessage,
39
39
  replay,
40
+ scopeRunStart,
40
41
  sliceRanges,
41
42
  treeFromRecord,
42
43
  type EpisodeCommit,
@@ -791,36 +792,6 @@ const stepDefOf = (step: ReachedStep): StepDef => {
791
792
  }
792
793
  }
793
794
 
794
- /** Is `name` inside `scope`'s subtree? The root scope `""` holds everything. */
795
- const inScope = (name: string, scope: string): boolean =>
796
- scope === "" || name === scope || name.startsWith(`${scope}.`)
797
-
798
- /**
799
- * Where the current unbroken run of rests inside `scope`'s subtree began —
800
- * a dip into a descendant scope does not break it, a sibling or ancestor
801
- * does, and so does a fresh `scope()` call of the same scope.
802
- */
803
- const scopeRunStart = (trace: readonly ReachedStep[], scope: string): number => {
804
- let start = -1
805
- for (let k = 0; k < trace.length; k++) {
806
- if (!inScope(trace[k]!.memoryScope, scope)) continue
807
- const previous = trace[k - 1]
808
- const unbroken =
809
- previous !== undefined &&
810
- inScope(previous.memoryScope, scope) &&
811
- scopeCallOf(previous, scope) === scopeCallOf(trace[k]!, scope)
812
- if (!unbroken) start = k
813
- }
814
- return start
815
- }
816
-
817
- /** The innermost `scope()` call enclosing `scope` at `step` — a new call is a new conversation. */
818
- const scopeCallOf = (step: ReachedStep, scope: string): number | undefined => {
819
- let call: number | undefined
820
- for (const entry of step.scopeCalls) if (inScope(scope, entry.prefix)) call = entry.call
821
- return call
822
- }
823
-
824
795
  // Shown for the root scope, which has no name of its own.
825
796
  const ROOT_MEMORY_SCOPE_NAME = "root"
826
797
 
@@ -940,7 +940,7 @@ describe("replay: skills resolution", () => {
940
940
  expect(await wireSkills(inScope({ name: "a" }, step), { skills: { a: [] } })).toBeUndefined()
941
941
  })
942
942
 
943
- it("fails two agent steps in one memory scope with different lists", async () => {
943
+ it("runs two scope() calls of one memory scope with different lists as two conversations", async () => {
944
944
  const flow: Flow = async () => {
945
945
  await scope({ name: "a", skills: ["one"] }, async () => {
946
946
  await agent("first", "prompt")
@@ -959,9 +959,7 @@ describe("replay: skills resolution", () => {
959
959
  start: hashOf(0),
960
960
  budgetBytes: 1024,
961
961
  })
962
- expect(outcome.kind === "failed" && outcome.message).toContain(
963
- "different model, system prompt, skills or file access",
964
- )
962
+ expect(outcome.kind).toBe("rest")
965
963
  })
966
964
  })
967
965
 
@@ -1084,7 +1082,7 @@ describe("replay: access resolution", () => {
1084
1082
  expect(outcome.rest.access).toEqual({ read: null, write: ["src/a.ts"] })
1085
1083
  })
1086
1084
 
1087
- it("fails two agent steps in one memory scope with different scope access", async () => {
1085
+ it("runs two scope() calls of one memory scope with different access as two conversations", async () => {
1088
1086
  const flow: Flow = async () => {
1089
1087
  await scope({ name: "a", access: { write: ["one"] } }, () => agent("first", "prompt"))
1090
1088
  await scope({ name: "a", access: { write: ["two"] } }, () => agent("second", "prompt"))
@@ -1092,9 +1090,7 @@ describe("replay: access resolution", () => {
1092
1090
  const history = new History()
1093
1091
  history.land("agent", "a.first", 1, "a.second", { "x.txt": "x" })
1094
1092
  const outcome = await restOf(flow, {}, history)
1095
- expect(outcome.kind === "failed" && outcome.message).toContain(
1096
- "different model, system prompt, skills or file access",
1097
- )
1093
+ expect(outcome.kind).toBe("rest")
1098
1094
  })
1099
1095
 
1100
1096
  it("does not conflict two steps that differ only in steering file", async () => {
@@ -139,6 +139,36 @@ const memoryScopeOf = (name: string): string => {
139
139
  return dot === -1 ? "" : name.slice(0, dot)
140
140
  }
141
141
 
142
+ /** Is `name` inside `scope`'s subtree? The root scope `""` holds everything. */
143
+ const inScope = (name: string, scope: string): boolean =>
144
+ scope === "" || name === scope || name.startsWith(`${scope}.`)
145
+
146
+ /**
147
+ * Where the current unbroken run of rests inside `scope`'s subtree began —
148
+ * a dip into a descendant scope does not break it, a sibling or ancestor
149
+ * does, and so does a fresh `scope()` call of the same scope.
150
+ */
151
+ export const scopeRunStart = (trace: readonly ReachedStep[], scope: string): number => {
152
+ let start = -1
153
+ for (let k = 0; k < trace.length; k++) {
154
+ if (!inScope(trace[k]!.memoryScope, scope)) continue
155
+ const previous = trace[k - 1]
156
+ const unbroken =
157
+ previous !== undefined &&
158
+ inScope(previous.memoryScope, scope) &&
159
+ scopeCallOf(previous, scope) === scopeCallOf(trace[k]!, scope)
160
+ if (!unbroken) start = k
161
+ }
162
+ return start
163
+ }
164
+
165
+ /** The innermost `scope()` call enclosing `scope` at `step` — a new call is a new conversation. */
166
+ const scopeCallOf = (step: ReachedStep, scope: string): number | undefined => {
167
+ let call: number | undefined
168
+ for (const entry of step.scopeCalls) if (inScope(scope, entry.prefix)) call = entry.call
169
+ return call
170
+ }
171
+
142
172
  class Stop extends Error {}
143
173
 
144
174
  const short = (hash: string): string => hash.slice(0, 7)
@@ -505,14 +535,16 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
505
535
  skills: step.skills,
506
536
  access: unfoldedAccess.get(step),
507
537
  }
508
- const seen = personas.get(step.memoryScope)
538
+ // Keyed like the memory key: a fresh scope() call is a fresh conversation, free to differ.
539
+ const conversation = `${step.memoryScope}#${scopeRunStart(trace, step.memoryScope)}`
540
+ const seen = personas.get(conversation)
509
541
  if (seen === undefined) {
510
- personas.set(step.memoryScope, identity)
542
+ personas.set(conversation, identity)
511
543
  return undefined
512
544
  }
513
545
  return samePersona(seen, identity)
514
546
  ? undefined
515
- : `gtd: "${step.name}" runs with a different model, system prompt, skills or file access than an earlier agent step in memory scope "${step.memoryScope || "root"}" — one scope is one conversation, and a conversation has one identity`
547
+ : `gtd: "${step.name}" runs with a different model, system prompt, skills or file access than an earlier agent step in its conversation in memory scope "${step.memoryScope || "root"}" — a conversation has one identity`
516
548
  }
517
549
 
518
550
  const DIVERGED = "the workflow changed under this process; run `gtd abandon` to start over"
@@ -1,4 +1,10 @@
1
- export { replay, type EpisodeCommit, type ReachedStep, type ReplayOutcome } from "./Replay.js"
1
+ export {
2
+ replay,
3
+ scopeRunStart,
4
+ type EpisodeCommit,
5
+ type ReachedStep,
6
+ type ReplayOutcome,
7
+ } from "./Replay.js"
2
8
  export { treeFromRecord, type TreeView } from "./Tree.js"
3
9
  export {
4
10
  formatCommitMessage,
@@ -14,7 +14,10 @@ read them.
14
14
  `<!-- base: <sha> -->` comment, then `##` chunks with prose and `- [ ]` hunk
15
15
  pointers (`./path`, `./path#start-end`), each optionally followed by
16
16
  `— <note>`. A pointer note starting `Risk:` is a reviewer's risk. A ticked
17
- pointer is a hunk the human has read.
17
+ pointer is a hunk the human has read. A chunk's `Tick exactly one:` list is
18
+ its decision. A `## Findings` or `## Walkthrough` heading groups the `###`
19
+ chunks under it; a walkthrough chunk takes no decision. Any other `##` is a
20
+ chunk of its own.
18
21
  - **Free-form**: any markdown, for a file with no mode or an unknown one. Only
19
22
  its threads are validated.
20
23
  - **Threads**, shared by all three: a footnote whose definition is a list of
@@ -64,6 +64,8 @@ export interface SteeringLink {
64
64
  * format-agnostic: `review`'s chunk/hunk and `qa`'s question/option are the
65
65
  * same two things here (a container and one of its numbered children), plus
66
66
  * `paragraph`, which a client resolves from its cursor rather than the view.
67
+ * A `review` finding's answer boxes are `option`s too, `questionIndex` being
68
+ * the chunk's index.
67
69
  */
68
70
  export type SteeringAnchor =
69
71
  | { readonly kind: "chunk"; readonly index: number }
@@ -110,7 +112,7 @@ export interface SteeringViewNode {
110
112
  readonly detail?: string
111
113
  /** A free-form status label, when the format has one (`qa`'s `"open"`/`"answered"`). */
112
114
  readonly status?: string
113
- /** `true` when this node's own condition is fully satisfied (`qa`'s answered-question flag) — distinct from `checked`, which is a per-item tick. */
115
+ /** `true` when this node's own condition is fully satisfied (`qa`'s answered question, `review`'s decided finding) — distinct from `checked`, which is a per-item tick. */
114
116
  readonly answered?: boolean
115
117
  /** This node's own checkbox state, when it has one (a hunk's tick, an option's tick). */
116
118
  readonly checked?: boolean
@@ -138,6 +140,10 @@ export interface SteeringViewNode {
138
140
  readonly endLine?: number
139
141
  /** Where `annotate` attaches a NEW note to this node. */
140
142
  readonly anchor: SteeringAnchor
143
+ /** The section a top-level node sits in, when the format groups them (`review`'s `Findings` / `Walkthrough`). */
144
+ readonly group?: string
145
+ /** A `review` finding's answer boxes, in order — fix as suggested, won't fix, and the free-text slot, whose `title` is the human's text (`""` while unfilled). Kept out of `children`, which are its hunks. */
146
+ readonly decision?: readonly SteeringViewNode[]
141
147
  readonly children?: readonly SteeringViewNode[]
142
148
  /**
143
149
  * A node's body, a SIBLING of `children` and never a reuse of it: a `qa`
@@ -97,7 +97,8 @@ describe.each(FORMATS)("the %s format", (dir, format, ticks) => {
97
97
 
98
98
  if (ticks)
99
99
  it("round-trips every tick: writing back what was read leaves the bytes unchanged", () => {
100
- for (const node of nodesOf(format.view(content).nodes)) {
100
+ const nodes = nodesOf(format.view(content).nodes)
101
+ for (const node of [...nodes, ...nodes.flatMap((n) => n.decision ?? [])]) {
101
102
  if (node.checked === undefined || node.anchor.kind === "chunk") continue
102
103
  const anchor: SteeringAnchor = node.anchor
103
104
  const same = write(
@@ -0,0 +1,31 @@
1
+ # Review: 1a2b3c4
2
+
3
+ <!-- base: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b -->
4
+
5
+ ## Findings
6
+
7
+ ### A lone CR is kept
8
+
9
+ _should: contract_
10
+
11
+ The parser keeps a lone `\r` as content.
12
+
13
+ In the code:
14
+
15
+ - [x] ./src/parse.ts#30-31 — where it is kept
16
+
17
+ Tick exactly one:
18
+
19
+ - [ ] fix as suggested
20
+ - [x] won't fix
21
+ - [ ] _your answer_
22
+
23
+ ## Walkthrough
24
+
25
+ ### Parser accepts CRLF
26
+
27
+ - [ ] ./src/parse.ts#10-14 — normalizes `\r\n` first
28
+
29
+ ## Fixed automatically
30
+
31
+ - a test name typo (0123456)
@@ -0,0 +1,119 @@
1
+ {
2
+ "header": "1a2b3c4",
3
+ "nodes": [
4
+ {
5
+ "title": "A lone CR is kept",
6
+ "detail": "The parser keeps a lone `\\r` as content. In the code:",
7
+ "body": [
8
+ {
9
+ "title": "The parser keeps a lone `\\r` as content.",
10
+ "anchor": {
11
+ "kind": "paragraph",
12
+ "line": 10
13
+ }
14
+ },
15
+ {
16
+ "title": "In the code:",
17
+ "anchor": {
18
+ "kind": "paragraph",
19
+ "line": 12
20
+ }
21
+ }
22
+ ],
23
+ "status": "should: contract",
24
+ "anchor": {
25
+ "kind": "chunk",
26
+ "index": 0
27
+ },
28
+ "group": "Findings",
29
+ "answered": true,
30
+ "decision": [
31
+ {
32
+ "title": "fix as suggested",
33
+ "checked": false,
34
+ "anchor": {
35
+ "kind": "option",
36
+ "questionIndex": 0,
37
+ "index": 0
38
+ }
39
+ },
40
+ {
41
+ "title": "won't fix",
42
+ "checked": true,
43
+ "anchor": {
44
+ "kind": "option",
45
+ "questionIndex": 0,
46
+ "index": 1
47
+ }
48
+ },
49
+ {
50
+ "title": "",
51
+ "checked": false,
52
+ "anchor": {
53
+ "kind": "option",
54
+ "questionIndex": 0,
55
+ "index": 2
56
+ }
57
+ }
58
+ ],
59
+ "children": [
60
+ {
61
+ "title": "./src/parse.ts#30-31",
62
+ "path": "./src/parse.ts",
63
+ "line": 30,
64
+ "endLine": 31,
65
+ "checked": true,
66
+ "detail": "where it is kept",
67
+ "anchor": {
68
+ "kind": "hunk",
69
+ "chunkIndex": 0,
70
+ "index": 0
71
+ }
72
+ }
73
+ ]
74
+ },
75
+ {
76
+ "title": "Parser accepts CRLF",
77
+ "detail": "",
78
+ "body": [],
79
+ "anchor": {
80
+ "kind": "chunk",
81
+ "index": 1
82
+ },
83
+ "group": "Walkthrough",
84
+ "children": [
85
+ {
86
+ "title": "./src/parse.ts#10-14",
87
+ "path": "./src/parse.ts",
88
+ "line": 10,
89
+ "endLine": 14,
90
+ "checked": false,
91
+ "detail": "normalizes `\\r\\n` first",
92
+ "anchor": {
93
+ "kind": "hunk",
94
+ "chunkIndex": 1,
95
+ "index": 0
96
+ }
97
+ }
98
+ ]
99
+ },
100
+ {
101
+ "title": "Fixed automatically",
102
+ "detail": "- a test name typo (0123456)",
103
+ "body": [
104
+ {
105
+ "title": "a test name typo (0123456)",
106
+ "anchor": {
107
+ "kind": "paragraph",
108
+ "line": 30
109
+ }
110
+ }
111
+ ],
112
+ "anchor": {
113
+ "kind": "chunk",
114
+ "index": 2
115
+ },
116
+ "children": []
117
+ }
118
+ ]
119
+ }
@@ -16,11 +16,15 @@ export {
16
16
  reviewDecisions,
17
17
  reviewNotes,
18
18
  reviewRisks,
19
+ reviewSections,
20
+ REVIEW_SECTIONS,
21
+ sectionedReview,
19
22
  severityLine,
20
23
  unansweredNotes,
21
24
  type ReviewChoice,
22
25
  type ReviewDecision,
23
26
  type ReviewNote,
27
+ type ReviewSection,
24
28
  } from "./review.js"
25
29
  export { applySteeringEdits } from "./Edits.js"
26
30
 
@@ -830,7 +830,7 @@ const replaceOptionTextEdit = (
830
830
  * offset for, breaking this anchor's `apply` forever. The placeholder reads
831
831
  * back as `""`, so an erase still reads as unanswered.
832
832
  */
833
- const normalizeFreeTextAnswer = (text: string): string => {
833
+ export const normalizeFreeTextAnswer = (text: string): string => {
834
834
  const normalized = text.replace(/\s+/g, " ").trim()
835
835
  return normalized.length === 0 ? FREE_TEXT_PLACEHOLDER : normalized
836
836
  }