@pmelab/gtd 64.2.0 → 65.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 +36 -26
- package/claude/hooks/assist.ts +9 -35
- package/claude/hooks/doors.ts +4 -4
- package/claude/hooks/git.ts +56 -0
- package/claude/hooks/handoff.ts +11 -11
- package/claude/hooks/jobs.ts +8 -8
- package/claude/hooks/pr.ts +8 -8
- package/claude/hooks/register.tsx +16 -78
- package/claude/hooks/tools.ts +7 -20
- package/claude/hooks/wording.ts +3 -3
- package/dist/chunks/{flows-dnIE_qbe.mjs → flows-DGs0W3HI.mjs} +19 -2
- package/dist/chunks/{testkit-BwCH8ky6.mjs → testkit-CgrLmpdJ.mjs} +741 -80
- package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-BNS8ciNu.mjs} +5 -3
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +565 -114
- package/dist/gtd.core.mjs +132 -129
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +2 -2
- package/dist/web/main.js +2 -2
- package/dist/wire.mjs +1 -1
- package/dist/workflow-kit.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.test.ts +10 -8
- package/src/cli/Metrics.test.ts +2 -1
- package/src/cli/Program.test.ts +4 -2
- package/src/cli/Program.ts +87 -5
- package/src/edge/Edge.test.ts +37 -16
- package/src/edge/Edge.ts +296 -31
- package/src/emit/GitScript.ts +116 -15
- package/src/emit/index.ts +7 -0
- package/src/flows/config.ts +7 -1
- package/src/flows/index.test.ts +1 -0
- package/src/flows/index.ts +2 -0
- package/src/flows/runtime.ts +32 -1
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Replay.test.ts +9 -0
- package/src/replay/Replay.ts +59 -15
- package/src/replay/Trailers.ts +14 -0
- package/src/replay/index.ts +1 -1
- package/src/step/LandStep.ts +12 -0
- package/src/step/RepoSnapshot.ts +11 -0
- package/src/step/index.ts +1 -0
- package/src/step/planStart.test.ts +4 -3
- package/src/step/planStart.ts +7 -0
- package/src/step/planStep.ts +17 -12
- package/src/step/startBranch.ts +54 -0
- package/src/twins/EmittedScriptRecognizer.ts +117 -0
- package/src/twins/EmittedScripts.test.ts +147 -1
- package/src/twins/InMemRepo.ts +116 -0
- package/src/wire/Demand.ts +22 -10
- package/src/wire/constants.ts +3 -0
- package/src/wire/types.ts +4 -0
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/compile.ts +2 -0
- package/src/workflow/load.ts +2 -0
- package/claude/hooks/ship.ts +0 -730
|
@@ -13,6 +13,12 @@ import {
|
|
|
13
13
|
hardResetTo,
|
|
14
14
|
keepTurn,
|
|
15
15
|
mixedResetTo,
|
|
16
|
+
switchBranchTo,
|
|
17
|
+
switchBack,
|
|
18
|
+
switchTo,
|
|
19
|
+
deleteBranch,
|
|
20
|
+
pushRef,
|
|
21
|
+
shipCommit,
|
|
16
22
|
shellQuote,
|
|
17
23
|
softResetTo,
|
|
18
24
|
updateRef,
|
|
@@ -26,6 +32,7 @@ import {
|
|
|
26
32
|
modeContradictionSkipNotice,
|
|
27
33
|
OUTCOME_MARKER,
|
|
28
34
|
pinScript,
|
|
35
|
+
failRestScript,
|
|
29
36
|
repinScript,
|
|
30
37
|
unpinScript,
|
|
31
38
|
} from "../emit/index.js"
|
|
@@ -161,6 +168,19 @@ const recognizeKeepTurn = (repo: InMemRepo, block: string): BlockOutcome | undef
|
|
|
161
168
|
return failure === undefined ? { kind: "applied" } : { kind: "failed", error: failure, status: 1 }
|
|
162
169
|
}
|
|
163
170
|
|
|
171
|
+
const attempt = (apply: () => void): BlockOutcome => {
|
|
172
|
+
try {
|
|
173
|
+
apply()
|
|
174
|
+
return { kind: "applied" }
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return {
|
|
177
|
+
kind: "failed",
|
|
178
|
+
error: `fatal: ${e instanceof Error ? e.message : String(e)}`,
|
|
179
|
+
status: 128,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
// fallow-ignore-next-line complexity
|
|
165
185
|
const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
166
186
|
if (block === discardPending()) {
|
|
@@ -250,6 +270,73 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
|
|
|
250
270
|
return undefined
|
|
251
271
|
}
|
|
252
272
|
|
|
273
|
+
if (block.startsWith("git switch -q -C ")) {
|
|
274
|
+
const [branch, start] = extractQuotedTokens(block)
|
|
275
|
+
if (branch !== undefined && switchBranchTo(branch, start) === block) {
|
|
276
|
+
return attempt(() => repo.switchBranchTo(branch, start))
|
|
277
|
+
}
|
|
278
|
+
return undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (block.startsWith("if ! git switch -q ")) {
|
|
282
|
+
const [branch, , note] = extractQuotedTokens(block)
|
|
283
|
+
if (branch !== undefined && switchBack(branch) === block) {
|
|
284
|
+
try {
|
|
285
|
+
repo.switchTo(branch)
|
|
286
|
+
return { kind: "applied" }
|
|
287
|
+
} catch {
|
|
288
|
+
return { kind: "noop", output: `${note}\n` }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (block.startsWith("git switch -q ")) {
|
|
295
|
+
const [branch] = extractQuotedTokens(block)
|
|
296
|
+
if (branch !== undefined && switchTo(branch) === block) {
|
|
297
|
+
return attempt(() => repo.switchTo(branch))
|
|
298
|
+
}
|
|
299
|
+
return undefined
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (block.startsWith("git branch -q -D ")) {
|
|
303
|
+
const [branch] = extractQuotedTokens(block)
|
|
304
|
+
if (branch !== undefined && deleteBranch(branch) === block) {
|
|
305
|
+
return attempt(() => repo.deleteBranch(branch))
|
|
306
|
+
}
|
|
307
|
+
return undefined
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (block.startsWith("if ! git push --force ")) {
|
|
311
|
+
const [remote, refspec] = extractQuotedTokens(block)
|
|
312
|
+
const ref = refspec?.split(":")[0]
|
|
313
|
+
if (remote !== undefined && ref !== undefined && pushRef(remote, ref) === block) {
|
|
314
|
+
const failure = repo.pushRef(remote, ref)
|
|
315
|
+
if (failure === undefined) return { kind: "applied" }
|
|
316
|
+
const retry = extractQuotedTokens(block)[3]!
|
|
317
|
+
return { kind: "failed", error: `${failure}\n${retry}`, status: 1 }
|
|
318
|
+
}
|
|
319
|
+
return undefined
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (block.startsWith("gtd_ship_parent=")) {
|
|
323
|
+
const [parent, branch, history, shipMessage, message] = extractQuotedTokens(block)
|
|
324
|
+
if (
|
|
325
|
+
parent === undefined ||
|
|
326
|
+
branch === undefined ||
|
|
327
|
+
history === undefined ||
|
|
328
|
+
shipMessage === undefined ||
|
|
329
|
+
message === undefined ||
|
|
330
|
+
shipCommit(branch, parent, shipMessage, history, message) !== block
|
|
331
|
+
) {
|
|
332
|
+
return undefined
|
|
333
|
+
}
|
|
334
|
+
const failure = repo.shipCommit(branch, parent, shipMessage, history, message)
|
|
335
|
+
return failure === undefined
|
|
336
|
+
? { kind: "applied" }
|
|
337
|
+
: { kind: "failed", error: failure, status: 1 }
|
|
338
|
+
}
|
|
339
|
+
|
|
253
340
|
if (block.startsWith("git update-ref -d ")) {
|
|
254
341
|
const [ref] = extractQuotedTokens(block)
|
|
255
342
|
if (ref !== undefined && deleteRef(ref) === block) {
|
|
@@ -375,6 +462,35 @@ const recognizeModeCommand = (
|
|
|
375
462
|
return recognizeScriptedCommand(repo, new Map([[command, bound]]), command)
|
|
376
463
|
}
|
|
377
464
|
|
|
465
|
+
const FAIL_REST_MIDDLE = '\n} 2>&1 )" || gtd_run_status=$?\n'
|
|
466
|
+
|
|
467
|
+
/** A `run(…, { onFail: "rest" })` body: the inner script's failure is recorded in the tree and the block itself succeeds. */
|
|
468
|
+
// fallow-ignore-next-line complexity
|
|
469
|
+
const recognizeFailRest = (
|
|
470
|
+
repo: InMemRepo,
|
|
471
|
+
commands: CommandTable,
|
|
472
|
+
block: string,
|
|
473
|
+
): BlockOutcome | undefined => {
|
|
474
|
+
if (!block.startsWith("rm -f -- '") || !block.includes("gtd_run_out=")) return undefined
|
|
475
|
+
const [path] = extractQuotedTokens(block)
|
|
476
|
+
const headerEnd = block.indexOf('gtd_run_out="$( {\n')
|
|
477
|
+
const bodyEnd = block.indexOf(FAIL_REST_MIDDLE)
|
|
478
|
+
if (path === undefined || headerEnd === -1 || bodyEnd === -1) return undefined
|
|
479
|
+
const body = block.slice(headerEnd + 'gtd_run_out="$( {\n'.length, bodyEnd)
|
|
480
|
+
if (failRestScript(body, path) !== block) return undefined
|
|
481
|
+
if (repo.readFile(path) !== undefined) repo.deleteFile(path)
|
|
482
|
+
const result = applyEmittedScript(repo, commands, body)
|
|
483
|
+
if (!result.ok && result.error?.startsWith(UNRECOGNIZED_PREFIX)) {
|
|
484
|
+
return { kind: "failed", error: `${result.output}${result.error}` }
|
|
485
|
+
}
|
|
486
|
+
if (!result.ok) {
|
|
487
|
+
// Mirrors `$(…)` (strips trailing newlines) and the script's `tail -n 50`.
|
|
488
|
+
const lines = result.output.replace(/\n+$/, "").split("\n")
|
|
489
|
+
repo.writeFile(path, `exit ${result.status}\n${lines.slice(-50).join("\n")}\n`)
|
|
490
|
+
}
|
|
491
|
+
return { kind: "noop", output: result.output }
|
|
492
|
+
}
|
|
493
|
+
|
|
378
494
|
const FAILURE_PROMPT_HEADER = 'gtd_validate_status=0\ngtd_validate_out="$( {\n'
|
|
379
495
|
const FAILURE_PROMPT_MIDDLE =
|
|
380
496
|
'\n} 2>&1 )" || gtd_validate_status=$?\n' +
|
|
@@ -728,6 +844,7 @@ const recognizersFor = (
|
|
|
728
844
|
(block) => (git ? recognizeGitBuilders(repo, block) : undefined),
|
|
729
845
|
(block) => recognizeWriteLines(repo, block),
|
|
730
846
|
(block) => recognizePinScripts(repo, block),
|
|
847
|
+
(block) => recognizeFailRest(repo, commands, block),
|
|
731
848
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
732
849
|
(block) => recognizePresentationSubshell(repo, commands, block, git),
|
|
733
850
|
(block) => recognizeModeContradictionSkipNotice(block),
|
|
@@ -5,15 +5,20 @@ import {
|
|
|
5
5
|
combinedScript,
|
|
6
6
|
commitAll,
|
|
7
7
|
commitAsIs,
|
|
8
|
+
deleteBranch,
|
|
8
9
|
emitScripts,
|
|
10
|
+
failRestScript,
|
|
9
11
|
failurePromptWrapper,
|
|
10
12
|
fileExistsGuard,
|
|
11
13
|
modeContradictionSkipNotice,
|
|
14
|
+
switchBranchTo,
|
|
15
|
+
switchBack,
|
|
16
|
+
switchTo,
|
|
12
17
|
transitionOutcome,
|
|
13
18
|
withFileVar,
|
|
14
19
|
type EmitStep,
|
|
15
20
|
} from "../emit/index.js"
|
|
16
|
-
import { Effect } from "effect"
|
|
21
|
+
import { Effect, Option } from "effect"
|
|
17
22
|
import { ScriptSurface } from "../emit/index.js"
|
|
18
23
|
import { GitService } from "../platform/index.js"
|
|
19
24
|
import { listFormat } from "../steering/index.js"
|
|
@@ -305,6 +310,147 @@ printf '%s\n' 'a' '' 'it'\''s' '100%' '-x' 'a\b' '.gtd/packages/00-x.md' > '.gtd
|
|
|
305
310
|
)
|
|
306
311
|
})
|
|
307
312
|
|
|
313
|
+
describe("branch scripts", () => {
|
|
314
|
+
it("switchBranchTo creates the branch at HEAD and moves onto it", () => {
|
|
315
|
+
const head = t.observe.resolveRef("HEAD")
|
|
316
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
317
|
+
expect(t.observe.resolveRef("gtd/x")).toBe(head)
|
|
318
|
+
t.seed.writeFile("a.txt", "a\n")
|
|
319
|
+
expect(run(commitAll("gtd(agent): building")).ok).toBe(true)
|
|
320
|
+
expect(t.observe.resolveRef("gtd/x")).toBe(t.observe.resolveRef("HEAD"))
|
|
321
|
+
expect(t.observe.resolveRef("gtd/x")).not.toBe(head)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it("switchTo moves onto an existing branch and fails for a missing one", () => {
|
|
325
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
326
|
+
expect(run(switchTo("nope")).ok).toBe(false)
|
|
327
|
+
expect(run(switchBranchTo("gtd/y")).ok).toBe(true)
|
|
328
|
+
expect(run(switchTo("gtd/x")).ok).toBe(true)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const branch = () =>
|
|
332
|
+
t.provide(Effect.flatMap(GitService, (g) => g.currentBranch())).then(Option.getOrUndefined)
|
|
333
|
+
|
|
334
|
+
it("switchBack carries a change to a path both branches agree on", async () => {
|
|
335
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
336
|
+
t.seed.commit("feat: on gtd/x", { "b.txt": "b\n" })
|
|
337
|
+
expect(run(switchBranchTo("x", "HEAD~1")).ok).toBe(true)
|
|
338
|
+
expect(run(switchTo("gtd/x")).ok).toBe(true)
|
|
339
|
+
t.seed.writeFile("a.txt", "changed\n")
|
|
340
|
+
const result = run(switchBack("x"))
|
|
341
|
+
expect(result.ok).toBe(true)
|
|
342
|
+
expect(result.output).toBe("")
|
|
343
|
+
expect(await branch()).toBe("x")
|
|
344
|
+
expect(read("a.txt")).toBe("changed\n")
|
|
345
|
+
expect(read("b.txt")).toBeUndefined()
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it("switchBack stays put, saying so, when a change would be overwritten", async () => {
|
|
349
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
350
|
+
t.seed.commit("feat: on gtd/x", { "b.txt": "b\n" })
|
|
351
|
+
expect(run(switchBranchTo("x", "HEAD~1")).ok).toBe(true)
|
|
352
|
+
expect(run(switchTo("gtd/x")).ok).toBe(true)
|
|
353
|
+
t.seed.writeFile("b.txt", "changed\n")
|
|
354
|
+
const result = run(switchBack("x"))
|
|
355
|
+
expect(result.ok).toBe(true)
|
|
356
|
+
expect(result.output).toContain("pending changes keep HEAD off x")
|
|
357
|
+
expect(await branch()).toBe("gtd/x")
|
|
358
|
+
expect(read("b.txt")).toBe("changed\n")
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it("deleteBranch removes another branch and refuses the checked-out one", () => {
|
|
362
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
363
|
+
expect(run(deleteBranch("gtd/x")).ok).toBe(false)
|
|
364
|
+
expect(run(switchBranchTo("gtd/y")).ok).toBe(true)
|
|
365
|
+
expect(run(deleteBranch("gtd/x")).ok).toBe(true)
|
|
366
|
+
expect(t.observe.refExists("refs/heads/gtd/x")).toBe(false)
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe("shipCommit", () => {
|
|
371
|
+
const HISTORY = "refs/gtd/history/0123456789012345678901234567890123456789"
|
|
372
|
+
const LANDING = "gtd(check): shipping → after\n\nGtd-Format: 4"
|
|
373
|
+
const git = <A>(f: (g: GitService["Service"]) => Effect.Effect<A, Error>) =>
|
|
374
|
+
t.provide(Effect.flatMap(GitService, f))
|
|
375
|
+
// As a landing renders it: under `set -eu`.
|
|
376
|
+
const ship = (branch: string, parent: string, shipMessage: string) =>
|
|
377
|
+
ScriptSurface.render([
|
|
378
|
+
{
|
|
379
|
+
kind: "gitWrite",
|
|
380
|
+
write: {
|
|
381
|
+
kind: "ship",
|
|
382
|
+
branch,
|
|
383
|
+
parent,
|
|
384
|
+
shipMessage,
|
|
385
|
+
historyRef: HISTORY,
|
|
386
|
+
message: LANDING,
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
])
|
|
390
|
+
|
|
391
|
+
it("squashes the staged tree minus .gtd/ onto the branch, then lands naming it", async () => {
|
|
392
|
+
const branch = await git((g) => g.currentBranch())
|
|
393
|
+
const user = Option.getOrElse(branch, () => "main")
|
|
394
|
+
const parent = t.observe.resolveRef("HEAD")
|
|
395
|
+
expect(run(switchBranchTo(`gtd/${user}`)).ok).toBe(true)
|
|
396
|
+
t.seed.writeFile("a.txt", "shipped\n")
|
|
397
|
+
t.seed.writeFile(".gtd/notes.md", "kept back\n")
|
|
398
|
+
const result = run(ship(user, parent, "feat: it's shipped"))
|
|
399
|
+
expect(result.ok).toBe(true)
|
|
400
|
+
const shipped = t.observe.resolveRef(user)
|
|
401
|
+
expect(shipped).not.toBe(parent)
|
|
402
|
+
expect(t.observe.resolveRef(`${shipped}~1`)).toBe(parent)
|
|
403
|
+
expect(await git((g) => g.readFileAtRef(shipped, "a.txt"))).toBe("shipped\n")
|
|
404
|
+
expect(await git((g) => g.diffPaths(parent, shipped))).toEqual(["a.txt"])
|
|
405
|
+
const message = await git((g) => g.lastCommitMessage(shipped))
|
|
406
|
+
expect(message).toContain("feat: it's shipped")
|
|
407
|
+
expect(message).toContain(`\n\nGtd-History: ${HISTORY}\nGtd-Tip: ${parent}`)
|
|
408
|
+
expect(await git((g) => g.lastCommitMessage("HEAD"))).toContain(`Gtd-Shipped: ${shipped}`)
|
|
409
|
+
expect(t.observe.statusPorcelain()).toBe("")
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it("keeps the .gtd/ files the branch tracks as it has them", async () => {
|
|
413
|
+
const user = Option.getOrElse(await git((g) => g.currentBranch()), () => "main")
|
|
414
|
+
t.seed.commit("chore: track", { ".gtd/keep.md": "tracked\n" })
|
|
415
|
+
const parent = t.observe.resolveRef("HEAD")
|
|
416
|
+
expect(run(switchBranchTo(`gtd/${user}`)).ok).toBe(true)
|
|
417
|
+
t.seed.writeFile("a.txt", "shipped\n")
|
|
418
|
+
t.seed.writeFile(".gtd/keep.md", "process\n")
|
|
419
|
+
expect(run(ship(user, parent, "feat: x")).ok).toBe(true)
|
|
420
|
+
const shipped = t.observe.resolveRef(user)
|
|
421
|
+
expect(await git((g) => g.readFileAtRef(shipped, ".gtd/keep.md"))).toBe("tracked\n")
|
|
422
|
+
expect(await git((g) => g.diffPaths(parent, shipped))).toEqual(["a.txt"])
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it("fails before landing when the branch moved since the decision", () => {
|
|
426
|
+
const head = t.observe.resolveRef("HEAD")
|
|
427
|
+
expect(run(switchBranchTo("gtd/x")).ok).toBe(true)
|
|
428
|
+
expect(run(switchBranchTo("x")).ok).toBe(true)
|
|
429
|
+
t.seed.commit("feat: moved", { "b.txt": "b\n" })
|
|
430
|
+
expect(run(switchTo("gtd/x")).ok).toBe(true)
|
|
431
|
+
t.seed.writeFile("a.txt", "a\n")
|
|
432
|
+
expect(run(ship("x", head, "feat: x")).ok).toBe(false)
|
|
433
|
+
expect(t.observe.resolveRef("HEAD")).toBe(head)
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
describe("failRestScript", () => {
|
|
438
|
+
const FAIL_PATH = ".gtd/run-failed.txt"
|
|
439
|
+
const wrapped = (body: string) => failRestScript(body, FAIL_PATH)
|
|
440
|
+
|
|
441
|
+
it("records the status and output, without the output's trailing newline, and succeeds", () => {
|
|
442
|
+
expect(run(wrapped(withFileVar(FAILING, FILE))).ok).toBe(true)
|
|
443
|
+
expect(read(FAIL_PATH)).toBe(`exit 2\nbad: ${FILE}\n`)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it("leaves no record when the body passes, and clears an earlier one", () => {
|
|
447
|
+
t.seed.writeFile(FAIL_PATH, "exit 1\nold\n")
|
|
448
|
+
t.seed.writeFile(FILE, "a\n")
|
|
449
|
+
expect(run(wrapped(withFileVar(COPY, FILE))).ok).toBe(true)
|
|
450
|
+
expect(read(FAIL_PATH)).toBeUndefined()
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
308
454
|
describe("commitAll", () => {
|
|
309
455
|
it("a staging hook's edit lands in the commit", () => {
|
|
310
456
|
t.seed.stagingHook("readme.txt", "hooked")
|
package/src/twins/InMemRepo.ts
CHANGED
|
@@ -180,6 +180,89 @@ export class InMemRepo {
|
|
|
180
180
|
this.index = new Map(tree)
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/** `git switch -C <name> [<start>]`: pending changes ride along only when the tip's tree is unchanged. */
|
|
184
|
+
switchBranchTo(name: string, start?: string): void {
|
|
185
|
+
const hash = start === undefined ? this.head : this.resolveRef(start)
|
|
186
|
+
if (hash === null) throw new Error(`invalid reference: ${start ?? "HEAD"}`)
|
|
187
|
+
this.moveToBranch(name, hash, true)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `git switch <name>`: a pending change rides along when its path is the same in both trees. */
|
|
191
|
+
switchTo(name: string): void {
|
|
192
|
+
const hash = this.branches.get(name)
|
|
193
|
+
if (hash === undefined) throw new Error(`invalid reference: ${name}`)
|
|
194
|
+
if (hash === this.head) return this.moveToBranch(name, hash, false)
|
|
195
|
+
const from = this.headTree()
|
|
196
|
+
const to = this.getCommit(hash)?.files ?? new Map<string, string>()
|
|
197
|
+
const pending = [
|
|
198
|
+
...new Set([...from.keys(), ...this.index.keys(), ...this.worktree.keys()]),
|
|
199
|
+
].filter(
|
|
200
|
+
(path) =>
|
|
201
|
+
this.index.get(path) !== from.get(path) || this.worktree.get(path) !== from.get(path),
|
|
202
|
+
)
|
|
203
|
+
if (pending.some((path) => to.get(path) !== from.get(path))) {
|
|
204
|
+
throw new Error(`cannot switch to '${name}': local changes would be overwritten`)
|
|
205
|
+
}
|
|
206
|
+
const carry = (side: Map<string, string>) => {
|
|
207
|
+
const out = new Map(to)
|
|
208
|
+
for (const path of pending) {
|
|
209
|
+
const content = side.get(path)
|
|
210
|
+
if (content === undefined) out.delete(path)
|
|
211
|
+
else out.set(path, content)
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
this.worktree = carry(this.worktree)
|
|
216
|
+
this.index = carry(this.index)
|
|
217
|
+
this.head = hash
|
|
218
|
+
this.currentBranch = name
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private moveToBranch(name: string, hash: string, reset: boolean): void {
|
|
222
|
+
if (hash !== this.head) {
|
|
223
|
+
if (this.statusPorcelain().trim() !== "") {
|
|
224
|
+
throw new Error(`cannot switch to '${name}': the working tree has uncommitted changes`)
|
|
225
|
+
}
|
|
226
|
+
const tree = this.getCommit(hash)?.files ?? new Map<string, string>()
|
|
227
|
+
this.head = hash
|
|
228
|
+
this.worktree = new Map(tree)
|
|
229
|
+
this.index = new Map(tree)
|
|
230
|
+
}
|
|
231
|
+
if (reset || !this.branches.has(name)) this.branches.set(name, hash)
|
|
232
|
+
this.currentBranch = name
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
deleteBranch(name: string): void {
|
|
236
|
+
if (name === this.currentBranch) throw new Error(`cannot delete branch '${name}' checked out`)
|
|
237
|
+
if (!this.branches.delete(name)) throw new Error(`branch '${name}' not found`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private remotes: Map<string, Map<string, string>> = new Map()
|
|
241
|
+
|
|
242
|
+
/** Test setup: a remote that accepts pushes. */
|
|
243
|
+
// fallow-ignore-next-line unused-class-member
|
|
244
|
+
addRemote(name: string): void {
|
|
245
|
+
this.remotes.set(name, new Map())
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** What a remote holds after pushes: ref → hash. */
|
|
249
|
+
// fallow-ignore-next-line unused-class-member
|
|
250
|
+
remoteRefs(name: string): ReadonlyMap<string, string> {
|
|
251
|
+
return this.remotes.get(name) ?? new Map()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** `git push --force <remote> <ref>:<ref>`; returns git's complaint, or `undefined`. */
|
|
255
|
+
pushRef(remote: string, ref: string): string | undefined {
|
|
256
|
+
const there = this.remotes.get(remote)
|
|
257
|
+
if (there === undefined) {
|
|
258
|
+
return `fatal: '${remote}' does not appear to be a git repository`
|
|
259
|
+
}
|
|
260
|
+
const hash = this.resolveRef(ref)
|
|
261
|
+
if (hash === null) return `error: src refspec ${ref} does not match any`
|
|
262
|
+
there.set(ref, hash)
|
|
263
|
+
return undefined
|
|
264
|
+
}
|
|
265
|
+
|
|
183
266
|
/** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
|
|
184
267
|
listRefs(prefix: string): Array<{ ref: string; hash: string; date: string }> {
|
|
185
268
|
return [...this.refs.entries()]
|
|
@@ -473,6 +556,39 @@ export class InMemRepo {
|
|
|
473
556
|
return undefined
|
|
474
557
|
}
|
|
475
558
|
|
|
559
|
+
/** Twin of `GitScript.shipCommit`: the staged tree, with `.gtd/` as `branch` has it, onto `branch`, then the landing commit naming it. */
|
|
560
|
+
shipCommit(
|
|
561
|
+
branch: string,
|
|
562
|
+
parent: string,
|
|
563
|
+
shipMessage: string,
|
|
564
|
+
historyRef: string,
|
|
565
|
+
message: string,
|
|
566
|
+
): string | undefined {
|
|
567
|
+
this.stageAll()
|
|
568
|
+
if (this.head === null) return "fatal: Needed a single revision"
|
|
569
|
+
if (this.signingFails) return "error: gpg failed to sign the data"
|
|
570
|
+
const kept = [...(this.commits.get(parent)?.files ?? [])]
|
|
571
|
+
const tree = new Map([
|
|
572
|
+
...[...this.index].filter(([path]) => !path.startsWith(".gtd/")),
|
|
573
|
+
...kept.filter(([path]) => path.startsWith(".gtd/")),
|
|
574
|
+
])
|
|
575
|
+
const shipped = `${shipMessage}\n\nGtd-History: ${historyRef}\nGtd-Tip: ${this.head}\n`
|
|
576
|
+
const hash = makeHash(shipped, parent, tree)
|
|
577
|
+
if (this.branches.get(branch) !== parent) {
|
|
578
|
+
return `fatal: update_ref failed for ref 'refs/heads/${branch}': cannot lock ref 'refs/heads/${branch}': is at ${this.branches.get(branch) ?? "nothing"} but expected ${parent}`
|
|
579
|
+
}
|
|
580
|
+
this.commits.set(hash, {
|
|
581
|
+
hash,
|
|
582
|
+
message: shipped,
|
|
583
|
+
files: tree,
|
|
584
|
+
parent,
|
|
585
|
+
seq: this.nextSeq++,
|
|
586
|
+
signed: this.signingEnabled,
|
|
587
|
+
})
|
|
588
|
+
this.branches.set(branch, hash)
|
|
589
|
+
return this.commitAsIs(`${message}\nGtd-Shipped: ${hash}`)
|
|
590
|
+
}
|
|
591
|
+
|
|
476
592
|
/** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
|
|
477
593
|
discardPending(): void {
|
|
478
594
|
this.index = new Map(this.worktree)
|
package/src/wire/Demand.ts
CHANGED
|
@@ -54,14 +54,24 @@ export const beatKindOf = (input: {
|
|
|
54
54
|
* `stalled at "<state>"` stays a stable substring (see
|
|
55
55
|
* `tests/integration/features/driver-doc.feature`).
|
|
56
56
|
*/
|
|
57
|
-
export const stallDiagnosis = (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
57
|
+
export const stallDiagnosis = (
|
|
58
|
+
state: StateName,
|
|
59
|
+
actor: Actor,
|
|
60
|
+
failure?: string,
|
|
61
|
+
blocked?: string,
|
|
62
|
+
): string =>
|
|
63
|
+
blocked !== undefined
|
|
64
|
+
? `stalled at "${state}": landing this ship step is refused.\n\n${blocked}\n`
|
|
65
|
+
: failure !== undefined
|
|
66
|
+
? `stalled at "${state}": the script exited non-zero and its failure is recorded in the tree. ` +
|
|
67
|
+
`Change the tree to run the script again; the step advances only on a zero exit.\n\n${failure}`
|
|
68
|
+
: `stalled at "${state}": the last gtd(${actor}): ${state} turn landed an empty ` +
|
|
69
|
+
`attempt, the tree is clean, and another dispatch would repeat it.\n\n` +
|
|
70
|
+
`Two ways out:\n` +
|
|
71
|
+
` - sharpen the step's prompt so the turn has something concrete to author\n` +
|
|
72
|
+
` - pass allowEmpty: true to the step, so an empty turn completes it and the\n` +
|
|
73
|
+
` flow decides what follows — finishing, or an escalation step after N\n` +
|
|
74
|
+
` empty turns\n`
|
|
65
75
|
|
|
66
76
|
/** One dispatch session — `resume`'s own meaning lives with `src/wire/Sessions.ts`'s `resolveSession`, which produces it. */
|
|
67
77
|
export interface DemandSession {
|
|
@@ -100,7 +110,7 @@ export type Demand =
|
|
|
100
110
|
export const demandOf = (input: {
|
|
101
111
|
readonly rendered: Pick<
|
|
102
112
|
RenderedDemandSource,
|
|
103
|
-
"actor" | "content" | "state" | "skills" | "access" | "pin"
|
|
113
|
+
"actor" | "content" | "state" | "skills" | "access" | "pin" | "failure" | "blocked"
|
|
104
114
|
>
|
|
105
115
|
readonly kind: BeatKind
|
|
106
116
|
readonly session?: DemandSession
|
|
@@ -108,7 +118,9 @@ export const demandOf = (input: {
|
|
|
108
118
|
}): Demand => {
|
|
109
119
|
const { rendered, kind, session, validate } = input
|
|
110
120
|
const content =
|
|
111
|
-
kind === "stalled"
|
|
121
|
+
kind === "stalled"
|
|
122
|
+
? stallDiagnosis(rendered.state, rendered.actor, rendered.failure, rendered.blocked)
|
|
123
|
+
: rendered.content
|
|
112
124
|
const skills =
|
|
113
125
|
rendered.skills !== undefined && rendered.skills.length > 0 ? rendered.skills : undefined
|
|
114
126
|
const access = rendered.access ?? { read: null, write: null }
|
package/src/wire/constants.ts
CHANGED
|
@@ -23,3 +23,6 @@ export const ACCESS_REFUSAL =
|
|
|
23
23
|
|
|
24
24
|
/** Where a process keeps its pin: the engine and workflow it runs on, as `gtd bundle` prints them. */
|
|
25
25
|
export const PIN_PATH = ".gtd/process.gtdpin"
|
|
26
|
+
|
|
27
|
+
/** Where a `run(…, { onFail: "rest" })` body's failure is recorded in the tree: the exit status and the output. */
|
|
28
|
+
export const RUN_FAILED_PATH = ".gtd/run-failed.txt"
|
package/src/wire/types.ts
CHANGED
|
@@ -55,4 +55,8 @@ export interface RenderedDemandSource {
|
|
|
55
55
|
readonly skills?: readonly string[]
|
|
56
56
|
readonly access?: StepAccess
|
|
57
57
|
readonly pin?: PinMark
|
|
58
|
+
/** The recorded output of a failed `onFail: "rest"` run, shown at the stalled beat. */
|
|
59
|
+
readonly failure?: string
|
|
60
|
+
/** Why landing a ship step is refused, shown at the stalled beat. */
|
|
61
|
+
readonly blocked?: string
|
|
58
62
|
}
|
package/src/workflow/Workflow.ts
CHANGED
package/src/workflow/compile.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface CompiledConfig {
|
|
|
8
8
|
/** The built-in modes (qa/review, with gtd's own validators) merged with `config.modes`, per half. */
|
|
9
9
|
readonly modes: Record<string, ModeDef>
|
|
10
10
|
readonly ui?: UiSettings
|
|
11
|
+
readonly historyRemote?: string
|
|
11
12
|
readonly skills: Readonly<Record<string, readonly string[]>>
|
|
12
13
|
readonly access: GtdConfig["access"] & {}
|
|
13
14
|
readonly diagnostics: readonly Diagnostic[]
|
|
@@ -48,6 +49,7 @@ export const compileConfig = (value: unknown, origin: string): CompiledConfig =>
|
|
|
48
49
|
return {
|
|
49
50
|
modes,
|
|
50
51
|
...(config.ui !== undefined ? { ui: config.ui } : {}),
|
|
52
|
+
...(config.historyRemote !== undefined ? { historyRemote: config.historyRemote } : {}),
|
|
51
53
|
// Null prototype: a `__proto__` key must stay an ordinary (reportable) entry.
|
|
52
54
|
skills: Object.assign(Object.create(null), config.skills),
|
|
53
55
|
access: Object.assign(Object.create(null), config.access),
|
package/src/workflow/load.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface ConfigOperations {
|
|
|
40
40
|
readonly doors: ReadonlyMap<string, Door>
|
|
41
41
|
/** `config.ui` (absent when unconfigured) — `gtd ui` and its CLI flags read it. */
|
|
42
42
|
readonly ui?: flows.UiSettings
|
|
43
|
+
readonly historyRemote?: string
|
|
43
44
|
/** Non-fatal findings, formatted through `formatDiagnostic` like an error. */
|
|
44
45
|
readonly warnings: readonly Diagnostic[]
|
|
45
46
|
/** The content hash of every file `gtd.config.ts`'s evaluation touched — itself plus any relative import — by path relative to the root. */
|
|
@@ -197,6 +198,7 @@ export const load: Effect.Effect<
|
|
|
197
198
|
return entry === undefined ? undefined : resolve(name, entry)
|
|
198
199
|
},
|
|
199
200
|
...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
|
|
201
|
+
...(compiled.historyRemote !== undefined ? { historyRemote: compiled.historyRemote } : {}),
|
|
200
202
|
warnings: diagnostics.filter((d) => d.severity === "warning"),
|
|
201
203
|
fingerprint:
|
|
202
204
|
pinned?.fingerprint ??
|