@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.
Files changed (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
  4. package/dist/flows.mjs +24 -1
  5. package/dist/gtd.bundle.mjs +174 -23
  6. package/dist/testkit.mjs +315 -27
  7. package/dist/wire.mjs +1 -1
  8. package/package.json +1 -1
  9. package/src/cli/Cli.ts +5 -5
  10. package/src/cli/History.ts +5 -0
  11. package/src/cli/Program.ts +1 -0
  12. package/src/edge/Edge.test.ts +160 -0
  13. package/src/edge/Edge.ts +90 -8
  14. package/src/emit/GitScript.test.ts +39 -3
  15. package/src/emit/GitScript.ts +25 -1
  16. package/src/emit/index.ts +2 -0
  17. package/src/flows/helpers.ts +28 -0
  18. package/src/flows/runtime.ts +7 -0
  19. package/src/install/Install.ts +2 -1
  20. package/src/platform/Git.test.ts +25 -1
  21. package/src/platform/Git.ts +30 -0
  22. package/src/platform/index.ts +1 -0
  23. package/src/replay/Format.test.ts +16 -1
  24. package/src/replay/Replay.test.ts +91 -0
  25. package/src/replay/Replay.ts +22 -2
  26. package/src/replay/Trailers.test.ts +110 -1
  27. package/src/replay/Trailers.ts +37 -1
  28. package/src/replay/index.ts +2 -0
  29. package/src/replay/seamGate.test.ts +62 -0
  30. package/src/step/LandStep.ts +1 -0
  31. package/src/step/planStep.test.ts +32 -0
  32. package/src/step/planStep.ts +3 -1
  33. package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
  34. package/src/testkit/EmittedScriptRecognizer.ts +12 -0
  35. package/src/testkit/FakeGitOperations.ts +5 -0
  36. package/src/testkit/GitTiers.ts +158 -0
  37. package/src/testkit/InMemRepo.ts +38 -3
  38. package/src/wire/BeatDocument.test.ts +25 -0
  39. package/src/wire/BeatDocument.ts +4 -2
  40. package/src/wire/BeatPlain.test.ts +1 -0
  41. package/src/wire/BeatStatus.test.ts +2 -0
  42. package/src/wire/BeatStatus.ts +13 -2
  43. package/src/wire/WireSchema.test.ts +10 -0
  44. package/src/wire/types.ts +8 -0
  45. 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 => {
@@ -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")
@@ -1,5 +1,6 @@
1
- // Pure TypeScript, no Effect, no real filesystem/git.
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 = { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ }
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, { hash, message, files: new Map(tree), parent, seq: this.nextSeq++ })
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
  })
@@ -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 24-key document
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
 
@@ -28,6 +28,7 @@ const documentFor = (kind: BeatKind, content: string, rest: Partial<RenderedDema
28
28
  next: { target: "idle" },
29
29
  cost: 0,
30
30
  costByModel: [],
31
+ approvals: [],
31
32
  })
32
33
  return beatDocument(demand, status)
33
34
  }
@@ -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)
@@ -1,4 +1,11 @@
1
- import type { Actor, ModelCost, RenderedDemandSource, StateMode, StateName } from "./types.js"
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 } = input
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
@@ -41,6 +41,7 @@ export interface StepDef {
41
41
  readonly allowEmpty?: boolean
42
42
  readonly keep?: boolean
43
43
  readonly acceptClean?: boolean
44
+ readonly approve?: boolean
44
45
  readonly skills?: readonly string[]
45
46
  readonly access?: StepAccess
46
47
  }