@pmelab/gtd 27.3.0 → 27.4.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 +14 -1
- package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
- package/dist/flows.mjs +24 -1
- package/dist/gtd.bundle.mjs +174 -23
- package/dist/testkit.mjs +315 -27
- package/dist/wire.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.ts +5 -0
- package/src/cli/Program.ts +1 -0
- package/src/edge/Edge.test.ts +160 -0
- package/src/edge/Edge.ts +90 -8
- package/src/emit/GitScript.test.ts +39 -3
- package/src/emit/GitScript.ts +25 -1
- package/src/emit/index.ts +2 -0
- package/src/flows/helpers.ts +28 -0
- 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/seamGate.test.ts +62 -0
- package/src/step/LandStep.ts +1 -0
- 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/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/dist/wire.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-
|
|
1
|
+
import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-C12dahrH.mjs";
|
|
2
2
|
export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, resolveSession, selectPath, stallDiagnosis, statusOf };
|
package/package.json
CHANGED
package/src/cli/Cli.ts
CHANGED
|
@@ -705,11 +705,11 @@ const COMMAND_ROWS: readonly CommandRow[] = [
|
|
|
705
705
|
arity: { name: "ref" },
|
|
706
706
|
details: [
|
|
707
707
|
"Replay one kept process under today's config and print its",
|
|
708
|
-
"steps
|
|
709
|
-
"(or unique prefix) or a branch name. Refuses
|
|
710
|
-
"config file differs from its content at the
|
|
711
|
-
"version mismatch only warns on stderr. --json
|
|
712
|
-
"trail as one document; writes nothing",
|
|
708
|
+
"steps, judge verdicts and approvals. <ref> is a full ref, an",
|
|
709
|
+
"entry hash (or unique prefix) or a branch name. Refuses",
|
|
710
|
+
"(exit 1) when a config file differs from its content at the",
|
|
711
|
+
"ref's tip. A version mismatch only warns on stderr. --json",
|
|
712
|
+
"prints the trail as one document; writes nothing",
|
|
713
713
|
],
|
|
714
714
|
},
|
|
715
715
|
{
|
package/src/cli/History.ts
CHANGED
|
@@ -128,6 +128,11 @@ export const runHistoryShowCommand = (
|
|
|
128
128
|
for (const step of trail.steps) {
|
|
129
129
|
lines.push(`${seven(step.hash)} ${step.subject}`)
|
|
130
130
|
for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`)
|
|
131
|
+
if (step.approval !== undefined) {
|
|
132
|
+
lines.push(
|
|
133
|
+
` approved ${step.approval.step} by ${step.approval.name} <${step.approval.email}>`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
131
136
|
}
|
|
132
137
|
lines.push(`ended (${trail.ended})`)
|
|
133
138
|
out.write(`${lines.join("\n")}\n`)
|
package/src/cli/Program.ts
CHANGED
package/src/edge/Edge.test.ts
CHANGED
|
@@ -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,
|
|
@@ -1306,6 +1307,28 @@ export default async () => {
|
|
|
1306
1307
|
expect(trail.steps.filter((s) => s.verdicts.length > 0)).toHaveLength(1)
|
|
1307
1308
|
})
|
|
1308
1309
|
|
|
1310
|
+
it("sets approval on the approval step only", async () => {
|
|
1311
|
+
const repo = repoWith(`import { human } from "@pmelab/gtd/flows"
|
|
1312
|
+
|
|
1313
|
+
export default async () => {
|
|
1314
|
+
await human("idle")
|
|
1315
|
+
await human("gate", { message: "seam report", approve: true })
|
|
1316
|
+
await human("after")
|
|
1317
|
+
}
|
|
1318
|
+
`)
|
|
1319
|
+
await land(repo, { "src/a.ts": "a\n" })
|
|
1320
|
+
const approval = await land(repo)
|
|
1321
|
+
const tip = await land(repo, { "src/b.ts": "b\n" })
|
|
1322
|
+
const trail = await provide(historyTrail(tip), repo)
|
|
1323
|
+
expect(trail.steps.find((s) => s.hash === approval)?.approval).toEqual({
|
|
1324
|
+
step: "gate#1",
|
|
1325
|
+
name: "Test",
|
|
1326
|
+
email: "test@test.com",
|
|
1327
|
+
commit: approval,
|
|
1328
|
+
})
|
|
1329
|
+
expect(trail.steps.filter((s) => s.approval !== undefined)).toHaveLength(1)
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1309
1332
|
it("records the version and warns about none while they match", async () => {
|
|
1310
1333
|
const { repo, tip } = await finished()
|
|
1311
1334
|
const trail = await provide(historyTrail(tip), repo)
|
|
@@ -1369,3 +1392,140 @@ export default async () => {
|
|
|
1369
1392
|
expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
|
|
1370
1393
|
})
|
|
1371
1394
|
})
|
|
1395
|
+
|
|
1396
|
+
describe("landing at an approve rest", () => {
|
|
1397
|
+
const GATED = `import { human } from "@pmelab/gtd/flows"
|
|
1398
|
+
|
|
1399
|
+
export default async () => {
|
|
1400
|
+
await human("idle")
|
|
1401
|
+
await human("gate", { message: "seam report", approve: true })
|
|
1402
|
+
await human("after")
|
|
1403
|
+
}
|
|
1404
|
+
`
|
|
1405
|
+
|
|
1406
|
+
const atGate = async (): Promise<InMemRepo> => {
|
|
1407
|
+
const repo = repoWith(GATED)
|
|
1408
|
+
await land(repo, { "src/a.ts": "a\n" })
|
|
1409
|
+
return repo
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const snapshotAt = async (repo: InMemRepo, git?: GitOperations) => {
|
|
1413
|
+
const rest = await provide(currentRest, repo)
|
|
1414
|
+
const effect = snapshotFromRest(rest)
|
|
1415
|
+
return git === undefined ? await provide(effect, repo) : await provideWithGit(effect, git, repo)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
it("rests at a message step carrying approve", async () => {
|
|
1419
|
+
const rest = await provide(currentRest, await atGate())
|
|
1420
|
+
expect(rest.state).toBe("gate")
|
|
1421
|
+
expect(rest.stepDef).toMatchObject({ kind: "message", content: "seam report", approve: true })
|
|
1422
|
+
})
|
|
1423
|
+
|
|
1424
|
+
it("refuses a dirty tree and commits nothing", async () => {
|
|
1425
|
+
const repo = await atGate()
|
|
1426
|
+
const head = headOf(repo)
|
|
1427
|
+
repo.writeFile("src/edited.ts", "edit\n")
|
|
1428
|
+
const { landing } = await snapshotAt(repo)
|
|
1429
|
+
expect(landing).toEqual({
|
|
1430
|
+
kind: "refusal",
|
|
1431
|
+
message:
|
|
1432
|
+
"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`",
|
|
1433
|
+
})
|
|
1434
|
+
expect(headOf(repo)).toBe(head)
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
const stageThenRevert = (repo: InMemRepo): void => {
|
|
1438
|
+
repo.writeFile("src/a.ts", "edited\n")
|
|
1439
|
+
repo.stageAll()
|
|
1440
|
+
repo.writeFile("src/a.ts", "a\n")
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
it("refuses an edit staged and then reverted in the worktree", async () => {
|
|
1444
|
+
const repo = await atGate()
|
|
1445
|
+
stageThenRevert(repo)
|
|
1446
|
+
expect((await snapshotAt(repo)).landing).toMatchObject({
|
|
1447
|
+
kind: "refusal",
|
|
1448
|
+
message: expect.stringContaining("approves exactly the judged diff"),
|
|
1449
|
+
})
|
|
1450
|
+
})
|
|
1451
|
+
|
|
1452
|
+
it("counts no index-only edit at a rest that approves nothing", async () => {
|
|
1453
|
+
const repo = await atGate()
|
|
1454
|
+
await land(repo)
|
|
1455
|
+
stageThenRevert(repo)
|
|
1456
|
+
const rest = await provide(currentRest, repo)
|
|
1457
|
+
expect(rest.state).toBe("after")
|
|
1458
|
+
expect(rest.changes).toEqual([])
|
|
1459
|
+
expect((await snapshotAt(repo)).landing).toEqual({ kind: "noop", settled: false })
|
|
1460
|
+
})
|
|
1461
|
+
|
|
1462
|
+
it("refuses a clean tree when git has no author identity", async () => {
|
|
1463
|
+
const repo = await atGate()
|
|
1464
|
+
const git = { ...fakeGitOperations(repo), authorIdent: () => Effect.succeed(Option.none()) }
|
|
1465
|
+
const { landing } = await snapshotAt(repo, git)
|
|
1466
|
+
expect(landing).toEqual({
|
|
1467
|
+
kind: "refusal",
|
|
1468
|
+
message:
|
|
1469
|
+
"gtd land: approval-gate: gate#1 records who approved it, but git has no author identity — set user.name and user.email",
|
|
1470
|
+
})
|
|
1471
|
+
})
|
|
1472
|
+
|
|
1473
|
+
it("refuses an identity a trailer cannot carry", async () => {
|
|
1474
|
+
const repo = await atGate()
|
|
1475
|
+
const git = {
|
|
1476
|
+
...fakeGitOperations(repo),
|
|
1477
|
+
authorIdent: () => Effect.succeed(Option.some({ name: "Ada\u001b[31m", email: "a@x.io" })),
|
|
1478
|
+
}
|
|
1479
|
+
expect((await snapshotAt(repo, git)).landing).toEqual({
|
|
1480
|
+
kind: "refusal",
|
|
1481
|
+
message:
|
|
1482
|
+
"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)",
|
|
1483
|
+
})
|
|
1484
|
+
})
|
|
1485
|
+
|
|
1486
|
+
it("approve implies acceptClean even when acceptClean is false", async () => {
|
|
1487
|
+
const repo = repoWith(GATED.replace("approve: true", "approve: true, acceptClean: false"))
|
|
1488
|
+
await land(repo, { "src/a.ts": "a\n" })
|
|
1489
|
+
expect((await snapshotAt(repo)).landing.kind).toBe("commit")
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
it("lands a clean tree as a commit carrying the approver", async () => {
|
|
1493
|
+
const repo = await atGate()
|
|
1494
|
+
const { landing } = await snapshotAt(repo)
|
|
1495
|
+
if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
|
|
1496
|
+
expect(landing.to).toBe("after")
|
|
1497
|
+
expect(landing.spec.approved).toEqual({
|
|
1498
|
+
step: { name: "gate", occurrence: 1 },
|
|
1499
|
+
name: "Test",
|
|
1500
|
+
email: "test@test.com",
|
|
1501
|
+
})
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
it("reads the author identity only at an approve rest", async () => {
|
|
1505
|
+
const repo = repoWith(GATED)
|
|
1506
|
+
repo.writeFile("src/a.ts", "a\n")
|
|
1507
|
+
const git = {
|
|
1508
|
+
...fakeGitOperations(repo),
|
|
1509
|
+
authorIdent: () => Effect.die(new Error("authorIdent read at a plain rest")),
|
|
1510
|
+
}
|
|
1511
|
+
expect((await snapshotAt(repo, git)).landing.kind).toBe("commit")
|
|
1512
|
+
})
|
|
1513
|
+
|
|
1514
|
+
it("collects each approval of the current process, oldest first", async () => {
|
|
1515
|
+
const repo = await atGate()
|
|
1516
|
+
expect((await provide(currentRun, repo)).approvals).toEqual([])
|
|
1517
|
+
const approval = await land(repo)
|
|
1518
|
+
expect((await provide(currentRun, repo)).approvals).toEqual([
|
|
1519
|
+
{ step: "gate#1", name: "Test", email: "test@test.com", commit: approval },
|
|
1520
|
+
])
|
|
1521
|
+
})
|
|
1522
|
+
|
|
1523
|
+
it("resumes past the approval commit without asking again", async () => {
|
|
1524
|
+
const repo = await atGate()
|
|
1525
|
+
await land(repo)
|
|
1526
|
+
expect(repo.lastCommitMessage()).toContain(
|
|
1527
|
+
'Gtd-Approved: {"step":"gate#1","name":"Test","email":"test@test.com"}',
|
|
1528
|
+
)
|
|
1529
|
+
expect((await provide(currentRest, repo)).state).toBe("after")
|
|
1530
|
+
})
|
|
1531
|
+
})
|
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,
|
|
@@ -29,7 +30,9 @@ import {
|
|
|
29
30
|
} from "../workflow/index.js"
|
|
30
31
|
import {
|
|
31
32
|
formatFault,
|
|
33
|
+
formatStepId,
|
|
32
34
|
formatSubject,
|
|
35
|
+
isRecordableApproval,
|
|
33
36
|
parseCommitMessage,
|
|
34
37
|
replay,
|
|
35
38
|
treeFromRecord,
|
|
@@ -41,7 +44,14 @@ import {
|
|
|
41
44
|
} from "../replay/index.js"
|
|
42
45
|
import type { ScopeAccess } from "../flows/index.js"
|
|
43
46
|
import { steeringFormatFor } from "../steering/index.js"
|
|
44
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
judgeJson,
|
|
49
|
+
UNATTRIBUTED_MODEL,
|
|
50
|
+
type ApprovalEntry,
|
|
51
|
+
type ModelCost,
|
|
52
|
+
type StepAccess,
|
|
53
|
+
} from "../wire/index.js"
|
|
54
|
+
|
|
45
55
|
import { GTD_VERSION } from "../install/index.js"
|
|
46
56
|
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
47
57
|
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
@@ -141,6 +151,8 @@ export interface ProcessRun {
|
|
|
141
151
|
readonly trace: readonly TraceEntry[]
|
|
142
152
|
readonly costEntries: readonly CostEntry[]
|
|
143
153
|
readonly judgeVerdicts: readonly JudgeVerdictEntry[]
|
|
154
|
+
/** Oldest first. */
|
|
155
|
+
readonly approvals: readonly ApprovalEntry[]
|
|
144
156
|
/** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
|
|
145
157
|
readonly pinnedVars: Record<string, string>
|
|
146
158
|
/** Absent for a process started before recording existed. */
|
|
@@ -228,6 +240,21 @@ const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
|
|
|
228
240
|
return undefined
|
|
229
241
|
}
|
|
230
242
|
|
|
243
|
+
const approvalEntryOf = (
|
|
244
|
+
hash: string,
|
|
245
|
+
message: ReturnType<typeof parseCommitMessage>,
|
|
246
|
+
): readonly ApprovalEntry[] =>
|
|
247
|
+
message.approved === undefined
|
|
248
|
+
? []
|
|
249
|
+
: [
|
|
250
|
+
{
|
|
251
|
+
step: formatStepId(message.approved.step),
|
|
252
|
+
name: message.approved.name,
|
|
253
|
+
email: message.approved.email,
|
|
254
|
+
commit: hash,
|
|
255
|
+
},
|
|
256
|
+
]
|
|
257
|
+
|
|
231
258
|
const buildRun = (
|
|
232
259
|
history: History,
|
|
233
260
|
location: EpisodeLocation,
|
|
@@ -247,6 +274,7 @@ const buildRun = (
|
|
|
247
274
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
248
275
|
costEntries: parsed.flatMap(costEntriesOf),
|
|
249
276
|
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
277
|
+
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
|
|
250
278
|
pinnedVars: { ...first?.vars },
|
|
251
279
|
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
252
280
|
headTurn: headTurnOf(head),
|
|
@@ -729,7 +757,8 @@ const messageDef = (
|
|
|
729
757
|
...common,
|
|
730
758
|
kind: "message",
|
|
731
759
|
content: request.options.message ?? request.options.label ?? step.name,
|
|
732
|
-
...optional("acceptClean", request.options.acceptClean),
|
|
760
|
+
...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
|
|
761
|
+
...optional("approve", request.options.approve),
|
|
733
762
|
})
|
|
734
763
|
|
|
735
764
|
const stepDefOf = (step: ReachedStep): StepDef => {
|
|
@@ -933,6 +962,17 @@ const setupFor = (
|
|
|
933
962
|
return { resolved, def, vars, env, setup }
|
|
934
963
|
})
|
|
935
964
|
|
|
965
|
+
// Only an approval gate counts an index-only edit (one staged, then reverted
|
|
966
|
+
// in the worktree): its approval must cover exactly the judged diff. Every
|
|
967
|
+
// other rest keeps the worktree's view, so a net-zero turn stays an attempt.
|
|
968
|
+
const pendingChanges = (git: GitOperations, withIndex: boolean) =>
|
|
969
|
+
Effect.gen(function* () {
|
|
970
|
+
const worktree = yield* git.changedPaths()
|
|
971
|
+
if (!withIndex) return worktree
|
|
972
|
+
const seen = new Set(worktree.map((e) => e.path))
|
|
973
|
+
return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))]
|
|
974
|
+
})
|
|
975
|
+
|
|
936
976
|
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
937
977
|
export const restAt = (
|
|
938
978
|
ref: string | undefined,
|
|
@@ -974,7 +1014,7 @@ export const restAt = (
|
|
|
974
1014
|
}
|
|
975
1015
|
const changes =
|
|
976
1016
|
ref === undefined
|
|
977
|
-
? (yield* git.
|
|
1017
|
+
? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
|
|
978
1018
|
status: normalizeStatus(e.status),
|
|
979
1019
|
path: e.path,
|
|
980
1020
|
}))
|
|
@@ -1107,6 +1147,32 @@ export const restIsIdle = (rest: Rest): boolean =>
|
|
|
1107
1147
|
|
|
1108
1148
|
const isHumanReviewGate = (def: StepDef): boolean => def.actor === "human" && def.mode === "review"
|
|
1109
1149
|
|
|
1150
|
+
// An approval covers exactly the judged diff, so any edit refuses it; the
|
|
1151
|
+
// approver is read only at such a rest, and must be a name a trailer can carry.
|
|
1152
|
+
const approvalRefusal = (rest: Rest, approver: Option.Option<AuthorIdent>): Landing | undefined => {
|
|
1153
|
+
if (rest.stepDef.approve !== true) return undefined
|
|
1154
|
+
const id = rest.step.id
|
|
1155
|
+
if (rest.changes.length > 0) {
|
|
1156
|
+
return {
|
|
1157
|
+
kind: "refusal",
|
|
1158
|
+
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\``,
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (Option.isNone(approver)) {
|
|
1162
|
+
return {
|
|
1163
|
+
kind: "refusal",
|
|
1164
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`,
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (!isRecordableApproval({ step: id, ...approver.value })) {
|
|
1168
|
+
return {
|
|
1169
|
+
kind: "refusal",
|
|
1170
|
+
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)`,
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return undefined
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1110
1176
|
/** The landing a clean tree decides before any replay, if it decides one. */
|
|
1111
1177
|
const cleanLanding = (rest: Rest): Landing | undefined => {
|
|
1112
1178
|
if (rest.changes.length > 0) return undefined
|
|
@@ -1182,7 +1248,15 @@ const keptTurn = (rest: Rest) =>
|
|
|
1182
1248
|
? { keepTurn: keptRef(rest.run.startHash, rest.state) }
|
|
1183
1249
|
: {}
|
|
1184
1250
|
|
|
1185
|
-
const
|
|
1251
|
+
const approvedTrailer = (rest: Rest, approver: Option.Option<AuthorIdent>) =>
|
|
1252
|
+
Option.isSome(approver) ? { approved: { step: rest.step.id, ...approver.value } } : {}
|
|
1253
|
+
|
|
1254
|
+
const landingTo = (
|
|
1255
|
+
rest: Rest,
|
|
1256
|
+
to: StateName,
|
|
1257
|
+
branch: Option.Option<string>,
|
|
1258
|
+
approver: Option.Option<AuthorIdent>,
|
|
1259
|
+
): Landing => {
|
|
1186
1260
|
if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
|
|
1187
1261
|
// The landing that leaves the flow's first step starts the process: it
|
|
1188
1262
|
// pins the process settings every later invocation reads back.
|
|
@@ -1202,6 +1276,7 @@ const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): La
|
|
|
1202
1276
|
from: rest.state,
|
|
1203
1277
|
to,
|
|
1204
1278
|
step: rest.step.id,
|
|
1279
|
+
...approvedTrailer(rest, approver),
|
|
1205
1280
|
...(starts ? { vars: rest.vars, config: rest.config } : {}),
|
|
1206
1281
|
...(finishes ? closingTrailers(branch) : {}),
|
|
1207
1282
|
},
|
|
@@ -1219,8 +1294,11 @@ const decideLanding = (
|
|
|
1219
1294
|
rest: Rest,
|
|
1220
1295
|
verdicts: readonly JudgeVerdict[] | undefined,
|
|
1221
1296
|
branch: Option.Option<string>,
|
|
1297
|
+
approver: Option.Option<AuthorIdent>,
|
|
1222
1298
|
): Effect.Effect<Landing, Error> =>
|
|
1223
1299
|
Effect.gen(function* () {
|
|
1300
|
+
const unapproved = approvalRefusal(rest, approver)
|
|
1301
|
+
if (unapproved !== undefined) return unapproved
|
|
1224
1302
|
const early = cleanLanding(rest)
|
|
1225
1303
|
if (early !== undefined) return early
|
|
1226
1304
|
if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
|
|
@@ -1239,12 +1317,12 @@ const decideLanding = (
|
|
|
1239
1317
|
)
|
|
1240
1318
|
const to = yield* landingTarget(outcome, rest)
|
|
1241
1319
|
if (typeof to !== "string") return to
|
|
1242
|
-
return landingTo(rest, to, branch)
|
|
1320
|
+
return landingTo(rest, to, branch, approver)
|
|
1243
1321
|
})
|
|
1244
1322
|
|
|
1245
1323
|
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
1246
1324
|
export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
|
|
1247
|
-
decideLanding(rest, undefined, Option.none()).pipe(
|
|
1325
|
+
decideLanding(rest, undefined, Option.none(), Option.none()).pipe(
|
|
1248
1326
|
Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
|
|
1249
1327
|
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
1250
1328
|
)
|
|
@@ -1255,8 +1333,10 @@ export const snapshotFromRest = (
|
|
|
1255
1333
|
verdicts?: readonly JudgeVerdict[],
|
|
1256
1334
|
): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
|
|
1257
1335
|
Effect.gen(function* () {
|
|
1258
|
-
const
|
|
1259
|
-
const
|
|
1336
|
+
const git = yield* GitService
|
|
1337
|
+
const branch = yield* git.currentBranch()
|
|
1338
|
+
const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : Option.none()
|
|
1339
|
+
const landing = yield* decideLanding(rest, verdicts, branch, approver)
|
|
1260
1340
|
return {
|
|
1261
1341
|
stepDef: rest.stepDef,
|
|
1262
1342
|
state: rest.state,
|
|
@@ -1300,6 +1380,7 @@ export interface HistoryStep {
|
|
|
1300
1380
|
readonly from: StateName | undefined
|
|
1301
1381
|
readonly to: StateName
|
|
1302
1382
|
readonly verdicts: readonly JudgeVerdict[]
|
|
1383
|
+
readonly approval?: ApprovalEntry
|
|
1303
1384
|
}
|
|
1304
1385
|
|
|
1305
1386
|
export interface HistoryTrail {
|
|
@@ -1371,6 +1452,7 @@ const historySteps = (run: ProcessRun): readonly HistoryStep[] => {
|
|
|
1371
1452
|
from: message.parsed?.from,
|
|
1372
1453
|
to: entry.state,
|
|
1373
1454
|
verdicts: message.judge,
|
|
1455
|
+
...optional("approval", approvalEntryOf(entry.hash, message)[0]),
|
|
1374
1456
|
}
|
|
1375
1457
|
})
|
|
1376
1458
|
}
|
|
@@ -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/flows/helpers.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
changes,
|
|
3
3
|
codeThreads,
|
|
4
4
|
head,
|
|
5
|
+
human,
|
|
5
6
|
judge,
|
|
6
7
|
matches,
|
|
7
8
|
openQuestions,
|
|
@@ -250,3 +251,30 @@ export const judgeDiffShape = async (
|
|
|
250
251
|
: "refactor-first"
|
|
251
252
|
return { choice, strays, unreadable: [], judgment }
|
|
252
253
|
}
|
|
254
|
+
|
|
255
|
+
const displayPath = (path: string): string =>
|
|
256
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
257
|
+
/[\x00-\x1f\x7f-\x9f]/.test(path) ? JSON.stringify(path) : path
|
|
258
|
+
|
|
259
|
+
const seamReport = (shape: DiffShapeResult): string => {
|
|
260
|
+
const unreadable = new Set(shape.unreadable)
|
|
261
|
+
const strays = shape.strays.map(
|
|
262
|
+
(path) => `- ${displayPath(path)}${unreadable.has(path) ? " (unreadable — not judged)" : ""}`,
|
|
263
|
+
)
|
|
264
|
+
return [
|
|
265
|
+
"Seam report: the judge found the diff's shape needs a refactor first. These paths stray outside the allowlist:",
|
|
266
|
+
"",
|
|
267
|
+
...strays,
|
|
268
|
+
"",
|
|
269
|
+
"Landing a clean tree with `gtd land` approves this diff exactly as judged; edits are refused. To stop instead, run `gtd abandon`.",
|
|
270
|
+
].join("\n")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Rest at an approval gate `name` showing the seam report — only when
|
|
275
|
+
* `shape.choice` is `refactor-first`. `land` and `reject` reach no step.
|
|
276
|
+
*/
|
|
277
|
+
export const seamGate = async (name: string, shape: DiffShapeResult): Promise<void> => {
|
|
278
|
+
if (shape.choice !== "refactor-first") return
|
|
279
|
+
await human(name, { message: seamReport(shape), approve: true })
|
|
280
|
+
}
|
package/src/flows/runtime.ts
CHANGED
|
@@ -39,6 +39,13 @@ export interface HumanOptions extends SteeringOptions {
|
|
|
39
39
|
* Without this a clean landing is a no-op: the gate waits for a change.
|
|
40
40
|
*/
|
|
41
41
|
readonly acceptClean?: boolean | undefined
|
|
42
|
+
/**
|
|
43
|
+
* A sign-off gate: only a clean landing completes it, and that commit
|
|
44
|
+
* carries `Gtd-Approved: {"step":"<step>#<n>","name":"<name>","email":"<email>"}`.
|
|
45
|
+
* A dirty landing is refused. Implies `acceptClean`. Cannot be combined with
|
|
46
|
+
* `mode` or `file`: an approval records exactly the judged tree.
|
|
47
|
+
*/
|
|
48
|
+
readonly approve?: boolean | undefined
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export interface RunOptions extends SteeringOptions {}
|
package/src/install/Install.ts
CHANGED
|
@@ -246,7 +246,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
|
|
|
246
246
|
(\`{read, write}\`, each an array of globs or \`null\` for unrestricted). When set: \`model\`, \`memory\`,
|
|
247
247
|
\`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
|
|
248
248
|
document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
|
|
249
|
-
\`cost\`, \`costByModel\`.
|
|
249
|
+
\`cost\`, \`costByModel\`. Always \`approvals\` (an array of
|
|
250
|
+
\`{step, name, email, commit}\`, \`[]\` when none).
|
|
250
251
|
|
|
251
252
|
\`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
|
|
252
253
|
(true for a no-op at a \`script\` rest — stop immediately, nothing more to
|