@pmelab/gtd 60.1.0 → 62.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 +16 -0
- package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-XQeYCNMn.mjs} +1670 -1470
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +991 -627
- package/dist/gtd.core.mjs +117 -117
- 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/Cli.ts +6 -1
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Launch.test.ts +52 -0
- package/src/cli/Launch.ts +143 -0
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Program.test.ts +30 -20
- package/src/cli/Program.ts +4 -2
- package/src/cli/Strays.ts +3 -2
- package/src/cli/index.ts +1 -0
- package/src/edge/Edge.test.ts +79 -26
- package/src/edge/Edge.ts +178 -99
- 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/platform/PinFile.ts +61 -0
- package/src/platform/index.ts +1 -0
- 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 +11 -48
- package/src/replay/Trailers.ts +36 -42
- 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 -3
- package/src/step/planStart.ts +1 -5
- 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/PinFile.test.ts +107 -0
- package/src/twins/PinFile.ts +12 -0
- package/src/twins/index.ts +2 -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/workflow/fingerprint.ts +2 -2
- package/src/workflow/index.ts +2 -2
- package/src/workflow/load.ts +14 -1
- package/src/workflow/pin.ts +15 -7
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
|
/**
|
|
@@ -56,7 +56,6 @@ const start = (
|
|
|
56
56
|
first: startRefusal === undefined ? { firstStep: "work" } : { refusal: startRefusal },
|
|
57
57
|
commandLabel: req.commandLabel,
|
|
58
58
|
vars: req.vars,
|
|
59
|
-
config: {},
|
|
60
59
|
})
|
|
61
60
|
}).pipe(Effect.provide(testLayers(repo))),
|
|
62
61
|
)
|
|
@@ -177,7 +176,7 @@ describe("planStart", () => {
|
|
|
177
176
|
kind: "gitWrite",
|
|
178
177
|
write: {
|
|
179
178
|
kind: "commitAll",
|
|
180
|
-
message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format:
|
|
179
|
+
message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format: 2",
|
|
181
180
|
},
|
|
182
181
|
},
|
|
183
182
|
{ kind: "outcome", outcome: { kind: "commit", subject: "gtd(human): work" } },
|
|
@@ -196,7 +195,7 @@ describe("planStart", () => {
|
|
|
196
195
|
if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
|
|
197
196
|
throw new Error("expected a gitWrite step")
|
|
198
197
|
expect(write.write.message).toBe(
|
|
199
|
-
"gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format:
|
|
198
|
+
"gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format: 2",
|
|
200
199
|
)
|
|
201
200
|
const outcome = plan.steps.find((s) => s.kind === "outcome")
|
|
202
201
|
expect(outcome).toEqual({
|
package/src/step/planStart.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
ownFlagRefusal,
|
|
8
8
|
resolveStartVars,
|
|
9
9
|
StartFlags,
|
|
10
|
-
type ConfigFingerprint,
|
|
11
10
|
type ResolvedWorkflow,
|
|
12
11
|
} from "../workflow/index.js"
|
|
13
12
|
import { formatCommitMessage, formatSubject } from "../replay/index.js"
|
|
@@ -70,8 +69,7 @@ const checkOverrides = (
|
|
|
70
69
|
* `gtd start <name>`: start a brand NEW process on a workflow, writing an
|
|
71
70
|
* opening commit that names the workflow's first step and carries a
|
|
72
71
|
* `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
|
|
73
|
-
* a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base
|
|
74
|
-
* and a `Gtd-Config:` trailer per config file.
|
|
72
|
+
* and a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base.
|
|
75
73
|
* All validation is a REFUSAL, not an Effect failure.
|
|
76
74
|
*/
|
|
77
75
|
export const planStart = (
|
|
@@ -83,7 +81,6 @@ export const planStart = (
|
|
|
83
81
|
readonly first: { readonly firstStep: string } | { readonly refusal: string }
|
|
84
82
|
readonly commandLabel: string
|
|
85
83
|
readonly vars: Record<string, string>
|
|
86
|
-
readonly config: ConfigFingerprint
|
|
87
84
|
},
|
|
88
85
|
): Effect.Effect<
|
|
89
86
|
StartOutcome,
|
|
@@ -136,7 +133,6 @@ export const planStart = (
|
|
|
136
133
|
workflow: name,
|
|
137
134
|
...(base !== undefined ? { reviewBase: base } : {}),
|
|
138
135
|
vars,
|
|
139
|
-
config: start.config,
|
|
140
136
|
})
|
|
141
137
|
const steps: readonly LandStep[] = [
|
|
142
138
|
{ kind: "gitWrite", write: { kind: "commitAll", message } },
|
|
@@ -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)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { runCli } from "../cli/index.js"
|
|
3
|
+
import type { PinMark } from "../wire/index.js"
|
|
4
|
+
import { makeCapturingCliIo } from "./cliIo.js"
|
|
5
|
+
import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
6
|
+
import type { InMemRepo } from "./InMemRepo.js"
|
|
7
|
+
|
|
8
|
+
const gtd = async (repo: InMemRepo, ...args: string[]) => {
|
|
9
|
+
const { io, result } = makeCapturingCliIo(repo)
|
|
10
|
+
await Effect.runPromise(runCli(["node", "gtd.js", ...args], io))
|
|
11
|
+
return result()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const apply = (repo: InMemRepo, script: string): void => {
|
|
15
|
+
const outcome = applyEmittedScript(repo, new Map(), script)
|
|
16
|
+
if (!outcome.ok) throw new Error(outcome.error)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Takes the pending pin or unpin beat — `mark` alone when given — the way a
|
|
21
|
+
* driver does: its script, then its landing. Whether it took one.
|
|
22
|
+
*/
|
|
23
|
+
export const takePinBeat = async (repo: InMemRepo, mark?: PinMark): Promise<boolean> => {
|
|
24
|
+
const next = await gtd(repo, "next", "--json")
|
|
25
|
+
if (next.exitCode !== 0) return false
|
|
26
|
+
const beat = JSON.parse(next.stdout) as { readonly pin?: PinMark; readonly content: string }
|
|
27
|
+
if (beat.pin === undefined || (mark !== undefined && beat.pin !== mark)) return false
|
|
28
|
+
apply(repo, beat.content)
|
|
29
|
+
const land = await gtd(repo, "land", "--json")
|
|
30
|
+
if (land.exitCode !== 0) throw new Error(`gtd land exited ${land.exitCode}: ${land.stderr}`)
|
|
31
|
+
apply(repo, (JSON.parse(land.stdout) as { readonly script: string }).script)
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
6
|
+
import { livePinFile, type PinFile } from "../platform/index.js"
|
|
7
|
+
import { InMemRepo } from "./index.js"
|
|
8
|
+
import { inMemPinFile } from "./PinFile.js"
|
|
9
|
+
|
|
10
|
+
const PIN = ".gtd/process.gtdpin"
|
|
11
|
+
|
|
12
|
+
interface Tier {
|
|
13
|
+
readonly root: string
|
|
14
|
+
/** The pin a launcher starting at the root finds. */
|
|
15
|
+
readonly pinFile: () => PinFile
|
|
16
|
+
readonly write: (path: string, content: string) => void
|
|
17
|
+
readonly remove: (path: string) => void
|
|
18
|
+
readonly commit: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const liveTier = (): Tier => {
|
|
22
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-pinfile-")))
|
|
23
|
+
cleanups.push(() => rmSync(root, { recursive: true, force: true }))
|
|
24
|
+
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, stdio: "ignore" })
|
|
25
|
+
git("init", "-q")
|
|
26
|
+
return {
|
|
27
|
+
root,
|
|
28
|
+
pinFile: () => livePinFile(root, PIN),
|
|
29
|
+
write: (path, content) => {
|
|
30
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
31
|
+
writeFileSync(join(root, path), content)
|
|
32
|
+
},
|
|
33
|
+
remove: (path) => rmSync(join(root, path)),
|
|
34
|
+
commit: () => {
|
|
35
|
+
git("add", "-A")
|
|
36
|
+
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "c")
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const inMemTier = (): Tier => {
|
|
42
|
+
const repo = new InMemRepo()
|
|
43
|
+
const root = "/repo"
|
|
44
|
+
return {
|
|
45
|
+
root,
|
|
46
|
+
pinFile: () => inMemPinFile(repo, root, PIN),
|
|
47
|
+
write: (path, content) => repo.writeFile(path, content),
|
|
48
|
+
remove: (path) => repo.deleteFile(path),
|
|
49
|
+
commit: () => repo.seedCommit("c"),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const cleanups: (() => void)[] = []
|
|
54
|
+
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
|
|
55
|
+
|
|
56
|
+
const text = (pinFile: PinFile) => {
|
|
57
|
+
const found = pinFile()
|
|
58
|
+
return found && { path: found.path, content: Buffer.from(found.bytes).toString() }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe.each([
|
|
62
|
+
["live", liveTier],
|
|
63
|
+
["in-memory", inMemTier],
|
|
64
|
+
])("PinFile contract: %s", (_, make) => {
|
|
65
|
+
it("finds nothing without a pin", () => {
|
|
66
|
+
const tier = make()
|
|
67
|
+
tier.write("README.md", "x")
|
|
68
|
+
tier.commit()
|
|
69
|
+
expect(text(tier.pinFile())).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("finds the worktree's pin, committed or not", () => {
|
|
73
|
+
const tier = make()
|
|
74
|
+
tier.write(PIN, "pinned")
|
|
75
|
+
expect(text(tier.pinFile())).toEqual({ path: join(tier.root, PIN), content: "pinned" })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("prefers the worktree's pin over HEAD's", () => {
|
|
79
|
+
const tier = make()
|
|
80
|
+
tier.write(PIN, "committed")
|
|
81
|
+
tier.commit()
|
|
82
|
+
tier.write(PIN, "rewritten")
|
|
83
|
+
expect(text(tier.pinFile())?.content).toBe("rewritten")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("falls back to HEAD's pin once the worktree's is deleted", () => {
|
|
87
|
+
const tier = make()
|
|
88
|
+
tier.write(PIN, "committed")
|
|
89
|
+
tier.commit()
|
|
90
|
+
tier.remove(PIN)
|
|
91
|
+
expect(text(tier.pinFile())).toEqual({ path: join(tier.root, PIN), content: "committed" })
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("livePinFile", () => {
|
|
96
|
+
it("reads an unreadable pin as no pin's bytes", () => {
|
|
97
|
+
const tier = liveTier()
|
|
98
|
+
tier.write(`${PIN}/inside`, "x")
|
|
99
|
+
expect(text(tier.pinFile())).toEqual({ path: join(tier.root, PIN), content: "" })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("finds nothing outside a repository", () => {
|
|
103
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "gtd-pinfile-")))
|
|
104
|
+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }))
|
|
105
|
+
expect(livePinFile(dir, PIN)()).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import type { PinFile } from "../platform/index.js"
|
|
3
|
+
import type { InMemRepo } from "./InMemRepo.js"
|
|
4
|
+
|
|
5
|
+
export const inMemPinFile =
|
|
6
|
+
(repo: InMemRepo, root: string, path: string): PinFile =>
|
|
7
|
+
() => {
|
|
8
|
+
const content = repo.readFile(path) ?? repo.fileAtRef("HEAD", path) ?? undefined
|
|
9
|
+
return content === undefined
|
|
10
|
+
? undefined
|
|
11
|
+
: { path: join(root, path), bytes: Buffer.from(content) }
|
|
12
|
+
}
|
package/src/twins/index.ts
CHANGED
|
@@ -11,3 +11,5 @@ export { applyFlowScript } from "./FlowScripts.js"
|
|
|
11
11
|
export { gitTiers, runGitServiceContract, runGitScriptContract, type GitTier } from "./GitTiers.js"
|
|
12
12
|
export { strictGitOperations } from "./StrictGitOperations.js"
|
|
13
13
|
export { fakeGitOperations } from "./FakeGitOperations.js"
|
|
14
|
+
export { takePinBeat } from "./PinBeats.js"
|
|
15
|
+
export { inMemPinFile } from "./PinFile.js"
|
package/src/wire/BeatDocument.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { BeatKind, Demand, DemandSession } from "./Demand.js"
|
|
2
2
|
import type { BeatStatus, NextMatch, StatusChange } from "./BeatStatus.js"
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
Actor,
|
|
5
|
+
ApprovalEntry,
|
|
6
|
+
ModelCost,
|
|
7
|
+
PinMark,
|
|
8
|
+
StateMode,
|
|
9
|
+
StateName,
|
|
10
|
+
StepAccess,
|
|
11
|
+
} from "./types.js"
|
|
4
12
|
import { WIRE_SCHEMA } from "./constants.js"
|
|
5
13
|
|
|
6
14
|
/** `gtd next --json`'s `next` key — `null` when landing would commit nothing. */
|
|
@@ -27,20 +35,30 @@ const dispatchFieldsOf = (
|
|
|
27
35
|
readonly validate: string | undefined
|
|
28
36
|
readonly skills: readonly string[] | undefined
|
|
29
37
|
readonly access: StepAccess | undefined
|
|
38
|
+
readonly pin: PinMark | undefined
|
|
30
39
|
} => {
|
|
40
|
+
const none = {
|
|
41
|
+
session: undefined,
|
|
42
|
+
validate: undefined,
|
|
43
|
+
skills: undefined,
|
|
44
|
+
access: undefined,
|
|
45
|
+
pin: undefined,
|
|
46
|
+
}
|
|
31
47
|
switch (demand.kind) {
|
|
32
48
|
case "prompt":
|
|
33
49
|
return {
|
|
50
|
+
...none,
|
|
34
51
|
session: demand.session,
|
|
35
52
|
validate: demand.validate,
|
|
36
53
|
skills: demand.skills,
|
|
37
54
|
access: demand.access,
|
|
38
55
|
}
|
|
56
|
+
case "script":
|
|
57
|
+
return { ...none, pin: demand.pin }
|
|
39
58
|
case "capture":
|
|
40
59
|
case "message":
|
|
41
|
-
case "script":
|
|
42
60
|
case "stalled":
|
|
43
|
-
return
|
|
61
|
+
return none
|
|
44
62
|
default:
|
|
45
63
|
return assertNeverDemand(demand)
|
|
46
64
|
}
|
|
@@ -57,6 +75,8 @@ export interface BeatDocument {
|
|
|
57
75
|
readonly model: string | undefined
|
|
58
76
|
readonly system: string | undefined
|
|
59
77
|
readonly validate: string | undefined
|
|
78
|
+
/** On a pin or unpin beat only: what its script does to `.gtd/process.gtdpin`. */
|
|
79
|
+
readonly pin: PinMark | undefined
|
|
60
80
|
readonly log: string
|
|
61
81
|
readonly state: StateName
|
|
62
82
|
readonly actor: Actor
|
|
@@ -76,13 +96,13 @@ export interface BeatDocument {
|
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
/**
|
|
79
|
-
* Flatten a `Demand` plus its `BeatStatus` into the single
|
|
99
|
+
* Flatten a `Demand` plus its `BeatStatus` into the single 26-key document
|
|
80
100
|
* `gtd next --json` emits — the ONLY place the two are joined. `cost`/
|
|
81
101
|
* `costByModel` are omitted together, exactly when no cost was recorded
|
|
82
102
|
* (`cost <= 0`).
|
|
83
103
|
*/
|
|
84
104
|
export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument => {
|
|
85
|
-
const { session, validate, skills, access } = dispatchFieldsOf(demand)
|
|
105
|
+
const { session, validate, skills, access, pin } = dispatchFieldsOf(demand)
|
|
86
106
|
const hasCost = status.cost > 0
|
|
87
107
|
return {
|
|
88
108
|
schema: WIRE_SCHEMA,
|
|
@@ -94,6 +114,7 @@ export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument =
|
|
|
94
114
|
model: status.model,
|
|
95
115
|
system: status.system,
|
|
96
116
|
validate,
|
|
117
|
+
pin,
|
|
97
118
|
log: status.log,
|
|
98
119
|
state: status.state,
|
|
99
120
|
actor: status.actor,
|
package/src/wire/Demand.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Actor,
|
|
3
|
+
ContentKind,
|
|
4
|
+
PinMark,
|
|
5
|
+
RenderedDemandSource,
|
|
6
|
+
StateName,
|
|
7
|
+
StepAccess,
|
|
8
|
+
} from "./types.js"
|
|
2
9
|
|
|
3
10
|
/** The whole beat vocabulary a driver acts on — what to DO with a rest, not merely whether dispatching it is safe. */
|
|
4
11
|
export type BeatKind = "capture" | "message" | "script" | "prompt" | "stalled"
|
|
@@ -72,7 +79,7 @@ export interface DemandSession {
|
|
|
72
79
|
export type Demand =
|
|
73
80
|
| { readonly kind: "capture"; readonly content: string }
|
|
74
81
|
| { readonly kind: "message"; readonly content: string }
|
|
75
|
-
| { readonly kind: "script"; readonly content: string }
|
|
82
|
+
| { readonly kind: "script"; readonly content: string; readonly pin?: PinMark }
|
|
76
83
|
| { readonly kind: "stalled"; readonly content: string }
|
|
77
84
|
| {
|
|
78
85
|
readonly kind: "prompt"
|
|
@@ -91,7 +98,10 @@ export type Demand =
|
|
|
91
98
|
* any other kind.
|
|
92
99
|
*/
|
|
93
100
|
export const demandOf = (input: {
|
|
94
|
-
readonly rendered: Pick<
|
|
101
|
+
readonly rendered: Pick<
|
|
102
|
+
RenderedDemandSource,
|
|
103
|
+
"actor" | "content" | "state" | "skills" | "access" | "pin"
|
|
104
|
+
>
|
|
95
105
|
readonly kind: BeatKind
|
|
96
106
|
readonly session?: DemandSession
|
|
97
107
|
readonly validate?: string
|
|
@@ -102,7 +112,7 @@ export const demandOf = (input: {
|
|
|
102
112
|
const skills =
|
|
103
113
|
rendered.skills !== undefined && rendered.skills.length > 0 ? rendered.skills : undefined
|
|
104
114
|
const access = rendered.access ?? { read: null, write: null }
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
if (kind === "prompt") return { kind, content, session, validate, skills, access }
|
|
116
|
+
if (kind === "script" && rendered.pin !== undefined) return { kind, content, pin: rendered.pin }
|
|
117
|
+
return { kind, content }
|
|
108
118
|
}
|
package/src/wire/constants.ts
CHANGED
|
@@ -20,3 +20,6 @@ export const renderFormat = (fmt: string, ...args: readonly string[]): string =>
|
|
|
20
20
|
/** The first line of the landing refusal for a turn that wrote outside its `write` access — a stable marker drivers and tests grep for. */
|
|
21
21
|
export const ACCESS_REFUSAL =
|
|
22
22
|
"gtd land: this turn wrote outside its write access — revert these paths, then run `gtd land` again:"
|
|
23
|
+
|
|
24
|
+
/** Where a process keeps its pin: the engine and workflow it runs on, as `gtd bundle` prints them. */
|
|
25
|
+
export const PIN_PATH = ".gtd/process.gtdpin"
|