@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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import {
|
|
7
7
|
commitAll,
|
|
8
8
|
commitAsIs,
|
|
9
|
+
commitClean,
|
|
9
10
|
deleteRef,
|
|
10
11
|
discardPending,
|
|
11
12
|
hardResetTo,
|
|
@@ -185,6 +186,17 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
|
|
|
185
186
|
return undefined
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
if (block.startsWith("gtd_head=$(git rev-parse --verify HEAD) || exit 1\nif [ -n ")) {
|
|
190
|
+
const [message] = extractQuotedTokens(block.slice(block.indexOf('"$gtd_head" -m ')))
|
|
191
|
+
if (message !== undefined && commitClean(message) === block) {
|
|
192
|
+
const failure = repo.commitClean(message)
|
|
193
|
+
return failure === undefined
|
|
194
|
+
? { kind: "applied" }
|
|
195
|
+
: { kind: "failed", error: failure, status: 1 }
|
|
196
|
+
}
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
188
200
|
if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
|
|
189
201
|
const [message] = extractQuotedTokens(conditionLine)
|
|
190
202
|
if (message !== undefined && commitAsIs(message) === block) {
|
|
@@ -77,7 +77,12 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
|
|
|
77
77
|
|
|
78
78
|
changedPaths: (base?: string) => Effect.succeed(repo.changedPathsWorktree(base)),
|
|
79
79
|
|
|
80
|
+
stagedPaths: () => Effect.succeed(repo.stagedPaths()),
|
|
81
|
+
|
|
80
82
|
diffPaths: (base: string, tip: string) => tryCatch(() => repo.diffPaths(base, tip)),
|
|
83
|
+
|
|
84
|
+
// The identity `GitTiers`' live tier configures; the fake commits as no one in particular.
|
|
85
|
+
authorIdent: () => Effect.succeed(Option.some({ name: "Test", email: "test@test.com" })),
|
|
81
86
|
})
|
|
82
87
|
|
|
83
88
|
const failOn = (output: string | undefined): void => {
|
package/src/testkit/GitTiers.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { Narrator, GitService, Host, type GitOperations } from "../platform/inde
|
|
|
23
23
|
import {
|
|
24
24
|
commitAll,
|
|
25
25
|
commitAsIs,
|
|
26
|
+
commitClean,
|
|
26
27
|
softResetTo,
|
|
27
28
|
mixedResetTo,
|
|
28
29
|
hardResetTo,
|
|
@@ -81,6 +82,8 @@ export interface GitTierSeed {
|
|
|
81
82
|
readonly stagingHook: (path: string, content: string) => void
|
|
82
83
|
/** Every commit `git commit` signs, or `git commit-tree -S` writes, fails; plain `git commit-tree` is unaffected. */
|
|
83
84
|
readonly failingSigner: () => void
|
|
85
|
+
/** `commit.gpgSign=true` with a signer that succeeds; `git commit` and `commit-tree -S` both sign. */
|
|
86
|
+
readonly workingSigner: () => void
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
export interface GitTierObserve {
|
|
@@ -94,6 +97,8 @@ export interface GitTierObserve {
|
|
|
94
97
|
readonly headSubject: () => string
|
|
95
98
|
/** Paths HEAD's own commit touched, against its parent — not the whole tree. */
|
|
96
99
|
readonly headTouchedPaths: () => ReadonlyArray<string>
|
|
100
|
+
/** True when HEAD's commit object carries a signature (`gpgsig` header). */
|
|
101
|
+
readonly headSigned: () => boolean
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
export interface GitTier {
|
|
@@ -233,6 +238,18 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
233
238
|
gitExec("config", "gpg.format", "openpgp")
|
|
234
239
|
gitExec("config", "gpg.program", "false")
|
|
235
240
|
},
|
|
241
|
+
workingSigner: () => {
|
|
242
|
+
const signer = join(root, ".git/gtd-test-signer.sh")
|
|
243
|
+
// git checks `[GNUPG:] SIG_CREATED ` on stderr (--status-fd=2) and takes stdout as the signature.
|
|
244
|
+
writeFileSync(
|
|
245
|
+
signer,
|
|
246
|
+
`#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '-----BEGIN PGP SIGNATURE-----' '' 'ZHVtbXk=' '-----END PGP SIGNATURE-----'\nprintf '\\n[GNUPG:] SIG_CREATED ' >&2\n`,
|
|
247
|
+
{ mode: 0o755 },
|
|
248
|
+
)
|
|
249
|
+
gitExec("config", "commit.gpgsign", "true")
|
|
250
|
+
gitExec("config", "gpg.format", "openpgp")
|
|
251
|
+
gitExec("config", "gpg.program", signer)
|
|
252
|
+
},
|
|
236
253
|
},
|
|
237
254
|
observe: {
|
|
238
255
|
resolveRef: (ref) => gitExec("rev-parse", ref),
|
|
@@ -268,6 +285,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
268
285
|
gitExec("show", "--name-only", "--pretty=format:", "HEAD")
|
|
269
286
|
.split("\n")
|
|
270
287
|
.filter((line) => line.length > 0),
|
|
288
|
+
headSigned: () => gitExec("cat-file", "commit", "HEAD").includes("\ngpgsig "),
|
|
271
289
|
},
|
|
272
290
|
runScript: (script) => {
|
|
273
291
|
// One stream, as a driver's terminal shows it.
|
|
@@ -345,6 +363,9 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
345
363
|
failingSigner: () => {
|
|
346
364
|
repo.signingFails = true
|
|
347
365
|
},
|
|
366
|
+
workingSigner: () => {
|
|
367
|
+
repo.signingEnabled = true
|
|
368
|
+
},
|
|
348
369
|
},
|
|
349
370
|
observe: {
|
|
350
371
|
resolveRef: (ref) => {
|
|
@@ -369,6 +390,7 @@ const makeInMemTier = (initialCommit = true): GitTier => {
|
|
|
369
390
|
.filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p))
|
|
370
391
|
.sort()
|
|
371
392
|
},
|
|
393
|
+
headSigned: () => repo.headSigned(),
|
|
372
394
|
},
|
|
373
395
|
runScript: (script, commands = new Map()) => {
|
|
374
396
|
const { ok, status, output, error } = applyEmittedScript(repo, commands, script)
|
|
@@ -409,7 +431,9 @@ export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new
|
|
|
409
431
|
"subjectHistory",
|
|
410
432
|
"readFileAtRef",
|
|
411
433
|
"changedPaths",
|
|
434
|
+
"stagedPaths",
|
|
412
435
|
"diffPaths",
|
|
436
|
+
"authorIdent",
|
|
413
437
|
"commitAllWithPrefix",
|
|
414
438
|
"softResetTo",
|
|
415
439
|
"commitAsIs",
|
|
@@ -481,7 +505,9 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
481
505
|
g.readFileAtRef("HEAD", "second.txt"),
|
|
482
506
|
g.changedPaths(),
|
|
483
507
|
g.changedPaths("HEAD~1"),
|
|
508
|
+
g.stagedPaths(),
|
|
484
509
|
g.diffPaths("HEAD~1", "HEAD"),
|
|
510
|
+
g.authorIdent(),
|
|
485
511
|
]),
|
|
486
512
|
)
|
|
487
513
|
expect(await t.observe.snapshot()).toBe(before)
|
|
@@ -1071,6 +1097,14 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
1071
1097
|
expect(await runGit(t, (g) => g.changedPaths())).toEqual([])
|
|
1072
1098
|
})
|
|
1073
1099
|
|
|
1100
|
+
it("ignores an edit staged and then reverted in the worktree", async () => {
|
|
1101
|
+
t.seed.commit("chore: seed", { "a.txt": "1" })
|
|
1102
|
+
t.seed.writeFile("a.txt", "2")
|
|
1103
|
+
t.seed.stageAll()
|
|
1104
|
+
t.seed.writeFile("a.txt", "1")
|
|
1105
|
+
expect(await runGit(t, (g) => g.changedPaths())).toEqual([])
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1074
1108
|
for (const { label, path } of PATHOLOGICAL_PATHS) {
|
|
1075
1109
|
it(`reports an untracked path containing ${label}, verbatim`, async () => {
|
|
1076
1110
|
t.seed.writeFile(path, "content")
|
|
@@ -1172,6 +1206,43 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
1172
1206
|
})
|
|
1173
1207
|
})
|
|
1174
1208
|
|
|
1209
|
+
describe("stagedPaths", () => {
|
|
1210
|
+
it("returns [] when the index matches HEAD, whatever the worktree holds", async () => {
|
|
1211
|
+
t.seed.commit("chore: seed", { "a.txt": "1" })
|
|
1212
|
+
t.seed.writeFile("a.txt", "2")
|
|
1213
|
+
t.seed.writeFile("u.txt", "u")
|
|
1214
|
+
expect(await runGit(t, (g) => g.stagedPaths())).toEqual([])
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
it("reports an edit staged and then reverted in the worktree", async () => {
|
|
1218
|
+
t.seed.commit("chore: seed", { "a.txt": "1" })
|
|
1219
|
+
t.seed.writeFile("a.txt", "2")
|
|
1220
|
+
t.seed.stageAll()
|
|
1221
|
+
t.seed.writeFile("a.txt", "1")
|
|
1222
|
+
expect(await runGit(t, (g) => g.stagedPaths())).toEqual([{ path: "a.txt", status: "M" }])
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1225
|
+
it("reports staged additions and deletions", async () => {
|
|
1226
|
+
t.seed.commit("chore: seed", { "gone.txt": "g" })
|
|
1227
|
+
t.seed.deleteFile("gone.txt")
|
|
1228
|
+
t.seed.writeFile("new.txt", "n")
|
|
1229
|
+
t.seed.stageAll()
|
|
1230
|
+
const staged = await runGit(t, (g) => g.stagedPaths())
|
|
1231
|
+
expect([...staged].sort((a, b) => a.path.localeCompare(b.path))).toEqual([
|
|
1232
|
+
{ path: "gone.txt", status: "D" },
|
|
1233
|
+
{ path: "new.txt", status: "A" },
|
|
1234
|
+
])
|
|
1235
|
+
})
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
describe("authorIdent", () => {
|
|
1239
|
+
it("is the configured name and email", async () => {
|
|
1240
|
+
expect(await runGit(t, (g) => g.authorIdent())).toEqual(
|
|
1241
|
+
Option.some({ name: "Test", email: "test@test.com" }),
|
|
1242
|
+
)
|
|
1243
|
+
})
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1175
1246
|
describe("topLevel", () => {
|
|
1176
1247
|
it("resolves to this tier's own root", async () => {
|
|
1177
1248
|
expect(await runGit(t, (g) => g.topLevel())).toBe(t.root)
|
|
@@ -1286,6 +1357,93 @@ export const runGitScriptContract = (makeTier: () => GitTier): void => {
|
|
|
1286
1357
|
})
|
|
1287
1358
|
})
|
|
1288
1359
|
|
|
1360
|
+
describe("commitClean", () => {
|
|
1361
|
+
it("on a clean tree commits an empty commit with the message", () => {
|
|
1362
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
1363
|
+
run(t, commitClean("gtd(human): gate → again"))
|
|
1364
|
+
expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore)
|
|
1365
|
+
expect(t.observe.headSubject()).toBe("gtd(human): gate → again")
|
|
1366
|
+
expect(t.observe.headTouchedPaths()).toEqual([])
|
|
1367
|
+
})
|
|
1368
|
+
|
|
1369
|
+
it("a staging hook never runs: nothing touched, nothing staged, file absent", () => {
|
|
1370
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
1371
|
+
t.seed.stagingHook("hooked.txt", "hooked")
|
|
1372
|
+
run(t, commitClean("gtd(human): gate → again"))
|
|
1373
|
+
expect(t.observe.headTouchedPaths()).toEqual([])
|
|
1374
|
+
expect(t.observe.resolveRef("HEAD^")).toBe(headBefore)
|
|
1375
|
+
expect(t.observe.statusPorcelain().trim()).toBe("")
|
|
1376
|
+
expect(t.observe.existsPath("hooked.txt")).toBe(false)
|
|
1377
|
+
})
|
|
1378
|
+
|
|
1379
|
+
it("a rejecting hook is never consulted", () => {
|
|
1380
|
+
t.seed.rejectingHook("rejected")
|
|
1381
|
+
run(t, commitClean("gtd(human): gate → again"))
|
|
1382
|
+
expect(t.observe.headSubject()).toBe("gtd(human): gate → again")
|
|
1383
|
+
})
|
|
1384
|
+
|
|
1385
|
+
it("a working signer signs the approval", () => {
|
|
1386
|
+
t.seed.workingSigner()
|
|
1387
|
+
run(t, commitClean("gtd(human): gate → again"))
|
|
1388
|
+
expect(t.observe.headSigned()).toBe(true)
|
|
1389
|
+
})
|
|
1390
|
+
|
|
1391
|
+
it("no signing config: the approval does not try to sign", () => {
|
|
1392
|
+
run(t, commitClean("gtd(human): gate → again"))
|
|
1393
|
+
expect(t.observe.headSigned()).toBe(false)
|
|
1394
|
+
})
|
|
1395
|
+
|
|
1396
|
+
it("a failing signer fails the approval: HEAD and status unchanged", () => {
|
|
1397
|
+
t.seed.failingSigner()
|
|
1398
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
1399
|
+
const result = t.runScript(commitClean("gtd(human): gate → again"))
|
|
1400
|
+
expect(result.ok).toBe(false)
|
|
1401
|
+
expect(result.status).toBe(1)
|
|
1402
|
+
expect(result.output).toContain("failed to sign")
|
|
1403
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore)
|
|
1404
|
+
expect(t.observe.statusPorcelain().trim()).toBe("")
|
|
1405
|
+
})
|
|
1406
|
+
|
|
1407
|
+
const refused = (name: string, dirty: () => void, pending: string): void => {
|
|
1408
|
+
it(`${name}: fails, HEAD unchanged, edit still pending`, () => {
|
|
1409
|
+
dirty()
|
|
1410
|
+
const headBefore = t.observe.resolveRef("HEAD")
|
|
1411
|
+
const result = t.runScript(commitClean("gtd(human): gate → again"))
|
|
1412
|
+
expect(result.ok).toBe(false)
|
|
1413
|
+
expect(result.output).toContain("changed since `gtd land`")
|
|
1414
|
+
expect(t.observe.resolveRef("HEAD")).toBe(headBefore)
|
|
1415
|
+
expect(t.observe.statusPorcelain()).toContain(pending)
|
|
1416
|
+
})
|
|
1417
|
+
}
|
|
1418
|
+
refused(
|
|
1419
|
+
"a modified tracked file",
|
|
1420
|
+
() => {
|
|
1421
|
+
t.seed.commit("feat: base", { "a.txt": "1" })
|
|
1422
|
+
t.seed.writeFile("a.txt", "2")
|
|
1423
|
+
},
|
|
1424
|
+
"a.txt",
|
|
1425
|
+
)
|
|
1426
|
+
refused(
|
|
1427
|
+
"a staged change",
|
|
1428
|
+
() => {
|
|
1429
|
+
t.seed.writeFile("s.txt", "s")
|
|
1430
|
+
t.seed.stageAll()
|
|
1431
|
+
},
|
|
1432
|
+
"s.txt",
|
|
1433
|
+
)
|
|
1434
|
+
refused("an untracked file", () => t.seed.writeFile("u.txt", "u"), "u.txt")
|
|
1435
|
+
refused(
|
|
1436
|
+
"an edit staged then reverted in the worktree",
|
|
1437
|
+
() => {
|
|
1438
|
+
t.seed.commit("feat: base", { "a.txt": "1" })
|
|
1439
|
+
t.seed.writeFile("a.txt", "2")
|
|
1440
|
+
t.seed.stageAll()
|
|
1441
|
+
t.seed.writeFile("a.txt", "1")
|
|
1442
|
+
},
|
|
1443
|
+
"a.txt",
|
|
1444
|
+
)
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1289
1447
|
describe("commitAsIs", () => {
|
|
1290
1448
|
it("does NOT stage — a worktree write after staging is excluded (the squash mechanic)", () => {
|
|
1291
1449
|
t.seed.writeFile("a.txt", "a")
|
package/src/testkit/InMemRepo.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
//
|
|
1
|
+
// No real filesystem/git.
|
|
2
2
|
import { createHash } from "node:crypto"
|
|
3
|
+
import { CHANGED_SINCE_LAND } from "../emit/index.js"
|
|
3
4
|
|
|
4
5
|
/** Embedded in every value this fake reaches (see also `strictGitOperations`'s default message) so `scripts/assert-no-test-doubles.mjs` can catch a leak into the shipped bundle by string search. */
|
|
5
6
|
export const TEST_DOUBLE_SENTINEL = "gtd:test-double:never-ship"
|
|
@@ -11,6 +12,7 @@ interface Commit {
|
|
|
11
12
|
parent: string | null
|
|
12
13
|
/** Creation order — the twin's stand-in for a committer date. */
|
|
13
14
|
seq: number
|
|
15
|
+
readonly signed: boolean
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
function sha1(input: string): string {
|
|
@@ -270,6 +272,11 @@ export class InMemRepo {
|
|
|
270
272
|
return diffTrees(baseTree, worktreeTree)
|
|
271
273
|
}
|
|
272
274
|
|
|
275
|
+
/** The index vs HEAD — the port's `stagedPaths`. */
|
|
276
|
+
stagedPaths(): Array<{ path: string; status: string }> {
|
|
277
|
+
return diffTrees(this.treeAt(undefined), this.index)
|
|
278
|
+
}
|
|
279
|
+
|
|
273
280
|
diffPaths(base: string, tip: string): string[] {
|
|
274
281
|
for (const ref of [base, tip]) {
|
|
275
282
|
if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`)
|
|
@@ -346,6 +353,7 @@ export class InMemRepo {
|
|
|
346
353
|
/** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
|
|
347
354
|
hookRejection: string | undefined = undefined
|
|
348
355
|
signingFails = false
|
|
356
|
+
signingEnabled = false
|
|
349
357
|
/** Seeded: a pre-commit hook writing and staging this file. */
|
|
350
358
|
hookStages: { readonly path: string; readonly content: string } | undefined = undefined
|
|
351
359
|
|
|
@@ -353,7 +361,14 @@ export class InMemRepo {
|
|
|
353
361
|
const tree = new Map(this.index)
|
|
354
362
|
const parent = this.head
|
|
355
363
|
const hash = makeHash(message, parent, tree)
|
|
356
|
-
const commit: Commit = {
|
|
364
|
+
const commit: Commit = {
|
|
365
|
+
hash,
|
|
366
|
+
message,
|
|
367
|
+
files: new Map(tree),
|
|
368
|
+
parent,
|
|
369
|
+
seq: this.nextSeq++,
|
|
370
|
+
signed: this.signingEnabled,
|
|
371
|
+
}
|
|
357
372
|
this.commits.set(hash, commit)
|
|
358
373
|
this.head = hash
|
|
359
374
|
this.branches.set(this.currentBranch, hash)
|
|
@@ -378,6 +393,19 @@ export class InMemRepo {
|
|
|
378
393
|
return undefined
|
|
379
394
|
}
|
|
380
395
|
|
|
396
|
+
/** Twin of `GitScript.commitClean`: no hook runs, HEAD's tree is recorded; `undefined` on success, else the stderr text. */
|
|
397
|
+
commitClean(message: string): string | undefined {
|
|
398
|
+
if (this.head === null) return "fatal: Needed a single revision"
|
|
399
|
+
if (this.statusPorcelain().trim() !== "") return CHANGED_SINCE_LAND
|
|
400
|
+
if (this.signingFails) return "error: gpg failed to sign the data"
|
|
401
|
+
this.writeCommit(message)
|
|
402
|
+
return undefined
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
headSigned(): boolean {
|
|
406
|
+
return this.headCommit()?.signed === true
|
|
407
|
+
}
|
|
408
|
+
|
|
381
409
|
/** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
|
|
382
410
|
seedCommit(message: string): void {
|
|
383
411
|
if (this.hookRejection !== undefined || this.hookStages !== undefined || this.signingFails) {
|
|
@@ -393,7 +421,14 @@ export class InMemRepo {
|
|
|
393
421
|
const tree = new Map(this.index)
|
|
394
422
|
const parent = this.head
|
|
395
423
|
const hash = makeHash(message, parent, tree)
|
|
396
|
-
this.commits.set(hash, {
|
|
424
|
+
this.commits.set(hash, {
|
|
425
|
+
hash,
|
|
426
|
+
message,
|
|
427
|
+
files: new Map(tree),
|
|
428
|
+
parent,
|
|
429
|
+
seq: this.nextSeq++,
|
|
430
|
+
signed: false,
|
|
431
|
+
})
|
|
397
432
|
const ref = `${refPrefix}/${hash}`
|
|
398
433
|
this.refs.set(ref, hash)
|
|
399
434
|
this.index = new Map(this.headTree())
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type StatusChange,
|
|
11
11
|
DEMAND_BRIEFING,
|
|
12
12
|
renderBeatPlain,
|
|
13
|
+
type ApprovalEntry,
|
|
13
14
|
type ModelCost,
|
|
14
15
|
type RenderedDemandSource,
|
|
15
16
|
WIRE_SCHEMA,
|
|
@@ -34,6 +35,7 @@ interface DocumentInput {
|
|
|
34
35
|
readonly next?: NextMatch | null
|
|
35
36
|
readonly cost?: number
|
|
36
37
|
readonly costByModel?: readonly ModelCost[]
|
|
38
|
+
readonly approvals?: readonly ApprovalEntry[]
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** `demandOf`'s own input, isolated so `documentFor` stays a flat two-call body — `session`/`validate` are only spread in when the caller actually passed one, since `demandOf`'s `prompt` variant forbids an explicit `undefined`. */
|
|
@@ -61,6 +63,7 @@ const statusInput = (
|
|
|
61
63
|
next: input.next ?? null,
|
|
62
64
|
cost: input.cost ?? 0,
|
|
63
65
|
costByModel: input.costByModel ?? [],
|
|
66
|
+
approvals: input.approvals ?? [],
|
|
64
67
|
})
|
|
65
68
|
|
|
66
69
|
/** Builds one full `BeatDocument` in one call, mirroring the pre-split `beatFields` call shape so every golden below reads the same as before this package. */
|
|
@@ -87,6 +90,7 @@ describe("beatDocument / renderBeatJson", () => {
|
|
|
87
90
|
workflow: "feature",
|
|
88
91
|
changes: [],
|
|
89
92
|
next: null,
|
|
93
|
+
approvals: [],
|
|
90
94
|
})
|
|
91
95
|
})
|
|
92
96
|
|
|
@@ -267,9 +271,29 @@ describe("beatDocument / renderBeatJson", () => {
|
|
|
267
271
|
"judge",
|
|
268
272
|
"skills",
|
|
269
273
|
"access",
|
|
274
|
+
"approvals",
|
|
270
275
|
])
|
|
271
276
|
})
|
|
272
277
|
|
|
278
|
+
it("emits approvals always, [] when none, at every kind", () => {
|
|
279
|
+
for (const kind of BEAT_KINDS) {
|
|
280
|
+
const parsed = JSON.parse(renderJsonLine({ kind })) as Record<string, unknown>
|
|
281
|
+
expect(parsed.approvals).toEqual([])
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it("carries approvals verbatim, in the order given", () => {
|
|
286
|
+
const approvals = [
|
|
287
|
+
{ step: "gate#1", name: "Ada", email: "ada@x.io", commit: "a".repeat(40) },
|
|
288
|
+
{ step: "gate#2", name: "Bo", email: "bo@x.io", commit: "b".repeat(40) },
|
|
289
|
+
]
|
|
290
|
+
const parsed = JSON.parse(renderJsonLine({ kind: "message", approvals })) as Record<
|
|
291
|
+
string,
|
|
292
|
+
unknown
|
|
293
|
+
>
|
|
294
|
+
expect(parsed.approvals).toEqual(approvals)
|
|
295
|
+
})
|
|
296
|
+
|
|
273
297
|
it("emits access at kind prompt, null per side when unrestricted", () => {
|
|
274
298
|
const bare = JSON.parse(renderJsonLine({ kind: "prompt" })) as Record<string, unknown>
|
|
275
299
|
expect(bare.access).toEqual({ read: null, write: null })
|
|
@@ -377,6 +401,7 @@ describe("golden bytes", () => {
|
|
|
377
401
|
workflow: "feature",
|
|
378
402
|
changes: [],
|
|
379
403
|
next: null,
|
|
404
|
+
approvals: [],
|
|
380
405
|
}) + "\n",
|
|
381
406
|
)
|
|
382
407
|
})
|
package/src/wire/BeatDocument.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BeatKind, Demand, DemandSession } from "./Demand.js"
|
|
2
2
|
import type { BeatStatus, NextMatch, StatusChange } from "./BeatStatus.js"
|
|
3
|
-
import type { Actor, ModelCost, StateMode, StateName, StepAccess } from "./types.js"
|
|
3
|
+
import type { Actor, ApprovalEntry, ModelCost, StateMode, StateName, StepAccess } from "./types.js"
|
|
4
4
|
import { WIRE_SCHEMA } from "./constants.js"
|
|
5
5
|
|
|
6
6
|
/** `gtd next --json`'s `next` key — `null` when landing would commit nothing. */
|
|
@@ -72,10 +72,11 @@ export interface BeatDocument {
|
|
|
72
72
|
readonly judge: string | undefined
|
|
73
73
|
readonly skills: readonly string[] | undefined
|
|
74
74
|
readonly access: StepAccess | undefined
|
|
75
|
+
readonly approvals: readonly ApprovalEntry[]
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
|
-
* Flatten a `Demand` plus its `BeatStatus` into the single
|
|
79
|
+
* Flatten a `Demand` plus its `BeatStatus` into the single 25-key document
|
|
79
80
|
* `gtd next --json` emits — the ONLY place the two are joined. `cost`/
|
|
80
81
|
* `costByModel` are omitted together, exactly when no cost was recorded
|
|
81
82
|
* (`cost <= 0`).
|
|
@@ -108,6 +109,7 @@ export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument =
|
|
|
108
109
|
judge: status.judge,
|
|
109
110
|
skills,
|
|
110
111
|
access,
|
|
112
|
+
approvals: status.approvals,
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
|
|
@@ -19,6 +19,7 @@ const baseInput = (overrides: Partial<RenderedDemandSource> = {}) => ({
|
|
|
19
19
|
next: null,
|
|
20
20
|
cost: 0,
|
|
21
21
|
costByModel: [],
|
|
22
|
+
approvals: [],
|
|
22
23
|
})
|
|
23
24
|
|
|
24
25
|
describe("statusOf", () => {
|
|
@@ -47,6 +48,7 @@ describe("statusOf", () => {
|
|
|
47
48
|
next: { target: "idle" },
|
|
48
49
|
cost: 12,
|
|
49
50
|
costByModel: [{ model: "opus", cost: 12 }],
|
|
51
|
+
approvals: [],
|
|
50
52
|
})
|
|
51
53
|
expect(status.workflow).toBe("feature")
|
|
52
54
|
expect(status.idle).toBe(true)
|
package/src/wire/BeatStatus.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ApprovalEntry,
|
|
3
|
+
Actor,
|
|
4
|
+
ModelCost,
|
|
5
|
+
RenderedDemandSource,
|
|
6
|
+
StateMode,
|
|
7
|
+
StateName,
|
|
8
|
+
} from "./types.js"
|
|
2
9
|
|
|
3
10
|
/** One pending change — `gtd next --json`'s `changes` entries. */
|
|
4
11
|
export interface StatusChange {
|
|
@@ -39,6 +46,7 @@ export interface BeatStatus {
|
|
|
39
46
|
readonly cost: number
|
|
40
47
|
readonly costByModel: readonly ModelCost[]
|
|
41
48
|
readonly judge: string | undefined
|
|
49
|
+
readonly approvals: readonly ApprovalEntry[]
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
/**
|
|
@@ -57,8 +65,10 @@ export const statusOf = (input: {
|
|
|
57
65
|
readonly next: NextMatch | null
|
|
58
66
|
readonly cost: number
|
|
59
67
|
readonly costByModel: readonly ModelCost[]
|
|
68
|
+
readonly approvals: readonly ApprovalEntry[]
|
|
60
69
|
}): BeatStatus => {
|
|
61
|
-
const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel } =
|
|
70
|
+
const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel, approvals } =
|
|
71
|
+
input
|
|
62
72
|
return {
|
|
63
73
|
idle,
|
|
64
74
|
initial,
|
|
@@ -77,5 +87,6 @@ export const statusOf = (input: {
|
|
|
77
87
|
cost,
|
|
78
88
|
costByModel,
|
|
79
89
|
judge: rendered.judge,
|
|
90
|
+
approvals,
|
|
80
91
|
}
|
|
81
92
|
}
|
|
@@ -86,6 +86,7 @@ const beat = (kind: BeatKind): string => {
|
|
|
86
86
|
next: { target: "idle" },
|
|
87
87
|
cost: 12,
|
|
88
88
|
costByModel: [{ model: "opus", cost: 12 }],
|
|
89
|
+
approvals: [{ step: "gate#1", name: "Ada", email: "ada@x.io", commit: "a".repeat(40) }],
|
|
89
90
|
})
|
|
90
91
|
return renderBeatJson(beatDocument(demand, status))
|
|
91
92
|
}
|
|
@@ -129,6 +130,15 @@ describe(`wire schema ${WIRE_SCHEMA}`, () => {
|
|
|
129
130
|
})
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
it("approvals is an additive field: present on every beat, WIRE_SCHEMA unchanged", () => {
|
|
134
|
+
expect(WIRE_SCHEMA).toBe(1)
|
|
135
|
+
for (const kind of BEAT_KINDS) {
|
|
136
|
+
expect(JSON.parse(documents[`beat-${kind}.json`]!)).toHaveProperty("approvals", [
|
|
137
|
+
{ step: "gate#1", name: "Ada", email: "ada@x.io", commit: "a".repeat(40) },
|
|
138
|
+
])
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
132
142
|
describe("the shape check", () => {
|
|
133
143
|
const land = JSON.parse(documents["land.json"]!) as Record<string, unknown>
|
|
134
144
|
const { subject, ...rest } = land
|
package/src/wire/types.ts
CHANGED
|
@@ -30,6 +30,14 @@ export interface StepAccess {
|
|
|
30
30
|
readonly write: readonly string[] | null
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** One approval commit of the process: `step` is the formatted `<name>#<n>`, `commit` the approving commit's hash. */
|
|
34
|
+
export interface ApprovalEntry {
|
|
35
|
+
readonly step: string
|
|
36
|
+
readonly name: string
|
|
37
|
+
readonly email: string
|
|
38
|
+
readonly commit: string
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
export interface RenderedDemandSource {
|
|
34
42
|
readonly state: StateName
|
|
35
43
|
readonly actor: Actor
|
package/src/workflow/Workflow.ts
CHANGED