@pmelab/gtd 27.7.0 → 27.8.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 +23 -13
- package/claude/hooks/assist.ts +35 -2
- package/claude/hooks/delegate.ts +22 -0
- package/claude/hooks/handoff.ts +13 -4
- package/claude/hooks/jobs.ts +67 -0
- package/claude/hooks/paste.ts +17 -0
- package/claude/hooks/register.tsx +126 -65
- package/claude/hooks/ship.ts +7 -2
- package/claude/hooks/tools.ts +32 -0
- package/claude/hooks/wording.ts +11 -0
- package/dist/chunks/{reserved-Df8GeSHw.mjs → reserved-UdSuHDUJ.mjs} +2 -2
- package/dist/chunks/{tail-DmIfEAI4.mjs → tail-fj60rQ3y.mjs} +1 -1
- package/dist/flows.mjs +16 -1
- package/dist/gtd.bundle.mjs +75 -2
- package/dist/testkit.mjs +60 -2
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +3 -3
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +3 -3
- package/package.json +1 -1
- package/src/edge/Edge.test.ts +28 -0
- package/src/edge/Edge.ts +13 -0
- package/src/flows/runtime.test.ts +27 -0
- package/src/flows/runtime.ts +18 -0
- package/src/replay/Replay.test.ts +137 -0
- package/src/replay/Replay.ts +39 -1
- package/src/replay/Slices.test.ts +73 -0
- package/src/replay/Slices.ts +45 -0
- package/src/replay/index.ts +1 -0
- package/src/workflows/feature/review.test.ts +9 -3
- package/src/workflows/feature/review.ts +3 -3
- package/src/workflows/shared/steps.ts +1 -2
- package/src/workflows/shared/tail.test.ts +18 -9
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
restart,
|
|
16
16
|
run,
|
|
17
17
|
scope,
|
|
18
|
+
slice,
|
|
18
19
|
threads,
|
|
19
20
|
type Flow,
|
|
20
21
|
type ScopeAccess,
|
|
@@ -1210,3 +1211,139 @@ describe("human({ approve })", () => {
|
|
|
1210
1211
|
expect(outcome.kind).toBe("rest")
|
|
1211
1212
|
})
|
|
1212
1213
|
})
|
|
1214
|
+
|
|
1215
|
+
describe("slice()", () => {
|
|
1216
|
+
const spansOf = (o: ReplayOutcome) => (o as { slices: unknown }).slices
|
|
1217
|
+
|
|
1218
|
+
it("records enteredAt and leftAt as the commit replay stood on", async () => {
|
|
1219
|
+
const wf = async () => {
|
|
1220
|
+
await human("a")
|
|
1221
|
+
await slice("refactor", async () => {
|
|
1222
|
+
await human("b")
|
|
1223
|
+
})
|
|
1224
|
+
await human("c")
|
|
1225
|
+
}
|
|
1226
|
+
const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "c")
|
|
1227
|
+
const outcome = await replayOf(wf, h)
|
|
1228
|
+
expect(outcome).toMatchObject({ kind: "rest" })
|
|
1229
|
+
expect(spansOf(outcome)).toEqual([
|
|
1230
|
+
{ name: "refactor", enteredAt: hashOf(1), leftAt: hashOf(2) },
|
|
1231
|
+
])
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
it("keeps leftAt undefined when the process rests inside the slice", async () => {
|
|
1235
|
+
const wf = async () => {
|
|
1236
|
+
await slice("refactor", async () => {
|
|
1237
|
+
await human("a")
|
|
1238
|
+
})
|
|
1239
|
+
}
|
|
1240
|
+
const outcome = await replayOf(wf, new History())
|
|
1241
|
+
expect(spansOf(outcome)).toEqual([
|
|
1242
|
+
{ name: "refactor", enteredAt: hashOf(0), leftAt: undefined },
|
|
1243
|
+
])
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
it("records enteredAt === leftAt for a slice that landed no commit", async () => {
|
|
1247
|
+
const wf = async () => {
|
|
1248
|
+
await human("a")
|
|
1249
|
+
await slice("empty", async () => undefined)
|
|
1250
|
+
await human("b")
|
|
1251
|
+
}
|
|
1252
|
+
const h = new History().land("human", "a", 1, "b")
|
|
1253
|
+
expect(spansOf(await replayOf(wf, h))).toEqual([
|
|
1254
|
+
{ name: "empty", enteredAt: hashOf(1), leftAt: hashOf(1) },
|
|
1255
|
+
])
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
it("prefixes no step name and ignores an enclosing scope", async () => {
|
|
1259
|
+
const wf = async () => {
|
|
1260
|
+
await scope("build", async () => {
|
|
1261
|
+
await slice("refactor", async () => {
|
|
1262
|
+
await human("gate")
|
|
1263
|
+
})
|
|
1264
|
+
})
|
|
1265
|
+
}
|
|
1266
|
+
const outcome = await replayOf(wf, new History())
|
|
1267
|
+
expect(outcome).toMatchObject({ kind: "rest", rest: { name: "build.gate" } })
|
|
1268
|
+
expect((spansOf(outcome) as { name: string }[])[0]!.name).toBe("refactor")
|
|
1269
|
+
})
|
|
1270
|
+
|
|
1271
|
+
const failed = async (wf: Flow) => {
|
|
1272
|
+
const outcome = await replayOf(wf, new History())
|
|
1273
|
+
expect(outcome.kind).toBe("failed")
|
|
1274
|
+
return (outcome as { message: string }).message
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
it.each(["Refactor", "step_1", "v2.api", ""])("refuses the name %j", async (name) => {
|
|
1278
|
+
expect(await failed(async () => slice(name, async () => human("a")))).toBe(
|
|
1279
|
+
`gtd: slice "${name}": name must match /^[a-z][a-z0-9-]*$/`,
|
|
1280
|
+
)
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
it("accepts refactor-2", async () => {
|
|
1284
|
+
const outcome = await replayOf(
|
|
1285
|
+
async () => slice("refactor-2", async () => human("a")),
|
|
1286
|
+
new History(),
|
|
1287
|
+
)
|
|
1288
|
+
expect(outcome.kind).toBe("rest")
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
it("refuses the reserved name main", async () => {
|
|
1292
|
+
expect(await failed(async () => slice("main", async () => human("a")))).toBe(
|
|
1293
|
+
'gtd: slice "main" is reserved for the implicit slice',
|
|
1294
|
+
)
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
it("refuses nesting, also through scope()", async () => {
|
|
1298
|
+
const message = 'gtd: slice "inner" is entered inside slice "outer" — slices do not nest'
|
|
1299
|
+
expect(
|
|
1300
|
+
await failed(async () => slice("outer", async () => slice("inner", async () => human("a")))),
|
|
1301
|
+
).toBe(message)
|
|
1302
|
+
expect(
|
|
1303
|
+
await failed(async () =>
|
|
1304
|
+
slice("outer", async () => scope("s", async () => slice("inner", async () => human("a")))),
|
|
1305
|
+
),
|
|
1306
|
+
).toBe(message)
|
|
1307
|
+
})
|
|
1308
|
+
|
|
1309
|
+
it("checks the name rule before main and nesting", async () => {
|
|
1310
|
+
expect(
|
|
1311
|
+
await failed(async () => slice("outer", async () => slice("Main", async () => human("a")))),
|
|
1312
|
+
).toBe('gtd: slice "Main": name must match /^[a-z][a-z0-9-]*$/')
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
it("lets a flow that caught an error from a slice enter another", async () => {
|
|
1316
|
+
const wf = async () => {
|
|
1317
|
+
try {
|
|
1318
|
+
await slice("a", async () => {
|
|
1319
|
+
throw new Error("boom")
|
|
1320
|
+
})
|
|
1321
|
+
} catch {}
|
|
1322
|
+
await slice("b", async () => human("x"))
|
|
1323
|
+
}
|
|
1324
|
+
expect(await replayOf(wf, new History())).toMatchObject({ kind: "rest" })
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
it("refuses a non-string name", async () => {
|
|
1328
|
+
expect(
|
|
1329
|
+
await failed(async () => slice(undefined as unknown as string, async () => human("a"))),
|
|
1330
|
+
).toContain("name must match")
|
|
1331
|
+
})
|
|
1332
|
+
|
|
1333
|
+
it("refuses a name entered twice, sequentially and from a loop", async () => {
|
|
1334
|
+
const message =
|
|
1335
|
+
'gtd: slice "again" is entered twice in this episode — a loop must sit inside its slice'
|
|
1336
|
+
expect(
|
|
1337
|
+
await failed(async () => {
|
|
1338
|
+
await slice("again", async () => undefined)
|
|
1339
|
+
await slice("again", async () => human("a"))
|
|
1340
|
+
}),
|
|
1341
|
+
).toBe(message)
|
|
1342
|
+
expect(
|
|
1343
|
+
await failed(async () => {
|
|
1344
|
+
for (let i = 0; i < 2; i++) await slice("again", async () => undefined)
|
|
1345
|
+
await human("a")
|
|
1346
|
+
}),
|
|
1347
|
+
).toBe(message)
|
|
1348
|
+
})
|
|
1349
|
+
})
|
package/src/replay/Replay.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
|
|
24
24
|
import { globMatches } from "../wire/index.js"
|
|
25
25
|
import { refNameFault } from "./RefName.js"
|
|
26
|
+
import { IMPLICIT_SLICE, SLICE_NAME, type SliceSpan } from "./Slices.js"
|
|
26
27
|
import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
|
|
27
28
|
import {
|
|
28
29
|
formatStepId,
|
|
@@ -117,6 +118,7 @@ export type ReplayOutcome =
|
|
|
117
118
|
readonly landed: ReachedStep | undefined
|
|
118
119
|
/** Marks the flow made after the pending turn's step resolved; [] without a pending turn. */
|
|
119
120
|
readonly marks: readonly Mark[]
|
|
121
|
+
readonly slices: readonly SliceSpan[]
|
|
120
122
|
}
|
|
121
123
|
| {
|
|
122
124
|
readonly kind: "ended"
|
|
@@ -297,6 +299,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
297
299
|
>()
|
|
298
300
|
let landed: ReachedStep | undefined
|
|
299
301
|
const marks: Mark[] = []
|
|
302
|
+
const slices: { name: string; enteredAt: string; leftAt: string | undefined }[] = []
|
|
300
303
|
let outcome: ReplayOutcome | undefined
|
|
301
304
|
let signal: (() => void) | undefined
|
|
302
305
|
const settled = new Promise<void>((resolve) => {
|
|
@@ -558,7 +561,14 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
558
561
|
const commit = commits[cursor]
|
|
559
562
|
if (commit !== undefined) return consumeCommit(step, commit)
|
|
560
563
|
if (input.pending === undefined || pendingUsed) {
|
|
561
|
-
return finish({
|
|
564
|
+
return finish({
|
|
565
|
+
kind: "rest",
|
|
566
|
+
rest: step,
|
|
567
|
+
trace,
|
|
568
|
+
landed,
|
|
569
|
+
marks,
|
|
570
|
+
slices: slices.map((s) => ({ ...s })),
|
|
571
|
+
})
|
|
562
572
|
}
|
|
563
573
|
return landPending(step, input.pending)
|
|
564
574
|
}
|
|
@@ -630,6 +640,34 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
630
640
|
scopes.pop()
|
|
631
641
|
scopeCallIds.pop()
|
|
632
642
|
},
|
|
643
|
+
enterSlice: (name) => {
|
|
644
|
+
if (outcome !== undefined) return
|
|
645
|
+
const fail = (message: string): never => {
|
|
646
|
+
void finish({ kind: "failed", message })
|
|
647
|
+
throw new Stop()
|
|
648
|
+
}
|
|
649
|
+
if (typeof name !== "string" || !SLICE_NAME.test(name)) {
|
|
650
|
+
fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`)
|
|
651
|
+
}
|
|
652
|
+
if (name === IMPLICIT_SLICE) {
|
|
653
|
+
fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`)
|
|
654
|
+
}
|
|
655
|
+
const open = slices.find((s) => s.leftAt === undefined)
|
|
656
|
+
if (open !== undefined) {
|
|
657
|
+
fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`)
|
|
658
|
+
}
|
|
659
|
+
if (slices.some((s) => s.name === name)) {
|
|
660
|
+
fail(
|
|
661
|
+
`gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`,
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
slices.push({ name, enteredAt: position.hash, leftAt: undefined })
|
|
665
|
+
},
|
|
666
|
+
leaveSlice: () => {
|
|
667
|
+
if (outcome !== undefined) return
|
|
668
|
+
const open = slices.find((s) => s.leftAt === undefined)
|
|
669
|
+
if (open !== undefined) open.leftAt = position.hash
|
|
670
|
+
},
|
|
633
671
|
read: (path) => position.tree.read(path),
|
|
634
672
|
glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
|
|
635
673
|
changes: () => changesBetween(previousPosition.tree, position.tree),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { sliceRanges, type SliceSpan } from "./Slices.js"
|
|
3
|
+
|
|
4
|
+
const span = (name: string, enteredAt: string, leftAt?: string): SliceSpan => ({
|
|
5
|
+
name,
|
|
6
|
+
enteredAt,
|
|
7
|
+
leftAt,
|
|
8
|
+
})
|
|
9
|
+
const running = { base: "B", tip: "T" }
|
|
10
|
+
|
|
11
|
+
describe("sliceRanges", () => {
|
|
12
|
+
it("two adjacent spans: first from episode.base to its leftAt, second from there to episode.tip", () => {
|
|
13
|
+
expect(sliceRanges([span("a", "B", "c2"), span("b", "c2", "T")], running)).toEqual([
|
|
14
|
+
{ name: "a", base: "B", tip: "c2", open: false },
|
|
15
|
+
{ name: "b", base: "c2", tip: "T", open: true },
|
|
16
|
+
])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("a leading gap folds into the first range via episode.base", () => {
|
|
20
|
+
expect(sliceRanges([span("a", "c1", "c2")], running)).toEqual([
|
|
21
|
+
{ name: "a", base: "B", tip: "T", open: true },
|
|
22
|
+
])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("an inner gap folds into the next range: its base is the previous leftAt", () => {
|
|
26
|
+
expect(sliceRanges([span("a", "B", "c1"), span("b", "c3", "T")], running)).toEqual([
|
|
27
|
+
{ name: "a", base: "B", tip: "c1", open: false },
|
|
28
|
+
{ name: "b", base: "c1", tip: "T", open: true },
|
|
29
|
+
])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("a trailing gap folds back: the last range's tip is the episode tip, and it stays open", () => {
|
|
33
|
+
expect(sliceRanges([span("a", "B", "c1")], running)).toEqual([
|
|
34
|
+
{ name: "a", base: "B", tip: "T", open: true },
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("drops a closed empty span, first, middle and last, its neighbours absorbing the gap", () => {
|
|
39
|
+
expect(sliceRanges([span("e", "B", "B"), span("a", "B", "c1")], running)).toEqual([
|
|
40
|
+
{ name: "a", base: "B", tip: "T", open: true },
|
|
41
|
+
])
|
|
42
|
+
expect(
|
|
43
|
+
sliceRanges([span("a", "B", "c1"), span("e", "c2", "c2"), span("b", "c2", "c4")], running),
|
|
44
|
+
).toEqual([
|
|
45
|
+
{ name: "a", base: "B", tip: "c1", open: false },
|
|
46
|
+
{ name: "b", base: "c1", tip: "T", open: true },
|
|
47
|
+
])
|
|
48
|
+
expect(sliceRanges([span("a", "B", "c1"), span("e", "c1", "c1")], running)).toEqual([
|
|
49
|
+
{ name: "a", base: "B", tip: "T", open: true },
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("no spans, and only empty closed spans, each yield one open main range over the whole episode", () => {
|
|
54
|
+
const main = [{ name: "main", base: "B", tip: "T", open: true }]
|
|
55
|
+
expect(sliceRanges([], running)).toEqual(main)
|
|
56
|
+
expect(sliceRanges([span("e", "c1", "c1")], running)).toEqual(main)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("an unleft empty span is kept and open", () => {
|
|
60
|
+
expect(sliceRanges([span("a", "B", "c1"), span("b", "c1")], running)).toEqual([
|
|
61
|
+
{ name: "a", base: "B", tip: "c1", open: false },
|
|
62
|
+
{ name: "b", base: "c1", tip: "T", open: true },
|
|
63
|
+
])
|
|
64
|
+
expect(sliceRanges([span("b", "T")], running)).toEqual([
|
|
65
|
+
{ name: "b", base: "B", tip: "T", open: true },
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("rejects a pending turn's empty hash", () => {
|
|
70
|
+
expect(() => sliceRanges([span("a", "", "c1")], running)).toThrow("not a commit")
|
|
71
|
+
expect(() => sliceRanges([span("a", "c1", "")], running)).toThrow("not a commit")
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const IMPLICIT_SLICE = "main"
|
|
2
|
+
|
|
3
|
+
/** Same shape as mark()'s name rule; kept local so @pmelab/gtd/flows publishes no regex. */
|
|
4
|
+
export const SLICE_NAME = /^[a-z][a-z0-9-]*$/
|
|
5
|
+
|
|
6
|
+
/** One slice() call as replay saw it. Hashes are the commit replay stood on; "" is a pending turn. */
|
|
7
|
+
export interface SliceSpan {
|
|
8
|
+
readonly name: string
|
|
9
|
+
readonly enteredAt: string
|
|
10
|
+
/** undefined: the flow never left it — the process rests inside, or restart() fired inside. */
|
|
11
|
+
readonly leftAt: string | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SliceRange {
|
|
15
|
+
readonly name: string
|
|
16
|
+
/** Exclusive: the range is base..tip in git's sense. */
|
|
17
|
+
readonly base: string
|
|
18
|
+
readonly tip: string
|
|
19
|
+
/** The tip can still move: only the last range, since a rest is never past the episode's end. */
|
|
20
|
+
readonly open: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Folds boundaries into ranges: each kept span's base is the previous kept
|
|
25
|
+
* span's `leftAt`, so a gap before a span belongs to it, and the last span
|
|
26
|
+
* runs to the episode tip. A closed span that landed nothing is dropped.
|
|
27
|
+
*/
|
|
28
|
+
export const sliceRanges = (
|
|
29
|
+
spans: readonly SliceSpan[],
|
|
30
|
+
episode: { readonly base: string; readonly tip: string },
|
|
31
|
+
): readonly SliceRange[] => {
|
|
32
|
+
if (spans.some((s) => s.enteredAt === "" || s.leftAt === "")) {
|
|
33
|
+
throw new Error('sliceRanges: a pending turn\'s "" hash is not a commit')
|
|
34
|
+
}
|
|
35
|
+
const kept = spans.filter((s) => s.leftAt === undefined || s.enteredAt !== s.leftAt)
|
|
36
|
+
if (kept.length === 0) {
|
|
37
|
+
return [{ name: IMPLICIT_SLICE, base: episode.base, tip: episode.tip, open: true }]
|
|
38
|
+
}
|
|
39
|
+
return kept.map((s, i) => ({
|
|
40
|
+
name: s.name,
|
|
41
|
+
base: i === 0 ? episode.base : (kept[i - 1]!.leftAt ?? episode.base),
|
|
42
|
+
tip: i === kept.length - 1 ? episode.tip : (s.leftAt ?? episode.tip),
|
|
43
|
+
open: i === kept.length - 1,
|
|
44
|
+
}))
|
|
45
|
+
}
|
package/src/replay/index.ts
CHANGED
|
@@ -509,13 +509,19 @@ describe("severity routing", () => {
|
|
|
509
509
|
notes: ["add — quote", "sub", "mul"],
|
|
510
510
|
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
511
511
|
})
|
|
512
|
-
expect(run.gates).toEqual([
|
|
512
|
+
expect(run.gates).toEqual([true, true])
|
|
513
513
|
expect(run.gateMessages[1]).toContain("NOT acted on")
|
|
514
514
|
expect(run.gateMessages[1]).toContain("note-1")
|
|
515
515
|
expect(run.gateMessages[0]).not.toContain("NOT acted on")
|
|
516
516
|
})
|
|
517
517
|
|
|
518
|
-
it("
|
|
518
|
+
it("the first round's gate accepts a clean land", async () => {
|
|
519
|
+
const run = await drive({ notes: ["add", "sub", "mul"] })
|
|
520
|
+
expect(run.gates.length).toBeGreaterThan(0)
|
|
521
|
+
expect(run.gates.every((g) => g)).toBe(true)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it("a later gate still accepts a clean land after a deferral round with a nit", async () => {
|
|
519
525
|
const run = await drive({
|
|
520
526
|
notes: ["add — quote", "sub — typo", "mul"],
|
|
521
527
|
answers: {
|
|
@@ -527,7 +533,7 @@ describe("severity routing", () => {
|
|
|
527
533
|
})
|
|
528
534
|
expect(run.log).toContain("review.defer")
|
|
529
535
|
expect(run.log.at(-1)).toBe("review.await-review")
|
|
530
|
-
expect(run.gates).toEqual([
|
|
536
|
+
expect(run.gates).toEqual([true, true])
|
|
531
537
|
})
|
|
532
538
|
|
|
533
539
|
it("a question and a could note in one round: the gate's base is the answering commit", async () => {
|
|
@@ -167,8 +167,8 @@ export const review = (
|
|
|
167
167
|
escalations: EscalationCount = { rounds: 0 },
|
|
168
168
|
): Promise<kit.ReviewOutcome> => {
|
|
169
169
|
const severities = new Map<string, Severity>()
|
|
170
|
-
// Armed by a defer-only round
|
|
171
|
-
// review (`reviewing`) disarms it
|
|
170
|
+
// Armed by a defer-only round to pick the next gate's message; a fresh
|
|
171
|
+
// review (`reviewing`) disarms it.
|
|
172
172
|
let deferred: readonly t.NoteInput[] = []
|
|
173
173
|
return kit.review(base, {
|
|
174
174
|
file: REVIEW,
|
|
@@ -184,7 +184,7 @@ export const review = (
|
|
|
184
184
|
const notes = deferred
|
|
185
185
|
deferred = []
|
|
186
186
|
return notes.length > 0
|
|
187
|
-
? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes)
|
|
187
|
+
? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes))
|
|
188
188
|
: awaitReview(b)
|
|
189
189
|
},
|
|
190
190
|
reviewMissing,
|
|
@@ -117,7 +117,6 @@ export const fixRisks = (notes: readonly t.NoteInput[]): Promise<void> =>
|
|
|
117
117
|
export const awaitReview = (
|
|
118
118
|
base: string,
|
|
119
119
|
message = t.buildReviewAwaitReviewMessage(base),
|
|
120
|
-
acceptClean = false,
|
|
121
120
|
): Promise<void> =>
|
|
122
121
|
human("review.await-review", {
|
|
123
122
|
message,
|
|
@@ -125,7 +124,7 @@ export const awaitReview = (
|
|
|
125
124
|
file: REVIEW,
|
|
126
125
|
mode: "review",
|
|
127
126
|
base,
|
|
128
|
-
|
|
127
|
+
acceptClean: true,
|
|
129
128
|
})
|
|
130
129
|
|
|
131
130
|
/** The round at commit `round` left no `.gtd/REVIEW.md`. */
|
|
@@ -38,8 +38,18 @@ const handOff = async (
|
|
|
38
38
|
} = {},
|
|
39
39
|
) => {
|
|
40
40
|
const log: string[] = []
|
|
41
|
+
const gates: boolean[] = []
|
|
41
42
|
const scripts = new Map<string, string>()
|
|
42
43
|
const files = new Map<string, string>()
|
|
44
|
+
const onReviewing = () => {
|
|
45
|
+
if (log.filter((n) => n === "review.reviewing").length > 1) throw new Error("re-review")
|
|
46
|
+
if (after !== "unwritten") files.set(REVIEW, reviewDoc("new add function"))
|
|
47
|
+
}
|
|
48
|
+
const onGate = (request: StepRequest) => {
|
|
49
|
+
if (request.kind === "human") gates.push(request.options.acceptClean === true)
|
|
50
|
+
if (after === "deleted") files.delete(REVIEW)
|
|
51
|
+
else if (after !== "unwritten") files.set(REVIEW, after)
|
|
52
|
+
}
|
|
43
53
|
installContext(
|
|
44
54
|
fixtureContext(
|
|
45
55
|
{ codeThreads: extra.codeThreads ?? [] },
|
|
@@ -48,14 +58,8 @@ const handOff = async (
|
|
|
48
58
|
if (request.kind === "restart") return Promise.resolve()
|
|
49
59
|
log.push(request.name)
|
|
50
60
|
if (request.kind === "run") scripts.set(request.name, request.body)
|
|
51
|
-
if (request.name === "review.reviewing")
|
|
52
|
-
|
|
53
|
-
if (after !== "unwritten") files.set(REVIEW, reviewDoc("new add function"))
|
|
54
|
-
}
|
|
55
|
-
if (request.name === "review.await-review") {
|
|
56
|
-
if (after === "deleted") files.delete(REVIEW)
|
|
57
|
-
else if (after !== "unwritten") files.set(REVIEW, after)
|
|
58
|
-
}
|
|
61
|
+
if (request.name === "review.reviewing") onReviewing()
|
|
62
|
+
if (request.name === "review.await-review") onGate(request)
|
|
59
63
|
return Promise.resolve()
|
|
60
64
|
},
|
|
61
65
|
refuse: (message: string): never => {
|
|
@@ -96,7 +100,7 @@ const handOff = async (
|
|
|
96
100
|
() => undefined,
|
|
97
101
|
(e: unknown) => (e as Error).message,
|
|
98
102
|
)
|
|
99
|
-
return { log, scripts, refusal }
|
|
103
|
+
return { log, scripts, refusal, gates }
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
describe("the hand-off review", () => {
|
|
@@ -105,6 +109,11 @@ describe("the hand-off review", () => {
|
|
|
105
109
|
expect(run.log).toEqual(["review.reviewing", "review.await-review", "review.closing"])
|
|
106
110
|
})
|
|
107
111
|
|
|
112
|
+
it("the review gate accepts a clean landing", async () => {
|
|
113
|
+
const run = await handOff(reviewDoc("new add function"))
|
|
114
|
+
expect(run.gates).toEqual([true])
|
|
115
|
+
})
|
|
116
|
+
|
|
108
117
|
it("a note ends the round with the review kept and the reviewer's version copied beside it", async () => {
|
|
109
118
|
const run = await handOff(reviewDoc("new add function — rename it to sum"))
|
|
110
119
|
expect(run.log).toEqual(["review.reviewing", "review.await-review", "review.handing-off"])
|