@pmelab/gtd 58.0.0 → 58.2.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 (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/dist/chunks/{Schema-C6fLlQzx.mjs → Schema-B3o6eRNU.mjs} +637 -3957
  4. package/dist/chunks/{reserved-BzAFXuz_.mjs → reserved-lMYCaapY.mjs} +1 -1
  5. package/dist/chunks/{review-CVqRS0mc.mjs → review-Bfaror38.mjs} +2 -2
  6. package/dist/chunks/{tail-JQzB21Q3.mjs → tail-Bd7L940p.mjs} +1 -1
  7. package/dist/chunks/testkit-JDtpNKyi.mjs +10410 -0
  8. package/dist/gtd.bundle.mjs +8 -8
  9. package/dist/testkit/gherkin.mjs +283 -0
  10. package/dist/testkit.mjs +2 -15461
  11. package/dist/workflows/cleanup.mjs +2 -2
  12. package/dist/workflows/feature.mjs +3 -3
  13. package/dist/workflows/fix.mjs +2 -2
  14. package/dist/workflows/review.mjs +3 -3
  15. package/dist/workflows/rollout.mjs +1 -1
  16. package/package.json +8 -1
  17. package/src/cli/Cli.test.ts +1 -1
  18. package/src/cli/History.test.ts +1 -1
  19. package/src/cli/Metrics.test.ts +1 -1
  20. package/src/cli/Program.test.ts +1 -1
  21. package/src/cli/Strays.test.ts +1 -1
  22. package/src/edge/Edge.test.ts +1 -1
  23. package/src/emit/Emit.ts +1 -1
  24. package/src/emit/GitScript.ts +1 -1
  25. package/src/emit/OutcomeScript.ts +1 -1
  26. package/src/gherkin/git-control.feature +78 -0
  27. package/src/{testkit/steps.ts → gherkin/index.ts} +55 -4
  28. package/src/gherkin/module-config.feature +24 -0
  29. package/src/gherkin/tiny.fixture.ts +8 -0
  30. package/src/platform/Git.test.ts +2 -2
  31. package/src/platform/RetainedHistory.test.ts +2 -2
  32. package/src/platform/Workspace.test.ts +2 -2
  33. package/src/platform/WorktreeState.test.ts +1 -1
  34. package/src/step/planStart.test.ts +1 -1
  35. package/src/testkit/TestRepo.test.ts +118 -2
  36. package/src/testkit/TestRepo.ts +64 -10
  37. package/src/testkit/index.ts +16 -9
  38. package/src/{testkit → twins}/EmittedScriptRecognizer.ts +12 -14
  39. package/src/{testkit → twins}/FlowScripts.ts +9 -8
  40. package/src/{testkit → twins}/InMemRepo.ts +26 -1
  41. package/src/{testkit → twins}/Layers.ts +14 -3
  42. package/src/twins/index.ts +13 -0
  43. package/src/workflow/discovery.test.ts +1 -1
  44. package/src/workflow/discovery.ts +3 -3
  45. package/src/workflow/load.ts +3 -3
  46. /package/src/{testkit → gherkin}/testkit.feature +0 -0
  47. /package/src/{testkit → twins}/EmittedScriptRecognizer.test.ts +0 -0
  48. /package/src/{testkit → twins}/EmittedScripts.test.ts +0 -0
  49. /package/src/{testkit → twins}/FakeGitOperations.ts +0 -0
  50. /package/src/{testkit → twins}/FlowScripts.test.ts +0 -0
  51. /package/src/{testkit → twins}/GitTiers.test.ts +0 -0
  52. /package/src/{testkit → twins}/GitTiers.ts +0 -0
  53. /package/src/{testkit → twins}/Layers.test.ts +0 -0
  54. /package/src/{testkit → twins}/StrictGitOperations.ts +0 -0
  55. /package/src/{testkit → twins}/cliIo.ts +0 -0
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
- import { It as registryEntry, Lt as requireFlag, Pt as flagReferences, St as envDefaults, a as flagShipped, i as skills, n as summary, r as access, t as steering, x as coder, xt as defaults } from "../chunks/reserved-BzAFXuz_.mjs";
3
- import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-JQzB21Q3.mjs";
2
+ import { It as registryEntry, Lt as requireFlag, Pt as flagReferences, St as envDefaults, a as flagShipped, i as skills, n as summary, r as access, t as steering, x as coder, xt as defaults } from "../chunks/reserved-lMYCaapY.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-Bd7L940p.mjs";
4
4
  //#region src/workflows/cleanup/cleanup.ts
5
5
  const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
6
6
  const cleanupFlow = async () => {
@@ -1,8 +1,8 @@
1
1
  import { agent, answered, applyScript, changes, changesSince, check, codeChanges, env, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, matches, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars, wrote } from "../flows.mjs";
2
2
  import { lensEntriesOf } from "../workflow-kit.mjs";
3
- import { B as designSystem, Ct as testFileGlobs, D as planner, F as clearCriteriaMessage, I as clearCriteriaQuestion, J as implementBuildingPrompt, L as criterionLine, M as buildReviewCollectingPrompt, P as builderSystem, Q as naiveTryMessage, R as designGateAnswerMessage, S as collecting, St as envDefaults, V as designTriagePrompt, X as naiveApplyFailure, Y as implementFixPrompt, Z as naivePassPrompt, _ as REQUIREMENTS, _t as unwindFailedMessage, at as refactorFixPrompt, d as healthy, et as normaliseCriteriaScript, f as FEEDBACK, g as REFACTOR_NOTE, gt as startGateBlockedMessage, h as REFACTOR, ht as seamSteerMessage, i as skills, it as refactorDroppedRefusal, j as agentWithSkills, l as gate, mt as seamByHandMessage, n as summary, o as naiveTestOn, ot as refactorNotePrompt, p as NAIVE_TEST, pt as reviewerSystem, q as idleMessage, r as access, rt as refactorBuildingPrompt, s as shipped, st as refactorRules, t as steering, vt as unwindFailure, w as fixNits, x as coder, xt as defaults, y as answerReviewQuestions, z as designImpactPrompt } from "../chunks/reserved-BzAFXuz_.mjs";
4
- import { a as refusePendingHandOff } from "../chunks/tail-JQzB21Q3.mjs";
5
- import { n as isCode$1, r as reUnwind, t as buildTail } from "../chunks/review-CVqRS0mc.mjs";
3
+ import { B as designSystem, Ct as testFileGlobs, D as planner, F as clearCriteriaMessage, I as clearCriteriaQuestion, J as implementBuildingPrompt, L as criterionLine, M as buildReviewCollectingPrompt, P as builderSystem, Q as naiveTryMessage, R as designGateAnswerMessage, S as collecting, St as envDefaults, V as designTriagePrompt, X as naiveApplyFailure, Y as implementFixPrompt, Z as naivePassPrompt, _ as REQUIREMENTS, _t as unwindFailedMessage, at as refactorFixPrompt, d as healthy, et as normaliseCriteriaScript, f as FEEDBACK, g as REFACTOR_NOTE, gt as startGateBlockedMessage, h as REFACTOR, ht as seamSteerMessage, i as skills, it as refactorDroppedRefusal, j as agentWithSkills, l as gate, mt as seamByHandMessage, n as summary, o as naiveTestOn, ot as refactorNotePrompt, p as NAIVE_TEST, pt as reviewerSystem, q as idleMessage, r as access, rt as refactorBuildingPrompt, s as shipped, st as refactorRules, t as steering, vt as unwindFailure, w as fixNits, x as coder, xt as defaults, y as answerReviewQuestions, z as designImpactPrompt } from "../chunks/reserved-lMYCaapY.mjs";
4
+ import { a as refusePendingHandOff } from "../chunks/tail-Bd7L940p.mjs";
5
+ import { n as isCode$1, r as reUnwind, t as buildTail } from "../chunks/review-Bfaror38.mjs";
6
6
  import { posix } from "node:path";
7
7
  //#region src/workflows/shared/fences.ts
8
8
  const markFenced = (text) => {
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
2
  import { b as reviewNotes, x as reviewRisks } from "../chunks/steering-CCNvngSH.mjs";
3
- import { G as fixReviewNotesPrompt, St as envDefaults, W as finisherSystem, c as baseline, i as skills, j as agentWithSkills, n as summary, r as access, s as shipped, t as steering, v as REVIEW, x as coder, xt as defaults } from "../chunks/reserved-BzAFXuz_.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-JQzB21Q3.mjs";
3
+ import { G as fixReviewNotesPrompt, St as envDefaults, W as finisherSystem, c as baseline, i as skills, j as agentWithSkills, n as summary, r as access, s as shipped, t as steering, v as REVIEW, x as coder, xt as defaults } from "../chunks/reserved-lMYCaapY.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-Bd7L940p.mjs";
5
5
  //#region src/workflows/fix/fix.ts
6
6
  /**
7
7
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, removeScript, run, start } from "../flows.mjs";
2
- import { St as envDefaults, W as finisherSystem, _ as REQUIREMENTS, dt as reviewGateBlockedMessage, i as skills, j as agentWithSkills, l as gate, n as summary, r as access, s as shipped, t as steering, ut as reviewFeedbackFixPrompt, x as coder, xt as defaults } from "../chunks/reserved-BzAFXuz_.mjs";
3
- import { a as refusePendingHandOff } from "../chunks/tail-JQzB21Q3.mjs";
4
- import { r as reUnwind, t as buildTail } from "../chunks/review-CVqRS0mc.mjs";
2
+ import { St as envDefaults, W as finisherSystem, _ as REQUIREMENTS, dt as reviewGateBlockedMessage, i as skills, j as agentWithSkills, l as gate, n as summary, r as access, s as shipped, t as steering, ut as reviewFeedbackFixPrompt, x as coder, xt as defaults } from "../chunks/reserved-lMYCaapY.mjs";
3
+ import { a as refusePendingHandOff } from "../chunks/tail-Bd7L940p.mjs";
4
+ import { r as reUnwind, t as buildTail } from "../chunks/review-Bfaror38.mjs";
5
5
  //#region src/workflows/review/review.ts
6
6
  const fixFeedback = async () => {
7
7
  await agentWithSkills("feedback.fixing", reviewFeedbackFixPrompt(), {
@@ -1,5 +1,5 @@
1
1
  import { agent, changesSince, glob, head, mark, read, refuse, removeScript, run, scope, vars } from "../flows.mjs";
2
- import { Ft as flipProblems, Lt as requireFlag, Nt as flagMentions, St as envDefaults, a as flagShipped, f as FEEDBACK, i as skills, l as gate, n as summary, r as access, t as steering, x as coder, xt as defaults } from "../chunks/reserved-BzAFXuz_.mjs";
2
+ import { Ft as flipProblems, Lt as requireFlag, Nt as flagMentions, St as envDefaults, a as flagShipped, f as FEEDBACK, i as skills, l as gate, n as summary, r as access, t as steering, x as coder, xt as defaults } from "../chunks/reserved-lMYCaapY.mjs";
3
3
  //#region src/workflows/rollout/rollout.ts
4
4
  const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
5
5
  const suiteBlockedMessage = () => "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "58.0.0",
3
+ "version": "58.2.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -66,6 +66,11 @@
66
66
  "types": "./src/testkit/index.ts",
67
67
  "source": "./src/testkit/index.ts",
68
68
  "default": "./dist/testkit.mjs"
69
+ },
70
+ "./testkit/gherkin": {
71
+ "types": "./src/gherkin/index.ts",
72
+ "source": "./src/gherkin/index.ts",
73
+ "default": "./dist/testkit/gherkin.mjs"
69
74
  }
70
75
  },
71
76
  "files": [
@@ -76,6 +81,8 @@
76
81
  "src/wire/",
77
82
  "src/steering/",
78
83
  "src/testkit/",
84
+ "src/twins/",
85
+ "src/gherkin/",
79
86
  "src/cli/",
80
87
  "src/edge/",
81
88
  "src/emit/",
@@ -25,7 +25,7 @@ import {
25
25
  import { EXIT_CODES, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
26
26
  import { Host } from "../platform/index.js"
27
27
  import { type CommandRequirements } from "./Cli.js"
28
- import { InMemRepo, makeCapturingCliIo, testLayers } from "../testkit/index.js"
28
+ import { InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
29
29
  import { CONFIG_SCAFFOLD } from "../install/index.js"
30
30
 
31
31
  const FLAG_NAMES = [
@@ -1,6 +1,6 @@
1
1
  import { Effect, Exit } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
- import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../testkit/index.js"
3
+ import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
4
4
  import { runCli } from "./index.js"
5
5
  import { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
6
6
 
@@ -1,6 +1,6 @@
1
1
  import { Effect } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
- import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../testkit/index.js"
3
+ import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
4
4
  import { runCli } from "./index.js"
5
5
  import { processMetrics, readProcessMetrics, totalMetrics, type MetricCommit } from "./Metrics.js"
6
6
 
@@ -18,7 +18,7 @@ vi.mock("node:os", async (importOriginal) => ({
18
18
  import { runCli, type Command } from "./index.js"
19
19
  import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
20
20
  import { needsOf, runCommand, SelectorUsageError } from "./Program.js"
21
- import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../testkit/index.js"
21
+ import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../twins/index.js"
22
22
  import {
23
23
  commitAll,
24
24
  DID_NOT_RUN_COMMENT,
@@ -1,7 +1,7 @@
1
1
  import { Effect } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
3
  import { EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_STRAYS_FOUND } from "../wire/index.js"
4
- import { InMemRepo, makeCapturingCliIo } from "../testkit/index.js"
4
+ import { InMemRepo, makeCapturingCliIo } from "../twins/index.js"
5
5
  import { runCli } from "./index.js"
6
6
 
7
7
  const seed = (): InMemRepo => {
@@ -31,7 +31,7 @@ import {
31
31
  makeCapturingCliIo,
32
32
  makeInMemoryWorkspaceOps,
33
33
  testLayers,
34
- } from "../testkit/index.js"
34
+ } from "../twins/index.js"
35
35
 
36
36
  type Env = Readonly<Record<string, string | undefined>>
37
37
  type Files = Readonly<Record<string, string>>
package/src/emit/Emit.ts CHANGED
@@ -110,7 +110,7 @@ export const emitScripts = (
110
110
  optional: assembleScript(optional),
111
111
  })
112
112
 
113
- /** Exported so `src/testkit/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
113
+ /** Exported so `src/twins/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
114
114
  export const DID_NOT_RUN_COMMENT =
115
115
  "# gtd emitted this and did NOT run it — pipe it into `sh` to land the turn"
116
116
 
@@ -130,7 +130,7 @@ export const fetchHistoryRefs = (remote: string): string =>
130
130
  // No test asserts the two stay byte-identical; a drift between them is a
131
131
  // review-time risk this boundary accepts.
132
132
 
133
- /** Marks an emitted block as print-only — mirrors `OutcomeScript.ts`'s `OUTCOME_MARKER` verbatim, so `src/testkit/EmittedScriptRecognizer.ts` recognizes either path's output identically. */
133
+ /** Marks an emitted block as print-only — mirrors `OutcomeScript.ts`'s `OUTCOME_MARKER` verbatim, so `src/twins/EmittedScriptRecognizer.ts` recognizes either path's output identically. */
134
134
  const OUTCOME_MARKER = "# gtd: outcome (print-only)"
135
135
 
136
136
  const printfLine = (fmt: string, args: readonly string[]): string =>
@@ -19,7 +19,7 @@ const printfLine = (fmt: string, args: readonly string[]): string =>
19
19
 
20
20
  /**
21
21
  * Marks an emitted block as print-only. Load-bearing beyond documentation:
22
- * `src/testkit/EmittedScriptRecognizer.ts` recognizes an outcome block by
22
+ * `src/twins/EmittedScriptRecognizer.ts` recognizes an outcome block by
23
23
  * this line, and a bare `printf` would be ambiguous with a workflow
24
24
  * `script:` command that happens to start the same way.
25
25
  */
@@ -0,0 +1,78 @@
1
+ @inmem
2
+ Feature: a scenario controls the commands and the history a workflow sees
3
+
4
+ A step's script may run programs the in-memory repository has none of: a
5
+ canned command stands in for each, matched by the command as the script
6
+ spells it. Branches and refs are set up before the process starts, so a
7
+ workflow that reads another branch or a remote ref finds them.
8
+
9
+ Background:
10
+ Given a gtd config with:
11
+ """
12
+ import { check, human, run, showScript } from "@pmelab/gtd/flows"
13
+
14
+ export const tested = async () => {
15
+ const green = await check("test", "npm test", { report: ".gtd/FEEDBACK.md" })
16
+ await human(green ? "sign-off" : "red")
17
+ }
18
+
19
+ export const restored = async () => {
20
+ await run("restore", showScript("origin/main", "a.txt", "a.txt"))
21
+ await human("done")
22
+ }
23
+
24
+ export default tested
25
+ """
26
+ And a file "a.txt" with:
27
+ """
28
+ base
29
+ """
30
+ And the working tree is committed as "chore: base"
31
+
32
+ Scenario: a red canned command writes its output to the check's report
33
+ Given the command "npm test" exits 1 with:
34
+ """
35
+ 1 failing
36
+ """
37
+ And step "test" runs its script
38
+ When gtd starts workflow "tested"
39
+ And the process is driven
40
+ Then the process rests at "red"
41
+ And ".gtd/FEEDBACK.md" contains "1 failing"
42
+
43
+ Scenario: a green canned command passes the check
44
+ Given the command "npm test" exits 0
45
+ And step "test" runs its script
46
+ When gtd starts workflow "tested"
47
+ And the process is driven
48
+ Then the process rests at "sign-off"
49
+
50
+ Scenario: a command no canned command matches fails the drive, naming it
51
+ Given step "test" runs its script
52
+ When gtd starts workflow "tested"
53
+ Then driving the process is refused with "no canned command matches `npm test`"
54
+
55
+ Scenario: a remote ref is read by its short name
56
+ Given the ref "refs/remotes/origin/main" points at HEAD
57
+ And a file "a.txt" with:
58
+ """
59
+ local
60
+ """
61
+ And the working tree is committed as "chore: local"
62
+ And step "restore" runs its script
63
+ When gtd starts workflow "restored"
64
+ And the process is driven
65
+ Then the process rests at "done"
66
+ And "a.txt" contains "base"
67
+
68
+ Scenario: a branch is checked out and back
69
+ Given a branch "feature/x"
70
+ And "feature/x" is checked out
71
+ And a file "a.txt" with:
72
+ """
73
+ feature
74
+ """
75
+ And the working tree is committed as "feat: x"
76
+ And "main" is checked out
77
+ Then "a.txt" contains "base"
78
+ And "a.txt" does not contain "feature"
@@ -11,7 +11,16 @@ import {
11
11
  } from "quickpickle"
12
12
  import assert from "node:assert"
13
13
  import type { TestContext } from "vitest"
14
- import { runsItsScript, testRepo, type Actor, type TestRepo } from "./TestRepo.js"
14
+ import { resolve } from "node:path"
15
+ import { pathToFileURL } from "node:url"
16
+ import {
17
+ runsItsScript,
18
+ testRepo,
19
+ type Actor,
20
+ type CannedCommand,
21
+ type TestRepo,
22
+ type WorkflowModule,
23
+ } from "../testkit/index.js"
15
24
 
16
25
  export class TestkitWorld extends QuickPickleWorld {
17
26
  // Re-declared: under `nodenext` a subclass loses the base's members from TS's view.
@@ -21,8 +30,9 @@ export class TestkitWorld extends QuickPickleWorld {
21
30
  super(context, info)
22
31
  }
23
32
 
24
- private gtdConfig: string | undefined = undefined
33
+ private gtdConfig: WorkflowModule | undefined = undefined
25
34
  private env: Record<string, string> = {}
35
+ private commands: CannedCommand[] = []
26
36
  /** Per step, what its scripted actor does, in the order the scenario declared it. */
27
37
  actions = new Map<string, Actor[]>()
28
38
  verdicts = new Map<string, string>()
@@ -33,7 +43,7 @@ export class TestkitWorld extends QuickPickleWorld {
33
43
  // The repository is created on first use and keeps the config and
34
44
  // environment it was created with, so a later change to either is refused
35
45
  // rather than silently ignored.
36
- configure(config: string): void {
46
+ configure(config: WorkflowModule): void {
37
47
  this.assertNotCreated("a gtd config")
38
48
  this.gtdConfig = config
39
49
  }
@@ -41,9 +51,16 @@ export class TestkitWorld extends QuickPickleWorld {
41
51
  extendConfig(tail: string): void {
42
52
  this.assertNotCreated("a gtd config")
43
53
  if (this.gtdConfig === undefined) throw new Error("no gtd config to extend")
54
+ if (typeof this.gtdConfig !== "string")
55
+ throw new Error("a gtd config from a module cannot be extended")
44
56
  this.gtdConfig += `\n${tail}`
45
57
  }
46
58
 
59
+ command(command: CannedCommand): void {
60
+ if (this.created === undefined) this.commands.push(command)
61
+ else this.created.command(command)
62
+ }
63
+
47
64
  setEnv(name: string, value: string): void {
48
65
  this.assertNotCreated(`the environment variable ${name}`)
49
66
  this.env[name] = value
@@ -58,7 +75,11 @@ export class TestkitWorld extends QuickPickleWorld {
58
75
  repo(): TestRepo {
59
76
  if (this.gtdConfig === undefined)
60
77
  throw new Error("no gtd config: add a `gtd config` step first")
61
- return (this.created ??= testRepo({ workflow: this.gtdConfig, env: this.env }))
78
+ return (this.created ??= testRepo({
79
+ workflow: this.gtdConfig,
80
+ env: this.env,
81
+ commands: this.commands,
82
+ }))
62
83
  }
63
84
 
64
85
  act(step: string, action: Actor): void {
@@ -108,6 +129,13 @@ Given("a gtd config with:", (world: TestkitWorld, source: string) => {
108
129
  world.configure(withNewline(source))
109
130
  })
110
131
 
132
+ // Imported by the test runner itself, from the working directory: the module and
133
+ // the engine share one `@pmelab/gtd/flows`, and a config source string could
134
+ // import only files written into the in-memory repository.
135
+ Given("a gtd config from the module {string}", async (world: TestkitWorld, path: string) => {
136
+ world.configure({ ...(await import(pathToFileURL(resolve(path)).href)) })
137
+ })
138
+
111
139
  Given("the gtd config ends with:", (world: TestkitWorld, source: string) => {
112
140
  world.extendConfig(withNewline(source))
113
141
  })
@@ -119,6 +147,29 @@ Given(
119
147
  },
120
148
  )
121
149
 
150
+ Given("the command {string} exits {int}", (world: TestkitWorld, run: string, exit: number) => {
151
+ world.command({ run, exit })
152
+ })
153
+
154
+ Given(
155
+ "the command {string} exits {int} with:",
156
+ (world: TestkitWorld, run: string, exit: number, output: string) => {
157
+ world.command({ run, exit, output: withNewline(output) })
158
+ },
159
+ )
160
+
161
+ Given("a branch {string}", (world: TestkitWorld, name: string) => {
162
+ world.repo().branch(name)
163
+ })
164
+
165
+ Given("{string} is checked out", (world: TestkitWorld, name: string) => {
166
+ world.repo().checkout(name)
167
+ })
168
+
169
+ Given("the ref {string} points at HEAD", (world: TestkitWorld, name: string) => {
170
+ world.repo().ref(name)
171
+ })
172
+
122
173
  Given("a file {string} with:", (world: TestkitWorld, path: string, content: string) => {
123
174
  world.repo().write(path, withNewline(content))
124
175
  })
@@ -0,0 +1,24 @@
1
+ @inmem
2
+ Feature: a scenario runs a workflow module from disk
3
+
4
+ The test runner imports the module, so it shares the engine's runtime and
5
+ may import anything on disk. A config written as source can import only
6
+ files inside the in-memory repository.
7
+
8
+ Scenario: the module's default export starts on a change in the tree
9
+ Given a gtd config from the module "src/gherkin/tiny.fixture.ts"
10
+ And a file "IDEA.md" with:
11
+ """
12
+ a calculator
13
+ """
14
+ And step "plan" writes "PLAN.md" with:
15
+ """
16
+ add two numbers
17
+ """
18
+ When the process is driven
19
+ Then the process rests at "sign-off"
20
+ And the commit subjects end with:
21
+ """
22
+ gtd(agent): plan → sign-off
23
+ """
24
+
@@ -0,0 +1,8 @@
1
+ import { agent, human } from "../flows/index.js"
2
+
3
+ export const tiny = async (): Promise<void> => {
4
+ await agent("plan", "Write PLAN.md.")
5
+ await human("sign-off")
6
+ }
7
+
8
+ export default tiny
@@ -7,11 +7,11 @@ import { Cause, Effect, Exit, Option } from "effect"
7
7
  import { describe, expect, it } from "vitest"
8
8
  import { GitService, InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
9
9
  import { Host } from "./index.js"
10
- import { gitTiers, runGitServiceContract, runGitScriptContract } from "../testkit/index.js"
10
+ import { gitTiers, runGitServiceContract, runGitScriptContract } from "../twins/index.js"
11
11
 
12
12
  /**
13
13
  * The `GitOperations` contract, run identically against both the Live and
14
- * InMemory tiers (`src/testkit/GitTiers.ts`) — 20 operations × 2 tiers.
14
+ * InMemory tiers (`src/twins/GitTiers.ts`) — 20 operations × 2 tiers.
15
15
  */
16
16
  for (const makeTier of gitTiers) {
17
17
  const name = makeTier().name
@@ -1,6 +1,6 @@
1
1
  import { Effect, Option } from "effect"
2
2
  import { describe, expect, it, vi } from "vitest"
3
- import { strictGitOperations as stubGit } from "../testkit/index.js"
3
+ import { strictGitOperations as stubGit } from "../twins/index.js"
4
4
  import {
5
5
  clearRetainedHistory,
6
6
  HISTORY_REF,
@@ -13,7 +13,7 @@ import {
13
13
 
14
14
  /**
15
15
  * Unit coverage for the retention edge module (`src/platform/RetainedHistory.ts`),
16
- * using `strictGitOperations` (`src/testkit/StrictGitOperations.ts`) as its
16
+ * using `strictGitOperations` (`src/twins/StrictGitOperations.ts`) as its
17
17
  * `GitOperations` double — a Proxy failing on any method a test didn't
18
18
  * override, so an unexpected call fails loudly instead of silently succeeding.
19
19
  */
@@ -15,7 +15,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
15
15
  import { Effect, Layer } from "effect"
16
16
  import { NodeContext } from "@effect/platform-node"
17
17
  import { GitService, Host, Workspace, type WorkspaceOps } from "./index.js"
18
- import { InMemRepo, makeInMemoryWorkspaceOps } from "../testkit/index.js"
18
+ import { InMemRepo, makeInMemoryWorkspaceOps } from "../twins/index.js"
19
19
 
20
20
  // `execFileSync` is a named export of the built-in `node:child_process` ESM
21
21
  // module — not configurable, so `vi.spyOn` can't wrap it directly. `vi.mock`
@@ -31,7 +31,7 @@ vi.mock("node:child_process", async (importOriginal) => {
31
31
  })
32
32
 
33
33
  /**
34
- * One `Workspace` tier, in the shape `src/testkit/GitTiers.ts` establishes
34
+ * One `Workspace` tier, in the shape `src/twins/GitTiers.ts` establishes
35
35
  * for `GitOperations`: a `root`, a way to seed working-tree/committed
36
36
  * content, and `provide` to run an Effect against this tier's `Workspace`.
37
37
  */
@@ -16,7 +16,7 @@ import { NodeContext } from "@effect/platform-node"
16
16
  import { describe, expect, it } from "vitest"
17
17
  import { loopLogPath, worktreeGitDir } from "./WorktreeState.js"
18
18
  import { GitService, Host, Workspace } from "./index.js"
19
- import { InMemRepo, testLayers } from "../testkit/index.js"
19
+ import { InMemRepo, testLayers } from "../twins/index.js"
20
20
 
21
21
  const provide = <A>(
22
22
  eff: Effect.Effect<A, never, Host | Workspace>,
@@ -1,7 +1,7 @@
1
1
  import { Effect } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
3
  import { planStart, type StartOutcome } from "./planStart.js"
4
- import { InMemRepo, testLayers } from "../testkit/index.js"
4
+ import { InMemRepo, testLayers } from "../twins/index.js"
5
5
  import { ConfigService } from "../workflow/index.js"
6
6
 
7
7
  const WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
@@ -12,7 +12,16 @@ import {
12
12
  softResetTo,
13
13
  updateRef,
14
14
  } from "../emit/index.js"
15
- import { agent, human, moveScript, read, removeScript, run } from "../flows/index.js"
15
+ import {
16
+ agent,
17
+ check,
18
+ human,
19
+ moveScript,
20
+ read,
21
+ removeScript,
22
+ run,
23
+ showScript,
24
+ } from "../flows/index.js"
16
25
  import { runsItsScript, testRepo } from "./TestRepo.js"
17
26
 
18
27
  const tiny = async (): Promise<void> => {
@@ -112,7 +121,7 @@ describe("testRepo", () => {
112
121
  it("carries the recognizer's error when a git-free fallback fails", () => {
113
122
  const repo = testRepo({ workflow: { default: tiny } })
114
123
  expect(() => repo.runScript("printf 'x' > 'y'\n")).toThrow(
115
- "testkit: cannot run script in memory — give the step an actor instead\nunrecognized script block: printf 'x' > 'y'",
124
+ "testkit: cannot run script in memory — add a canned command for it, or give the step an actor\nunrecognized script block: printf 'x' > 'y'",
116
125
  )
117
126
  })
118
127
 
@@ -194,4 +203,111 @@ describe("testRepo", () => {
194
203
  repo.drive({ spin: (_, r) => r.write("n", `${++n}\n`) }, { maxBeats: 5 }),
195
204
  ).rejects.toThrow(/did not rest within 5 beats/)
196
205
  })
206
+
207
+ describe("canned commands", () => {
208
+ const checked = async (): Promise<void> => {
209
+ const green = await check("test", "npm test", { report: ".gtd/FEEDBACK.md" })
210
+ await human(green ? "sign-off" : "red")
211
+ }
212
+
213
+ it("runs a check's command as canned, its output the report", async () => {
214
+ const repo = testRepo({
215
+ workflow: { default: checked },
216
+ commands: [{ run: "npm test", exit: 1, output: "1 failing" }],
217
+ })
218
+ repo.write("go", "\n")
219
+ const rest = await repo.drive({ test: runsItsScript })
220
+ expect(rest.state).toBe("red")
221
+ expect(repo.read(".gtd/FEEDBACK.md")).toContain("1 failing")
222
+ })
223
+
224
+ it("exits 0 by default, and matches by pattern", async () => {
225
+ const repo = testRepo({ workflow: { default: checked }, commands: [{ run: /^npm / }] })
226
+ repo.write("go", "\n")
227
+ expect((await repo.drive({ test: runsItsScript })).state).toBe("sign-off")
228
+ })
229
+
230
+ it("lets a command change the tree, and takes the first match", async () => {
231
+ const repo = testRepo({
232
+ workflow: { default: checked },
233
+ commands: [
234
+ { run: "npm test", effect: (r) => r.write("ran", "first\n") },
235
+ { run: /npm/, exit: 1 },
236
+ ],
237
+ })
238
+ repo.write("go", "\n")
239
+ expect((await repo.drive({ test: runsItsScript })).state).toBe("sign-off")
240
+ expect(repo.read("ran")).toBe("first\n")
241
+ })
242
+
243
+ it("takes a command added later", async () => {
244
+ const repo = testRepo({ workflow: { default: checked } })
245
+ repo.command({ run: "npm test", exit: 2 })
246
+ repo.write("go", "\n")
247
+ expect((await repo.drive({ test: runsItsScript })).state).toBe("red")
248
+ })
249
+
250
+ it("runs a plain script line as canned", () => {
251
+ const repo = testRepo({
252
+ workflow: { default: tiny },
253
+ commands: [{ run: "make docs", effect: (r) => r.write("docs.html", "<p>\n") }],
254
+ })
255
+ repo.runScript("make docs\n")
256
+ expect(repo.read("docs.html")).toBe("<p>\n")
257
+ })
258
+
259
+ it("fails the drive on a command none matches, naming it", async () => {
260
+ const repo = testRepo({ workflow: { default: checked } })
261
+ repo.write("go", "\n")
262
+ await expect(repo.drive({ test: runsItsScript })).rejects.toThrow(
263
+ "no canned command matches `npm test`",
264
+ )
265
+ })
266
+ })
267
+
268
+ describe("history", () => {
269
+ it("creates and checks out a branch, refusing uncommitted changes", () => {
270
+ const repo = testRepo({ workflow: { default: tiny }, files: { "a.txt": "main\n" } })
271
+ repo.branch("feature/x")
272
+ repo.checkout("feature/x")
273
+ expect(repo.branchName()).toBe("feature/x")
274
+ repo.write("a.txt", "feature\n")
275
+ expect(() => repo.checkout("main")).toThrow("uncommitted changes")
276
+ repo.commit("feat: x")
277
+ repo.checkout("main")
278
+ expect(repo.branchName()).toBe("main")
279
+ expect(repo.read("a.txt")).toBe("main\n")
280
+ expect(repo.show("feature/x", "a.txt")).toBe("feature\n")
281
+ })
282
+
283
+ it("branches at a given ref, and refuses an existing name or a missing ref", () => {
284
+ const repo = testRepo({ workflow: { default: tiny }, files: { "a.txt": "one\n" } })
285
+ repo.write("a.txt", "two\n")
286
+ repo.commit("chore: two")
287
+ repo.branch("old", "HEAD~1")
288
+ expect(repo.show("old", "a.txt")).toBe("one\n")
289
+ expect(() => repo.branch("old")).toThrow("already exists")
290
+ expect(() => repo.branch("new", "nowhere")).toThrow("not a valid object name")
291
+ })
292
+
293
+ it("points a remote ref that a script reads by its short name", async () => {
294
+ const restore = async (): Promise<void> => {
295
+ await run("restore", showScript("origin/main", "a.txt", "a.txt"))
296
+ await human("done")
297
+ }
298
+ const repo = testRepo({ workflow: { default: restore }, files: { "a.txt": "base\n" } })
299
+ repo.ref("refs/remotes/origin/main")
300
+ repo.write("a.txt", "local\n")
301
+ repo.commit("chore: local")
302
+ repo.write("go", "\n")
303
+ await repo.drive({ restore: runsItsScript })
304
+ expect(repo.read("a.txt")).toBe("base\n")
305
+ })
306
+
307
+ it("refuses a ref outside refs/ or at nothing", () => {
308
+ const repo = testRepo({ workflow: { default: tiny } })
309
+ expect(() => repo.ref("origin/main")).toThrow("must start with refs/")
310
+ expect(() => repo.ref("refs/tags/v1", "nowhere")).toThrow("resolves to no commit")
311
+ })
312
+ })
197
313
  })