@pmelab/gtd 60.1.0 → 61.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 +7 -0
- package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-C9sq0x07.mjs} +1695 -1454
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +1666 -1449
- package/dist/gtd.core.mjs +86 -86
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Program.test.ts +30 -19
- package/src/cli/Program.ts +4 -1
- package/src/cli/Strays.ts +3 -2
- package/src/edge/Edge.test.ts +78 -25
- package/src/edge/Edge.ts +161 -28
- package/src/emit/GitScript.ts +21 -3
- package/src/emit/index.ts +3 -0
- package/src/gherkin/messages.test.ts +1 -0
- package/src/gherkin/module-config.feature +1 -0
- package/src/gherkin/more-steps.feature +1 -0
- package/src/gherkin/testkit.feature +2 -1
- package/src/install/Install.ts +9 -2
- package/src/replay/Format.test.ts +35 -8
- package/src/replay/Format.ts +7 -4
- package/src/replay/Replay.test.ts +54 -0
- package/src/replay/Replay.ts +61 -17
- package/src/replay/Trailers.test.ts +12 -12
- package/src/replay/Trailers.ts +35 -20
- package/src/replay/Tree.ts +11 -0
- package/src/replay/index.ts +3 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/step/planStep.ts +10 -8
- package/src/testkit/TestRepo.test.ts +4 -1
- package/src/testkit/TestRepo.ts +3 -1
- package/src/twins/EmittedScriptRecognizer.ts +32 -1
- package/src/twins/GitTiers.ts +23 -0
- package/src/twins/InMemRepo.ts +14 -0
- package/src/twins/PinBeats.ts +33 -0
- package/src/twins/index.ts +1 -0
- package/src/wire/BeatDocument.ts +26 -5
- package/src/wire/Demand.ts +16 -6
- package/src/wire/constants.ts +3 -0
- package/src/wire/types.ts +4 -0
package/src/replay/Replay.ts
CHANGED
|
@@ -20,17 +20,20 @@ import {
|
|
|
20
20
|
threads,
|
|
21
21
|
} from "../steering/index.js"
|
|
22
22
|
import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
|
|
23
|
-
import { globMatches } from "../wire/index.js"
|
|
23
|
+
import { globMatches, PIN_PATH } from "../wire/index.js"
|
|
24
24
|
import { refNameFault } from "./RefName.js"
|
|
25
|
-
import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
|
|
25
|
+
import { diffTrees, isEmptyDiff, withoutPath, type TreeView } from "./Tree.js"
|
|
26
26
|
import {
|
|
27
27
|
formatStepId,
|
|
28
28
|
parseCommitMessage,
|
|
29
29
|
type CommitMessage,
|
|
30
30
|
type JudgeVerdict,
|
|
31
31
|
type StepId,
|
|
32
|
+
PIN_STATES,
|
|
32
33
|
} from "./Trailers.js"
|
|
33
34
|
|
|
35
|
+
const UNPIN_STATE = PIN_STATES.remove
|
|
36
|
+
|
|
34
37
|
export interface EpisodeCommit {
|
|
35
38
|
readonly hash: string
|
|
36
39
|
readonly message: string
|
|
@@ -297,7 +300,29 @@ const modeWithoutFile = (step: ReachedStep): string | undefined => {
|
|
|
297
300
|
return `gtd: step "${step.name}": mode requires file in ${step.kind}() options — name the steering file the mode checks`
|
|
298
301
|
}
|
|
299
302
|
|
|
300
|
-
|
|
303
|
+
// The pin is the engine's, not the process's: flow code never sees it, so a
|
|
304
|
+
// pin commit is an empty diff replay can step over.
|
|
305
|
+
const withoutPin = (input: ReplayInput): ReplayInput => {
|
|
306
|
+
const hide = (tree: TreeView): TreeView => withoutPath(tree, PIN_PATH)
|
|
307
|
+
return {
|
|
308
|
+
...input,
|
|
309
|
+
episode: {
|
|
310
|
+
base: { ...input.episode.base, tree: hide(input.episode.base.tree) },
|
|
311
|
+
commits: input.episode.commits.map((c) => ({
|
|
312
|
+
...c,
|
|
313
|
+
tree: hide(c.tree),
|
|
314
|
+
...(c.kept !== undefined ? { kept: { ...c.kept, tree: hide(c.kept.tree) } } : {}),
|
|
315
|
+
})),
|
|
316
|
+
},
|
|
317
|
+
...(input.startTree !== undefined ? { startTree: hide(input.startTree) } : {}),
|
|
318
|
+
...(input.pending !== undefined
|
|
319
|
+
? { pending: { ...input.pending, tree: hide(input.pending.tree) } }
|
|
320
|
+
: {}),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
325
|
+
const input = withoutPin(raw)
|
|
301
326
|
const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
|
|
302
327
|
...c,
|
|
303
328
|
parsed: parseCommitMessage(c.message),
|
|
@@ -317,6 +342,11 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
317
342
|
let cursor = 0
|
|
318
343
|
let pendingUsed = false
|
|
319
344
|
let position: Position = input.episode.base
|
|
345
|
+
// Never stands on a pin commit: `head()` names the turn, so a flow
|
|
346
|
+
// reverting it reverts the turn, not the pin.
|
|
347
|
+
const skipPins = (): void => {
|
|
348
|
+
while (cursor < commits.length && commits[cursor]!.parsed.pin !== undefined) cursor++
|
|
349
|
+
}
|
|
320
350
|
let previousPosition: Position = input.episode.base
|
|
321
351
|
let expectedNext: { readonly name: string; readonly hash: string } | undefined
|
|
322
352
|
const occurrences = new Map<string, number>()
|
|
@@ -560,20 +590,25 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
560
590
|
if (unapproved !== undefined) return finish(unapproved)
|
|
561
591
|
cursor++
|
|
562
592
|
advance(commit)
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
else
|
|
566
|
-
keptTips.set(step.name, {
|
|
567
|
-
hash: commit.kept.hash,
|
|
568
|
-
before: commit.tree,
|
|
569
|
-
after: commit.kept.tree,
|
|
570
|
-
})
|
|
571
|
-
}
|
|
593
|
+
skipPins()
|
|
594
|
+
noteKept(step, commit)
|
|
572
595
|
const to = commit.parsed.parsed?.to
|
|
573
|
-
|
|
596
|
+
// A turn that ends a pinned process names the unpin, which is no step.
|
|
597
|
+
if (to !== undefined && to !== UNPIN_STATE) expectedNext = { name: to, hash: commit.hash }
|
|
574
598
|
return replyFor(step.request, commit.parsed.judge)
|
|
575
599
|
}
|
|
576
600
|
|
|
601
|
+
const noteKept = (step: ReachedStep, commit: ParsedCommit): void => {
|
|
602
|
+
if (commit.parsed.kept === undefined) return
|
|
603
|
+
if (commit.kept === undefined) keptTips.delete(step.name)
|
|
604
|
+
else
|
|
605
|
+
keptTips.set(step.name, {
|
|
606
|
+
hash: commit.kept.hash,
|
|
607
|
+
before: commit.tree,
|
|
608
|
+
after: commit.kept.tree,
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
|
|
577
612
|
const landPending = (step: ReachedStep, pending: NonNullable<typeof input.pending>): unknown => {
|
|
578
613
|
pendingUsed = true
|
|
579
614
|
landed = step
|
|
@@ -587,7 +622,10 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
587
622
|
}
|
|
588
623
|
|
|
589
624
|
const answer = (step: ReachedStep): unknown => {
|
|
590
|
-
while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name))
|
|
625
|
+
while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name)) {
|
|
626
|
+
cursor++
|
|
627
|
+
skipPins()
|
|
628
|
+
}
|
|
591
629
|
const commit = commits[cursor]
|
|
592
630
|
if (commit !== undefined) return consumeCommit(step, commit)
|
|
593
631
|
if (input.pending === undefined || pendingUsed) {
|
|
@@ -604,6 +642,9 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
604
642
|
|
|
605
643
|
// gtd.config.ts is loaded without a type check.
|
|
606
644
|
const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
|
|
645
|
+
if (Object.values(PIN_STATES).includes(scoped(request.name))) {
|
|
646
|
+
return `gtd: step "${scoped(request.name)}": the name is reserved for the pin and unpin beats — rename the step`
|
|
647
|
+
}
|
|
607
648
|
if (request.kind === "run" && typeof request.body !== "string") {
|
|
608
649
|
return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
|
|
609
650
|
}
|
|
@@ -636,13 +677,15 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
636
677
|
return blocker === undefined ? answer(step) : finish(blocker)
|
|
637
678
|
}
|
|
638
679
|
|
|
639
|
-
const endOfFlow = (via: "return" | "restart"): ReplayOutcome =>
|
|
640
|
-
|
|
680
|
+
const endOfFlow = (via: "return" | "restart"): ReplayOutcome => {
|
|
681
|
+
skipPins()
|
|
682
|
+
return cursor < commits.length
|
|
641
683
|
? {
|
|
642
684
|
kind: "divergence",
|
|
643
685
|
message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
|
|
644
686
|
}
|
|
645
687
|
: { kind: "ended", via, trace, landed, marks }
|
|
688
|
+
}
|
|
646
689
|
|
|
647
690
|
const changesSince = (hash: string): readonly Change[] => {
|
|
648
691
|
const tree = treeAt(hash)
|
|
@@ -714,13 +757,14 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
714
757
|
// At the rest, trailing attempts sit above the last step commit: the head a
|
|
715
758
|
// prompt names is the commit the process actually stands on.
|
|
716
759
|
head: () => {
|
|
717
|
-
const rest = commits.slice(cursor)
|
|
760
|
+
const rest = commits.slice(cursor).filter((c) => c.parsed.pin === undefined)
|
|
718
761
|
return rest.length > 0 && rest.every((c) => c.parsed.step === undefined)
|
|
719
762
|
? rest[rest.length - 1]!.hash
|
|
720
763
|
: position.hash
|
|
721
764
|
},
|
|
722
765
|
}
|
|
723
766
|
|
|
767
|
+
skipPins()
|
|
724
768
|
installContext(context)
|
|
725
769
|
try {
|
|
726
770
|
// A pre-upgrade flow destructuring `{ entry }` reads `undefined` (an
|
|
@@ -46,7 +46,7 @@ describe("the commit-message codec", () => {
|
|
|
46
46
|
reviewBase: "abc",
|
|
47
47
|
})
|
|
48
48
|
expect(message).toBe(
|
|
49
|
-
"gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format:
|
|
49
|
+
"gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format: 2",
|
|
50
50
|
)
|
|
51
51
|
expect(parseCommitMessage(message).workflow).toBe("fix")
|
|
52
52
|
expect(parseCommitMessage("gtd(human): fix-precheck").workflow).toBeUndefined()
|
|
@@ -56,7 +56,7 @@ describe("the commit-message codec", () => {
|
|
|
56
56
|
const one = formatCommitMessage({ actor: "human", to: "x", vars: { b: "2", a: "1", c: "" } })
|
|
57
57
|
const two = formatCommitMessage({ actor: "human", to: "x", vars: { c: "", a: "1", b: "2" } })
|
|
58
58
|
expect(one).toBe(two)
|
|
59
|
-
expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format:
|
|
59
|
+
expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format: 2")
|
|
60
60
|
expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
|
|
61
61
|
})
|
|
62
62
|
|
|
@@ -68,7 +68,7 @@ describe("the commit-message codec", () => {
|
|
|
68
68
|
config: { "lib/my flow.ts": "0123456789abcdef", "gtd.config.ts": "deadbeefdeadbeef" },
|
|
69
69
|
})
|
|
70
70
|
expect(message).toBe(
|
|
71
|
-
"gtd(human): x\n\nGtd-Var: a=1\nGtd-Config: deadbeefdeadbeef gtd.config.ts\nGtd-Config: 0123456789abcdef lib/my flow.ts\nGtd-Format:
|
|
71
|
+
"gtd(human): x\n\nGtd-Var: a=1\nGtd-Config: deadbeefdeadbeef gtd.config.ts\nGtd-Config: 0123456789abcdef lib/my flow.ts\nGtd-Format: 2",
|
|
72
72
|
)
|
|
73
73
|
expect(parseCommitMessage(message).config).toEqual({
|
|
74
74
|
"gtd.config.ts": "deadbeefdeadbeef",
|
|
@@ -118,8 +118,8 @@ describe("the commit-message codec", () => {
|
|
|
118
118
|
it("reads a Gtd-Version carrying control bytes as unknown, and keeps spaces and parens", () => {
|
|
119
119
|
const withVersion = (value: string) =>
|
|
120
120
|
formatCommitMessage({ actor: "a", to: "b" }).replace(
|
|
121
|
-
"Gtd-Format:
|
|
122
|
-
`Gtd-Version: ${value}\nGtd-Format:
|
|
121
|
+
"Gtd-Format: 2",
|
|
122
|
+
`Gtd-Version: ${value}\nGtd-Format: 2`,
|
|
123
123
|
)
|
|
124
124
|
expect(parseCommitMessage(withVersion("1.0\u001b]0;x\u0007")).version).toBeUndefined()
|
|
125
125
|
expect(parseCommitMessage(withVersion("1.0\u009b")).version).toBeUndefined()
|
|
@@ -135,15 +135,15 @@ describe("the commit-message codec", () => {
|
|
|
135
135
|
expect(parsed.version).toBeUndefined()
|
|
136
136
|
})
|
|
137
137
|
|
|
138
|
-
it("
|
|
138
|
+
it("writes the current Gtd-Format when branch and version are written", () => {
|
|
139
139
|
const message = formatCommitMessage({ actor: "a", to: "b", branch: "m", version: "1" })
|
|
140
|
-
expect(message.endsWith("Gtd-Format:
|
|
140
|
+
expect(message.endsWith("Gtd-Format: 2")).toBe(true)
|
|
141
141
|
})
|
|
142
142
|
|
|
143
143
|
it("writes Gtd-Format last on every message, even one with no other trailer", () => {
|
|
144
144
|
const message = formatCommitMessage({ actor: "agent", to: "building" })
|
|
145
|
-
expect(message).toBe("gtd(agent): building\n\nGtd-Format:
|
|
146
|
-
expect(parseCommitMessage(message).format).toBe(
|
|
145
|
+
expect(message).toBe("gtd(agent): building\n\nGtd-Format: 2")
|
|
146
|
+
expect(parseCommitMessage(message).format).toBe(2)
|
|
147
147
|
})
|
|
148
148
|
|
|
149
149
|
it("reads a message without Gtd-Format as format 1, and an explicit one as written", () => {
|
|
@@ -183,10 +183,10 @@ describe("Gtd-Kept", () => {
|
|
|
183
183
|
expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Kept: ${hash}0`).kept).toBeUndefined()
|
|
184
184
|
})
|
|
185
185
|
|
|
186
|
-
it("reads undefined when absent, and leaves Gtd-Format at
|
|
186
|
+
it("reads undefined when absent, and leaves Gtd-Format at the current format", () => {
|
|
187
187
|
const parsed = parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" }))
|
|
188
188
|
expect(parsed.kept).toBeUndefined()
|
|
189
|
-
expect(parsed.format).toBe(
|
|
189
|
+
expect(parsed.format).toBe(2)
|
|
190
190
|
})
|
|
191
191
|
})
|
|
192
192
|
|
|
@@ -326,7 +326,7 @@ describe("Gtd-Mark", () => {
|
|
|
326
326
|
const at = lines.findIndex((l) => l.startsWith("Gtd-Mark:"))
|
|
327
327
|
expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
|
|
328
328
|
expect(lines[at + 1]).toMatch(/^Gtd-Approved:/)
|
|
329
|
-
expect(lines).toContain("Gtd-Format:
|
|
329
|
+
expect(lines).toContain("Gtd-Format: 2")
|
|
330
330
|
})
|
|
331
331
|
|
|
332
332
|
it("writes nothing without marks", () => {
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// command unusable.
|
|
5
5
|
|
|
6
6
|
import type { Mark } from "../flows/index.js"
|
|
7
|
+
import type { PinMark } from "../wire/index.js"
|
|
7
8
|
import { HISTORY_FORMAT } from "./Format.js"
|
|
8
9
|
|
|
9
10
|
const TRANSITION_SEP = " → "
|
|
@@ -67,6 +68,11 @@ export interface Approval {
|
|
|
67
68
|
readonly email: string
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
export type PinChange = PinMark
|
|
72
|
+
|
|
73
|
+
/** What a pin and an unpin beat rest at, and their commits name — neither is a step of the workflow. */
|
|
74
|
+
export const PIN_STATES: Readonly<Record<PinChange, string>> = { add: "pin", remove: "unpin" }
|
|
75
|
+
|
|
70
76
|
export interface CommitMessage {
|
|
71
77
|
readonly subject: string
|
|
72
78
|
readonly parsed: ParsedSubject | undefined
|
|
@@ -88,6 +94,8 @@ export interface CommitMessage {
|
|
|
88
94
|
readonly kept: string | undefined
|
|
89
95
|
/** `Gtd-Approved`; a malformed or control-character value is skipped. */
|
|
90
96
|
readonly approved: Approval | undefined
|
|
97
|
+
/** `Gtd-Pin`: a commit that adds or removes the pin — never a turn. */
|
|
98
|
+
readonly pin: PinChange | undefined
|
|
91
99
|
readonly format: number
|
|
92
100
|
}
|
|
93
101
|
|
|
@@ -156,6 +164,7 @@ interface Trailers {
|
|
|
156
164
|
version: string | undefined
|
|
157
165
|
kept: string | undefined
|
|
158
166
|
approved: Approval | undefined
|
|
167
|
+
pin: PinChange | undefined
|
|
159
168
|
readonly judge: JudgeVerdict[]
|
|
160
169
|
readonly marks: Mark[]
|
|
161
170
|
readonly vars: Record<string, string>
|
|
@@ -206,6 +215,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
206
215
|
"Gtd-Approved": (value, into) => {
|
|
207
216
|
into.approved ??= decodeApproval(value)
|
|
208
217
|
},
|
|
218
|
+
"Gtd-Pin": (value, into) => {
|
|
219
|
+
if (value === "add" || value === "remove") into.pin ??= value
|
|
220
|
+
},
|
|
209
221
|
"Gtd-Format": (value, into) => {
|
|
210
222
|
if (FORMAT_RE.test(value)) into.format ??= Number(value)
|
|
211
223
|
},
|
|
@@ -226,6 +238,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
|
|
|
226
238
|
version: undefined,
|
|
227
239
|
kept: undefined,
|
|
228
240
|
approved: undefined,
|
|
241
|
+
pin: undefined,
|
|
229
242
|
judge: [],
|
|
230
243
|
marks: [],
|
|
231
244
|
vars: {},
|
|
@@ -256,6 +269,7 @@ export interface CommitSpec {
|
|
|
256
269
|
readonly marks?: readonly Mark[]
|
|
257
270
|
/** Written after `Gtd-Mark`, before `Gtd-Branch`. */
|
|
258
271
|
readonly approved?: Approval
|
|
272
|
+
readonly pin?: PinChange
|
|
259
273
|
readonly branch?: string
|
|
260
274
|
readonly version?: string
|
|
261
275
|
}
|
|
@@ -264,27 +278,28 @@ const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string,
|
|
|
264
278
|
Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
265
279
|
|
|
266
280
|
/** Trailer order is fixed so the same spec always yields the same bytes. */
|
|
281
|
+
const trailer = (name: string, value: string | undefined): readonly string[] =>
|
|
282
|
+
value === undefined ? [] : [`Gtd-${name}: ${value}`]
|
|
283
|
+
|
|
284
|
+
const costValue = (cost: CommitSpec["cost"]): string | undefined =>
|
|
285
|
+
cost === undefined ? undefined : `${cost.cost}${cost.model !== undefined ? ` ${cost.model}` : ""}`
|
|
286
|
+
|
|
267
287
|
export const formatCommitMessage = (spec: CommitSpec): string => {
|
|
268
|
-
const lines
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`)
|
|
284
|
-
}
|
|
285
|
-
if (spec.branch !== undefined) lines.push(`Gtd-Branch: ${spec.branch}`)
|
|
286
|
-
if (spec.version !== undefined) lines.push(`Gtd-Version: ${spec.version}`)
|
|
287
|
-
lines.push(`Gtd-Format: ${HISTORY_FORMAT}`)
|
|
288
|
+
const lines = [
|
|
289
|
+
...trailer("Step", spec.step && formatStepId(spec.step)),
|
|
290
|
+
...trailer("Workflow", spec.workflow),
|
|
291
|
+
...trailer("Review-Base", spec.reviewBase),
|
|
292
|
+
...sortedEntries(spec.vars).map(([name, value]) => `Gtd-Var: ${name}=${value}`),
|
|
293
|
+
...sortedEntries(spec.config).map(([path, hash]) => `Gtd-Config: ${hash} ${encodePath(path)}`),
|
|
294
|
+
...trailer("Cost", costValue(spec.cost)),
|
|
295
|
+
...(spec.judge ?? []).map((verdict) => `Gtd-Judge: ${JSON.stringify(verdict)}`),
|
|
296
|
+
...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
|
|
297
|
+
...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
|
|
298
|
+
...trailer("Pin", spec.pin),
|
|
299
|
+
...trailer("Branch", spec.branch),
|
|
300
|
+
...trailer("Version", spec.version),
|
|
301
|
+
`Gtd-Format: ${HISTORY_FORMAT}`,
|
|
302
|
+
]
|
|
288
303
|
const subject = formatSubject(spec.actor, spec.to, spec.from)
|
|
289
304
|
return `${subject}\n\n${lines.join("\n")}`
|
|
290
305
|
}
|
package/src/replay/Tree.ts
CHANGED
|
@@ -43,3 +43,14 @@ export const diffTrees = (before: TreeView, after: TreeView): TreeDiff => {
|
|
|
43
43
|
|
|
44
44
|
export const isEmptyDiff = (diff: TreeDiff): boolean =>
|
|
45
45
|
diff.added.length === 0 && diff.modified.length === 0 && diff.deleted.length === 0
|
|
46
|
+
|
|
47
|
+
export const withoutPath = (tree: TreeView, hidden: string): TreeView => {
|
|
48
|
+
let paths: readonly string[] | undefined
|
|
49
|
+
return {
|
|
50
|
+
paths: () => (paths ??= tree.paths().filter((path) => path !== hidden)),
|
|
51
|
+
read: (path) => (path === hidden ? undefined : tree.read(path)),
|
|
52
|
+
...(tree.id !== undefined
|
|
53
|
+
? { id: (path: string) => (path === hidden ? undefined : tree.id!(path)) }
|
|
54
|
+
: {}),
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/replay/index.ts
CHANGED
|
@@ -12,8 +12,11 @@ export {
|
|
|
12
12
|
formatSubject,
|
|
13
13
|
isRecordableApproval,
|
|
14
14
|
parseCommitMessage,
|
|
15
|
+
type CommitMessage,
|
|
15
16
|
type CommitSpec,
|
|
16
17
|
type JudgeVerdict,
|
|
18
|
+
type PinChange,
|
|
19
|
+
PIN_STATES,
|
|
17
20
|
} from "./Trailers.js"
|
|
18
21
|
export { accessShapeFault } from "./Access.js"
|
|
19
22
|
export { formatFault } from "./Format.js"
|
package/src/step/LandStep.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { StateName } from "../workflow/index.js"
|
|
|
8
8
|
*/
|
|
9
9
|
export type GitWrite =
|
|
10
10
|
| { readonly kind: "commitAll"; readonly message: string }
|
|
11
|
+
| { readonly kind: "commitPath"; readonly path: string; readonly message: string }
|
|
11
12
|
| { readonly kind: "commitClean"; readonly message: string }
|
|
12
13
|
| { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
|
|
13
14
|
| { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
|
package/src/step/RepoSnapshot.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type Landing =
|
|
|
19
19
|
readonly keepHistory?: string
|
|
20
20
|
/** The kept ref this landing's turn goes to; the branch gets a marker commit instead. */
|
|
21
21
|
readonly keepTurn?: string
|
|
22
|
+
/** The one path this landing commits; every other pending change stays pending. */
|
|
23
|
+
readonly only?: string
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -177,7 +177,7 @@ describe("planStart", () => {
|
|
|
177
177
|
kind: "gitWrite",
|
|
178
178
|
write: {
|
|
179
179
|
kind: "commitAll",
|
|
180
|
-
message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format:
|
|
180
|
+
message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format: 2",
|
|
181
181
|
},
|
|
182
182
|
},
|
|
183
183
|
{ kind: "outcome", outcome: { kind: "commit", subject: "gtd(human): work" } },
|
|
@@ -196,7 +196,7 @@ describe("planStart", () => {
|
|
|
196
196
|
if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
|
|
197
197
|
throw new Error("expected a gitWrite step")
|
|
198
198
|
expect(write.write.message).toBe(
|
|
199
|
-
"gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format:
|
|
199
|
+
"gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format: 2",
|
|
200
200
|
)
|
|
201
201
|
const outcome = plan.steps.find((s) => s.kind === "outcome")
|
|
202
202
|
expect(outcome).toEqual({
|
|
@@ -39,7 +39,7 @@ describe("planStep — commit", () => {
|
|
|
39
39
|
expect(outcome.steps).toEqual([
|
|
40
40
|
{
|
|
41
41
|
kind: "gitWrite",
|
|
42
|
-
write: { kind: "commitAll", message: "gtd(agent): await-answers\n\nGtd-Format:
|
|
42
|
+
write: { kind: "commitAll", message: "gtd(agent): await-answers\n\nGtd-Format: 2" },
|
|
43
43
|
},
|
|
44
44
|
{ kind: "outcome", outcome: { kind: "commit", subject: "gtd(agent): await-answers" } },
|
|
45
45
|
])
|
|
@@ -59,7 +59,7 @@ describe("planStep — commit", () => {
|
|
|
59
59
|
kind: "gitWrite",
|
|
60
60
|
write: {
|
|
61
61
|
kind: "commitAll",
|
|
62
|
-
message: "gtd(human): building → done\n\nGtd-Step: building#1\nGtd-Format:
|
|
62
|
+
message: "gtd(human): building → done\n\nGtd-Step: building#1\nGtd-Format: 2",
|
|
63
63
|
},
|
|
64
64
|
})
|
|
65
65
|
expect(outcome.steps.at(-1)).toEqual({
|
|
@@ -99,7 +99,7 @@ describe("planStep — commit", () => {
|
|
|
99
99
|
expect(trailers[1]).toBe("Gtd-Cost: 1.5 m")
|
|
100
100
|
expect(trailers[2]).toBe('Gtd-Judge: {"id":"q1","answer":true,"p":0.9}')
|
|
101
101
|
expect(trailers[3]).toBe('Gtd-Judge: {"id":"q2","answer":"x","p":0.4}')
|
|
102
|
-
expect(trailers[4]).toBe("Gtd-Format:
|
|
102
|
+
expect(trailers[4]).toBe("Gtd-Format: 2")
|
|
103
103
|
expect(trailers).toHaveLength(5)
|
|
104
104
|
})
|
|
105
105
|
})
|
|
@@ -182,7 +182,7 @@ describe("planStep — keepTurn", () => {
|
|
|
182
182
|
write: {
|
|
183
183
|
kind: "keepTurn",
|
|
184
184
|
ref: kept,
|
|
185
|
-
message: "gtd(agent): building → checking\n\nGtd-Step: building#1\nGtd-Format:
|
|
185
|
+
message: "gtd(agent): building → checking\n\nGtd-Step: building#1\nGtd-Format: 2",
|
|
186
186
|
},
|
|
187
187
|
})
|
|
188
188
|
})
|
package/src/step/planStep.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { formatCommitMessage, type JudgeVerdict } from "../replay/index.js"
|
|
2
2
|
import type { StateName } from "../workflow/index.js"
|
|
3
|
-
import type { LandStep } from "./LandStep.js"
|
|
4
|
-
import type { RepoSnapshot } from "./RepoSnapshot.js"
|
|
3
|
+
import type { GitWrite, LandStep } from "./LandStep.js"
|
|
4
|
+
import type { Landing, RepoSnapshot } from "./RepoSnapshot.js"
|
|
5
5
|
|
|
6
6
|
export type { JudgeVerdict }
|
|
7
7
|
|
|
@@ -35,6 +35,13 @@ const uncheckStep = (snapshot: RepoSnapshot): readonly LandStep[] =>
|
|
|
35
35
|
? [{ kind: "uncheck", file: snapshot.file }]
|
|
36
36
|
: []
|
|
37
37
|
|
|
38
|
+
const gitWriteOf = (landing: Extract<Landing, { kind: "commit" }>, message: string): GitWrite => {
|
|
39
|
+
if (landing.only !== undefined) return { kind: "commitPath", path: landing.only, message }
|
|
40
|
+
if (landing.keepTurn !== undefined) return { kind: "keepTurn", ref: landing.keepTurn, message }
|
|
41
|
+
if (landing.spec.approved !== undefined) return { kind: "commitClean", message }
|
|
42
|
+
return { kind: "commitAll", message }
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
export const planStep = (
|
|
39
46
|
snapshot: RepoSnapshot,
|
|
40
47
|
opts: {
|
|
@@ -84,12 +91,7 @@ export const planStep = (
|
|
|
84
91
|
...uncheckStep(snapshot),
|
|
85
92
|
{
|
|
86
93
|
kind: "gitWrite",
|
|
87
|
-
write:
|
|
88
|
-
landing.keepTurn !== undefined
|
|
89
|
-
? { kind: "keepTurn", ref: landing.keepTurn, message }
|
|
90
|
-
: landing.spec.approved !== undefined
|
|
91
|
-
? { kind: "commitClean", message }
|
|
92
|
-
: { kind: "commitAll", message },
|
|
94
|
+
write: gitWriteOf(landing, message),
|
|
93
95
|
},
|
|
94
96
|
...(landing.keepHistory !== undefined
|
|
95
97
|
? [
|
|
@@ -61,10 +61,12 @@ describe("testRepo", () => {
|
|
|
61
61
|
expect(repo.subjects()).toEqual([
|
|
62
62
|
"chore: initial commit",
|
|
63
63
|
"gtd(agent): plan → build",
|
|
64
|
+
"gtd(check): pin → build",
|
|
64
65
|
"gtd(agent): build → check",
|
|
65
66
|
"gtd(check): check → sign-off",
|
|
66
67
|
])
|
|
67
68
|
expect(repo.read("src/add.ts")).toContain("a + b")
|
|
69
|
+
expect(repo.read(".gtd/process.gtdpin")).toBeDefined()
|
|
68
70
|
})
|
|
69
71
|
|
|
70
72
|
it("runs a workflow given as gtd.config.ts source", async () => {
|
|
@@ -550,7 +552,8 @@ describe("testRepo", () => {
|
|
|
550
552
|
trailers: { Refs: ["#1", "#2"] },
|
|
551
553
|
})
|
|
552
554
|
await repo.drive({ plan: (_, r) => r.write("PLAN.md", "p\n") })
|
|
553
|
-
expect(repo.commits().at(-
|
|
555
|
+
expect(repo.commits().at(-2)!.trailers["Gtd-Step"]).toEqual(["plan#1"])
|
|
556
|
+
expect(repo.commits().at(-1)!.trailers["Gtd-Pin"]).toEqual(["add"])
|
|
554
557
|
})
|
|
555
558
|
|
|
556
559
|
const greeter = {
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -353,13 +353,15 @@ const expectedVerdict = (doc: BeatDocument): string => {
|
|
|
353
353
|
|
|
354
354
|
const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(", ")
|
|
355
355
|
|
|
356
|
-
/** Who acts at `doc`: its scripted actor, or
|
|
356
|
+
/** Who acts at `doc`: its scripted actor, or its own script at a pin beat or a check under `checks: "run"`. A stall has none. */
|
|
357
357
|
const actorAt = (
|
|
358
358
|
doc: BeatDocument,
|
|
359
359
|
actors: Actors,
|
|
360
360
|
checks: DriveOptions["checks"],
|
|
361
361
|
): { readonly scripted: boolean; readonly actor: ScriptedActor | undefined } => {
|
|
362
362
|
if (doc.kind === "stalled") return { scripted: false, actor: undefined }
|
|
363
|
+
// A drive is a driver: pinning is its own business, not a step to script.
|
|
364
|
+
if (doc.pin !== undefined) return { scripted: false, actor: runsItsScript }
|
|
363
365
|
if (Object.hasOwn(actors, doc.state)) return { scripted: true, actor: actors[doc.state] }
|
|
364
366
|
const runs = checks === "run" && doc.actor === "check"
|
|
365
367
|
return { scripted: false, actor: runs ? runsItsScript : undefined }
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
commitAll,
|
|
8
8
|
commitAsIs,
|
|
9
9
|
commitClean,
|
|
10
|
+
commitPath,
|
|
10
11
|
deleteRef,
|
|
11
12
|
discardPending,
|
|
12
13
|
hardResetTo,
|
|
@@ -24,6 +25,8 @@ import {
|
|
|
24
25
|
contradictionMessage,
|
|
25
26
|
modeContradictionSkipNotice,
|
|
26
27
|
OUTCOME_MARKER,
|
|
28
|
+
pinScript,
|
|
29
|
+
unpinScript,
|
|
27
30
|
} from "../emit/index.js"
|
|
28
31
|
import {
|
|
29
32
|
BUILT_IN_MODE_NAMES,
|
|
@@ -32,7 +35,7 @@ import {
|
|
|
32
35
|
type SteeringFormat,
|
|
33
36
|
} from "../steering/internal.js"
|
|
34
37
|
import { refNameFault } from "../replay/index.js"
|
|
35
|
-
import { renderFormat } from "../wire/index.js"
|
|
38
|
+
import { PIN_PATH, renderFormat } from "../wire/index.js"
|
|
36
39
|
import { applyFlowScript, rewritten } from "./FlowScripts.js"
|
|
37
40
|
import type { InMemRepo } from "./InMemRepo.js"
|
|
38
41
|
import type { CommandTable } from "./Layers.js"
|
|
@@ -174,6 +177,17 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
|
|
|
174
177
|
|
|
175
178
|
if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block)
|
|
176
179
|
|
|
180
|
+
if (block.startsWith("git add -A -- ")) {
|
|
181
|
+
const [path, message] = extractQuotedTokens(block)
|
|
182
|
+
if (path === undefined || message === undefined || commitPath(path, message) !== block) {
|
|
183
|
+
return undefined
|
|
184
|
+
}
|
|
185
|
+
const failure = repo.commitPath(path, message)
|
|
186
|
+
return failure === undefined
|
|
187
|
+
? { kind: "applied" }
|
|
188
|
+
: { kind: "failed", error: failure, status: 1 }
|
|
189
|
+
}
|
|
190
|
+
|
|
177
191
|
if (block.startsWith("git add -A || exit 1\n")) {
|
|
178
192
|
const [message] = extractQuotedTokens(conditionLine)
|
|
179
193
|
if (message !== undefined && commitAll(message) === block) {
|
|
@@ -255,6 +269,22 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
|
|
|
255
269
|
return undefined
|
|
256
270
|
}
|
|
257
271
|
|
|
272
|
+
/** What the in-memory `gtd bundle` prints: a pin header, and no engine behind it. */
|
|
273
|
+
const IN_MEMORY_PIN = "gtdpin 1 gtd in-memory\n"
|
|
274
|
+
|
|
275
|
+
/** A pin or unpin beat's script. */
|
|
276
|
+
const recognizePinScripts = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
277
|
+
if (block === pinScript(PIN_PATH)) {
|
|
278
|
+
repo.writeFile(PIN_PATH, IN_MEMORY_PIN)
|
|
279
|
+
return { kind: "applied" }
|
|
280
|
+
}
|
|
281
|
+
if (block === unpinScript(PIN_PATH)) {
|
|
282
|
+
if (repo.readFile(PIN_PATH) !== undefined) repo.deleteFile(PIN_PATH)
|
|
283
|
+
return { kind: "applied" }
|
|
284
|
+
}
|
|
285
|
+
return undefined
|
|
286
|
+
}
|
|
287
|
+
|
|
258
288
|
/** The REAL `fileExistsGuard` block, re-derived from the extracted path and string-compared. The one recognizer that can report `{ kind: "stopped" }`: a real `exit 0` there ends the script successfully. */
|
|
259
289
|
const recognizeFileExistsGuard = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
260
290
|
const [file] = extractQuotedTokens(block)
|
|
@@ -694,6 +724,7 @@ const recognizersFor = (
|
|
|
694
724
|
(block) => recognizeBinaryGuard(commands, block),
|
|
695
725
|
(block) => (git ? recognizeGitBuilders(repo, block) : undefined),
|
|
696
726
|
(block) => recognizeWriteLines(repo, block),
|
|
727
|
+
(block) => recognizePinScripts(repo, block),
|
|
697
728
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
698
729
|
(block) => recognizePresentationSubshell(repo, commands, block, git),
|
|
699
730
|
(block) => recognizeModeContradictionSkipNotice(block),
|
package/src/twins/GitTiers.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { Effect, Exit, Option } from "effect"
|
|
|
21
21
|
import { Narrator, GitService, Host, type GitOperations, NodeServices } from "../platform/index.js"
|
|
22
22
|
import {
|
|
23
23
|
commitAll,
|
|
24
|
+
commitPath,
|
|
25
|
+
unpinScript,
|
|
24
26
|
commitAsIs,
|
|
25
27
|
commitClean,
|
|
26
28
|
softResetTo,
|
|
@@ -1356,6 +1358,27 @@ export const runGitScriptContract = (makeTier: () => GitTier): void => {
|
|
|
1356
1358
|
})
|
|
1357
1359
|
})
|
|
1358
1360
|
|
|
1361
|
+
describe("commitPath", () => {
|
|
1362
|
+
it("commits the one path; every other pending change stays pending", () => {
|
|
1363
|
+
t.seed.writeFile(".gtd/process.gtdpin", "pin")
|
|
1364
|
+
t.seed.writeFile("other.ts", "export const y = 1")
|
|
1365
|
+
run(t, commitPath(".gtd/process.gtdpin", "gtd(check): pin → building"))
|
|
1366
|
+
expect(t.observe.headSubject()).toBe("gtd(check): pin → building")
|
|
1367
|
+
expect(t.observe.headTouchedPaths()).toEqual([".gtd/process.gtdpin"])
|
|
1368
|
+
expect(t.observe.statusPorcelain()).toContain("other.ts")
|
|
1369
|
+
expect(t.observe.statusPorcelain()).not.toContain("process.gtdpin")
|
|
1370
|
+
})
|
|
1371
|
+
|
|
1372
|
+
it("commits the path's deletion", () => {
|
|
1373
|
+
t.seed.commit("feat: pinned", { ".gtd/process.gtdpin": "pin" })
|
|
1374
|
+
run(t, unpinScript(".gtd/process.gtdpin"))
|
|
1375
|
+
run(t, commitPath(".gtd/process.gtdpin", "gtd(check): unpin → idle"))
|
|
1376
|
+
expect(t.observe.headTouchedPaths()).toEqual([".gtd/process.gtdpin"])
|
|
1377
|
+
expect(t.observe.existsPath(".gtd/process.gtdpin")).toBe(false)
|
|
1378
|
+
expect(t.observe.statusPorcelain().trim()).toBe("")
|
|
1379
|
+
})
|
|
1380
|
+
})
|
|
1381
|
+
|
|
1359
1382
|
describe("commitClean", () => {
|
|
1360
1383
|
it("on a clean tree commits an empty commit with the message", () => {
|
|
1361
1384
|
const headBefore = t.observe.resolveRef("HEAD")
|
package/src/twins/InMemRepo.ts
CHANGED
|
@@ -474,6 +474,20 @@ export class InMemRepo {
|
|
|
474
474
|
this.resetHard()
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
+
/** Twin of `GitScript.commitPath`: commits `path` as the worktree has it, every other staged change left staged. */
|
|
478
|
+
commitPath(path: string, message: string): string | undefined {
|
|
479
|
+
if (!this.worktree.has(path) && !this.headTree().has(path) && !this.index.has(path)) {
|
|
480
|
+
return `error: pathspec '${path}' did not match any file(s) known to git`
|
|
481
|
+
}
|
|
482
|
+
const staged = new Map(this.index)
|
|
483
|
+
this.index = new Map(this.headTree())
|
|
484
|
+
this.stage(path)
|
|
485
|
+
const failure = this.commitAsIs(message)
|
|
486
|
+
this.index = staged
|
|
487
|
+
this.stage(path)
|
|
488
|
+
return failure
|
|
489
|
+
}
|
|
490
|
+
|
|
477
491
|
commitAllWithPrefix(prefix: string): string | undefined {
|
|
478
492
|
this.index = new Map(this.worktree)
|
|
479
493
|
return this.commitAsIs(prefix)
|