@pmelab/gtd 23.2.0 → 24.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 (236) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +44 -11
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43830 -56044
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +1953 -1724
  19. package/package.json +70 -8
  20. package/skills/authoring/SKILL.md +5 -4
  21. package/src/cli/Cli.test.ts +1329 -0
  22. package/src/cli/Cli.ts +1348 -0
  23. package/src/cli/Program.test.ts +2262 -0
  24. package/src/cli/Program.ts +1331 -0
  25. package/src/cli/ToolCommands.ts +43 -0
  26. package/src/cli/index.ts +14 -0
  27. package/src/edge/Edge.test.ts +1016 -0
  28. package/src/edge/Edge.ts +1225 -0
  29. package/src/edge/index.ts +16 -0
  30. package/src/emit/Emit.test.ts +425 -0
  31. package/src/emit/Emit.ts +139 -0
  32. package/src/emit/GitScript.test.ts +260 -0
  33. package/src/emit/GitScript.ts +131 -0
  34. package/src/emit/ModeContradiction.test.ts +120 -0
  35. package/src/emit/ModeContradiction.ts +66 -0
  36. package/src/emit/OutcomeScript.test.ts +238 -0
  37. package/src/emit/OutcomeScript.ts +73 -0
  38. package/src/emit/SteeringMode.test.ts +274 -0
  39. package/src/emit/SteeringMode.ts +256 -0
  40. package/src/emit/index.ts +40 -0
  41. package/src/flows/factory.ts +5 -1
  42. package/src/flows/runtime.ts +3 -0
  43. package/src/install/ConfigScaffold.ts +19 -0
  44. package/src/install/Install.test.ts +426 -0
  45. package/src/install/Install.ts +634 -0
  46. package/src/install/Version.ts +27 -0
  47. package/src/install/index.ts +3 -0
  48. package/src/platform/CommandRunner.ts +74 -0
  49. package/src/platform/Commentary.test.ts +126 -0
  50. package/src/platform/Commentary.ts +92 -0
  51. package/src/platform/Git.test.ts +50 -0
  52. package/src/platform/Git.ts +552 -0
  53. package/src/platform/Host.ts +42 -0
  54. package/src/platform/RetainedHistory.test.ts +65 -0
  55. package/src/platform/RetainedHistory.ts +34 -0
  56. package/src/platform/Workspace.test.ts +669 -0
  57. package/src/platform/Workspace.ts +631 -0
  58. package/src/platform/WorktreeState.test.ts +162 -0
  59. package/src/platform/WorktreeState.ts +50 -0
  60. package/src/platform/index.ts +22 -0
  61. package/src/replay/Access.test.ts +53 -0
  62. package/src/replay/Access.ts +46 -0
  63. package/src/replay/Format.test.ts +20 -0
  64. package/src/replay/Format.ts +14 -0
  65. package/src/replay/Replay.test.ts +887 -0
  66. package/src/replay/Replay.ts +669 -0
  67. package/src/replay/Trailers.test.ts +117 -0
  68. package/src/replay/Trailers.ts +213 -0
  69. package/src/replay/Tree.test.ts +25 -0
  70. package/src/replay/Tree.ts +45 -0
  71. package/src/replay/index.ts +11 -0
  72. package/src/steering/Blocks.fixture.ts +6 -0
  73. package/src/steering/Blocks.test.ts +167 -0
  74. package/src/steering/Blocks.ts +321 -0
  75. package/src/steering/CodeThreads.test.ts +127 -0
  76. package/src/steering/CodeThreads.ts +197 -0
  77. package/src/steering/Edits.test.ts +19 -0
  78. package/src/steering/Edits.ts +20 -0
  79. package/src/steering/Eol.test.ts +16 -0
  80. package/src/steering/Eol.ts +2 -0
  81. package/src/steering/FORMATS.md +58 -0
  82. package/src/steering/Footnotes.test.ts +977 -0
  83. package/src/steering/Footnotes.ts +661 -0
  84. package/src/steering/Inline.fixture.ts +7 -0
  85. package/src/steering/Inline.test.ts +227 -0
  86. package/src/steering/Inline.ts +195 -0
  87. package/src/steering/MarkdownTree.test.ts +247 -0
  88. package/src/steering/MarkdownTree.ts +260 -0
  89. package/src/steering/Outline.ts +19 -0
  90. package/src/steering/SteeringFormat.test.ts +91 -0
  91. package/src/steering/SteeringFormat.ts +267 -0
  92. package/src/steering/SteeringFormats.test.ts +163 -0
  93. package/src/steering/Threads.ts +163 -0
  94. package/src/steering/contract.test.ts +264 -0
  95. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  96. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  97. package/src/steering/fixtures/free-form/notes.md +12 -0
  98. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  99. package/src/steering/fixtures/qa/answered.md +23 -0
  100. package/src/steering/fixtures/qa/answered.read.json +119 -0
  101. package/src/steering/fixtures/qa/requirements.md +160 -0
  102. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  103. package/src/steering/fixtures/review/review.md +76 -0
  104. package/src/steering/fixtures/review/review.read.json +237 -0
  105. package/src/steering/fixtures/review/ticked.md +23 -0
  106. package/src/steering/fixtures/review/ticked.read.json +99 -0
  107. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  108. package/src/steering/fixtures/review-notes/noted.md +19 -0
  109. package/src/steering/freeform.test.ts +311 -0
  110. package/src/steering/freeform.ts +148 -0
  111. package/src/steering/index.test.ts +62 -0
  112. package/src/steering/index.ts +140 -0
  113. package/src/steering/qa.test.ts +2160 -0
  114. package/src/steering/qa.ts +1104 -0
  115. package/src/steering/review.test.ts +1829 -0
  116. package/src/steering/review.ts +1180 -0
  117. package/src/step/LandStep.ts +31 -0
  118. package/src/step/RepoSnapshot.ts +29 -0
  119. package/src/step/accessRefusal.test.ts +58 -0
  120. package/src/step/accessRefusal.ts +26 -0
  121. package/src/step/index.ts +5 -0
  122. package/src/step/planStart.test.ts +202 -0
  123. package/src/step/planStart.ts +167 -0
  124. package/src/step/planStep.test.ts +104 -0
  125. package/src/step/planStep.ts +89 -0
  126. package/src/step/snapshot.fixture.ts +28 -0
  127. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  128. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  129. package/src/testkit/FakeGitOperations.ts +95 -0
  130. package/src/testkit/GitTiers.test.ts +11 -0
  131. package/src/testkit/GitTiers.ts +1052 -0
  132. package/src/testkit/InMemRepo.ts +467 -0
  133. package/src/testkit/Layers.test.ts +58 -0
  134. package/src/testkit/Layers.ts +241 -0
  135. package/src/testkit/StrictGitOperations.ts +31 -0
  136. package/src/testkit/TestRepo.test.ts +119 -0
  137. package/src/testkit/TestRepo.ts +274 -0
  138. package/src/testkit/cliIo.ts +36 -0
  139. package/src/testkit/index.ts +9 -0
  140. package/src/testkit/steps.ts +177 -0
  141. package/src/testkit/testkit.feature +65 -0
  142. package/src/wire/BeatDocument.test.ts +419 -0
  143. package/src/wire/BeatDocument.ts +114 -0
  144. package/src/wire/BeatPlain.test.ts +113 -0
  145. package/src/wire/BeatPlain.ts +127 -0
  146. package/src/wire/BeatStatus.test.ts +59 -0
  147. package/src/wire/BeatStatus.ts +81 -0
  148. package/src/wire/Demand.test.ts +142 -0
  149. package/src/wire/Demand.ts +108 -0
  150. package/src/wire/ExitCodes.test.ts +21 -0
  151. package/src/wire/ExitCodes.ts +16 -0
  152. package/src/wire/Glob.ts +29 -0
  153. package/src/wire/Judge.ts +22 -0
  154. package/src/wire/Land.test.ts +120 -0
  155. package/src/wire/Land.ts +75 -0
  156. package/src/wire/Select.test.ts +148 -0
  157. package/src/wire/Select.ts +78 -0
  158. package/src/wire/Sessions.test.ts +59 -0
  159. package/src/wire/Sessions.ts +39 -0
  160. package/src/wire/WireSchema.test.ts +162 -0
  161. package/src/wire/constants.ts +22 -0
  162. package/src/wire/golden/1/beat-capture.json +34 -0
  163. package/src/wire/golden/1/beat-message.json +34 -0
  164. package/src/wire/golden/1/beat-prompt.json +44 -0
  165. package/src/wire/golden/1/beat-script.json +34 -0
  166. package/src/wire/golden/1/beat-stalled.json +34 -0
  167. package/src/wire/golden/1/judge.json +14 -0
  168. package/src/wire/golden/1/land-noop.json +10 -0
  169. package/src/wire/golden/1/land.json +10 -0
  170. package/src/wire/index.ts +12 -0
  171. package/src/wire/types.ts +46 -0
  172. package/src/workflow/Diagnostic.test.ts +93 -0
  173. package/src/workflow/Diagnostic.ts +54 -0
  174. package/src/workflow/SteeringFormats.test.ts +27 -0
  175. package/src/workflow/SteeringFormats.ts +6 -0
  176. package/src/workflow/Workflow.ts +77 -0
  177. package/src/workflow/compile.test.ts +53 -0
  178. package/src/workflow/compile.ts +56 -0
  179. package/src/workflow/discovery.ts +122 -0
  180. package/src/workflow/fingerprint.test.ts +52 -0
  181. package/src/workflow/fingerprint.ts +53 -0
  182. package/src/workflow/index.ts +33 -0
  183. package/src/workflow/load.test.ts +1017 -0
  184. package/src/workflow/load.ts +836 -0
  185. package/src/workflow/skills.test.ts +109 -0
  186. package/src/workflow/skills.ts +90 -0
  187. package/src/workflow/vars.ts +33 -0
  188. package/src/workflow-kit/health.test.ts +30 -0
  189. package/src/workflow-kit/health.ts +86 -0
  190. package/src/workflow-kit/index.ts +7 -0
  191. package/src/workflow-kit/quality.test.ts +17 -0
  192. package/src/workflow-kit/quality.ts +45 -0
  193. package/src/workflow-kit/review.ts +324 -0
  194. package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
  195. package/src/workflows/feature/feature.test.ts +32 -0
  196. package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
  197. package/src/workflows/feature/index.ts +10 -0
  198. package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
  199. package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
  200. package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
  201. package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
  202. package/src/workflows/feature/review.ts +106 -0
  203. package/src/workflows/feature/steps.test.ts +82 -0
  204. package/src/workflows/feature/steps.ts +92 -0
  205. package/src/workflows/fix/fix.feature +80 -0
  206. package/src/workflows/fix/fix.test.ts +74 -0
  207. package/src/workflows/fix/fix.ts +52 -0
  208. package/src/workflows/fix/index.ts +6 -0
  209. package/src/workflows/index.ts +36 -2
  210. package/src/workflows/review/index.ts +6 -0
  211. package/src/workflows/review/review.test.ts +99 -0
  212. package/src/workflows/review/review.ts +33 -0
  213. package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
  214. package/src/workflows/{access.ts → shared/access.ts} +3 -5
  215. package/src/workflows/shared/config.fixture.ts +12 -0
  216. package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
  217. package/src/workflows/{health.ts → shared/health.ts} +38 -55
  218. package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
  219. package/src/workflows/{options.ts → shared/options.ts} +14 -2
  220. package/src/workflows/shared/reserved.ts +11 -0
  221. package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
  222. package/src/workflows/shared/scenarios.ts +35 -0
  223. package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
  224. package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
  225. package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
  226. package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
  227. package/src/workflows/shared/tail.test.ts +205 -0
  228. package/src/workflows/shared/tail.ts +135 -0
  229. package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
  230. package/src/workflows/{text.ts → shared/text.ts} +48 -7
  231. package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
  232. package/src/workflows/shared/workflows.steps.ts +7 -0
  233. package/src/workflows/review.ts +0 -362
  234. /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
  235. /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
  236. /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
