@pmelab/gtd 24.0.0 → 24.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 +42 -13
- package/claude/hooks/access.ts +26 -1
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43339 -55575
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +8 -6
- package/package.json +69 -8
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { beatDocument, renderBeatJson } from "./BeatDocument.js"
|
|
3
|
+
import {
|
|
4
|
+
BEAT_KINDS,
|
|
5
|
+
demandOf,
|
|
6
|
+
stallDiagnosis,
|
|
7
|
+
type BeatKind,
|
|
8
|
+
statusOf,
|
|
9
|
+
type NextMatch,
|
|
10
|
+
type StatusChange,
|
|
11
|
+
DEMAND_BRIEFING,
|
|
12
|
+
renderBeatPlain,
|
|
13
|
+
type ModelCost,
|
|
14
|
+
type RenderedDemandSource,
|
|
15
|
+
WIRE_SCHEMA,
|
|
16
|
+
} from "./index.js"
|
|
17
|
+
|
|
18
|
+
const rendered = (overrides: Partial<RenderedDemandSource> = {}): RenderedDemandSource => ({
|
|
19
|
+
state: "build.fixing",
|
|
20
|
+
actor: "agent",
|
|
21
|
+
content: "fix it",
|
|
22
|
+
...overrides,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
interface DocumentInput {
|
|
26
|
+
readonly rendered?: RenderedDemandSource
|
|
27
|
+
readonly kind: BeatKind
|
|
28
|
+
readonly idle?: boolean
|
|
29
|
+
readonly initial?: boolean
|
|
30
|
+
readonly log?: string
|
|
31
|
+
readonly session?: { readonly id: string; readonly resume: boolean }
|
|
32
|
+
readonly validate?: string
|
|
33
|
+
readonly changes?: readonly StatusChange[]
|
|
34
|
+
readonly next?: NextMatch | null
|
|
35
|
+
readonly cost?: number
|
|
36
|
+
readonly costByModel?: readonly ModelCost[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `demandOf`'s own input, isolated so `documentFor` stays a flat two-call body — `session`/`validate` are only spread in when the caller actually passed one, since `demandOf`'s `prompt` variant forbids an explicit `undefined`. */
|
|
40
|
+
const demandInput = (
|
|
41
|
+
input: DocumentInput,
|
|
42
|
+
r: RenderedDemandSource,
|
|
43
|
+
): Parameters<typeof demandOf>[0] => ({
|
|
44
|
+
rendered: r,
|
|
45
|
+
kind: input.kind,
|
|
46
|
+
...(input.session !== undefined ? { session: input.session } : {}),
|
|
47
|
+
...(input.validate !== undefined ? { validate: input.validate } : {}),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** `statusOf`'s own input, isolated for the same reason as `demandInput` above. */
|
|
51
|
+
const statusInput = (
|
|
52
|
+
input: DocumentInput,
|
|
53
|
+
r: RenderedDemandSource,
|
|
54
|
+
): Parameters<typeof statusOf>[0] => ({
|
|
55
|
+
rendered: r,
|
|
56
|
+
idle: input.idle ?? false,
|
|
57
|
+
initial: input.initial ?? false,
|
|
58
|
+
workflow: "feature",
|
|
59
|
+
log: input.log ?? "log",
|
|
60
|
+
changes: input.changes ?? [],
|
|
61
|
+
next: input.next ?? null,
|
|
62
|
+
cost: input.cost ?? 0,
|
|
63
|
+
costByModel: input.costByModel ?? [],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/** Builds one full `BeatDocument` in one call, mirroring the pre-split `beatFields` call shape so every golden below reads the same as before this package. */
|
|
67
|
+
const documentFor = (input: DocumentInput) => {
|
|
68
|
+
const r = input.rendered ?? rendered()
|
|
69
|
+
return beatDocument(demandOf(demandInput(input, r)), statusOf(statusInput(input, r)))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const renderJsonLine = (input: Parameters<typeof documentFor>[0]): string =>
|
|
73
|
+
renderBeatJson(documentFor(input))
|
|
74
|
+
|
|
75
|
+
describe("beatDocument / renderBeatJson", () => {
|
|
76
|
+
it("emits the unconditional fields plus the resolved kind and content", () => {
|
|
77
|
+
const line = renderJsonLine({ rendered: rendered(), kind: "script", log: ".git/gtd-loop.log" })
|
|
78
|
+
expect(JSON.parse(line)).toEqual({
|
|
79
|
+
schema: WIRE_SCHEMA,
|
|
80
|
+
kind: "script",
|
|
81
|
+
content: "fix it",
|
|
82
|
+
idle: false,
|
|
83
|
+
initial: false,
|
|
84
|
+
log: ".git/gtd-loop.log",
|
|
85
|
+
state: "build.fixing",
|
|
86
|
+
actor: "agent",
|
|
87
|
+
workflow: "feature",
|
|
88
|
+
changes: [],
|
|
89
|
+
next: null,
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("carries initial independently of idle, always present", () => {
|
|
94
|
+
for (const [idle, initial] of [
|
|
95
|
+
[false, true],
|
|
96
|
+
[true, true],
|
|
97
|
+
[false, false],
|
|
98
|
+
] as const) {
|
|
99
|
+
const parsed = JSON.parse(renderJsonLine({ kind: "script", idle, initial })) as Record<
|
|
100
|
+
string,
|
|
101
|
+
unknown
|
|
102
|
+
>
|
|
103
|
+
expect(parsed.idle).toBe(idle)
|
|
104
|
+
expect(parsed.initial).toBe(initial)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("uses the stall diagnosis as content, never the rendered content, at kind stalled", () => {
|
|
109
|
+
const line = renderJsonLine({ rendered: rendered(), kind: "stalled" })
|
|
110
|
+
const parsed = JSON.parse(line) as { content: string }
|
|
111
|
+
expect(parsed.content).toBe(stallDiagnosis("build.fixing", "agent"))
|
|
112
|
+
expect(parsed.content).not.toBe("fix it")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("gates session/validate to kind prompt, dropping them at every other kind", () => {
|
|
116
|
+
const session = { id: "8f2c", resume: true }
|
|
117
|
+
for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
|
|
118
|
+
const line = renderJsonLine({
|
|
119
|
+
kind,
|
|
120
|
+
session,
|
|
121
|
+
validate: "gtd check qa 'TODO.md'",
|
|
122
|
+
})
|
|
123
|
+
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
124
|
+
expect(parsed.session).toBeUndefined()
|
|
125
|
+
expect(parsed.validate).toBeUndefined()
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("emits session and validate at kind prompt", () => {
|
|
130
|
+
const session = { id: "8f2c", resume: true }
|
|
131
|
+
const line = renderJsonLine({ kind: "prompt", session, validate: "gtd check qa 'TODO.md'" })
|
|
132
|
+
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
133
|
+
expect(parsed.session).toEqual({ id: "8f2c", resume: true })
|
|
134
|
+
expect(parsed.validate).toBe("gtd check qa 'TODO.md'")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("omits session/validate at kind prompt when the caller supplied none, never emitting null", () => {
|
|
138
|
+
const line = renderJsonLine({ kind: "prompt" })
|
|
139
|
+
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
140
|
+
expect("session" in parsed).toBe(false)
|
|
141
|
+
expect("validate" in parsed).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("emits model/system/memory/file/mode/label at every kind when present, never null", () => {
|
|
145
|
+
const r = rendered({
|
|
146
|
+
model: "opus",
|
|
147
|
+
system: "You are a careful senior engineer.",
|
|
148
|
+
memory: "build#a1b2c3d",
|
|
149
|
+
file: "TODO.md",
|
|
150
|
+
mode: "qa",
|
|
151
|
+
label: "Fixing",
|
|
152
|
+
})
|
|
153
|
+
for (const kind of BEAT_KINDS) {
|
|
154
|
+
const line = renderJsonLine({ rendered: r, kind })
|
|
155
|
+
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
156
|
+
expect(parsed.model).toBe("opus")
|
|
157
|
+
expect(parsed.system).toBe("You are a careful senior engineer.")
|
|
158
|
+
expect(parsed.memory).toBe("build#a1b2c3d")
|
|
159
|
+
expect(parsed.file).toBe("TODO.md")
|
|
160
|
+
expect(parsed.mode).toBe("qa")
|
|
161
|
+
expect(parsed.label).toBe("Fixing")
|
|
162
|
+
expect(parsed).not.toHaveProperty("edges")
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("emits skills as an array of trimmed names at kind prompt", () => {
|
|
167
|
+
const withSkills = rendered({ skills: ["code-review", "testing"] })
|
|
168
|
+
const line = renderJsonLine({ rendered: withSkills, kind: "prompt" })
|
|
169
|
+
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
170
|
+
expect(parsed.skills).toEqual(["code-review", "testing"])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("omits skills at capture, message, script and stalled — even when the source rest carries them, the leak this design prevents", () => {
|
|
174
|
+
const withSkills = rendered({ skills: ["code-review", "testing"] })
|
|
175
|
+
for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
|
|
176
|
+
const line = renderJsonLine({ rendered: withSkills, kind })
|
|
177
|
+
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
178
|
+
expect("skills" in parsed).toBe(false)
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("omits skills entirely (never []) when the list is empty or absent, at kind prompt", () => {
|
|
183
|
+
const empty = renderJsonLine({ rendered: rendered({ skills: [] }), kind: "prompt" })
|
|
184
|
+
expect("skills" in (JSON.parse(empty) as Record<string, unknown>)).toBe(false)
|
|
185
|
+
|
|
186
|
+
const absent = renderJsonLine({ rendered: rendered(), kind: "prompt" })
|
|
187
|
+
expect("skills" in (JSON.parse(absent) as Record<string, unknown>)).toBe(false)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("omits system when its rendered value is the empty string, unlike model which carries an empty string through", () => {
|
|
191
|
+
const line = renderJsonLine({ rendered: rendered({ model: "", system: "" }), kind: "prompt" })
|
|
192
|
+
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
193
|
+
expect("system" in parsed).toBe(false)
|
|
194
|
+
expect(parsed.model).toBe("")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("omits optional fields entirely (not undefined/null) when unset", () => {
|
|
198
|
+
const line = renderJsonLine({ kind: "script" })
|
|
199
|
+
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
200
|
+
for (const key of [
|
|
201
|
+
"session",
|
|
202
|
+
"model",
|
|
203
|
+
"system",
|
|
204
|
+
"validate",
|
|
205
|
+
"label",
|
|
206
|
+
"memory",
|
|
207
|
+
"file",
|
|
208
|
+
"mode",
|
|
209
|
+
"edges",
|
|
210
|
+
"cost",
|
|
211
|
+
"costByModel",
|
|
212
|
+
"judge",
|
|
213
|
+
"skills",
|
|
214
|
+
"access",
|
|
215
|
+
]) {
|
|
216
|
+
expect(key in parsed).toBe(false)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("emits keys in the documented order", () => {
|
|
221
|
+
const r = rendered({
|
|
222
|
+
model: "opus",
|
|
223
|
+
system: "You are a careful senior engineer.",
|
|
224
|
+
memory: "build#a1b2c3d",
|
|
225
|
+
file: "TODO.md",
|
|
226
|
+
mode: "qa",
|
|
227
|
+
label: "Fixing",
|
|
228
|
+
judge: '{"state":"build.fixing","questions":[]}',
|
|
229
|
+
skills: ["code-review"],
|
|
230
|
+
})
|
|
231
|
+
const line = renderJsonLine({
|
|
232
|
+
rendered: r,
|
|
233
|
+
kind: "prompt",
|
|
234
|
+
idle: false,
|
|
235
|
+
initial: false,
|
|
236
|
+
log: ".git/gtd-loop.log",
|
|
237
|
+
session: { id: "8f2c", resume: true },
|
|
238
|
+
validate: "gtd check qa 'TODO.md'",
|
|
239
|
+
changes: [{ status: "M", path: "TODO.md" }],
|
|
240
|
+
next: { target: "idle" },
|
|
241
|
+
cost: 12,
|
|
242
|
+
costByModel: [{ model: "opus", cost: 12 }],
|
|
243
|
+
})
|
|
244
|
+
const keys = Object.keys(JSON.parse(line) as Record<string, unknown>)
|
|
245
|
+
expect(keys).toEqual([
|
|
246
|
+
"schema",
|
|
247
|
+
"kind",
|
|
248
|
+
"content",
|
|
249
|
+
"idle",
|
|
250
|
+
"initial",
|
|
251
|
+
"session",
|
|
252
|
+
"model",
|
|
253
|
+
"system",
|
|
254
|
+
"validate",
|
|
255
|
+
"log",
|
|
256
|
+
"state",
|
|
257
|
+
"actor",
|
|
258
|
+
"workflow",
|
|
259
|
+
"label",
|
|
260
|
+
"memory",
|
|
261
|
+
"file",
|
|
262
|
+
"mode",
|
|
263
|
+
"changes",
|
|
264
|
+
"next",
|
|
265
|
+
"cost",
|
|
266
|
+
"costByModel",
|
|
267
|
+
"judge",
|
|
268
|
+
"skills",
|
|
269
|
+
"access",
|
|
270
|
+
])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it("emits access at kind prompt, null per side when unrestricted", () => {
|
|
274
|
+
const bare = JSON.parse(renderJsonLine({ kind: "prompt" })) as Record<string, unknown>
|
|
275
|
+
expect(bare.access).toEqual({ read: null, write: null })
|
|
276
|
+
const restricted = JSON.parse(
|
|
277
|
+
renderJsonLine({
|
|
278
|
+
rendered: rendered({ access: { read: null, write: [".gtd/REVIEW.md"] } }),
|
|
279
|
+
kind: "prompt",
|
|
280
|
+
}),
|
|
281
|
+
) as Record<string, unknown>
|
|
282
|
+
expect(restricted.access).toEqual({ read: null, write: [".gtd/REVIEW.md"] })
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it("omits access at every kind but prompt, even when the rest carries it", () => {
|
|
286
|
+
const withAccess = rendered({ access: { read: ["a"], write: ["b"] } })
|
|
287
|
+
for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
|
|
288
|
+
const parsed = JSON.parse(renderJsonLine({ rendered: withAccess, kind })) as Record<
|
|
289
|
+
string,
|
|
290
|
+
unknown
|
|
291
|
+
>
|
|
292
|
+
expect("access" in parsed).toBe(false)
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("ends with a trailing newline", () => {
|
|
297
|
+
expect(renderJsonLine({ kind: "script" }).endsWith("\n")).toBe(true)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it("carries changes verbatim, always present even when empty", () => {
|
|
301
|
+
const changes = [
|
|
302
|
+
{ status: "M", path: "TODO.md" },
|
|
303
|
+
{ status: "A", path: "REVIEW.md" },
|
|
304
|
+
]
|
|
305
|
+
const line = renderJsonLine({ kind: "prompt", changes })
|
|
306
|
+
const parsed = JSON.parse(line) as { changes: unknown }
|
|
307
|
+
expect(parsed.changes).toEqual(changes)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it("carries next as null on no match, an object (action omitted when absent) on a match", () => {
|
|
311
|
+
const noMatch = renderJsonLine({ kind: "prompt" })
|
|
312
|
+
expect((JSON.parse(noMatch) as { next: unknown }).next).toBeNull()
|
|
313
|
+
|
|
314
|
+
const matched = renderJsonLine({
|
|
315
|
+
kind: "prompt",
|
|
316
|
+
next: { target: "idle" },
|
|
317
|
+
})
|
|
318
|
+
const parsedMatch = JSON.parse(matched) as { next: Record<string, unknown> }
|
|
319
|
+
expect(parsedMatch.next).toEqual({ target: "idle" })
|
|
320
|
+
expect("action" in parsedMatch.next).toBe(false)
|
|
321
|
+
|
|
322
|
+
const matchedWithAction = renderJsonLine({
|
|
323
|
+
kind: "prompt",
|
|
324
|
+
next: { target: "idle" },
|
|
325
|
+
})
|
|
326
|
+
const parsedAction = JSON.parse(matchedWithAction) as { next: Record<string, unknown> }
|
|
327
|
+
expect(parsedAction.next).toEqual({ target: "idle" })
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it("omits cost/costByModel when cost is zero, emits both when a cost was recorded", () => {
|
|
331
|
+
const noCost = renderJsonLine({ kind: "prompt" })
|
|
332
|
+
const parsedNoCost = JSON.parse(noCost) as Record<string, unknown>
|
|
333
|
+
expect("cost" in parsedNoCost).toBe(false)
|
|
334
|
+
expect("costByModel" in parsedNoCost).toBe(false)
|
|
335
|
+
|
|
336
|
+
const withCost = renderJsonLine({
|
|
337
|
+
kind: "prompt",
|
|
338
|
+
cost: 42,
|
|
339
|
+
costByModel: [{ model: "opus", cost: 42 }],
|
|
340
|
+
})
|
|
341
|
+
const parsedWithCost = JSON.parse(withCost) as Record<string, unknown>
|
|
342
|
+
expect(parsedWithCost.cost).toBe(42)
|
|
343
|
+
expect(parsedWithCost.costByModel).toEqual([{ model: "opus", cost: 42 }])
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it("omits judge entirely (not undefined/null) when unset, emits it verbatim when set", () => {
|
|
347
|
+
const unset = renderJsonLine({ kind: "prompt" })
|
|
348
|
+
expect("judge" in (JSON.parse(unset) as Record<string, unknown>)).toBe(false)
|
|
349
|
+
|
|
350
|
+
const set = renderJsonLine({
|
|
351
|
+
rendered: rendered({ judge: '{"state":"build.fixing","questions":[]}' }),
|
|
352
|
+
kind: "prompt",
|
|
353
|
+
})
|
|
354
|
+
const parsedSet = JSON.parse(set) as Record<string, unknown>
|
|
355
|
+
expect(parsedSet.judge).toBe('{"state":"build.fixing","questions":[]}')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it("carries no version key", () => {
|
|
359
|
+
const line = renderJsonLine({ kind: "prompt" })
|
|
360
|
+
expect("version" in (JSON.parse(line) as Record<string, unknown>)).toBe(false)
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
describe("golden bytes", () => {
|
|
365
|
+
it("renderBeatJson renders a rest with absent optionals as these bytes, schema first", () => {
|
|
366
|
+
const line = renderJsonLine({ rendered: rendered(), kind: "script", log: ".git/gtd-loop.log" })
|
|
367
|
+
expect(line).toBe(
|
|
368
|
+
JSON.stringify({
|
|
369
|
+
schema: WIRE_SCHEMA,
|
|
370
|
+
kind: "script",
|
|
371
|
+
content: "fix it",
|
|
372
|
+
idle: false,
|
|
373
|
+
initial: false,
|
|
374
|
+
log: ".git/gtd-loop.log",
|
|
375
|
+
state: "build.fixing",
|
|
376
|
+
actor: "agent",
|
|
377
|
+
workflow: "feature",
|
|
378
|
+
changes: [],
|
|
379
|
+
next: null,
|
|
380
|
+
}) + "\n",
|
|
381
|
+
)
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* ONE test, looping the ONE kind list (`BEAT_KINDS`, exported from
|
|
387
|
+
* `Demand.ts`), covering all three places a `BeatKind` must be handled:
|
|
388
|
+
* `beatDocument` (the JSON side, via `dispatchFieldsOf`'s exhaustive switch),
|
|
389
|
+
* `DEMAND_BRIEFING` (the plain-render briefing table), and `renderBeatPlain`
|
|
390
|
+
* itself (whether the status header is actually suppressed). Replaces the
|
|
391
|
+
* three previously hand-maintained kind lists (this file's own literal, plus
|
|
392
|
+
* one each in `Demand.test.ts` and `BeatPlain.test.ts`) with the single
|
|
393
|
+
* source `Demand.ts` now exports — a sixth `BeatKind` member is caught here
|
|
394
|
+
* (a missing `DEMAND_BRIEFING`/`dispatchFieldsOf` entry fails `tsc` first,
|
|
395
|
+
* before this test can even run) as well as by this loop actually exercising
|
|
396
|
+
* every member.
|
|
397
|
+
*/
|
|
398
|
+
describe("exhaustiveness over Demand/BeatKind", () => {
|
|
399
|
+
it("BeatKind is exactly the five documented members", () => {
|
|
400
|
+
expect([...BEAT_KINDS].sort()).toEqual(["capture", "message", "prompt", "script", "stalled"])
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it("every kind: produces a document, has a DEMAND_BRIEFING entry, and the plain header is shown iff the briefing doesn't suppress it", () => {
|
|
404
|
+
for (const kind of BEAT_KINDS) {
|
|
405
|
+
const document = documentFor({ kind })
|
|
406
|
+
expect(() => renderBeatJson(document)).not.toThrow()
|
|
407
|
+
expect(document.kind).toBe(kind)
|
|
408
|
+
|
|
409
|
+
const briefing = DEMAND_BRIEFING[kind]
|
|
410
|
+
expect(briefing).toBeDefined()
|
|
411
|
+
|
|
412
|
+
const plain = renderBeatPlain(document)
|
|
413
|
+
expect(plain.includes("State: ")).toBe(!briefing.suppressHeader)
|
|
414
|
+
if (briefing.instructionLine !== undefined) {
|
|
415
|
+
expect(plain.startsWith(`${briefing.instructionLine}\n`)).toBe(true)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { BeatKind, Demand, DemandSession } from "./Demand.js"
|
|
2
|
+
import type { BeatStatus, NextMatch, StatusChange } from "./BeatStatus.js"
|
|
3
|
+
import type { Actor, ModelCost, StateMode, StateName, StepAccess } from "./types.js"
|
|
4
|
+
import { WIRE_SCHEMA } from "./constants.js"
|
|
5
|
+
|
|
6
|
+
/** `gtd next --json`'s `next` key — `null` when landing would commit nothing. */
|
|
7
|
+
const nextField = (next: NextMatch | null): { target: string } | null =>
|
|
8
|
+
next === null ? null : { target: next.target }
|
|
9
|
+
|
|
10
|
+
/** Unreachable in a well-typed call — used as the `default` arm of an exhaustive switch over `Demand["kind"]`, so a future `BeatKind` member that isn't handled fails `tsc`, not just review. */
|
|
11
|
+
const assertNeverDemand = (demand: never): never => {
|
|
12
|
+
throw new Error(`unreachable demand: ${JSON.stringify(demand)}`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The ONE exhaustive switch translating a `Demand` into its wire
|
|
17
|
+
* `session`/`validate`/`skills`/`access` set — only the `prompt` variant carries
|
|
18
|
+
* any of them, and every other member is listed explicitly (not a
|
|
19
|
+
* `default:` fallthrough) so adding a sixth `BeatKind` without adding its
|
|
20
|
+
* case here fails `tsc` at the `assertNeverDemand` call, not silently
|
|
21
|
+
* falling through to "no session."
|
|
22
|
+
*/
|
|
23
|
+
const dispatchFieldsOf = (
|
|
24
|
+
demand: Demand,
|
|
25
|
+
): {
|
|
26
|
+
readonly session: DemandSession | undefined
|
|
27
|
+
readonly validate: string | undefined
|
|
28
|
+
readonly skills: readonly string[] | undefined
|
|
29
|
+
readonly access: StepAccess | undefined
|
|
30
|
+
} => {
|
|
31
|
+
switch (demand.kind) {
|
|
32
|
+
case "prompt":
|
|
33
|
+
return {
|
|
34
|
+
session: demand.session,
|
|
35
|
+
validate: demand.validate,
|
|
36
|
+
skills: demand.skills,
|
|
37
|
+
access: demand.access,
|
|
38
|
+
}
|
|
39
|
+
case "capture":
|
|
40
|
+
case "message":
|
|
41
|
+
case "script":
|
|
42
|
+
case "stalled":
|
|
43
|
+
return { session: undefined, validate: undefined, skills: undefined, access: undefined }
|
|
44
|
+
default:
|
|
45
|
+
return assertNeverDemand(demand)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One beat's whole field set, flattened — the ONE object `renderBeatJson` renders from, in the object's own key order (also the JSON key order). */
|
|
50
|
+
export interface BeatDocument {
|
|
51
|
+
readonly schema: number
|
|
52
|
+
readonly kind: BeatKind
|
|
53
|
+
readonly content: string
|
|
54
|
+
readonly idle: boolean
|
|
55
|
+
readonly initial: boolean
|
|
56
|
+
readonly session: DemandSession | undefined
|
|
57
|
+
readonly model: string | undefined
|
|
58
|
+
readonly system: string | undefined
|
|
59
|
+
readonly validate: string | undefined
|
|
60
|
+
readonly log: string
|
|
61
|
+
readonly state: StateName
|
|
62
|
+
readonly actor: Actor
|
|
63
|
+
readonly workflow: string
|
|
64
|
+
readonly label: string | undefined
|
|
65
|
+
readonly memory: string | undefined
|
|
66
|
+
readonly file: string | undefined
|
|
67
|
+
readonly mode: StateMode | undefined
|
|
68
|
+
readonly changes: readonly StatusChange[]
|
|
69
|
+
readonly next: { readonly target: string } | null
|
|
70
|
+
readonly cost: number | undefined
|
|
71
|
+
readonly costByModel: readonly ModelCost[] | undefined
|
|
72
|
+
readonly judge: string | undefined
|
|
73
|
+
readonly skills: readonly string[] | undefined
|
|
74
|
+
readonly access: StepAccess | undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Flatten a `Demand` plus its `BeatStatus` into the single 24-key document
|
|
79
|
+
* `gtd next --json` emits — the ONLY place the two are joined. `cost`/
|
|
80
|
+
* `costByModel` are omitted together, exactly when no cost was recorded
|
|
81
|
+
* (`cost <= 0`).
|
|
82
|
+
*/
|
|
83
|
+
export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument => {
|
|
84
|
+
const { session, validate, skills, access } = dispatchFieldsOf(demand)
|
|
85
|
+
const hasCost = status.cost > 0
|
|
86
|
+
return {
|
|
87
|
+
schema: WIRE_SCHEMA,
|
|
88
|
+
kind: demand.kind,
|
|
89
|
+
content: demand.content,
|
|
90
|
+
idle: status.idle,
|
|
91
|
+
initial: status.initial,
|
|
92
|
+
session,
|
|
93
|
+
model: status.model,
|
|
94
|
+
system: status.system,
|
|
95
|
+
validate,
|
|
96
|
+
log: status.log,
|
|
97
|
+
state: status.state,
|
|
98
|
+
actor: status.actor,
|
|
99
|
+
workflow: status.workflow,
|
|
100
|
+
label: status.label,
|
|
101
|
+
memory: status.memory,
|
|
102
|
+
file: status.file,
|
|
103
|
+
mode: status.mode,
|
|
104
|
+
changes: status.changes,
|
|
105
|
+
next: nextField(status.next),
|
|
106
|
+
cost: hasCost ? status.cost : undefined,
|
|
107
|
+
costByModel: hasCost ? status.costByModel : undefined,
|
|
108
|
+
judge: status.judge,
|
|
109
|
+
skills,
|
|
110
|
+
access,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const renderBeatJson = (document: BeatDocument): string => JSON.stringify(document) + "\n"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
beatDocument,
|
|
4
|
+
demandOf,
|
|
5
|
+
type BeatKind,
|
|
6
|
+
statusOf,
|
|
7
|
+
type RenderedDemandSource,
|
|
8
|
+
} from "./index.js"
|
|
9
|
+
import { renderBeatPlain } from "./BeatPlain.js"
|
|
10
|
+
|
|
11
|
+
const rendered = (overrides: Partial<RenderedDemandSource> = {}): RenderedDemandSource => ({
|
|
12
|
+
state: "build.fixing",
|
|
13
|
+
actor: "agent",
|
|
14
|
+
content: "fix it",
|
|
15
|
+
...overrides,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const documentFor = (kind: BeatKind, content: string, rest: Partial<RenderedDemandSource> = {}) => {
|
|
19
|
+
const r = rendered({ content, ...rest })
|
|
20
|
+
const demand = demandOf({ rendered: r, kind })
|
|
21
|
+
const status = statusOf({
|
|
22
|
+
rendered: r,
|
|
23
|
+
idle: false,
|
|
24
|
+
initial: false,
|
|
25
|
+
workflow: "feature",
|
|
26
|
+
log: "log",
|
|
27
|
+
changes: [{ status: "M", path: "TODO.md" }],
|
|
28
|
+
next: { target: "idle" },
|
|
29
|
+
cost: 0,
|
|
30
|
+
costByModel: [],
|
|
31
|
+
})
|
|
32
|
+
return beatDocument(demand, status)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HEADER =
|
|
36
|
+
"State: build.fixing\nAwaits: agent\nWorkflow: feature\nPending:\n M TODO.md\nNext: → idle"
|
|
37
|
+
|
|
38
|
+
describe("renderBeatPlain", () => {
|
|
39
|
+
it("prepends the run instruction, then shows header, blank line, content verbatim at kind script", () => {
|
|
40
|
+
expect(renderBeatPlain(documentFor("script", "npm test"))).toBe(
|
|
41
|
+
`Run this script:\n${HEADER}\n\nnpm test\n`,
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("shows header, blank line, content verbatim at kind message — no instruction line", () => {
|
|
46
|
+
expect(renderBeatPlain(documentFor("message", "write NOTE.md"))).toBe(
|
|
47
|
+
`${HEADER}\n\nwrite NOTE.md\n`,
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("prepends prose stating the edit is already made and gtd land will land it, at kind capture", () => {
|
|
52
|
+
const plain = renderBeatPlain(documentFor("capture", "already edited"))
|
|
53
|
+
expect(plain).toBe(
|
|
54
|
+
`The edit is already made — run \`gtd land\` to land it.\n${HEADER}\n\nalready edited\n`,
|
|
55
|
+
)
|
|
56
|
+
expect(plain).toMatch(/already made/)
|
|
57
|
+
expect(plain).toContain("gtd land")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("shows header, blank line, the stall diagnosis (never the rendered content) at kind stalled", () => {
|
|
61
|
+
const plain = renderBeatPlain(documentFor("stalled", "would-be prompt"))
|
|
62
|
+
expect(plain.startsWith(`${HEADER}\n\n`)).toBe(true)
|
|
63
|
+
expect(plain).toContain('stalled at "build.fixing"')
|
|
64
|
+
expect(plain).not.toContain("would-be prompt")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("a stalled beat at a prompt state whose machine declares system: prints its stall diagnosis with no System: line and no persona text", () => {
|
|
68
|
+
const document = documentFor("stalled", "would-be prompt", {
|
|
69
|
+
system: "You are a careful senior engineer.",
|
|
70
|
+
})
|
|
71
|
+
const plain = renderBeatPlain(document)
|
|
72
|
+
expect(plain).not.toContain("System:")
|
|
73
|
+
expect(plain).not.toContain("You are a careful senior engineer.")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("drops the header entirely at kind prompt, emitting bare content — no self-validate command given", () => {
|
|
77
|
+
expect(renderBeatPlain(documentFor("prompt", "fix the bug"))).toBe("fix the bug\n")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("at kind prompt, appends the self-validation instruction when a command is given", () => {
|
|
81
|
+
const document = documentFor("prompt", "fix the bug", { file: "TODO.md", mode: "qa" })
|
|
82
|
+
expect(renderBeatPlain(document, "gtd check qa 'TODO.md'")).toBe(
|
|
83
|
+
"fix the bug\n\nBefore finishing your turn, run `gtd check qa 'TODO.md'` — it checks " +
|
|
84
|
+
"TODO.md — and fix every violation it reports until it exits cleanly. Do not finish " +
|
|
85
|
+
"while it still reports violations.\n",
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("is byte-identical to a bare prompt render when no self-validate command is given", () => {
|
|
90
|
+
const document = documentFor("prompt", "fix the bug")
|
|
91
|
+
expect(renderBeatPlain(document)).toBe(renderBeatPlain(document, undefined))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("plain output at kind prompt is byte-identical whether or not the machine declares system:", () => {
|
|
95
|
+
const withoutSystem = documentFor("prompt", "fix the bug")
|
|
96
|
+
const withSystem = documentFor("prompt", "fix the bug", { system: "a persona" })
|
|
97
|
+
expect(renderBeatPlain(withSystem)).toBe(renderBeatPlain(withoutSystem))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("never prints a Skills: line, in any kind's header or the bare prompt body", () => {
|
|
101
|
+
const kinds: readonly BeatKind[] = ["capture", "message", "script", "stalled", "prompt"]
|
|
102
|
+
for (const kind of kinds) {
|
|
103
|
+
const plain = renderBeatPlain(documentFor(kind, "fix the bug", { skills: ["code-review"] }))
|
|
104
|
+
expect(plain).not.toContain("Skills:")
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("plain output at kind prompt is byte-identical whether or not the machine declares skills", () => {
|
|
109
|
+
const withoutSkills = documentFor("prompt", "fix the bug")
|
|
110
|
+
const withSkills = documentFor("prompt", "fix the bug", { skills: ["code-review"] })
|
|
111
|
+
expect(renderBeatPlain(withSkills)).toBe(renderBeatPlain(withoutSkills))
|
|
112
|
+
})
|
|
113
|
+
})
|