@pmelab/gtd 24.4.0 → 26.0.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 (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -18
  3. package/dist/chunks/{reserved-B9pG93Fc.mjs → reserved-6pAgFGgB.mjs} +69 -125
  4. package/dist/gtd.bundle.mjs +28 -3
  5. package/dist/testkit.mjs +30 -5
  6. package/dist/workflow-kit.mjs +47 -90
  7. package/dist/workflows/feature.mjs +25 -47
  8. package/dist/workflows/fix.mjs +1 -1
  9. package/dist/workflows/review.mjs +1 -1
  10. package/dist/workflows.mjs +135 -261
  11. package/package.json +2 -2
  12. package/src/cli/Cli.test.ts +25 -0
  13. package/src/cli/Cli.ts +12 -1
  14. package/src/flags/index.test.ts +67 -0
  15. package/src/flags/index.ts +39 -0
  16. package/src/replay/Replay.ts +2 -1
  17. package/src/workflow/vars.test.ts +16 -0
  18. package/src/workflow/vars.ts +4 -1
  19. package/src/workflow-kit/scenarios.test.ts +116 -0
  20. package/src/workflow-kit/scenarios.ts +53 -113
  21. package/src/workflows/feature/feature.ts +2 -5
  22. package/src/workflows/feature/packages.test.ts +17 -34
  23. package/src/workflows/feature/packages.ts +13 -52
  24. package/src/workflows/feature/planning.ts +1 -1
  25. package/src/workflows/feature/review.test.ts +0 -47
  26. package/src/workflows/feature/review.ts +6 -10
  27. package/src/workflows/index.ts +0 -2
  28. package/src/workflows/shared/health.test.ts +1 -21
  29. package/src/workflows/shared/health.ts +4 -39
  30. package/src/workflows/shared/options.ts +0 -1
  31. package/src/workflows/shared/steps.ts +16 -11
  32. package/src/workflows/shared/tail.test.ts +1 -41
  33. package/src/workflows/shared/tail.ts +14 -30
  34. package/src/workflows/shared/text.fixture.ts +1 -0
  35. package/src/workflows/shared/text.test.ts +23 -41
  36. package/src/workflows/shared/text.ts +54 -74
  37. package/src/workflows/shared/vars.ts +0 -1
  38. package/src/workflows/shared/scenarios.test.ts +0 -256
  39. package/src/workflows/shared/scenarios.ts +0 -35
@@ -10,7 +10,7 @@ import {
10
10
  type AgentOptions,
11
11
  type SummaryContext,
12
12
  } from "../../flows/index.js"
13
- import type { WordingDrift } from "../../workflow-kit/index.js"
13
+ import type { RemovedAssertions } from "../../workflow-kit/index.js"
14
14
  import {
15
15
  accessPreamble,
16
16
  skillsPreamble,
@@ -274,7 +274,8 @@ ${footnoteFoldIn}${codeThreadReplies()}
274
274
  - Every open point here is TECHNICAL — triage already resolved
275
275
  the product ones, one phase earlier. PERMISSIVE: answer it
276
276
  yourself unless you genuinely cannot defend a default; a wrong
277
- technical call is still caught by the full run and the quality lap
277
+ technical call is still caught by each package's suite run and the
278
+ quality lap
278
279
  - The narrow exception: a simplification that drops something
279
280
  \`.gtd/REQUIREMENTS.md\` mentions is an open question, not a
280
281
  silent default
@@ -286,11 +287,13 @@ ${footnoteFoldIn}${codeThreadReplies()}
286
287
  only, as fenced TypeScript (or the repository's language)
287
288
  3. \`## Call Stacks\` — seam level, entry point → module
288
289
  boundary; one line per hop, never per line of code
289
- 4. \`## E2E Scenarios\` — Gherkin in fenced \`gherkin\` blocks
290
- (whatever e2e framework the repository uses), each preceded
291
- by a line \`- e2e: <path>\`. With no user-visible
292
- behaviour change, the literal line \`No e2e change.\` plus a
293
- one-line reason — never empty or missing
290
+ 4. \`## E2E Scenarios\` — one line per user-visible behaviour,
291
+ \`- e2e: <path> — <behaviour> (covers <concern>)\`, in plain
292
+ words: the builder writes the scenario in the repository's
293
+ own e2e framework, so never spell out Gherkin here. With no
294
+ user-visible behaviour change, the literal line
295
+ \`No e2e change.\` plus a one-line reason — never empty or
296
+ missing
294
297
  5. \`## Unit Tests\` — one \`### <interface>\` per entry of
295
298
  \`## Interfaces\`, each test a line
296
299
  \`- unit: <path> — <behaviour> (covers <concern>)\`;
@@ -320,35 +323,31 @@ export const architectureDecomposePrompt = (): string =>
320
323
  ${stateFileRules}
321
324
  - The only state files this turn touches are the package files
322
325
  under \`.gtd/packages/\` — no other files for notes or output.
323
- Never delete \`.gtd/ARCHITECTURE.md\`: the build tail's full run
324
- still needs it, and package files may reference it
326
+ Never delete \`.gtd/ARCHITECTURE.md\`: package files may
327
+ reference it, and it is removed with the last package
325
328
  - Work from \`.gtd/ARCHITECTURE.md\` if you wrote it earlier this
326
329
  conversation, otherwise read it (the converged technical
327
330
  plan). It already lists the concerns, interfaces, e2e scenarios
328
331
  and unit tests — this turn groups them into packages. A
329
332
  \`## Merged Concerns\` heading there records merges, never a
330
333
  concern of its own: write no package file for it
331
- - A package is a consecutive run of \`## Unit Tests\` entries that go
332
- green together on the fast suite, at most ~8 unit-test entries
333
- each. Keep the merge rule: concerns whose footprints centre on the
334
- same files stay in one package unless the later one only consumes
335
- an interface the earlier one creates. Prefer fewer, larger
336
- packages. Order them so each stays green on the fast suite —
337
- interface-introducing packages first
334
+ - A package is one concern's slice, and each must leave the full
335
+ suite green — every package boundary costs a full run, so write
336
+ as few as the concerns allow. Keep the merge rule: concerns whose
337
+ footprints centre on the same files stay in one package unless
338
+ the later one only consumes an interface the earlier one creates.
339
+ Order them interface-introducing packages first
338
340
  - Write the packages under \`.gtd/packages/\`, each carrying its
339
341
  requirement(s) — both, independently, if merged — its tasks, and
340
342
  each task's acceptance criteria as \`- [ ]\` checkboxes and
341
343
  relevant paths:
342
- - \`.gtd/packages/00-e2e-scenarios.md\` — package 0: it writes
343
- \`## E2E Scenarios\` as tests in the repository's own e2e
344
- framework and leaves them red. Write no such file when
345
- \`## E2E Scenarios\` says "No e2e change."
346
- - \`01-…\` onward — the unit-test packages. A requirement no test
347
- covers (docs, README, deletions, config) becomes a chore
348
- package, or a task of one; chore packages declare no tests
349
- - the last numbered package is the wiring package: CLI entry,
350
- workflow composition, config. A red e2e after it is a bug, not
351
- missing work
344
+ - \`01-…\` onward — one vertical slice each: the unit tests,
345
+ the code, the wiring (CLI entry, workflow composition, config)
346
+ and every \`## E2E Scenarios\` entry its concern covers, so the
347
+ slice ends with its scenarios passing. A requirement no test
348
+ covers (docs, README, deletions, config) is a task of the slice
349
+ it belongs to; only one no slice owns becomes a chore package,
350
+ which declares no tests
352
351
  - Every package with tests has a \`## Tests\` section, one line per
353
352
  declared test, copied from the architecture entries:
354
353
  \`- unit: \\\`<path>\\\`\` or \`- e2e: \\\`<path>\\\`\`. Chore packages have
@@ -398,10 +397,10 @@ export const packagesItemBuildingPrompt = (pkg: string): string =>
398
397
  - Write the tests the package declares under \`## Tests\` first,
399
398
  then the code that makes them pass; the turn is refused unless
400
399
  every declared test is in your diff
401
- - Package 0 (\`.gtd/packages/00-e2e-scenarios.md\`) writes the scenarios verbatim
402
- from \`## E2E Scenarios\` of \`.gtd/ARCHITECTURE.md\`, with only
403
- step code that typechecks, and leaves them red — no
404
- implementation
400
+ - A declared e2e test is a scenario in the repository's own e2e
401
+ framework for the behaviour its \`## E2E Scenarios\` entry names.
402
+ Word it to fit the existing steps — reuse and generalise them
403
+ rather than adding one-off ones
405
404
  - Implement every task the package describes, no more, no less,
406
405
  fanning independent ones out to parallel subagents where your
407
406
  harness supports it; leave other package files untouched
@@ -470,34 +469,10 @@ turn, or land untouched to give it one more attempt at the same
470
469
  analysis.
471
470
  `
472
471
 
473
- /** What the tail's full-run fix needs of a `BuiltPlan` (structural: `packages.ts` imports this module). */
474
- export interface BuildContext {
475
- readonly ranges: readonly { readonly pkg: string; readonly from: string; readonly to: string }[]
476
- readonly scenarios: {
477
- readonly added: readonly string[]
478
- readonly changed: readonly string[]
479
- }
480
- }
481
-
482
- const buildContextPrompt = (built: BuildContext): string => {
483
- const list = (paths: readonly string[]): string =>
484
- paths.length === 0 ? " (none)" : paths.map((path) => ` - ${path}`).join("\n")
485
- return `- Read \`.gtd/ARCHITECTURE.md\` — the plan every package was built from
486
- - A red scenario that is NEW in this plan likely means missing wiring
487
- between packages; new scenarios:
488
- ${list(built.scenarios.added)}
489
- - A red CHANGED or existing scenario or test is a regression a package
490
- introduced; scenarios this plan changed:
491
- ${list(built.scenarios.changed)}
492
- - Each package's commits, to bisect a break:
493
- ${built.ranges.map((r) => ` git log --oneline ${r.from}..${r.to} # ${r.pkg}`).join("\n")}
494
- `
495
- }
496
-
497
- export const buildFixPrompt = (built?: BuildContext): string =>
472
+ export const buildFixPrompt = (): string =>
498
473
  `${stateFileRules}
499
474
  ${fixFeedbackPrompt}
500
- ${built === undefined ? "" : buildContextPrompt(built)}- Leave everything uncommitted — do not commit
475
+ - Leave everything uncommitted — do not commit
501
476
  `
502
477
 
503
478
  export const finisherSystem = (): string =>
@@ -888,7 +863,28 @@ actionability, and never dismiss a real note or edit as approval.
888
863
  the sign-off
889
864
  `
890
865
 
891
- export const buildReviewReviewingPrompt = (base: string, carry?: string): string =>
866
+ const scenariosPrompt = (removed: readonly RemovedAssertions[]): string =>
867
+ `- When a scenario in a \`.feature\` file was added or changed since the
868
+ base, the first chunk is \`## Scenarios\`: one pointer per such
869
+ scenario, its note saying in plain words what behaviour it now pins
870
+ down — the human signs off the scenarios here, nowhere else
871
+ ${
872
+ removed.length === 0
873
+ ? ""
874
+ : `- These assertions of scenarios that existed at the base are gone:
875
+ ${removed.map(({ path, lines }) => lines.map((a) => ` ${path} — ${a.scenario} — ${a.line}\n`).join("")).join("")} Open the note of each scenario that lost one with \`Weakened:\`,
876
+ naming the lost line and whether \`.gtd/REQUIREMENTS.md\` asked
877
+ for it. A scenario that no longer exists has no pointer of its own:
878
+ put its \`Weakened:\` note on a pointer of the chunk covering its
879
+ removal
880
+ `
881
+ }`
882
+
883
+ export const buildReviewReviewingPrompt = (
884
+ base: string,
885
+ carry?: string,
886
+ removed: readonly RemovedAssertions[] = [],
887
+ ): string =>
892
888
  `${styleBlock}
893
889
 
894
890
  ${styleFormatContract}
@@ -931,7 +927,7 @@ review the changes:
931
927
  A note sitting entirely on the line(s) beneath the pointer is
932
928
  also valid. Either way, the note must never start with a bare \`./path\` token
933
929
  — that parses as a second pointer, not a note
934
- No diff is given — read the changes yourself. The range runs
930
+ ${scenariosPrompt(removed)}No diff is given — read the changes yourself. The range runs
935
931
  from \`${base}\` to the working tree (committed turns
936
932
  plus anything pending); on a feedback round that's the previous
937
933
  review's boundary, so it covers only what's new.
@@ -986,19 +982,3 @@ ${it.processCostByModel.map((m) => `- ${m.model}: ${m.cost}\n`).join("")}
986
982
  Print the closing message and stop — this writes nothing itself.
987
983
  `
988
984
  }
989
-
990
- export const scenarioWordingMessage = (at: string, drifted: readonly WordingDrift[]): string =>
991
- `The e2e scenario wording was frozen when package 0 landed, and the last
992
- turn changed it. Removed lines are marked \`-\`, added ones \`+\`:
993
-
994
- ${drifted.map(({ path, lines }) => `${path}\n${lines.join("\n")}`).join("\n\n")}
995
-
996
- To accept the change, land untouched.
997
-
998
- To reject it, run
999
-
1000
- git checkout ${at} -- ${drifted.map(({ path }) => quote(path)).join(" ")}
1001
-
1002
- and land. Restoring only some of the files is a partial accept: every file
1003
- still differing from the frozen wording is accepted.
1004
- `
@@ -20,7 +20,6 @@ export const defaults: Readonly<Record<string, string>> = {
20
20
  /** Environment settings. */
21
21
  export const envDefaults: Readonly<Record<string, string>> = {
22
22
  testCommand: "npm test",
23
- fastTestCommand: "",
24
23
  plannerModel: "smart",
25
24
  coderModel: "base",
26
25
  }
@@ -1,256 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest"
2
- import { installContext, type StepRequest } from "../../flows/index.js"
3
- import {
4
- driftedScenarios,
5
- freezeScenarios,
6
- guarded,
7
- holdWording,
8
- scenarioText,
9
- } from "./scenarios.js"
10
- import { fixtureContext, renderText } from "./text.fixture.js"
11
-
12
- afterEach(() => installContext(undefined))
13
-
14
- const tree = (files: Record<string, string>) => ({
15
- read: (path: string) => files[path],
16
- })
17
-
18
- describe("scenarioText", () => {
19
- it("ignores comments, tags, blank lines and indentation", () => {
20
- const loose = [
21
- "@wip @slow",
22
- "Feature: x",
23
- "",
24
- " # a comment",
25
- " @tag",
26
- " Scenario: y",
27
- " Given a thing",
28
- ].join("\n")
29
- const tight = "Feature: x\nScenario: y\nGiven a thing"
30
- expect(scenarioText(loose)).toBe(scenarioText(tight))
31
- })
32
-
33
- it("keeps changed wording", () => {
34
- expect(scenarioText("Given a")).not.toBe(scenarioText("Given b"))
35
- })
36
-
37
- it.each([
38
- ["a # line", "# Review: abc"],
39
- ["an @ line", "@mention"],
40
- ["a blank line", ""],
41
- ])("keeps %s inside a doc string", (_, line) => {
42
- const doc = (body: string[]) =>
43
- ["Given a file with:", ' """', ...body.map((l) => (l === "" ? "" : ` ${l}`)), ' """'].join(
44
- "\n",
45
- )
46
- expect(scenarioText(doc(["top", line, "end"]))).not.toBe(scenarioText(doc(["top", "end"])))
47
- })
48
-
49
- it("keeps indentation inside a doc string", () => {
50
- const doc = (line: string) => ["Given a file with:", ' """', ` ${line}`, ' """'].join("\n")
51
- expect(scenarioText(doc(" nested"))).not.toBe(scenarioText(doc("nested")))
52
- })
53
-
54
- it("ignores a doc string re-indented together with its delimiters", () => {
55
- const at = (indent: string) =>
56
- [
57
- "Given a file with:",
58
- `${indent}\`\`\``,
59
- `${indent}# Review`,
60
- `${indent} - x`,
61
- `${indent}\`\`\``,
62
- ].join("\n")
63
- expect(scenarioText(at(" "))).toBe(scenarioText(at(" ")))
64
- })
65
- })
66
-
67
- describe("freezeScenarios", () => {
68
- it("snapshots wording at head, nothing frozen is undefined", () => {
69
- const files = { "a.feature": "@t\nGiven a" }
70
- renderText(() => {
71
- expect(freezeScenarios([])).toBeUndefined()
72
- })
73
- installContext(fixtureContext({ head: "h1" }, tree(files)))
74
- expect(freezeScenarios(["a.feature"])).toEqual({
75
- at: "h1",
76
- texts: { "a.feature": scenarioText("Given a") },
77
- })
78
- })
79
-
80
- it("merges over previous, a new path overriding an old one", () => {
81
- installContext(
82
- fixtureContext({ head: "h2" }, tree({ "b.feature": "Given b", "a.feature": "Given a2" })),
83
- )
84
- const merged = freezeScenarios(["a.feature", "b.feature"], {
85
- at: "h1",
86
- texts: { "a.feature": "Given a", "c.feature": "Given c" },
87
- })
88
- expect(merged).toEqual({
89
- at: "h2",
90
- texts: {
91
- "a.feature": "Given a2",
92
- "b.feature": "Given b",
93
- "c.feature": "Given c",
94
- },
95
- })
96
- })
97
-
98
- it("keeps previous untouched when no paths are given", () => {
99
- const previous = { at: "h1", texts: { "a.feature": "Given a" } }
100
- installContext(fixtureContext({ head: "h2" }, tree({})))
101
- expect(freezeScenarios([], previous)).toBe(previous)
102
- })
103
- })
104
-
105
- describe("driftedScenarios", () => {
106
- const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
107
-
108
- it("flags edited and deleted files, ignores comment-only edits and other paths", () => {
109
- installContext(
110
- fixtureContext({}, tree({ "a.feature": "# note\nGiven a2", "other.feature": "Given z" })),
111
- )
112
- expect(driftedScenarios(frozen)).toEqual(["a.feature", "b.feature"])
113
- })
114
-
115
- it("is empty while wording is unchanged", () => {
116
- installContext(
117
- fixtureContext({}, tree({ "a.feature": " @x\n Given a", "b.feature": "Given b" })),
118
- )
119
- expect(driftedScenarios(frozen)).toEqual([])
120
- })
121
- })
122
-
123
- describe("holdWording", () => {
124
- const run = async (
125
- files: () => Record<string, string>,
126
- frozen: Parameters<typeof holdWording>[0],
127
- head = "h2",
128
- ): Promise<StepRequest[]> => {
129
- const steps: StepRequest[] = []
130
- installContext(
131
- fixtureContext(
132
- { head },
133
- {
134
- read: (path) => files()[path],
135
- step: (request) => {
136
- steps.push(request)
137
- return Promise.resolve()
138
- },
139
- },
140
- ),
141
- )
142
- await holdWording(frozen)
143
- return steps
144
- }
145
-
146
- it("does nothing without a freeze or without drift", async () => {
147
- expect(await run(() => ({}), undefined)).toEqual([])
148
- const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
149
- expect(await run(() => ({ "a.feature": "Given a" }), frozen)).toEqual([])
150
- })
151
-
152
- it("rests at scenario-wording with the removed and added lines and the restore command", async () => {
153
- const frozen = { at: "h1", texts: { "my dir/a.feature": "Given a\nThen ok" } }
154
- const steps = await run(() => ({ "my dir/a.feature": "Given a\nThen better" }), frozen)
155
- const [step] = steps
156
- if (step?.kind !== "human") throw new Error("expected a human step")
157
- expect(step.name).toBe("scenario-wording")
158
- expect(step.options.acceptClean).toBe(true)
159
- expect(step.options.base).toBe("h1")
160
- const message = step.options.message ?? ""
161
- expect(message).toContain("- Then ok")
162
- expect(message).toContain("+ Then better")
163
- expect(message).toContain("git checkout h1 -- 'my dir/a.feature'")
164
- expect(message).toContain("partial accept")
165
- })
166
-
167
- const shownDiff = async (before: string, after: string): Promise<string[]> => {
168
- const frozen = { at: "h1", texts: { "a.feature": before } }
169
- const [step] = await run(() => ({ "a.feature": after }), frozen)
170
- if (step?.kind !== "human") throw new Error("expected a human step")
171
- return (step.options.message ?? "").split("\n").filter((l) => /^[-+] /.test(l))
172
- }
173
-
174
- it("shows a moved step as one removal and one addition of it, in place", async () => {
175
- expect(await shownDiff("Given a\nWhen b\nThen c", "When b\nGiven a\nThen c")).toEqual([
176
- "- Given a",
177
- "+ Given a",
178
- ])
179
- })
180
-
181
- it("shows a blank line added inside a doc string", async () => {
182
- expect(await shownDiff('"""\na\nb\n"""', '"""\na\n\nb\n"""')).toEqual(["+ "])
183
- })
184
-
185
- it("shows a repeated step as its added copy", async () => {
186
- expect(await shownDiff("Given a\nThen c", "Given a\nGiven a\nThen c")).toEqual(["+ Given a"])
187
- })
188
-
189
- it("accepts still-differing files by moving the snapshot to head", async () => {
190
- const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
191
- await run(() => ({ "a.feature": "Given a2", "b.feature": "Given b2" }), frozen)
192
- expect(frozen).toEqual({
193
- at: "h2",
194
- texts: { "a.feature": "Given a2", "b.feature": "Given b2" },
195
- })
196
- })
197
-
198
- it("rejects restored files, keeping the original wording frozen", async () => {
199
- const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
200
- let files: Record<string, string> = { "a.feature": "Given a2", "b.feature": "Given b2" }
201
- installContext(
202
- fixtureContext(
203
- { head: "h2" },
204
- {
205
- read: (path) => files[path],
206
- step: () => {
207
- files = { "a.feature": "Given a", "b.feature": "Given b2" }
208
- return Promise.resolve()
209
- },
210
- },
211
- ),
212
- )
213
- await holdWording(frozen)
214
- expect(frozen.texts).toEqual({ "a.feature": "Given a", "b.feature": "Given b2" })
215
- })
216
-
217
- it("drops an accepted deletion from the snapshot", async () => {
218
- const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
219
- await run(() => ({}), frozen)
220
- expect(frozen.texts).toEqual({})
221
- })
222
- })
223
-
224
- describe("guarded", () => {
225
- it("runs the turn, then holds wording", async () => {
226
- const order: string[] = []
227
- const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
228
- installContext(
229
- fixtureContext(
230
- { head: "h2" },
231
- {
232
- read: () => "Given changed",
233
- step: (request) => {
234
- order.push(request.kind)
235
- return Promise.resolve()
236
- },
237
- },
238
- ),
239
- )
240
- await guarded(frozen, () => {
241
- order.push("turn")
242
- return Promise.resolve()
243
- })()
244
- expect(order).toEqual(["turn", "human"])
245
- })
246
-
247
- it("is the bare turn without a freeze", async () => {
248
- const order: string[] = []
249
- installContext(fixtureContext())
250
- await guarded(undefined, () => {
251
- order.push("turn")
252
- return Promise.resolve()
253
- })()
254
- expect(order).toEqual(["turn"])
255
- })
256
- })
@@ -1,35 +0,0 @@
1
- import { human, scope } from "../../flows/index.js"
2
- import * as kit from "../../workflow-kit/index.js"
3
- import * as t from "./text.js"
4
-
5
- export {
6
- driftedScenarios,
7
- freezeScenarios,
8
- scenarioText,
9
- wordingDrift,
10
- type FrozenScenarios,
11
- } from "../../workflow-kit/index.js"
12
-
13
- /** After a turn: stop at `scenario-wording` while wording drifted; accept moves the snapshot. */
14
- export const holdWording = (frozen: kit.FrozenScenarios | undefined): Promise<void> =>
15
- kit.holdWording(frozen, (at, drifted) =>
16
- human("scenario-wording", {
17
- message: t.scenarioWordingMessage(at, drifted),
18
- label: "Scenario wording changed",
19
- acceptClean: true,
20
- base: at,
21
- }),
22
- )
23
-
24
- /** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
25
- export const guarded =
26
- (
27
- frozen: kit.FrozenScenarios | undefined,
28
- turn: () => Promise<void>,
29
- within?: string,
30
- ): (() => Promise<void>) =>
31
- async () => {
32
- await turn()
33
- if (within === undefined) await holdWording(frozen)
34
- else await scope(within, () => holdWording(frozen))
35
- }