@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,260 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process"
|
|
2
|
+
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { describe, expect, it } from "vitest"
|
|
6
|
+
import fc from "fast-check"
|
|
7
|
+
import {
|
|
8
|
+
commitAll,
|
|
9
|
+
commitAsIs,
|
|
10
|
+
deleteRef,
|
|
11
|
+
discardPending,
|
|
12
|
+
hardResetTo,
|
|
13
|
+
mixedResetTo,
|
|
14
|
+
pathspec,
|
|
15
|
+
ScriptSurface,
|
|
16
|
+
shellQuote,
|
|
17
|
+
softResetTo,
|
|
18
|
+
updateRef,
|
|
19
|
+
} from "./GitScript.js"
|
|
20
|
+
import type { LandStep } from "../step/index.js"
|
|
21
|
+
|
|
22
|
+
const runBashCheckSyntax = (script: string): number => {
|
|
23
|
+
try {
|
|
24
|
+
execFileSync("bash", ["-n"], { input: script })
|
|
25
|
+
return 0
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return (error as { status?: number }).status ?? 1
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A fake `git` on `PATH` that behaves per invocation count (tracked in a
|
|
33
|
+
* counter file), so a script's real retry/discrimination logic can be
|
|
34
|
+
* exercised against real bash without a real repo. `firstBehavior`/
|
|
35
|
+
* `secondBehavior` are shell snippets run in place of the real command.
|
|
36
|
+
*/
|
|
37
|
+
const withFakeGit = (
|
|
38
|
+
firstBehavior: string,
|
|
39
|
+
secondBehavior = `exit 0`,
|
|
40
|
+
): { readonly binDir: string; readonly counterFile: string } => {
|
|
41
|
+
const binDir = mkdtempSync(join(tmpdir(), "gitscript-fakebin-"))
|
|
42
|
+
const counterFile = join(binDir, "count")
|
|
43
|
+
const gitPath = join(binDir, "git")
|
|
44
|
+
writeFileSync(
|
|
45
|
+
gitPath,
|
|
46
|
+
[
|
|
47
|
+
"#!/bin/bash",
|
|
48
|
+
`count=$(( $(cat '${counterFile}' 2>/dev/null || echo 0) + 1 ))`,
|
|
49
|
+
`echo "$count" > '${counterFile}'`,
|
|
50
|
+
`if [ "$count" -eq 1 ]; then`,
|
|
51
|
+
` ${firstBehavior}`,
|
|
52
|
+
`else`,
|
|
53
|
+
` ${secondBehavior}`,
|
|
54
|
+
`fi`,
|
|
55
|
+
].join("\n"),
|
|
56
|
+
{ mode: 0o755 },
|
|
57
|
+
)
|
|
58
|
+
chmodSync(gitPath, 0o755)
|
|
59
|
+
return { binDir, counterFile }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const runWithFakeGit = (script: string, binDir: string): { status: number | null } =>
|
|
63
|
+
spawnSync("bash", ["-c", script], {
|
|
64
|
+
env: { ...process.env, PATH: `${binDir}:${process.env["PATH"]}` },
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const allBuilders: ReadonlyArray<[string, string]> = [
|
|
69
|
+
["commitAll", commitAll("gtd(agent): working")],
|
|
70
|
+
["commitAsIs", commitAsIs("gtd(agent): working")],
|
|
71
|
+
["softResetTo", softResetTo("HEAD~1")],
|
|
72
|
+
["mixedResetTo", mixedResetTo("HEAD~1")],
|
|
73
|
+
["hardResetTo", hardResetTo("HEAD~1")],
|
|
74
|
+
["discardPending", discardPending()],
|
|
75
|
+
["updateRef", updateRef("refs/worktree/gtd/review-head", "abc123")],
|
|
76
|
+
["deleteRef", deleteRef("refs/worktree/gtd/review-head")],
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
describe("commitAll", () => {
|
|
80
|
+
it("stages everything then commits with --allow-empty, retrying without hooks", () => {
|
|
81
|
+
const script = commitAll("gtd(agent): working")
|
|
82
|
+
expect(script).toContain("git add -A")
|
|
83
|
+
expect(script).toContain("git commit --allow-empty -m 'gtd(agent): working'")
|
|
84
|
+
expect(script).toContain("--no-verify")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("short-circuits: a failed git add never reaches the commit", () => {
|
|
88
|
+
const { binDir, counterFile } = withFakeGit(`exit 1`)
|
|
89
|
+
const result = runWithFakeGit(commitAll("msg"), binDir)
|
|
90
|
+
expect(result.status).not.toBe(0)
|
|
91
|
+
expect(execFileSync("cat", [counterFile], { encoding: "utf8" }).trim()).toBe("1")
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("commitAsIs", () => {
|
|
96
|
+
it("commits the staged index as-is with --allow-empty, without an add", () => {
|
|
97
|
+
const script = commitAsIs("gtd(agent): working")
|
|
98
|
+
expect(script).not.toContain("git add -A")
|
|
99
|
+
expect(script).toContain("git commit --allow-empty -m 'gtd(agent): working'")
|
|
100
|
+
expect(script).toContain("--no-verify")
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe("commitAllowEmpty retry discrimination (via commitAsIs)", () => {
|
|
105
|
+
it("retries without hooks on the hook's own empty-git-commit rejection", () => {
|
|
106
|
+
const { binDir, counterFile } = withFakeGit(
|
|
107
|
+
`echo "fatal: rejecting an empty git commit" >&2; exit 1`,
|
|
108
|
+
`case "$*" in *--no-verify*) exit 0 ;; *) exit 1 ;; esac`,
|
|
109
|
+
)
|
|
110
|
+
const result = runWithFakeGit(commitAsIs("msg"), binDir)
|
|
111
|
+
expect(result.status).toBe(0)
|
|
112
|
+
expect(execFileSync("cat", [counterFile], { encoding: "utf8" }).trim()).toBe("2")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("does NOT retry — and stays failed — on a non-hook commit failure", () => {
|
|
116
|
+
const { binDir, counterFile } = withFakeGit(`echo "some other failure" >&2; exit 1`)
|
|
117
|
+
const result = runWithFakeGit(commitAsIs("msg"), binDir)
|
|
118
|
+
expect(result.status).not.toBe(0)
|
|
119
|
+
expect(execFileSync("cat", [counterFile], { encoding: "utf8" }).trim()).toBe("1")
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe("softResetTo", () => {
|
|
124
|
+
it("emits git reset --soft <ref>", () => {
|
|
125
|
+
expect(softResetTo("HEAD~1")).toBe("git reset --soft 'HEAD~1'")
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe("mixedResetTo", () => {
|
|
130
|
+
it("emits git reset --mixed <ref>", () => {
|
|
131
|
+
expect(mixedResetTo("HEAD~1")).toBe("git reset --mixed 'HEAD~1'")
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe("hardResetTo", () => {
|
|
136
|
+
it("emits git reset --hard <ref>", () => {
|
|
137
|
+
expect(hardResetTo("HEAD~1")).toBe("git reset --hard 'HEAD~1'")
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe("discardPending", () => {
|
|
142
|
+
it("stages everything then hard-resets to HEAD", () => {
|
|
143
|
+
const script = discardPending()
|
|
144
|
+
expect(script).toContain("git add -A")
|
|
145
|
+
expect(script).toContain("git reset --hard HEAD")
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("short-circuits: a failed git add never reaches the hard reset", () => {
|
|
149
|
+
const { binDir, counterFile } = withFakeGit(`exit 1`)
|
|
150
|
+
const result = runWithFakeGit(discardPending(), binDir)
|
|
151
|
+
expect(result.status).not.toBe(0)
|
|
152
|
+
expect(execFileSync("cat", [counterFile], { encoding: "utf8" }).trim()).toBe("1")
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe("updateRef", () => {
|
|
157
|
+
it("emits git update-ref <ref> <hash>", () => {
|
|
158
|
+
expect(updateRef("refs/worktree/gtd/review-head", "abc123")).toBe(
|
|
159
|
+
"git update-ref 'refs/worktree/gtd/review-head' 'abc123'",
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe("deleteRef", () => {
|
|
165
|
+
it("emits git update-ref -d <ref> verbatim (tolerant of a missing ref already in real git)", () => {
|
|
166
|
+
expect(deleteRef("refs/worktree/gtd/review-head")).toBe(
|
|
167
|
+
"git update-ref -d 'refs/worktree/gtd/review-head'",
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe("pathspec", () => {
|
|
173
|
+
it("quotes every path and space-joins them", () => {
|
|
174
|
+
expect(pathspec(["src/a.ts", "src/b.ts"])).toBe("'src/a.ts' 'src/b.ts'")
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("returns an empty string for an empty list", () => {
|
|
178
|
+
expect(pathspec([])).toBe("")
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe("shellQuote", () => {
|
|
183
|
+
it.each([
|
|
184
|
+
["a newline", "gtd(agent): line one\nline two"],
|
|
185
|
+
["a backtick", "gtd(agent): `echo hi`"],
|
|
186
|
+
["a double quote", 'gtd(agent): say "hi"'],
|
|
187
|
+
["a dollar sign", "gtd(agent): $HOME expansion"],
|
|
188
|
+
["an arrow", "gtd(agent): design.product-author → design.product-answer"],
|
|
189
|
+
["a single quote", "gtd(agent): it's done"],
|
|
190
|
+
["a command substitution", "$(touch PWNED_MARKER)"],
|
|
191
|
+
["a semicolon-chained command", "host; touch PWNED_MARKER"],
|
|
192
|
+
])("round-trips a message containing %s", (_label, s) => {
|
|
193
|
+
const out = execFileSync("bash", ["-c", `printf %s ${shellQuote(s)}`], { encoding: "utf8" })
|
|
194
|
+
expect(out).toBe(s)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// 500 samples through 500 bash spawns starved the 30s test timeout whenever
|
|
198
|
+
// the suite ran under turbo's parallel task load. The property is unchanged;
|
|
199
|
+
// all samples now round-trip through a SINGLE bash, NUL-delimited on the way
|
|
200
|
+
// back out (NUL is filtered from the inputs, so it can't collide).
|
|
201
|
+
it("round-trips arbitrary strings — including newlines and non-ASCII — through a real bash printf", () => {
|
|
202
|
+
const samples = fc.sample(
|
|
203
|
+
// `unit: "binary"` covers the full Unicode code-point range (control
|
|
204
|
+
// chars, newlines, non-ASCII) in one code point per unit, unlike the
|
|
205
|
+
// default `"grapheme-ascii"` unit fast-check 4.8 otherwise samples,
|
|
206
|
+
// which never produces a newline or a non-ASCII byte. NUL can't
|
|
207
|
+
// survive an argv round-trip (bash/exec truncate at the first NUL),
|
|
208
|
+
// so it's filtered out here deliberately rather than left to chance.
|
|
209
|
+
fc.string({ unit: "binary" }).filter((s) => !s.includes("\0")),
|
|
210
|
+
500,
|
|
211
|
+
)
|
|
212
|
+
const script = samples.map((s) => `printf '%s\\0' ${shellQuote(s)}`).join("\n")
|
|
213
|
+
const out = execFileSync("bash", ["-c", script], { encoding: "utf8" })
|
|
214
|
+
expect(out.split("\0").slice(0, -1)).toEqual(samples)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe("every builder's output", () => {
|
|
219
|
+
it.each(allBuilders)("%s is syntactically valid bash", (_name, script) => {
|
|
220
|
+
expect(runBashCheckSyntax(script)).toBe(0)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe("ScriptSurface.render", () => {
|
|
225
|
+
const steps: readonly LandStep[] = [
|
|
226
|
+
{ kind: "command", command: `echo hi` },
|
|
227
|
+
{ kind: "gitWrite", write: { kind: "commitAll", message: "gtd(human): building" } },
|
|
228
|
+
{ kind: "outcome", outcome: { kind: "commit", subject: "gtd(human): building" } },
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
it("renders every step in order ", () => {
|
|
232
|
+
const script = ScriptSurface.render(steps)
|
|
233
|
+
expect(script).toContain("set -eu")
|
|
234
|
+
expect(script).toContain("echo hi")
|
|
235
|
+
expect(script).toContain("git add -A")
|
|
236
|
+
expect(script).toContain("# gtd: outcome (print-only)")
|
|
237
|
+
expect(runBashCheckSyntax(script)).toBe(0)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("renders the empty script for no steps", () => {
|
|
241
|
+
expect(ScriptSurface.render([])).toBe("")
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("renders a `command` step's onFailure wrapper", () => {
|
|
245
|
+
const script = ScriptSurface.render([
|
|
246
|
+
{ kind: "command", command: "false", onFailure: "custom prompt" },
|
|
247
|
+
])
|
|
248
|
+
expect(script).toContain("custom prompt")
|
|
249
|
+
expect(runBashCheckSyntax(script)).toBe(0)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("renders a transition outcome naming both states", () => {
|
|
253
|
+
const script = ScriptSurface.render([
|
|
254
|
+
{ kind: "outcome", outcome: { kind: "transition", from: "building", to: "done" } },
|
|
255
|
+
])
|
|
256
|
+
expect(script).toContain("building")
|
|
257
|
+
expect(script).toContain("done")
|
|
258
|
+
expect(runBashCheckSyntax(script)).toBe(0)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { GitWrite, LandStep, Outcome } from "../step/index.js"
|
|
2
|
+
|
|
3
|
+
// POSIX single-quote escaping for a shell command; every builder below routes its interpolated values through this.
|
|
4
|
+
export const shellQuote = (value: string): string => `'${value.replace(/'/g, "'\\''")}'`
|
|
5
|
+
|
|
6
|
+
export const pathspec = (paths: ReadonlyArray<string>): string => paths.map(shellQuote).join(" ")
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `--allow-empty` because gtd's workflow commits may be empty on purpose.
|
|
10
|
+
* Mirrors `Git.ts`'s retry: only re-tries without hooks on the specific
|
|
11
|
+
* "empty git commit" rejection message, discriminated from the captured
|
|
12
|
+
* output rather than the exit code, so a genuinely rejected commit (a lint
|
|
13
|
+
* error, a commit-msg hook) still fails.
|
|
14
|
+
*/
|
|
15
|
+
const commitAllowEmpty = (message: string): string => {
|
|
16
|
+
const m = shellQuote(message)
|
|
17
|
+
return [
|
|
18
|
+
`if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
|
|
19
|
+
` case "$out" in`,
|
|
20
|
+
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
|
|
21
|
+
` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
|
|
22
|
+
` esac`,
|
|
23
|
+
`fi`,
|
|
24
|
+
].join("\n")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `git add -A` then `commitAllowEmpty`, joined with `&&` so a failing `git add` never reaches the commit. */
|
|
28
|
+
export const commitAll = (message: string): string => `git add -A &&\n${commitAllowEmpty(message)}`
|
|
29
|
+
|
|
30
|
+
/** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
|
|
31
|
+
export const commitAsIs = (message: string): string => commitAllowEmpty(message)
|
|
32
|
+
|
|
33
|
+
/** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
|
|
34
|
+
export const softResetTo = (ref: string): string => `git reset --soft ${shellQuote(ref)}`
|
|
35
|
+
|
|
36
|
+
export const mixedResetTo = (ref: string): string => `git reset --mixed ${shellQuote(ref)}`
|
|
37
|
+
|
|
38
|
+
export const hardResetTo = (ref: string): string => `git reset --hard ${shellQuote(ref)}`
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `git add -A` then `git reset --hard HEAD` — discards every pending change,
|
|
42
|
+
* tracked or untracked. Joined with `&&`: a failed stage must not reach the
|
|
43
|
+
* hard reset, or untracked survivors remain — the exact outcome this builder
|
|
44
|
+
* exists to avoid. No current caller — see `Git.ts`'s
|
|
45
|
+
* `GitWriterOperations.discardPending` for why it's kept anyway.
|
|
46
|
+
*/
|
|
47
|
+
export const discardPending = (): string => `git add -A && git reset --hard HEAD`
|
|
48
|
+
|
|
49
|
+
export const updateRef = (ref: string, hash: string): string =>
|
|
50
|
+
`git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`
|
|
51
|
+
|
|
52
|
+
/** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
|
|
53
|
+
export const deleteRef = (ref: string): string => `git update-ref -d ${shellQuote(ref)}`
|
|
54
|
+
|
|
55
|
+
// ── ScriptSurface: LandStep[] → shell ────────────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// The sole place `src/step/`'s `LandStep` data becomes runnable shell text.
|
|
58
|
+
// Deliberately does NOT import `src/emit/OutcomeScript.ts`/`src/emit/Emit.ts` (both
|
|
59
|
+
// import `shellQuote` from this file already) — a reverse import back into
|
|
60
|
+
// either would be a real cycle, not just a lint nit, so the two `printf`
|
|
61
|
+
// builders below are a small, load-bearing duplication of
|
|
62
|
+
// `OutcomeScript.ts`'s `transitionOutcome`/`commitOutcome`, not shared code.
|
|
63
|
+
// No test asserts the two stay byte-identical; a drift between them is a
|
|
64
|
+
// review-time risk this boundary accepts.
|
|
65
|
+
|
|
66
|
+
/** Marks an emitted block as print-only — mirrors `OutcomeScript.ts`'s `OUTCOME_MARKER` verbatim, so `src/testkit/EmittedScriptRecognizer.ts` recognizes either path's output identically. */
|
|
67
|
+
const OUTCOME_MARKER = "# gtd: outcome (print-only)"
|
|
68
|
+
|
|
69
|
+
const printfLine = (fmt: string, args: readonly string[]): string =>
|
|
70
|
+
`${OUTCOME_MARKER}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`
|
|
71
|
+
|
|
72
|
+
const renderOutcome = (outcome: Outcome): string => {
|
|
73
|
+
switch (outcome.kind) {
|
|
74
|
+
case "transition":
|
|
75
|
+
return printfLine("%s %s → %s\n", ["'->'", shellQuote(outcome.from), shellQuote(outcome.to)])
|
|
76
|
+
case "commit":
|
|
77
|
+
return printfLine("%s %s\n", ["'[commit]'", shellQuote(outcome.subject)])
|
|
78
|
+
case "note":
|
|
79
|
+
return printfLine("%s\n", [shellQuote(outcome.text)])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const renderGitWrite = (write: GitWrite): string => {
|
|
84
|
+
switch (write.kind) {
|
|
85
|
+
case "commitAll":
|
|
86
|
+
return commitAll(write.message)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wraps `command` so a non-zero exit prints `prompt` plus the command's
|
|
92
|
+
* captured output before propagating that exit code — mirrors `Emit.ts`'s
|
|
93
|
+
* `failurePromptWrapper` (same reverse-import concern as `renderOutcome`
|
|
94
|
+
* above: `Emit.ts` imports `shellQuote` from here already).
|
|
95
|
+
*/
|
|
96
|
+
const failurePromptWrapper = (command: string, prompt: string): string => {
|
|
97
|
+
const promptQ = shellQuote(prompt)
|
|
98
|
+
return [
|
|
99
|
+
`gtd_validate_status=0`,
|
|
100
|
+
`gtd_validate_out="$( {`,
|
|
101
|
+
command,
|
|
102
|
+
`} 2>&1 )" || gtd_validate_status=$?`,
|
|
103
|
+
`if [ "$gtd_validate_status" -ne 0 ]; then`,
|
|
104
|
+
` printf '%s\\n\\n%s\\n' ${promptQ} "$gtd_validate_out"`,
|
|
105
|
+
` exit "$gtd_validate_status"`,
|
|
106
|
+
`fi`,
|
|
107
|
+
].join("\n")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const renderLandStep = (step: LandStep): string => {
|
|
111
|
+
if (step.kind === "gitWrite") return renderGitWrite(step.write)
|
|
112
|
+
if (step.kind === "outcome") return renderOutcome(step.outcome)
|
|
113
|
+
if (step.kind === "uncheck") return `gtd uncheck ${shellQuote(step.file)}`
|
|
114
|
+
return step.onFailure !== undefined
|
|
115
|
+
? failurePromptWrapper(step.command, step.onFailure)
|
|
116
|
+
: step.command
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A rendered landing script — a plain `string` at runtime, constructible only
|
|
121
|
+
* by `ScriptSurface.render`, so a bare string cannot reach `gtd land`'s
|
|
122
|
+
* emitted output by construction.
|
|
123
|
+
*/
|
|
124
|
+
export type RunnableScript = string & { readonly guarded: unique symbol }
|
|
125
|
+
|
|
126
|
+
export const ScriptSurface = {
|
|
127
|
+
render: (steps: readonly LandStep[]): RunnableScript => {
|
|
128
|
+
const rendered = steps.map(renderLandStep)
|
|
129
|
+
return (rendered.length === 0 ? "" : ["set -eu", ...rendered].join("\n\n")) as RunnableScript
|
|
130
|
+
},
|
|
131
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { describe, expect, it } from "vitest"
|
|
6
|
+
import {
|
|
7
|
+
buildModeContradictionCheck,
|
|
8
|
+
contradictionMessage,
|
|
9
|
+
modeContradictionSkipNotice,
|
|
10
|
+
} from "./ModeContradiction.js"
|
|
11
|
+
import { shellQuote } from "./index.js"
|
|
12
|
+
|
|
13
|
+
describe("buildModeContradictionCheck", () => {
|
|
14
|
+
const inputs = {
|
|
15
|
+
mode: "qa",
|
|
16
|
+
samplePath: "/fixture-scratch/gtd-mode-sample-qa-12345.md",
|
|
17
|
+
sample: "Sample plan.\n",
|
|
18
|
+
formatCommand: "npx oxfmt --write '/fixture-scratch/gtd-mode-sample-qa-12345.md'",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
it("pins the block's exact bytes against a path the test supplies", () => {
|
|
22
|
+
const block = buildModeContradictionCheck(inputs)
|
|
23
|
+
const pathQ = shellQuote(inputs.samplePath)
|
|
24
|
+
const messageQ = shellQuote(contradictionMessage(inputs.mode, inputs.formatCommand))
|
|
25
|
+
expect(block).toBe(
|
|
26
|
+
[
|
|
27
|
+
`printf '%s' ${shellQuote(inputs.sample)} > ${pathQ}`,
|
|
28
|
+
inputs.formatCommand,
|
|
29
|
+
`gtd check qa ${pathQ} >/dev/null 2>&1 || {`,
|
|
30
|
+
` printf '%s\\n' ${messageQ} >&2`,
|
|
31
|
+
` cat ${pathQ} >&2`,
|
|
32
|
+
` rm -f ${pathQ}`,
|
|
33
|
+
` exit 1`,
|
|
34
|
+
`}`,
|
|
35
|
+
`rm -f ${pathQ}`,
|
|
36
|
+
].join("\n"),
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("shell-quotes a sample containing single quotes and newlines safely", () => {
|
|
41
|
+
const sample = "a 'quoted' sample\nwith two lines\n"
|
|
42
|
+
const block = buildModeContradictionCheck({ ...inputs, sample })
|
|
43
|
+
expect(
|
|
44
|
+
block.startsWith(`printf '%s' ${shellQuote(sample)} > ${shellQuote(inputs.samplePath)}`),
|
|
45
|
+
).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe("contradictionMessage", () => {
|
|
50
|
+
const message = contradictionMessage("review", "npx oxfmt --write '<file>'")
|
|
51
|
+
|
|
52
|
+
it("names the mode", () => {
|
|
53
|
+
expect(message).toContain('"review"')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("tells the agent this is a configuration bug and not the steering file", () => {
|
|
57
|
+
expect(message.toLowerCase()).toContain("configuration bug")
|
|
58
|
+
expect(message).toContain("Do NOT edit the steering file")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("tells the agent to stop and end its turn", () => {
|
|
62
|
+
expect(message.toLowerCase()).toContain("stop and end your turn")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("names the exact rendered format: command", () => {
|
|
66
|
+
expect(message).toContain("npx oxfmt --write '<file>'")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("is not fixPromptInstruction's text and shares no wording that blames the turn", () => {
|
|
70
|
+
expect(message).not.toContain("Your last turn")
|
|
71
|
+
expect(message).not.toContain("Fix these format violations")
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe("modeContradictionSkipNotice", () => {
|
|
76
|
+
it("is a single printf-to-stderr line naming the mode", () => {
|
|
77
|
+
const line = modeContradictionSkipNotice("adr")
|
|
78
|
+
expect(line.split("\n").length).toBe(1)
|
|
79
|
+
expect(line).toContain(">&2")
|
|
80
|
+
expect(line).toContain("adr")
|
|
81
|
+
expect(line.toLowerCase()).toContain("skip")
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe("buildModeContradictionCheck, run for real (a stub `gtd check` standing in for a reflow-broken validator)", () => {
|
|
86
|
+
it("fails loudly, naming the mode and printing the rendered format: command, when the mode's validator rejects the (simulated) reflowed sample", () => {
|
|
87
|
+
const dir = mkdtempSync(join(tmpdir(), "gtd-mode-contradiction-run-"))
|
|
88
|
+
try {
|
|
89
|
+
// A stub `gtd` on PATH ahead of the real one: `check` always fails,
|
|
90
|
+
// standing in for a `format:` command whose reflow broke the sample.
|
|
91
|
+
writeFileSync(join(dir, "gtd"), '#!/bin/sh\nif [ "$1" = check ]; then exit 1; fi\nexit 0\n', {
|
|
92
|
+
mode: 0o755,
|
|
93
|
+
})
|
|
94
|
+
const samplePath = join(dir, "sample.md")
|
|
95
|
+
const script = buildModeContradictionCheck({
|
|
96
|
+
mode: "review",
|
|
97
|
+
samplePath,
|
|
98
|
+
sample: "irrelevant, only shape matters here",
|
|
99
|
+
formatCommand: "true", // the "format:" command itself is a no-op; the stub `gtd check` simulates the break it would cause
|
|
100
|
+
})
|
|
101
|
+
let result: { readonly status: number | null; readonly stderr: string }
|
|
102
|
+
try {
|
|
103
|
+
execFileSync("bash", ["-c", script], {
|
|
104
|
+
env: { ...process.env, PATH: `${dir}:${process.env.PATH}` },
|
|
105
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
106
|
+
})
|
|
107
|
+
result = { status: 0, stderr: "" }
|
|
108
|
+
} catch (error) {
|
|
109
|
+
const err = error as { status: number | null; stderr: Buffer }
|
|
110
|
+
result = { status: err.status, stderr: err.stderr.toString() }
|
|
111
|
+
}
|
|
112
|
+
expect(result.status).not.toBe(0)
|
|
113
|
+
expect(result.stderr).toContain('"review"')
|
|
114
|
+
expect(result.stderr).toContain("CONFIGURATION BUG")
|
|
115
|
+
expect(result.stderr).toContain("true") // the rendered format: command
|
|
116
|
+
} finally {
|
|
117
|
+
rmSync(dir, { recursive: true, force: true })
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { shellQuote } from "./GitScript.js"
|
|
2
|
+
|
|
3
|
+
/** Every value `buildModeContradictionCheck` needs, already resolved/rendered by the caller. */
|
|
4
|
+
export interface ModeContradictionInputs {
|
|
5
|
+
readonly mode: string
|
|
6
|
+
/** Absolute scratch path the sample is written to, formatted at, and removed from. */
|
|
7
|
+
readonly samplePath: string
|
|
8
|
+
/** The format's own canonical sample (`SteeringFormat.sample`), written verbatim via `printf '%s'`. */
|
|
9
|
+
readonly sample: string
|
|
10
|
+
/** The mode's `format:` command, run with `$GTD_FILE` set to `samplePath`. */
|
|
11
|
+
readonly formatCommand: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Written for two readers at once, since gtd validate's exit code carries no
|
|
16
|
+
* second meaning to distinguish them: the agent (this is a CONFIGURATION BUG,
|
|
17
|
+
* not the steering file — stop, don't edit it, or a fix-retry loop will
|
|
18
|
+
* thrash on the document) and the human (which mode, and the exact rendered
|
|
19
|
+
* `format:` command, to fix `gtd.config.ts` without reproducing the failure).
|
|
20
|
+
*/
|
|
21
|
+
export const contradictionMessage = (mode: string, formatCommand: string): string =>
|
|
22
|
+
`gtd: mode "${mode}"'s format: command breaks its own validator — this is a ` +
|
|
23
|
+
`CONFIGURATION BUG, not a problem with the steering file. Do NOT edit the ` +
|
|
24
|
+
`steering file — stop and end your turn now.\n` +
|
|
25
|
+
`The rendered format: command that ran:\n${formatCommand}\n` +
|
|
26
|
+
`What it turned mode "${mode}"'s own canonical sample into:`
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Formats a copy of the mode's own canonical sample and re-validates it with
|
|
30
|
+
* `gtd check <mode>`, failing loudly when the round-trip breaks it — detects a
|
|
31
|
+
* mode whose `format:` contradicts its own `validate:` without ever touching
|
|
32
|
+
* the real steering file. `printf '%s'` over a `shellQuote`d sample, never a
|
|
33
|
+
* heredoc, since no delimiter chosen ahead of time can collide with the
|
|
34
|
+
* sample's own bytes. One bash fragment with no blank lines (so
|
|
35
|
+
* `src/emit/Emit.ts`'s `assembleScript` never splits it from a sibling step), meant
|
|
36
|
+
* to run BEFORE `[ -f <file> ] || exit 0` in the emitted validate script so it
|
|
37
|
+
* still fires at a first-write beat where the real steering file doesn't
|
|
38
|
+
* exist yet.
|
|
39
|
+
*/
|
|
40
|
+
export const buildModeContradictionCheck = (inputs: ModeContradictionInputs): string => {
|
|
41
|
+
const { mode, samplePath, sample, formatCommand } = inputs
|
|
42
|
+
const pathQ = shellQuote(samplePath)
|
|
43
|
+
const messageQ = shellQuote(contradictionMessage(mode, formatCommand))
|
|
44
|
+
return [
|
|
45
|
+
`printf '%s' ${shellQuote(sample)} > ${pathQ}`,
|
|
46
|
+
formatCommand,
|
|
47
|
+
`gtd check ${mode} ${pathQ} >/dev/null 2>&1 || {`,
|
|
48
|
+
` printf '%s\\n' ${messageQ} >&2`,
|
|
49
|
+
` cat ${pathQ} >&2`,
|
|
50
|
+
` rm -f ${pathQ}`,
|
|
51
|
+
` exit 1`,
|
|
52
|
+
`}`,
|
|
53
|
+
`rm -f ${pathQ}`,
|
|
54
|
+
].join("\n")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Skip notice for a mode whose validator is EXTERNAL (a user `validate:`
|
|
59
|
+
* command, not one of the two built-in modes) — there's no in-process parser
|
|
60
|
+
* to round-trip a sample through. Printed to stderr since silence would read
|
|
61
|
+
* as a clean bill of health.
|
|
62
|
+
*/
|
|
63
|
+
export const modeContradictionSkipNotice = (mode: string): string => {
|
|
64
|
+
const message = `gtd: mode "${mode}" has an external validate: command — skipping the format/validate contradiction check`
|
|
65
|
+
return `printf '%s\\n' ${shellQuote(message)} >&2`
|
|
66
|
+
}
|