@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
package/src/flows/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
sections,
|
|
29
29
|
settings,
|
|
30
30
|
settingsOf,
|
|
31
|
+
ship,
|
|
31
32
|
skillsFor,
|
|
32
33
|
start,
|
|
33
34
|
threads,
|
|
@@ -58,6 +59,7 @@ export {
|
|
|
58
59
|
type ScopeOptions,
|
|
59
60
|
type Section,
|
|
60
61
|
type Settings,
|
|
62
|
+
type ShipOptions,
|
|
61
63
|
type SkillsExport,
|
|
62
64
|
type Steering,
|
|
63
65
|
type SteeringOptions,
|
package/src/flows/runtime.ts
CHANGED
|
@@ -89,7 +89,20 @@ export type HumanOptions = HumanCommon &
|
|
|
89
89
|
})
|
|
90
90
|
)
|
|
91
91
|
|
|
92
|
-
export type RunOptions = SteeringOptions
|
|
92
|
+
export type RunOptions = SteeringOptions & {
|
|
93
|
+
/**
|
|
94
|
+
* `"rest"`: a non-zero exit is recorded in the tree and the process rests at
|
|
95
|
+
* the step as `stalled`; any tree change runs the script again, and only a
|
|
96
|
+
* zero exit advances. Without it a failing body lands and the flow carries on.
|
|
97
|
+
*/
|
|
98
|
+
readonly onFail?: "rest" | undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A ship step rests on no steering file. */
|
|
102
|
+
export type ShipOptions = SteeringCommon & {
|
|
103
|
+
readonly file?: undefined
|
|
104
|
+
readonly mode?: undefined
|
|
105
|
+
}
|
|
93
106
|
|
|
94
107
|
export type JudgePrimitive = "noul" | "choice" | "score"
|
|
95
108
|
|
|
@@ -204,6 +217,12 @@ export type StepRequest =
|
|
|
204
217
|
readonly body: string
|
|
205
218
|
readonly options: RunOptions
|
|
206
219
|
}
|
|
220
|
+
| {
|
|
221
|
+
readonly kind: "ship"
|
|
222
|
+
readonly name: string
|
|
223
|
+
readonly message: string
|
|
224
|
+
readonly options: ShipOptions
|
|
225
|
+
}
|
|
207
226
|
| {
|
|
208
227
|
readonly kind: "judge"
|
|
209
228
|
readonly name: string
|
|
@@ -344,6 +363,18 @@ export const human: Human = (
|
|
|
344
363
|
export const run = (name: string, body: string, options: RunOptions = {}): Promise<void> =>
|
|
345
364
|
ctx().step({ kind: "run", name, body, options }) as Promise<void>
|
|
346
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Squashes HEAD's tree, with `.gtd/` as the user's branch has it, into one commit on the user's branch
|
|
368
|
+
* with `message`, and moves that branch there; HEAD stays on `gtd/<b>`.
|
|
369
|
+
* Resolves to the new commit's hash, or `undefined` when nothing shipped.
|
|
370
|
+
*/
|
|
371
|
+
export const ship = (
|
|
372
|
+
name: string,
|
|
373
|
+
message: string,
|
|
374
|
+
options: ShipOptions = {},
|
|
375
|
+
): Promise<string | undefined> =>
|
|
376
|
+
ctx().step({ kind: "ship", name, message, options }) as Promise<string | undefined>
|
|
377
|
+
|
|
347
378
|
/** A judge gate: resolves to what the judge answered, for the flow to decide on. */
|
|
348
379
|
export const judge = <const Q extends readonly JudgeQuestionInput[] = readonly JudgeQuestion[]>(
|
|
349
380
|
name: string,
|
|
@@ -4,6 +4,9 @@ import type { GitOperations } from "./Git.js"
|
|
|
4
4
|
/** Per-worktree (`refs/worktree/gtd/*`) so linked worktrees sharing one `.git` don't clobber each other's retained history. */
|
|
5
5
|
export const HISTORY_REF = "refs/worktree/gtd/history"
|
|
6
6
|
|
|
7
|
+
/** Set by an abandon that left `gtd/<b>` for `<b>`: only then does restore recreate `gtd/<b>`. */
|
|
8
|
+
export const RESUME_BRANCH_REF = "refs/worktree/gtd/history-branch"
|
|
9
|
+
|
|
7
10
|
/** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
|
|
8
11
|
export const PROCESS_HISTORY_PREFIX = "refs/gtd/history/"
|
|
9
12
|
|
package/src/platform/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
refuse,
|
|
16
16
|
restart,
|
|
17
17
|
run,
|
|
18
|
+
ship,
|
|
18
19
|
scope,
|
|
19
20
|
threads,
|
|
20
21
|
type Flow,
|
|
@@ -647,6 +648,14 @@ describe("replay", () => {
|
|
|
647
648
|
expect(outcome.kind === "failed" && outcome.message).toContain("shell script string")
|
|
648
649
|
})
|
|
649
650
|
|
|
651
|
+
it("fails a ship step with an empty message", async () => {
|
|
652
|
+
const wf = async () => {
|
|
653
|
+
await ship("shipping", " ")
|
|
654
|
+
}
|
|
655
|
+
const outcome = await replayOf(wf, new History())
|
|
656
|
+
expect(outcome.kind === "failed" && outcome.message).toContain("non-empty commit message")
|
|
657
|
+
})
|
|
658
|
+
|
|
650
659
|
it("fails a flow that awaits something other than a step", async () => {
|
|
651
660
|
const waiting = async () => {
|
|
652
661
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
package/src/replay/Replay.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
threads,
|
|
21
21
|
} from "../steering/index.js"
|
|
22
22
|
import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
|
|
23
|
-
import { globMatches, PIN_PATH } from "../wire/index.js"
|
|
23
|
+
import { globMatches, PIN_PATH, RUN_FAILED_PATH } from "../wire/index.js"
|
|
24
24
|
import { refNameFault } from "./RefName.js"
|
|
25
25
|
import { diffTrees, isEmptyDiff, withoutPath, type TreeView } from "./Tree.js"
|
|
26
26
|
import {
|
|
@@ -60,6 +60,8 @@ export interface Episode {
|
|
|
60
60
|
export interface PendingTurn {
|
|
61
61
|
readonly tree: TreeView
|
|
62
62
|
readonly verdicts?: readonly JudgeVerdict[]
|
|
63
|
+
/** What a pending ship step resolves to: a stand-in hash when it will ship, the real one exists only once it landed. */
|
|
64
|
+
readonly shipped?: string
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
export interface ReplayInput {
|
|
@@ -90,7 +92,13 @@ export type StepKind = Exclude<StepRequest["kind"], "restart">
|
|
|
90
92
|
type Actor = "agent" | "human" | "check" | "judge"
|
|
91
93
|
|
|
92
94
|
const actorOfKind = (kind: StepKind): Actor =>
|
|
93
|
-
kind === "run"
|
|
95
|
+
kind === "run" || kind === "ship"
|
|
96
|
+
? "check"
|
|
97
|
+
: kind === "agent"
|
|
98
|
+
? "agent"
|
|
99
|
+
: kind === "human"
|
|
100
|
+
? "human"
|
|
101
|
+
: "judge"
|
|
94
102
|
|
|
95
103
|
/** One step replay reached, completed or not. */
|
|
96
104
|
export interface ReachedStep {
|
|
@@ -316,10 +324,21 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
|
|
|
316
324
|
const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
|
|
317
325
|
agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
|
|
318
326
|
human: new Set([...STEERING_OPTIONS, "message", "acceptClean", "approve"]),
|
|
319
|
-
run: new Set(STEERING_OPTIONS),
|
|
327
|
+
run: new Set([...STEERING_OPTIONS, "onFail"]),
|
|
328
|
+
ship: new Set(["label", "base"]),
|
|
320
329
|
judge: new Set([...STEERING_OPTIONS, "message"]),
|
|
321
330
|
}
|
|
322
331
|
|
|
332
|
+
const restsOnFail = (step: ReachedStep): boolean =>
|
|
333
|
+
step.request.kind === "run" && step.request.options.onFail === "rest"
|
|
334
|
+
|
|
335
|
+
const onFailFault = (step: ReachedStep): string | undefined =>
|
|
336
|
+
step.request.kind === "run" &&
|
|
337
|
+
step.request.options.onFail !== undefined &&
|
|
338
|
+
step.request.options.onFail !== "rest"
|
|
339
|
+
? `gtd: step "${step.name}": onFail must be "rest" in run() options`
|
|
340
|
+
: undefined
|
|
341
|
+
|
|
323
342
|
const unknownOptions = (step: ReachedStep): string | undefined => {
|
|
324
343
|
const unknown = Object.keys(step.request.options).filter(
|
|
325
344
|
(key) => !KNOWN_OPTIONS[step.kind].has(key),
|
|
@@ -454,12 +473,13 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
454
473
|
const scoped = (name: string): string =>
|
|
455
474
|
[...scopes.flatMap((s) => (s.name === undefined ? [] : [s.name])), name].join(".")
|
|
456
475
|
|
|
457
|
-
const isAttemptAt = (commit: ParsedCommit,
|
|
476
|
+
const isAttemptAt = (commit: ParsedCommit, step: ReachedStep): boolean =>
|
|
458
477
|
commit.parsed.step === undefined &&
|
|
459
478
|
commit.parsed.parsed !== undefined &&
|
|
460
479
|
commit.parsed.parsed.from === undefined &&
|
|
461
|
-
commit.parsed.parsed.to === name &&
|
|
462
|
-
isEmptyDiff(diffTrees(position.tree, commit.tree))
|
|
480
|
+
commit.parsed.parsed.to === step.name &&
|
|
481
|
+
(isEmptyDiff(diffTrees(position.tree, commit.tree)) ||
|
|
482
|
+
(restsOnFail(step) && commit.tree.read(RUN_FAILED_PATH) !== undefined))
|
|
463
483
|
|
|
464
484
|
const advance = (next: Position): void => {
|
|
465
485
|
previousPosition = position
|
|
@@ -469,13 +489,16 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
469
489
|
const replyFor = (
|
|
470
490
|
request: Exclude<StepRequest, { kind: "restart" }>,
|
|
471
491
|
verdicts: readonly JudgeVerdict[],
|
|
492
|
+
shipped: string | undefined,
|
|
472
493
|
): unknown =>
|
|
473
494
|
request.kind === "judge"
|
|
474
495
|
? {
|
|
475
496
|
answers: answersFrom(verdicts, request.questions),
|
|
476
497
|
truncated: judgeCuts.get(request) ?? [],
|
|
477
498
|
}
|
|
478
|
-
:
|
|
499
|
+
: request.kind === "ship"
|
|
500
|
+
? shipped
|
|
501
|
+
: undefined
|
|
479
502
|
|
|
480
503
|
const judgeCuts = new WeakMap<object, readonly string[]>()
|
|
481
504
|
|
|
@@ -636,7 +659,11 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
636
659
|
)
|
|
637
660
|
}
|
|
638
661
|
const error =
|
|
639
|
-
unknownOptions(step) ??
|
|
662
|
+
unknownOptions(step) ??
|
|
663
|
+
onFailFault(step) ??
|
|
664
|
+
approveConflict(step) ??
|
|
665
|
+
modeWithoutFile(step) ??
|
|
666
|
+
checkIdentity(step)
|
|
640
667
|
return error === undefined ? undefined : { kind: "failed", message: error }
|
|
641
668
|
}
|
|
642
669
|
|
|
@@ -670,7 +697,7 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
670
697
|
expectNextOf(commit)
|
|
671
698
|
skipPins()
|
|
672
699
|
noteKept(step, commit)
|
|
673
|
-
return replyFor(step.request, commit.parsed.judge)
|
|
700
|
+
return replyFor(step.request, commit.parsed.judge, commit.parsed.shipped)
|
|
674
701
|
}
|
|
675
702
|
|
|
676
703
|
// A turn that ends a pinned process names the unpin, which is no step.
|
|
@@ -699,11 +726,15 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
699
726
|
keptTips.set(step.name, { hash: "", before: position.tree, after: pending.tree })
|
|
700
727
|
}
|
|
701
728
|
advance({ hash: "", tree: keeping ? position.tree : pending.tree })
|
|
702
|
-
return replyFor(step.request, pending.verdicts ?? [])
|
|
729
|
+
return replyFor(step.request, pending.verdicts ?? [], pending.shipped)
|
|
703
730
|
}
|
|
704
731
|
|
|
705
732
|
const answer = (step: ReachedStep): unknown => {
|
|
706
|
-
while (cursor < commits.length && isAttemptAt(commits[cursor]!, step
|
|
733
|
+
while (cursor < commits.length && isAttemptAt(commits[cursor]!, step)) {
|
|
734
|
+
// An attempt that recorded a failure carries its tree; an empty one changes nothing.
|
|
735
|
+
if (!isEmptyDiff(diffTrees(position.tree, commits[cursor]!.tree))) {
|
|
736
|
+
advance({ hash: commits[cursor]!.hash, tree: commits[cursor]!.tree })
|
|
737
|
+
}
|
|
707
738
|
cursor++
|
|
708
739
|
skipPins()
|
|
709
740
|
}
|
|
@@ -718,18 +749,31 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
718
749
|
marks,
|
|
719
750
|
})
|
|
720
751
|
}
|
|
752
|
+
if (restsOnFail(step) && input.pending.tree.read(RUN_FAILED_PATH) !== undefined) {
|
|
753
|
+
return finish({ kind: "rest", rest: step, trace, landed, marks })
|
|
754
|
+
}
|
|
721
755
|
return landPending(step, input.pending)
|
|
722
756
|
}
|
|
723
757
|
|
|
758
|
+
const bodyFault = (request: Exclude<StepRequest, { kind: "restart" | "agent" }>) => {
|
|
759
|
+
if (
|
|
760
|
+
request.kind === "ship" &&
|
|
761
|
+
(typeof request.message !== "string" || request.message.trim() === "")
|
|
762
|
+
) {
|
|
763
|
+
return `gtd: step "${scoped(request.name)}": a ship() message is a non-empty commit message`
|
|
764
|
+
}
|
|
765
|
+
if (request.kind === "run" && typeof request.body !== "string") {
|
|
766
|
+
return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
|
|
767
|
+
}
|
|
768
|
+
return undefined
|
|
769
|
+
}
|
|
770
|
+
|
|
724
771
|
// gtd.config.ts is loaded without a type check.
|
|
725
772
|
const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
|
|
726
773
|
if (scoped(request.name).startsWith(ENGINE_PREFIX)) {
|
|
727
774
|
return `gtd: step "${scoped(request.name)}": the "${ENGINE_PREFIX}" prefix is reserved for gtd's own beats — rename the step`
|
|
728
775
|
}
|
|
729
|
-
if (request.kind
|
|
730
|
-
return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
|
|
731
|
-
}
|
|
732
|
-
if (request.kind !== "agent") return undefined
|
|
776
|
+
if (request.kind !== "agent") return bodyFault(request)
|
|
733
777
|
const name = scoped(request.name)
|
|
734
778
|
if (request.options.keep === true) {
|
|
735
779
|
// A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -118,6 +118,10 @@ export interface CommitMessage {
|
|
|
118
118
|
readonly pin: PinChange | undefined
|
|
119
119
|
/** `Gtd-Anchor`, on a repin's commit: replay sets aside every commit after this one; a non-hash value is skipped. */
|
|
120
120
|
readonly anchor: string | undefined
|
|
121
|
+
/** `Gtd-Shipped`, on a ship step's landing: the commit it put on the user's branch. */
|
|
122
|
+
readonly shipped: string | undefined
|
|
123
|
+
/** `Gtd-Tip`, on a shipped commit: the process commit whose tree it carries. */
|
|
124
|
+
readonly tip: string | undefined
|
|
121
125
|
readonly format: number
|
|
122
126
|
}
|
|
123
127
|
|
|
@@ -178,6 +182,8 @@ interface Trailers {
|
|
|
178
182
|
approved: Approval | undefined
|
|
179
183
|
pin: PinChange | undefined
|
|
180
184
|
anchor: string | undefined
|
|
185
|
+
shipped: string | undefined
|
|
186
|
+
tip: string | undefined
|
|
181
187
|
readonly judge: JudgeVerdict[]
|
|
182
188
|
readonly marks: Mark[]
|
|
183
189
|
readonly vars: Record<string, string>
|
|
@@ -228,6 +234,12 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
228
234
|
"Gtd-Anchor": (value, into) => {
|
|
229
235
|
if (HASH_RE.test(value)) into.anchor ??= value
|
|
230
236
|
},
|
|
237
|
+
"Gtd-Shipped": (value, into) => {
|
|
238
|
+
if (HASH_RE.test(value)) into.shipped ??= value
|
|
239
|
+
},
|
|
240
|
+
"Gtd-Tip": (value, into) => {
|
|
241
|
+
if (HASH_RE.test(value)) into.tip ??= value
|
|
242
|
+
},
|
|
231
243
|
"Gtd-Format": (value, into) => {
|
|
232
244
|
if (FORMAT_RE.test(value)) into.format ??= Number(value)
|
|
233
245
|
},
|
|
@@ -250,6 +262,8 @@ export const parseCommitMessage = (message: string): CommitMessage => {
|
|
|
250
262
|
approved: undefined,
|
|
251
263
|
pin: undefined,
|
|
252
264
|
anchor: undefined,
|
|
265
|
+
shipped: undefined,
|
|
266
|
+
tip: undefined,
|
|
253
267
|
judge: [],
|
|
254
268
|
marks: [],
|
|
255
269
|
vars: {},
|
package/src/replay/index.ts
CHANGED
package/src/step/LandStep.ts
CHANGED
|
@@ -12,6 +12,18 @@ export type GitWrite =
|
|
|
12
12
|
| { readonly kind: "commitClean"; readonly message: string }
|
|
13
13
|
| { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
|
|
14
14
|
| { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
|
|
15
|
+
| { readonly kind: "switchBranch"; readonly branch: string }
|
|
16
|
+
| { readonly kind: "switchTo"; readonly branch: string }
|
|
17
|
+
| { readonly kind: "pushRef"; readonly remote: string; readonly ref: string }
|
|
18
|
+
| ({ readonly kind: "ship"; readonly message: string } & ShipSpec)
|
|
19
|
+
|
|
20
|
+
/** A ship step's squash: `shipMessage` on `branch`, CAS against `parent`, its `Gtd-History` naming `historyRef`. */
|
|
21
|
+
export interface ShipSpec {
|
|
22
|
+
readonly branch: string
|
|
23
|
+
readonly parent: string
|
|
24
|
+
readonly shipMessage: string
|
|
25
|
+
readonly historyRef: string
|
|
26
|
+
}
|
|
15
27
|
|
|
16
28
|
/** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
|
|
17
29
|
export type Outcome =
|
package/src/step/RepoSnapshot.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CommitSpec } from "../replay/index.js"
|
|
2
2
|
import type { PendingChange, StateName, StepDef } from "../workflow/index.js"
|
|
3
|
+
import type { ShipSpec } from "./LandStep.js"
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* What landing the pending turn does, decided at the edge by replaying the
|
|
@@ -19,6 +20,16 @@ export type Landing =
|
|
|
19
20
|
readonly keepHistory?: string
|
|
20
21
|
/** The kept ref this landing's turn goes to; the branch gets a marker commit instead. */
|
|
21
22
|
readonly keepTurn?: string
|
|
23
|
+
/** The `gtd/<b>` branch a starting landing switches to before it commits. */
|
|
24
|
+
readonly startBranch?: string
|
|
25
|
+
/** The user's branch `<b>` a finishing landing switches back to, after its history ref. */
|
|
26
|
+
readonly leaveBranch?: string
|
|
27
|
+
/** A remote the closing or shipping landing force-pushes its history ref to, after the commit. */
|
|
28
|
+
readonly pushHistory?: string
|
|
29
|
+
/** A ship step's squash onto the user's branch, made ahead of the landing commit. */
|
|
30
|
+
readonly ship?: ShipSpec
|
|
31
|
+
/** Printed after the landing: why a ship step shipped nothing. */
|
|
32
|
+
readonly note?: string
|
|
22
33
|
/** The one path this landing commits; every other pending change stays pending. */
|
|
23
34
|
readonly only?: string
|
|
24
35
|
}
|
package/src/step/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { planStep, type JudgeVerdict } from "./planStep.js"
|
|
2
2
|
export { accessRefusal } from "./accessRefusal.js"
|
|
3
3
|
export { planStart } from "./planStart.js"
|
|
4
|
+
export { startBranchFor, GTD_BRANCH_PREFIX, userBranchOf } from "./startBranch.js"
|
|
4
5
|
export type { GitWrite, LandStep, Outcome } from "./LandStep.js"
|
|
5
6
|
export type { Landing, RepoSnapshot } from "./RepoSnapshot.js"
|
|
6
7
|
export { strayPaths } from "./strayPaths.js"
|
|
@@ -108,7 +108,7 @@ describe("planStart", () => {
|
|
|
108
108
|
repo.seedCommit("feat: ahead of main")
|
|
109
109
|
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
|
|
110
110
|
if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
|
|
111
|
-
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
111
|
+
const write = plan.steps.find((s) => s.kind === "gitWrite" && s.write.kind === "commitAll")
|
|
112
112
|
if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
|
|
113
113
|
throw new Error("expected a gitWrite step")
|
|
114
114
|
expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
|
|
@@ -125,7 +125,7 @@ describe("planStart", () => {
|
|
|
125
125
|
repo.seedCommit("feat: my branch")
|
|
126
126
|
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "other" } })
|
|
127
127
|
if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
|
|
128
|
-
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
128
|
+
const write = plan.steps.find((s) => s.kind === "gitWrite" && s.write.kind === "commitAll")
|
|
129
129
|
if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
|
|
130
130
|
throw new Error("expected a gitWrite step")
|
|
131
131
|
expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
|
|
@@ -172,6 +172,7 @@ describe("planStart", () => {
|
|
|
172
172
|
if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
|
|
173
173
|
expect(plan.subject).toBe("gtd(human): work")
|
|
174
174
|
expect(plan.steps).toEqual([
|
|
175
|
+
{ kind: "gitWrite", write: { kind: "switchBranch", branch: "gtd/main" } },
|
|
175
176
|
{
|
|
176
177
|
kind: "gitWrite",
|
|
177
178
|
write: {
|
|
@@ -191,7 +192,7 @@ describe("planStart", () => {
|
|
|
191
192
|
vars: { base: "main" },
|
|
192
193
|
})
|
|
193
194
|
if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
|
|
194
|
-
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
195
|
+
const write = plan.steps.find((s) => s.kind === "gitWrite" && s.write.kind === "commitAll")
|
|
195
196
|
if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
|
|
196
197
|
throw new Error("expected a gitWrite step")
|
|
197
198
|
expect(write.write.message).toBe(
|
package/src/step/planStart.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../workflow/index.js"
|
|
12
12
|
import { formatCommitMessage, formatSubject } from "../replay/index.js"
|
|
13
13
|
import type { LandStep } from "./LandStep.js"
|
|
14
|
+
import { startBranchFor } from "./startBranch.js"
|
|
14
15
|
|
|
15
16
|
/** Just enough of the current rest for `planStart`'s checks. */
|
|
16
17
|
export interface StartCurrent {
|
|
@@ -87,6 +88,7 @@ export const planStart = (
|
|
|
87
88
|
Error,
|
|
88
89
|
GitService | ConfigService | ConfigDiscovery | Workspace | Host | Narrator
|
|
89
90
|
> =>
|
|
91
|
+
// fallow-ignore-next-line complexity
|
|
90
92
|
Effect.gen(function* () {
|
|
91
93
|
const { workflow, first, commandLabel, vars: varOverrides } = start
|
|
92
94
|
const name = workflow.name
|
|
@@ -134,7 +136,12 @@ export const planStart = (
|
|
|
134
136
|
...(base !== undefined ? { reviewBase: base } : {}),
|
|
135
137
|
vars,
|
|
136
138
|
})
|
|
139
|
+
const onBranch = yield* startBranchFor(commandLabel)
|
|
140
|
+
if (onBranch.kind === "refusal") return refusal(onBranch.message)
|
|
137
141
|
const steps: readonly LandStep[] = [
|
|
142
|
+
...(onBranch.kind === "switch"
|
|
143
|
+
? [{ kind: "gitWrite", write: { kind: "switchBranch", branch: onBranch.branch } } as const]
|
|
144
|
+
: []),
|
|
138
145
|
{ kind: "gitWrite", write: { kind: "commitAll", message } },
|
|
139
146
|
{ kind: "outcome", outcome: { kind: "commit", subject } },
|
|
140
147
|
]
|
package/src/step/planStep.ts
CHANGED
|
@@ -36,12 +36,16 @@ const uncheckStep = (snapshot: RepoSnapshot): readonly LandStep[] =>
|
|
|
36
36
|
: []
|
|
37
37
|
|
|
38
38
|
const gitWriteOf = (landing: Extract<Landing, { kind: "commit" }>, message: string): GitWrite => {
|
|
39
|
+
if (landing.ship !== undefined) return { kind: "ship", message, ...landing.ship }
|
|
39
40
|
if (landing.only !== undefined) return { kind: "commitPath", path: landing.only, message }
|
|
40
41
|
if (landing.keepTurn !== undefined) return { kind: "keepTurn", ref: landing.keepTurn, message }
|
|
41
42
|
if (landing.spec.approved !== undefined) return { kind: "commitClean", message }
|
|
42
43
|
return { kind: "commitAll", message }
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const writeIf = (value: string | undefined, write: (value: string) => GitWrite): LandStep[] =>
|
|
47
|
+
value === undefined ? [] : [{ kind: "gitWrite", write: write(value) }]
|
|
48
|
+
|
|
45
49
|
export const planStep = (
|
|
46
50
|
snapshot: RepoSnapshot,
|
|
47
51
|
opts: {
|
|
@@ -78,6 +82,7 @@ export const planStep = (
|
|
|
78
82
|
...(opts.judge !== undefined && opts.judge.length > 0 ? { judge: opts.judge } : {}),
|
|
79
83
|
})
|
|
80
84
|
const subject = message.split("\n")[0]!
|
|
85
|
+
const historyRef = landing.keepHistory ?? landing.ship?.historyRef
|
|
81
86
|
const outcome: LandStep =
|
|
82
87
|
landing.to === snapshot.state
|
|
83
88
|
? { kind: "outcome", outcome: { kind: "commit", subject } }
|
|
@@ -88,20 +93,20 @@ export const planStep = (
|
|
|
88
93
|
to: landing.to,
|
|
89
94
|
subject,
|
|
90
95
|
steps: [
|
|
96
|
+
...writeIf(landing.startBranch, (branch) => ({ kind: "switchBranch", branch })),
|
|
91
97
|
...uncheckStep(snapshot),
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
},
|
|
96
|
-
...(landing.keepHistory !== undefined
|
|
97
|
-
? [
|
|
98
|
-
{
|
|
99
|
-
kind: "gitWrite",
|
|
100
|
-
write: { kind: "updateRef", ref: landing.keepHistory, target: "HEAD" },
|
|
101
|
-
} as const,
|
|
102
|
-
]
|
|
103
|
-
: []),
|
|
98
|
+
{ kind: "gitWrite", write: gitWriteOf(landing, message) },
|
|
99
|
+
...writeIf(historyRef, (ref) => ({ kind: "updateRef", ref, target: "HEAD" })),
|
|
100
|
+
...writeIf(landing.leaveBranch, (branch) => ({ kind: "switchTo", branch })),
|
|
104
101
|
outcome,
|
|
102
|
+
...(landing.note !== undefined
|
|
103
|
+
? [{ kind: "outcome", outcome: { kind: "note", text: landing.note } } as const]
|
|
104
|
+
: []),
|
|
105
|
+
...writeIf(historyRef !== undefined ? landing.pushHistory : undefined, (remote) => ({
|
|
106
|
+
kind: "pushRef",
|
|
107
|
+
remote,
|
|
108
|
+
ref: historyRef!,
|
|
109
|
+
})),
|
|
105
110
|
],
|
|
106
111
|
}
|
|
107
112
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Effect, Option } from "effect"
|
|
2
|
+
import { GitService } from "../platform/index.js"
|
|
3
|
+
import { PROCESS_HISTORY_PREFIX } from "../platform/index.js"
|
|
4
|
+
|
|
5
|
+
export const GTD_BRANCH_PREFIX = "gtd/"
|
|
6
|
+
|
|
7
|
+
/** `<b>` for a `gtd/<b>` branch name; `undefined` for any other. */
|
|
8
|
+
export const userBranchOf = (branch: string): string | undefined =>
|
|
9
|
+
branch.startsWith(GTD_BRANCH_PREFIX) ? branch.slice(GTD_BRANCH_PREFIX.length) : undefined
|
|
10
|
+
|
|
11
|
+
/** A branch already named `gtd/…` and a detached HEAD start in place. */
|
|
12
|
+
export type StartBranch =
|
|
13
|
+
| { readonly kind: "inPlace" }
|
|
14
|
+
| { readonly kind: "switch"; readonly branch: string }
|
|
15
|
+
| { readonly kind: "refusal"; readonly message: string }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where a new process starts: on `gtd/<b>`, so `<b>` never carries its commits.
|
|
19
|
+
* An existing `gtd/<b>` is reused only when its tip is contained in `<b>` or in
|
|
20
|
+
* a kept history ref (a squash onto `<b>` never makes it an ancestor).
|
|
21
|
+
*/
|
|
22
|
+
export const startBranchFor = (
|
|
23
|
+
commandLabel: string,
|
|
24
|
+
): Effect.Effect<StartBranch, Error, GitService> =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const git = yield* GitService
|
|
27
|
+
const current = yield* git.currentBranch()
|
|
28
|
+
if (Option.isNone(current) || current.value.startsWith(GTD_BRANCH_PREFIX)) {
|
|
29
|
+
return { kind: "inPlace" } as const
|
|
30
|
+
}
|
|
31
|
+
if (!(yield* git.hasCommits())) return { kind: "inPlace" } as const
|
|
32
|
+
const branch = `${GTD_BRANCH_PREFIX}${current.value}`
|
|
33
|
+
const existing = yield* git.readRefOption(`refs/heads/${branch}`)
|
|
34
|
+
if (Option.isSome(existing)) {
|
|
35
|
+
const head = yield* git.resolveRef("HEAD")
|
|
36
|
+
let contained = yield* git.isAncestor(existing.value, head)
|
|
37
|
+
if (!contained) {
|
|
38
|
+
const kept = yield* git.listRefs(PROCESS_HISTORY_PREFIX)
|
|
39
|
+
for (const k of kept) {
|
|
40
|
+
if (yield* git.isAncestor(existing.value, k.hash)) {
|
|
41
|
+
contained = true
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!contained) {
|
|
47
|
+
return {
|
|
48
|
+
kind: "refusal",
|
|
49
|
+
message: `${commandLabel}: branch ${branch} holds commits that are neither on ${current.value} nor in a kept history ref — merge or delete it first`,
|
|
50
|
+
} as const
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { kind: "switch", branch } as const
|
|
54
|
+
})
|