@pmelab/gtd 59.0.1 → 60.1.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 (108) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +48 -32
  3. package/claude/hooks/doors.ts +8 -4
  4. package/dist/chunks/flows-DENt0uWV.mjs +707 -0
  5. package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
  6. package/dist/chunks/testkit-BhTey1Do.mjs +30517 -0
  7. package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +11285 -19864
  10. package/dist/gtd.core.mjs +593 -0
  11. package/dist/steering.mjs +2 -2
  12. package/dist/testkit/gherkin.mjs +246 -37
  13. package/dist/testkit.mjs +3 -2
  14. package/dist/web/main.js +234 -232
  15. package/dist/wire.mjs +2 -2
  16. package/dist/workflow-kit.mjs +3 -3
  17. package/package.json +7 -7
  18. package/skills/authoring/SKILL.md +66 -60
  19. package/src/cli/Cli.test.ts +185 -184
  20. package/src/cli/Cli.ts +219 -224
  21. package/src/cli/History.ts +9 -4
  22. package/src/cli/Judges.ts +1 -1
  23. package/src/cli/Output.test.ts +35 -0
  24. package/src/cli/Output.ts +34 -3
  25. package/src/cli/Program.test.ts +78 -84
  26. package/src/cli/Program.ts +192 -128
  27. package/src/cli/ToolCommands.ts +2 -2
  28. package/src/edge/Edge.test.ts +72 -62
  29. package/src/edge/Edge.ts +6 -6
  30. package/src/flows/factory.test.ts +14 -0
  31. package/src/flows/factory.ts +22 -5
  32. package/src/flows/helpers.test.ts +35 -0
  33. package/src/flows/helpers.ts +19 -10
  34. package/src/flows/index.test.ts +66 -0
  35. package/src/flows/index.ts +109 -5
  36. package/src/flows/internal.ts +7 -0
  37. package/src/flows/runtime.test.ts +192 -1
  38. package/src/flows/runtime.ts +189 -55
  39. package/src/gherkin/git-control.feature +6 -6
  40. package/src/gherkin/index.test.ts +127 -0
  41. package/src/gherkin/index.ts +370 -51
  42. package/src/gherkin/messages.test.ts +38 -4
  43. package/src/gherkin/messages.ts +21 -4
  44. package/src/gherkin/more-steps.feature +170 -0
  45. package/src/gherkin/scenario-end.feature +101 -0
  46. package/src/gherkin/testkit.feature +83 -7
  47. package/src/install/Install.test.ts +7 -7
  48. package/src/install/Install.ts +8 -8
  49. package/src/install/Version.ts +9 -3
  50. package/src/install/index.ts +1 -1
  51. package/src/platform/CommandRunner.ts +13 -11
  52. package/src/platform/Commentary.ts +2 -2
  53. package/src/platform/Git.test.ts +4 -5
  54. package/src/platform/Git.ts +42 -38
  55. package/src/platform/Host.ts +1 -1
  56. package/src/platform/NodeServices.ts +11 -0
  57. package/src/platform/Workspace.test.ts +4 -5
  58. package/src/platform/Workspace.ts +3 -3
  59. package/src/platform/WorktreeState.test.ts +2 -3
  60. package/src/platform/WorktreeState.ts +1 -1
  61. package/src/platform/index.ts +2 -1
  62. package/src/replay/Replay.test.ts +13 -2
  63. package/src/replay/Replay.ts +19 -30
  64. package/src/replay/judgeDiffShape.test.ts +1 -1
  65. package/src/steering/Footnotes.test.ts +1 -1
  66. package/src/steering/SteeringFormats.test.ts +1 -1
  67. package/src/steering/freeform.test.ts +1 -1
  68. package/src/steering/index.test.ts +27 -1
  69. package/src/steering/index.ts +6 -104
  70. package/src/steering/internal.ts +11 -0
  71. package/src/steering/qa.test.ts +7 -2
  72. package/src/steering/reads.test.ts +42 -0
  73. package/src/steering/reads.ts +43 -0
  74. package/src/steering/registry.ts +93 -0
  75. package/src/steering/review.test.ts +2 -2
  76. package/src/step/planStart.test.ts +18 -16
  77. package/src/step/planStart.ts +12 -12
  78. package/src/testkit/TestRepo.test.ts +245 -7
  79. package/src/testkit/TestRepo.ts +271 -40
  80. package/src/testkit/imports.test.ts +58 -0
  81. package/src/testkit/imports.ts +102 -0
  82. package/src/testkit/index.test.ts +28 -0
  83. package/src/testkit/index.ts +14 -2
  84. package/src/twins/EmittedScriptRecognizer.ts +2 -6
  85. package/src/twins/EmittedScripts.test.ts +5 -3
  86. package/src/twins/GitTiers.ts +6 -7
  87. package/src/twins/InMemRepo.ts +1 -0
  88. package/src/twins/Layers.test.ts +1 -1
  89. package/src/twins/Layers.ts +10 -3
  90. package/src/twins/cliIo.ts +9 -4
  91. package/src/wire/Script.ts +11 -0
  92. package/src/wire/WireSchema.test.ts +3 -0
  93. package/src/wire/golden/1/script.json +4 -0
  94. package/src/wire/index.ts +1 -0
  95. package/src/workflow/SteeringFormats.test.ts +1 -1
  96. package/src/workflow/compile.ts +2 -2
  97. package/src/workflow/discovery.test.ts +27 -10
  98. package/src/workflow/discovery.ts +5 -6
  99. package/src/workflow/index.ts +1 -0
  100. package/src/workflow/load.test.ts +316 -119
  101. package/src/workflow/load.ts +478 -305
  102. package/src/workflow/pin.test.ts +159 -0
  103. package/src/workflow/pin.ts +72 -0
  104. package/src/workflow/vars.ts +3 -3
  105. package/src/workflow-kit/index.test.ts +25 -0
  106. package/src/workflow-kit/index.ts +37 -4
  107. package/src/workflow-kit/review.ts +2 -2
  108. package/dist/chunks/testkit-07Z_ENch.mjs +0 -32175
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert"
2
- import type { TestRepo } from "../testkit/index.js"
2
+ import type { BeatDocument, TestRepo } from "../testkit/index.js"
3
3
 
