@pmelab/gtd 60.0.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 +21 -2
- package/dist/chunks/testkit-C9sq0x07.mjs +30758 -0
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +12238 -20706
- package/dist/gtd.core.mjs +593 -0
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +2 -2
- package/package.json +7 -7
- package/src/cli/Cli.test.ts +2 -2
- package/src/cli/Cli.ts +68 -51
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Output.test.ts +1 -1
- package/src/cli/Output.ts +3 -2
- package/src/cli/Program.test.ts +44 -25
- package/src/cli/Program.ts +39 -11
- package/src/cli/Strays.ts +3 -2
- package/src/cli/ToolCommands.ts +2 -2
- package/src/edge/Edge.test.ts +86 -31
- package/src/edge/Edge.ts +164 -31
- package/src/emit/GitScript.ts +21 -3
- package/src/emit/index.ts +3 -0
- package/src/flows/runtime.ts +1 -1
- 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/install/Version.ts +9 -3
- package/src/install/index.ts +1 -1
- package/src/platform/CommandRunner.ts +13 -11
- package/src/platform/Commentary.ts +2 -2
- package/src/platform/Git.test.ts +4 -5
- package/src/platform/Git.ts +42 -38
- package/src/platform/Host.ts +1 -1
- package/src/platform/NodeServices.ts +11 -0
- package/src/platform/Workspace.test.ts +4 -5
- package/src/platform/Workspace.ts +3 -3
- package/src/platform/WorktreeState.test.ts +2 -3
- package/src/platform/WorktreeState.ts +1 -1
- package/src/platform/index.ts +2 -1
- 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/planStart.ts +3 -3
- 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/EmittedScripts.test.ts +1 -1
- package/src/twins/GitTiers.ts +29 -7
- package/src/twins/InMemRepo.ts +14 -0
- package/src/twins/Layers.test.ts +1 -1
- package/src/twins/Layers.ts +10 -3
- package/src/twins/PinBeats.ts +33 -0
- package/src/twins/cliIo.ts +9 -4
- 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/workflow/discovery.test.ts +24 -7
- package/src/workflow/discovery.ts +5 -6
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +60 -3
- package/src/workflow/load.ts +316 -166
- package/src/workflow/pin.test.ts +159 -0
- package/src/workflow/pin.ts +72 -0
- package/src/workflow/vars.ts +3 -3
- package/dist/chunks/testkit-BvecVjQ0.mjs +0 -32355
package/src/edge/Edge.test.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
applyEmittedScript,
|
|
31
31
|
makeCapturingCliIo,
|
|
32
32
|
makeInMemoryWorkspaceOps,
|
|
33
|
+
takePinBeat,
|
|
33
34
|
testLayers,
|
|
34
35
|
} from "../twins/index.js"
|
|
35
36
|
|
|
@@ -74,7 +75,7 @@ const write = (repo: InMemRepo, files: Files): void => {
|
|
|
74
75
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
const headOf = (repo: InMemRepo): string => repo.resolveRef(
|
|
78
|
+
const headOf = (repo: InMemRepo, ref = "HEAD"): string => repo.resolveRef(ref)!
|
|
78
79
|
|
|
79
80
|
const repoWith = (config: string, files: Files = {}): InMemRepo => {
|
|
80
81
|
const repo = new InMemRepo()
|
|
@@ -95,19 +96,27 @@ const applied = (repo: InMemRepo, script: string): string => {
|
|
|
95
96
|
return headOf(repo)
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Lands the pending turn the way a driver does — `gtd land --json`, then its
|
|
101
|
+
* script, then the pin or unpin beat it leads to — and returns the turn's
|
|
102
|
+
* commit, or the closing commit when the turn led to the unpin.
|
|
103
|
+
*/
|
|
99
104
|
const land = async (repo: InMemRepo, files: Files = {}): Promise<string> => {
|
|
100
105
|
write(repo, files)
|
|
101
106
|
const { stdout, stderr, exitCode } = await cli(repo, "land", "--json")
|
|
102
107
|
if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
|
|
103
|
-
|
|
108
|
+
const turn = applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
|
|
109
|
+
await takePinBeat(repo, "add")
|
|
110
|
+
return (await takePinBeat(repo, "remove")) ? headOf(repo) : turn
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
const enter = async (repo: InMemRepo, workflow: string, ...vars: string[]): Promise<string> => {
|
|
107
114
|
const flags = vars.flatMap((v) => ["--var", v])
|
|
108
115
|
const { stdout, stderr, exitCode } = await cli(repo, "start", workflow, ...flags)
|
|
109
116
|
if (exitCode !== 0) throw new Error(`gtd start exited ${exitCode}: ${stderr}`)
|
|
110
|
-
|
|
117
|
+
const opening = applied(repo, stdout)
|
|
118
|
+
await takePinBeat(repo, "add")
|
|
119
|
+
return opening
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
/** Commits an exact message — for trailers no landing writes on its own. */
|
|
@@ -174,7 +183,11 @@ describe("currentRun", () => {
|
|
|
174
183
|
{ state: "checking", hash: checking, actor: "agent" },
|
|
175
184
|
])
|
|
176
185
|
expect(run.episode.base).toBe(boundary)
|
|
177
|
-
expect(run.episode.commits.map((c) => c.
|
|
186
|
+
expect(run.episode.commits.map((c) => c.message.split("\n")[0])).toEqual([
|
|
187
|
+
"gtd(human): idle → building",
|
|
188
|
+
"gtd(check): pin → building",
|
|
189
|
+
"gtd(agent): building → checking",
|
|
190
|
+
])
|
|
178
191
|
})
|
|
179
192
|
|
|
180
193
|
it("a non-gtd commit on top of a process bounds a fresh episode", async () => {
|
|
@@ -192,7 +205,7 @@ describe("currentRun", () => {
|
|
|
192
205
|
await land(repo, { "a.txt": "a\n" })
|
|
193
206
|
await land(repo, { "b.txt": "b\n" })
|
|
194
207
|
const closing = await land(repo, { "c.txt": "c\n" })
|
|
195
|
-
expect(repo.lastCommitSubject()).toBe("gtd(
|
|
208
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
|
|
196
209
|
const next = await land(repo, { "d.txt": "d\n" })
|
|
197
210
|
const run = await provide(currentRun, repo)
|
|
198
211
|
expect(run.startParentHash).toBe(closing)
|
|
@@ -203,8 +216,8 @@ describe("currentRun", () => {
|
|
|
203
216
|
const repo = repoWith(LINEAR)
|
|
204
217
|
const before = headOf(repo)
|
|
205
218
|
const opening = await enter(repo, "side")
|
|
206
|
-
expect(repo.lastCommitMessage()).toBe(
|
|
207
|
-
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Config: ${contentHash(LINEAR)} gtd.config.ts\nGtd-Format:
|
|
219
|
+
expect(repo.lastCommitMessage(opening)).toBe(
|
|
220
|
+
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Config: ${contentHash(LINEAR)} gtd.config.ts\nGtd-Format: 2`,
|
|
208
221
|
)
|
|
209
222
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
210
223
|
const run = await provide(currentRun, repo)
|
|
@@ -216,7 +229,7 @@ describe("currentRun", () => {
|
|
|
216
229
|
{ state: "tidying", hash: fixed, actor: "agent" },
|
|
217
230
|
])
|
|
218
231
|
expect(run.episode.base).toBe(opening)
|
|
219
|
-
expect(run.episode.commits.map((c) => c.hash)).toEqual([fixed])
|
|
232
|
+
expect(run.episode.commits.map((c) => c.hash)).toEqual([headOf(repo, `${fixed}~1`), fixed])
|
|
220
233
|
})
|
|
221
234
|
|
|
222
235
|
it("a workflow starting at the default's initial step still opens its own episode, not the empty tree", async () => {
|
|
@@ -236,8 +249,8 @@ export const again = workflow(async () => {
|
|
|
236
249
|
const repo = repoWith(STARTS_AT_INITIAL)
|
|
237
250
|
const before = headOf(repo)
|
|
238
251
|
const opening = await enter(repo, "again")
|
|
239
|
-
expect(repo.lastCommitMessage()).toBe(
|
|
240
|
-
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Config: ${contentHash(STARTS_AT_INITIAL)} gtd.config.ts\nGtd-Format:
|
|
252
|
+
expect(repo.lastCommitMessage(opening)).toBe(
|
|
253
|
+
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Config: ${contentHash(STARTS_AT_INITIAL)} gtd.config.ts\nGtd-Format: 2`,
|
|
241
254
|
)
|
|
242
255
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
243
256
|
const run = await provide(currentRun, repo)
|
|
@@ -374,15 +387,23 @@ export const again = workflow(async () => {
|
|
|
374
387
|
it("describes a step commit that changed something", async () => {
|
|
375
388
|
const repo = repoWith(LINEAR)
|
|
376
389
|
await land(repo, { "a.txt": "a\n" })
|
|
390
|
+
await land(repo, { "b.txt": "b\n" })
|
|
377
391
|
const run = await provide(currentRun, repo)
|
|
378
|
-
expect(run.headTurn).toEqual({ state: "
|
|
392
|
+
expect(run.headTurn).toEqual({ state: "checking", actor: "agent", empty: false, step: true })
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it("is undefined at a pin commit, which is no turn", async () => {
|
|
396
|
+
const repo = repoWith(LINEAR)
|
|
397
|
+
await land(repo, { "a.txt": "a\n" })
|
|
398
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): pin → building")
|
|
399
|
+
expect((await provide(currentRun, repo)).headTurn).toBeUndefined()
|
|
379
400
|
})
|
|
380
401
|
|
|
381
402
|
it("describes an attempt: a step-less, empty commit", async () => {
|
|
382
403
|
const repo = repoWith(LINEAR)
|
|
383
404
|
await land(repo, { "a.txt": "a\n" })
|
|
384
405
|
await land(repo)
|
|
385
|
-
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format:
|
|
406
|
+
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 2")
|
|
386
407
|
const run = await provide(currentRun, repo)
|
|
387
408
|
expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
|
|
388
409
|
})
|
|
@@ -469,12 +490,14 @@ describe("history reads stay bounded by the episode, not the repository", () =>
|
|
|
469
490
|
const raceyGit: GitOperations = {
|
|
470
491
|
...base,
|
|
471
492
|
subjectHistory: (pageSize, skip, head) =>
|
|
472
|
-
Effect.tap(base.subjectHistory(pageSize, skip, head), () =>
|
|
473
|
-
|
|
474
|
-
moved
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
493
|
+
Effect.tap(base.subjectHistory(pageSize, skip, head), () =>
|
|
494
|
+
Effect.sync(() => {
|
|
495
|
+
if (!moved) {
|
|
496
|
+
moved = true
|
|
497
|
+
repo.hardResetTo(boundary)
|
|
498
|
+
}
|
|
499
|
+
}),
|
|
500
|
+
),
|
|
478
501
|
}
|
|
479
502
|
|
|
480
503
|
const run = await provideWithGit(currentRun, raceyGit, repo)
|
|
@@ -521,7 +544,8 @@ describe("summaryRun", () => {
|
|
|
521
544
|
expect(summary.trace).toEqual([
|
|
522
545
|
{ state: "building", hash: building, actor: "human" },
|
|
523
546
|
{ state: "checking", hash: checking, actor: "agent" },
|
|
524
|
-
{ state: "
|
|
547
|
+
{ state: "unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
|
|
548
|
+
{ state: "idle", hash: closing, actor: "check" },
|
|
525
549
|
])
|
|
526
550
|
})
|
|
527
551
|
|
|
@@ -633,7 +657,9 @@ export default workflow(async () => {
|
|
|
633
657
|
`--model=${model}`,
|
|
634
658
|
)
|
|
635
659
|
if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
|
|
636
|
-
|
|
660
|
+
const turn = applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
|
|
661
|
+
await takePinBeat(repo, "add")
|
|
662
|
+
return (await takePinBeat(repo, "remove")) ? headOf(repo) : turn
|
|
637
663
|
}
|
|
638
664
|
type Rendered = {
|
|
639
665
|
entryCommit: string
|
|
@@ -709,6 +735,7 @@ describe("restAt", () => {
|
|
|
709
735
|
)
|
|
710
736
|
expect(exitCode).toBe(0)
|
|
711
737
|
await applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
|
|
738
|
+
await takePinBeat(repo, "add")
|
|
712
739
|
}
|
|
713
740
|
const { cost } = await provide(currentRest, repo)
|
|
714
741
|
expect(cost.total).toBe(11)
|
|
@@ -804,6 +831,7 @@ export const review = workflow(async () => {
|
|
|
804
831
|
it("is the base the resting step names", async () => {
|
|
805
832
|
const repo = repoWith(LOOP)
|
|
806
833
|
const first = await land(repo, { "a.txt": "a\n" })
|
|
834
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): pin → checkpoint")
|
|
807
835
|
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
|
|
808
836
|
await land(repo, { "b.txt": "b\n" })
|
|
809
837
|
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
|
|
@@ -1041,8 +1069,8 @@ export default workflow(async () => {
|
|
|
1041
1069
|
|
|
1042
1070
|
it("an ordinary start's first commit pins every process setting, sorted by name", async () => {
|
|
1043
1071
|
const repo = repoWith(LINEAR)
|
|
1044
|
-
await land(repo, { "a.txt": "a\n" })
|
|
1045
|
-
expect(repo.lastCommitMessage()).toContain(
|
|
1072
|
+
const start = await land(repo, { "a.txt": "a\n" })
|
|
1073
|
+
expect(repo.lastCommitMessage(start)).toContain(
|
|
1046
1074
|
"\n\nGtd-Step: idle#1\nGtd-Var: base=\nGtd-Var: reviewer=nobody",
|
|
1047
1075
|
)
|
|
1048
1076
|
await land(repo)
|
|
@@ -1263,13 +1291,34 @@ describe("finishing landings keep history", () => {
|
|
|
1263
1291
|
return landing
|
|
1264
1292
|
}
|
|
1265
1293
|
|
|
1294
|
+
/** Lands the turn that ends the flow, and runs the unpin beat's script without landing it. */
|
|
1295
|
+
const toUnpin = async (repo: InMemRepo, files: Files) => {
|
|
1296
|
+
write(repo, files)
|
|
1297
|
+
const turn = await cli(repo, "land", "--json")
|
|
1298
|
+
applied(repo, (JSON.parse(turn.stdout) as { readonly script: string }).script)
|
|
1299
|
+
const beat = await cli(repo, "next", "--json")
|
|
1300
|
+
applied(repo, (JSON.parse(beat.stdout) as { readonly content: string }).content)
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
it("a pinned process's last turn lands at the unpin, keeping no history yet", async () => {
|
|
1304
|
+
const repo = repoWith(LINEAR)
|
|
1305
|
+
await land(repo, { "a.txt": "a\n" })
|
|
1306
|
+
await land(repo, { "b.txt": "b\n" })
|
|
1307
|
+
repo.writeFile("c.txt", "c\n")
|
|
1308
|
+
const landing = await landingOf(repo)
|
|
1309
|
+
expect(landing.to).toBe("unpin")
|
|
1310
|
+
expect(landing.keepHistory).toBeUndefined()
|
|
1311
|
+
expect(landing.spec.version).toBeUndefined()
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1266
1314
|
it("flow return: keeps refs/gtd/history/<startHash>, records version and branch", async () => {
|
|
1267
1315
|
const repo = repoWith(LINEAR)
|
|
1268
1316
|
const building = await land(repo, { "a.txt": "a\n" })
|
|
1269
1317
|
await land(repo, { "b.txt": "b\n" })
|
|
1270
|
-
repo
|
|
1318
|
+
await toUnpin(repo, { "c.txt": "c\n" })
|
|
1271
1319
|
const landing = await landingOf(repo)
|
|
1272
1320
|
expect(landing.to).toBe("idle")
|
|
1321
|
+
expect(landing.spec.pin).toBe("remove")
|
|
1273
1322
|
expect(landing.keepHistory).toBe(processHistoryRef(building))
|
|
1274
1323
|
expect(landing.spec.version).toBe(GTD_VERSION)
|
|
1275
1324
|
expect(landing.spec.branch).toBe(repo.branchName())
|
|
@@ -1285,7 +1334,7 @@ export default async () => {
|
|
|
1285
1334
|
}
|
|
1286
1335
|
`)
|
|
1287
1336
|
const building = await land(repo, { "a.txt": "a\n" })
|
|
1288
|
-
repo
|
|
1337
|
+
await toUnpin(repo, { "b.txt": "b\n" })
|
|
1289
1338
|
const landing = await landingOf(repo)
|
|
1290
1339
|
expect(landing.to).toBe("idle")
|
|
1291
1340
|
expect(landing.keepHistory).toBe(processHistoryRef(building))
|
|
@@ -1312,7 +1361,7 @@ export default async () => {
|
|
|
1312
1361
|
const repo = repoWith(LINEAR)
|
|
1313
1362
|
const building = await land(repo, { "a.txt": "a\n" })
|
|
1314
1363
|
await land(repo, { "b.txt": "b\n" })
|
|
1315
|
-
repo
|
|
1364
|
+
await toUnpin(repo, { "c.txt": "c\n" })
|
|
1316
1365
|
const detached: GitOperations = {
|
|
1317
1366
|
...fakeGitOperations(repo),
|
|
1318
1367
|
currentBranch: () => Effect.succeed(Option.none()),
|
|
@@ -1336,7 +1385,7 @@ export const side = workflow(async () => {
|
|
|
1336
1385
|
})
|
|
1337
1386
|
`)
|
|
1338
1387
|
const opening = await enter(repo, "side")
|
|
1339
|
-
repo
|
|
1388
|
+
await toUnpin(repo, { "a.txt": "a\n" })
|
|
1340
1389
|
const landing = await landingOf(repo)
|
|
1341
1390
|
expect(landing.to).toBe("idle")
|
|
1342
1391
|
expect(landing.keepHistory).toBe(processHistoryRef(opening))
|
|
@@ -1466,14 +1515,20 @@ describe("historyTrail", () => {
|
|
|
1466
1515
|
const trail = await provide(historyTrail(tip), repo)
|
|
1467
1516
|
expect(trail.tip).toBe(tip)
|
|
1468
1517
|
expect(trail.ended).toBe("return")
|
|
1469
|
-
expect(trail.steps.map((s) => s.hash)).toEqual([
|
|
1518
|
+
expect(trail.steps.map((s) => s.hash)).toEqual([
|
|
1519
|
+
building,
|
|
1520
|
+
checking,
|
|
1521
|
+
headOf(repo, `${tip}~1`),
|
|
1522
|
+
tip,
|
|
1523
|
+
])
|
|
1470
1524
|
expect(trail.steps[2]).toMatchObject({
|
|
1471
|
-
subject: "gtd(agent): checking →
|
|
1525
|
+
subject: "gtd(agent): checking → unpin",
|
|
1472
1526
|
actor: "agent",
|
|
1473
1527
|
from: "checking",
|
|
1474
|
-
to: "
|
|
1528
|
+
to: "unpin",
|
|
1475
1529
|
verdicts: [],
|
|
1476
1530
|
})
|
|
1531
|
+
expect(trail.steps[3]).toMatchObject({ subject: "gtd(check): unpin → idle", actor: "check" })
|
|
1477
1532
|
expect(trail.steps[0]).toMatchObject({ actor: "human", to: "building" })
|
|
1478
1533
|
})
|
|
1479
1534
|
|
|
@@ -1626,7 +1681,7 @@ export default async () => {
|
|
|
1626
1681
|
)
|
|
1627
1682
|
}).pipe(Effect.provide(testLayers(repo, {}))),
|
|
1628
1683
|
)
|
|
1629
|
-
expect(trail.steps).toHaveLength(
|
|
1684
|
+
expect(trail.steps).toHaveLength(4)
|
|
1630
1685
|
expect(calls).toBe(1)
|
|
1631
1686
|
})
|
|
1632
1687
|
|
package/src/edge/Edge.ts
CHANGED
|
@@ -37,18 +37,23 @@ import {
|
|
|
37
37
|
isRecordableApproval,
|
|
38
38
|
parseCommitMessage,
|
|
39
39
|
replay,
|
|
40
|
+
type CommitMessage,
|
|
40
41
|
scopeRunStart,
|
|
41
42
|
treeFromRecord,
|
|
42
43
|
type EpisodeCommit,
|
|
43
44
|
type JudgeVerdict,
|
|
45
|
+
type PinChange,
|
|
46
|
+
PIN_STATES,
|
|
44
47
|
type ReachedStep,
|
|
45
48
|
type ReplayOutcome,
|
|
46
49
|
type TreeView,
|
|
47
50
|
} from "../replay/index.js"
|
|
48
51
|
import type { Mark, ScopeAccess, SummaryContext } from "../flows/index.js"
|
|
49
52
|
import { steeringFormatFor } from "../steering/index.js"
|
|
53
|
+
import { pinScript, unpinScript } from "../emit/index.js"
|
|
50
54
|
import {
|
|
51
55
|
judgeJson,
|
|
56
|
+
PIN_PATH,
|
|
52
57
|
UNATTRIBUTED_MODEL,
|
|
53
58
|
type ApprovalEntry,
|
|
54
59
|
type ModelCost,
|
|
@@ -87,6 +92,16 @@ interface EpisodeLocation {
|
|
|
87
92
|
readonly processStart: number
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
const isOpening = ({ parsed, step }: CommitMessage): boolean =>
|
|
96
|
+
parsed !== undefined &&
|
|
97
|
+
step === undefined &&
|
|
98
|
+
parsed.from === undefined &&
|
|
99
|
+
parsed.actor === "human"
|
|
100
|
+
|
|
101
|
+
// A pin commit's subject may name the first step; it bounds nothing.
|
|
102
|
+
const boundsEpisode = (def: WorkflowDefinition, { parsed, pin }: CommitMessage): boolean =>
|
|
103
|
+
pin !== "add" && (parsed === undefined || !ACTORS.has(parsed.actor) || parsed.to === def.initial)
|
|
104
|
+
|
|
90
105
|
/**
|
|
91
106
|
* Where the episode HEAD belongs to begins, read off subjects alone. Walking
|
|
92
107
|
* back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
|
|
@@ -99,13 +114,7 @@ interface EpisodeLocation {
|
|
|
99
114
|
const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocation => {
|
|
100
115
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
101
116
|
const message = parseCommitMessage(history[i]!.message)
|
|
102
|
-
|
|
103
|
-
const opening =
|
|
104
|
-
subject !== undefined &&
|
|
105
|
-
message.step === undefined &&
|
|
106
|
-
subject.from === undefined &&
|
|
107
|
-
subject.actor === "human"
|
|
108
|
-
if (opening) {
|
|
117
|
+
if (isOpening(message)) {
|
|
109
118
|
return {
|
|
110
119
|
workflow: message.workflow,
|
|
111
120
|
legacyOpening: message.workflow === undefined,
|
|
@@ -113,7 +122,7 @@ const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocati
|
|
|
113
122
|
processStart: i,
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
|
-
if (
|
|
125
|
+
if (boundsEpisode(def, message)) {
|
|
117
126
|
return { workflow: undefined, legacyOpening: false, baseIndex: i, processStart: i + 1 }
|
|
118
127
|
}
|
|
119
128
|
}
|
|
@@ -156,6 +165,8 @@ export interface ProcessRun {
|
|
|
156
165
|
readonly trace: readonly TraceEntry[]
|
|
157
166
|
readonly costEntries: readonly CostEntry[]
|
|
158
167
|
readonly judgeVerdicts: readonly JudgeVerdictEntry[]
|
|
168
|
+
/** Whether a pin commit has landed in this process. */
|
|
169
|
+
readonly pinCommitted: boolean
|
|
159
170
|
/** Every process commit's `Gtd-Mark`s, oldest first. */
|
|
160
171
|
readonly marks: readonly Mark[]
|
|
161
172
|
/** Oldest first. */
|
|
@@ -186,7 +197,8 @@ const headTurnOf = (head: History[number] | undefined): ProcessRun["headTurn"] =
|
|
|
186
197
|
if (head === undefined) return undefined
|
|
187
198
|
const message = parseCommitMessage(head.message)
|
|
188
199
|
const subject = message.parsed
|
|
189
|
-
if (subject === undefined || !ACTORS.has(subject.actor)
|
|
200
|
+
if (subject === undefined || !ACTORS.has(subject.actor) || message.pin !== undefined)
|
|
201
|
+
return undefined
|
|
190
202
|
return {
|
|
191
203
|
state: subject.to,
|
|
192
204
|
actor: subject.actor,
|
|
@@ -267,8 +279,11 @@ const buildRun = (
|
|
|
267
279
|
location: EpisodeLocation,
|
|
268
280
|
closingHash: string | undefined,
|
|
269
281
|
): ProcessRun => {
|
|
270
|
-
const
|
|
271
|
-
const
|
|
282
|
+
const allProcessCommits = history.slice(location.processStart)
|
|
283
|
+
const allParsed = allProcessCommits.map((c) => parseCommitMessage(c.message))
|
|
284
|
+
// A pin commit is no turn; the unpin commit stays, as the one that closes the process.
|
|
285
|
+
const processCommits = allProcessCommits.filter((_, i) => allParsed[i]!.pin !== "add")
|
|
286
|
+
const parsed = allParsed.filter((m) => m.pin !== "add")
|
|
272
287
|
const first = parsed[0]
|
|
273
288
|
const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
|
|
274
289
|
const head = history[history.length - 1]
|
|
@@ -281,6 +296,7 @@ const buildRun = (
|
|
|
281
296
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
282
297
|
costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
|
|
283
298
|
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
299
|
+
pinCommitted: allParsed.some((m) => m.pin === "add"),
|
|
284
300
|
marks: parsed.flatMap((m) => m.marks),
|
|
285
301
|
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
|
|
286
302
|
pinnedVars: { ...first?.vars },
|
|
@@ -320,6 +336,8 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
|
|
|
320
336
|
const parsed = parseCommitMessage(subject).parsed
|
|
321
337
|
if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
|
|
322
338
|
if (parsed.actor === "human" && parsed.from === undefined) return false
|
|
339
|
+
// A pin commit's subject can name the first step; it bounds nothing.
|
|
340
|
+
if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false
|
|
323
341
|
return parsed.to === def.initial
|
|
324
342
|
}
|
|
325
343
|
|
|
@@ -849,6 +867,10 @@ interface ResolvedRest {
|
|
|
849
867
|
* before any mutation.
|
|
850
868
|
*/
|
|
851
869
|
interface Rest extends ResolvedRest {
|
|
870
|
+
/** Set at a pin or unpin beat; `step` is then the step the flow reached around it. */
|
|
871
|
+
readonly pin: PinChange | undefined
|
|
872
|
+
/** Whether HEAD's tree carries the pin. */
|
|
873
|
+
readonly pinned: boolean
|
|
852
874
|
readonly step: ReachedStep
|
|
853
875
|
readonly trace: readonly ReachedStep[]
|
|
854
876
|
readonly run: ProcessRun
|
|
@@ -985,13 +1007,15 @@ export const restAt = (
|
|
|
985
1007
|
// staying true; passing a pending tree here would make it serve a stale
|
|
986
1008
|
// steering file on every cache hit.
|
|
987
1009
|
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
988
|
-
const
|
|
989
|
-
|
|
990
|
-
|
|
1010
|
+
const pinned = headHasPin(workspace, run)
|
|
1011
|
+
const pin = pinBeatAt(outcome, run, def, pinned)
|
|
1012
|
+
const resting = restingOn(outcome, pin)
|
|
1013
|
+
if (resting === undefined) {
|
|
1014
|
+
return yield* Effect.fail(replayError(outcome) ?? new Error("gtd: replay found no rest"))
|
|
991
1015
|
}
|
|
992
|
-
const step =
|
|
993
|
-
|
|
994
|
-
|
|
1016
|
+
const { reached, step } = resting
|
|
1017
|
+
const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run)
|
|
1018
|
+
yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`)
|
|
995
1019
|
if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
|
|
996
1020
|
return yield* Effect.fail(
|
|
997
1021
|
new Error(
|
|
@@ -1006,7 +1030,6 @@ export const restAt = (
|
|
|
1006
1030
|
path: e.path,
|
|
1007
1031
|
}))
|
|
1008
1032
|
: []
|
|
1009
|
-
const memory = memoryOf(outcome.trace, run)
|
|
1010
1033
|
const configNow = config.fingerprint
|
|
1011
1034
|
// Only the current rest warns, so a command must read it once — a second
|
|
1012
1035
|
// read warns twice. An earlier ref (restore's tip) would warn about a
|
|
@@ -1015,11 +1038,13 @@ export const restAt = (
|
|
|
1015
1038
|
return {
|
|
1016
1039
|
def,
|
|
1017
1040
|
workflowName: resolved.name,
|
|
1018
|
-
state
|
|
1041
|
+
state,
|
|
1019
1042
|
stepDef,
|
|
1020
|
-
actor:
|
|
1043
|
+
actor: stepDef.actor,
|
|
1044
|
+
pin,
|
|
1045
|
+
pinned,
|
|
1021
1046
|
step,
|
|
1022
|
-
trace:
|
|
1047
|
+
trace: reached.trace,
|
|
1023
1048
|
run,
|
|
1024
1049
|
vars,
|
|
1025
1050
|
env,
|
|
@@ -1035,6 +1060,66 @@ export const restAt = (
|
|
|
1035
1060
|
|
|
1036
1061
|
export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
|
|
1037
1062
|
|
|
1063
|
+
/** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
|
|
1064
|
+
const restingOn = (outcome: ReplayOutcome, pin: PinChange | undefined) => {
|
|
1065
|
+
if (outcome.kind === "rest") return { reached: outcome, step: outcome.rest }
|
|
1066
|
+
if (outcome.kind !== "ended" || pin !== "remove") return undefined
|
|
1067
|
+
const step = outcome.trace.at(-1)
|
|
1068
|
+
return step === undefined ? undefined : { reached: outcome, step }
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** A pin or unpin beat has its own script, no step's, and no agent memory. */
|
|
1072
|
+
const restFacts = (
|
|
1073
|
+
pin: PinChange | undefined,
|
|
1074
|
+
step: ReachedStep,
|
|
1075
|
+
trace: readonly ReachedStep[],
|
|
1076
|
+
run: ProcessRun,
|
|
1077
|
+
) =>
|
|
1078
|
+
pin === undefined
|
|
1079
|
+
? { stepDef: stepDefOf(step), state: step.name, memory: memoryOf(trace, run) }
|
|
1080
|
+
: {
|
|
1081
|
+
stepDef: pinStepDef(pin),
|
|
1082
|
+
state: PIN_STATES[pin],
|
|
1083
|
+
memory: { key: undefined, resumed: false },
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const headHasPin = (workspace: WorkspaceOps, run: ProcessRun): boolean => {
|
|
1087
|
+
const head = run.episode.commits.at(-1)?.hash ?? run.episode.base
|
|
1088
|
+
return head !== undefined && workspace.treeSync(head).has(PIN_PATH)
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const underwayAt = (run: ProcessRun, step: ReachedStep, def: WorkflowDefinition): boolean =>
|
|
1092
|
+
run.workflow !== undefined || step.name !== def.initial || step.id.occurrence !== 1
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* A started process pins first — again, should a turn have deleted the pin;
|
|
1096
|
+
* a pinned flow that ended unpins before the closing commit.
|
|
1097
|
+
*/
|
|
1098
|
+
const pinBeatAt = (
|
|
1099
|
+
outcome: ReplayOutcome,
|
|
1100
|
+
run: ProcessRun,
|
|
1101
|
+
def: WorkflowDefinition,
|
|
1102
|
+
pinned: boolean,
|
|
1103
|
+
): PinChange | undefined => {
|
|
1104
|
+
if (flowEnded(outcome, run, def)) return pinned || run.pinCommitted ? "remove" : undefined
|
|
1105
|
+
if (outcome.kind !== "rest" || pinned) return undefined
|
|
1106
|
+
return underwayAt(run, outcome.rest, def) ? "add" : undefined
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// A default-workflow process back at the first step has ended, as a returned flow has.
|
|
1110
|
+
const flowEnded = (outcome: ReplayOutcome, run: ProcessRun, def: WorkflowDefinition): boolean =>
|
|
1111
|
+
outcome.kind === "ended" ||
|
|
1112
|
+
(outcome.kind === "rest" &&
|
|
1113
|
+
run.workflow === undefined &&
|
|
1114
|
+
outcome.rest.name === def.initial &&
|
|
1115
|
+
underwayAt(run, outcome.rest, def))
|
|
1116
|
+
|
|
1117
|
+
const pinStepDef = (pin: PinChange): StepDef => ({
|
|
1118
|
+
actor: "check",
|
|
1119
|
+
kind: "script",
|
|
1120
|
+
content: pin === "add" ? pinScript(PIN_PATH) : unpinScript(PIN_PATH),
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1038
1123
|
type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
|
|
1039
1124
|
type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
|
|
1040
1125
|
|
|
@@ -1094,6 +1179,7 @@ export interface RenderedRest extends RestHints {
|
|
|
1094
1179
|
readonly content: string
|
|
1095
1180
|
readonly memory?: string
|
|
1096
1181
|
readonly memoryResumed: boolean
|
|
1182
|
+
readonly pin?: PinChange
|
|
1097
1183
|
}
|
|
1098
1184
|
|
|
1099
1185
|
export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
@@ -1105,6 +1191,7 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
|
1105
1191
|
...rest.hints,
|
|
1106
1192
|
...(rest.memory !== undefined ? { memory: rest.memory } : {}),
|
|
1107
1193
|
memoryResumed: rest.memoryResumed,
|
|
1194
|
+
...(rest.pin !== undefined ? { pin: rest.pin } : {}),
|
|
1108
1195
|
})
|
|
1109
1196
|
|
|
1110
1197
|
/**
|
|
@@ -1122,9 +1209,7 @@ export const stalledAt = (rest: Rest): boolean =>
|
|
|
1122
1209
|
|
|
1123
1210
|
/** No process is underway: the default workflow's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
|
|
1124
1211
|
export const noProcessUnderway = (rest: Rest): boolean =>
|
|
1125
|
-
rest.
|
|
1126
|
-
rest.state === rest.def.initial &&
|
|
1127
|
-
rest.step.id.occurrence === 1
|
|
1212
|
+
rest.pin === undefined && !underwayAt(rest.run, rest.step, rest.def)
|
|
1128
1213
|
|
|
1129
1214
|
/** `idle` means exactly one thing: no process underway, clean tree. */
|
|
1130
1215
|
export const restIsIdle = (rest: Rest): boolean =>
|
|
@@ -1254,15 +1339,15 @@ const landingTo = (
|
|
|
1254
1339
|
if (refused !== undefined) return refused
|
|
1255
1340
|
// Landing on the first step while a process is underway ends it (flow
|
|
1256
1341
|
// return and `restart()` alike); the script then points the history ref at
|
|
1257
|
-
// that very commit.
|
|
1258
|
-
const finishes =
|
|
1342
|
+
// that very commit. A pinned process ends one beat later, at the unpin.
|
|
1343
|
+
const { finishes, landsAt } = endingOf(rest, to, underway)
|
|
1259
1344
|
return {
|
|
1260
1345
|
kind: "commit",
|
|
1261
|
-
to,
|
|
1346
|
+
to: landsAt,
|
|
1262
1347
|
spec: {
|
|
1263
1348
|
actor: rest.actor,
|
|
1264
1349
|
from: rest.state,
|
|
1265
|
-
to,
|
|
1350
|
+
to: landsAt,
|
|
1266
1351
|
step: rest.step.id,
|
|
1267
1352
|
...(marks.length > 0 && { marks }),
|
|
1268
1353
|
...approvedTrailer(rest, approver),
|
|
@@ -1274,6 +1359,53 @@ const landingTo = (
|
|
|
1274
1359
|
}
|
|
1275
1360
|
}
|
|
1276
1361
|
|
|
1362
|
+
const endingOf = (rest: Rest, to: StateName, underway: boolean) => {
|
|
1363
|
+
const ends = underway && to === rest.def.initial
|
|
1364
|
+
return { finishes: ends && !rest.pinned, landsAt: ends && rest.pinned ? PIN_STATES.remove : to }
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/** A pin or unpin commit carries the pin alone; the unpin commit closes the process. */
|
|
1368
|
+
const pinLanding = (rest: Rest, pin: PinChange, branch: Option.Option<string>): Landing => {
|
|
1369
|
+
const change = rest.changes.find((c) => c.path === PIN_PATH)?.status
|
|
1370
|
+
return pin === "add" ? pinCommit(rest, change) : unpinCommit(rest, change, branch)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const pinCommit = (rest: Rest, change: string | undefined): Landing => {
|
|
1374
|
+
if (change === undefined || change === "D") return pinRefusal("add")
|
|
1375
|
+
const to = rest.step.name
|
|
1376
|
+
return {
|
|
1377
|
+
kind: "commit",
|
|
1378
|
+
to,
|
|
1379
|
+
spec: { actor: rest.actor, from: rest.state, to, pin: "add" },
|
|
1380
|
+
only: PIN_PATH,
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const unpinCommit = (
|
|
1385
|
+
rest: Rest,
|
|
1386
|
+
change: string | undefined,
|
|
1387
|
+
branch: Option.Option<string>,
|
|
1388
|
+
): Landing => {
|
|
1389
|
+
if (change !== "D" && rest.pinned) return pinRefusal("remove")
|
|
1390
|
+
const to = rest.def.initial
|
|
1391
|
+
return {
|
|
1392
|
+
kind: "commit",
|
|
1393
|
+
to,
|
|
1394
|
+
spec: { actor: rest.actor, from: rest.state, to, pin: "remove", ...closingTrailers(branch) },
|
|
1395
|
+
keepHistory: processHistoryRef(rest.run.startHash),
|
|
1396
|
+
// A turn already deleted the pin: the closing commit has no path of its own.
|
|
1397
|
+
...(rest.pinned ? { only: PIN_PATH } : {}),
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const pinRefusal = (pin: PinChange): Landing => ({
|
|
1402
|
+
kind: "refusal",
|
|
1403
|
+
message:
|
|
1404
|
+
pin === "add"
|
|
1405
|
+
? `gtd land: ${PIN_PATH} is missing — run the pin beat's script (\`gtd next --json=content\`); if its \`gtd bundle\` fails, fix what it reports, or run \`gtd abandon\``
|
|
1406
|
+
: `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`,
|
|
1407
|
+
})
|
|
1408
|
+
|
|
1277
1409
|
const startTrailers = (rest: Rest, starts: boolean) =>
|
|
1278
1410
|
starts ? { vars: rest.vars, config: rest.config } : {}
|
|
1279
1411
|
|
|
@@ -1292,6 +1424,7 @@ const decideLanding = (
|
|
|
1292
1424
|
approver: Option.Option<AuthorIdent>,
|
|
1293
1425
|
): Effect.Effect<Landing, Error> =>
|
|
1294
1426
|
Effect.gen(function* () {
|
|
1427
|
+
if (rest.pin !== undefined) return pinLanding(rest, rest.pin, branch)
|
|
1295
1428
|
const unapproved = approvalRefusal(rest, approver)
|
|
1296
1429
|
if (unapproved !== undefined) return unapproved
|
|
1297
1430
|
const early = cleanLanding(rest)
|
|
@@ -1319,7 +1452,7 @@ const decideLanding = (
|
|
|
1319
1452
|
export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
|
|
1320
1453
|
decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
|
|
1321
1454
|
Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
|
|
1322
|
-
Effect.
|
|
1455
|
+
Effect.catch(() => Effect.succeed(undefined)),
|
|
1323
1456
|
)
|
|
1324
1457
|
|
|
1325
1458
|
/** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
|
|
@@ -1432,8 +1565,8 @@ const configRefusal = (
|
|
|
1432
1565
|
)
|
|
1433
1566
|
|
|
1434
1567
|
const differingConfigPaths = (
|
|
1435
|
-
git: GitService["
|
|
1436
|
-
workspace: Workspace["
|
|
1568
|
+
git: GitService["Service"],
|
|
1569
|
+
workspace: Workspace["Service"],
|
|
1437
1570
|
root: string,
|
|
1438
1571
|
config: { readonly fingerprint: ConfigFingerprint },
|
|
1439
1572
|
run: ProcessRun,
|