@pmelab/gtd 64.3.0 → 66.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 +35 -25
- package/claude/hooks/assist.ts +9 -35
- package/claude/hooks/doors.ts +4 -4
- package/claude/hooks/drive.ts +10 -1
- 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 +86 -26
- package/claude/hooks/register.tsx +35 -82
- package/claude/hooks/tools.ts +7 -20
- package/claude/hooks/wording.ts +3 -3
- package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
- package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
- package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
- package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +965 -163
- package/dist/gtd.core.mjs +150 -134
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +2 -2
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +185 -18
- package/dist/wire.mjs +1 -1
- package/dist/workflow-kit.mjs +9 -3
- 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 +105 -7
- package/src/edge/Edge.test.ts +37 -16
- package/src/edge/Edge.ts +380 -33
- package/src/emit/GitScript.ts +116 -15
- package/src/emit/index.ts +7 -0
- package/src/flows/config.ts +7 -1
- package/src/flows/helpers.ts +36 -18
- package/src/flows/index.test.ts +4 -0
- package/src/flows/index.ts +7 -0
- package/src/flows/runtime.ts +153 -15
- package/src/install/Install.ts +9 -2
- package/src/platform/Git.test.ts +7 -11
- package/src/platform/NodeSync.ts +2 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/SyncIo.ts +2 -0
- package/src/platform/Workspace.test.ts +37 -0
- package/src/platform/Workspace.ts +36 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Replay.test.ts +9 -0
- package/src/replay/Replay.ts +92 -16
- package/src/replay/Trailers.ts +17 -0
- package/src/replay/Tree.ts +5 -0
- package/src/replay/index.ts +1 -1
- package/src/steering/FORMATS.md +3 -1
- package/src/steering/SteeringFormat.ts +13 -0
- package/src/steering/fixtures/review/sectioned.md +1 -1
- package/src/steering/fixtures/review/sectioned.read.json +0 -1
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +181 -5
- package/src/steering/review.ts +160 -18
- package/src/step/LandStep.ts +12 -0
- package/src/step/RepoSnapshot.ts +15 -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 +22 -17
- package/src/step/startBranch.ts +54 -0
- package/src/twins/EmittedScriptRecognizer.ts +118 -1
- package/src/twins/EmittedScripts.test.ts +147 -1
- package/src/twins/InMemRepo.ts +140 -3
- package/src/twins/Layers.ts +1 -0
- package/src/twins/SyncIo.test.ts +19 -4
- package/src/twins/SyncIo.ts +13 -1
- package/src/wire/BeatDocument.test.ts +15 -0
- package/src/wire/BeatDocument.ts +8 -2
- package/src/wire/Demand.ts +26 -12
- package/src/wire/constants.ts +3 -0
- package/src/wire/golden/1/beat-capture.json +1 -0
- package/src/wire/golden/1/beat-message.json +1 -0
- package/src/wire/golden/1/beat-prompt.json +1 -0
- package/src/wire/golden/1/beat-script.json +1 -0
- package/src/wire/golden/1/beat-stalled.json +1 -0
- package/src/wire/types.ts +4 -0
- package/src/workflow/Workflow.ts +3 -0
- package/src/workflow/compile.ts +2 -0
- package/src/workflow/load.ts +2 -0
- package/src/workflow-kit/index.test.ts +1 -0
- package/src/workflow-kit/index.ts +1 -0
- package/src/workflow-kit/scenarios.ts +8 -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,
|
|
@@ -737,6 +761,7 @@ const promptDef = (
|
|
|
737
761
|
...optional("system", request.options.system),
|
|
738
762
|
...optional("allowEmpty", request.options.allowEmpty),
|
|
739
763
|
...optional("keep", request.options.keep),
|
|
764
|
+
...optional("original", request.original),
|
|
740
765
|
...optional("skills", skills),
|
|
741
766
|
...optional("access", access),
|
|
742
767
|
})
|
|
@@ -744,7 +769,19 @@ const promptDef = (
|
|
|
744
769
|
const scriptDef = (common: StepCommon, request: RequestOf<"run">): StepDef => ({
|
|
745
770
|
...common,
|
|
746
771
|
kind: "script",
|
|
747
|
-
content:
|
|
772
|
+
content:
|
|
773
|
+
request.options.onFail === "rest"
|
|
774
|
+
? failRestScript(request.body, RUN_FAILED_PATH)
|
|
775
|
+
: request.body,
|
|
776
|
+
...optional("onFail", request.options.onFail),
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
const SHIP_CONTENT = noteOutcome("gtd: landing this step ships the process onto your branch")
|
|
780
|
+
|
|
781
|
+
const shipDef = (common: StepCommon): StepDef => ({
|
|
782
|
+
...common,
|
|
783
|
+
kind: "script",
|
|
784
|
+
content: SHIP_CONTENT,
|
|
748
785
|
})
|
|
749
786
|
|
|
750
787
|
const judgeDef = (common: StepCommon, request: RequestOf<"judge">, step: ReachedStep): StepDef => {
|
|
@@ -777,6 +814,8 @@ const stepDefOf = (step: ReachedStep): StepDef => {
|
|
|
777
814
|
return promptDef(common, request, step.skills, step.access)
|
|
778
815
|
case "run":
|
|
779
816
|
return scriptDef(common, request)
|
|
817
|
+
case "ship":
|
|
818
|
+
return shipDef(common)
|
|
780
819
|
case "judge":
|
|
781
820
|
return judgeDef(common, request, step)
|
|
782
821
|
case "human":
|
|
@@ -885,6 +924,8 @@ interface Rest extends ResolvedRest {
|
|
|
885
924
|
/** Total recorded cost of the process, and its per-model breakdown. */
|
|
886
925
|
readonly cost: { readonly total: number; readonly byModel: readonly ModelCost[] }
|
|
887
926
|
readonly setup: ReplaySetup
|
|
927
|
+
/** At a ship step on a clean tree: why landing it would be refused. */
|
|
928
|
+
readonly shipBlocked?: string
|
|
888
929
|
}
|
|
889
930
|
|
|
890
931
|
/** Per-model token totals, highest-cost first (ties broken by model name). */
|
|
@@ -1054,6 +1095,8 @@ export const restAt = (
|
|
|
1054
1095
|
yield* (yield* Narrator).warn(
|
|
1055
1096
|
`gtd: notice: the live config differs from the pinned one (${config.drift.join(", ")}); \`gtd repin\` to switch`,
|
|
1056
1097
|
)
|
|
1098
|
+
const shipBlocked =
|
|
1099
|
+
ref === undefined ? yield* shipBlockedAt(setup, pin, step, changes) : undefined
|
|
1057
1100
|
return {
|
|
1058
1101
|
def,
|
|
1059
1102
|
workflowName: resolved.name,
|
|
@@ -1073,6 +1116,7 @@ export const restAt = (
|
|
|
1073
1116
|
hints: hintsOf(stepDef),
|
|
1074
1117
|
cost: costTotals(run.costEntries),
|
|
1075
1118
|
setup,
|
|
1119
|
+
...(shipBlocked !== undefined ? { shipBlocked } : {}),
|
|
1076
1120
|
}
|
|
1077
1121
|
})
|
|
1078
1122
|
|
|
@@ -1245,7 +1289,10 @@ export interface RenderedRest extends RestHints {
|
|
|
1245
1289
|
readonly content: string
|
|
1246
1290
|
readonly memory?: string
|
|
1247
1291
|
readonly memoryResumed: boolean
|
|
1292
|
+
readonly original?: string
|
|
1248
1293
|
readonly pin?: PinChange
|
|
1294
|
+
readonly failure?: string
|
|
1295
|
+
readonly blocked?: string
|
|
1249
1296
|
}
|
|
1250
1297
|
|
|
1251
1298
|
export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
@@ -1255,8 +1302,13 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
|
1255
1302
|
kind: rest.stepDef.kind,
|
|
1256
1303
|
content: rest.stepDef.content,
|
|
1257
1304
|
...rest.hints,
|
|
1305
|
+
...(failedRunStalled(rest)
|
|
1306
|
+
? { failure: rest.setup.workspace.readSync(RUN_FAILED_PATH) ?? "" }
|
|
1307
|
+
: {}),
|
|
1308
|
+
...(rest.shipBlocked !== undefined ? { blocked: rest.shipBlocked } : {}),
|
|
1258
1309
|
...(rest.memory !== undefined ? { memory: rest.memory } : {}),
|
|
1259
1310
|
memoryResumed: rest.memoryResumed,
|
|
1311
|
+
...(rest.stepDef.original !== undefined ? { original: rest.stepDef.original } : {}),
|
|
1260
1312
|
...(rest.pin !== undefined ? { pin: rest.pin } : {}),
|
|
1261
1313
|
})
|
|
1262
1314
|
|
|
@@ -1265,13 +1317,25 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
|
1265
1317
|
* attempt at this very agent rest, and another dispatch would repeat it.
|
|
1266
1318
|
*/
|
|
1267
1319
|
export const stalledAt = (rest: Rest): boolean =>
|
|
1320
|
+
failedRunStalled(rest) ||
|
|
1321
|
+
rest.shipBlocked !== undefined ||
|
|
1322
|
+
(rest.changes.length === 0 &&
|
|
1323
|
+
rest.stepDef.kind === "prompt" &&
|
|
1324
|
+
rest.stepDef.allowEmpty !== true &&
|
|
1325
|
+
rest.run.headTurn?.state === rest.state &&
|
|
1326
|
+
rest.run.headTurn.actor === rest.actor &&
|
|
1327
|
+
!rest.run.headTurn.step &&
|
|
1328
|
+
rest.run.headTurn.empty)
|
|
1329
|
+
|
|
1330
|
+
/** A `run(…, { onFail: "rest" })` step whose last turn recorded a failure, on a clean tree. */
|
|
1331
|
+
const failedRunStalled = (rest: Rest): boolean =>
|
|
1268
1332
|
rest.changes.length === 0 &&
|
|
1269
|
-
rest.stepDef.
|
|
1270
|
-
rest.stepDef.allowEmpty !== true &&
|
|
1333
|
+
rest.stepDef.onFail === "rest" &&
|
|
1271
1334
|
rest.run.headTurn?.state === rest.state &&
|
|
1272
|
-
rest.run.headTurn.actor === rest.actor &&
|
|
1273
1335
|
!rest.run.headTurn.step &&
|
|
1274
|
-
|
|
1336
|
+
// Read from the (clean) tree, not the commit's touched paths: a retry that
|
|
1337
|
+
// fails with identical output touches nothing yet must still stall.
|
|
1338
|
+
rest.setup.workspace.readSync(RUN_FAILED_PATH) !== undefined
|
|
1275
1339
|
|
|
1276
1340
|
/** 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
1341
|
export const noProcessUnderway = (rest: Rest): boolean =>
|
|
@@ -1322,7 +1386,7 @@ const cleanLanding = (rest: Rest): Landing | undefined => {
|
|
|
1322
1386
|
return undefined
|
|
1323
1387
|
}
|
|
1324
1388
|
|
|
1325
|
-
/** At the human review gate the pending tree lands with every tick cleared
|
|
1389
|
+
/** At the human review gate the pending tree lands with every tick cleared; an agent-bound landing keeps them only if the unrewritten tree replays to the same step. */
|
|
1326
1390
|
const reviewGateRewrite = (def: StepDef): Rewrite | undefined => {
|
|
1327
1391
|
const reviewFormat = steeringFormatFor("review")
|
|
1328
1392
|
if (!isHumanReviewGate(def) || def.file === undefined || reviewFormat === undefined) {
|
|
@@ -1331,6 +1395,16 @@ const reviewGateRewrite = (def: StepDef): Rewrite | undefined => {
|
|
|
1331
1395
|
return { path: def.file, apply: (content) => reviewFormat.clearTicks(content) }
|
|
1332
1396
|
}
|
|
1333
1397
|
|
|
1398
|
+
/** Whether the pending review file differs from HEAD's once both have their ticks cleared. */
|
|
1399
|
+
const reviewEditedBeyondTicks = (rest: Rest): boolean => {
|
|
1400
|
+
const rewrite = reviewGateRewrite(rest.stepDef)
|
|
1401
|
+
if (rewrite === undefined) return false
|
|
1402
|
+
const pending = rest.setup.workspace.readSync(rewrite.path)
|
|
1403
|
+
const committed = rest.setup.workspace.readCommittedSync(rewrite.path)
|
|
1404
|
+
if (pending === undefined || committed === undefined) return true
|
|
1405
|
+
return rewrite.apply(pending) !== rewrite.apply(committed)
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1334
1408
|
const multilineRefusal = (vars: Record<string, string>): Landing | undefined => {
|
|
1335
1409
|
const multiline = multilineSetting(vars)
|
|
1336
1410
|
return multiline === undefined
|
|
@@ -1487,7 +1561,9 @@ const decideLanding = (
|
|
|
1487
1561
|
verdicts: readonly JudgeVerdict[] | undefined,
|
|
1488
1562
|
branch: Option.Option<string>,
|
|
1489
1563
|
approver: Option.Option<AuthorIdent>,
|
|
1564
|
+
shipped?: string,
|
|
1490
1565
|
): Effect.Effect<Landing, Error> =>
|
|
1566
|
+
// fallow-ignore-next-line complexity
|
|
1491
1567
|
Effect.gen(function* () {
|
|
1492
1568
|
if (rest.pin !== undefined) return pinLanding(rest, rest.pin, branch)
|
|
1493
1569
|
const unapproved = approvalRefusal(rest, approver)
|
|
@@ -1498,46 +1574,317 @@ const decideLanding = (
|
|
|
1498
1574
|
const message = accessRefusal(rest.changes, rest.stepDef.access.write)
|
|
1499
1575
|
if (message !== undefined) return { kind: "refusal", message }
|
|
1500
1576
|
}
|
|
1577
|
+
const landing = pendingTree(
|
|
1578
|
+
rest.setup.workspace,
|
|
1579
|
+
rest.setup.pendingWorktree(),
|
|
1580
|
+
reviewGateRewrite(rest.stepDef),
|
|
1581
|
+
)
|
|
1582
|
+
// A recorded failure lands as an attempt: the process stays at the step.
|
|
1583
|
+
if (rest.stepDef.onFail === "rest" && landing.read(RUN_FAILED_PATH) !== undefined) {
|
|
1584
|
+
return { kind: "attempt", subject: formatSubject(rest.actor, rest.state) }
|
|
1585
|
+
}
|
|
1501
1586
|
const outcome = yield* Effect.promise(() =>
|
|
1502
1587
|
replayFor(rest.setup, {
|
|
1503
|
-
tree:
|
|
1504
|
-
rest.setup.workspace,
|
|
1505
|
-
rest.setup.pendingWorktree(),
|
|
1506
|
-
reviewGateRewrite(rest.stepDef),
|
|
1507
|
-
),
|
|
1588
|
+
tree: landing,
|
|
1508
1589
|
...(verdicts !== undefined ? { verdicts } : {}),
|
|
1590
|
+
...(shipped !== undefined ? { shipped } : {}),
|
|
1509
1591
|
}),
|
|
1510
1592
|
)
|
|
1511
1593
|
const to = yield* landingTarget(outcome, rest)
|
|
1512
1594
|
if (typeof to !== "string") return to
|
|
1513
|
-
|
|
1595
|
+
const landed = landingTo(rest, to, branch, approver, outcomeMarks(outcome))
|
|
1596
|
+
// Ticks survive an agent-bound landing only when the review file changed beyond ticks
|
|
1597
|
+
// (a tick-only sign-off lands clean) and the unrewritten tree replays to the same step.
|
|
1598
|
+
if (
|
|
1599
|
+
landed.kind !== "commit" ||
|
|
1600
|
+
!isHumanReviewGate(rest.stepDef) ||
|
|
1601
|
+
outcome.kind !== "rest" ||
|
|
1602
|
+
outcome.rest.request.kind !== "agent" ||
|
|
1603
|
+
!reviewEditedBeyondTicks(rest)
|
|
1604
|
+
) {
|
|
1605
|
+
return landed
|
|
1606
|
+
}
|
|
1607
|
+
const ticked = yield* Effect.promise(() =>
|
|
1608
|
+
replayFor(rest.setup, {
|
|
1609
|
+
tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), undefined),
|
|
1610
|
+
...(verdicts !== undefined ? { verdicts } : {}),
|
|
1611
|
+
...(shipped !== undefined ? { shipped } : {}),
|
|
1612
|
+
}),
|
|
1613
|
+
)
|
|
1614
|
+
return ticked.kind === "rest" && ticked.rest.name === outcome.rest.name
|
|
1615
|
+
? { ...landed, keepTicks: true }
|
|
1616
|
+
: landed
|
|
1617
|
+
})
|
|
1618
|
+
|
|
1619
|
+
/** The user's branch: `<b>` for a process on `gtd/<b>` while `<b>` still exists; any other branch as is. */
|
|
1620
|
+
const userBranch = (git: GitOperations): Effect.Effect<Option.Option<string>, Error> =>
|
|
1621
|
+
Effect.gen(function* () {
|
|
1622
|
+
const current = yield* git.currentBranch()
|
|
1623
|
+
const name = Option.isSome(current) ? userBranchOf(current.value) : undefined
|
|
1624
|
+
if (name === undefined) return current
|
|
1625
|
+
const there = yield* git.readRefOption(`refs/heads/${name}`)
|
|
1626
|
+
return Option.isSome(there) ? Option.some(name) : current
|
|
1627
|
+
})
|
|
1628
|
+
|
|
1629
|
+
/** `<b>` for a process on `gtd/<b>` while `<b>` exists: where a finishing landing leaves HEAD. */
|
|
1630
|
+
const returnBranch = Effect.gen(function* () {
|
|
1631
|
+
const git = yield* GitService
|
|
1632
|
+
const current = yield* git.currentBranch()
|
|
1633
|
+
const name = Option.isSome(current) ? userBranchOf(current.value) : undefined
|
|
1634
|
+
if (name === undefined) return undefined
|
|
1635
|
+
return Option.isSome(yield* git.readRefOption(`refs/heads/${name}`)) ? name : undefined
|
|
1636
|
+
})
|
|
1637
|
+
|
|
1638
|
+
/** A starting landing moves onto `gtd/<b>` first. */
|
|
1639
|
+
const withBranchAndPush = (landing: Landing) =>
|
|
1640
|
+
Effect.gen(function* () {
|
|
1641
|
+
if (landing.kind !== "commit") return landing
|
|
1642
|
+
let out = landing
|
|
1643
|
+
if (landing.spec.vars !== undefined) {
|
|
1644
|
+
const start = yield* startBranchFor("gtd land")
|
|
1645
|
+
if (start.kind === "refusal") return { kind: "refusal", message: start.message } as Landing
|
|
1646
|
+
if (start.kind === "switch") out = { ...out, startBranch: start.branch }
|
|
1647
|
+
}
|
|
1648
|
+
return yield* withHistory(out)
|
|
1649
|
+
})
|
|
1650
|
+
|
|
1651
|
+
/** A closing landing switches back to `<b>`; it and a shipping one push their history ref. */
|
|
1652
|
+
const withHistory = (landing: Landing & { kind: "commit" }) =>
|
|
1653
|
+
Effect.gen(function* () {
|
|
1654
|
+
let out = landing
|
|
1655
|
+
const back = landing.keepHistory === undefined ? undefined : yield* returnBranch
|
|
1656
|
+
if (back !== undefined) out = { ...out, leaveBranch: back }
|
|
1657
|
+
if (
|
|
1658
|
+
landing.keepHistory === undefined &&
|
|
1659
|
+
landing.ship === undefined &&
|
|
1660
|
+
landing.recordedHistory === undefined
|
|
1661
|
+
) {
|
|
1662
|
+
return out
|
|
1663
|
+
}
|
|
1664
|
+
const remote = (yield* (yield* ConfigService).load).historyRemote
|
|
1665
|
+
return remote === undefined ? out : { ...out, pushHistory: remote }
|
|
1514
1666
|
})
|
|
1515
1667
|
|
|
1516
1668
|
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
1517
1669
|
export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
|
|
1518
|
-
decideLanding(
|
|
1670
|
+
decideLanding(
|
|
1671
|
+
rest,
|
|
1672
|
+
undefined,
|
|
1673
|
+
Option.none(),
|
|
1674
|
+
Option.none(),
|
|
1675
|
+
// Assumes the ship ships: deciding that needs git, and a preview reads none.
|
|
1676
|
+
rest.step.request.kind === "ship" ? SHIPPED_STAND_IN : undefined,
|
|
1677
|
+
).pipe(
|
|
1519
1678
|
Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
|
|
1520
1679
|
Effect.catch(() => Effect.succeed(undefined)),
|
|
1521
1680
|
)
|
|
1522
1681
|
|
|
1682
|
+
const snapshotOf = (rest: Rest, landing: Landing): RepoSnapshot => ({
|
|
1683
|
+
stepDef: rest.stepDef,
|
|
1684
|
+
state: rest.state,
|
|
1685
|
+
actor: rest.actor,
|
|
1686
|
+
changes: rest.changes,
|
|
1687
|
+
file: rest.hints.file,
|
|
1688
|
+
landing,
|
|
1689
|
+
})
|
|
1690
|
+
|
|
1691
|
+
const refusedSnapshot = (rest: Rest, message: string): RepoSnapshot =>
|
|
1692
|
+
snapshotOf(rest, { kind: "refusal", message })
|
|
1693
|
+
|
|
1694
|
+
// The squash's hash exists only once the landing script ran; the flow deciding
|
|
1695
|
+
// the landing's target sees this instead, and every later replay the real one.
|
|
1696
|
+
const SHIPPED_STAND_IN = "0".repeat(40)
|
|
1697
|
+
|
|
1698
|
+
const SHIP_EXCLUDED = ".gtd/"
|
|
1699
|
+
|
|
1700
|
+
type ShipDecision =
|
|
1701
|
+
| { readonly kind: "ship"; readonly branch: string; readonly parent: string }
|
|
1702
|
+
| { readonly kind: "recorded"; readonly hash: string }
|
|
1703
|
+
| { readonly kind: "skip"; readonly note: string }
|
|
1704
|
+
| { readonly kind: "blocked"; readonly message: string }
|
|
1705
|
+
|
|
1706
|
+
/** `tree` with file modes read from `ref` (the working tree when `undefined`), for the ship decision only: reading them costs a git call. */
|
|
1707
|
+
const withModes = (workspace: WorkspaceOps, tree: TreeView, ref: string | undefined): TreeView => {
|
|
1708
|
+
let modes: ReadonlyMap<string, string> | undefined
|
|
1709
|
+
return { ...tree, mode: (path) => (modes ??= workspace.modeSync(ref)).get(path) }
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
const shippedPaths = (before: TreeView, after: TreeView): readonly string[] => {
|
|
1713
|
+
const diff = diffTrees(before, after)
|
|
1714
|
+
return [...diff.added, ...diff.modified, ...diff.deleted].filter(
|
|
1715
|
+
(path) => !path.startsWith(SHIP_EXCLUDED),
|
|
1716
|
+
)
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
const noTarget = (why: string): ShipDecision => ({
|
|
1720
|
+
kind: "skip",
|
|
1721
|
+
note: `gtd: no branch to ship to: ${why}`,
|
|
1722
|
+
})
|
|
1723
|
+
|
|
1724
|
+
/** `<b>` and its tip, for a process on `gtd/<b>`; why there is none otherwise. */
|
|
1725
|
+
const shipTarget = (git: GitOperations) =>
|
|
1726
|
+
Effect.gen(function* () {
|
|
1727
|
+
const current = yield* git.currentBranch()
|
|
1728
|
+
if (Option.isNone(current)) return noTarget("HEAD is detached")
|
|
1729
|
+
const branch = userBranchOf(current.value)
|
|
1730
|
+
if (branch === undefined) return noTarget(`${current.value} is no gtd/ branch`)
|
|
1731
|
+
if (userBranchOf(branch) !== undefined) {
|
|
1732
|
+
return noTarget(`${current.value} records no user branch`)
|
|
1733
|
+
}
|
|
1734
|
+
const parent = yield* git.readRefOption(`refs/heads/${branch}`)
|
|
1735
|
+
if (Option.isNone(parent)) return noTarget(`${branch} does not exist`)
|
|
1736
|
+
return { kind: "ship", branch, parent: parent.value } as const
|
|
1737
|
+
})
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* The paths `<b>` changed outside this process that shipping `tree` would
|
|
1741
|
+
* revert. A path the working tree changed since HEAD counts as brought in:
|
|
1742
|
+
* whether it carries `<b>`'s change is the person's call, not a merge gtd redoes.
|
|
1743
|
+
*/
|
|
1744
|
+
const revertedPaths = (
|
|
1745
|
+
git: GitOperations,
|
|
1746
|
+
workspace: WorkspaceOps,
|
|
1747
|
+
parent: string,
|
|
1748
|
+
tree: TreeView,
|
|
1749
|
+
changed: ReadonlySet<string>,
|
|
1750
|
+
treeRef: string | undefined,
|
|
1751
|
+
) =>
|
|
1752
|
+
Effect.gen(function* () {
|
|
1753
|
+
const head = yield* git.resolveRef("HEAD")
|
|
1754
|
+
const shipped = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip
|
|
1755
|
+
if (shipped !== undefined && (yield* git.isAncestor(shipped, head))) return []
|
|
1756
|
+
const fork = yield* git.mergeBase(parent, head)
|
|
1757
|
+
const foreign = Option.isNone(fork)
|
|
1758
|
+
? [...changed]
|
|
1759
|
+
: shippedPaths(
|
|
1760
|
+
withModes(workspace, commitTree(workspace, fork.value), fork.value),
|
|
1761
|
+
withModes(workspace, commitTree(workspace, parent), parent),
|
|
1762
|
+
)
|
|
1763
|
+
const committed = withModes(workspace, commitTree(workspace, head), head)
|
|
1764
|
+
const moded = withModes(workspace, tree, treeRef)
|
|
1765
|
+
return foreign.filter(
|
|
1766
|
+
(path) =>
|
|
1767
|
+
changed.has(path) &&
|
|
1768
|
+
moded.read(path) === committed.read(path) &&
|
|
1769
|
+
moded.mode!(path) === committed.mode!(path),
|
|
1770
|
+
)
|
|
1771
|
+
})
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Whether landing a ship step with `tree` would ship, and onto what. The squash
|
|
1775
|
+
* replaces the user's branch's tree with `tree`, so it is blocked while that
|
|
1776
|
+
* branch carries a change `tree` would revert: one made since the last ship
|
|
1777
|
+
* (whose `Gtd-Tip` HEAD descends from), or since it forked from HEAD.
|
|
1778
|
+
*/
|
|
1779
|
+
const decideShip = (
|
|
1780
|
+
workspace: WorkspaceOps,
|
|
1781
|
+
tree: TreeView,
|
|
1782
|
+
/** The commit `tree` is of; `undefined` for the working tree. */
|
|
1783
|
+
treeRef: string | undefined,
|
|
1784
|
+
): Effect.Effect<ShipDecision, Error, GitService> =>
|
|
1785
|
+
Effect.gen(function* () {
|
|
1786
|
+
const git = yield* GitService
|
|
1787
|
+
const target = yield* shipTarget(git)
|
|
1788
|
+
if (target.kind !== "ship") return target
|
|
1789
|
+
const { branch, parent } = target
|
|
1790
|
+
const changed = new Set(
|
|
1791
|
+
shippedPaths(
|
|
1792
|
+
withModes(workspace, commitTree(workspace, parent), parent),
|
|
1793
|
+
withModes(workspace, tree, treeRef),
|
|
1794
|
+
),
|
|
1795
|
+
)
|
|
1796
|
+
// A landing whose hook rejected after the ship moved `<b>`: its tip is a ship of this very HEAD.
|
|
1797
|
+
// An edit since (say, to satisfy that hook) ships again rather than reusing the stale ship.
|
|
1798
|
+
const pending = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip
|
|
1799
|
+
if (
|
|
1800
|
+
changed.size === 0 &&
|
|
1801
|
+
pending !== undefined &&
|
|
1802
|
+
pending === (yield* git.resolveRef("HEAD"))
|
|
1803
|
+
) {
|
|
1804
|
+
return { kind: "recorded", hash: parent }
|
|
1805
|
+
}
|
|
1806
|
+
const reverted = yield* revertedPaths(git, workspace, parent, tree, changed, treeRef)
|
|
1807
|
+
if (reverted.length > 0) {
|
|
1808
|
+
return {
|
|
1809
|
+
kind: "blocked",
|
|
1810
|
+
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`,
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
return changed.size === 0
|
|
1814
|
+
? { kind: "skip", note: `gtd: nothing to ship: ${branch} already has this tree` }
|
|
1815
|
+
: target
|
|
1816
|
+
})
|
|
1817
|
+
|
|
1818
|
+
const shipBlockedAt = (
|
|
1819
|
+
setup: ReplaySetup,
|
|
1820
|
+
pin: PinChange | undefined,
|
|
1821
|
+
step: ReachedStep,
|
|
1822
|
+
changes: readonly PendingChange[],
|
|
1823
|
+
) =>
|
|
1824
|
+
Effect.gen(function* () {
|
|
1825
|
+
if (pin !== undefined || step.request.kind !== "ship" || changes.length > 0) return undefined
|
|
1826
|
+
const head = yield* (yield* GitService).resolveRef("HEAD")
|
|
1827
|
+
const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head), head)
|
|
1828
|
+
return decision.kind === "blocked" ? decision.message : undefined
|
|
1829
|
+
})
|
|
1830
|
+
|
|
1831
|
+
const shipSnapshot = (
|
|
1832
|
+
rest: Rest,
|
|
1833
|
+
message: string,
|
|
1834
|
+
verdicts: readonly JudgeVerdict[] | undefined,
|
|
1835
|
+
branch: Option.Option<string>,
|
|
1836
|
+
approver: Option.Option<AuthorIdent>,
|
|
1837
|
+
) =>
|
|
1838
|
+
Effect.gen(function* () {
|
|
1839
|
+
const tree = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), undefined)
|
|
1840
|
+
const decision = yield* decideShip(rest.setup.workspace, tree, undefined)
|
|
1841
|
+
if (decision.kind === "blocked") return refusedSnapshot(rest, decision.message)
|
|
1842
|
+
const shipping = decision.kind === "ship"
|
|
1843
|
+
const decided = yield* decideLanding(
|
|
1844
|
+
rest,
|
|
1845
|
+
verdicts,
|
|
1846
|
+
branch,
|
|
1847
|
+
approver,
|
|
1848
|
+
decision.kind === "recorded" ? decision.hash : shipping ? SHIPPED_STAND_IN : undefined,
|
|
1849
|
+
)
|
|
1850
|
+
if (decided.kind !== "commit") return snapshotOf(rest, decided)
|
|
1851
|
+
if (decision.kind === "recorded") {
|
|
1852
|
+
const recorded = {
|
|
1853
|
+
...decided,
|
|
1854
|
+
spec: { ...decided.spec, shipped: decision.hash },
|
|
1855
|
+
recordedHistory: processHistoryRef(rest.run.startHash),
|
|
1856
|
+
}
|
|
1857
|
+
return snapshotOf(rest, yield* withBranchAndPush(recorded))
|
|
1858
|
+
}
|
|
1859
|
+
const landing: Landing = shipping
|
|
1860
|
+
? {
|
|
1861
|
+
...decided,
|
|
1862
|
+
ship: {
|
|
1863
|
+
branch: decision.branch,
|
|
1864
|
+
parent: decision.parent,
|
|
1865
|
+
shipMessage: message,
|
|
1866
|
+
historyRef: processHistoryRef(rest.run.startHash),
|
|
1867
|
+
},
|
|
1868
|
+
}
|
|
1869
|
+
: { ...decided, note: decision.note }
|
|
1870
|
+
return snapshotOf(rest, yield* withBranchAndPush(landing))
|
|
1871
|
+
})
|
|
1872
|
+
|
|
1523
1873
|
/** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
|
|
1524
1874
|
export const snapshotFromRest = (
|
|
1525
1875
|
rest: Rest,
|
|
1526
1876
|
verdicts?: readonly JudgeVerdict[],
|
|
1527
|
-
): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
|
|
1877
|
+
): Effect.Effect<RepoSnapshot, Error, Workspace | GitService | RestRequirements> =>
|
|
1528
1878
|
Effect.gen(function* () {
|
|
1529
1879
|
const git = yield* GitService
|
|
1530
|
-
const branch = yield* git
|
|
1880
|
+
const branch = yield* userBranch(git)
|
|
1531
1881
|
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,
|
|
1882
|
+
const request = rest.step.request
|
|
1883
|
+
if (rest.pin === undefined && request.kind === "ship") {
|
|
1884
|
+
return yield* shipSnapshot(rest, request.message, verdicts, branch, approver)
|
|
1540
1885
|
}
|
|
1886
|
+
const decided = yield* decideLanding(rest, verdicts, branch, approver)
|
|
1887
|
+
return snapshotOf(rest, yield* withBranchAndPush(decided))
|
|
1541
1888
|
})
|
|
1542
1889
|
|
|
1543
1890
|
// ── Summary ─────────────────────────────────────────────────────────────────
|