4
4
  const CONTENT_LINES = 5
5
5
  const SUBJECTS = 5
@@ -22,10 +22,19 @@ const beatLines = async (repo: TestRepo): Promise<string[]> => {
22
22
  }
23
23
  }
24
24
 
25
+ /** A scripted step no drive reached, and whether a Background or the scenario scripted it. */
26
+ export interface Unreached {
27
+ readonly step: string
28
+ readonly origin: "Background" | "scenario"
29
+ }
30
+
31
+ export const unreachedList = (steps: readonly Unreached[]): string =>
32
+ steps.map(({ step, origin }) => `"${step}" (${origin})`).join(", ")
33
+
25
34
  /** Where the process rests, for a failed outcome to end with. Never throws: it describes a failure. */
26
35
  export const restDescription = async (
27
36
  repo: () => TestRepo,
28
- neverUsed: readonly string[],
37
+ neverUsed: readonly Unreached[],
29
38
  ): Promise<string> => {
30
39
  let lines: string[]
31
40
  try {
@@ -41,8 +50,7 @@ export const restDescription = async (
41
50
  } catch (error) {
42
51
  lines = [`unknown — ${reason(error)}`]
43
52
  }
44
- if (neverUsed.length > 0)
45
- lines.push(`actors never used: ${neverUsed.map((step) => `"${step}"`).join(", ")}`)
53
+ if (neverUsed.length > 0) lines.push(`actors never used: ${unreachedList(neverUsed)}`)
46
54
  return ["where the process rests:", ...lines.map((line) => ` ${line}`)].join("\n")
47
55
  }
48
56
 
@@ -82,3 +90,12 @@ export const assertLacks = (where: string, text: string, content: string | undef
82
90
  if (content === undefined || content.includes(text))
83
91
  assert.fail(containsFailure(where, text, content, false))
84
92
  }
93
+
94
+ /** A rest at `step`; a stall there is no rest, so it points to the step that asserts one. */
95
+ export const assertRestsAt = (beat: BeatDocument, step: string): void => {
96
+ if (beat.kind === "stalled")
97
+ assert.fail(
98
+ `expected a rest at "${step}", but the process stalls at "${beat.state}" — assert a stall with: the process stalls at "${beat.state}"`,
99
+ )
100
+ assert.strictEqual(beat.state, step)
101
+ }
@@ -0,0 +1,170 @@
1
+ @inmem
2
+ Feature: steps for checks, stalls, beat fields, commit bodies and the environment
3
+
4
+ Background:
5
+ Given a gtd config with:
6
+ """
7
+ import { agent, check, envSettings, human, scope, settings, workflow } from "@pmelab/gtd/flows"
8
+
9
+ const defaults = { title: "untitled" }
10
+ const envDefaults = { greeting: "hi" }
11
+
12
+ export const tested = workflow(async () => {
13
+ await scope({ name: "build", skills: ["tdd", "review"] }, async () => {
14
+ await agent("plan", "Plan.", { model: "smart" })
15
+ })
16
+ const green = await check("test", "npm test", ".gtd/FEEDBACK.md")
17
+ await agent(green ? "ship" : "repair", `Say ${envSettings.greeting} to ${settings.title}.`)
18
+ await human("done")
19
+ }, { defaults, envDefaults })
20
+
21
+ export default tested
22
+ """
23
+
24
+ Scenario: the resting step's fields are read by name
25
+ When gtd starts workflow "tested"
26
+ Then the process rests at "build.plan"
27
+ And the resting step's model is "smart"
28
+ And the resting step's idle is "false"
29
+ And the resting step's skills includes "review"
30
+ And the resting step's content includes "Plan"
31
+
32
+ Scenario: a quoted argument keeps its spaces
33
+ Given step "build.plan" writes "PLAN.md" with:
34
+ """
35
+ add two numbers
36
+ """
37
+ And check step "test" passes
38
+ When gtd starts workflow "tested" with "--var 'title=a calculator'"
39
+ And the process is driven
40
+ Then the resting step's content contains "Say hi to a calculator."
41
+
42
+ Scenario: an agent step that changes nothing stalls
43
+ Given step "build.plan" changes nothing
44
+ When gtd starts workflow "tested"
45
+ And the process is driven
46
+ Then the process stalls at "build.plan"
47
+
48
+ Scenario: a passing check step goes on, and a variable changed mid-process is seen from then on
49
+ Given step "build.plan" writes "PLAN.md" with:
50
+ """
51
+ add two numbers
52
+ """
53
+ And check step "test" passes
54
+ When gtd starts workflow "tested"
55
+ And the process is driven
56
+ Then the process rests at "ship"
57
+ And the resting step's content contains "Say hi to untitled."
58
+ Given from now on the environment variable "GTD_GREETING" is "hello"
59
+ Then the resting step's content contains "Say hello to untitled."
60
+
61
+ Scenario: a failing check step writes its report
62
+ Given the environment variable "GTD_GREETING" is "hey"
63
+ And step "build.plan" writes "PLAN.md" with:
64
+ """
65
+ add two numbers
66
+ """
67
+ And check step "test" fails with:
68
+ """
69
+ 1 test failing
70
+ """
71
+ When gtd starts workflow "tested"
72
+ And the process is driven
73
+ Then the process rests at "repair"
74
+ And ".gtd/FEEDBACK.md" contains "1 test failing"
75
+ And the resting step's content contains "Say hey to untitled."
76
+
77
+ Scenario: a check step scripted at a step that is no check refuses the drive
78
+ Given check step "build.plan" passes
79
+ When gtd starts workflow "tested"
80
+ Then driving the process is refused with 'step "build.plan" is no check step; its actor is agent'
81
+
82
+ Scenario: commit bodies are read whole, every commit of a subject at once
83
+ Given the working tree is committed with the message:
84
+ """
85
+ chore: seed
86
+
87
+ Seeded by hand.
88
+
89
+ Refs: #1
90
+ """
91
+ And step "build.plan" writes "PLAN.md" with:
92
+ """
93
+ add two numbers
94
+ """
95
+ And check step "test" passes
96
+ When gtd starts workflow "tested"
97
+ And the process is driven
98
+ Then the last commit body contains "Gtd-Step: test#1"
99
+ And the commit "gtd(agent): build.plan → test" has a body containing "Gtd-Step: build.plan#1"
100
+ And the commit "chore: seed" has a body containing "Seeded by hand."
101
+ And no commit body contains "never written"
102
+
103
+ Scenario: a canned command matched by pattern, a branch at a ref, and a file as committed there
104
+ Given a file "a.txt" with:
105
+ """
106
+ one
107
+ """
108
+ And the working tree is committed as "chore: one"
109
+ And a file "a.txt" with:
110
+ """
111
+ two
112
+ """
113
+ And the working tree is committed as "chore: two"
114
+ And a branch "old" at "HEAD~1"
115
+ And the command matching "^npm (test|run)" exits 1 with:
116
+ """
117
+ red
118
+ """
119
+ And step "build.plan" writes "PLAN.md" with:
120
+ """
121
+ add two numbers
122
+ """
123
+ And step "test" runs its script
124
+ When gtd starts workflow "tested"
125
+ And the process is driven
126
+ Then the process rests at "repair"
127
+ And "a.txt" as committed at "old" contains "one"
128
+ And ".gtd/FEEDBACK.md" contains "red"
129
+
130
+ Scenario: text is appended to a file, and a step overwrites one only if it exists
131
+ Given a file "NOTES.md" with:
132
+ """
133
+ first
134
+ """
135
+ And text is appended to "NOTES.md":
136
+ """
137
+ second
138
+ """
139
+ And step "build.plan" overwrites "PLAN.md", if it exists, with:
140
+ """
141
+ never written
142
+ """
143
+ And step "build.plan" overwrites "NOTES.md", if it exists, with:
144
+ """
145
+ rewritten
146
+ """
147
+ And step "test" runs its script
148
+ And the command "npm test" exits 0
149
+ When gtd starts workflow "tested"
150
+ And the process is driven
151
+ Then the process rests at "ship"
152
+ And "NOTES.md" contains "rewritten"
153
+ And "PLAN.md" does not exist
154
+
155
+ Scenario: a config given as source imports a module the scenario names
156
+ Given the module "src/gherkin/tiny.fixture.ts" is importable as "#workflow"
157
+ And a gtd config with:
158
+ """
159
+ export { tiny, tiny as default } from "#workflow"
160
+ """
161
+ And a file "IDEA.md" with:
162
+ """
163
+ a calculator
164
+ """
165
+ And step "plan" writes "PLAN.md" with:
166
+ """
167
+ add two numbers
168
+ """
169
+ When the process is driven
170
+ Then the process rests at "sign-off"
@@ -0,0 +1,101 @@
1
+ @inmem
2
+ Feature: a scenario's scripted steps must be reached by its end, not by every drive
3
+
4
+ Background:
5
+ Given a gtd config with:
6
+ """
7
+ import { agent, human, workflow } from "@pmelab/gtd/flows"
8
+
9
+ export const shipping = workflow(async () => {
10
+ await agent("plan", "Write PLAN.md.")
11
+ await human("sign-off")
12
+ await agent("ship", "Ship it.")
13
+ await human("done")
14
+ })
15
+
16
+ export default shipping
17
+ """
18
+ And step "ship" writes "SHIPPED.md" with:
19
+ """
20
+ shipped
21
+ """
22
+
23
+ Scenario: an actor the Background scripts, reached only by the second drive, passes
24
+ Given step "plan" writes "PLAN.md" with:
25
+ """
26
+ add two numbers
27
+ """
28
+ When gtd starts workflow "shipping"
29
+ And the process is driven
30
+ Then the process rests at "sign-off"
31
+ Given step "sign-off" writes "SIGNED.md" with:
32
+ """
33
+ signed
34
+ """
35
+ When the process is driven
36
+ Then the process rests at "done"
37
+ And "SHIPPED.md" contains "shipped"
38
+
39
+ Scenario: an actor the Background scripts may go unreached in a scenario another scenario reaches it in
40
+ Given step "plan" writes "PLAN.md" with:
41
+ """
42
+ add two numbers
43
+ """
44
+ When gtd starts workflow "shipping"
45
+ And the process is driven
46
+ Then the process rests at "sign-off"
47
+
48
+ @fails
49
+ Scenario: an actor the scenario scripts that no drive reaches fails it at its end
50
+ Given step "plan" writes "PLAN.md" with:
51
+ """
52
+ add two numbers
53
+ """
54
+ And step "done" writes "DONE.md" with:
55
+ """
56
+ done
57
+ """
58
+ When gtd starts workflow "shipping"
59
+ And the process is driven
60
+ Then the process rests at "sign-off"
61
+
62
+ @fails
63
+ Scenario: a lenient drive leaves an actor no drive reaches to fail the scenario at its end
64
+ Given step "plan" writes "PLAN.md" with:
65
+ """
66
+ add two numbers
67
+ """
68
+ And step "done" writes "DONE.md" with:
69
+ """
70
+ done
71
+ """
72
+ When gtd starts workflow "shipping"
73
+ And the process is driven leniently
74
+ Then the process rests at "sign-off"
75
+
76
+ Scenario: a refused drive excuses the actors it was handed
77
+ Given step "plan" writes "PLAN.md" with:
78
+ """
79
+ add two numbers
80
+ """
81
+ And step "sign-off" changes nothing
82
+ And step "done" writes "DONE.md" with:
83
+ """
84
+ done
85
+ """
86
+ When gtd starts workflow "shipping"
87
+ Then driving the process is refused with 'the actor for human step "sign-off" changed nothing'
88
+
89
+ @fails
90
+ Scenario: an actor scripted after a refused drive, and reached by no later one, fails the scenario
91
+ Given step "plan" writes "PLAN.md" with:
92
+ """
93
+ add two numbers
94
+ """
95
+ And step "sign-off" changes nothing
96
+ When gtd starts workflow "shipping"
97
+ Then driving the process is refused with 'the actor for human step "sign-off" changed nothing'
98
+ Given step "done" writes "DONE.md" with:
99
+ """
100
+ done
101
+ """
@@ -7,9 +7,9 @@ Feature: testkit drives any workflow against the real engine
7
7
  Background:
8
8
  Given a gtd config with:
9
9
  """
10
- import { agent, human, judge, read, refuse, run } from "@pmelab/gtd/flows"
10
+ import { agent, human, judge, read, refuse, run, workflow } from "@pmelab/gtd/flows"
11
11
 
12
- export const tiny = async () => {
12
+ export const tiny = workflow(async () => {
13
13
  await agent("plan", "Write PLAN.md.")
14
14
  await run("check", "npm test > .gtd/FEEDBACK.md 2>&1 && rm .gtd/FEEDBACK.md")
15
15
  if (read(".gtd/FEEDBACK.md") !== undefined) await agent("repair", "Make the suite pass.")
@@ -18,11 +18,11 @@ Feature: testkit drives any workflow against the real engine
18
18
  evidence: { plan: read("PLAN.md") ?? "" },
19
19
  })
20
20
  await human(answers.ready?.answer === "yes" ? "ship" : "rework")
21
- }
21
+ })
22
22
 
23
- export const picky = async () => {
23
+ export const picky = workflow(async () => {
24
24
  refuse("gtd: picky refuses every start")
25
- }
25
+ })
26
26
 
27
27
  export default tiny
28
28
  """
@@ -83,9 +83,12 @@ Feature: testkit drives any workflow against the real engine
83
83
  And "PLAN.md" contains "add two numbers"
84
84
 
85
85
  Scenario: a judge step with queued actions refuses the drive
86
- Given step "plan" changes nothing
86
+ Given step "plan" writes "PLAN.md" with:
87
+ """
88
+ add two numbers
89
+ """
87
90
  And step "check" changes nothing
88
- And step "ready" is prompted with "never seen"
91
+ And step "ready" changes nothing
89
92
  And judge step "ready" answers:
90
93
  """
91
94
  [{ "id": "ready", "answer": true, "p": 0.9 }]
@@ -98,6 +101,79 @@ Feature: testkit drives any workflow against the real engine
98
101
  When gtd starts workflow "tiny"
99
102
  Then driving the process is refused with 'was not prompted with "Write a poem"'
100
103
 
104
+ Scenario: a step only asserted to be prompted is where the drive rests, and nothing lands there
105
+ Given step "plan" is prompted with "Write PLAN.md."
106
+ When gtd starts workflow "tiny"
107
+ And the process is driven
108
+ Then the process rests at "plan"
109
+ And the last commit subject is "gtd(human): plan"
110
+
111
+ Scenario: a prompt asserted beside a script is checked at every visit, before the step acts
112
+ Given step "check" is prompted with "npm test"
113
+ And step "check" writes ".gtd/FEEDBACK.md" with:
114
+ """
115
+ 1 test failing
116
+ """
117
+ And step "plan" writes "PLAN.md" with:
118
+ """
119
+ add two numbers
120
+ """
121
+ When gtd starts workflow "tiny"
122
+ And the process is driven
123
+ Then the process rests at "repair"
124
+
125
+ @fails
126
+ Scenario: a prompt asserted at a step no drive reaches fails the scenario at its end
127
+ Given step "plan" changes nothing
128
+ And step "repair" is prompted with "Make the suite pass."
129
+ When gtd starts workflow "tiny"
130
+ And the process is driven
131
+ Then the process stalls at "plan"
132
+
133
+ Scenario: a human step scripted to change nothing refuses the drive instead of repeating it
134
+ Given step "plan" writes "PLAN.md" with:
135
+ """
136
+ add two numbers
137
+ """
138
+ And step "check" changes nothing
139
+ And judge step "ready" answers:
140
+ """
141
+ [{ "id": "ready", "answer": true, "p": 0.9 }]
142
+ """
143
+ And step "ship" changes nothing
144
+ When gtd starts workflow "tiny"
145
+ Then driving the process is refused with 'the actor for human step "ship" changed nothing'
146
+
147
+ Scenario: a lenient drive rests at an unscripted check step
148
+ Given step "plan" writes "PLAN.md" with:
149
+ """
150
+ add two numbers
151
+ """
152
+ When gtd starts workflow "tiny"
153
+ And the process is driven leniently
154
+ Then the process rests at "check"
155
+ And the resting step's content contains "npm test"
156
+
157
+ Scenario: a step scripted for a later drive counts once any drive reached it
158
+ Given step "plan" writes "PLAN.md" with:
159
+ """
160
+ add two numbers
161
+ """
162
+ And step "check" writes ".gtd/FEEDBACK.md" with:
163
+ """
164
+ 1 test failing
165
+ """
166
+ When gtd starts workflow "tiny"
167
+ And the process is driven
168
+ Then the process rests at "repair"
169
+ Given step "repair" deletes ".gtd/FEEDBACK.md"
170
+ And judge step "ready" answers:
171
+ """
172
+ [{ "id": "ready", "answer": false, "p": 0.9 }]
173
+ """
174
+ When the process is driven
175
+ Then the process rests at "rework"
176
+
101
177
  Scenario: a start the workflow refuses is reported with its message
102
178
  Then starting workflow "picky" with "--var mode=bad" is refused with "picky refuses every start"
103
179
 
@@ -164,8 +164,8 @@ describe("renderBriefing", () => {
164
164
 
165
165
  it("contains the door invocation, exec'ing the loop", () => {
166
166
  const briefing = renderBriefing()
167
- expect(briefing).toContain('gtd door "$@"')
168
- expect(briefing).toContain("gtd doors")
167
+ expect(briefing).toContain('gtd start "$@"')
168
+ expect(briefing).toMatch(/bare `gtd start` lists/i)
169
169
  expect(briefing).toMatch(/exec.{0,20}GTD_BUILD/s)
170
170
  })
171
171
 
@@ -418,19 +418,19 @@ describe("DOOR_COMMAND", () => {
418
418
 
419
419
  it("cds to the repository root before any gtd call", () => {
420
420
  const cdIndex = DOOR_COMMAND.indexOf('cd "$(git rev-parse --show-toplevel)"')
421
- const gtdIndex = DOOR_COMMAND.indexOf("gtd door")
421
+ const gtdIndex = DOOR_COMMAND.indexOf("gtd start")
422
422
  expect(cdIndex).toBeGreaterThan(-1)
423
423
  expect(gtdIndex).toBeGreaterThan(cdIndex)
424
424
  })
425
425
 
426
- it("hands every argument to gtd door", () => {
427
- expect(DOOR_COMMAND).toContain('gtd door "$@"')
426
+ it("hands every argument to gtd start", () => {
427
+ expect(DOOR_COMMAND).toContain('gtd start "$@"')
428
428
  })
429
429
 
430
430
  it("captures the emitted script via command substitution, never a pipe into sh", () => {
431
- expect(DOOR_COMMAND).toContain('script="$(gtd door "$@")"')
431
+ expect(DOOR_COMMAND).toContain('script="$(gtd start "$@")"')
432
432
  expect(DOOR_COMMAND).toContain('sh -c "$script"')
433
- expect(DOOR_COMMAND).not.toMatch(/gtd door[^\n]*\|\s*sh/)
433
+ expect(DOOR_COMMAND).not.toMatch(/gtd start[^\n]*\|\s*sh/)
434
434
  })
435
435
 
436
436
  it("prints usage to stderr and exits 2 when no name is given", () => {
@@ -175,7 +175,7 @@ if [ -z "\${1:-}" ]; then
175
175
  exit 2
176
176
  fi
177
177
  cd "$(git rev-parse --show-toplevel)"
178
- script="$(gtd door "$@")"
178
+ script="$(gtd start "$@")"
179
179
  sh -c "$script"
180
180
  exec "$GTD_BUILD"`
181
181
 
@@ -365,7 +365,7 @@ const DRIVER_OBLIGATIONS = `
365
365
  \`--json=judge\`, pipes that rendered \`{ schema, state, questions }\` document to a
366
366
  judge model, and pipes ITS verdict (one \`{ id, answer, p }\` per pending
367
367
  question, as JSON on stdin) to \`gtd judge answer\` — the FIFTH command
368
- that changes anything, alongside \`gtd land\`/\`gtd --workflow\`/\`gtd
368
+ that changes anything, alongside \`gtd land\`/\`gtd start\`/\`gtd
369
369
  abandon\`/\`gtd restore\`. It follows the exact same required-half /
370
370
  optional-half script contract as \`gtd land\` in obligation 8 above:
371
371
  read \`--json=script\` (and, if you want them, \`--json=settled\`/
@@ -565,9 +565,9 @@ ${EDIT_COMMAND}
565
565
 
566
566
  ### \`gtd-door <name> [args...]\` — start a process through a door and drive it
567
567
 
568
- Runs \`gtd door <name> [args...]\`, captured by command substitution — never a
568
+ Runs \`gtd start <name> [args...]\`, captured by command substitution — never a
569
569
  pipe: a pipeline reports only its LAST command's exit status, so
570
- \`gtd door ... | sh\` under \`set -e\` would sail past a refusal and \`exec\` a
570
+ \`gtd start ... | sh\` under \`set -e\` would sail past a refusal and \`exec\` a
571
571
  loop over a process that was never started. It runs the captured script, then
572
572
  \`exec\`s the suite's RESOLVED \`gtd-build\` path — never the literal string
573
573
  \`gtd-build\`. This is one invocation that carries the process all the way to
@@ -575,9 +575,9 @@ its next human gate, not just a starter: \`gtd-door review\` on a RED baseline
575
575
  hands off to \`gtd-build\`, which halts at the blocked gate and prints it,
576
576
  rather than starting the review; \`gtd-door fix\` on a GREEN suite is a no-op
577
577
  straight back to \`idle\` — the exec'd \`gtd-build\` exits immediately on it.
578
- \`gtd doors\` lists the doors a repository offers (those of the workflows its
579
- config exports — gtd-workflows' \`default\` brings \`fix\` and
580
- \`review [base]\` — plus any the config declares). Refuses with
578
+ A bare \`gtd start\` lists what a repository can start: its workflows, and
579
+ the doors those workflows bring (gtd-workflows' \`default\` brings \`fix\`
580
+ and \`review [base]\`) plus any the config declares. Refuses with
581
581
  \`usage: gtd-door <name> [args...]\` on stderr and exit \`2\` when the name is
582
582
  missing — nothing was even attempted. Default path: \`~/.local/bin/gtd-door\`.
583
583
 
@@ -682,7 +682,7 @@ const PREREQUISITES = `
682
682
  ## Prerequisites and portability
683
683
 
684
684
  - A POSIX \`sh\` (dash, ash, bash's own POSIX mode, etc.) — gtd's own emitted
685
- scripts (\`gtd land --json=script\`, \`gtd --workflow <name>\`, \`gtd abandon\`,
685
+ scripts (\`gtd land --json=script\`, \`gtd start <name>\`, \`gtd abandon\`,
686
686
  \`gtd restore\`, \`gtd judge answer --json=script\`) are POSIX sh; captured,
687
687
  then piped into it (see obligation 8 above). Reading
688
688
  \`gtd next --json=<path>\`/\`gtd land --json=<path>\`'s own output needs
@@ -22,6 +22,12 @@ const findPackageJson = (fromUrl: string): string => {
22
22
  throw new Error(`package.json not found by walking up from ${fromUrl}`)
23
23
  }
24
24
 
25
- export const GTD_VERSION: string = (
26
- _require(findPackageJson(import.meta.url)) as { version: string }
27
- ).version
25
+ /** The global a pin's workflow registers itself under, ahead of the engine in the same file. */
26
+ export const PIN_KEY = Symbol.for("@pmelab/gtd/pin")
27
+
28
+ // Inside a pin, `import.meta.url` is the pin's own file, in the user's
29
+ // repository: the pin registered the version it was built by before this runs.
30
+ const pinned = (globalThis as { [PIN_KEY]?: { readonly version: string } })[PIN_KEY]?.version
31
+
32
+ export const GTD_VERSION: string =
33
+ pinned ?? (_require(findPackageJson(import.meta.url)) as { version: string }).version
@@ -1,2 +1,2 @@
1
1
  export { renderBriefing, WORKFLOWS_REPO } from "./Install.js"
2
- export { GTD_VERSION } from "./Version.js"
2
+ export { GTD_VERSION, PIN_KEY } from "./Version.js"
@@ -1,5 +1,5 @@
1
- import { Command, CommandExecutor } from "@effect/platform"
2
1
  import { Context, Effect, Layer, Stream } from "effect"
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/process"
3
3
  import { Host } from "./Host.js"
4
4
 
5
5
  /** One command run's outcome: its exit status (a signal death reported as `null`, matching `spawnSync`) and its combined output (stdout then stderr). */
@@ -23,13 +23,13 @@ interface CommandOutcome {
23
23
  * stdout/stderr, none of which this port's single-shot "run to completion"
24
24
  * shape supports.
25
25
  */
26
- export class CommandRunner extends Context.Tag("CommandRunner")<
26
+ export class CommandRunner extends Context.Service<
27
27
  CommandRunner,
28
28
  {
29
29
  /** A SPAWN failure (no `bash`, unreadable cwd) fails the Effect; a non-zero EXIT is a value — how a mode command reports "invalid"/"broken" for the caller to interpret. */
30
30
  readonly bash: (command: string) => Effect.Effect<CommandOutcome, Error>
31
31
  }
32
- >() {
32
+ >()("CommandRunner") {
33
33
  /** A test layer over a canned `bash` implementation — no subprocess. */
34
34
  static readonly layer = (
35
35
  bash: (command: string) => Effect.Effect<CommandOutcome, Error>,
@@ -39,17 +39,19 @@ export class CommandRunner extends Context.Tag("CommandRunner")<
39
39
  CommandRunner,
40
40
  Effect.gen(function* () {
41
41
  const { root } = yield* Host
42
- const executor = yield* CommandExecutor.CommandExecutor
42
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
43
43
  return {
44
44
  bash: (command: string) =>
45
45
  Effect.scoped(
46
46
  Effect.gen(function* () {
47
- const process = yield* executor.start(
48
- Command.make("bash", "-c", command).pipe(
49
- Command.workingDirectory(root),
50
- Command.stdout("pipe"),
51
- Command.stderr("pipe"),
52
- ),
47
+ const process = yield* spawner.spawn(
48
+ ChildProcess.make("bash", ["-c", command], {
49
+ cwd: root,
50
+ stdout: "pipe",
51
+ stderr: "pipe",
52
+ // Not detached, like `Git.ts`'s `run`: a background job the command starts survives it.
53
+ detached: false,
54
+ }),
53
55
  )
54
56
  const collect = (stream: typeof process.stdout) =>
55
57
  stream.pipe(Stream.decodeText(), Stream.mkString)
@@ -60,7 +62,7 @@ export class CommandRunner extends Context.Tag("CommandRunner")<
60
62
  // A signal death fails process.exitCode; map to null to match spawnSync's contract.
61
63
  process.exitCode.pipe(
62
64
  Effect.map((code): number | null => code),
63
- Effect.catchAll(() => Effect.succeed(null)),
65
+ Effect.catch(() => Effect.succeed(null)),
64
66
  ),
65
67
  ],
66
68
  { concurrency: "unbounded" },
@@ -5,13 +5,13 @@ import { Context, Effect, Layer } from "effect"
5
5
  * layer was built, never by the call site; `warn` is its ungated
6
6
  * counterpart, always written regardless of verbosity.
7
7
  */
8
- export class Narrator extends Context.Tag("Narrator")<
8
+ export class Narrator extends Context.Service<
9
9
  Narrator,
10
10
  {
11
11
  readonly narrate: (line: string) => Effect.Effect<void>
12
12
  readonly warn: (line: string) => Effect.Effect<void>
13
13
  }
14
- >() {
14
+ >()("Narrator") {
15
15
  /**
16
16
  * `write` is the raw stderr sink (`CliIo.stderr` in production, so the
17
17
  * in-memory `CliIo` used by `@inmem` e2e scenarios captures narration into
@@ -2,11 +2,10 @@ import { execFileSync } from "node:child_process"
2
2
  import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
3
3
  import { tmpdir } from "node:os"
4
4
  import { join } from "node:path"
5
- import { NodeContext } from "@effect/platform-node"
6
5
  import { Cause, Effect, Exit, Option } from "effect"
7
6
  import { describe, expect, it } from "vitest"
8
7
  import { GitService, InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
9
- import { Host } from "./index.js"
8
+ import { Host, NodeServices } from "./index.js"
10
9
  import { gitTiers, runGitServiceContract, runGitScriptContract } from "../twins/index.js"
11
10
 
12
11
  /**
@@ -78,7 +77,7 @@ describe("diffPaths [Live]", () => {
78
77
  Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
79
78
  Effect.provide(GitService.Live),
80
79
  Effect.provide(Host.layer({ root: join(root, "sub"), home: root, env: {} })),
81
- Effect.provide(NodeContext.layer),
80
+ Effect.provide(NodeServices.layer),
82
81
  ),
83
82
  )
84
83
  expect([...paths].sort()).toEqual(["sub/a.txt", "top.txt"])
@@ -131,7 +130,7 @@ describe("diffPaths [Live]", () => {
131
130
  Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
132
131
  Effect.provide(GitService.Live),
133
132
  Effect.provide(Host.layer({ root: repo, home: root, env: {} })),
134
- Effect.provide(NodeContext.layer),
133
+ Effect.provide(NodeServices.layer),
135
134
  ),
136
135
  )
137
136
  expect([...paths]).toEqual(["sub"])
@@ -155,7 +154,7 @@ describe("authorIdent [Live]", () => {
155
154
  Effect.flatMap(GitService, (g) => g.authorIdent()).pipe(
156
155
  Effect.provide(GitService.Live),
157
156
  Effect.provide(Host.layer({ root, home: root, env: {} })),
158
- Effect.provide(NodeContext.layer),
157
+ Effect.provide(NodeServices.layer),
159
158
  ),
160
159
  )
161
160
  expect(Option.isNone(ident)).toBe(true)