@pmelab/gtd 64.3.0 → 66.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +35 -25
- package/claude/hooks/assist.ts +9 -35
- package/claude/hooks/doors.ts +4 -4
- package/claude/hooks/drive.ts +10 -1
- package/claude/hooks/git.ts +56 -0
- package/claude/hooks/handoff.ts +11 -11
- package/claude/hooks/jobs.ts +8 -8
- package/claude/hooks/pr.ts +86 -26
- package/claude/hooks/register.tsx +35 -82
- package/claude/hooks/tools.ts +7 -20
- package/claude/hooks/wording.ts +3 -3
- package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
- package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
- package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
- package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +965 -163
- package/dist/gtd.core.mjs +150 -134
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +2 -2
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +185 -18
- package/dist/wire.mjs +1 -1
- package/dist/workflow-kit.mjs +9 -3
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.test.ts +10 -8
- package/src/cli/Metrics.test.ts +2 -1
- package/src/cli/Program.test.ts +4 -2
- package/src/cli/Program.ts +105 -7
- package/src/edge/Edge.test.ts +37 -16
- package/src/edge/Edge.ts +380 -33
- package/src/emit/GitScript.ts +116 -15
- package/src/emit/index.ts +7 -0
- package/src/flows/config.ts +7 -1
- package/src/flows/helpers.ts +36 -18
- package/src/flows/index.test.ts +4 -0
- package/src/flows/index.ts +7 -0
- package/src/flows/runtime.ts +153 -15
- package/src/install/Install.ts +9 -2
- package/src/platform/Git.test.ts +7 -11
- package/src/platform/NodeSync.ts +2 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/SyncIo.ts +2 -0
- package/src/platform/Workspace.test.ts +37 -0
- package/src/platform/Workspace.ts +36 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Replay.test.ts +9 -0
- package/src/replay/Replay.ts +92 -16
- package/src/replay/Trailers.ts +17 -0
- package/src/replay/Tree.ts +5 -0
- package/src/replay/index.ts +1 -1
- package/src/steering/FORMATS.md +3 -1
- package/src/steering/SteeringFormat.ts +13 -0
- package/src/steering/fixtures/review/sectioned.md +1 -1
- package/src/steering/fixtures/review/sectioned.read.json +0 -1
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +181 -5
- package/src/steering/review.ts +160 -18
- package/src/step/LandStep.ts +12 -0
- package/src/step/RepoSnapshot.ts +15 -0
- package/src/step/index.ts +1 -0
- package/src/step/planStart.test.ts +4 -3
- package/src/step/planStart.ts +7 -0
- package/src/step/planStep.ts +22 -17
- package/src/step/startBranch.ts +54 -0
- package/src/twins/EmittedScriptRecognizer.ts +118 -1
- package/src/twins/EmittedScripts.test.ts +147 -1
- package/src/twins/InMemRepo.ts +140 -3
- package/src/twins/Layers.ts +1 -0
- package/src/twins/SyncIo.test.ts +19 -4
- package/src/twins/SyncIo.ts +13 -1
- package/src/wire/BeatDocument.test.ts +15 -0
- package/src/wire/BeatDocument.ts +8 -2
- package/src/wire/Demand.ts +26 -12
- package/src/wire/constants.ts +3 -0
- package/src/wire/golden/1/beat-capture.json +1 -0
- package/src/wire/golden/1/beat-message.json +1 -0
- package/src/wire/golden/1/beat-prompt.json +1 -0
- package/src/wire/golden/1/beat-script.json +1 -0
- package/src/wire/golden/1/beat-stalled.json +1 -0
- package/src/wire/types.ts +4 -0
- package/src/workflow/Workflow.ts +3 -0
- package/src/workflow/compile.ts +2 -0
- package/src/workflow/load.ts +2 -0
- package/src/workflow-kit/index.test.ts +1 -0
- package/src/workflow-kit/index.ts +1 -0
- package/src/workflow-kit/scenarios.ts +8 -0
- package/claude/hooks/ship.ts +0 -730
|
@@ -108,7 +108,7 @@ describe("planStart", () => {
|
|
|
108
108
|
repo.seedCommit("feat: ahead of main")
|
|
109
109
|
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
|
|
110
110
|
if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
|
|
111
|
-
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
111
|
+
const write = plan.steps.find((s) => s.kind === "gitWrite" && s.write.kind === "commitAll")
|
|
112
112
|
if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
|
|
113
113
|
throw new Error("expected a gitWrite step")
|
|
114
114
|
expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
|
|
@@ -125,7 +125,7 @@ describe("planStart", () => {
|
|
|
125
125
|
repo.seedCommit("feat: my branch")
|
|
126
126
|
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "other" } })
|
|
127
127
|
if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
|
|
128
|
-
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
128
|
+
const write = plan.steps.find((s) => s.kind === "gitWrite" && s.write.kind === "commitAll")
|
|
129
129
|
if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
|
|
130
130
|
throw new Error("expected a gitWrite step")
|
|
131
131
|
expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
|
|
@@ -172,6 +172,7 @@ describe("planStart", () => {
|
|
|
172
172
|
if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
|
|
173
173
|
expect(plan.subject).toBe("gtd(human): work")
|
|
174
174
|
expect(plan.steps).toEqual([
|
|
175
|
+
{ kind: "gitWrite", write: { kind: "switchBranch", branch: "gtd/main" } },
|
|
175
176
|
{
|
|
176
177
|
kind: "gitWrite",
|
|
177
178
|
write: {
|
|
@@ -191,7 +192,7 @@ describe("planStart", () => {
|
|
|
191
192
|
vars: { base: "main" },
|
|
192
193
|
})
|
|
193
194
|
if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
|
|
194
|
-
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
195
|
+
const write = plan.steps.find((s) => s.kind === "gitWrite" && s.write.kind === "commitAll")
|
|
195
196
|
if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
|
|
196
197
|
throw new Error("expected a gitWrite step")
|
|
197
198
|
expect(write.write.message).toBe(
|
package/src/step/planStart.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../workflow/index.js"
|
|
12
12
|
import { formatCommitMessage, formatSubject } from "../replay/index.js"
|
|
13
13
|
import type { LandStep } from "./LandStep.js"
|
|
14
|
+
import { startBranchFor } from "./startBranch.js"
|
|
14
15
|
|
|
15
16
|
/** Just enough of the current rest for `planStart`'s checks. */
|
|
16
17
|
export interface StartCurrent {
|
|
@@ -87,6 +88,7 @@ export const planStart = (
|
|
|
87
88
|
Error,
|
|
88
89
|
GitService | ConfigService | ConfigDiscovery | Workspace | Host | Narrator
|
|
89
90
|
> =>
|
|
91
|
+
// fallow-ignore-next-line complexity
|
|
90
92
|
Effect.gen(function* () {
|
|
91
93
|
const { workflow, first, commandLabel, vars: varOverrides } = start
|
|
92
94
|
const name = workflow.name
|
|
@@ -134,7 +136,12 @@ export const planStart = (
|
|
|
134
136
|
...(base !== undefined ? { reviewBase: base } : {}),
|
|
135
137
|
vars,
|
|
136
138
|
})
|
|
139
|
+
const onBranch = yield* startBranchFor(commandLabel)
|
|
140
|
+
if (onBranch.kind === "refusal") return refusal(onBranch.message)
|
|
137
141
|
const steps: readonly LandStep[] = [
|
|
142
|
+
...(onBranch.kind === "switch"
|
|
143
|
+
? [{ kind: "gitWrite", write: { kind: "switchBranch", branch: onBranch.branch } } as const]
|
|
144
|
+
: []),
|
|
138
145
|
{ kind: "gitWrite", write: { kind: "commitAll", message } },
|
|
139
146
|
{ kind: "outcome", outcome: { kind: "commit", subject } },
|
|
140
147
|
]
|
package/src/step/planStep.ts
CHANGED
|
@@ -24,11 +24,11 @@ export type StepOutcome =
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* up — a tick is read-progress, never sign-off.
|
|
27
|
+
* Clear ticks at the human review gate, a tick is read-progress. Keep them when
|
|
28
|
+
* the next step is an agent turn: the agent reads them.
|
|
30
29
|
*/
|
|
31
|
-
const uncheckStep = (snapshot: RepoSnapshot): readonly LandStep[] =>
|
|
30
|
+
const uncheckStep = (snapshot: RepoSnapshot, keepTicks: boolean): readonly LandStep[] =>
|
|
31
|
+
!keepTicks &&
|
|
32
32
|
snapshot.stepDef.actor === "human" &&
|
|
33
33
|
snapshot.stepDef.mode === "review" &&
|
|
34
34
|
snapshot.file !== undefined
|
|
@@ -36,12 +36,16 @@ const uncheckStep = (snapshot: RepoSnapshot): readonly LandStep[] =>
|
|
|
36
36
|
: []
|
|
37
37
|
|
|
38
38
|
const gitWriteOf = (landing: Extract<Landing, { kind: "commit" }>, message: string): GitWrite => {
|
|
39
|
+
if (landing.ship !== undefined) return { kind: "ship", message, ...landing.ship }
|
|
39
40
|
if (landing.only !== undefined) return { kind: "commitPath", path: landing.only, message }
|
|
40
41
|
if (landing.keepTurn !== undefined) return { kind: "keepTurn", ref: landing.keepTurn, message }
|
|
41
42
|
if (landing.spec.approved !== undefined) return { kind: "commitClean", message }
|
|
42
43
|
return { kind: "commitAll", message }
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const writeIf = (value: string | undefined, write: (value: string) => GitWrite): LandStep[] =>
|
|
47
|
+
value === undefined ? [] : [{ kind: "gitWrite", write: write(value) }]
|
|
48
|
+
|
|
45
49
|
export const planStep = (
|
|
46
50
|
snapshot: RepoSnapshot,
|
|
47
51
|
opts: {
|
|
@@ -78,6 +82,7 @@ export const planStep = (
|
|
|
78
82
|
...(opts.judge !== undefined && opts.judge.length > 0 ? { judge: opts.judge } : {}),
|
|
79
83
|
})
|
|
80
84
|
const subject = message.split("\n")[0]!
|
|
85
|
+
const historyRef = landing.keepHistory ?? landing.ship?.historyRef ?? landing.recordedHistory
|
|
81
86
|
const outcome: LandStep =
|
|
82
87
|
landing.to === snapshot.state
|
|
83
88
|
? { kind: "outcome", outcome: { kind: "commit", subject } }
|
|
@@ -88,20 +93,20 @@ export const planStep = (
|
|
|
88
93
|
to: landing.to,
|
|
89
94
|
subject,
|
|
90
95
|
steps: [
|
|
91
|
-
...
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
},
|
|
96
|
-
...(landing.keepHistory !== undefined
|
|
97
|
-
? [
|
|
98
|
-
{
|
|
99
|
-
kind: "gitWrite",
|
|
100
|
-
write: { kind: "updateRef", ref: landing.keepHistory, target: "HEAD" },
|
|
101
|
-
} as const,
|
|
102
|
-
]
|
|
103
|
-
: []),
|
|
96
|
+
...writeIf(landing.startBranch, (branch) => ({ kind: "switchBranch", branch })),
|
|
97
|
+
...uncheckStep(snapshot, landing.keepTicks === true),
|
|
98
|
+
{ kind: "gitWrite", write: gitWriteOf(landing, message) },
|
|
99
|
+
...writeIf(historyRef, (ref) => ({ kind: "updateRef", ref, target: "HEAD" })),
|
|
100
|
+
...writeIf(landing.leaveBranch, (branch) => ({ kind: "switchTo", branch })),
|
|
104
101
|
outcome,
|
|
102
|
+
...(landing.note !== undefined
|
|
103
|
+
? [{ kind: "outcome", outcome: { kind: "note", text: landing.note } } as const]
|
|
104
|
+
: []),
|
|
105
|
+
...writeIf(historyRef !== undefined ? landing.pushHistory : undefined, (remote) => ({
|
|
106
|
+
kind: "pushRef",
|
|
107
|
+
remote,
|
|
108
|
+
ref: historyRef!,
|
|
109
|
+
})),
|
|
105
110
|
],
|
|
106
111
|
}
|
|
107
112
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Effect, Option } from "effect"
|
|
2
|
+
import { GitService } from "../platform/index.js"
|
|
3
|
+
import { PROCESS_HISTORY_PREFIX } from "../platform/index.js"
|
|
4
|
+
|
|
5
|
+
export const GTD_BRANCH_PREFIX = "gtd/"
|
|
6
|
+
|
|
7
|
+
/** `<b>` for a `gtd/<b>` branch name; `undefined` for any other. */
|
|
8
|
+
export const userBranchOf = (branch: string): string | undefined =>
|
|
9
|
+
branch.startsWith(GTD_BRANCH_PREFIX) ? branch.slice(GTD_BRANCH_PREFIX.length) : undefined
|
|
10
|
+
|
|
11
|
+
/** A branch already named `gtd/…` and a detached HEAD start in place. */
|
|
12
|
+
export type StartBranch =
|
|
13
|
+
| { readonly kind: "inPlace" }
|
|
14
|
+
| { readonly kind: "switch"; readonly branch: string }
|
|
15
|
+
| { readonly kind: "refusal"; readonly message: string }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where a new process starts: on `gtd/<b>`, so `<b>` never carries its commits.
|
|
19
|
+
* An existing `gtd/<b>` is reused only when its tip is contained in `<b>` or in
|
|
20
|
+
* a kept history ref (a squash onto `<b>` never makes it an ancestor).
|
|
21
|
+
*/
|
|
22
|
+
export const startBranchFor = (
|
|
23
|
+
commandLabel: string,
|
|
24
|
+
): Effect.Effect<StartBranch, Error, GitService> =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const git = yield* GitService
|
|
27
|
+
const current = yield* git.currentBranch()
|
|
28
|
+
if (Option.isNone(current) || current.value.startsWith(GTD_BRANCH_PREFIX)) {
|
|
29
|
+
return { kind: "inPlace" } as const
|
|
30
|
+
}
|
|
31
|
+
if (!(yield* git.hasCommits())) return { kind: "inPlace" } as const
|
|
32
|
+
const branch = `${GTD_BRANCH_PREFIX}${current.value}`
|
|
33
|
+
const existing = yield* git.readRefOption(`refs/heads/${branch}`)
|
|
34
|
+
if (Option.isSome(existing)) {
|
|
35
|
+
const head = yield* git.resolveRef("HEAD")
|
|
36
|
+
let contained = yield* git.isAncestor(existing.value, head)
|
|
37
|
+
if (!contained) {
|
|
38
|
+
const kept = yield* git.listRefs(PROCESS_HISTORY_PREFIX)
|
|
39
|
+
for (const k of kept) {
|
|
40
|
+
if (yield* git.isAncestor(existing.value, k.hash)) {
|
|
41
|
+
contained = true
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!contained) {
|
|
47
|
+
return {
|
|
48
|
+
kind: "refusal",
|
|
49
|
+
message: `${commandLabel}: branch ${branch} holds commits that are neither on ${current.value} nor in a kept history ref — merge or delete it first`,
|
|
50
|
+
} as const
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { kind: "switch", branch } as const
|
|
54
|
+
})
|
|
@@ -13,6 +13,12 @@ import {
|
|
|
13
13
|
hardResetTo,
|
|
14
14
|
keepTurn,
|
|
15
15
|
mixedResetTo,
|
|
16
|
+
switchBranchTo,
|
|
17
|
+
switchBack,
|
|
18
|
+
switchTo,
|
|
19
|
+
deleteBranch,
|
|
20
|
+
pushRef,
|
|
21
|
+
shipCommit,
|
|
16
22
|
shellQuote,
|
|
17
23
|
softResetTo,
|
|
18
24
|
updateRef,
|
|
@@ -26,6 +32,7 @@ import {
|
|
|
26
32
|
modeContradictionSkipNotice,
|
|
27
33
|
OUTCOME_MARKER,
|
|
28
34
|
pinScript,
|
|
35
|
+
failRestScript,
|
|
29
36
|
repinScript,
|
|
30
37
|
unpinScript,
|
|
31
38
|
} from "../emit/index.js"
|
|
@@ -161,6 +168,19 @@ const recognizeKeepTurn = (repo: InMemRepo, block: string): BlockOutcome | undef
|
|
|
161
168
|
return failure === undefined ? { kind: "applied" } : { kind: "failed", error: failure, status: 1 }
|
|
162
169
|
}
|
|
163
170
|
|
|
171
|
+
const attempt = (apply: () => void): BlockOutcome => {
|
|
172
|
+
try {
|
|
173
|
+
apply()
|
|
174
|
+
return { kind: "applied" }
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return {
|
|
177
|
+
kind: "failed",
|
|
178
|
+
error: `fatal: ${e instanceof Error ? e.message : String(e)}`,
|
|
179
|
+
status: 128,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
// fallow-ignore-next-line complexity
|
|
165
185
|
const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
166
186
|
if (block === discardPending()) {
|
|
@@ -250,6 +270,73 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
|
|
|
250
270
|
return undefined
|
|
251
271
|
}
|
|
252
272
|
|
|
273
|
+
if (block.startsWith("git switch -q -C ")) {
|
|
274
|
+
const [branch, start] = extractQuotedTokens(block)
|
|
275
|
+
if (branch !== undefined && switchBranchTo(branch, start) === block) {
|
|
276
|
+
return attempt(() => repo.switchBranchTo(branch, start))
|
|
277
|
+
}
|
|
278
|
+
return undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (block.startsWith("if ! git switch -q ")) {
|
|
282
|
+
const [branch, , note] = extractQuotedTokens(block)
|
|
283
|
+
if (branch !== undefined && switchBack(branch) === block) {
|
|
284
|
+
try {
|
|
285
|
+
repo.switchTo(branch)
|
|
286
|
+
return { kind: "applied" }
|
|
287
|
+
} catch {
|
|
288
|
+
return { kind: "noop", output: `${note}\n` }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (block.startsWith("git switch -q ")) {
|
|
295
|
+
const [branch] = extractQuotedTokens(block)
|
|
296
|
+
if (branch !== undefined && switchTo(branch) === block) {
|
|
297
|
+
return attempt(() => repo.switchTo(branch))
|
|
298
|
+
}
|
|
299
|
+
return undefined
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (block.startsWith("git branch -q -D ")) {
|
|
303
|
+
const [branch] = extractQuotedTokens(block)
|
|
304
|
+
if (branch !== undefined && deleteBranch(branch) === block) {
|
|
305
|
+
return attempt(() => repo.deleteBranch(branch))
|
|
306
|
+
}
|
|
307
|
+
return undefined
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (block.startsWith("if ! git push --force ")) {
|
|
311
|
+
const [remote, refspec] = extractQuotedTokens(block)
|
|
312
|
+
const ref = refspec?.split(":")[0]
|
|
313
|
+
if (remote !== undefined && ref !== undefined && pushRef(remote, ref) === block) {
|
|
314
|
+
const failure = repo.pushRef(remote, ref)
|
|
315
|
+
if (failure === undefined) return { kind: "applied" }
|
|
316
|
+
const retry = extractQuotedTokens(block)[3]!
|
|
317
|
+
return { kind: "failed", error: `${failure}\n${retry}`, status: 1 }
|
|
318
|
+
}
|
|
319
|
+
return undefined
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (block.startsWith("gtd_ship_parent=")) {
|
|
323
|
+
const [parent, branch, history, shipMessage, message] = extractQuotedTokens(block)
|
|
324
|
+
if (
|
|
325
|
+
parent === undefined ||
|
|
326
|
+
branch === undefined ||
|
|
327
|
+
history === undefined ||
|
|
328
|
+
shipMessage === undefined ||
|
|
329
|
+
message === undefined ||
|
|
330
|
+
shipCommit(branch, parent, shipMessage, history, message) !== block
|
|
331
|
+
) {
|
|
332
|
+
return undefined
|
|
333
|
+
}
|
|
334
|
+
const failure = repo.shipCommit(branch, parent, shipMessage, history, message)
|
|
335
|
+
return failure === undefined
|
|
336
|
+
? { kind: "applied" }
|
|
337
|
+
: { kind: "failed", error: failure, status: 1 }
|
|
338
|
+
}
|
|
339
|
+
|
|
253
340
|
if (block.startsWith("git update-ref -d ")) {
|
|
254
341
|
const [ref] = extractQuotedTokens(block)
|
|
255
342
|
if (ref !== undefined && deleteRef(ref) === block) {
|
|
@@ -375,6 +462,35 @@ const recognizeModeCommand = (
|
|
|
375
462
|
return recognizeScriptedCommand(repo, new Map([[command, bound]]), command)
|
|
376
463
|
}
|
|
377
464
|
|
|
465
|
+
const FAIL_REST_MIDDLE = '\n} 2>&1 )" || gtd_run_status=$?\n'
|
|
466
|
+
|
|
467
|
+
/** A `run(…, { onFail: "rest" })` body: the inner script's failure is recorded in the tree and the block itself succeeds. */
|
|
468
|
+
// fallow-ignore-next-line complexity
|
|
469
|
+
const recognizeFailRest = (
|
|
470
|
+
repo: InMemRepo,
|
|
471
|
+
commands: CommandTable,
|
|
472
|
+
block: string,
|
|
473
|
+
): BlockOutcome | undefined => {
|
|
474
|
+
if (!block.startsWith("rm -f -- '") || !block.includes("gtd_run_out=")) return undefined
|
|
475
|
+
const [path] = extractQuotedTokens(block)
|
|
476
|
+
const headerEnd = block.indexOf('gtd_run_out="$( {\n')
|
|
477
|
+
const bodyEnd = block.indexOf(FAIL_REST_MIDDLE)
|
|
478
|
+
if (path === undefined || headerEnd === -1 || bodyEnd === -1) return undefined
|
|
479
|
+
const body = block.slice(headerEnd + 'gtd_run_out="$( {\n'.length, bodyEnd)
|
|
480
|
+
if (failRestScript(body, path) !== block) return undefined
|
|
481
|
+
if (repo.readFile(path) !== undefined) repo.deleteFile(path)
|
|
482
|
+
const result = applyEmittedScript(repo, commands, body)
|
|
483
|
+
if (!result.ok && result.error?.startsWith(UNRECOGNIZED_PREFIX)) {
|
|
484
|
+
return { kind: "failed", error: `${result.output}${result.error}` }
|
|
485
|
+
}
|
|
486
|
+
if (!result.ok) {
|
|
487
|
+
// Mirrors `$(…)` (strips trailing newlines) and the script's `tail -n 50`.
|
|
488
|
+
const lines = result.output.replace(/\n+$/, "").split("\n")
|
|
489
|
+
repo.writeFile(path, `exit ${result.status}\n${lines.slice(-50).join("\n")}\n`)
|
|
490
|
+
}
|
|
491
|
+
return { kind: "noop", output: result.output }
|
|
492
|
+
}
|
|
493
|
+
|
|
378
494
|
const FAILURE_PROMPT_HEADER = 'gtd_validate_status=0\ngtd_validate_out="$( {\n'
|
|
379
495
|
const FAILURE_PROMPT_MIDDLE =
|
|
380
496
|
'\n} 2>&1 )" || gtd_validate_status=$?\n' +
|
|
@@ -565,7 +681,7 @@ const recognizeGtdCheck = (repo: InMemRepo, block: string): BlockOutcome | undef
|
|
|
565
681
|
|
|
566
682
|
const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/
|
|
567
683
|
|
|
568
|
-
/** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit,
|
|
684
|
+
/** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, absent when the next step is an agent turn. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
|
|
569
685
|
const recognizeGtdUncheck = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
570
686
|
const match = GTD_UNCHECK_RE.exec(block)
|
|
571
687
|
if (!match) return undefined
|
|
@@ -728,6 +844,7 @@ const recognizersFor = (
|
|
|
728
844
|
(block) => (git ? recognizeGitBuilders(repo, block) : undefined),
|
|
729
845
|
(block) => recognizeWriteLines(repo, block),
|
|
730
846
|
(block) => recognizePinScripts(repo, block),
|
|
847
|
+
(block) => recognizeFailRest(repo, commands, block),
|
|
731
848
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
732
849
|
(block) => recognizePresentationSubshell(repo, commands, block, git),
|
|
733
850
|
(block) => recognizeModeContradictionSkipNotice(block),
|
|
@@ -5,15 +5,20 @@ import {
|
|
|
5
5
|
combinedScript,
|
|
6
6
|
commitAll,
|
|
7
7
|
commitAsIs,
|
|
8
|
+
deleteBranch,
|
|
8
9
|
emitScripts,
|
|
10
|
+
failRestScript,
|
|
9
11
|
failurePromptWrapper,
|
|
10
12
|
fileExistsGuard,
|
|
11
13
|
modeContradictionSkipNotice,
|
|
14
|
+
switchBranchTo,
|
|
15
|
+
switchBack,
|
|
16
|
+
switchTo,
|
|
12
17
|
transitionOutcome,
|
|
13
18
|
withFileVar,
|
|
14
19
|
type EmitStep,
|
|
15
20
|
} from "../emit/index.js"
|
|
16
|
-
import { Effect } from "effect"
|
|
21
|
+
import { Effect, Option } from "effect"
|
|
17
22
|
import { ScriptSurface } from "../emit/index.js"
|
|
18
23
|
import { GitService } from "../platform/index.js"
|
|
19
24
|
import { listFormat } from "../steering/index.js"
|
|
@@ -305,6 +310,147 @@ printf '%s\n' 'a' '' 'it'\''s' '100%' '-x' 'a\b' '.gtd/packages/00-x.md' > '.gtd
|
|
|
305
310
|
)
|
|
306
311
|
})
|
|
307
312
|
|
|
313
|
+
describe("branch scripts", () => {
|
|
314
|
+
it("switchBranchTo creates the branch at HEAD and moves onto it", () => {
|
|
315
|
+
const head = t.observe.resolveRef("HEAD")
|
|
316
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
317
|
+
expect(t.observe.resolveRef("gtd/x")).toBe(head)
|
|
318
|
+
t.seed.writeFile("a.txt", "a\n")
|
|
319
|
+
expect(run(commitAll("gtd(agent): building")).ok).toBe(true)
|
|
320
|
+
expect(t.observe.resolveRef("gtd/x")).toBe(t.observe.resolveRef("HEAD"))
|
|
321
|
+
expect(t.observe.resolveRef("gtd/x")).not.toBe(head)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it("switchTo moves onto an existing branch and fails for a missing one", () => {
|
|
325
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
326
|
+
expect(run(switchTo("nope")).ok).toBe(false)
|
|
327
|
+
expect(run(switchBranchTo("gtd/y")).ok).toBe(true)
|
|
328
|
+
expect(run(switchTo("gtd/x")).ok).toBe(true)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const branch = () =>
|
|
332
|
+
t.provide(Effect.flatMap(GitService, (g) => g.currentBranch())).then(Option.getOrUndefined)
|
|
333
|
+
|
|
334
|
+
it("switchBack carries a change to a path both branches agree on", async () => {
|
|
335
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
336
|
+
t.seed.commit("feat: on gtd/x", { "b.txt": "b\n" })
|
|
337
|
+
expect(run(switchBranchTo("x", "HEAD~1")).ok).toBe(true)
|
|
338
|
+
expect(run(switchTo("gtd/x")).ok).toBe(true)
|
|
339
|
+
t.seed.writeFile("a.txt", "changed\n")
|
|
340
|
+
const result = run(switchBack("x"))
|
|
341
|
+
expect(result.ok).toBe(true)
|
|
342
|
+
expect(result.output).toBe("")
|
|
343
|
+
expect(await branch()).toBe("x")
|
|
344
|
+
expect(read("a.txt")).toBe("changed\n")
|
|
345
|
+
expect(read("b.txt")).toBeUndefined()
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it("switchBack stays put, saying so, when a change would be overwritten", async () => {
|
|
349
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
350
|
+
t.seed.commit("feat: on gtd/x", { "b.txt": "b\n" })
|
|
351
|
+
expect(run(switchBranchTo("x", "HEAD~1")).ok).toBe(true)
|
|
352
|
+
expect(run(switchTo("gtd/x")).ok).toBe(true)
|
|
353
|
+
t.seed.writeFile("b.txt", "changed\n")
|
|
354
|
+
const result = run(switchBack("x"))
|
|
355
|
+
expect(result.ok).toBe(true)
|
|
356
|
+
expect(result.output).toContain("pending changes keep HEAD off x")
|
|
357
|
+
expect(await branch()).toBe("gtd/x")
|
|
358
|
+
expect(read("b.txt")).toBe("changed\n")
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it("deleteBranch removes another branch and refuses the checked-out one", () => {
|
|
362
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
363
|
+
expect(run(deleteBranch("gtd/x")).ok).toBe(false)
|
|
364
|
+
expect(run(switchBranchTo("gtd/y")).ok).toBe(true)
|
|
365
|
+
expect(run(deleteBranch("gtd/x")).ok).toBe(true)
|
|
366
|
+
expect(t.observe.refExists("refs/heads/gtd/x")).toBe(false)
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe("shipCommit", () => {
|
|
371
|
+
const HISTORY = "refs/gtd/history/0123456789012345678901234567890123456789"
|
|
372
|
+
const LANDING = "gtd(check): shipping → after\n\nGtd-Format: 4"
|
|
373
|
+
const git = <A>(f: (g: GitService["Service"]) => Effect.Effect<A, Error>) =>
|
|
374
|
+
t.provide(Effect.flatMap(GitService, f))
|
|
375
|
+
// As a landing renders it: under `set -eu`.
|
|
376
|
+
const ship = (branch: string, parent: string, shipMessage: string) =>
|
|
377
|
+
ScriptSurface.render([
|
|
378
|
+
{
|
|
379
|
+
kind: "gitWrite",
|
|
380
|
+
write: {
|
|
381
|
+
kind: "ship",
|
|
382
|
+
branch,
|
|
383
|
+
parent,
|
|
384
|
+
shipMessage,
|
|
385
|
+
historyRef: HISTORY,
|
|
386
|
+
message: LANDING,
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
])
|
|
390
|
+
|
|
391
|
+
it("squashes the staged tree minus .gtd/ onto the branch, then lands naming it", async () => {
|
|
392
|
+
const branch = await git((g) => g.currentBranch())
|
|
393
|
+
const user = Option.getOrElse(branch, () => "main")
|
|
394
|
+
const parent = t.observe.resolveRef("HEAD")
|
|
395
|
+
expect(run(switchBranchTo(`gtd/${user}`)).ok).toBe(true)
|
|
396
|
+
t.seed.writeFile("a.txt", "shipped\n")
|
|
397
|
+
t.seed.writeFile(".gtd/notes.md", "kept back\n")
|
|
398
|
+
const result = run(ship(user, parent, "feat: it's shipped"))
|
|
399
|
+
expect(result.ok).toBe(true)
|
|
400
|
+
const shipped = t.observe.resolveRef(user)
|
|
401
|
+
expect(shipped).not.toBe(parent)
|
|
402
|
+
expect(t.observe.resolveRef(`${shipped}~1`)).toBe(parent)
|
|
403
|
+
expect(await git((g) => g.readFileAtRef(shipped, "a.txt"))).toBe("shipped\n")
|
|
404
|
+
expect(await git((g) => g.diffPaths(parent, shipped))).toEqual(["a.txt"])
|
|
405
|
+
const message = await git((g) => g.lastCommitMessage(shipped))
|
|
406
|
+
expect(message).toContain("feat: it's shipped")
|
|
407
|
+
expect(message).toContain(`\n\nGtd-History: ${HISTORY}\nGtd-Tip: ${parent}`)
|
|
408
|
+
expect(await git((g) => g.lastCommitMessage("HEAD"))).toContain(`Gtd-Shipped: ${shipped}`)
|
|
409
|
+
expect(t.observe.statusPorcelain()).toBe("")
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it("keeps the .gtd/ files the branch tracks as it has them", async () => {
|
|
413
|
+
const user = Option.getOrElse(await git((g) => g.currentBranch()), () => "main")
|
|
414
|
+
t.seed.commit("chore: track", { ".gtd/keep.md": "tracked\n" })
|
|
415
|
+
const parent = t.observe.resolveRef("HEAD")
|
|
416
|
+
expect(run(switchBranchTo(`gtd/${user}`)).ok).toBe(true)
|
|
417
|
+
t.seed.writeFile("a.txt", "shipped\n")
|
|
418
|
+
t.seed.writeFile(".gtd/keep.md", "process\n")
|
|
419
|
+
expect(run(ship(user, parent, "feat: x")).ok).toBe(true)
|
|
420
|
+
const shipped = t.observe.resolveRef(user)
|
|
421
|
+
expect(await git((g) => g.readFileAtRef(shipped, ".gtd/keep.md"))).toBe("tracked\n")
|
|
422
|
+
expect(await git((g) => g.diffPaths(parent, shipped))).toEqual(["a.txt"])
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it("fails before landing when the branch moved since the decision", () => {
|
|
426
|
+
const head = t.observe.resolveRef("HEAD")
|
|
427
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
428
|
+
expect(run(switchBranchTo("x")).ok).toBe(true)
|
|
429
|
+
t.seed.commit("feat: moved", { "b.txt": "b\n" })
|
|
430
|
+
expect(run(switchTo("gtd/x")).ok).toBe(true)
|
|
431
|
+
t.seed.writeFile("a.txt", "a\n")
|
|
432
|
+
expect(run(ship("x", head, "feat: x")).ok).toBe(false)
|
|
433
|
+
expect(t.observe.resolveRef("HEAD")).toBe(head)
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
describe("failRestScript", () => {
|
|
438
|
+
const FAIL_PATH = ".gtd/run-failed.txt"
|
|
439
|
+
const wrapped = (body: string) => failRestScript(body, FAIL_PATH)
|
|
440
|
+
|
|
441
|
+
it("records the status and output, without the output's trailing newline, and succeeds", () => {
|
|
442
|
+
expect(run(wrapped(withFileVar(FAILING, FILE))).ok).toBe(true)
|
|
443
|
+
expect(read(FAIL_PATH)).toBe(`exit 2\nbad: ${FILE}\n`)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it("leaves no record when the body passes, and clears an earlier one", () => {
|
|
447
|
+
t.seed.writeFile(FAIL_PATH, "exit 1\nold\n")
|
|
448
|
+
t.seed.writeFile(FILE, "a\n")
|
|
449
|
+
expect(run(wrapped(withFileVar(COPY, FILE))).ok).toBe(true)
|
|
450
|
+
expect(read(FAIL_PATH)).toBeUndefined()
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
308
454
|
describe("commitAll", () => {
|
|
309
455
|
it("a staging hook's edit lands in the commit", () => {
|
|
310
456
|
t.seed.stagingHook("readme.txt", "hooked")
|