@pmelab/gtd 27.3.0 → 27.5.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 +22 -1
- package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
- package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-DM5W_LAt.mjs} +64 -3
- package/dist/chunks/{tail-iLAMoigl.mjs → tail-6gvLXXcg.mjs} +1 -1
- package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
- package/dist/flows.mjs +27 -2
- package/dist/gtd.bundle.mjs +218 -32
- package/dist/testkit.mjs +358 -37
- package/dist/wire.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +140 -64
- 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 +346 -107
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +49 -1
- package/src/cli/Cli.ts +25 -8
- package/src/cli/History.ts +5 -0
- package/src/cli/Program.ts +1 -0
- package/src/edge/Edge.test.ts +205 -1
- package/src/edge/Edge.ts +105 -12
- package/src/emit/GitScript.test.ts +39 -3
- package/src/emit/GitScript.ts +25 -1
- package/src/emit/index.ts +2 -0
- package/src/flags/index.test.ts +14 -1
- package/src/flags/index.ts +14 -3
- package/src/flows/helpers.ts +34 -2
- package/src/flows/runtime.ts +7 -0
- package/src/install/Install.ts +2 -1
- package/src/platform/Git.test.ts +25 -1
- package/src/platform/Git.ts +30 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Format.test.ts +16 -1
- package/src/replay/Replay.test.ts +91 -0
- package/src/replay/Replay.ts +22 -2
- package/src/replay/Trailers.test.ts +110 -1
- package/src/replay/Trailers.ts +37 -1
- package/src/replay/index.ts +2 -0
- package/src/replay/judgeDiffShape.test.ts +43 -0
- package/src/replay/seamGate.test.ts +62 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/planStart.ts +3 -2
- package/src/step/planStep.test.ts +32 -0
- package/src/step/planStep.ts +3 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
- package/src/testkit/EmittedScriptRecognizer.ts +12 -0
- package/src/testkit/FakeGitOperations.ts +5 -0
- package/src/testkit/GitTiers.ts +158 -0
- package/src/testkit/InMemRepo.ts +38 -3
- package/src/testkit/TestRepo.ts +3 -0
- package/src/testkit/steps.ts +18 -0
- package/src/wire/BeatDocument.test.ts +25 -0
- package/src/wire/BeatDocument.ts +4 -2
- package/src/wire/BeatPlain.test.ts +1 -0
- package/src/wire/BeatStatus.test.ts +2 -0
- package/src/wire/BeatStatus.ts +13 -2
- package/src/wire/WireSchema.test.ts +10 -0
- package/src/wire/types.ts +8 -0
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/index.ts +8 -1
- package/src/workflow/vars.test.ts +23 -1
- package/src/workflow/vars.ts +24 -0
- package/src/workflows/feature/feature.ts +9 -2
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/seam-pin.feature +52 -0
- package/src/workflows/feature/seam.feature +387 -0
- package/src/workflows/feature/seam.test.ts +88 -0
- package/src/workflows/feature/seam.ts +110 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/options.test.ts +12 -1
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/skills.test.ts +20 -2
- package/src/workflows/shared/skills.ts +6 -0
- package/src/workflows/shared/text.test.ts +59 -0
- package/src/workflows/shared/text.ts +86 -0
- package/src/workflows/shared/vars.ts +4 -0
- package/src/workflows/shared/workflows.steps.ts +39 -1
package/src/edge/Edge.test.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { GTD_VERSION } from "../install/index.js"
|
|
|
22
22
|
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
23
23
|
import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
|
|
24
24
|
import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
|
|
25
|
-
import { contentHash } from "../workflow/index.js"
|
|
25
|
+
import { contentHash, StartFlags } from "../workflow/index.js"
|
|
26
26
|
import {
|
|
27
27
|
fakeGitOperations,
|
|
28
28
|
InMemRepo,
|
|
@@ -151,6 +151,7 @@ describe("currentRun", () => {
|
|
|
151
151
|
trace: [],
|
|
152
152
|
costEntries: [],
|
|
153
153
|
judgeVerdicts: [],
|
|
154
|
+
approvals: [],
|
|
154
155
|
pinnedVars: {},
|
|
155
156
|
headTurn: undefined,
|
|
156
157
|
closingHash: undefined,
|
|
@@ -780,6 +781,50 @@ export const defaults = { deployTarget: "npm test", reviewer: "nobody" }
|
|
|
780
781
|
})
|
|
781
782
|
})
|
|
782
783
|
|
|
784
|
+
describe("StartFlags", () => {
|
|
785
|
+
const FLAGGED = `import { agent, human } from "@pmelab/gtd/flows"
|
|
786
|
+
|
|
787
|
+
export default async () => {
|
|
788
|
+
await human("idle")
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export const side = async () => {
|
|
792
|
+
await agent("working", "w")
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export const defaults = { flag_naive_pass: "0", other: "x" }
|
|
796
|
+
`
|
|
797
|
+
const withFlags = <A>(
|
|
798
|
+
eff: Effect.Effect<A, Error, RestRequirements>,
|
|
799
|
+
repo: InMemRepo,
|
|
800
|
+
flags: Record<string, string>,
|
|
801
|
+
): Promise<A> =>
|
|
802
|
+
Effect.runPromise(
|
|
803
|
+
eff.pipe(Effect.provideService(StartFlags, flags), Effect.provide(testLayers(repo, {}))),
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
it("a starting process takes the declared flag from StartFlags, never an undeclared one", async () => {
|
|
807
|
+
const rest = await withFlags(currentRest, repoWith(FLAGGED), {
|
|
808
|
+
flag_naive_pass: "1",
|
|
809
|
+
flag_other: "1",
|
|
810
|
+
})
|
|
811
|
+
expect(rest.vars).toEqual({ flag_naive_pass: "1", other: "x" })
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
it("a running process missing the pin resolves the declared default, never the live flag", async () => {
|
|
815
|
+
const repo = repoWith(FLAGGED.replace('flag_naive_pass: "0", ', ""))
|
|
816
|
+
await enter(repo, "side")
|
|
817
|
+
// a process started before the flag existed: its opening commit lacks the pin
|
|
818
|
+
repo.writeFile("gtd.config.ts", FLAGGED)
|
|
819
|
+
const rest = await withFlags(currentRest, repo, { flag_naive_pass: "1" })
|
|
820
|
+
expect(rest.vars.flag_naive_pass).toBe("0")
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
it("with no StartFlags provided, resolves the declared default", async () => {
|
|
824
|
+
expect((await provide(currentRest, repoWith(FLAGGED))).vars.flag_naive_pass).toBe("0")
|
|
825
|
+
})
|
|
826
|
+
})
|
|
827
|
+
|
|
783
828
|
describe("environment settings", () => {
|
|
784
829
|
const ENVV = `import { agent, human } from "@pmelab/gtd/flows"
|
|
785
830
|
|
|
@@ -1306,6 +1351,28 @@ export default async () => {
|
|
|
1306
1351
|
expect(trail.steps.filter((s) => s.verdicts.length > 0)).toHaveLength(1)
|
|
1307
1352
|
})
|
|
1308
1353
|
|
|
1354
|
+
it("sets approval on the approval step only", async () => {
|
|
1355
|
+
const repo = repoWith(`import { human } from "@pmelab/gtd/flows"
|
|
1356
|
+
|
|
1357
|
+
export default async () => {
|
|
1358
|
+
await human("idle")
|
|
1359
|
+
await human("gate", { message: "seam report", approve: true })
|
|
1360
|
+
await human("after")
|
|
1361
|
+
}
|
|
1362
|
+
`)
|
|
1363
|
+
await land(repo, { "src/a.ts": "a\n" })
|
|
1364
|
+
const approval = await land(repo)
|
|
1365
|
+
const tip = await land(repo, { "src/b.ts": "b\n" })
|
|
1366
|
+
const trail = await provide(historyTrail(tip), repo)
|
|
1367
|
+
expect(trail.steps.find((s) => s.hash === approval)?.approval).toEqual({
|
|
1368
|
+
step: "gate#1",
|
|
1369
|
+
name: "Test",
|
|
1370
|
+
email: "test@test.com",
|
|
1371
|
+
commit: approval,
|
|
1372
|
+
})
|
|
1373
|
+
expect(trail.steps.filter((s) => s.approval !== undefined)).toHaveLength(1)
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1309
1376
|
it("records the version and warns about none while they match", async () => {
|
|
1310
1377
|
const { repo, tip } = await finished()
|
|
1311
1378
|
const trail = await provide(historyTrail(tip), repo)
|
|
@@ -1369,3 +1436,140 @@ export default async () => {
|
|
|
1369
1436
|
expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
|
|
1370
1437
|
})
|
|
1371
1438
|
})
|
|
1439
|
+
|
|
1440
|
+
describe("landing at an approve rest", () => {
|
|
1441
|
+
const GATED = `import { human } from "@pmelab/gtd/flows"
|
|
1442
|
+
|
|
1443
|
+
export default async () => {
|
|
1444
|
+
await human("idle")
|
|
1445
|
+
await human("gate", { message: "seam report", approve: true })
|
|
1446
|
+
await human("after")
|
|
1447
|
+
}
|
|
1448
|
+
`
|
|
1449
|
+
|
|
1450
|
+
const atGate = async (): Promise<InMemRepo> => {
|
|
1451
|
+
const repo = repoWith(GATED)
|
|
1452
|
+
await land(repo, { "src/a.ts": "a\n" })
|
|
1453
|
+
return repo
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const snapshotAt = async (repo: InMemRepo, git?: GitOperations) => {
|
|
1457
|
+
const rest = await provide(currentRest, repo)
|
|
1458
|
+
const effect = snapshotFromRest(rest)
|
|
1459
|
+
return git === undefined ? await provide(effect, repo) : await provideWithGit(effect, git, repo)
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
it("rests at a message step carrying approve", async () => {
|
|
1463
|
+
const rest = await provide(currentRest, await atGate())
|
|
1464
|
+
expect(rest.state).toBe("gate")
|
|
1465
|
+
expect(rest.stepDef).toMatchObject({ kind: "message", content: "seam report", approve: true })
|
|
1466
|
+
})
|
|
1467
|
+
|
|
1468
|
+
it("refuses a dirty tree and commits nothing", async () => {
|
|
1469
|
+
const repo = await atGate()
|
|
1470
|
+
const head = headOf(repo)
|
|
1471
|
+
repo.writeFile("src/edited.ts", "edit\n")
|
|
1472
|
+
const { landing } = await snapshotAt(repo)
|
|
1473
|
+
expect(landing).toEqual({
|
|
1474
|
+
kind: "refusal",
|
|
1475
|
+
message:
|
|
1476
|
+
"gtd land: approval-gate: gate#1 approves exactly the judged diff — revert your edits and run `gtd land` on a clean tree to approve, or run `gtd abandon`",
|
|
1477
|
+
})
|
|
1478
|
+
expect(headOf(repo)).toBe(head)
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1481
|
+
const stageThenRevert = (repo: InMemRepo): void => {
|
|
1482
|
+
repo.writeFile("src/a.ts", "edited\n")
|
|
1483
|
+
repo.stageAll()
|
|
1484
|
+
repo.writeFile("src/a.ts", "a\n")
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
it("refuses an edit staged and then reverted in the worktree", async () => {
|
|
1488
|
+
const repo = await atGate()
|
|
1489
|
+
stageThenRevert(repo)
|
|
1490
|
+
expect((await snapshotAt(repo)).landing).toMatchObject({
|
|
1491
|
+
kind: "refusal",
|
|
1492
|
+
message: expect.stringContaining("approves exactly the judged diff"),
|
|
1493
|
+
})
|
|
1494
|
+
})
|
|
1495
|
+
|
|
1496
|
+
it("counts no index-only edit at a rest that approves nothing", async () => {
|
|
1497
|
+
const repo = await atGate()
|
|
1498
|
+
await land(repo)
|
|
1499
|
+
stageThenRevert(repo)
|
|
1500
|
+
const rest = await provide(currentRest, repo)
|
|
1501
|
+
expect(rest.state).toBe("after")
|
|
1502
|
+
expect(rest.changes).toEqual([])
|
|
1503
|
+
expect((await snapshotAt(repo)).landing).toEqual({ kind: "noop", settled: false })
|
|
1504
|
+
})
|
|
1505
|
+
|
|
1506
|
+
it("refuses a clean tree when git has no author identity", async () => {
|
|
1507
|
+
const repo = await atGate()
|
|
1508
|
+
const git = { ...fakeGitOperations(repo), authorIdent: () => Effect.succeed(Option.none()) }
|
|
1509
|
+
const { landing } = await snapshotAt(repo, git)
|
|
1510
|
+
expect(landing).toEqual({
|
|
1511
|
+
kind: "refusal",
|
|
1512
|
+
message:
|
|
1513
|
+
"gtd land: approval-gate: gate#1 records who approved it, but git has no author identity — set user.name and user.email",
|
|
1514
|
+
})
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
it("refuses an identity a trailer cannot carry", async () => {
|
|
1518
|
+
const repo = await atGate()
|
|
1519
|
+
const git = {
|
|
1520
|
+
...fakeGitOperations(repo),
|
|
1521
|
+
authorIdent: () => Effect.succeed(Option.some({ name: "Ada\u001b[31m", email: "a@x.io" })),
|
|
1522
|
+
}
|
|
1523
|
+
expect((await snapshotAt(repo, git)).landing).toEqual({
|
|
1524
|
+
kind: "refusal",
|
|
1525
|
+
message:
|
|
1526
|
+
"gtd land: approval-gate: gate#1 cannot record Ada\u001b[31m <a@x.io> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)",
|
|
1527
|
+
})
|
|
1528
|
+
})
|
|
1529
|
+
|
|
1530
|
+
it("approve implies acceptClean even when acceptClean is false", async () => {
|
|
1531
|
+
const repo = repoWith(GATED.replace("approve: true", "approve: true, acceptClean: false"))
|
|
1532
|
+
await land(repo, { "src/a.ts": "a\n" })
|
|
1533
|
+
expect((await snapshotAt(repo)).landing.kind).toBe("commit")
|
|
1534
|
+
})
|
|
1535
|
+
|
|
1536
|
+
it("lands a clean tree as a commit carrying the approver", async () => {
|
|
1537
|
+
const repo = await atGate()
|
|
1538
|
+
const { landing } = await snapshotAt(repo)
|
|
1539
|
+
if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
|
|
1540
|
+
expect(landing.to).toBe("after")
|
|
1541
|
+
expect(landing.spec.approved).toEqual({
|
|
1542
|
+
step: { name: "gate", occurrence: 1 },
|
|
1543
|
+
name: "Test",
|
|
1544
|
+
email: "test@test.com",
|
|
1545
|
+
})
|
|
1546
|
+
})
|
|
1547
|
+
|
|
1548
|
+
it("reads the author identity only at an approve rest", async () => {
|
|
1549
|
+
const repo = repoWith(GATED)
|
|
1550
|
+
repo.writeFile("src/a.ts", "a\n")
|
|
1551
|
+
const git = {
|
|
1552
|
+
...fakeGitOperations(repo),
|
|
1553
|
+
authorIdent: () => Effect.die(new Error("authorIdent read at a plain rest")),
|
|
1554
|
+
}
|
|
1555
|
+
expect((await snapshotAt(repo, git)).landing.kind).toBe("commit")
|
|
1556
|
+
})
|
|
1557
|
+
|
|
1558
|
+
it("collects each approval of the current process, oldest first", async () => {
|
|
1559
|
+
const repo = await atGate()
|
|
1560
|
+
expect((await provide(currentRun, repo)).approvals).toEqual([])
|
|
1561
|
+
const approval = await land(repo)
|
|
1562
|
+
expect((await provide(currentRun, repo)).approvals).toEqual([
|
|
1563
|
+
{ step: "gate#1", name: "Test", email: "test@test.com", commit: approval },
|
|
1564
|
+
])
|
|
1565
|
+
})
|
|
1566
|
+
|
|
1567
|
+
it("resumes past the approval commit without asking again", async () => {
|
|
1568
|
+
const repo = await atGate()
|
|
1569
|
+
await land(repo)
|
|
1570
|
+
expect(repo.lastCommitMessage()).toContain(
|
|
1571
|
+
'Gtd-Approved: {"step":"gate#1","name":"Test","email":"test@test.com"}',
|
|
1572
|
+
)
|
|
1573
|
+
expect((await provide(currentRest, repo)).state).toBe("after")
|
|
1574
|
+
})
|
|
1575
|
+
})
|
package/src/edge/Edge.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
GitService,
|
|
5
5
|
Host,
|
|
6
6
|
Workspace,
|
|
7
|
+
type AuthorIdent,
|
|
7
8
|
type GitOperations,
|
|
8
9
|
type HostOps,
|
|
9
10
|
type WorkspaceOps,
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
contentHash,
|
|
16
17
|
multilineSetting,
|
|
17
18
|
resolveVars,
|
|
19
|
+
resolveStartVars,
|
|
20
|
+
StartFlags,
|
|
18
21
|
resolveScopeAccess,
|
|
19
22
|
resolveScopeSkills,
|
|
20
23
|
type ConfigFingerprint,
|
|
@@ -29,7 +32,9 @@ import {
|
|
|
29
32
|
} from "../workflow/index.js"
|
|
30
33
|
import {
|
|
31
34
|
formatFault,
|
|
35
|
+
formatStepId,
|
|
32
36
|
formatSubject,
|
|
37
|
+
isRecordableApproval,
|
|
33
38
|
parseCommitMessage,
|
|
34
39
|
replay,
|
|
35
40
|
treeFromRecord,
|
|
@@ -41,7 +46,14 @@ import {
|
|
|
41
46
|
} from "../replay/index.js"
|
|
42
47
|
import type { ScopeAccess } from "../flows/index.js"
|
|
43
48
|
import { steeringFormatFor } from "../steering/index.js"
|
|
44
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
judgeJson,
|
|
51
|
+
UNATTRIBUTED_MODEL,
|
|
52
|
+
type ApprovalEntry,
|
|
53
|
+
type ModelCost,
|
|
54
|
+
type StepAccess,
|
|
55
|
+
} from "../wire/index.js"
|
|
56
|
+
|
|
45
57
|
import { GTD_VERSION } from "../install/index.js"
|
|
46
58
|
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
47
59
|
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
@@ -141,6 +153,8 @@ export interface ProcessRun {
|
|
|
141
153
|
readonly trace: readonly TraceEntry[]
|
|
142
154
|
readonly costEntries: readonly CostEntry[]
|
|
143
155
|
readonly judgeVerdicts: readonly JudgeVerdictEntry[]
|
|
156
|
+
/** Oldest first. */
|
|
157
|
+
readonly approvals: readonly ApprovalEntry[]
|
|
144
158
|
/** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
|
|
145
159
|
readonly pinnedVars: Record<string, string>
|
|
146
160
|
/** Absent for a process started before recording existed. */
|
|
@@ -228,6 +242,21 @@ const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
|
|
|
228
242
|
return undefined
|
|
229
243
|
}
|
|
230
244
|
|
|
245
|
+
const approvalEntryOf = (
|
|
246
|
+
hash: string,
|
|
247
|
+
message: ReturnType<typeof parseCommitMessage>,
|
|
248
|
+
): readonly ApprovalEntry[] =>
|
|
249
|
+
message.approved === undefined
|
|
250
|
+
? []
|
|
251
|
+
: [
|
|
252
|
+
{
|
|
253
|
+
step: formatStepId(message.approved.step),
|
|
254
|
+
name: message.approved.name,
|
|
255
|
+
email: message.approved.email,
|
|
256
|
+
commit: hash,
|
|
257
|
+
},
|
|
258
|
+
]
|
|
259
|
+
|
|
231
260
|
const buildRun = (
|
|
232
261
|
history: History,
|
|
233
262
|
location: EpisodeLocation,
|
|
@@ -247,6 +276,7 @@ const buildRun = (
|
|
|
247
276
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
248
277
|
costEntries: parsed.flatMap(costEntriesOf),
|
|
249
278
|
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
279
|
+
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
|
|
250
280
|
pinnedVars: { ...first?.vars },
|
|
251
281
|
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
252
282
|
headTurn: headTurnOf(head),
|
|
@@ -534,9 +564,11 @@ const settingsFor = (
|
|
|
534
564
|
pinnedVars: Readonly<Record<string, string>>,
|
|
535
565
|
hostEnv: Readonly<Record<string, string | undefined>>,
|
|
536
566
|
startOverrides: Readonly<Record<string, string>> = {},
|
|
567
|
+
/** The live flags a STARTING process pins; a running one passes `{}` and replays its pins. */
|
|
568
|
+
startFlags: Readonly<Record<string, string>>,
|
|
537
569
|
): { readonly vars: Record<string, string>; readonly env: Record<string, string> } => ({
|
|
538
570
|
vars: {
|
|
539
|
-
...
|
|
571
|
+
...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
|
|
540
572
|
...pinnedVars,
|
|
541
573
|
},
|
|
542
574
|
env: resolveVars(workflow.env, {}, hostEnv),
|
|
@@ -729,7 +761,8 @@ const messageDef = (
|
|
|
729
761
|
...common,
|
|
730
762
|
kind: "message",
|
|
731
763
|
content: request.options.message ?? request.options.label ?? step.name,
|
|
732
|
-
...optional("acceptClean", request.options.acceptClean),
|
|
764
|
+
...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
|
|
765
|
+
...optional("approve", request.options.approve),
|
|
733
766
|
})
|
|
734
767
|
|
|
735
768
|
const stepDefOf = (step: ReachedStep): StepDef => {
|
|
@@ -883,7 +916,14 @@ const setupFor = (
|
|
|
883
916
|
Effect.gen(function* () {
|
|
884
917
|
const resolved = yield* workflowOf(config, run)
|
|
885
918
|
const def = resolved.def
|
|
886
|
-
const
|
|
919
|
+
const startFlags = run.trace.length === 0 ? yield* StartFlags : {}
|
|
920
|
+
const { vars, env } = settingsFor(
|
|
921
|
+
resolved,
|
|
922
|
+
run.pinnedVars,
|
|
923
|
+
host.env,
|
|
924
|
+
startOverrides,
|
|
925
|
+
startFlags,
|
|
926
|
+
)
|
|
887
927
|
const skills = yield* resolveScopeSkills(def, vars)
|
|
888
928
|
const access = yield* resolveScopeAccess(def, skills, vars)
|
|
889
929
|
const budget = yield* Effect.try({
|
|
@@ -933,6 +973,17 @@ const setupFor = (
|
|
|
933
973
|
return { resolved, def, vars, env, setup }
|
|
934
974
|
})
|
|
935
975
|
|
|
976
|
+
// Only an approval gate counts an index-only edit (one staged, then reverted
|
|
977
|
+
// in the worktree): its approval must cover exactly the judged diff. Every
|
|
978
|
+
// other rest keeps the worktree's view, so a net-zero turn stays an attempt.
|
|
979
|
+
const pendingChanges = (git: GitOperations, withIndex: boolean) =>
|
|
980
|
+
Effect.gen(function* () {
|
|
981
|
+
const worktree = yield* git.changedPaths()
|
|
982
|
+
if (!withIndex) return worktree
|
|
983
|
+
const seen = new Set(worktree.map((e) => e.path))
|
|
984
|
+
return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))]
|
|
985
|
+
})
|
|
986
|
+
|
|
936
987
|
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
937
988
|
export const restAt = (
|
|
938
989
|
ref: string | undefined,
|
|
@@ -974,7 +1025,7 @@ export const restAt = (
|
|
|
974
1025
|
}
|
|
975
1026
|
const changes =
|
|
976
1027
|
ref === undefined
|
|
977
|
-
? (yield* git.
|
|
1028
|
+
? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
|
|
978
1029
|
status: normalizeStatus(e.status),
|
|
979
1030
|
path: e.path,
|
|
980
1031
|
}))
|
|
@@ -1027,7 +1078,7 @@ export const startRefusal = (
|
|
|
1027
1078
|
> =>
|
|
1028
1079
|
Effect.gen(function* () {
|
|
1029
1080
|
const host = yield* Host
|
|
1030
|
-
const { vars, env } = settingsFor(resolved, {}, host.env, startVars)
|
|
1081
|
+
const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags)
|
|
1031
1082
|
const skills = yield* resolveScopeSkills(resolved.def, vars)
|
|
1032
1083
|
const access = yield* resolveScopeAccess(resolved.def, skills, vars)
|
|
1033
1084
|
const workspace = rest.setup.workspace
|
|
@@ -1107,6 +1158,32 @@ export const restIsIdle = (rest: Rest): boolean =>
|
|
|
1107
1158
|
|
|
1108
1159
|
const isHumanReviewGate = (def: StepDef): boolean => def.actor === "human" && def.mode === "review"
|
|
1109
1160
|
|
|
1161
|
+
// An approval covers exactly the judged diff, so any edit refuses it; the
|
|
1162
|
+
// approver is read only at such a rest, and must be a name a trailer can carry.
|
|
1163
|
+
const approvalRefusal = (rest: Rest, approver: Option.Option<AuthorIdent>): Landing | undefined => {
|
|
1164
|
+
if (rest.stepDef.approve !== true) return undefined
|
|
1165
|
+
const id = rest.step.id
|
|
1166
|
+
if (rest.changes.length > 0) {
|
|
1167
|
+
return {
|
|
1168
|
+
kind: "refusal",
|
|
1169
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} approves exactly the judged diff — revert your edits and run \`gtd land\` on a clean tree to approve, or run \`gtd abandon\``,
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (Option.isNone(approver)) {
|
|
1173
|
+
return {
|
|
1174
|
+
kind: "refusal",
|
|
1175
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`,
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (!isRecordableApproval({ step: id, ...approver.value })) {
|
|
1179
|
+
return {
|
|
1180
|
+
kind: "refusal",
|
|
1181
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} cannot record ${approver.value.name} <${approver.value.email}> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)`,
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return undefined
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1110
1187
|
/** The landing a clean tree decides before any replay, if it decides one. */
|
|
1111
1188
|
const cleanLanding = (rest: Rest): Landing | undefined => {
|
|
1112
1189
|
if (rest.changes.length > 0) return undefined
|
|
@@ -1182,7 +1259,15 @@ const keptTurn = (rest: Rest) =>
|
|
|
1182
1259
|
? { keepTurn: keptRef(rest.run.startHash, rest.state) }
|
|
1183
1260
|
: {}
|
|
1184
1261
|
|
|
1185
|
-
const
|
|
1262
|
+
const approvedTrailer = (rest: Rest, approver: Option.Option<AuthorIdent>) =>
|
|
1263
|
+
Option.isSome(approver) ? { approved: { step: rest.step.id, ...approver.value } } : {}
|
|
1264
|
+
|
|
1265
|
+
const landingTo = (
|
|
1266
|
+
rest: Rest,
|
|
1267
|
+
to: StateName,
|
|
1268
|
+
branch: Option.Option<string>,
|
|
1269
|
+
approver: Option.Option<AuthorIdent>,
|
|
1270
|
+
): Landing => {
|
|
1186
1271
|
if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
|
|
1187
1272
|
// The landing that leaves the flow's first step starts the process: it
|
|
1188
1273
|
// pins the process settings every later invocation reads back.
|
|
@@ -1202,6 +1287,7 @@ const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): La
|
|
|
1202
1287
|
from: rest.state,
|
|
1203
1288
|
to,
|
|
1204
1289
|
step: rest.step.id,
|
|
1290
|
+
...approvedTrailer(rest, approver),
|
|
1205
1291
|
...(starts ? { vars: rest.vars, config: rest.config } : {}),
|
|
1206
1292
|
...(finishes ? closingTrailers(branch) : {}),
|
|
1207
1293
|
},
|
|
@@ -1219,8 +1305,11 @@ const decideLanding = (
|
|
|
1219
1305
|
rest: Rest,
|
|
1220
1306
|
verdicts: readonly JudgeVerdict[] | undefined,
|
|
1221
1307
|
branch: Option.Option<string>,
|
|
1308
|
+
approver: Option.Option<AuthorIdent>,
|
|
1222
1309
|
): Effect.Effect<Landing, Error> =>
|
|
1223
1310
|
Effect.gen(function* () {
|
|
1311
|
+
const unapproved = approvalRefusal(rest, approver)
|
|
1312
|
+
if (unapproved !== undefined) return unapproved
|
|
1224
1313
|
const early = cleanLanding(rest)
|
|
1225
1314
|
if (early !== undefined) return early
|
|
1226
1315
|
if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
|
|
@@ -1239,12 +1328,12 @@ const decideLanding = (
|
|
|
1239
1328
|
)
|
|
1240
1329
|
const to = yield* landingTarget(outcome, rest)
|
|
1241
1330
|
if (typeof to !== "string") return to
|
|
1242
|
-
return landingTo(rest, to, branch)
|
|
1331
|
+
return landingTo(rest, to, branch, approver)
|
|
1243
1332
|
})
|
|
1244
1333
|
|
|
1245
1334
|
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
1246
1335
|
export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
|
|
1247
|
-
decideLanding(rest, undefined, Option.none()).pipe(
|
|
1336
|
+
decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
|
|
1248
1337
|
Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
|
|
1249
1338
|
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
1250
1339
|
)
|
|
@@ -1255,8 +1344,10 @@ export const snapshotFromRest = (
|
|
|
1255
1344
|
verdicts?: readonly JudgeVerdict[],
|
|
1256
1345
|
): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
|
|
1257
1346
|
Effect.gen(function* () {
|
|
1258
|
-
const
|
|
1259
|
-
const
|
|
1347
|
+
const git = yield* GitService
|
|
1348
|
+
const branch = yield* git.currentBranch()
|
|
1349
|
+
const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : Option.none()
|
|
1350
|
+
const landing = yield* decideLanding(rest, verdicts, branch, approver)
|
|
1260
1351
|
return {
|
|
1261
1352
|
stepDef: rest.stepDef,
|
|
1262
1353
|
state: rest.state,
|
|
@@ -1289,7 +1380,7 @@ export const summaryFor = (
|
|
|
1289
1380
|
.map((entry) => ({ hash: entry.hash, state: entry.state })),
|
|
1290
1381
|
processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
|
|
1291
1382
|
processCostByModel: costByModel(run.costEntries),
|
|
1292
|
-
...settingsFor(resolved, run.pinnedVars, host.env),
|
|
1383
|
+
...settingsFor(resolved, run.pinnedVars, host.env, {}, {}),
|
|
1293
1384
|
})
|
|
1294
1385
|
})
|
|
1295
1386
|
|
|
@@ -1300,6 +1391,7 @@ export interface HistoryStep {
|
|
|
1300
1391
|
readonly from: StateName | undefined
|
|
1301
1392
|
readonly to: StateName
|
|
1302
1393
|
readonly verdicts: readonly JudgeVerdict[]
|
|
1394
|
+
readonly approval?: ApprovalEntry
|
|
1303
1395
|
}
|
|
1304
1396
|
|
|
1305
1397
|
export interface HistoryTrail {
|
|
@@ -1371,6 +1463,7 @@ const historySteps = (run: ProcessRun): readonly HistoryStep[] => {
|
|
|
1371
1463
|
from: message.parsed?.from,
|
|
1372
1464
|
to: entry.state,
|
|
1373
1465
|
verdicts: message.judge,
|
|
1466
|
+
...optional("approval", approvalEntryOf(entry.hash, message)[0]),
|
|
1374
1467
|
}
|
|
1375
1468
|
})
|
|
1376
1469
|
}
|
|
@@ -7,6 +7,7 @@ import fc from "fast-check"
|
|
|
7
7
|
import {
|
|
8
8
|
commitAll,
|
|
9
9
|
commitAsIs,
|
|
10
|
+
commitClean,
|
|
10
11
|
deleteRef,
|
|
11
12
|
discardPending,
|
|
12
13
|
fetchHistoryRefs,
|
|
@@ -70,6 +71,7 @@ const runWithFakeGit = (script: string, binDir: string): { status: number | null
|
|
|
70
71
|
const allBuilders: ReadonlyArray<[string, string]> = [
|
|
71
72
|
["commitAll", commitAll("gtd(agent): working")],
|
|
72
73
|
["commitAsIs", commitAsIs("gtd(agent): working")],
|
|
74
|
+
["commitClean", commitClean("gtd(human): gate → again")],
|
|
73
75
|
["softResetTo", softResetTo("HEAD~1")],
|
|
74
76
|
["mixedResetTo", mixedResetTo("HEAD~1")],
|
|
75
77
|
["hardResetTo", hardResetTo("HEAD~1")],
|
|
@@ -104,6 +106,40 @@ describe("commitAsIs", () => {
|
|
|
104
106
|
})
|
|
105
107
|
})
|
|
106
108
|
|
|
109
|
+
describe("commitClean", () => {
|
|
110
|
+
const logGit = (body: string): { binDir: string; log: string } => {
|
|
111
|
+
const dir = mkdtempSync(join(tmpdir(), "gtd-log-git-"))
|
|
112
|
+
const log = join(dir, "log")
|
|
113
|
+
const gitPath = join(dir, "git")
|
|
114
|
+
writeFileSync(gitPath, `#!/bin/sh\necho "$*" >> '${log}'\n${body}\n`)
|
|
115
|
+
chmodSync(gitPath, 0o755)
|
|
116
|
+
return { binDir: dir, log }
|
|
117
|
+
}
|
|
118
|
+
const readLog = (log: string): string[] =>
|
|
119
|
+
execFileSync("cat", [log], { encoding: "utf8" }).trim().split("\n")
|
|
120
|
+
const cases = `case "$1" in
|
|
121
|
+
rev-parse) echo head0 ;;
|
|
122
|
+
status) ;;
|
|
123
|
+
config) echo \${GTD_CFG:-false} ;;
|
|
124
|
+
commit-tree) echo commit0 ;;
|
|
125
|
+
esac`
|
|
126
|
+
|
|
127
|
+
it("a dirty tree exits 1 before any commit", () => {
|
|
128
|
+
const { binDir, counterFile } = withFakeGit(`echo head0`, `echo " M a.txt"`)
|
|
129
|
+
const result = runWithFakeGit(commitClean("msg"), binDir)
|
|
130
|
+
expect(result.status).toBe(1)
|
|
131
|
+
expect(execFileSync("cat", [counterFile], { encoding: "utf8" }).trim()).toBe("2")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("a refused update-ref (HEAD moved) exits 1; the write is guarded by the pinned HEAD", () => {
|
|
135
|
+
const { binDir, log } = logGit(`${cases}\nif [ "$1" = update-ref ]; then exit 1; fi`)
|
|
136
|
+
const result = runWithFakeGit(commitClean("msg"), binDir)
|
|
137
|
+
expect(result.status).toBe(1)
|
|
138
|
+
const lines = readLog(log)
|
|
139
|
+
expect(lines[lines.length - 1]).toBe("update-ref -m commit: msg HEAD commit0 head0")
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
107
143
|
describe("commitAllowEmpty retry discrimination (via commitAsIs)", () => {
|
|
108
144
|
it("retries without hooks on the hook's own empty-git-commit rejection", () => {
|
|
109
145
|
const { binDir, counterFile } = withFakeGit(
|
|
@@ -304,7 +340,7 @@ describe("keepTurn", () => {
|
|
|
304
340
|
` write-tree) echo tree0 ;;`,
|
|
305
341
|
` commit-tree) echo commit0 ;;`,
|
|
306
342
|
` config) echo false ;;`,
|
|
307
|
-
` update-ref) [ "$
|
|
343
|
+
` update-ref) [ "$4" = HEAD ] && exit 1; exit 0 ;;`,
|
|
308
344
|
` *) exit 0 ;;`,
|
|
309
345
|
`esac`,
|
|
310
346
|
].join("\n"),
|
|
@@ -313,7 +349,7 @@ describe("keepTurn", () => {
|
|
|
313
349
|
const result = runWithFakeGit(`set -eu\n${block}`, binDir)
|
|
314
350
|
expect(result.status).toBe(1)
|
|
315
351
|
const calls = readFileSync(log, "utf8").trim().split("\n")
|
|
316
|
-
expect(calls).toContain("update-ref HEAD commit0 head0")
|
|
352
|
+
expect(calls).toContain("update-ref -m commit: gtd(agent): done HEAD commit0 head0")
|
|
317
353
|
const resetAt = calls.indexOf("reset -q")
|
|
318
354
|
expect(resetAt).toBeGreaterThan(-1)
|
|
319
355
|
expect(calls.slice(resetAt + 1)).toEqual([
|
|
@@ -335,7 +371,7 @@ describe("keepTurn", () => {
|
|
|
335
371
|
` write-tree) echo tree0 ;;`,
|
|
336
372
|
` commit-tree) echo commit0 ;;`,
|
|
337
373
|
` config) echo false ;;`,
|
|
338
|
-
` update-ref) [ "$
|
|
374
|
+
` update-ref) [ "$4" = HEAD ] && exit 1; exit 0 ;;`,
|
|
339
375
|
` reset) exit 1 ;;`,
|
|
340
376
|
` *) exit 0 ;;`,
|
|
341
377
|
`esac`,
|
package/src/emit/GitScript.ts
CHANGED
|
@@ -24,10 +24,32 @@ const commitAllowEmpty = (message: string): string => {
|
|
|
24
24
|
].join("\n")
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export const CHANGED_SINCE_LAND =
|
|
28
|
+
"gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve"
|
|
29
|
+
|
|
27
30
|
/** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
|
|
28
31
|
export const commitAll = (message: string): string =>
|
|
29
32
|
`git add -A || exit 1\n${commitAllowEmpty(message)}`
|
|
30
33
|
|
|
34
|
+
/**
|
|
35
|
+
* An approval records exactly the judged tree: HEAD is pinned before the
|
|
36
|
+
* clean check and the commit is built over its tree with `commit-tree`, so no
|
|
37
|
+
* hook runs and none can add to it. A HEAD that moved fails the guarded
|
|
38
|
+
* `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
|
|
39
|
+
*/
|
|
40
|
+
export const commitClean = (message: string): string =>
|
|
41
|
+
[
|
|
42
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
43
|
+
`if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
|
|
44
|
+
` printf '%s\\n' ${shellQuote(CHANGED_SINCE_LAND)} >&2`,
|
|
45
|
+
` exit 1`,
|
|
46
|
+
`fi`,
|
|
47
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
48
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
49
|
+
`gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
50
|
+
`git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`,
|
|
51
|
+
].join("\n")
|
|
52
|
+
|
|
31
53
|
/** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
|
|
32
54
|
export const commitAsIs = (message: string): string => commitAllowEmpty(message)
|
|
33
55
|
|
|
@@ -70,7 +92,7 @@ export const keepTurn = (refPrefix: string, message: string): string => {
|
|
|
70
92
|
`gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
71
93
|
`git update-ref ${keptRef} "$gtd_kept" || exit 1`,
|
|
72
94
|
`gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
|
|
73
|
-
`if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref HEAD "$gtd_tip" "$gtd_head"; then`,
|
|
95
|
+
`if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
|
|
74
96
|
` git reset -q && git update-ref -d ${keptRef}`,
|
|
75
97
|
` exit 1`,
|
|
76
98
|
`fi`,
|
|
@@ -129,6 +151,8 @@ const renderGitWrite = (write: GitWrite): string => {
|
|
|
129
151
|
switch (write.kind) {
|
|
130
152
|
case "commitAll":
|
|
131
153
|
return commitAll(write.message)
|
|
154
|
+
case "commitClean":
|
|
155
|
+
return commitClean(write.message)
|
|
132
156
|
case "updateRef":
|
|
133
157
|
return updateRef(write.ref, write.target)
|
|
134
158
|
case "keepTurn":
|
package/src/emit/index.ts
CHANGED
package/src/flags/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { FLAGS, resolveFlags, type FlagDeclaration, type Flags } from "./index.js"
|
|
2
|
+
import { FLAGS, flagSettings, resolveFlags, type FlagDeclaration, type Flags } from "./index.js"
|
|
3
3
|
|
|
4
4
|
const registry = [
|
|
5
5
|
{ name: "alpha", owner: "pmelab", expiry: "2020-01-01", kind: "release" },
|
|
@@ -92,4 +92,17 @@ describe("FLAGS", () => {
|
|
|
92
92
|
})
|
|
93
93
|
expect(new Set(names).size).toBe(names.length)
|
|
94
94
|
})
|
|
95
|
+
|
|
96
|
+
it("leaves naive_pass off by default", () => {
|
|
97
|
+
expect(resolveFlags({}).naive_pass).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("flagSettings", () => {
|
|
102
|
+
it('maps every flag to "1" or "0" under flag_<name>', () => {
|
|
103
|
+
expect(flagSettings({ alpha: true, new_review: false })).toEqual({
|
|
104
|
+
flag_alpha: "1",
|
|
105
|
+
flag_new_review: "0",
|
|
106
|
+
})
|
|
107
|
+
})
|
|
95
108
|
})
|