@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
|
@@ -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/SyncIo.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface FileInfo {
|
|
|
4
4
|
readonly type: "file" | "directory" | "symlink" | "other"
|
|
5
5
|
/** `undefined` where the platform has no POSIX user ids. */
|
|
6
6
|
readonly uid: number | undefined
|
|
7
|
+
/** Any execute bit set; `undefined` where the platform has none. */
|
|
8
|
+
readonly executable?: boolean
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
chmodSync,
|
|
2
3
|
mkdirSync,
|
|
3
4
|
mkdtempSync,
|
|
4
5
|
readFileSync,
|
|
@@ -38,6 +39,7 @@ interface WorkspaceTier {
|
|
|
38
39
|
readonly root: string
|
|
39
40
|
readonly writeWorking: (path: string, content: string) => void
|
|
40
41
|
readonly commit: (path: string, content: string) => void
|
|
42
|
+
readonly chmod: (path: string, executable: boolean) => void
|
|
41
43
|
readonly provide: <A>(eff: Effect.Effect<A, Error, Workspace>) => Promise<A>
|
|
42
44
|
readonly dispose: () => void
|
|
43
45
|
}
|
|
@@ -70,6 +72,7 @@ const makeLiveTier = (): WorkspaceTier => {
|
|
|
70
72
|
gitExecIn(root, "add", "-A")
|
|
71
73
|
gitExecIn(root, "commit", "-m", `commit ${path}`)
|
|
72
74
|
},
|
|
75
|
+
chmod: (path, executable) => chmodSync(join(root, path), executable ? 0o755 : 0o644),
|
|
73
76
|
provide: (eff) => Effect.runPromise(eff.pipe(Effect.provide(workspaceLayer))),
|
|
74
77
|
dispose: () => rmSync(root, { recursive: true, force: true }),
|
|
75
78
|
}
|
|
@@ -86,6 +89,7 @@ const makeInMemTier = (): WorkspaceTier => {
|
|
|
86
89
|
repo.writeFile(path, content)
|
|
87
90
|
repo.seedCommit(`commit ${path}`)
|
|
88
91
|
},
|
|
92
|
+
chmod: (path, executable) => repo.setExecutable(path, executable),
|
|
89
93
|
provide: (eff) =>
|
|
90
94
|
Effect.runPromise(eff.pipe(Effect.provide(Layer.succeed(Workspace, workspaceOps)))),
|
|
91
95
|
dispose: () => {},
|
|
@@ -232,6 +236,39 @@ for (const { name, make } of tiers) {
|
|
|
232
236
|
await expect(attempt).rejects.toThrow(/repo-relative paths only/)
|
|
233
237
|
})
|
|
234
238
|
|
|
239
|
+
it("modeSync reports committed and working-tree modes, kept across an overwrite", async () => {
|
|
240
|
+
t = make()
|
|
241
|
+
t.commit("a.sh", "echo\n")
|
|
242
|
+
t.chmod("a.sh", true)
|
|
243
|
+
const modes = await t.provide(
|
|
244
|
+
Effect.gen(function* () {
|
|
245
|
+
const workspace = yield* Workspace
|
|
246
|
+
const committed = workspace.modeSync("HEAD").get("a.sh")
|
|
247
|
+
const chmodded = workspace.modeSync(undefined).get("a.sh")
|
|
248
|
+
t.writeWorking("a.sh", "echo 2\n")
|
|
249
|
+
return [committed, chmodded, workspace.modeSync(undefined).get("a.sh")]
|
|
250
|
+
}),
|
|
251
|
+
)
|
|
252
|
+
expect(modes).toEqual(["100644", "100755", "100755"])
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it.runIf(name === "Live")(
|
|
256
|
+
"modeSync reads only the owner execute bit, as git does",
|
|
257
|
+
async () => {
|
|
258
|
+
t = make()
|
|
259
|
+
t.commit("a.sh", "echo\n")
|
|
260
|
+
t.writeWorking("a.sh", "echo 2\n")
|
|
261
|
+
chmodSync(join(t.root, "a.sh"), 0o654)
|
|
262
|
+
const mode = await t.provide(
|
|
263
|
+
Effect.gen(function* () {
|
|
264
|
+
return (yield* Workspace).modeSync(undefined).get("a.sh")
|
|
265
|
+
}),
|
|
266
|
+
)
|
|
267
|
+
gitExecIn(t.root, "add", "a.sh")
|
|
268
|
+
expect(mode).toBe(gitExecIn(t.root, "ls-files", "-s", "a.sh").split(" ")[0])
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
|
|
235
272
|
it("atPath reads an absolute path INSIDE root, the same content readSync's relative form sees", async () => {
|
|
236
273
|
t = make()
|
|
237
274
|
t.writeWorking("nested/a.txt", "inside\n")
|
|
@@ -150,6 +150,16 @@ const applyRawDiff = (tree: Map<string, string>, entries: readonly RawDiffEntry[
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → mode`. */
|
|
154
|
+
const parseLsTreeModes = (out: string): Map<string, string> => {
|
|
155
|
+
const modes = new Map<string, string>()
|
|
156
|
+
for (const record of out.split("\0")) {
|
|
157
|
+
const tab = record.indexOf("\t")
|
|
158
|
+
if (tab !== -1) modes.set(record.slice(tab + 1), record.slice(0, tab).split(" ")[0]!)
|
|
159
|
+
}
|
|
160
|
+
return modes
|
|
161
|
+
}
|
|
162
|
+
|
|
153
163
|
/** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → blob id`. */
|
|
154
164
|
const parseLsTree = (out: string): Map<string, string> => {
|
|
155
165
|
const entries = new Map<string, string>()
|
|
@@ -227,6 +237,8 @@ export interface WorkspaceOps {
|
|
|
227
237
|
* reader compares contents instead.
|
|
228
238
|
*/
|
|
229
239
|
readonly worktreeSync: () => ReadonlyMap<string, string | undefined>
|
|
240
|
+
/** Every path's git file mode (`100644`, `100755`, …) at `ref`, or in the working tree as `git add -A` would store it when `ref` is `undefined`. */
|
|
241
|
+
readonly modeSync: (ref: string | undefined) => ReadonlyMap<string, string>
|
|
230
242
|
/**
|
|
231
243
|
* `hash → (path → blob id)` for `base` (when given) and every one of
|
|
232
244
|
* `commits`, in ONE call regardless of how many commits that is: a single
|
|
@@ -528,6 +540,29 @@ const makeWorkspaceOps = (
|
|
|
528
540
|
return new Map([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
529
541
|
}
|
|
530
542
|
|
|
543
|
+
const modeSync = (ref: string | undefined): ReadonlyMap<string, string> => {
|
|
544
|
+
const modes = parseLsTreeModes(gitSync(["ls-tree", "-r", "-z", "--full-tree", ref ?? "HEAD"]))
|
|
545
|
+
if (ref !== undefined) return modes
|
|
546
|
+
const status = gitSync([
|
|
547
|
+
"status",
|
|
548
|
+
"--porcelain=v1",
|
|
549
|
+
"-z",
|
|
550
|
+
"--untracked-files=all",
|
|
551
|
+
"--no-renames",
|
|
552
|
+
"--ignore-submodules=none",
|
|
553
|
+
])
|
|
554
|
+
for (const record of status.split("\0")) {
|
|
555
|
+
if (record.length < 4) continue
|
|
556
|
+
const path = record.slice(3).replace(/\/$/, "")
|
|
557
|
+
const stat = io.lstat(paths.join(root, path))
|
|
558
|
+
if (stat === undefined) modes.delete(path)
|
|
559
|
+
else if (stat.type === "symlink") modes.set(path, "120000")
|
|
560
|
+
else if (stat.type === "directory") modes.set(path, "160000")
|
|
561
|
+
else modes.set(path, stat.executable === true ? "100755" : "100644")
|
|
562
|
+
}
|
|
563
|
+
return modes
|
|
564
|
+
}
|
|
565
|
+
|
|
531
566
|
/** A submodule's (or embedded repository's) checked-out commit — what `git add` records for it. */
|
|
532
567
|
const gitlinkOf = (path: string): string | undefined => {
|
|
533
568
|
try {
|
|
@@ -547,6 +582,7 @@ const makeWorkspaceOps = (
|
|
|
547
582
|
return {
|
|
548
583
|
treeSync,
|
|
549
584
|
worktreeSync,
|
|
585
|
+
modeSync,
|
|
550
586
|
episodeTrees,
|
|
551
587
|
readSync,
|
|
552
588
|
read: (path) => Effect.try({ try: () => readSync(path), catch: toError }),
|
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 {
|
|
@@ -314,12 +322,53 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
|
|
|
314
322
|
// The option keys each step accepts. A gtd.config.ts is evaluated without a
|
|
315
323
|
// type check, so a misspelt key would otherwise be silently ignored.
|
|
316
324
|
const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
|
|
317
|
-
agent: new Set([
|
|
325
|
+
agent: new Set([
|
|
326
|
+
...STEERING_OPTIONS,
|
|
327
|
+
"model",
|
|
328
|
+
"system",
|
|
329
|
+
"allowEmpty",
|
|
330
|
+
"keep",
|
|
331
|
+
"validate",
|
|
332
|
+
"cap",
|
|
333
|
+
"exhausted",
|
|
334
|
+
"correction",
|
|
335
|
+
]),
|
|
318
336
|
human: new Set([...STEERING_OPTIONS, "message", "acceptClean", "approve"]),
|
|
319
|
-
run: new Set(STEERING_OPTIONS),
|
|
337
|
+
run: new Set([...STEERING_OPTIONS, "onFail"]),
|
|
338
|
+
ship: new Set(["label", "base"]),
|
|
320
339
|
judge: new Set([...STEERING_OPTIONS, "message"]),
|
|
321
340
|
}
|
|
322
341
|
|
|
342
|
+
const validatorOptionFault = (
|
|
343
|
+
name: string,
|
|
344
|
+
options: Extract<StepRequest, { kind: "agent" }>["options"],
|
|
345
|
+
): string | undefined => {
|
|
346
|
+
const { validate, cap, exhausted, correction } = options
|
|
347
|
+
const prefix = `gtd: step "${name}": `
|
|
348
|
+
const notFunction = Object.entries({ validate, exhausted, correction }).find(
|
|
349
|
+
([, value]) => value !== undefined && typeof value !== "function",
|
|
350
|
+
)
|
|
351
|
+
if (notFunction !== undefined)
|
|
352
|
+
return `${prefix}${notFunction[0]} must be a function in agent() options`
|
|
353
|
+
if (cap !== undefined && !(Number.isInteger(cap) && cap > 0)) {
|
|
354
|
+
return `${prefix}cap must be a positive integer in agent() options`
|
|
355
|
+
}
|
|
356
|
+
if (validate !== undefined && options.keep === true) {
|
|
357
|
+
return `${prefix}validate cannot be combined with keep: true — a kept turn is set aside, so there is no landed result to validate`
|
|
358
|
+
}
|
|
359
|
+
return undefined
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const restsOnFail = (step: ReachedStep): boolean =>
|
|
363
|
+
step.request.kind === "run" && step.request.options.onFail === "rest"
|
|
364
|
+
|
|
365
|
+
const onFailFault = (step: ReachedStep): string | undefined =>
|
|
366
|
+
step.request.kind === "run" &&
|
|
367
|
+
step.request.options.onFail !== undefined &&
|
|
368
|
+
step.request.options.onFail !== "rest"
|
|
369
|
+
? `gtd: step "${step.name}": onFail must be "rest" in run() options`
|
|
370
|
+
: undefined
|
|
371
|
+
|
|
323
372
|
const unknownOptions = (step: ReachedStep): string | undefined => {
|
|
324
373
|
const unknown = Object.keys(step.request.options).filter(
|
|
325
374
|
(key) => !KNOWN_OPTIONS[step.kind].has(key),
|
|
@@ -454,12 +503,13 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
454
503
|
const scoped = (name: string): string =>
|
|
455
504
|
[...scopes.flatMap((s) => (s.name === undefined ? [] : [s.name])), name].join(".")
|
|
456
505
|
|
|
457
|
-
const isAttemptAt = (commit: ParsedCommit,
|
|
506
|
+
const isAttemptAt = (commit: ParsedCommit, step: ReachedStep): boolean =>
|
|
458
507
|
commit.parsed.step === undefined &&
|
|
459
508
|
commit.parsed.parsed !== undefined &&
|
|
460
509
|
commit.parsed.parsed.from === undefined &&
|
|
461
|
-
commit.parsed.parsed.to === name &&
|
|
462
|
-
isEmptyDiff(diffTrees(position.tree, commit.tree))
|
|
510
|
+
commit.parsed.parsed.to === step.name &&
|
|
511
|
+
(isEmptyDiff(diffTrees(position.tree, commit.tree)) ||
|
|
512
|
+
(restsOnFail(step) && commit.tree.read(RUN_FAILED_PATH) !== undefined))
|
|
463
513
|
|
|
464
514
|
const advance = (next: Position): void => {
|
|
465
515
|
previousPosition = position
|
|
@@ -469,13 +519,16 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
469
519
|
const replyFor = (
|
|
470
520
|
request: Exclude<StepRequest, { kind: "restart" }>,
|
|
471
521
|
verdicts: readonly JudgeVerdict[],
|
|
522
|
+
shipped: string | undefined,
|
|
472
523
|
): unknown =>
|
|
473
524
|
request.kind === "judge"
|
|
474
525
|
? {
|
|
475
526
|
answers: answersFrom(verdicts, request.questions),
|
|
476
527
|
truncated: judgeCuts.get(request) ?? [],
|
|
477
528
|
}
|
|
478
|
-
:
|
|
529
|
+
: request.kind === "ship"
|
|
530
|
+
? shipped
|
|
531
|
+
: undefined
|
|
479
532
|
|
|
480
533
|
const judgeCuts = new WeakMap<object, readonly string[]>()
|
|
481
534
|
|
|
@@ -636,7 +689,11 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
636
689
|
)
|
|
637
690
|
}
|
|
638
691
|
const error =
|
|
639
|
-
unknownOptions(step) ??
|
|
692
|
+
unknownOptions(step) ??
|
|
693
|
+
onFailFault(step) ??
|
|
694
|
+
approveConflict(step) ??
|
|
695
|
+
modeWithoutFile(step) ??
|
|
696
|
+
checkIdentity(step)
|
|
640
697
|
return error === undefined ? undefined : { kind: "failed", message: error }
|
|
641
698
|
}
|
|
642
699
|
|
|
@@ -670,7 +727,7 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
670
727
|
expectNextOf(commit)
|
|
671
728
|
skipPins()
|
|
672
729
|
noteKept(step, commit)
|
|
673
|
-
return replyFor(step.request, commit.parsed.judge)
|
|
730
|
+
return replyFor(step.request, commit.parsed.judge, commit.parsed.shipped)
|
|
674
731
|
}
|
|
675
732
|
|
|
676
733
|
// A turn that ends a pinned process names the unpin, which is no step.
|
|
@@ -699,11 +756,15 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
699
756
|
keptTips.set(step.name, { hash: "", before: position.tree, after: pending.tree })
|
|
700
757
|
}
|
|
701
758
|
advance({ hash: "", tree: keeping ? position.tree : pending.tree })
|
|
702
|
-
return replyFor(step.request, pending.verdicts ?? [])
|
|
759
|
+
return replyFor(step.request, pending.verdicts ?? [], pending.shipped)
|
|
703
760
|
}
|
|
704
761
|
|
|
705
762
|
const answer = (step: ReachedStep): unknown => {
|
|
706
|
-
while (cursor < commits.length && isAttemptAt(commits[cursor]!, step
|
|
763
|
+
while (cursor < commits.length && isAttemptAt(commits[cursor]!, step)) {
|
|
764
|
+
// An attempt that recorded a failure carries its tree; an empty one changes nothing.
|
|
765
|
+
if (!isEmptyDiff(diffTrees(position.tree, commits[cursor]!.tree))) {
|
|
766
|
+
advance({ hash: commits[cursor]!.hash, tree: commits[cursor]!.tree })
|
|
767
|
+
}
|
|
707
768
|
cursor++
|
|
708
769
|
skipPins()
|
|
709
770
|
}
|
|
@@ -718,19 +779,34 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
718
779
|
marks,
|
|
719
780
|
})
|
|
720
781
|
}
|
|
782
|
+
if (restsOnFail(step) && input.pending.tree.read(RUN_FAILED_PATH) !== undefined) {
|
|
783
|
+
return finish({ kind: "rest", rest: step, trace, landed, marks })
|
|
784
|
+
}
|
|
721
785
|
return landPending(step, input.pending)
|
|
722
786
|
}
|
|
723
787
|
|
|
788
|
+
const bodyFault = (request: Exclude<StepRequest, { kind: "restart" | "agent" }>) => {
|
|
789
|
+
if (
|
|
790
|
+
request.kind === "ship" &&
|
|
791
|
+
(typeof request.message !== "string" || request.message.trim() === "")
|
|
792
|
+
) {
|
|
793
|
+
return `gtd: step "${scoped(request.name)}": a ship() message is a non-empty commit message`
|
|
794
|
+
}
|
|
795
|
+
if (request.kind === "run" && typeof request.body !== "string") {
|
|
796
|
+
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)`
|
|
797
|
+
}
|
|
798
|
+
return undefined
|
|
799
|
+
}
|
|
800
|
+
|
|
724
801
|
// gtd.config.ts is loaded without a type check.
|
|
725
802
|
const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
|
|
726
803
|
if (scoped(request.name).startsWith(ENGINE_PREFIX)) {
|
|
727
804
|
return `gtd: step "${scoped(request.name)}": the "${ENGINE_PREFIX}" prefix is reserved for gtd's own beats — rename the step`
|
|
728
805
|
}
|
|
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
|
|
806
|
+
if (request.kind !== "agent") return bodyFault(request)
|
|
733
807
|
const name = scoped(request.name)
|
|
808
|
+
const optionFault = validatorOptionFault(name, request.options)
|
|
809
|
+
if (optionFault !== undefined) return optionFault
|
|
734
810
|
if (request.options.keep === true) {
|
|
735
811
|
// A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
|
|
736
812
|
if ((request.options as { readonly allowEmpty?: unknown }).allowEmpty === true) {
|
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: {},
|
|
@@ -282,6 +296,8 @@ export interface CommitSpec {
|
|
|
282
296
|
readonly anchor?: string
|
|
283
297
|
readonly branch?: string
|
|
284
298
|
readonly version?: string
|
|
299
|
+
/** Written after `Gtd-Format`: a landing recording a ship an earlier, hook-rejected attempt already made. */
|
|
300
|
+
readonly shipped?: string
|
|
285
301
|
}
|
|
286
302
|
|
|
287
303
|
const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string, string][] =>
|
|
@@ -309,6 +325,7 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
|
|
|
309
325
|
...trailer("Branch", spec.branch),
|
|
310
326
|
...trailer("Version", spec.version),
|
|
311
327
|
`Gtd-Format: ${HISTORY_FORMAT}`,
|
|
328
|
+
...trailer("Shipped", spec.shipped),
|
|
312
329
|
]
|
|
313
330
|
const subject = formatSubject(spec.actor, spec.to, spec.from)
|
|
314
331
|
return `${subject}\n\n${lines.join("\n")}`
|
package/src/replay/Tree.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface TreeView {
|
|
|
8
8
|
readonly paths: () => readonly string[]
|
|
9
9
|
readonly read: (path: string) => string | undefined
|
|
10
10
|
readonly id?: (path: string) => string | undefined
|
|
11
|
+
/** Git file mode; when both sides give one, a differing mode is a modification even with equal content. */
|
|
12
|
+
readonly mode?: (path: string) => string | undefined
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export const treeFromRecord = (files: Readonly<Record<string, string>>): TreeView => ({
|
|
@@ -24,6 +26,9 @@ export interface TreeDiff {
|
|
|
24
26
|
export const diffTrees = (before: TreeView, after: TreeView): TreeDiff => {
|
|
25
27
|
// Blob ids only compare against blob ids; otherwise fall back to contents.
|
|
26
28
|
const same = (path: string): boolean => {
|
|
29
|
+
if (before.mode !== undefined && after.mode !== undefined) {
|
|
30
|
+
if (before.mode(path) !== after.mode(path)) return false
|
|
31
|
+
}
|
|
27
32
|
const a = before.id?.(path)
|
|
28
33
|
const b = after.id?.(path)
|
|
29
34
|
return a !== undefined && b !== undefined ? a === b : before.read(path) === after.read(path)
|
package/src/replay/index.ts
CHANGED
package/src/steering/FORMATS.md
CHANGED
|
@@ -17,7 +17,9 @@ read them.
|
|
|
17
17
|
pointer is a hunk the human has read. A chunk's `Tick exactly one:` list is
|
|
18
18
|
its decision. A `## Findings` or `## Walkthrough` heading groups the `###`
|
|
19
19
|
chunks under it; a walkthrough chunk takes no decision. Any other `##` is a
|
|
20
|
-
chunk of its own.
|
|
20
|
+
chunk of its own. Under `## Findings` a location is a plain list item
|
|
21
|
+
(`- ./path`, `- ./path#start-end`) with no checkbox; a `- [ ]`/`- [x]` pointer
|
|
22
|
+
there is an error.
|
|
21
23
|
- **`findings`**: the finding gate's file. `## ` entries, those under a
|
|
22
24
|
`# Dropped as noise` heading being the noise; each holds exactly one
|
|
23
25
|
`- [ ] fix` box. A ticked box fixes the finding; text below the box is the
|
|
@@ -17,6 +17,11 @@ export interface SteeringEdit {
|
|
|
17
17
|
export interface SteeringAction {
|
|
18
18
|
readonly title: string
|
|
19
19
|
readonly edits: readonly SteeringEdit[]
|
|
20
|
+
/** A range the editor selects once the edits apply; absent means no reveal. */
|
|
21
|
+
readonly select?: {
|
|
22
|
+
readonly start: { readonly line: number; readonly character: number }
|
|
23
|
+
readonly end: { readonly line: number; readonly character: number }
|
|
24
|
+
}
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
/** A go-to-definition target. `path` absent means "this same document" (a footnote jump); `character` defaults to 0. */
|
|
@@ -105,6 +110,12 @@ export interface SteeringViewThread {
|
|
|
105
110
|
readonly waitingOn: "human" | "agent"
|
|
106
111
|
}
|
|
107
112
|
|
|
113
|
+
/** One labelled part of a node's drafted comment. */
|
|
114
|
+
export interface SteeringViewSection {
|
|
115
|
+
readonly label: string
|
|
116
|
+
readonly body: readonly SteeringViewNode[]
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
/**
|
|
109
120
|
* One node of a format's `view`: a generic container/item tree, the SAME
|
|
110
121
|
* shape for every format. Deliberately never a closed per-format union — a
|
|
@@ -140,6 +151,8 @@ export interface SteeringViewNode {
|
|
|
140
151
|
* (question); absent wherever `detail` itself is absent.
|
|
141
152
|
*/
|
|
142
153
|
readonly detailInline?: readonly InlineNode[]
|
|
154
|
+
/** A review finding's drafted PR comment as labelled sections (What / Impact / Fix), when it has one. */
|
|
155
|
+
readonly comment?: readonly SteeringViewSection[]
|
|
143
156
|
/** A file path this node points at, when it has one (a `review` hunk). */
|
|
144
157
|
readonly path?: string
|
|
145
158
|
/** A 1-based line in `path` this node points at, when it has one. */
|