@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.
Files changed (79) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +22 -1
  3. package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
  4. package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-DM5W_LAt.mjs} +64 -3
  5. package/dist/chunks/{tail-iLAMoigl.mjs → tail-6gvLXXcg.mjs} +1 -1
  6. package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
  7. package/dist/flows.mjs +27 -2
  8. package/dist/gtd.bundle.mjs +218 -32
  9. package/dist/testkit.mjs +358 -37
  10. package/dist/wire.mjs +1 -1
  11. package/dist/workflows/cleanup.mjs +2 -2
  12. package/dist/workflows/feature.mjs +140 -64
  13. package/dist/workflows/fix.mjs +2 -2
  14. package/dist/workflows/review.mjs +2 -2
  15. package/dist/workflows/rollout.mjs +1 -1
  16. package/dist/workflows.mjs +346 -107
  17. package/package.json +1 -1
  18. package/src/cli/Cli.test.ts +49 -1
  19. package/src/cli/Cli.ts +25 -8
  20. package/src/cli/History.ts +5 -0
  21. package/src/cli/Program.ts +1 -0
  22. package/src/edge/Edge.test.ts +205 -1
  23. package/src/edge/Edge.ts +105 -12
  24. package/src/emit/GitScript.test.ts +39 -3
  25. package/src/emit/GitScript.ts +25 -1
  26. package/src/emit/index.ts +2 -0
  27. package/src/flags/index.test.ts +14 -1
  28. package/src/flags/index.ts +14 -3
  29. package/src/flows/helpers.ts +34 -2
  30. package/src/flows/runtime.ts +7 -0
  31. package/src/install/Install.ts +2 -1
  32. package/src/platform/Git.test.ts +25 -1
  33. package/src/platform/Git.ts +30 -0
  34. package/src/platform/index.ts +1 -0
  35. package/src/replay/Format.test.ts +16 -1
  36. package/src/replay/Replay.test.ts +91 -0
  37. package/src/replay/Replay.ts +22 -2
  38. package/src/replay/Trailers.test.ts +110 -1
  39. package/src/replay/Trailers.ts +37 -1
  40. package/src/replay/index.ts +2 -0
  41. package/src/replay/judgeDiffShape.test.ts +43 -0
  42. package/src/replay/seamGate.test.ts +62 -0
  43. package/src/step/LandStep.ts +1 -0
  44. package/src/step/planStart.ts +3 -2
  45. package/src/step/planStep.test.ts +32 -0
  46. package/src/step/planStep.ts +3 -1
  47. package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
  48. package/src/testkit/EmittedScriptRecognizer.ts +12 -0
  49. package/src/testkit/FakeGitOperations.ts +5 -0
  50. package/src/testkit/GitTiers.ts +158 -0
  51. package/src/testkit/InMemRepo.ts +38 -3
  52. package/src/testkit/TestRepo.ts +3 -0
  53. package/src/testkit/steps.ts +18 -0
  54. package/src/wire/BeatDocument.test.ts +25 -0
  55. package/src/wire/BeatDocument.ts +4 -2
  56. package/src/wire/BeatPlain.test.ts +1 -0
  57. package/src/wire/BeatStatus.test.ts +2 -0
  58. package/src/wire/BeatStatus.ts +13 -2
  59. package/src/wire/WireSchema.test.ts +10 -0
  60. package/src/wire/types.ts +8 -0
  61. package/src/workflow/Workflow.ts +1 -0
  62. package/src/workflow/index.ts +8 -1
  63. package/src/workflow/vars.test.ts +23 -1
  64. package/src/workflow/vars.ts +24 -0
  65. package/src/workflows/feature/feature.ts +9 -2
  66. package/src/workflows/feature/index.ts +1 -0
  67. package/src/workflows/feature/seam-pin.feature +52 -0
  68. package/src/workflows/feature/seam.feature +387 -0
  69. package/src/workflows/feature/seam.test.ts +88 -0
  70. package/src/workflows/feature/seam.ts +110 -0
  71. package/src/workflows/index.ts +1 -0
  72. package/src/workflows/shared/options.test.ts +12 -1
  73. package/src/workflows/shared/options.ts +1 -0
  74. package/src/workflows/shared/skills.test.ts +20 -2
  75. package/src/workflows/shared/skills.ts +6 -0
  76. package/src/workflows/shared/text.test.ts +59 -0
  77. package/src/workflows/shared/text.ts +86 -0
  78. package/src/workflows/shared/vars.ts +4 -0
  79. package/src/workflows/shared/workflows.steps.ts +39 -1
