@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,56 @@
1
+ import { seededValidateCommand } from "./SteeringFormats.js"
2
+ import { BUILT_IN_MODE_NAMES } from "../steering/index.js"
3
+ import type { ModeDef } from "./Workflow.js"
4
+ import { configProblems, type GtdConfig, type UiSettings } from "../flows/index.js"
5
+ import type { Diagnostic } from "./Diagnostic.js"
6
+
7
+ export interface CompiledConfig {
8
+ /** The built-in modes (qa/review, with gtd's own validators) merged with `config.modes`, per half. */
9
+ readonly modes: Record<string, ModeDef>
10
+ readonly ui?: UiSettings
11
+ readonly skills: Readonly<Record<string, readonly string[]>>
12
+ readonly access: GtdConfig["access"] & {}
13
+ readonly diagnostics: readonly Diagnostic[]
14
+ }
15
+
16
+ /**
17
+ * A `gtd.config.ts` `config` export (or its absence), checked and compiled. A
18
+ * malformed export compiles to nothing beyond the built-in modes, with every
19
+ * problem reported at `origin`.
20
+ */
21
+ export const compileConfig = (value: unknown, origin: string): CompiledConfig => {
22
+ const seeded: Record<string, ModeDef> = Object.fromEntries(
23
+ BUILT_IN_MODE_NAMES.map((name) => [name, { validate: seededValidateCommand(name) }]),
24
+ )
25
+ const problems = value === undefined ? [] : configProblems(value)
26
+ if (problems.length > 0) {
27
+ return {
28
+ modes: seeded,
29
+ skills: {},
30
+ access: {},
31
+ diagnostics: problems.map(({ path, message }) => ({
32
+ severity: "error",
33
+ path: ["config", ...path],
34
+ message,
35
+ origin,
36
+ })),
37
+ }
38
+ }
39
+ const config = (value ?? {}) as GtdConfig
40
+ // Per half: a `format:` alone, or a half left `undefined` (an unset
41
+ // `process.env.X`), keeps a built-in mode's own validator.
42
+ const modes = { ...seeded }
43
+ for (const [name, entry] of Object.entries(config.modes ?? {}))
44
+ modes[name] = {
45
+ ...modes[name],
46
+ ...Object.fromEntries(Object.entries(entry).filter(([, command]) => command !== undefined)),
47
+ }
48
+ return {
49
+ modes,
50
+ ...(config.ui !== undefined ? { ui: config.ui } : {}),
51
+ // Null prototype: a `__proto__` key must stay an ordinary (reportable) entry.
52
+ skills: Object.assign(Object.create(null), config.skills),
53
+ access: Object.assign(Object.create(null), config.access),
54
+ diagnostics: [],
55
+ }
56
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs"
2
+ import { dirname, join } from "node:path"
3
+ import { Context, Effect, Layer } from "effect"
4
+
5
+ /** The workflow module found on the cwd→home walk, with its source. */
6
+ export interface WorkflowModule {
7
+ readonly filepath: string
8
+ readonly source: string
9
+ }
10
+
11
+ /** A leftover rc file, and whether the user running gtd owns it — and so can delete it. */
12
+ interface LegacyRcFile {
13
+ readonly path: string
14
+ readonly owned: boolean
15
+ }
16
+
17
+ interface ConfigDiscoveryOps {
18
+ /** Every removed `.gtdrc`-family file on the `root`→`home` walk and in `home` itself, innermost first. */
19
+ readonly legacyRcFiles: (
20
+ root: string,
21
+ home: string,
22
+ ) => Effect.Effect<readonly LegacyRcFile[], Error>
23
+ /** The innermost `gtd.config.ts` walking `root` up through `home`, if any. */
24
+ readonly workflowModule: (
25
+ root: string,
26
+ home: string,
27
+ ) => Effect.Effect<WorkflowModule | undefined, Error>
28
+ }
29
+
30
+ export const WORKFLOW_MODULE = "gtd.config.ts"
31
+
32
+ /**
33
+ * The file names gtd read repository settings from before `gtd.config.ts` took
34
+ * them over. Still looked for, only to refuse them: a leftover one would
35
+ * otherwise be silently ignored. Shared with the in-memory counterpart in
36
+ * `src/testkit/Layers.ts`, so both tiers refuse the same names.
37
+ */
38
+ export const LEGACY_RC_FILES = [
39
+ ".gtdrc",
40
+ ".gtdrc.json",
41
+ ".gtdrc.yaml",
42
+ ".gtdrc.yml",
43
+ "gtd.config.json",
44
+ "gtd.config.yaml",
45
+ ]
46
+
47
+ /** The directories searched for a leftover rc file: the walk, plus `home` when the walk misses it. */
48
+ export const legacyRcDirs = (root: string, home: string): readonly string[] => {
49
+ const chain = walkUp(root, home)
50
+ return chain.includes(home) ? chain : [...chain, home]
51
+ }
52
+
53
+ const legacyRcFileAt = (path: string): LegacyRcFile | undefined => {
54
+ try {
55
+ const stat = statSync(path)
56
+ if (!stat.isFile()) return undefined
57
+ // Above home, a shared directory (`/tmp`) can hold another user's file.
58
+ return { path, owned: process.getuid === undefined || stat.uid === process.getuid() }
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Enumerate the directory chain from `from` walking UP. Stops after including
66
+ * the user's home dir (inclusive, when it is an ancestor) or after reaching the
67
+ * filesystem root — whichever comes first. Returned innermost→outermost.
68
+ * Exported (via the barrel) for the same reason `SEARCH_PLACES` is — one
69
+ * shared implementation, not a second copy in `src/testkit/Layers.ts` that
70
+ * could silently diverge.
71
+ */
72
+ export const walkUp = (from: string, home: string): ReadonlyArray<string> => {
73
+ const chain: Array<string> = []
74
+ let dir = from
75
+ while (true) {
76
+ chain.push(dir)
77
+ if (dir === home) break
78
+ const parent = dirname(dir)
79
+ if (parent === dir) break // filesystem root
80
+ dir = parent
81
+ }
82
+ return chain
83
+ }
84
+
85
+ const legacyRcFiles = (root: string, home: string): Effect.Effect<readonly LegacyRcFile[], Error> =>
86
+ Effect.sync(() =>
87
+ legacyRcDirs(root, home).flatMap((dir) =>
88
+ LEGACY_RC_FILES.flatMap((name) => legacyRcFileAt(join(dir, name)) ?? []),
89
+ ),
90
+ )
91
+
92
+ const workflowModule = (
93
+ root: string,
94
+ home: string,
95
+ ): Effect.Effect<WorkflowModule | undefined, Error> =>
96
+ Effect.try({
97
+ try: () => {
98
+ for (const dir of walkUp(root, home)) {
99
+ const filepath = join(dir, WORKFLOW_MODULE)
100
+ if (existsSync(filepath)) return { filepath, source: readFileSync(filepath, "utf8") }
101
+ }
102
+ return undefined
103
+ },
104
+ catch: (e) => (e instanceof Error ? e : new Error(String(e))),
105
+ })
106
+
107
+ /**
108
+ * The discovery seam — a `Context.Tag`, not part of `WorkflowFiles` (that
109
+ * port is a plain sync record for `compileWorkflow`'s own content-file-ref
110
+ * reads; discovery is effectful and environment-dependent: `.Live` reads
111
+ * REAL disk, so an `@inmem` scenario's fake
112
+ * `Workspace` — a pure in-memory `Map`, no real files anywhere on disk —
113
+ * cannot back it. `src/testkit/Layers.ts` provides the in-memory
114
+ * counterpart instead, mirroring how `Workspace`/`Host`/`GitService` already
115
+ * each have a production and a fake layer behind one Tag.
116
+ */
117
+ export class ConfigDiscovery extends Context.Tag("ConfigDiscovery")<
118
+ ConfigDiscovery,
119
+ ConfigDiscoveryOps
120
+ >() {
121
+ static Live = Layer.succeed(ConfigDiscovery, { legacyRcFiles, workflowModule })
122
+ }
@@ -0,0 +1,52 @@
1
+ import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { describe, expect, it } from "vitest"
5
+ import { changedConfigFiles, configFingerprint } from "./fingerprint.js"
6
+
7
+ describe("the config fingerprint", () => {
8
+ it("hashes each file under its root-relative path, skipping one that cannot be read", () => {
9
+ const root = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
10
+ mkdirSync(join(root, "lib"))
11
+ writeFileSync(join(root, "gtd.config.ts"), "export default 1\n")
12
+ writeFileSync(join(root, "lib", "flow.ts"), "export const a = 1\n")
13
+
14
+ const fingerprint = configFingerprint(
15
+ [join(root, "gtd.config.ts"), join(root, "lib", "flow.ts"), join(root, "gone.ts")],
16
+ root,
17
+ )
18
+
19
+ expect(Object.keys(fingerprint).sort()).toEqual(["gtd.config.ts", "lib/flow.ts"])
20
+ expect(fingerprint["gtd.config.ts"]).toMatch(/^[0-9a-f]{16}$/)
21
+ writeFileSync(join(root, "lib", "flow.ts"), "export const a = 2\n")
22
+ expect(
23
+ changedConfigFiles(
24
+ fingerprint,
25
+ configFingerprint([join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")], root),
26
+ ),
27
+ ).toEqual(["lib/flow.ts"])
28
+ })
29
+
30
+ it("relates a file reported by its real path to a root reached through a symlink", () => {
31
+ const real = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
32
+ writeFileSync(join(real, "gtd.config.ts"), "export default 1\n")
33
+ const link = join(mkdtempSync(join(tmpdir(), "gtd-fingerprint-link-")), "repo")
34
+ symlinkSync(real, link)
35
+
36
+ expect(
37
+ Object.keys(configFingerprint([join(realpathSync(real), "gtd.config.ts")], link)),
38
+ ).toEqual(["gtd.config.ts"])
39
+ })
40
+
41
+ it("never records a path with a control character", () => {
42
+ const root = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
43
+ writeFileSync(join(root, "a\x1b.ts"), "export const a = 1\n")
44
+
45
+ expect(configFingerprint([join(root, "a\x1b.ts")], root)).toEqual({})
46
+ })
47
+
48
+ it("names a file only one side has as changed", () => {
49
+ expect(changedConfigFiles({ a: "1", b: "2" }, { a: "1", c: "3" })).toEqual(["b", "c"])
50
+ expect(changedConfigFiles({ a: "1" }, { a: "1" })).toEqual([])
51
+ })
52
+ })
@@ -0,0 +1,53 @@
1
+ import { createHash } from "node:crypto"
2
+ import { readFileSync, realpathSync } from "node:fs"
3
+ import { relative, sep } from "node:path"
4
+
5
+ /** Path (relative to `root`) → content hash of every file a config load touched. */
6
+ export type ConfigFingerprint = Readonly<Record<string, string>>
7
+
8
+ const HASH_LENGTH = 16
9
+
10
+ // A path the trailer cannot carry printably is never recorded, so it can never
11
+ // reach a terminal from history.
12
+ // eslint-disable-next-line no-control-regex -- the control range IS the point
13
+ const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/
14
+
15
+ export const contentHash = (content: string | Buffer): string =>
16
+ createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH)
17
+
18
+ const realOr = (path: string): string => {
19
+ try {
20
+ return realpathSync(path)
21
+ } catch {
22
+ return path
23
+ }
24
+ }
25
+
26
+ // An unreadable file is left out rather than failing: the fingerprint only
27
+ // feeds a warning, and the load that listed the file already read it.
28
+ // Paths are real paths: a loader may report `/private/var/…` for a root
29
+ // reached through the `/var` symlink, and the two must still relate.
30
+ export const configFingerprint = (files: readonly string[], root: string): ConfigFingerprint => {
31
+ const fingerprint: Record<string, string> = {}
32
+ const realRoot = realOr(root)
33
+ for (const file of files) {
34
+ const path = relative(realRoot, realOr(file)).split(sep).join("/")
35
+ if (CONTROL_CHARACTER.test(path)) continue
36
+ let content: Buffer
37
+ try {
38
+ content = readFileSync(file)
39
+ } catch {
40
+ continue
41
+ }
42
+ fingerprint[path] = contentHash(content)
43
+ }
44
+ return fingerprint
45
+ }
46
+
47
+ export const changedConfigFiles = (
48
+ recorded: ConfigFingerprint,
49
+ current: ConfigFingerprint,
50
+ ): readonly string[] =>
51
+ [...new Set([...Object.keys(recorded), ...Object.keys(current)])]
52
+ .filter((path) => recorded[path] !== current[path])
53
+ .sort()
@@ -0,0 +1,33 @@
1
+ export {
2
+ load,
3
+ refuseLegacyRc,
4
+ ConfigService,
5
+ type ConfigOperations,
6
+ type ResolvedWorkflow,
7
+ } from "./load.js"
8
+ export {
9
+ ConfigDiscovery,
10
+ LEGACY_RC_FILES,
11
+ legacyRcDirs,
12
+ WORKFLOW_MODULE,
13
+ walkUp,
14
+ } from "./discovery.js"
15
+ export { formatDiagnostic } from "./Diagnostic.js"
16
+ export { resolveVars, multilineSetting, isSettingName, SETTING_NAME_RULE } from "./vars.js"
17
+ export { resolveScopeAccess, resolveScopeSkills } from "./skills.js"
18
+ export { seededValidateCommand, isSeededValidateCommand } from "./SteeringFormats.js"
19
+ export {
20
+ type ChangeStatus,
21
+ type PendingChange,
22
+ type StepDef,
23
+ type WorkflowDefinition,
24
+ knownModes,
25
+ type StateMode,
26
+ type StateName,
27
+ } from "./Workflow.js"
28
+ export {
29
+ changedConfigFiles,
30
+ configFingerprint,
31
+ contentHash,
32
+ type ConfigFingerprint,
33
+ } from "./fingerprint.js"