@pmelab/gtd 64.3.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 +34 -25
- 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-BUX-47TD.mjs → testkit-CgrLmpdJ.mjs} +740 -79
- package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-BNS8ciNu.mjs} +5 -3
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +553 -81
- package/dist/gtd.core.mjs +132 -129
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +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 +4 -4
- 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
package/src/edge/Edge.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
formatCommitMessage,
|
|
40
40
|
scopeRunStart,
|
|
41
41
|
setAsideBy,
|
|
42
|
+
diffTrees,
|
|
42
43
|
treeFromRecord,
|
|
43
44
|
type EpisodeCommit,
|
|
44
45
|
type JudgeVerdict,
|
|
@@ -51,10 +52,11 @@ import {
|
|
|
51
52
|
} from "../replay/index.js"
|
|
52
53
|
import type { Mark, ScopeAccess, SummaryContext } from "../flows/index.js"
|
|
53
54
|
import { steeringFormatFor } from "../steering/index.js"
|
|
54
|
-
import { pinScript, unpinScript } from "../emit/index.js"
|
|
55
|
+
import { failRestScript, noteOutcome, pinScript, unpinScript } from "../emit/index.js"
|
|
55
56
|
import {
|
|
56
57
|
judgeJson,
|
|
57
58
|
PIN_PATH,
|
|
59
|
+
RUN_FAILED_PATH,
|
|
58
60
|
UNATTRIBUTED_MODEL,
|
|
59
61
|
type ApprovalEntry,
|
|
60
62
|
type ModelCost,
|
|
@@ -63,6 +65,7 @@ import {
|
|
|
63
65
|
|
|
64
66
|
import { GTD_VERSION } from "../install/index.js"
|
|
65
67
|
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
68
|
+
import { GTD_BRANCH_PREFIX, startBranchFor, userBranchOf } from "../step/index.js"
|
|
66
69
|
import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
|
|
67
70
|
|
|
68
71
|
export { UNATTRIBUTED_MODEL }
|
|
@@ -478,21 +481,38 @@ const runAtTip = (
|
|
|
478
481
|
return yield* runOf([...before, ...closing], { ...location }, tip.hash)
|
|
479
482
|
})
|
|
480
483
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
/** The process HEAD closes or sits inside — `gtd summary`'s run. */
|
|
484
|
+
/**
|
|
485
|
+
* The process HEAD closes or sits inside — `gtd summary`'s run. On `<b>`, where
|
|
486
|
+
* a finished process leaves HEAD, it is the one `gtd/<b>`'s tip closes, while
|
|
487
|
+
* `<b>` holds nothing since: its start, or its last ship.
|
|
488
|
+
*/
|
|
488
489
|
export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
|
|
489
490
|
function* () {
|
|
490
491
|
const git = yield* GitService
|
|
491
492
|
const def = (yield* (yield* ConfigService).load).workflow
|
|
492
|
-
|
|
493
|
+
const head = (yield* git.subjectHistory(1, 0))[0]
|
|
494
|
+
const left = head === undefined ? undefined : yield* leftProcessTip(git, head)
|
|
495
|
+
return yield* runAtTip(git, def, left ?? head)
|
|
493
496
|
},
|
|
494
497
|
)
|
|
495
498
|
|
|
499
|
+
const leftProcessTip = (
|
|
500
|
+
git: GitOperations,
|
|
501
|
+
head: { readonly hash: string; readonly subject: string },
|
|
502
|
+
) =>
|
|
503
|
+
Effect.gen(function* () {
|
|
504
|
+
if (parseCommitMessage(head.subject).parsed !== undefined) return undefined
|
|
505
|
+
const current = yield* git.currentBranch()
|
|
506
|
+
if (Option.isNone(current) || userBranchOf(current.value) !== undefined) return undefined
|
|
507
|
+
const tip = yield* git.readRefOption(`refs/heads/${GTD_BRANCH_PREFIX}${current.value}`)
|
|
508
|
+
if (Option.isNone(tip)) return undefined
|
|
509
|
+
const message = yield* git.lastCommitMessage(tip.value)
|
|
510
|
+
if (parseCommitMessage(message).parsed?.to === undefined) return undefined
|
|
511
|
+
const since = parseCommitMessage(yield* git.lastCommitMessage(head.hash)).tip ?? head.hash
|
|
512
|
+
if (!(yield* git.isAncestor(since, tip.value))) return undefined
|
|
513
|
+
return { hash: tip.value, subject: message.split("\n")[0]! }
|
|
514
|
+
})
|
|
515
|
+
|
|
496
516
|
// ── Trees ───────────────────────────────────────────────────────────────────
|
|
497
517
|
|
|
498
518
|
const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
|
|
@@ -653,7 +673,11 @@ interface ReplaySetup {
|
|
|
653
673
|
|
|
654
674
|
const replayFor = (
|
|
655
675
|
setup: ReplaySetup,
|
|
656
|
-
pending?: {
|
|
676
|
+
pending?: {
|
|
677
|
+
readonly tree: TreeView
|
|
678
|
+
readonly verdicts?: readonly JudgeVerdict[]
|
|
679
|
+
readonly shipped?: string
|
|
680
|
+
},
|
|
657
681
|
): Promise<ReplayOutcome> =>
|
|
658
682
|
replay({
|
|
659
683
|
flow: setup.def.flow,
|
|
@@ -744,7 +768,19 @@ const promptDef = (
|
|
|
744
768
|
const scriptDef = (common: StepCommon, request: RequestOf<"run">): StepDef => ({
|
|
745
769
|
...common,
|
|
746
770
|
kind: "script",
|
|
747
|
-
content:
|
|
771
|
+
content:
|
|
772
|
+
request.options.onFail === "rest"
|
|
773
|
+
? failRestScript(request.body, RUN_FAILED_PATH)
|
|
774
|
+
: request.body,
|
|
775
|
+
...optional("onFail", request.options.onFail),
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
const SHIP_CONTENT = noteOutcome("gtd: landing this step ships the process onto your branch")
|
|
779
|
+
|
|
780
|
+
const shipDef = (common: StepCommon): StepDef => ({
|
|
781
|
+
...common,
|
|
782
|
+
kind: "script",
|
|
783
|
+
content: SHIP_CONTENT,
|
|
748
784
|
})
|
|
749
785
|
|
|
750
786
|
const judgeDef = (common: StepCommon, request: RequestOf<"judge">, step: ReachedStep): StepDef => {
|
|
@@ -777,6 +813,8 @@ const stepDefOf = (step: ReachedStep): StepDef => {
|
|
|
777
813
|
return promptDef(common, request, step.skills, step.access)
|
|
778
814
|
case "run":
|
|
779
815
|
return scriptDef(common, request)
|
|
816
|
+
case "ship":
|
|
817
|
+
return shipDef(common)
|
|
780
818
|
case "judge":
|
|
781
819
|
return judgeDef(common, request, step)
|
|
782
820
|
case "human":
|
|
@@ -885,6 +923,8 @@ interface Rest extends ResolvedRest {
|
|
|
885
923
|
/** Total recorded cost of the process, and its per-model breakdown. */
|
|
886
924
|
readonly cost: { readonly total: number; readonly byModel: readonly ModelCost[] }
|
|
887
925
|
readonly setup: ReplaySetup
|
|
926
|
+
/** At a ship step on a clean tree: why landing it would be refused. */
|
|
927
|
+
readonly shipBlocked?: string
|
|
888
928
|
}
|
|
889
929
|
|
|
890
930
|
/** Per-model token totals, highest-cost first (ties broken by model name). */
|
|
@@ -1054,6 +1094,8 @@ export const restAt = (
|
|
|
1054
1094
|
yield* (yield* Narrator).warn(
|
|
1055
1095
|
`gtd: notice: the live config differs from the pinned one (${config.drift.join(", ")}); \`gtd repin\` to switch`,
|
|
1056
1096
|
)
|
|
1097
|
+
const shipBlocked =
|
|
1098
|
+
ref === undefined ? yield* shipBlockedAt(setup, pin, step, changes) : undefined
|
|
1057
1099
|
return {
|
|
1058
1100
|
def,
|
|
1059
1101
|
workflowName: resolved.name,
|
|
@@ -1073,6 +1115,7 @@ export const restAt = (
|
|
|
1073
1115
|
hints: hintsOf(stepDef),
|
|
1074
1116
|
cost: costTotals(run.costEntries),
|
|
1075
1117
|
setup,
|
|
1118
|
+
...(shipBlocked !== undefined ? { shipBlocked } : {}),
|
|
1076
1119
|
}
|
|
1077
1120
|
})
|
|
1078
1121
|
|
|
@@ -1246,6 +1289,8 @@ export interface RenderedRest extends RestHints {
|
|
|
1246
1289
|
readonly memory?: string
|
|
1247
1290
|
readonly memoryResumed: boolean
|
|
1248
1291
|
readonly pin?: PinChange
|
|
1292
|
+
readonly failure?: string
|
|
1293
|
+
readonly blocked?: string
|
|
1249
1294
|
}
|
|
1250
1295
|
|
|
1251
1296
|
export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
@@ -1255,6 +1300,10 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
|
1255
1300
|
kind: rest.stepDef.kind,
|
|
1256
1301
|
content: rest.stepDef.content,
|
|
1257
1302
|
...rest.hints,
|
|
1303
|
+
...(failedRunStalled(rest)
|
|
1304
|
+
? { failure: rest.setup.workspace.readSync(RUN_FAILED_PATH) ?? "" }
|
|
1305
|
+
: {}),
|
|
1306
|
+
...(rest.shipBlocked !== undefined ? { blocked: rest.shipBlocked } : {}),
|
|
1258
1307
|
...(rest.memory !== undefined ? { memory: rest.memory } : {}),
|
|
1259
1308
|
memoryResumed: rest.memoryResumed,
|
|
1260
1309
|
...(rest.pin !== undefined ? { pin: rest.pin } : {}),
|
|
@@ -1265,13 +1314,25 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
|
1265
1314
|
* attempt at this very agent rest, and another dispatch would repeat it.
|
|
1266
1315
|
*/
|
|
1267
1316
|
export const stalledAt = (rest: Rest): boolean =>
|
|
1317
|
+
failedRunStalled(rest) ||
|
|
1318
|
+
rest.shipBlocked !== undefined ||
|
|
1319
|
+
(rest.changes.length === 0 &&
|
|
1320
|
+
rest.stepDef.kind === "prompt" &&
|
|
1321
|
+
rest.stepDef.allowEmpty !== true &&
|
|
1322
|
+
rest.run.headTurn?.state === rest.state &&
|
|
1323
|
+
rest.run.headTurn.actor === rest.actor &&
|
|
1324
|
+
!rest.run.headTurn.step &&
|
|
1325
|
+
rest.run.headTurn.empty)
|
|
1326
|
+
|
|
1327
|
+
/** A `run(…, { onFail: "rest" })` step whose last turn recorded a failure, on a clean tree. */
|
|
1328
|
+
const failedRunStalled = (rest: Rest): boolean =>
|
|
1268
1329
|
rest.changes.length === 0 &&
|
|
1269
|
-
rest.stepDef.
|
|
1270
|
-
rest.stepDef.allowEmpty !== true &&
|
|
1330
|
+
rest.stepDef.onFail === "rest" &&
|
|
1271
1331
|
rest.run.headTurn?.state === rest.state &&
|
|
1272
|
-
rest.run.headTurn.actor === rest.actor &&
|
|
1273
1332
|
!rest.run.headTurn.step &&
|
|
1274
|
-
|
|
1333
|
+
// Read from the (clean) tree, not the commit's touched paths: a retry that
|
|
1334
|
+
// fails with identical output touches nothing yet must still stall.
|
|
1335
|
+
rest.setup.workspace.readSync(RUN_FAILED_PATH) !== undefined
|
|
1275
1336
|
|
|
1276
1337
|
/** 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. */
|
|
1277
1338
|
export const noProcessUnderway = (rest: Rest): boolean =>
|
|
@@ -1487,7 +1548,9 @@ const decideLanding = (
|
|
|
1487
1548
|
verdicts: readonly JudgeVerdict[] | undefined,
|
|
1488
1549
|
branch: Option.Option<string>,
|
|
1489
1550
|
approver: Option.Option<AuthorIdent>,
|
|
1551
|
+
shipped?: string,
|
|
1490
1552
|
): Effect.Effect<Landing, Error> =>
|
|
1553
|
+
// fallow-ignore-next-line complexity
|
|
1491
1554
|
Effect.gen(function* () {
|
|
1492
1555
|
if (rest.pin !== undefined) return pinLanding(rest, rest.pin, branch)
|
|
1493
1556
|
const unapproved = approvalRefusal(rest, approver)
|
|
@@ -1498,14 +1561,20 @@ const decideLanding = (
|
|
|
1498
1561
|
const message = accessRefusal(rest.changes, rest.stepDef.access.write)
|
|
1499
1562
|
if (message !== undefined) return { kind: "refusal", message }
|
|
1500
1563
|
}
|
|
1564
|
+
const landing = pendingTree(
|
|
1565
|
+
rest.setup.workspace,
|
|
1566
|
+
rest.setup.pendingWorktree(),
|
|
1567
|
+
reviewGateRewrite(rest.stepDef),
|
|
1568
|
+
)
|
|
1569
|
+
// A recorded failure lands as an attempt: the process stays at the step.
|
|
1570
|
+
if (rest.stepDef.onFail === "rest" && landing.read(RUN_FAILED_PATH) !== undefined) {
|
|
1571
|
+
return { kind: "attempt", subject: formatSubject(rest.actor, rest.state) }
|
|
1572
|
+
}
|
|
1501
1573
|
const outcome = yield* Effect.promise(() =>
|
|
1502
1574
|
replayFor(rest.setup, {
|
|
1503
|
-
tree:
|
|
1504
|
-
rest.setup.workspace,
|
|
1505
|
-
rest.setup.pendingWorktree(),
|
|
1506
|
-
reviewGateRewrite(rest.stepDef),
|
|
1507
|
-
),
|
|
1575
|
+
tree: landing,
|
|
1508
1576
|
...(verdicts !== undefined ? { verdicts } : {}),
|
|
1577
|
+
...(shipped !== undefined ? { shipped } : {}),
|
|
1509
1578
|
}),
|
|
1510
1579
|
)
|
|
1511
1580
|
const to = yield* landingTarget(outcome, rest)
|
|
@@ -1513,31 +1582,227 @@ const decideLanding = (
|
|
|
1513
1582
|
return landingTo(rest, to, branch, approver, outcomeMarks(outcome))
|
|
1514
1583
|
})
|
|
1515
1584
|
|
|
1585
|
+
/** The user's branch: `<b>` for a process on `gtd/<b>` while `<b>` still exists; any other branch as is. */
|
|
1586
|
+
const userBranch = (git: GitOperations): Effect.Effect<Option.Option<string>, Error> =>
|
|
1587
|
+
Effect.gen(function* () {
|
|
1588
|
+
const current = yield* git.currentBranch()
|
|
1589
|
+
const name = Option.isSome(current) ? userBranchOf(current.value) : undefined
|
|
1590
|
+
if (name === undefined) return current
|
|
1591
|
+
const there = yield* git.readRefOption(`refs/heads/${name}`)
|
|
1592
|
+
return Option.isSome(there) ? Option.some(name) : current
|
|
1593
|
+
})
|
|
1594
|
+
|
|
1595
|
+
/** `<b>` for a process on `gtd/<b>` while `<b>` exists: where a finishing landing leaves HEAD. */
|
|
1596
|
+
const returnBranch = Effect.gen(function* () {
|
|
1597
|
+
const git = yield* GitService
|
|
1598
|
+
const current = yield* git.currentBranch()
|
|
1599
|
+
const name = Option.isSome(current) ? userBranchOf(current.value) : undefined
|
|
1600
|
+
if (name === undefined) return undefined
|
|
1601
|
+
return Option.isSome(yield* git.readRefOption(`refs/heads/${name}`)) ? name : undefined
|
|
1602
|
+
})
|
|
1603
|
+
|
|
1604
|
+
/** A starting landing moves onto `gtd/<b>` first. */
|
|
1605
|
+
const withBranchAndPush = (landing: Landing) =>
|
|
1606
|
+
Effect.gen(function* () {
|
|
1607
|
+
if (landing.kind !== "commit") return landing
|
|
1608
|
+
let out = landing
|
|
1609
|
+
if (landing.spec.vars !== undefined) {
|
|
1610
|
+
const start = yield* startBranchFor("gtd land")
|
|
1611
|
+
if (start.kind === "refusal") return { kind: "refusal", message: start.message } as Landing
|
|
1612
|
+
if (start.kind === "switch") out = { ...out, startBranch: start.branch }
|
|
1613
|
+
}
|
|
1614
|
+
return yield* withHistory(out)
|
|
1615
|
+
})
|
|
1616
|
+
|
|
1617
|
+
/** A closing landing switches back to `<b>`; it and a shipping one push their history ref. */
|
|
1618
|
+
const withHistory = (landing: Landing & { kind: "commit" }) =>
|
|
1619
|
+
Effect.gen(function* () {
|
|
1620
|
+
let out = landing
|
|
1621
|
+
const back = landing.keepHistory === undefined ? undefined : yield* returnBranch
|
|
1622
|
+
if (back !== undefined) out = { ...out, leaveBranch: back }
|
|
1623
|
+
if (landing.keepHistory === undefined && landing.ship === undefined) return out
|
|
1624
|
+
const remote = (yield* (yield* ConfigService).load).historyRemote
|
|
1625
|
+
return remote === undefined ? out : { ...out, pushHistory: remote }
|
|
1626
|
+
})
|
|
1627
|
+
|
|
1516
1628
|
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
1517
1629
|
export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
|
|
1518
|
-
decideLanding(
|
|
1630
|
+
decideLanding(
|
|
1631
|
+
rest,
|
|
1632
|
+
undefined,
|
|
1633
|
+
Option.none(),
|
|
1634
|
+
Option.none(),
|
|
1635
|
+
// Assumes the ship ships: deciding that needs git, and a preview reads none.
|
|
1636
|
+
rest.step.request.kind === "ship" ? SHIPPED_STAND_IN : undefined,
|
|
1637
|
+
).pipe(
|
|
1519
1638
|
Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
|
|
1520
1639
|
Effect.catch(() => Effect.succeed(undefined)),
|
|
1521
1640
|
)
|
|
1522
1641
|
|
|
1642
|
+
const snapshotOf = (rest: Rest, landing: Landing): RepoSnapshot => ({
|
|
1643
|
+
stepDef: rest.stepDef,
|
|
1644
|
+
state: rest.state,
|
|
1645
|
+
actor: rest.actor,
|
|
1646
|
+
changes: rest.changes,
|
|
1647
|
+
file: rest.hints.file,
|
|
1648
|
+
landing,
|
|
1649
|
+
})
|
|
1650
|
+
|
|
1651
|
+
const refusedSnapshot = (rest: Rest, message: string): RepoSnapshot =>
|
|
1652
|
+
snapshotOf(rest, { kind: "refusal", message })
|
|
1653
|
+
|
|
1654
|
+
// The squash's hash exists only once the landing script ran; the flow deciding
|
|
1655
|
+
// the landing's target sees this instead, and every later replay the real one.
|
|
1656
|
+
const SHIPPED_STAND_IN = "0".repeat(40)
|
|
1657
|
+
|
|
1658
|
+
const SHIP_EXCLUDED = ".gtd/"
|
|
1659
|
+
|
|
1660
|
+
type ShipDecision =
|
|
1661
|
+
| { readonly kind: "ship"; readonly branch: string; readonly parent: string }
|
|
1662
|
+
| { readonly kind: "skip"; readonly note: string }
|
|
1663
|
+
| { readonly kind: "blocked"; readonly message: string }
|
|
1664
|
+
|
|
1665
|
+
const shippedPaths = (before: TreeView, after: TreeView): readonly string[] => {
|
|
1666
|
+
const diff = diffTrees(before, after)
|
|
1667
|
+
return [...diff.added, ...diff.modified, ...diff.deleted].filter(
|
|
1668
|
+
(path) => !path.startsWith(SHIP_EXCLUDED),
|
|
1669
|
+
)
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const noTarget = (why: string): ShipDecision => ({
|
|
1673
|
+
kind: "skip",
|
|
1674
|
+
note: `gtd: no branch to ship to: ${why}`,
|
|
1675
|
+
})
|
|
1676
|
+
|
|
1677
|
+
/** `<b>` and its tip, for a process on `gtd/<b>`; why there is none otherwise. */
|
|
1678
|
+
const shipTarget = (git: GitOperations) =>
|
|
1679
|
+
Effect.gen(function* () {
|
|
1680
|
+
const current = yield* git.currentBranch()
|
|
1681
|
+
if (Option.isNone(current)) return noTarget("HEAD is detached")
|
|
1682
|
+
const branch = userBranchOf(current.value)
|
|
1683
|
+
if (branch === undefined) return noTarget(`${current.value} is no gtd/ branch`)
|
|
1684
|
+
if (userBranchOf(branch) !== undefined) {
|
|
1685
|
+
return noTarget(`${current.value} records no user branch`)
|
|
1686
|
+
}
|
|
1687
|
+
const parent = yield* git.readRefOption(`refs/heads/${branch}`)
|
|
1688
|
+
if (Option.isNone(parent)) return noTarget(`${branch} does not exist`)
|
|
1689
|
+
return { kind: "ship", branch, parent: parent.value } as const
|
|
1690
|
+
})
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* The paths `<b>` changed outside this process that shipping `tree` would
|
|
1694
|
+
* revert. A path the working tree changed since HEAD counts as brought in:
|
|
1695
|
+
* whether it carries `<b>`'s change is the person's call, not a merge gtd redoes.
|
|
1696
|
+
*/
|
|
1697
|
+
const revertedPaths = (
|
|
1698
|
+
git: GitOperations,
|
|
1699
|
+
workspace: WorkspaceOps,
|
|
1700
|
+
parent: string,
|
|
1701
|
+
tree: TreeView,
|
|
1702
|
+
changed: ReadonlySet<string>,
|
|
1703
|
+
) =>
|
|
1704
|
+
Effect.gen(function* () {
|
|
1705
|
+
const head = yield* git.resolveRef("HEAD")
|
|
1706
|
+
const shipped = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip
|
|
1707
|
+
if (shipped !== undefined && (yield* git.isAncestor(shipped, head))) return []
|
|
1708
|
+
const fork = yield* git.mergeBase(parent, head)
|
|
1709
|
+
const foreign = Option.isNone(fork)
|
|
1710
|
+
? [...changed]
|
|
1711
|
+
: shippedPaths(commitTree(workspace, fork.value), commitTree(workspace, parent))
|
|
1712
|
+
const committed = commitTree(workspace, head)
|
|
1713
|
+
return foreign.filter((path) => changed.has(path) && tree.read(path) === committed.read(path))
|
|
1714
|
+
})
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* Whether landing a ship step with `tree` would ship, and onto what. The squash
|
|
1718
|
+
* replaces the user's branch's tree with `tree`, so it is blocked while that
|
|
1719
|
+
* branch carries a change `tree` would revert: one made since the last ship
|
|
1720
|
+
* (whose `Gtd-Tip` HEAD descends from), or since it forked from HEAD.
|
|
1721
|
+
*/
|
|
1722
|
+
const decideShip = (
|
|
1723
|
+
workspace: WorkspaceOps,
|
|
1724
|
+
tree: TreeView,
|
|
1725
|
+
): Effect.Effect<ShipDecision, Error, GitService> =>
|
|
1726
|
+
Effect.gen(function* () {
|
|
1727
|
+
const git = yield* GitService
|
|
1728
|
+
const target = yield* shipTarget(git)
|
|
1729
|
+
if (target.kind !== "ship") return target
|
|
1730
|
+
const { branch, parent } = target
|
|
1731
|
+
const changed = new Set(shippedPaths(commitTree(workspace, parent), tree))
|
|
1732
|
+
const reverted = yield* revertedPaths(git, workspace, parent, tree, changed)
|
|
1733
|
+
if (reverted.length > 0) {
|
|
1734
|
+
return {
|
|
1735
|
+
kind: "blocked",
|
|
1736
|
+
message: `gtd: ${branch} changed outside this process, and shipping would revert ${reverted.join(", ")} — bring those changes into the working tree (\`git merge --squash --no-commit ${branch}\`, or by hand) and land`,
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return changed.size === 0
|
|
1740
|
+
? { kind: "skip", note: `gtd: nothing to ship: ${branch} already has this tree` }
|
|
1741
|
+
: target
|
|
1742
|
+
})
|
|
1743
|
+
|
|
1744
|
+
const shipBlockedAt = (
|
|
1745
|
+
setup: ReplaySetup,
|
|
1746
|
+
pin: PinChange | undefined,
|
|
1747
|
+
step: ReachedStep,
|
|
1748
|
+
changes: readonly PendingChange[],
|
|
1749
|
+
) =>
|
|
1750
|
+
Effect.gen(function* () {
|
|
1751
|
+
if (pin !== undefined || step.request.kind !== "ship" || changes.length > 0) return undefined
|
|
1752
|
+
const head = yield* (yield* GitService).resolveRef("HEAD")
|
|
1753
|
+
const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head))
|
|
1754
|
+
return decision.kind === "blocked" ? decision.message : undefined
|
|
1755
|
+
})
|
|
1756
|
+
|
|
1757
|
+
const shipSnapshot = (
|
|
1758
|
+
rest: Rest,
|
|
1759
|
+
message: string,
|
|
1760
|
+
verdicts: readonly JudgeVerdict[] | undefined,
|
|
1761
|
+
branch: Option.Option<string>,
|
|
1762
|
+
approver: Option.Option<AuthorIdent>,
|
|
1763
|
+
) =>
|
|
1764
|
+
Effect.gen(function* () {
|
|
1765
|
+
const tree = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), undefined)
|
|
1766
|
+
const decision = yield* decideShip(rest.setup.workspace, tree)
|
|
1767
|
+
if (decision.kind === "blocked") return refusedSnapshot(rest, decision.message)
|
|
1768
|
+
const shipping = decision.kind === "ship"
|
|
1769
|
+
const decided = yield* decideLanding(
|
|
1770
|
+
rest,
|
|
1771
|
+
verdicts,
|
|
1772
|
+
branch,
|
|
1773
|
+
approver,
|
|
1774
|
+
shipping ? SHIPPED_STAND_IN : undefined,
|
|
1775
|
+
)
|
|
1776
|
+
if (decided.kind !== "commit") return snapshotOf(rest, decided)
|
|
1777
|
+
const landing: Landing = shipping
|
|
1778
|
+
? {
|
|
1779
|
+
...decided,
|
|
1780
|
+
ship: {
|
|
1781
|
+
branch: decision.branch,
|
|
1782
|
+
parent: decision.parent,
|
|
1783
|
+
shipMessage: message,
|
|
1784
|
+
historyRef: processHistoryRef(rest.run.startHash),
|
|
1785
|
+
},
|
|
1786
|
+
}
|
|
1787
|
+
: { ...decided, note: decision.note }
|
|
1788
|
+
return snapshotOf(rest, yield* withBranchAndPush(landing))
|
|
1789
|
+
})
|
|
1790
|
+
|
|
1523
1791
|
/** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
|
|
1524
1792
|
export const snapshotFromRest = (
|
|
1525
1793
|
rest: Rest,
|
|
1526
1794
|
verdicts?: readonly JudgeVerdict[],
|
|
1527
|
-
): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
|
|
1795
|
+
): Effect.Effect<RepoSnapshot, Error, Workspace | GitService | RestRequirements> =>
|
|
1528
1796
|
Effect.gen(function* () {
|
|
1529
1797
|
const git = yield* GitService
|
|
1530
|
-
const branch = yield* git
|
|
1798
|
+
const branch = yield* userBranch(git)
|
|
1531
1799
|
const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : Option.none()
|
|
1532
|
-
const
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
state: rest.state,
|
|
1536
|
-
actor: rest.actor,
|
|
1537
|
-
changes: rest.changes,
|
|
1538
|
-
file: rest.hints.file,
|
|
1539
|
-
landing,
|
|
1800
|
+
const request = rest.step.request
|
|
1801
|
+
if (rest.pin === undefined && request.kind === "ship") {
|
|
1802
|
+
return yield* shipSnapshot(rest, request.message, verdicts, branch, approver)
|
|
1540
1803
|
}
|
|
1804
|
+
const decided = yield* decideLanding(rest, verdicts, branch, approver)
|
|
1805
|
+
return snapshotOf(rest, yield* withBranchAndPush(decided))
|
|
1541
1806
|
})
|
|
1542
1807
|
|
|
1543
1808
|
// ── Summary ─────────────────────────────────────────────────────────────────
|
package/src/emit/GitScript.ts
CHANGED
|
@@ -12,8 +12,11 @@ export const pathspec = (paths: ReadonlyArray<string>): string => paths.map(shel
|
|
|
12
12
|
* output rather than the exit code, so a genuinely rejected commit (a lint
|
|
13
13
|
* error, a commit-msg hook) still fails.
|
|
14
14
|
*/
|
|
15
|
-
const commitAllowEmpty = (message: string, only?: string): string =>
|
|
16
|
-
|
|
15
|
+
const commitAllowEmpty = (message: string, only?: string): string =>
|
|
16
|
+
commitWordAllowEmpty(shellQuote(message), only)
|
|
17
|
+
|
|
18
|
+
/** `commitAllowEmpty` over a message that is already a shell word. */
|
|
19
|
+
const commitWordAllowEmpty = (m: string, only?: string): string => {
|
|
17
20
|
const paths = only === undefined ? "" : ` -- ${shellQuote(only)}`
|
|
18
21
|
return [
|
|
19
22
|
`if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
|
|
@@ -36,6 +39,29 @@ export const commitAll = (message: string): string =>
|
|
|
36
39
|
export const commitPath = (path: string, message: string): string =>
|
|
37
40
|
`git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A `run()` body that records its own failure instead of failing the beat: the
|
|
44
|
+
* status and the last 50 output lines go to `path`, and the script exits zero
|
|
45
|
+
* so the turn lands. The file is COMMITTED (and force-pushed with a history
|
|
46
|
+
* remote), hence the cap.
|
|
47
|
+
*/
|
|
48
|
+
export const failRestScript = (body: string, path: string): string => {
|
|
49
|
+
const p = shellQuote(path)
|
|
50
|
+
const slash = path.lastIndexOf("/")
|
|
51
|
+
return [
|
|
52
|
+
`rm -f -- ${p}`,
|
|
53
|
+
`gtd_run_status=0`,
|
|
54
|
+
`gtd_run_out="$( {`,
|
|
55
|
+
body,
|
|
56
|
+
`} 2>&1 )" || gtd_run_status=$?`,
|
|
57
|
+
`printf '%s\\n' "$gtd_run_out"`,
|
|
58
|
+
`if [ "$gtd_run_status" -ne 0 ]; then`,
|
|
59
|
+
` mkdir -p ${shellQuote(path.slice(0, slash))}`,
|
|
60
|
+
` printf 'exit %s\\n%s\\n' "$gtd_run_status" "$(printf '%s\\n' "$gtd_run_out" | tail -n 50)" >${p}`,
|
|
61
|
+
`fi`,
|
|
62
|
+
].join("\n")
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
/** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
|
|
40
66
|
export const pinScript = (path: string): string => {
|
|
41
67
|
const p = shellQuote(path)
|
|
@@ -123,6 +149,77 @@ export const keepTurn = (refPrefix: string, message: string): string => {
|
|
|
123
149
|
].join("\n")
|
|
124
150
|
}
|
|
125
151
|
|
|
152
|
+
/** `git switch -C <branch> [<start>]`: create or reset the branch, carrying pending changes over. */
|
|
153
|
+
export const switchBranchTo = (branch: string, start?: string): string =>
|
|
154
|
+
`git switch -q -C ${shellQuote(branch)}${start === undefined ? "" : ` ${shellQuote(start)}`}`
|
|
155
|
+
|
|
156
|
+
/** `git switch <branch>`: an existing branch, carrying pending changes over. */
|
|
157
|
+
export const switchTo = (branch: string): string => `git switch -q ${shellQuote(branch)}`
|
|
158
|
+
|
|
159
|
+
/** `switchTo` that, when pending changes block it, stays put and says so: the landing already happened. */
|
|
160
|
+
export const switchBack = (branch: string): string =>
|
|
161
|
+
[
|
|
162
|
+
`if ! git switch -q ${shellQuote(branch)} 2>/dev/null; then`,
|
|
163
|
+
` printf '%s\\n' ${shellQuote(`gtd: pending changes keep HEAD off ${branch}; switch to it once they are committed or stashed`)}`,
|
|
164
|
+
`fi`,
|
|
165
|
+
].join("\n")
|
|
166
|
+
|
|
167
|
+
export const deleteBranch = (branch: string): string => `git branch -q -D ${shellQuote(branch)}`
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Force-pushes one ref to a remote. A failed push exits non-zero with the retry
|
|
171
|
+
* on stderr: it runs after the closing commit has landed, so the commit stands.
|
|
172
|
+
*/
|
|
173
|
+
export const pushRef = (remote: string, ref: string): string => {
|
|
174
|
+
const retry = `git push --force ${shellQuote(remote)} ${shellQuote(`${ref}:${ref}`)}`
|
|
175
|
+
return [
|
|
176
|
+
`if ! ${retry}; then`,
|
|
177
|
+
` printf '%s\\n' ${shellQuote(`gtd: pushing ${ref} failed — retry: ${retry}`)} >&2`,
|
|
178
|
+
` exit 1`,
|
|
179
|
+
`fi`,
|
|
180
|
+
].join("\n")
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A ship step's landing, in one block because the landing commit's
|
|
185
|
+
* `Gtd-Shipped` trailer names the squash it makes first. The squash carries
|
|
186
|
+
* the staged tree with `.gtd/` as `<branch>` has it, built in a scratch index; `<branch>` moves by
|
|
187
|
+
* compare-and-swap against `parent`, so a branch moved since the decision
|
|
188
|
+
* fails the script before the landing commits. `commit-tree` ignores
|
|
189
|
+
* `commit.gpgSign`, so it is read here.
|
|
190
|
+
*/
|
|
191
|
+
export const shipCommit = (
|
|
192
|
+
branch: string,
|
|
193
|
+
parent: string,
|
|
194
|
+
shipMessage: string,
|
|
195
|
+
historyRef: string,
|
|
196
|
+
message: string,
|
|
197
|
+
): string =>
|
|
198
|
+
[
|
|
199
|
+
`gtd_ship_parent=${shellQuote(parent)}`,
|
|
200
|
+
`gtd_ship_branch=${shellQuote(branch)}`,
|
|
201
|
+
`gtd_ship_history=${shellQuote(historyRef)}`,
|
|
202
|
+
`gtd_ship_message=${shellQuote(shipMessage)}`,
|
|
203
|
+
`gtd_land_message=${shellQuote(message)}`,
|
|
204
|
+
`git add -A || exit 1`,
|
|
205
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
206
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
207
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
208
|
+
`gtd_tree=$(git write-tree) || exit 1`,
|
|
209
|
+
`gtd_index=$(git rev-parse --git-path gtd-ship-index) || exit 1`,
|
|
210
|
+
`rm -f -- "$gtd_index"`,
|
|
211
|
+
`GIT_INDEX_FILE="$gtd_index" git read-tree "$gtd_tree" || exit 1`,
|
|
212
|
+
`GIT_INDEX_FILE="$gtd_index" git rm -r -q --cached --ignore-unmatch -- .gtd || exit 1`,
|
|
213
|
+
`git ls-tree -r "$gtd_ship_parent" -- .gtd | GIT_INDEX_FILE="$gtd_index" git update-index --index-info || exit 1`,
|
|
214
|
+
`gtd_ship_tree=$(GIT_INDEX_FILE="$gtd_index" git write-tree) || exit 1`,
|
|
215
|
+
`rm -f -- "$gtd_index"`,
|
|
216
|
+
`gtd_ship_trailers=$(printf "Gtd-History: %s\\nGtd-Tip: %s" "$gtd_ship_history" "$gtd_head")`,
|
|
217
|
+
`gtd_shipped=$(git commit-tree "$gtd_sign" "$gtd_ship_tree" -p "$gtd_ship_parent" -m "$gtd_ship_message" -m "$gtd_ship_trailers") || exit 1`,
|
|
218
|
+
`git update-ref -m "gtd: ship" "refs/heads/$gtd_ship_branch" "$gtd_shipped" "$gtd_ship_parent" || exit 1`,
|
|
219
|
+
`gtd_message=$(printf "%s\\nGtd-Shipped: %s" "$gtd_land_message" "$gtd_shipped")`,
|
|
220
|
+
commitWordAllowEmpty(`"$gtd_message"`),
|
|
221
|
+
].join("\n")
|
|
222
|
+
|
|
126
223
|
export const updateRef = (ref: string, hash: string): string =>
|
|
127
224
|
`git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`
|
|
128
225
|
|
|
@@ -170,21 +267,25 @@ const renderOutcome = (outcome: Outcome): string => {
|
|
|
170
267
|
}
|
|
171
268
|
}
|
|
172
269
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
270
|
+
type Renderers = {
|
|
271
|
+
readonly [K in GitWrite["kind"]]: (write: Extract<GitWrite, { kind: K }>) => string
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const GIT_WRITES: Renderers = {
|
|
275
|
+
commitAll: (w) => commitAll(w.message),
|
|
276
|
+
commitPath: (w) => commitPath(w.path, w.message),
|
|
277
|
+
commitClean: (w) => commitClean(w.message),
|
|
278
|
+
updateRef: (w) => updateRef(w.ref, w.target),
|
|
279
|
+
keepTurn: (w) => keepTurn(w.ref, w.message),
|
|
280
|
+
switchBranch: (w) => switchBranchTo(w.branch),
|
|
281
|
+
switchTo: (w) => switchBack(w.branch),
|
|
282
|
+
pushRef: (w) => pushRef(w.remote, w.ref),
|
|
283
|
+
ship: (w) => shipCommit(w.branch, w.parent, w.shipMessage, w.historyRef, w.message),
|
|
186
284
|
}
|
|
187
285
|
|
|
286
|
+
const renderGitWrite = (write: GitWrite): string =>
|
|
287
|
+
(GIT_WRITES[write.kind] as (write: GitWrite) => string)(write)
|
|
288
|
+
|
|
188
289
|
/**
|
|
189
290
|
* Wraps `command` so a non-zero exit prints `prompt` plus the command's
|
|
190
291
|
* captured output before propagating that exit code — mirrors `Emit.ts`'s
|
package/src/emit/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export {
|
|
|
18
18
|
commitClean,
|
|
19
19
|
commitPath,
|
|
20
20
|
pinScript,
|
|
21
|
+
failRestScript,
|
|
21
22
|
repinScript,
|
|
22
23
|
unpinScript,
|
|
23
24
|
CHANGED_SINCE_LAND,
|
|
@@ -28,6 +29,12 @@ export {
|
|
|
28
29
|
updateRef,
|
|
29
30
|
keepTurn,
|
|
30
31
|
deleteRef,
|
|
32
|
+
switchBranchTo,
|
|
33
|
+
switchBack,
|
|
34
|
+
switchTo,
|
|
35
|
+
deleteBranch,
|
|
36
|
+
pushRef,
|
|
37
|
+
shipCommit,
|
|
31
38
|
fetchHistoryRefs,
|
|
32
39
|
type RunnableScript,
|
|
33
40
|
ScriptSurface,
|
package/src/flows/config.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface GtdConfig {
|
|
|
21
21
|
readonly ui?: UiSettings
|
|
22
22
|
readonly skills?: Readonly<Record<string, readonly string[]>>
|
|
23
23
|
readonly access?: Readonly<Record<string, ScopeAccess>>
|
|
24
|
+
/** A remote name: every finished or abandoned process force-pushes its history ref there. */
|
|
25
|
+
readonly historyRemote?: string
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export interface ConfigProblem {
|
|
@@ -28,7 +30,7 @@ export interface ConfigProblem {
|
|
|
28
30
|
readonly message: string
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
const CONFIG_KEYS = ["modes", "ui", "skills", "access"] as const
|
|
33
|
+
const CONFIG_KEYS = ["modes", "ui", "skills", "access", "historyRemote"] as const
|
|
32
34
|
const MODE_KEYS = ["format", "validate"] as const
|
|
33
35
|
const UI_KEYS = ["port", "host", "cert", "key", "format"] as const
|
|
34
36
|
|
|
@@ -113,6 +115,10 @@ export const configProblems = (value: unknown): readonly ConfigProblem[] => {
|
|
|
113
115
|
...unknownKeys(value, CONFIG_KEYS, []),
|
|
114
116
|
...(value.modes === undefined ? [] : recordOf(value.modes, ["modes"], "modes", modeProblems)),
|
|
115
117
|
...(value.ui === undefined ? [] : uiProblems(value.ui, ["ui"])),
|
|
118
|
+
...(value.historyRemote === undefined ||
|
|
119
|
+
(typeof value.historyRemote === "string" && value.historyRemote.trim() !== "")
|
|
120
|
+
? []
|
|
121
|
+
: [{ path: ["historyRemote"], message: "must be a non-empty remote name" }]),
|
|
116
122
|
...(value.skills === undefined
|
|
117
123
|
? []
|
|
118
124
|
: recordOf(value.skills, ["skills"], "skill-name arrays", (v, path) =>
|