@pmelab/gtd 23.2.0 → 24.1.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 +44 -11
- package/claude/hooks/access.ts +1 -1
- 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 +43830 -56044
- 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 +1953 -1724
- package/package.json +70 -8
- package/skills/authoring/SKILL.md +5 -4
- 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/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -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/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +32 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +74 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +36 -2
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +99 -0
- package/src/workflows/review/review.ts +33 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/shared/workflows.steps.ts +7 -0
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
landFields,
|
|
4
|
+
landProseText,
|
|
5
|
+
noopText,
|
|
6
|
+
renderLandJson,
|
|
7
|
+
renderLandPlain,
|
|
8
|
+
type LandFields,
|
|
9
|
+
type LandResultSource,
|
|
10
|
+
} from "./Land.js"
|
|
11
|
+
import { WIRE_SCHEMA } from "./index.js"
|
|
12
|
+
|
|
13
|
+
describe("landFields / renderLandJson", () => {
|
|
14
|
+
const sample: LandFields = {
|
|
15
|
+
schema: WIRE_SCHEMA,
|
|
16
|
+
script: "printf '%s %s\\n' '[commit]' 'gtd(agent): build.fixing'\ngit commit ...\n",
|
|
17
|
+
settled: false,
|
|
18
|
+
idle: false,
|
|
19
|
+
state: "build.review.closing",
|
|
20
|
+
subject: "gtd(agent): build.fixing",
|
|
21
|
+
cost: 0.42,
|
|
22
|
+
model: "smart",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
it("renderLandJson emits exactly schema/script/settled/idle/state/subject/cost/model, newline-terminated", () => {
|
|
26
|
+
const line = renderLandJson(landFields(sample))
|
|
27
|
+
expect(line.endsWith("\n")).toBe(true)
|
|
28
|
+
expect(JSON.parse(line)).toEqual(sample)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("renderLandJson carries null subject/cost/model verbatim for a genuine no-op — never omitted", () => {
|
|
32
|
+
const noop: LandFields = {
|
|
33
|
+
schema: WIRE_SCHEMA,
|
|
34
|
+
script: "printf '%s\\n' 'nothing to do at \"idle\"'\n",
|
|
35
|
+
settled: true,
|
|
36
|
+
idle: true,
|
|
37
|
+
state: "idle",
|
|
38
|
+
subject: null,
|
|
39
|
+
cost: null,
|
|
40
|
+
model: null,
|
|
41
|
+
}
|
|
42
|
+
expect(renderLandJson(landFields(noop))).toBe(JSON.stringify(noop) + "\n")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("landFields is a real reorder, not an identity copy: takes a LandResultSource-shaped value (Program.ts's LandResult field order) and pins it into LandFields' own declared order", () => {
|
|
46
|
+
// Written in `LandResultSource`'s (== `LandResult`'s) own field order —
|
|
47
|
+
// state/subject/cost/model/script/settled/idle — the opposite of
|
|
48
|
+
// `LandFields`' script/settled/idle/state/subject/cost/model, so this
|
|
49
|
+
// test would fail if `landFields` stopped reordering.
|
|
50
|
+
const resultShaped: LandResultSource = {
|
|
51
|
+
state: "build.review.closing",
|
|
52
|
+
subject: "gtd(agent): build.fixing",
|
|
53
|
+
cost: 0.42,
|
|
54
|
+
model: "smart",
|
|
55
|
+
script: "printf '%s %s\\n' '[commit]' 'gtd(agent): build.fixing'\ngit commit ...\n",
|
|
56
|
+
settled: false,
|
|
57
|
+
idle: false,
|
|
58
|
+
}
|
|
59
|
+
expect(Object.keys(landFields(resultShaped))).toEqual([
|
|
60
|
+
"schema",
|
|
61
|
+
"script",
|
|
62
|
+
"settled",
|
|
63
|
+
"idle",
|
|
64
|
+
"state",
|
|
65
|
+
"subject",
|
|
66
|
+
"cost",
|
|
67
|
+
"model",
|
|
68
|
+
])
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("noopText / landProseText", () => {
|
|
73
|
+
it("noopText names the state", () => {
|
|
74
|
+
expect(noopText("idle")).toBe('nothing to do at "idle"\n')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("landProseText names the commit subject, newline-terminated", () => {
|
|
78
|
+
expect(landProseText("gtd(human): idle → working")).toBe(
|
|
79
|
+
"commit everything with this message: gtd(human): idle → working\n",
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("landProseText carries no ANSI escape sequence", () => {
|
|
84
|
+
// eslint-disable-next-line no-control-regex -- asserting the ABSENCE of an escape byte
|
|
85
|
+
expect(landProseText("gtd(agent): drafting")).not.toMatch(/\x1b/)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("renderLandPlain", () => {
|
|
90
|
+
const sample: LandFields = {
|
|
91
|
+
schema: WIRE_SCHEMA,
|
|
92
|
+
script: "printf '%s %s\\n' '[commit]' 'gtd(agent): build.fixing'\ngit commit ...\n",
|
|
93
|
+
settled: false,
|
|
94
|
+
idle: false,
|
|
95
|
+
state: "build.review.closing",
|
|
96
|
+
subject: "gtd(agent): build.fixing",
|
|
97
|
+
cost: 0.42,
|
|
98
|
+
model: "smart",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it("names the commit subject and points at gtd land --json=script at a real landing", () => {
|
|
102
|
+
const plain = renderLandPlain(sample)
|
|
103
|
+
expect(plain).toContain("gtd(agent): build.fixing")
|
|
104
|
+
expect(plain).toContain("gtd land --json=script")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("prints the no-op note when nothing landed", () => {
|
|
108
|
+
const noop: LandFields = {
|
|
109
|
+
schema: WIRE_SCHEMA,
|
|
110
|
+
script: "printf '%s\\n' 'nothing to do at \"idle\"'\n",
|
|
111
|
+
settled: true,
|
|
112
|
+
idle: true,
|
|
113
|
+
state: "idle",
|
|
114
|
+
subject: null,
|
|
115
|
+
cost: null,
|
|
116
|
+
model: null,
|
|
117
|
+
}
|
|
118
|
+
expect(renderLandPlain(noop)).toBe(noopText("idle"))
|
|
119
|
+
})
|
|
120
|
+
})
|
package/src/wire/Land.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { StateName } from "./types.js"
|
|
2
|
+
import { renderFormat, WIRE_SCHEMA } from "./constants.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `gtd land`'s whole field set, in the object's own key order (also the JSON
|
|
6
|
+
* key order). `subject`/`cost`/`model` are `null` (never omitted) for a
|
|
7
|
+
* genuine no-op, mirroring `LandResult` itself.
|
|
8
|
+
*/
|
|
9
|
+
export interface LandFields {
|
|
10
|
+
readonly schema: number
|
|
11
|
+
readonly script: string
|
|
12
|
+
readonly settled: boolean
|
|
13
|
+
readonly idle: boolean
|
|
14
|
+
readonly state: StateName
|
|
15
|
+
readonly subject: string | null
|
|
16
|
+
readonly cost: number | null
|
|
17
|
+
readonly model: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `Program.ts`'s `LandResult`, expressed structurally so `wire` never
|
|
22
|
+
* imports it — the seven fields `LandFields` carries beside `schema`, but in `LandResult`'s
|
|
23
|
+
* own declaration order (`state, subject, cost, model, script, settled,
|
|
24
|
+
* idle`), not the wire's. Deliberately a different shape from `LandFields`:
|
|
25
|
+
* `landFields` below reorders INTO the wire's pinned key order, so its input
|
|
26
|
+
* type cannot equal its output type — an identity-typed
|
|
27
|
+
* `(input: LandFields) => LandFields` would let a future caller pass an
|
|
28
|
+
* already-wire-shaped object through unchanged, silently hiding a dropped
|
|
29
|
+
* reorder.
|
|
30
|
+
*/
|
|
31
|
+
export interface LandResultSource {
|
|
32
|
+
readonly state: StateName
|
|
33
|
+
readonly subject: string | null
|
|
34
|
+
readonly cost: number | null
|
|
35
|
+
readonly model: string | null
|
|
36
|
+
readonly script: string
|
|
37
|
+
readonly settled: boolean
|
|
38
|
+
readonly idle: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Assembles one `gtd land` result's fields in `LandFields`' declared order — the ONLY place `gtd land --json`'s wire shape is built. */
|
|
42
|
+
export const landFields = (input: LandResultSource): LandFields => ({
|
|
43
|
+
schema: WIRE_SCHEMA,
|
|
44
|
+
script: input.script,
|
|
45
|
+
settled: input.settled,
|
|
46
|
+
idle: input.idle,
|
|
47
|
+
state: input.state,
|
|
48
|
+
subject: input.subject,
|
|
49
|
+
cost: input.cost,
|
|
50
|
+
model: input.model,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export const renderLandJson = (fields: LandFields): string => JSON.stringify(fields) + "\n"
|
|
54
|
+
|
|
55
|
+
const FMT_NOOP = 'nothing to do at "%s"\n'
|
|
56
|
+
const FMT_LAND_PROSE = "commit everything with this message: %s\n"
|
|
57
|
+
|
|
58
|
+
/** `nothing to do at "<state>"` — a no-op step's plain-text line, and the text a print-only script's own `printf` carries. */
|
|
59
|
+
export const noopText = (state: string): string => renderFormat(FMT_NOOP, state)
|
|
60
|
+
|
|
61
|
+
/** `commit everything with this message: <subject>` — plain `gtd land`'s own stdout at a pending diff (no script); `--json` keeps emitting the script itself, unaffected. */
|
|
62
|
+
export const landProseText = (subject: string): string => renderFormat(FMT_LAND_PROSE, subject)
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `gtd land`'s plain-text encoding — names the commit subject at a real
|
|
66
|
+
* landing and points at `--json=script`, since the script itself is
|
|
67
|
+
* unreachable from plain output; prints the existing no-op note otherwise.
|
|
68
|
+
* `command` names the invocation to point at — `"land"` by default, but
|
|
69
|
+
* `gtd judge answer` (the only other caller of `planLanding`/`landFields`)
|
|
70
|
+
* passes its own two-word name so the hint stays runnable verbatim.
|
|
71
|
+
*/
|
|
72
|
+
export const renderLandPlain = (fields: LandFields, command = "land"): string =>
|
|
73
|
+
fields.subject !== null
|
|
74
|
+
? `${landProseText(fields.subject).trimEnd()}\n(run \`gtd ${command} --json=script | sh\` to get the landing script)\n`
|
|
75
|
+
: noopText(fields.state)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { selectPath } from "./Select.js"
|
|
4
|
+
|
|
5
|
+
describe("selectPath — scalar/boolean/list leaves", () => {
|
|
6
|
+
it("returns value with the raw string for a scalar leaf", () => {
|
|
7
|
+
expect(selectPath({ kind: "prompt" }, "kind")).toEqual({ kind: "value", text: "prompt" })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("returns value with true/false for a boolean leaf", () => {
|
|
11
|
+
expect(selectPath({ idle: true }, "idle")).toEqual({ kind: "value", text: "true" })
|
|
12
|
+
expect(selectPath({ idle: false }, "idle")).toEqual({ kind: "value", text: "false" })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("returns value with one JSON.stringify per entry, newline-joined, for a list leaf", () => {
|
|
16
|
+
const result = selectPath({ changes: [{ path: "a" }, { path: "b" }] }, "changes")
|
|
17
|
+
expect(result).toEqual({
|
|
18
|
+
kind: "value",
|
|
19
|
+
text: '{"path":"a"}\n{"path":"b"}',
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("never carries a trailing newline in value.text", () => {
|
|
24
|
+
const result = selectPath({ changes: [{ path: "a" }, { path: "b" }] }, "changes")
|
|
25
|
+
expect(result.kind).toBe("value")
|
|
26
|
+
if (result.kind === "value") {
|
|
27
|
+
expect(result.text.endsWith("\n")).toBe(false)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe("selectPath — absent vs unknown", () => {
|
|
33
|
+
it("returns absent for a key that is present with an undefined value", () => {
|
|
34
|
+
expect(selectPath({ label: undefined }, "label")).toEqual({ kind: "absent" })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("returns unknown for a key that is missing from the object", () => {
|
|
38
|
+
expect(selectPath({ kind: "prompt" }, "nope")).toEqual({ kind: "unknown", path: "nope" })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("returns absent for the whole remaining path when a parent is present-but-undefined", () => {
|
|
42
|
+
expect(selectPath({ session: undefined }, "session.id")).toEqual({ kind: "absent" })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("resolves an in-range all-digits segment against a list, descending into that entry", () => {
|
|
46
|
+
expect(selectPath({ changes: [{ path: "a" }] }, "changes.0.path")).toEqual({
|
|
47
|
+
kind: "value",
|
|
48
|
+
text: "a",
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("returns unknown for an out-of-range index against a list", () => {
|
|
53
|
+
expect(selectPath({ skills: ["a"] }, "skills.1")).toEqual({
|
|
54
|
+
kind: "unknown",
|
|
55
|
+
path: "skills.1",
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("returns unknown for a non-numeric segment against a list — no inherited array member (length, map, ...) resolves", () => {
|
|
60
|
+
expect(selectPath({ skills: ["a"] }, "skills.length")).toEqual({
|
|
61
|
+
kind: "unknown",
|
|
62
|
+
path: "skills.length",
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe("selectPath — array indexing", () => {
|
|
68
|
+
it("skills.0 reads the first declared skill, matching a driver's own read", () => {
|
|
69
|
+
expect(selectPath({ skills: ["code-review", "testing"] }, "skills.0")).toEqual({
|
|
70
|
+
kind: "value",
|
|
71
|
+
text: "code-review",
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe("selectPath — never throws", () => {
|
|
77
|
+
it("returns unknown when descending a path through a plain scalar", () => {
|
|
78
|
+
expect(selectPath({ a: 5 }, "a.b")).toEqual({ kind: "unknown", path: "a.b" })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("returns unknown when the root itself is a primitive", () => {
|
|
82
|
+
expect(selectPath(5, "a")).toEqual({ kind: "unknown", path: "a" })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("returns absent for the whole remaining path when a parent is null — never descends into it, never unknown", () => {
|
|
86
|
+
expect(selectPath({ a: null }, "a.b")).toEqual({ kind: "absent" })
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('selectPath — a null leaf is absent, never the string "null"', () => {
|
|
91
|
+
it('returns absent, not a value with text "null", for a null-valued leaf', () => {
|
|
92
|
+
expect(selectPath({ subject: null }, "subject")).toEqual({ kind: "absent" })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("BeatDocument.next is | null on no match — next.target reads as absent, not unknown, so a driver's read is never fatal", () => {
|
|
96
|
+
expect(selectPath({ next: null }, "next.target")).toEqual({ kind: "absent" })
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe("selectPath — own-property presence only, never inherited/prototype members", () => {
|
|
101
|
+
const fields = { kind: "message", changes: [{ path: "a" }] }
|
|
102
|
+
|
|
103
|
+
it("returns unknown for constructor — inherited from Object.prototype, not a document field", () => {
|
|
104
|
+
expect(selectPath(fields, "constructor")).toEqual({ kind: "unknown", path: "constructor" })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("returns unknown for toString — inherited from Object.prototype", () => {
|
|
108
|
+
expect(selectPath(fields, "toString")).toEqual({ kind: "unknown", path: "toString" })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("returns unknown for hasOwnProperty — inherited from Object.prototype", () => {
|
|
112
|
+
expect(selectPath(fields, "hasOwnProperty")).toEqual({
|
|
113
|
+
kind: "unknown",
|
|
114
|
+
path: "hasOwnProperty",
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("returns unknown for valueOf — inherited from Object.prototype", () => {
|
|
119
|
+
expect(selectPath(fields, "valueOf")).toEqual({ kind: "unknown", path: "valueOf" })
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("returns unknown for changes.length — inherited from Array.prototype, not an index into the array", () => {
|
|
123
|
+
expect(selectPath(fields, "changes.length")).toEqual({
|
|
124
|
+
kind: "unknown",
|
|
125
|
+
path: "changes.length",
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("returns unknown for changes.map — inherited from Array.prototype", () => {
|
|
130
|
+
expect(selectPath(fields, "changes.map")).toEqual({ kind: "unknown", path: "changes.map" })
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe("selectPath — nested field reach", () => {
|
|
135
|
+
it("reaches a nested scalar field", () => {
|
|
136
|
+
expect(selectPath({ session: { id: "abc123" } }, "session.id")).toEqual({
|
|
137
|
+
kind: "value",
|
|
138
|
+
text: "abc123",
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe("src/wire/Select.ts — zero imports", () => {
|
|
144
|
+
it("contains no import statement", () => {
|
|
145
|
+
const source = readFileSync(new URL("./Select.ts", import.meta.url), "utf8")
|
|
146
|
+
expect(source).not.toMatch(/^\s*import\b/m)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Zero imports on purpose — a pure, dependency-free vocabulary tier; keep it that way.
|
|
2
|
+
|
|
3
|
+
export type Selection =
|
|
4
|
+
| { readonly kind: "value"; readonly text: string }
|
|
5
|
+
| { readonly kind: "absent" }
|
|
6
|
+
| { readonly kind: "unknown"; readonly path: string }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Renders a fully-walked leaf value: scalars/booleans stringify directly,
|
|
10
|
+
* arrays become one `JSON.stringify` per entry, newline-joined.
|
|
11
|
+
* `selectPath`'s loop already returns `absent` for `undefined`/`null` after
|
|
12
|
+
* every segment (including the last), so neither ever reaches here.
|
|
13
|
+
*/
|
|
14
|
+
const toSelection = (value: unknown): Selection => {
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
return { kind: "value", text: value.map((entry) => JSON.stringify(entry)).join("\n") }
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === "object") {
|
|
19
|
+
return { kind: "value", text: JSON.stringify(value) }
|
|
20
|
+
}
|
|
21
|
+
return { kind: "value", text: String(value) }
|
|
22
|
+
}
|
|
23
|
+
|
|
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`. */
|
|
25
|
+
const NOT_FOUND: unique symbol = Symbol("not-found")
|
|
26
|
+
|
|
27
|
+
/** An all-digits segment (`"0"`, `"12"`) — the only shape an array segment resolves against. */
|
|
28
|
+
const ARRAY_INDEX = /^\d+$/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One step of the walk: looks `segment` up on `current`, or reports
|
|
32
|
+
* `NOT_FOUND` for a key that was never there (a primitive has no keys).
|
|
33
|
+
* Against an array, only an all-digits segment resolves — an in-range index
|
|
34
|
+
* (`skills.0` reads the first declared skill), never a non-index own key
|
|
35
|
+
* (`length`/`map`/every other inherited array member stays `NOT_FOUND`).
|
|
36
|
+
* Against an object, presence is an OWN-property test
|
|
37
|
+
* (`Object.prototype.hasOwnProperty`), never the `in` operator — `in` walks
|
|
38
|
+
* the prototype chain, so it would resolve inherited members
|
|
39
|
+
* (`constructor`, `toString`, `hasOwnProperty`, `valueOf`, ...) as real
|
|
40
|
+
* document fields.
|
|
41
|
+
*/
|
|
42
|
+
const resolveSegment = (current: unknown, segment: string): unknown => {
|
|
43
|
+
if (Array.isArray(current)) {
|
|
44
|
+
return ARRAY_INDEX.test(segment) && Number(segment) < current.length
|
|
45
|
+
? current[Number(segment)]
|
|
46
|
+
: NOT_FOUND
|
|
47
|
+
}
|
|
48
|
+
if (typeof current === "object" && current !== null) {
|
|
49
|
+
const record = current as Record<string, unknown>
|
|
50
|
+
return Object.prototype.hasOwnProperty.call(record, segment) ? record[segment] : NOT_FOUND
|
|
51
|
+
}
|
|
52
|
+
return NOT_FOUND
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Walks `fields` by dotted key path. Never throws — any unwalkable shape
|
|
57
|
+
* (a primitive mid-path, an out-of-range or non-numeric array segment)
|
|
58
|
+
* degrades to `unknown`, and a present-but-`undefined` value anywhere along
|
|
59
|
+
* the path short-circuits the whole remaining path to `absent` rather than
|
|
60
|
+
* reporting a key that was never actually missing.
|
|
61
|
+
*/
|
|
62
|
+
export const selectPath = (fields: unknown, path: string): Selection => {
|
|
63
|
+
try {
|
|
64
|
+
let current: unknown = fields
|
|
65
|
+
for (const segment of path.split(".")) {
|
|
66
|
+
current = resolveSegment(current, segment)
|
|
67
|
+
if (current === NOT_FOUND) return { kind: "unknown", path }
|
|
68
|
+
// `null` is treated exactly like `undefined` here: a `null`-typed field
|
|
69
|
+
// (e.g. `BeatDocument.next`, `LandFields.subject`/`cost`/`model`) is a
|
|
70
|
+
// legitimate "nothing here" value, not a value to descend into or print
|
|
71
|
+
// as the literal string "null".
|
|
72
|
+
if (current === undefined || current === null) return { kind: "absent" }
|
|
73
|
+
}
|
|
74
|
+
return toSelection(current)
|
|
75
|
+
} catch {
|
|
76
|
+
return { kind: "unknown", path }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { GTD_SESSION_NAMESPACE, resolveSession, uuidv5 } from "./Sessions.js"
|
|
3
|
+
|
|
4
|
+
// RFC 4122 §4.3 test vector — the DNS namespace uuidv5-hashing "www.example.com"
|
|
5
|
+
// is the standard, tool-independent proof that a hand-rolled v5 implementation
|
|
6
|
+
// is correct (unlike a gtd-namespace id, which no external source publishes).
|
|
7
|
+
const DNS_NAMESPACE = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
|
|
8
|
+
|
|
9
|
+
describe("uuidv5", () => {
|
|
10
|
+
it("matches the RFC 4122 test vector", () => {
|
|
11
|
+
expect(uuidv5(DNS_NAMESPACE, "www.example.com")).toBe("2ed6657d-e927-568b-95e1-2665a8aea6a2")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("sets the version nibble to 5 and the variant bits to 10xx", () => {
|
|
15
|
+
const id = uuidv5(GTD_SESSION_NAMESPACE, "build.fix#abc1234")
|
|
16
|
+
expect(id[14]).toBe("5")
|
|
17
|
+
expect(["8", "9", "a", "b"]).toContain(id[19])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("is deterministic: the same name always yields the same id", () => {
|
|
21
|
+
expect(uuidv5(GTD_SESSION_NAMESPACE, "root#abc1234")).toBe(
|
|
22
|
+
uuidv5(GTD_SESSION_NAMESPACE, "root#abc1234"),
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("a one-character-different name yields a different id", () => {
|
|
27
|
+
expect(uuidv5(GTD_SESSION_NAMESPACE, "root#abc1234")).not.toBe(
|
|
28
|
+
uuidv5(GTD_SESSION_NAMESPACE, "root#abc1235"),
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("resolveSession", () => {
|
|
34
|
+
it("derives the same id for the same memory key, passing resume through verbatim", () => {
|
|
35
|
+
expect(resolveSession("root#abc1234", false)).toEqual({
|
|
36
|
+
sessionId: uuidv5(GTD_SESSION_NAMESPACE, "root#abc1234"),
|
|
37
|
+
resume: false,
|
|
38
|
+
})
|
|
39
|
+
expect(resolveSession("root#abc1234", true)).toEqual({
|
|
40
|
+
sessionId: uuidv5(GTD_SESSION_NAMESPACE, "root#abc1234"),
|
|
41
|
+
resume: true,
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("a different memory key derives a different id", () => {
|
|
46
|
+
const a = resolveSession("root#abc1234", false)
|
|
47
|
+
const b = resolveSession("root#feed000", false)
|
|
48
|
+
expect(a.sessionId).not.toBe(b.sessionId)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("mints a random, non-repeating id and forces resume: false when there is no memory key", () => {
|
|
52
|
+
let calls = 0
|
|
53
|
+
const mint = () => `minted-${++calls}`
|
|
54
|
+
const first = resolveSession(undefined, true, mint)
|
|
55
|
+
const second = resolveSession(undefined, true, mint)
|
|
56
|
+
expect(first).toEqual({ sessionId: "minted-1", resume: false })
|
|
57
|
+
expect(second).toEqual({ sessionId: "minted-2", resume: false })
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
/** Fixed forever — every derived session id descends from this one constant. */
|
|
4
|
+
export const GTD_SESSION_NAMESPACE = "ca4be249-805e-41c1-8b6a-75a11c011e25"
|
|
5
|
+
|
|
6
|
+
const toBytes = (uuid: string): Buffer => Buffer.from(uuid.replace(/-/g, ""), "hex")
|
|
7
|
+
|
|
8
|
+
const toUuidString = (bytes: Buffer): string => {
|
|
9
|
+
const hex = bytes.toString("hex")
|
|
10
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* UUIDv5 (RFC 4122 §4.3), hand-rolled over `node:crypto`'s SHA-1 rather than
|
|
15
|
+
* a dependency — correctness is checked against the RFC's own DNS-namespace
|
|
16
|
+
* test vector in `Sessions.test.ts`.
|
|
17
|
+
*/
|
|
18
|
+
export const uuidv5 = (namespace: string, name: string): string => {
|
|
19
|
+
const hash = createHash("sha1").update(toBytes(namespace)).update(name, "utf8").digest()
|
|
20
|
+
const bytes = hash.subarray(0, 16)
|
|
21
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x50
|
|
22
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
|
23
|
+
return toUuidString(bytes)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolves a `prompt` rest's session id: with no `memoryKey` there's no
|
|
28
|
+
* history to derive a stable id from, so it mints an ephemeral one and forces
|
|
29
|
+
* `resume: false`; otherwise the id is deterministic (`uuidv5`) and `resume`
|
|
30
|
+
* is passed through as given.
|
|
31
|
+
*/
|
|
32
|
+
export const resolveSession = (
|
|
33
|
+
memoryKey: string | undefined,
|
|
34
|
+
resume: boolean,
|
|
35
|
+
mint: () => string = randomUUID,
|
|
36
|
+
): { readonly sessionId: string; readonly resume: boolean } =>
|
|
37
|
+
memoryKey === undefined
|
|
38
|
+
? { sessionId: mint(), resume: false }
|
|
39
|
+
: { sessionId: uuidv5(GTD_SESSION_NAMESPACE, memoryKey), resume }
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
import {
|
|
5
|
+
BEAT_KINDS,
|
|
6
|
+
beatDocument,
|
|
7
|
+
demandOf,
|
|
8
|
+
judgeJson,
|
|
9
|
+
landFields,
|
|
10
|
+
renderBeatJson,
|
|
11
|
+
renderLandJson,
|
|
12
|
+
statusOf,
|
|
13
|
+
WIRE_SCHEMA,
|
|
14
|
+
type BeatKind,
|
|
15
|
+
} from "./index.js"
|
|
16
|
+
|
|
17
|
+
const GOLDEN = resolve(import.meta.dirname, "golden")
|
|
18
|
+
|
|
19
|
+
// One golden set per schema. Removing or renaming a field bumps WIRE_SCHEMA
|
|
20
|
+
// and adds golden/<WIRE_SCHEMA>/ beside the old sets; adding a field needs
|
|
21
|
+
// neither. Only shape is compared, so reworded content never asks for a bump.
|
|
22
|
+
const DIR = resolve(GOLDEN, String(WIRE_SCHEMA))
|
|
23
|
+
const golden: ReadonlyMap<string, unknown> = new Map(
|
|
24
|
+
readdirSync(DIR)
|
|
25
|
+
.sort()
|
|
26
|
+
.map((file) => [file, JSON.parse(readFileSync(resolve(DIR, file), "utf8"))]),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
/** Every key path with its leaf type; `judge` is a JSON document carried as a string, so it is parsed first. */
|
|
30
|
+
const shape = (value: unknown, path = ""): string[] => {
|
|
31
|
+
if (path === "judge" && typeof value === "string") return shape(JSON.parse(value), path)
|
|
32
|
+
if (Array.isArray(value)) return value.flatMap((item) => shape(item, `${path}[]`))
|
|
33
|
+
if (value !== null && typeof value === "object")
|
|
34
|
+
return Object.entries(value).flatMap(([key, item]) =>
|
|
35
|
+
shape(item, path ? `${path}.${key}` : key),
|
|
36
|
+
)
|
|
37
|
+
return [`${path}: ${value === null ? "null" : typeof value}`]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const missing = (actual: unknown, expected: unknown): string[] => {
|
|
41
|
+
const has = new Set(shape(actual))
|
|
42
|
+
return [...new Set(shape(expected))].filter((leaf) => !has.has(leaf))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const JUDGE = judgeJson({ diff: "…" }, [
|
|
46
|
+
{
|
|
47
|
+
id: "q1",
|
|
48
|
+
primitive: "noul",
|
|
49
|
+
instructions: "Is the change done?",
|
|
50
|
+
criteria: "yes: every item ticked",
|
|
51
|
+
},
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
const beat = (kind: BeatKind): string => {
|
|
55
|
+
const rendered = {
|
|
56
|
+
state: "build.fixing",
|
|
57
|
+
actor: "agent",
|
|
58
|
+
content: "fix it",
|
|
59
|
+
model: "opus",
|
|
60
|
+
system: "You are a careful senior engineer.",
|
|
61
|
+
memory: "build#a1b2c3d",
|
|
62
|
+
file: "TODO.md",
|
|
63
|
+
mode: "qa",
|
|
64
|
+
label: "Fixing",
|
|
65
|
+
judge: JUDGE,
|
|
66
|
+
skills: ["code-review"],
|
|
67
|
+
access: { read: null, write: [".gtd/TODO.md"] },
|
|
68
|
+
}
|
|
69
|
+
const demand = demandOf(
|
|
70
|
+
kind === "prompt"
|
|
71
|
+
? {
|
|
72
|
+
rendered,
|
|
73
|
+
kind,
|
|
74
|
+
session: { id: "8f2c", resume: true },
|
|
75
|
+
validate: "gtd check qa 'TODO.md'",
|
|
76
|
+
}
|
|
77
|
+
: { rendered, kind },
|
|
78
|
+
)
|
|
79
|
+
const status = statusOf({
|
|
80
|
+
rendered,
|
|
81
|
+
idle: false,
|
|
82
|
+
initial: false,
|
|
83
|
+
workflow: "feature",
|
|
84
|
+
log: ".git/gtd-loop.log",
|
|
85
|
+
changes: [{ status: "M", path: "TODO.md" }],
|
|
86
|
+
next: { target: "idle" },
|
|
87
|
+
cost: 12,
|
|
88
|
+
costByModel: [{ model: "opus", cost: 12 }],
|
|
89
|
+
})
|
|
90
|
+
return renderBeatJson(beatDocument(demand, status))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const documents: Readonly<Record<string, string>> = {
|
|
94
|
+
...Object.fromEntries(BEAT_KINDS.map((kind) => [`beat-${kind}.json`, beat(kind)])),
|
|
95
|
+
"land.json": renderLandJson(
|
|
96
|
+
landFields({
|
|
97
|
+
state: "build.review.closing",
|
|
98
|
+
subject: "gtd(agent): build.fixing",
|
|
99
|
+
cost: 0.42,
|
|
100
|
+
model: "smart",
|
|
101
|
+
script: "git commit ...\n",
|
|
102
|
+
settled: false,
|
|
103
|
+
idle: false,
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
106
|
+
"land-noop.json": renderLandJson(
|
|
107
|
+
landFields({
|
|
108
|
+
state: "idle",
|
|
109
|
+
subject: null,
|
|
110
|
+
cost: null,
|
|
111
|
+
model: null,
|
|
112
|
+
script: "printf '%s\\n' 'nothing to do at \"idle\"'\n",
|
|
113
|
+
settled: true,
|
|
114
|
+
idle: true,
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
"judge.json": JUDGE,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe(`wire schema ${WIRE_SCHEMA}`, () => {
|
|
121
|
+
it("the golden set covers every document kind", () => {
|
|
122
|
+
expect([...golden.keys()]).toEqual(Object.keys(documents).sort())
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
for (const [file, json] of Object.entries(documents)) {
|
|
126
|
+
it(`${file}: still emits every golden field under its name and type — removing or renaming one needs a WIRE_SCHEMA bump`, () => {
|
|
127
|
+
expect(golden.get(file)).toHaveProperty("schema", WIRE_SCHEMA)
|
|
128
|
+
expect(missing(JSON.parse(json), golden.get(file))).toEqual([])
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe("the shape check", () => {
|
|
133
|
+
const land = JSON.parse(documents["land.json"]!) as Record<string, unknown>
|
|
134
|
+
const { subject, ...rest } = land
|
|
135
|
+
|
|
136
|
+
it("reports a removed field", () => {
|
|
137
|
+
expect(missing(rest, golden.get("land.json"))).toEqual(["subject: string"])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("reports a renamed field", () => {
|
|
141
|
+
expect(missing({ ...rest, title: subject }, golden.get("land.json"))).toEqual([
|
|
142
|
+
"subject: string",
|
|
143
|
+
])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("accepts an added field and changed content", () => {
|
|
147
|
+
expect(missing({ ...land, extra: true, script: "other" }, golden.get("land.json"))).toEqual(
|
|
148
|
+
[],
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("reads inside the judge document a beat carries", () => {
|
|
153
|
+
const beat = JSON.parse(documents["beat-message.json"]!) as Record<string, unknown>
|
|
154
|
+
const judge = JSON.parse(beat.judge as string) as Record<string, unknown>
|
|
155
|
+
const { state, ...withoutState } = judge
|
|
156
|
+
expect(state).toBeDefined()
|
|
157
|
+
expect(
|
|
158
|
+
missing({ ...beat, judge: JSON.stringify(withoutState) }, golden.get("beat-message.json")),
|
|
159
|
+
).toEqual(["judge.state.diff: string"])
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
})
|