@@ -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
  }
@@ -13,7 +13,14 @@ export {
13
13
  walkUp,
14
14
  } from "./discovery.js"
15
15
  export { formatDiagnostic } from "./Diagnostic.js"
16
- export { resolveVars, multilineSetting, isSettingName, SETTING_NAME_RULE } from "./vars.js"
16
+ export {
17
+ resolveVars,
18
+ resolveStartVars,
19
+ StartFlags,
20
+ multilineSetting,
21
+ isSettingName,
22
+ SETTING_NAME_RULE,
23
+ } from "./vars.js"
17
24
  export { resolveScopeAccess, resolveScopeSkills } from "./skills.js"
18
25
  export { seededValidateCommand, isSeededValidateCommand } from "./SteeringFormats.js"
19
26
  export {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { resolveVars } from "./vars.js"
2
+ import { resolveStartVars, resolveVars } from "./vars.js"
3
3
 
4
4
  describe("resolveVars — GTD_FLAG_* belongs to feature flags", () => {
5
5
  it.each(["flag_x", "FLAG_X", "Flag_x"])("%s keeps its default with GTD_FLAG_X set", (name) => {
@@ -14,3 +14,25 @@ describe("resolveVars — GTD_FLAG_* belongs to feature flags", () => {
14
14
  expect(resolveVars({ [name]: "off" }, {}, { [key]: "on" })).toEqual({ [name]: "on" })
15
15
  })
16
16
  })
17
+
18
+ describe("resolveStartVars", () => {
19
+ it("a declared flag_x takes the StartFlags value", () => {
20
+ expect(resolveStartVars({ flag_x: "0" }, { flag_x: "1" }, {}, {})).toEqual({ flag_x: "1" })
21
+ })
22
+
23
+ it("never introduces an undeclared flag", () => {
24
+ expect(resolveStartVars({ a: "1" }, { flag_x: "1" }, {}, {})).toEqual({ a: "1" })
25
+ })
26
+
27
+ it("--var flag_x beats the flag", () => {
28
+ expect(resolveStartVars({ flag_x: "0" }, { flag_x: "1" }, { flag_x: "0" }, {})).toEqual({
29
+ flag_x: "0",
30
+ })
31
+ })
32
+
33
+ it("GTD_FLAG_X never overrides, while GTD_<NAME> still does for other names", () => {
34
+ expect(
35
+ resolveStartVars({ flag_x: "0", a: "1" }, {}, {}, { GTD_FLAG_X: "1", GTD_A: "2" }),
36
+ ).toEqual({ flag_x: "0", a: "2" })
37
+ })
38
+ })
@@ -1,5 +1,12 @@
1
+ import { Context } from "effect"
2
+
1
3
  const PREFIX = "GTD_"
2
4
 
5
+ /** The live release flags as `flag_<name>` settings, provided by the CLI; absent means no flags. */
6
+ export class StartFlags extends Context.Reference<StartFlags>()("StartFlags", {
7
+ defaultValue: (): Readonly<Record<string, string>> => ({}),
8
+ }) {}
9
+
3
10
  /**
4
11
  * One kind of setting, later wins: the workflow's own defaults, `--var`
5
12
  * overrides (process settings only; environment settings pass `{}`), then
@@ -22,6 +29,23 @@ export const resolveVars = (
22
29
  return merged
23
30
  }
24
31
 
32
+ /**
33
+ * `resolveVars` for a process being STARTED: `flags` land between the workflow
34
+ * defaults and `--var`, for already-declared names only. A running process never
35
+ * calls this — it replays its pinned record.
36
+ */
37
+ export const resolveStartVars = (
38
+ workflowVars: Readonly<Record<string, string>>,
39
+ flags: Readonly<Record<string, string>>,
40
+ entryVars: Readonly<Record<string, string>>,
41
+ env: Readonly<Record<string, string | undefined>>,
42
+ ): Record<string, string> => {
43
+ const declared = Object.fromEntries(
44
+ Object.entries(flags).filter(([name]) => Object.hasOwn(workflowVars, name)),
45
+ )
46
+ return resolveVars({ ...workflowVars, ...declared }, entryVars, env)
47
+ }
48
+
25
49
  /** The first process setting whose value spans lines — a `Gtd-Var:` trailer cannot carry it. */
26
50
  export const multilineSetting = (vars: Readonly<Record<string, string>>): string | undefined =>
27
51
  Object.keys(vars)
@@ -7,6 +7,7 @@ import {
7
7
  revertScript,
8
8
  run,
9
9
  start,
10
+ vars,
10
11
  } from "../../flows/index.js"
11
12
  import { gate } from "../shared/health.js"
12
13
  import { shipped } from "../shared/options.js"
@@ -16,6 +17,7 @@ import * as t from "../shared/text.js"
16
17
  import { packages } from "./packages.js"
17
18
  import { architecture, design } from "./planning.js"
18
19
  import { buildTail, type ReviewOutcome } from "./review.js"
20
+ import { seamBuild } from "./seam.js"
19
21
 
20
22
  // The ordinary start. Any change to the tree starts a process: `idle` →
21
23
  // `unwind` reverts the sketch (its intent survives in history) → a
@@ -54,8 +56,13 @@ export const planAndBuild = async (firstBase: string): Promise<void> => {
54
56
  let base = firstBase
55
57
  for (;;) {
56
58
  await design(base)
57
- await architecture()
58
- await packages()
59
+ // GTD_FLAG_NAIVE_PASS: the record is pinned at the process start, so a mid-process flip never moves a process.
60
+ if (vars.flag_naive_pass === "1") {
61
+ await seamBuild()
62
+ } else {
63
+ await architecture()
64
+ await packages()
65
+ }
59
66
  const outcome = await buildTail(false, base, true)
60
67
  if (outcome.verdict === "signoff") return
61
68
  await reUnwind(outcome)
@@ -4,6 +4,7 @@ export * from "./steps.js"
4
4
  export * from "./planning.js"
5
5
  export * from "./packages.js"
6
6
  export * from "./refactor.js"
7
+ export * from "./seam.js"
7
8
  export { buildTail, type ReviewOutcome } from "./review.js"
8
9
  export * from "./feature.js"
9
10
  export * from "../shared/reserved.js"
@@ -0,0 +1,52 @@
1
+ @inmem
2
+ Feature: a process keeps the path it started on when GTD_FLAG_NAIVE_PASS changes
3
+
4
+ The flag is recorded in the opening commit, so flipping it mid-process never
5
+ moves a process between the architecture path and the naive-pass path.
6
+
7
+ Background:
8
+ Given the shipped workflows, as gtd install scaffolds them
9
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
10
+ And a file "src/calc.ts" with:
11
+ """
12
+ export const add = (a: number, b: number) => a + b
13
+ """
14
+ And the working tree is committed as "feat: add calculator"
15
+ And a file "TODO.md" with:
16
+ """
17
+ # Add subtract
18
+
19
+ subtract(a, b) returns the difference.
20
+ """
21
+ And step "unwind" changes nothing
22
+ And step "start-gate.check" changes nothing
23
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
24
+ """
25
+ # Requirements
26
+
27
+ ## Concerns
28
+
29
+ ### Subtract
30
+
31
+ - `subtract(a, b)` returns `a - b`.
32
+ """
33
+
34
+ Scenario: a process started with the flag off stays on architecture after the flag turns on
35
+ Given the ambient environment variable "GTD_FLAG_NAIVE_PASS" is "0"
36
+ When the process is driven
37
+ Then the process rests at "design.gate.answer"
38
+ Given the ambient environment variable "GTD_FLAG_NAIVE_PASS" is "1"
39
+ And step "design.gate.answer" changes nothing
40
+ When the process is driven
41
+ Then the process rests at "architecture.author"
42
+ And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_naive_pass=0"
43
+
44
+ Scenario: a process started with the flag on stays on the naive pass after the flag turns off
45
+ Given the ambient environment variable "GTD_FLAG_NAIVE_PASS" is "1"
46
+ When the process is driven
47
+ Then the process rests at "design.gate.answer"
48
+ Given the ambient environment variable "GTD_FLAG_NAIVE_PASS" is "0"
49
+ And step "design.gate.answer" changes nothing
50
+ When the process is driven
51
+ Then the process rests at "seam.naive"
52
+ And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_naive_pass=1"