@pmelab/gtd 24.0.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 +31 -5
- 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 +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,127 @@
|
|
|
1
|
+
import type { BeatKind } from "./Demand.js"
|
|
2
|
+
import type { BeatDocument } from "./BeatDocument.js"
|
|
3
|
+
import type { StatusChange } from "./BeatStatus.js"
|
|
4
|
+
import { UNATTRIBUTED_MODEL } from "./constants.js"
|
|
5
|
+
import type { ModelCost } from "./types.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* True when the per-model breakdown adds information beyond the `Cost:`
|
|
9
|
+
* total: more than one model, or a single model that carries an actual
|
|
10
|
+
* `--model` tag (a lone `unspecified` bucket just restates the total, so
|
|
11
|
+
* it's suppressed).
|
|
12
|
+
*/
|
|
13
|
+
const breakdownIsInformative = (byModel: readonly ModelCost[]): boolean =>
|
|
14
|
+
byModel.length > 1 || (byModel.length === 1 && byModel[0]!.model !== UNATTRIBUTED_MODEL)
|
|
15
|
+
|
|
16
|
+
const costStatusLines = (cost: number, byModel: readonly ModelCost[]): string[] => {
|
|
17
|
+
if (cost <= 0) return []
|
|
18
|
+
const lines = [`Cost: ${cost}`]
|
|
19
|
+
if (breakdownIsInformative(byModel)) {
|
|
20
|
+
for (const m of byModel) lines.push(` ${m.model}: ${m.cost}`)
|
|
21
|
+
}
|
|
22
|
+
return lines
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pendingStatusLines = (statusChanges: readonly StatusChange[]): string[] =>
|
|
26
|
+
statusChanges.length === 0
|
|
27
|
+
? ["Pending: (clean)"]
|
|
28
|
+
: ["Pending:", ...statusChanges.map((c) => ` ${c.status} ${c.path}`)]
|
|
29
|
+
|
|
30
|
+
const nextStatusLine = (next: BeatDocument["next"]): string =>
|
|
31
|
+
next === null ? "Next: (nothing would land)" : `Next: → ${next.target}`
|
|
32
|
+
|
|
33
|
+
/** Builds `{[key]: value}` for each entry whose value isn't `undefined` — the shared "omit absent optional fields" shape the header uses. */
|
|
34
|
+
const definedFields = (
|
|
35
|
+
entries: readonly (readonly [string, unknown])[],
|
|
36
|
+
): Record<string, unknown> => {
|
|
37
|
+
const result: Record<string, unknown> = {}
|
|
38
|
+
for (const [key, value] of entries) if (value !== undefined) result[key] = value
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The header block's own lines — `State:`/`Awaits:`/`Workflow:`/optional `Label:`/
|
|
44
|
+
* `Model:`/`Memory:`/`File:`/`Mode:`/`Cost:`(+breakdown)/`Pending:`/`Next:`,
|
|
45
|
+
* in that order. Used by `renderBeatPlain`'s header-showing kinds below.
|
|
46
|
+
*/
|
|
47
|
+
const beatHeaderLines = (document: BeatDocument): string[] => {
|
|
48
|
+
const optional = definedFields([
|
|
49
|
+
["Label", document.label],
|
|
50
|
+
["Model", document.model],
|
|
51
|
+
["Memory", document.memory],
|
|
52
|
+
["File", document.file],
|
|
53
|
+
["Mode", document.mode],
|
|
54
|
+
])
|
|
55
|
+
return [
|
|
56
|
+
`State: ${document.state}`,
|
|
57
|
+
`Awaits: ${document.actor}`,
|
|
58
|
+
`Workflow: ${document.workflow}`,
|
|
59
|
+
...Object.entries(optional).map(([key, value]) => `${key}: ${value}`),
|
|
60
|
+
...costStatusLines(document.cost ?? 0, document.costByModel ?? []),
|
|
61
|
+
...pendingStatusLines(document.changes),
|
|
62
|
+
nextStatusLine(document.next),
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `script`'s plain-output instruction line — a literal constant, never a template. */
|
|
67
|
+
const SCRIPT_INSTRUCTION = "Run this script:"
|
|
68
|
+
|
|
69
|
+
/** `capture`'s plain-output instruction line — a literal constant, never a template. */
|
|
70
|
+
const CAPTURE_INSTRUCTION = "The edit is already made — run `gtd land` to land it."
|
|
71
|
+
|
|
72
|
+
/** One `BeatKind`'s plain-render briefing: the instruction line prepended above the header (`undefined` for none), and whether the status header is suppressed entirely (`prompt` alone — those bytes ARE the agent's input). */
|
|
73
|
+
export interface DemandBriefing {
|
|
74
|
+
readonly instructionLine: string | undefined
|
|
75
|
+
readonly suppressHeader: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One entry per `BeatKind`, typed as `Record<BeatKind, DemandBriefing>` so
|
|
80
|
+
* the object literal itself is exhaustive: adding or removing a `BeatKind`
|
|
81
|
+
* member without updating this map fails `tsc`, the same guarantee
|
|
82
|
+
* `dispatchFieldsOf`'s switch (`BeatDocument.ts`) gives the JSON side. Exported
|
|
83
|
+
* so the one cross-cutting exhaustiveness test (`BeatDocument.test.ts`) can
|
|
84
|
+
* assert against it directly, rather than re-deriving it from
|
|
85
|
+
* `renderBeatPlain`'s output. Replaces the old `renderBeatPlain`'s hand-written
|
|
86
|
+
* `if (kind === "script") … if (kind === "capture") …` pair.
|
|
87
|
+
*/
|
|
88
|
+
export const DEMAND_BRIEFING: Record<BeatKind, DemandBriefing> = {
|
|
89
|
+
capture: { instructionLine: CAPTURE_INSTRUCTION, suppressHeader: false },
|
|
90
|
+
message: { instructionLine: undefined, suppressHeader: false },
|
|
91
|
+
script: { instructionLine: SCRIPT_INSTRUCTION, suppressHeader: false },
|
|
92
|
+
stalled: { instructionLine: undefined, suppressHeader: false },
|
|
93
|
+
prompt: { instructionLine: undefined, suppressHeader: true },
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The self-validation instruction appended to a `prompt` beat's plain output
|
|
98
|
+
* only — a structured (`--json`) driver runs the `validate` field
|
|
99
|
+
* itself instead. Advisory either way: `gtd land` refuses a turn whose
|
|
100
|
+
* steering file is invalid regardless of whether this was followed.
|
|
101
|
+
*/
|
|
102
|
+
const selfValidateInstruction = (command: string, file: string): string =>
|
|
103
|
+
`\nBefore finishing your turn, run \`${command}\` — it checks ${file} — and fix ` +
|
|
104
|
+
`every violation it reports until it exits cleanly. Do not finish while it ` +
|
|
105
|
+
`still reports violations.\n`
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `gtd next`'s plain-text encoding, driven by `DEMAND_BRIEFING`. At
|
|
109
|
+
* `kind === "prompt"` the status header is suppressed and only `content`
|
|
110
|
+
* plus the self-validation instruction is emitted — those bytes ARE the
|
|
111
|
+
* agent's input, so gtd's own bookkeeping (state name, edges, pending
|
|
112
|
+
* changes) must not be prefixed onto it. Every other kind gets the header
|
|
113
|
+
* plus its instruction line, since its plain output is read by a human or
|
|
114
|
+
* driver, never an agent. `selfValidateCommand` is already resolved; this
|
|
115
|
+
* function never resolves it itself.
|
|
116
|
+
*/
|
|
117
|
+
export const renderBeatPlain = (document: BeatDocument, selfValidateCommand?: string): string => {
|
|
118
|
+
const content = document.content.endsWith("\n") ? document.content : document.content + "\n"
|
|
119
|
+
const briefing = DEMAND_BRIEFING[document.kind]
|
|
120
|
+
if (briefing.suppressHeader) {
|
|
121
|
+
return selfValidateCommand !== undefined && document.file !== undefined
|
|
122
|
+
? content + selfValidateInstruction(selfValidateCommand, document.file)
|
|
123
|
+
: content
|
|
124
|
+
}
|
|
125
|
+
const body = beatHeaderLines(document).join("\n") + "\n\n" + content
|
|
126
|
+
return briefing.instructionLine !== undefined ? `${briefing.instructionLine}\n` + body : body
|
|
127
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { statusOf } from "./BeatStatus.js"
|
|
3
|
+
import type { RenderedDemandSource } from "./index.js"
|
|
4
|
+
|
|
5
|
+
const rendered = (overrides: Partial<RenderedDemandSource> = {}): RenderedDemandSource => ({
|
|
6
|
+
state: "build.fixing",
|
|
7
|
+
actor: "agent",
|
|
8
|
+
content: "fix it",
|
|
9
|
+
...overrides,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const baseInput = (overrides: Partial<RenderedDemandSource> = {}) => ({
|
|
13
|
+
rendered: rendered(overrides),
|
|
14
|
+
idle: false,
|
|
15
|
+
initial: false,
|
|
16
|
+
workflow: "feature",
|
|
17
|
+
log: "log",
|
|
18
|
+
changes: [],
|
|
19
|
+
next: null,
|
|
20
|
+
cost: 0,
|
|
21
|
+
costByModel: [],
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe("statusOf", () => {
|
|
25
|
+
it("carries model through verbatim, including the empty string", () => {
|
|
26
|
+
expect(statusOf(baseInput({ model: "" })).model).toBe("")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("omits system when its rendered value is the empty string, unlike model", () => {
|
|
30
|
+
const status = statusOf(baseInput({ model: "", system: "" }))
|
|
31
|
+
expect(status.system).toBeUndefined()
|
|
32
|
+
expect(status.model).toBe("")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("carries system through when non-empty", () => {
|
|
36
|
+
expect(statusOf(baseInput({ system: "a persona" })).system).toBe("a persona")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("carries idle/log/changes/next/cost/costByModel through unchanged (ungated — beatDocument applies the wire's omission rules)", () => {
|
|
40
|
+
const status = statusOf({
|
|
41
|
+
rendered: rendered(),
|
|
42
|
+
idle: true,
|
|
43
|
+
initial: true,
|
|
44
|
+
workflow: "feature",
|
|
45
|
+
log: ".git/gtd-loop.log",
|
|
46
|
+
changes: [{ status: "M", path: "TODO.md" }],
|
|
47
|
+
next: { target: "idle" },
|
|
48
|
+
cost: 12,
|
|
49
|
+
costByModel: [{ model: "opus", cost: 12 }],
|
|
50
|
+
})
|
|
51
|
+
expect(status.workflow).toBe("feature")
|
|
52
|
+
expect(status.idle).toBe(true)
|
|
53
|
+
expect(status.log).toBe(".git/gtd-loop.log")
|
|
54
|
+
expect(status.changes).toEqual([{ status: "M", path: "TODO.md" }])
|
|
55
|
+
expect(status.next).toEqual({ target: "idle" })
|
|
56
|
+
expect(status.cost).toBe(12)
|
|
57
|
+
expect(status.costByModel).toEqual([{ model: "opus", cost: 12 }])
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Actor, ModelCost, RenderedDemandSource, StateMode, StateName } from "./types.js"
|
|
2
|
+
|
|
3
|
+
/** One pending change — `gtd next --json`'s `changes` entries. */
|
|
4
|
+
export interface StatusChange {
|
|
5
|
+
readonly status: string
|
|
6
|
+
readonly path: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The step landing the pending change would leave the process at. */
|
|
10
|
+
export interface NextMatch {
|
|
11
|
+
readonly target: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Everything a `gtd next` beat carries that no driver `case`-arm branches on
|
|
16
|
+
* — the doc-tested minimal driver never reads any of these except
|
|
17
|
+
* `model`/`system` (read unconditionally, not gated to a `kind`) and never
|
|
18
|
+
* switches behavior on them. Ungated: `cost`/`costByModel`/`next` here carry
|
|
19
|
+
* their raw values; `beatDocument` applies the wire's own
|
|
20
|
+
* present-vs-omitted rules when flattening.
|
|
21
|
+
*/
|
|
22
|
+
export interface BeatStatus {
|
|
23
|
+
readonly idle: boolean
|
|
24
|
+
/** No process is underway, whatever the tree holds — `idle` additionally needs a clean tree. */
|
|
25
|
+
readonly initial: boolean
|
|
26
|
+
readonly model: string | undefined
|
|
27
|
+
readonly system: string | undefined
|
|
28
|
+
readonly log: string
|
|
29
|
+
readonly state: StateName
|
|
30
|
+
readonly actor: Actor
|
|
31
|
+
/** The workflow the process runs: its pinned name, or the default's display name. */
|
|
32
|
+
readonly workflow: string
|
|
33
|
+
readonly label: string | undefined
|
|
34
|
+
readonly memory: string | undefined
|
|
35
|
+
readonly file: string | undefined
|
|
36
|
+
readonly mode: StateMode | undefined
|
|
37
|
+
readonly changes: readonly StatusChange[]
|
|
38
|
+
readonly next: NextMatch | null
|
|
39
|
+
readonly cost: number
|
|
40
|
+
readonly costByModel: readonly ModelCost[]
|
|
41
|
+
readonly judge: string | undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Assemble one `BeatStatus`. `system` is omitted (not just falsy) when its
|
|
46
|
+
* rendered value is the empty string, unlike `model`: an empty
|
|
47
|
+
* `--system-prompt ""` would silently delete the harness's own default
|
|
48
|
+
* instead of failing loudly.
|
|
49
|
+
*/
|
|
50
|
+
export const statusOf = (input: {
|
|
51
|
+
readonly rendered: RenderedDemandSource
|
|
52
|
+
readonly idle: boolean
|
|
53
|
+
readonly initial: boolean
|
|
54
|
+
readonly workflow: string
|
|
55
|
+
readonly log: string
|
|
56
|
+
readonly changes: readonly StatusChange[]
|
|
57
|
+
readonly next: NextMatch | null
|
|
58
|
+
readonly cost: number
|
|
59
|
+
readonly costByModel: readonly ModelCost[]
|
|
60
|
+
}): BeatStatus => {
|
|
61
|
+
const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel } = input
|
|
62
|
+
return {
|
|
63
|
+
idle,
|
|
64
|
+
initial,
|
|
65
|
+
model: rendered.model,
|
|
66
|
+
system: rendered.system !== undefined && rendered.system !== "" ? rendered.system : undefined,
|
|
67
|
+
log,
|
|
68
|
+
state: rendered.state,
|
|
69
|
+
actor: rendered.actor,
|
|
70
|
+
workflow,
|
|
71
|
+
label: rendered.label,
|
|
72
|
+
memory: rendered.memory,
|
|
73
|
+
file: rendered.file,
|
|
74
|
+
mode: rendered.mode,
|
|
75
|
+
changes,
|
|
76
|
+
next,
|
|
77
|
+
cost,
|
|
78
|
+
costByModel,
|
|
79
|
+
judge: rendered.judge,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { BEAT_KINDS, beatKindOf, demandOf, stallDiagnosis } from "./Demand.js"
|
|
3
|
+
import type { RenderedDemandSource } from "./index.js"
|
|
4
|
+
|
|
5
|
+
const rendered = (overrides: Partial<RenderedDemandSource> = {}): RenderedDemandSource => ({
|
|
6
|
+
state: "build.fixing",
|
|
7
|
+
actor: "agent",
|
|
8
|
+
content: "fix it",
|
|
9
|
+
...overrides,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe("beatKindOf", () => {
|
|
13
|
+
it("is stalled whenever stalled is true, regardless of content kind or dirtiness", () => {
|
|
14
|
+
for (const contentKind of ["script", "prompt", "message"] as const) {
|
|
15
|
+
for (const dirty of [true, false]) {
|
|
16
|
+
expect(beatKindOf({ contentKind, dirty, stalled: true })).toBe("stalled")
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("is capture at a dirty message rest", () => {
|
|
22
|
+
expect(beatKindOf({ contentKind: "message", dirty: true, stalled: false })).toBe("capture")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("is message at a clean message rest", () => {
|
|
26
|
+
expect(beatKindOf({ contentKind: "message", dirty: false, stalled: false })).toBe("message")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("is script at a script rest, dirty or clean", () => {
|
|
30
|
+
expect(beatKindOf({ contentKind: "script", dirty: true, stalled: false })).toBe("script")
|
|
31
|
+
expect(beatKindOf({ contentKind: "script", dirty: false, stalled: false })).toBe("script")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("is prompt at a prompt rest, dirty or clean", () => {
|
|
35
|
+
expect(beatKindOf({ contentKind: "prompt", dirty: true, stalled: false })).toBe("prompt")
|
|
36
|
+
expect(beatKindOf({ contentKind: "prompt", dirty: false, stalled: false })).toBe("prompt")
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe("stallDiagnosis", () => {
|
|
41
|
+
it("names the step and mentions both escapes", () => {
|
|
42
|
+
const text = stallDiagnosis("build.working", "agent")
|
|
43
|
+
expect(text).toContain('stalled at "build.working"')
|
|
44
|
+
expect(text).toContain("gtd(agent): build.working")
|
|
45
|
+
expect(text).toMatch(/escalation/)
|
|
46
|
+
expect(text).toMatch(/allowEmpty: true/)
|
|
47
|
+
expect(text).toMatch(/prompt so the turn has something concrete/)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe("demandOf", () => {
|
|
52
|
+
it("uses the rendered content verbatim at every non-stalled kind", () => {
|
|
53
|
+
for (const kind of BEAT_KINDS.filter((k) => k !== "stalled")) {
|
|
54
|
+
const demand = demandOf({ rendered: rendered(), kind })
|
|
55
|
+
expect(demand.content).toBe("fix it")
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("uses the stall diagnosis as content, never the rendered content, at kind stalled", () => {
|
|
60
|
+
const demand = demandOf({ rendered: rendered(), kind: "stalled" })
|
|
61
|
+
expect(demand.content).toBe(stallDiagnosis("build.fixing", "agent"))
|
|
62
|
+
expect(demand.content).not.toBe("fix it")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("carries session/validate only on the prompt variant — no other kind's Demand shape can hold them", () => {
|
|
66
|
+
const session = { id: "8f2c", resume: true }
|
|
67
|
+
for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
|
|
68
|
+
const demand = demandOf({
|
|
69
|
+
rendered: rendered(),
|
|
70
|
+
kind,
|
|
71
|
+
session,
|
|
72
|
+
validate: "gtd check qa 'TODO.md'",
|
|
73
|
+
})
|
|
74
|
+
expect("session" in demand).toBe(false)
|
|
75
|
+
expect("validate" in demand).toBe(false)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("carries session/validate at kind prompt when supplied", () => {
|
|
80
|
+
const session = { id: "8f2c", resume: true }
|
|
81
|
+
const demand = demandOf({
|
|
82
|
+
rendered: rendered(),
|
|
83
|
+
kind: "prompt",
|
|
84
|
+
session,
|
|
85
|
+
validate: "gtd check qa 'TODO.md'",
|
|
86
|
+
})
|
|
87
|
+
expect(demand.kind).toBe("prompt")
|
|
88
|
+
if (demand.kind !== "prompt") throw new Error("unreachable")
|
|
89
|
+
expect(demand.session).toEqual(session)
|
|
90
|
+
expect(demand.validate).toBe("gtd check qa 'TODO.md'")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("carries session/validate present-with-undefined (not omitted) at kind prompt when the caller supplied none", () => {
|
|
94
|
+
const demand = demandOf({ rendered: rendered(), kind: "prompt" })
|
|
95
|
+
expect(demand.kind).toBe("prompt")
|
|
96
|
+
if (demand.kind !== "prompt") throw new Error("unreachable")
|
|
97
|
+
expect("session" in demand).toBe(true)
|
|
98
|
+
expect(demand.session).toBeUndefined()
|
|
99
|
+
expect("validate" in demand).toBe(true)
|
|
100
|
+
expect(demand.validate).toBeUndefined()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("carries skills at kind prompt when the rendered source has a non-empty list", () => {
|
|
104
|
+
const demand = demandOf({ rendered: rendered({ skills: ["a", "b"] }), kind: "prompt" })
|
|
105
|
+
expect(demand.kind).toBe("prompt")
|
|
106
|
+
if (demand.kind !== "prompt") throw new Error("unreachable")
|
|
107
|
+
expect(demand.skills).toEqual(["a", "b"])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("normalizes an empty or absent skills list to undefined at kind prompt, never []", () => {
|
|
111
|
+
const empty = demandOf({ rendered: rendered({ skills: [] }), kind: "prompt" })
|
|
112
|
+
const absent = demandOf({ rendered: rendered(), kind: "prompt" })
|
|
113
|
+
if (empty.kind !== "prompt" || absent.kind !== "prompt") throw new Error("unreachable")
|
|
114
|
+
expect(empty.skills).toBeUndefined()
|
|
115
|
+
expect(absent.skills).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("carries access at kind prompt, unrestricted on both sides when the source has none", () => {
|
|
119
|
+
const bare = demandOf({ rendered: rendered(), kind: "prompt" })
|
|
120
|
+
const restricted = demandOf({
|
|
121
|
+
rendered: rendered({ access: { read: ["docs/**"], write: [] } }),
|
|
122
|
+
kind: "prompt",
|
|
123
|
+
})
|
|
124
|
+
if (bare.kind !== "prompt" || restricted.kind !== "prompt") throw new Error("unreachable")
|
|
125
|
+
expect(bare.access).toEqual({ read: null, write: null })
|
|
126
|
+
expect(restricted.access).toEqual({ read: ["docs/**"], write: [] })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("carries no access field at any non-prompt kind", () => {
|
|
130
|
+
for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
|
|
131
|
+
const demand = demandOf({ rendered: rendered({ access: { read: null, write: [] } }), kind })
|
|
132
|
+
expect("access" in demand).toBe(false)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("carries no skills field at any non-prompt kind, even when the rendered source has one", () => {
|
|
137
|
+
for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
|
|
138
|
+
const demand = demandOf({ rendered: rendered({ skills: ["a"] }), kind })
|
|
139
|
+
expect("skills" in demand).toBe(false)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Actor, ContentKind, RenderedDemandSource, StateName, StepAccess } from "./types.js"
|
|
2
|
+
|
|
3
|
+
/** The whole beat vocabulary a driver acts on — what to DO with a rest, not merely whether dispatching it is safe. */
|
|
4
|
+
export type BeatKind = "capture" | "message" | "script" | "prompt" | "stalled"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `BeatKind`'s own member list, as a value — the ONE place the five kinds are
|
|
8
|
+
* enumerated. Every test that needs to loop "every kind" (the JSON document,
|
|
9
|
+
* `DEMAND_BRIEFING`, the plain header) imports this instead of re-typing its
|
|
10
|
+
* own `as const satisfies readonly BeatKind[]` literal, so the type and the
|
|
11
|
+
* loop can never drift apart.
|
|
12
|
+
*/
|
|
13
|
+
export const BEAT_KINDS: readonly BeatKind[] = ["capture", "message", "script", "prompt", "stalled"]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A resting content kind: `renderRest` never resolves a `Rest` at a commit
|
|
17
|
+
* state, so `beatKindOf` is never handed `"commit"` — narrowing the
|
|
18
|
+
* parameter here (rather than switching on all four `ContentKind`s) makes
|
|
19
|
+
* that case unwritable instead of merely untested.
|
|
20
|
+
*/
|
|
21
|
+
export type RestingContentKind = Exclude<ContentKind, "commit">
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The beat kind for a rest, in precedence order:
|
|
25
|
+
*
|
|
26
|
+
* 1. `stalled` — `Edge.ts`'s `stalledAt` verdict, passed in as a plain
|
|
27
|
+
* boolean (this module never resolves it itself). `stalledAt` already
|
|
28
|
+
* requires a clean tree, so this can never collide with `capture` below.
|
|
29
|
+
* 2. `capture` — a `message` rest with a DIRTY tree: the human already
|
|
30
|
+
* acted; a driver lands it immediately.
|
|
31
|
+
* 3. Otherwise, the content kind verbatim (`script`/`prompt`, or `message`
|
|
32
|
+
* on a clean tree).
|
|
33
|
+
*/
|
|
34
|
+
export const beatKindOf = (input: {
|
|
35
|
+
readonly contentKind: RestingContentKind
|
|
36
|
+
readonly dirty: boolean
|
|
37
|
+
readonly stalled: boolean
|
|
38
|
+
}): BeatKind => {
|
|
39
|
+
if (input.stalled) return "stalled"
|
|
40
|
+
if (input.contentKind === "message" && input.dirty) return "capture"
|
|
41
|
+
return input.contentKind
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The `stalled` beat's own content: a diagnosis naming the stuck step and
|
|
46
|
+
* the ways out. The first line is shaped so a stderr grep for
|
|
47
|
+
* `stalled at "<state>"` stays a stable substring (see
|
|
48
|
+
* `tests/integration/features/driver-doc.feature`).
|
|
49
|
+
*/
|
|
50
|
+
export const stallDiagnosis = (state: StateName, actor: Actor): string =>
|
|
51
|
+
`stalled at "${state}": the last gtd(${actor}): ${state} turn landed an empty ` +
|
|
52
|
+
`attempt, the tree is clean, and another dispatch would repeat it.\n\n` +
|
|
53
|
+
`Two ways out:\n` +
|
|
54
|
+
` - sharpen the step's prompt so the turn has something concrete to author\n` +
|
|
55
|
+
` - pass allowEmpty: true to the step, so an empty turn completes it and the\n` +
|
|
56
|
+
` flow decides what follows — finishing, or an escalation step after N\n` +
|
|
57
|
+
` empty turns\n`
|
|
58
|
+
|
|
59
|
+
/** One dispatch session — `resume`'s own meaning lives with `src/wire/Sessions.ts`'s `resolveSession`, which produces it. */
|
|
60
|
+
export interface DemandSession {
|
|
61
|
+
readonly id: string
|
|
62
|
+
readonly resume: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One driver `case` arm's whole demand: what to DO, and (only at `prompt`)
|
|
67
|
+
* the dispatch bookkeeping (`session`/`validate`) no other kind carries.
|
|
68
|
+
* Replaces `BeatFields`' flat `session`/`validate` fields (gated at
|
|
69
|
+
* `kind === "prompt"` by a runtime check) with a shape where a non-`prompt`
|
|
70
|
+
* variant cannot HOLD a session at all.
|
|
71
|
+
*/
|
|
72
|
+
export type Demand =
|
|
73
|
+
| { readonly kind: "capture"; readonly content: string }
|
|
74
|
+
| { readonly kind: "message"; readonly content: string }
|
|
75
|
+
| { readonly kind: "script"; readonly content: string }
|
|
76
|
+
| { readonly kind: "stalled"; readonly content: string }
|
|
77
|
+
| {
|
|
78
|
+
readonly kind: "prompt"
|
|
79
|
+
readonly content: string
|
|
80
|
+
readonly session: DemandSession | undefined
|
|
81
|
+
readonly validate: string | undefined
|
|
82
|
+
readonly skills: readonly string[] | undefined
|
|
83
|
+
readonly access: StepAccess
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Assemble one `Demand` — the ONLY place a rest's resolved content becomes
|
|
88
|
+
* `Demand.content` (`stallDiagnosis` at `kind === "stalled"`, the rendered
|
|
89
|
+
* content otherwise), and the only place `session`/`validate`/`skills`/`access` are
|
|
90
|
+
* attached, which the `prompt` variant's own type makes impossible to do at
|
|
91
|
+
* any other kind.
|
|
92
|
+
*/
|
|
93
|
+
export const demandOf = (input: {
|
|
94
|
+
readonly rendered: Pick<RenderedDemandSource, "actor" | "content" | "state" | "skills" | "access">
|
|
95
|
+
readonly kind: BeatKind
|
|
96
|
+
readonly session?: DemandSession
|
|
97
|
+
readonly validate?: string
|
|
98
|
+
}): Demand => {
|
|
99
|
+
const { rendered, kind, session, validate } = input
|
|
100
|
+
const content =
|
|
101
|
+
kind === "stalled" ? stallDiagnosis(rendered.state, rendered.actor) : rendered.content
|
|
102
|
+
const skills =
|
|
103
|
+
rendered.skills !== undefined && rendered.skills.length > 0 ? rendered.skills : undefined
|
|
104
|
+
const access = rendered.access ?? { read: null, write: null }
|
|
105
|
+
return kind === "prompt"
|
|
106
|
+
? { kind, content, session, validate, skills, access }
|
|
107
|
+
: { kind, content }
|
|
108
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
EXIT_CODES,
|
|
4
|
+
EXIT_OK,
|
|
5
|
+
EXIT_RUNTIME_ERROR,
|
|
6
|
+
EXIT_SIGINT,
|
|
7
|
+
EXIT_SIGTERM,
|
|
8
|
+
EXIT_USAGE_ERROR,
|
|
9
|
+
} from "./index.js"
|
|
10
|
+
|
|
11
|
+
describe("EXIT_CODES", () => {
|
|
12
|
+
it("is frozen at exactly {0, 1, 2, 130, 143} — a new code breaks every driver", () => {
|
|
13
|
+
expect(EXIT_CODES).toEqual(new Set([0, 1, 2, 130, 143]))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("each named constant is a member of the set", () => {
|
|
17
|
+
for (const code of [EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR, EXIT_SIGINT, EXIT_SIGTERM]) {
|
|
18
|
+
expect(EXIT_CODES.has(code)).toBe(true)
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const EXIT_OK = 0
|
|
2
|
+
/** Something failed that was never a matter of how gtd was invoked — a refusal or an unexpected error. */
|
|
3
|
+
export const EXIT_RUNTIME_ERROR = 1
|
|
4
|
+
/** gtd was invoked wrong — an unknown option/command, bad arity, a scope violation, a decode failure. */
|
|
5
|
+
export const EXIT_USAGE_ERROR = 2
|
|
6
|
+
export const EXIT_SIGINT = 130
|
|
7
|
+
export const EXIT_SIGTERM = 143
|
|
8
|
+
|
|
9
|
+
/** Frozen: adding a code is a driver-protocol break, the same as removing a wire field. */
|
|
10
|
+
export const EXIT_CODES: ReadonlySet<number> = new Set([
|
|
11
|
+
EXIT_OK,
|
|
12
|
+
EXIT_RUNTIME_ERROR,
|
|
13
|
+
EXIT_USAGE_ERROR,
|
|
14
|
+
EXIT_SIGINT,
|
|
15
|
+
EXIT_SIGTERM,
|
|
16
|
+
])
|
package/src/wire/Glob.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// `*` stays within one path segment, `**` crosses any number of them (zero
|
|
2
|
+
// included), and dotfiles are not special: this matches repository paths, not
|
|
3
|
+
// a shell's filename expansion.
|
|
4
|
+
|
|
5
|
+
const ESCAPE_RE = /[.+^${}()|[\]\\?]/g
|
|
6
|
+
|
|
7
|
+
const compile = (glob: string): RegExp => {
|
|
8
|
+
let pattern = "^"
|
|
9
|
+
let i = 0
|
|
10
|
+
while (i < glob.length) {
|
|
11
|
+
const char = glob[i]!
|
|
12
|
+
if (char !== "*") {
|
|
13
|
+
pattern += char.replace(ESCAPE_RE, "\\$&")
|
|
14
|
+
i += 1
|
|
15
|
+
} else if (glob[i + 1] !== "*") {
|
|
16
|
+
pattern += "[^/]*"
|
|
17
|
+
i += 1
|
|
18
|
+
} else if (glob[i + 2] === "/") {
|
|
19
|
+
pattern += "(?:.*/)?"
|
|
20
|
+
i += 3
|
|
21
|
+
} else {
|
|
22
|
+
pattern += ".*"
|
|
23
|
+
i += 2
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return new RegExp(`${pattern}$`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const globMatches = (path: string, glob: string): boolean => compile(glob).test(path)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { WIRE_SCHEMA } from "./constants.js"
|
|
2
|
+
|
|
3
|
+
export type JudgePrimitive = "noul" | "choice" | "score"
|
|
4
|
+
|
|
5
|
+
export interface JudgeQuestion {
|
|
6
|
+
readonly id: string
|
|
7
|
+
readonly primitive: JudgePrimitive
|
|
8
|
+
readonly instructions: string
|
|
9
|
+
readonly criteria: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Everything a judge needs: a judge gate's beat carries it, stringified, in `judge`; `gtd judge run` reads it on stdin. */
|
|
13
|
+
export interface JudgeDocument {
|
|
14
|
+
readonly schema: number
|
|
15
|
+
readonly state: Readonly<Record<string, string>>
|
|
16
|
+
readonly questions: readonly JudgeQuestion[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const judgeJson = (
|
|
20
|
+
state: JudgeDocument["state"],
|
|
21
|
+
questions: JudgeDocument["questions"],
|
|
22
|
+
): string => JSON.stringify({ schema: WIRE_SCHEMA, state, questions } satisfies JudgeDocument)
|