@@ -0,0 +1,109 @@
1
+ import { Cause, Effect, Exit } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { resolveScopeAccess, resolveScopeSkills } from "./skills.js"
4
+
5
+ const FILE = "/repo/gtd.config.ts"
6
+
7
+ const failure = async (skills: (vars: Record<string, string>) => unknown): Promise<string> => {
8
+ const exit = await Effect.runPromiseExit(
9
+ resolveScopeSkills({ skills: skills as never, configuredSkills: {}, skillsOrigin: FILE }, {}),
10
+ )
11
+ expect(Exit.isFailure(exit)).toBe(true)
12
+ return Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : ""
13
+ }
14
+
15
+ describe("resolveScopeSkills", () => {
16
+ it("wraps a throwing export with the config prefix and file", async () => {
17
+ const message = await failure(() => {
18
+ throw new Error("boom")
19
+ })
20
+ expect(message).toContain(`gtd config:\n - ${FILE}: boom`)
21
+ })
22
+
23
+ it("rejects a non-object return", async () => {
24
+ expect(await failure(() => ["a"])).toContain(
25
+ `gtd config:\n - ${FILE}: skills export must return an object of scope -> skill names`,
26
+ )
27
+ })
28
+
29
+ it("rejects a non-array value", async () => {
30
+ expect(await failure(() => ({ a: "x" }))).toContain(
31
+ `gtd config:\n - ${FILE}: skills export: "a" must be an array of skill names`,
32
+ )
33
+ })
34
+
35
+ it("rejects a non-string entry", async () => {
36
+ expect(await failure(() => ({ a: ["x", 1] }))).toContain(
37
+ `gtd config:\n - ${FILE}: skills export: "a" must be an array of skill names`,
38
+ )
39
+ })
40
+
41
+ it("reports an unknown config key at gtd.config.ts", async () => {
42
+ const exit = await Effect.runPromiseExit(
43
+ resolveScopeSkills(
44
+ { skills: () => ({ a: ["x"] }), configuredSkills: { nope: [] }, skillsOrigin: FILE },
45
+ {},
46
+ ),
47
+ )
48
+ expect(String(Exit.isFailure(exit) ? Cause.squash(exit.cause) : "")).toContain(
49
+ `${FILE}: config.skills.nope: "skills.nope" is not a scope that runs a turn — known scopes: a`,
50
+ )
51
+ })
52
+ })
53
+
54
+ describe("resolveScopeAccess", () => {
55
+ const run = (
56
+ def: {
57
+ access: (vars: Record<string, string>) => unknown
58
+ configuredAccess?: Record<string, object>
59
+ },
60
+ skills: Record<string, readonly string[]> = {},
61
+ ) =>
62
+ Effect.runPromiseExit(
63
+ resolveScopeAccess(
64
+ {
65
+ access: def.access as never,
66
+ configuredAccess: def.configuredAccess ?? {},
67
+ skillsOrigin: FILE,
68
+ },
69
+ skills,
70
+ {},
71
+ ),
72
+ )
73
+ const message = (exit: Awaited<ReturnType<typeof run>>): string =>
74
+ Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : ""
75
+
76
+ it("returns a well-shaped record", async () => {
77
+ const exit = await run({ access: () => ({ a: { write: ["x"] }, b: {} }) })
78
+ expect(Exit.isSuccess(exit) && exit.value).toEqual({ a: { write: ["x"] }, b: {} })
79
+ })
80
+
81
+ it("rejects a malformed export with the file", async () => {
82
+ expect(message(await run({ access: () => ["a"] }))).toContain(
83
+ `gtd config:\n - ${FILE}: access export must return an object`,
84
+ )
85
+ expect(message(await run({ access: () => ({ a: { write: "x" } }) }))).toContain(
86
+ `${FILE}: access export: "a": access.write must be an array`,
87
+ )
88
+ })
89
+
90
+ it("knows the scopes of both the skills and access exports", async () => {
91
+ const exit = await run(
92
+ {
93
+ access: () => ({ fromAccess: {} }),
94
+ configuredAccess: { fromSkills: {}, fromAccess: {} },
95
+ },
96
+ { fromSkills: ["s"] },
97
+ )
98
+ expect(Exit.isSuccess(exit)).toBe(true)
99
+ })
100
+
101
+ it("reports an unknown key with the exact message and known scopes", async () => {
102
+ const text = message(
103
+ await run({ access: () => ({ b: {} }), configuredAccess: { nope: {} } }, { a: ["x"] }),
104
+ )
105
+ expect(text).toContain(
106
+ `${FILE}: config.access.nope: "access.nope" is not a scope that runs a turn — known scopes: a, b`,
107
+ )
108
+ })
109
+ })
@@ -0,0 +1,90 @@
1
+ import { Effect } from "effect"
2
+ import { GtdError } from "../platform/index.js"
3
+ import type { WorkflowDefinition } from "./Workflow.js"
4
+ import { accessShapeFault } from "../replay/index.js"
5
+ import type { ScopeAccess } from "../flows/index.js"
6
+ import type { Diagnostic } from "./Diagnostic.js"
7
+ import { failOnErrors } from "./load.js"
8
+
9
+ const fail = (origin: string, message: string): GtdError =>
10
+ new GtdError(`gtd config:\n - ${origin}: ${message}`)
11
+
12
+ const checkShape = (value: unknown): Record<string, readonly string[]> => {
13
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
14
+ throw new Error("skills export must return an object of scope -> skill names")
15
+ }
16
+ for (const [scope, names] of Object.entries(value)) {
17
+ if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) {
18
+ throw new Error(`skills export: "${scope}" must be an array of skill names`)
19
+ }
20
+ }
21
+ return value as Record<string, readonly string[]>
22
+ }
23
+
24
+ /** Every `config` key that names no known scope is a load error. */
25
+ const checkKeys = (
26
+ configured: Readonly<Record<string, unknown>>,
27
+ origin: string,
28
+ known: readonly string[],
29
+ section: "skills" | "access",
30
+ ): Effect.Effect<void, GtdError> => {
31
+ const sorted = [...known].sort()
32
+ const diagnostics: Diagnostic[] = Object.keys(configured)
33
+ .filter((key) => !sorted.includes(key))
34
+ .sort()
35
+ .map((key) => ({
36
+ severity: "error" as const,
37
+ path: ["config", section, key],
38
+ message: `"${section}.${key}" is not a scope that runs a turn — known scopes: ${sorted.join(", ")}`,
39
+ origin,
40
+ }))
41
+ return failOnErrors(diagnostics)
42
+ }
43
+
44
+ /**
45
+ * Skill lists for the settings actually in use. `config.skills` keys are
46
+ * judged here, not at config load: only now are the pinned/entry vars known.
47
+ */
48
+ export const resolveScopeSkills = (
49
+ def: Pick<WorkflowDefinition, "skills" | "configuredSkills" | "skillsOrigin">,
50
+ vars: Readonly<Record<string, string>>,
51
+ ): Effect.Effect<Record<string, readonly string[]>, GtdError> =>
52
+ Effect.gen(function* () {
53
+ const skills = yield* Effect.try({
54
+ try: () => checkShape(def.skills(vars)),
55
+ catch: (e) => fail(def.skillsOrigin, e instanceof Error ? e.message : String(e)),
56
+ })
57
+ const known = Object.keys(skills)
58
+ yield* checkKeys(def.configuredSkills, def.skillsOrigin, known, "skills")
59
+ return skills
60
+ })
61
+
62
+ const checkAccessShape = (value: unknown): Record<string, ScopeAccess> => {
63
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
64
+ throw new Error("access export must return an object of scope -> { read?, write? }")
65
+ }
66
+ for (const [scope, access] of Object.entries(value)) {
67
+ const fault = accessShapeFault(access)
68
+ if (fault !== undefined) throw new Error(`access export: "${scope}": ${fault}`)
69
+ }
70
+ return value as Record<string, ScopeAccess>
71
+ }
72
+
73
+ /**
74
+ * Access for the settings actually in use. A `config.access` key may name
75
+ * any scope the workflow gives skills or access to.
76
+ */
77
+ export const resolveScopeAccess = (
78
+ def: Pick<WorkflowDefinition, "access" | "configuredAccess" | "skillsOrigin">,
79
+ skills: Readonly<Record<string, readonly string[]>>,
80
+ vars: Readonly<Record<string, string>>,
81
+ ): Effect.Effect<Record<string, ScopeAccess>, GtdError> =>
82
+ Effect.gen(function* () {
83
+ const access = yield* Effect.try({
84
+ try: () => checkAccessShape(def.access(vars)),
85
+ catch: (e) => fail(def.skillsOrigin, e instanceof Error ? e.message : String(e)),
86
+ })
87
+ const known = [...new Set([...Object.keys(skills), ...Object.keys(access)])]
88
+ yield* checkKeys(def.configuredAccess, def.skillsOrigin, known, "access")
89
+ return access
90
+ })
@@ -0,0 +1,33 @@
1
+ const PREFIX = "GTD_"
2
+
3
+ /**
4
+ * One kind of setting, later wins: the workflow's own defaults, `--var`
5
+ * overrides (process settings only; environment settings pass `{}`), then
6
+ * `GTD_<NAME>` for any name an earlier layer declared (a shell variable never
7
+ * introduces a name).
8
+ */
9
+ export const resolveVars = (
10
+ workflowVars: Readonly<Record<string, string>>,
11
+ entryVars: Readonly<Record<string, string>>,
12
+ env: Readonly<Record<string, string | undefined>>,
13
+ ): Record<string, string> => {
14
+ const merged = { ...workflowVars, ...entryVars }
15
+ for (const name of Object.keys(merged)) {
16
+ const value = env[PREFIX + name.toUpperCase()]
17
+ if (value !== undefined) merged[name] = value
18
+ }
19
+ return merged
20
+ }
21
+
22
+ /** The first process setting whose value spans lines — a `Gtd-Var:` trailer cannot carry it. */
23
+ export const multilineSetting = (vars: Readonly<Record<string, string>>): string | undefined =>
24
+ Object.keys(vars)
25
+ .sort()
26
+ .find((name) => /[\r\n]/.test(vars[name]!))
27
+
28
+ const SETTING_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
29
+
30
+ /** A name is written verbatim into a `Gtd-Var: <name>=<value>` trailer, so `=` or a newline would corrupt it. */
31
+ export const isSettingName = (name: string): boolean => SETTING_NAME.test(name)
32
+
33
+ export const SETTING_NAME_RULE = 'a setting name is a letter or "_", then letters, digits or "_"'
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { escalation, untilSettled } from "./health.js"
3
+
4
+ describe("escalation", () => {
5
+ it("describes and stops until `limit` rounds are spent, then reports exhaustion", async () => {
6
+ const log: string[] = []
7
+ const steps = {
8
+ limit: 2,
9
+ describe: async () => void log.push("describe"),
10
+ stop: async () => void log.push("stop"),
11
+ exhausted: async () => void log.push("exhausted"),
12
+ }
13
+ const count = { rounds: 0 }
14
+ for (let i = 0; i < 3; i++) await escalation(count, steps)
15
+ expect(log).toEqual(["describe", "stop", "describe", "stop", "exhausted"])
16
+ expect(count.rounds).toBe(2)
17
+ })
18
+ })
19
+
20
+ describe("untilSettled", () => {
21
+ it("reruns while pending and resolves the first run that counts", async () => {
22
+ const results = [false, false, true]
23
+ let pending = 2
24
+ const green = await untilSettled(
25
+ async () => results.shift()!,
26
+ () => pending-- > 0,
27
+ )
28
+ expect(green).toBe(true)
29
+ })
30
+ })
@@ -0,0 +1,86 @@
1
+ import { read, scope } from "../flows/index.js"
2
+
3
+ /** How many escalation rounds a run of red checks has spent — reset once the suite goes green. */
4
+ export interface EscalationCount {
5
+ rounds: number
6
+ }
7
+
8
+ export interface EscalationSteps {
9
+ /** Rounds a person narrows before `exhausted` replaces them. */
10
+ readonly limit: number
11
+ readonly describe: () => Promise<void>
12
+ readonly stop: () => Promise<void>
13
+ readonly exhausted: () => Promise<void>
14
+ }
15
+
16
+ /** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
17
+ export const escalation = async (count: EscalationCount, steps: EscalationSteps): Promise<void> => {
18
+ if (count.rounds >= steps.limit) return steps.exhausted()
19
+ count.rounds++
20
+ await steps.describe()
21
+ await steps.stop()
22
+ }
23
+
24
+ /** Run `check` again while `pending()` holds — a run then does not count — and resolve the first run that does. */
25
+ export const untilSettled = async (
26
+ check: () => Promise<boolean>,
27
+ pending: () => boolean,
28
+ ): Promise<boolean> => {
29
+ for (;;) {
30
+ const green = await check()
31
+ if (!pending()) return green
32
+ }
33
+ }
34
+
35
+ export interface Health {
36
+ /** One counted run of the suite; resolves `true` when green. */
37
+ readonly check: () => Promise<boolean>
38
+ /** Where `check` leaves a red run's output. */
39
+ readonly report: string
40
+ readonly fixCap: number
41
+ readonly fixesSoFar?: number
42
+ /** Whether two consecutive red reports are the same failure; `true` escalates at once. */
43
+ readonly sameFailure: (previous: string, current: string) => Promise<boolean>
44
+ /** Shared with other callers in the same run of red checks. */
45
+ readonly escalations: EscalationCount
46
+ readonly escalation: EscalationSteps
47
+ }
48
+
49
+ // check() stamps the report with the commit it ran at, so equal failures differ there.
50
+ const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/
51
+ const stripStamp = (text: string): string => text.replace(STAMP, "\n")
52
+
53
+ /**
54
+ * Loop until the suite is green: check, and on red run `fix` — escalating
55
+ * first once `fixCap` fixes are spent, or `sameFailure` calls the failure
56
+ * identical to the previous round's.
57
+ */
58
+ export const healthy = async (fix: () => Promise<void>, health: Health): Promise<void> => {
59
+ let fixes = health.fixesSoFar ?? 0
60
+ let previous: string | undefined
61
+ for (;;) {
62
+ if (await health.check()) {
63
+ health.escalations.rounds = 0
64
+ return
65
+ }
66
+ const current = stripStamp(read(health.report) ?? "")
67
+ const stuck = previous !== undefined && (await health.sameFailure(previous, current))
68
+ previous = current
69
+ if (stuck || fixes >= health.fixCap) {
70
+ await escalation(health.escalations, health.escalation)
71
+ fixes = 0
72
+ }
73
+ fixes++
74
+ await fix()
75
+ }
76
+ }
77
+
78
+ /** Hold the process in scope `name` until `passes` resolves `true`, resting at `blocked` between tries. */
79
+ export const gate = (
80
+ name: string,
81
+ passes: () => Promise<boolean>,
82
+ blocked: () => Promise<void>,
83
+ ): Promise<void> =>
84
+ scope(name, async () => {
85
+ while (!(await passes())) await blocked()
86
+ })
@@ -0,0 +1,7 @@
1
+ // Building blocks the shipped workflows share. Policy-free: every skill list,
2
+ // lens, prompt, step, path and threshold arrives as a parameter, so a new
3
+ // workflow composes these without inheriting the shipped one's choices.
4
+ export * from "./health.js"
5
+ export * from "./quality.js"
6
+ export * from "./scenarios.js"
7
+ export * from "./review.js"
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { lensesOf, lensScopes } from "./quality.js"
3
+
4
+ describe("lensesOf", () => {
5
+ it("splits on commas, trims, drops blanks", () => {
6
+ expect(lensesOf(" a ,, b,")).toEqual(["a", "b"])
7
+ })
8
+ })
9
+
10
+ describe("lensScopes", () => {
11
+ it("keys one entry per lens under the given scope", () => {
12
+ expect(lensScopes("x.quality", ["a", "b"], (lens) => [lens])).toEqual({
13
+ "x.quality.a": ["a"],
14
+ "x.quality.b": ["b"],
15
+ })
16
+ })
17
+ })
@@ -0,0 +1,45 @@
1
+ import { read } from "../flows/index.js"
2
+
3
+ /** Split a comma-separated lens list: trimmed, blanks dropped. */
4
+ export const lensesOf = (value: string): readonly string[] =>
5
+ value
6
+ .split(",")
7
+ .map((lens) => lens.trim())
8
+ .filter((lens) => lens.length > 0)
9
+
10
+ /** One `<scope>.<lens>` entry per lens — the per-lens part of a skills or access export. */
11
+ export const lensScopes = <T>(
12
+ scope: string,
13
+ lenses: readonly string[],
14
+ entry: (lens: string) => T,
15
+ ): Record<string, T> => Object.fromEntries(lenses.map((lens) => [`${scope}.${lens}`, entry(lens)]))
16
+
17
+ /** One `review` turn per lens, each appending its findings to `findings`; resolves `"findings"` when that file has any. */
18
+ export const qualityLap = async (
19
+ lenses: readonly string[],
20
+ review: (lens: string) => Promise<void>,
21
+ findings: string,
22
+ ): Promise<"clean" | "findings"> => {
23
+ for (const lens of lenses) await review(lens)
24
+ return (read(findings) ?? "").length > 0 ? "findings" : "clean"
25
+ }
26
+
27
+ export interface FindingsFix {
28
+ /** One fix turn; it removes `findings` once every finding is resolved. */
29
+ readonly fix: () => Promise<void>
30
+ readonly findings: string
31
+ readonly cap: number
32
+ /** Runs once `cap` turns left findings. */
33
+ readonly escalate: () => Promise<void>
34
+ }
35
+
36
+ /** Resolves `true` once `findings` is gone, `false` when `cap` turns escalated instead. */
37
+ export const fixFindings = async (f: FindingsFix): Promise<boolean> => {
38
+ for (let turns = 0; turns < f.cap; turns++) {
39
+ await f.fix()
40
+ // Not `changes()`: a wording gate after the fix would be the last step.
41
+ if (read(f.findings) === undefined) return true
42
+ }
43
+ await f.escalate()
44
+ return false
45
+ }