@pmelab/gtd 62.1.0 → 64.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 (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +8 -3
  3. package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
  4. package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
  5. package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
  6. package/dist/chunks/{testkit-CPzWnl3P.mjs → testkit-zGjNeQNL.mjs} +1144 -345
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +4023 -1326
  9. package/dist/gtd.core.mjs +120 -119
  10. package/dist/steering.mjs +1 -1
  11. package/dist/testkit/gherkin.mjs +2 -2
  12. package/dist/testkit.mjs +2 -2
  13. package/dist/workflow-kit.mjs +2 -2
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +5 -8
  16. package/src/cli/Cli.ts +10 -28
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +36 -45
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +98 -13
  23. package/src/edge/index.ts +1 -0
  24. package/src/emit/SteeringMode.test.ts +4 -2
  25. package/src/emit/SteeringMode.ts +10 -9
  26. package/src/gherkin/messages.test.ts +1 -1
  27. package/src/gherkin/module-config.feature +1 -1
  28. package/src/gherkin/testkit.feature +2 -2
  29. package/src/install/Version.ts +14 -13
  30. package/src/platform/Host.ts +75 -22
  31. package/src/platform/NodePinFile.ts +44 -0
  32. package/src/platform/NodeRuntime.ts +19 -0
  33. package/src/platform/NodeSync.ts +64 -0
  34. package/src/platform/PinFile.ts +0 -50
  35. package/src/platform/Sha.ts +121 -0
  36. package/src/platform/SyncIo.ts +35 -0
  37. package/src/platform/Workspace.test.ts +8 -3
  38. package/src/platform/Workspace.ts +82 -123
  39. package/src/platform/WorktreeState.test.ts +8 -4
  40. package/src/platform/WorktreeState.ts +21 -19
  41. package/src/platform/index.ts +18 -5
  42. package/src/replay/Format.test.ts +32 -10
  43. package/src/replay/Format.ts +5 -2
  44. package/src/replay/Replay.test.ts +110 -3
  45. package/src/replay/Replay.ts +120 -28
  46. package/src/replay/Trailers.test.ts +10 -10
  47. package/src/replay/Trailers.ts +32 -1
  48. package/src/replay/index.ts +2 -0
  49. package/src/step/planStart.test.ts +2 -2
  50. package/src/step/planStep.test.ts +4 -4
  51. package/src/testkit/TestRepo.test.ts +1 -1
  52. package/src/testkit/TestRepo.ts +4 -18
  53. package/src/twins/Host.test.ts +151 -0
  54. package/src/twins/Layers.test.ts +2 -2
  55. package/src/twins/Layers.ts +44 -18
  56. package/src/twins/Path.test.ts +52 -0
  57. package/src/twins/SyncIo.test.ts +153 -0
  58. package/src/twins/SyncIo.ts +159 -0
  59. package/src/twins/cliIo.ts +8 -1
  60. package/src/twins/index.ts +5 -0
  61. package/src/workflow/discovery.test.ts +6 -5
  62. package/src/workflow/discovery.ts +43 -32
  63. package/src/workflow/fingerprint.test.ts +23 -3
  64. package/src/workflow/fingerprint.ts +9 -11
  65. package/src/workflow/load.test.ts +16 -6
  66. package/src/workflow/load.ts +107 -69
  67. package/src/workflow/pin.test.ts +3 -2
  68. package/src/workflow/pin.ts +17 -11
@@ -1,5 +1,9 @@
1
1
  export { InMemRepo } from "./InMemRepo.js"
2
2
  export {
3
+ inMemPath,
4
+ inMemoryHost,
5
+ IN_MEMORY_PID,
6
+ IN_MEMORY_SCRATCH,
3
7
  makeInMemoryWorkspaceOps,
4
8
  testLayers,
5
9
  type CommandTable,
@@ -13,3 +17,4 @@ export { strictGitOperations } from "./StrictGitOperations.js"
13
17
  export { fakeGitOperations } from "./FakeGitOperations.js"
14
18
  export { takePinBeat } from "./PinBeats.js"
15
19
  export { inMemPinFile } from "./PinFile.js"
20
+ export { inMemorySyncIo } from "./SyncIo.js"
@@ -2,7 +2,7 @@ import { execSync } from "node:child_process"
2
2
  import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
3
3
  import { tmpdir } from "node:os"
4
4
  import { dirname, join } from "node:path"
5
- import { Effect, Layer } from "effect"
5
+ import { Effect, Layer, type Path } from "effect"
6
6
  import { afterEach, describe, expect, it } from "vitest"
7
7
  import { GitService, Host, Narrator, Workspace, NodeServices } from "../platform/index.js"
8
8
  import { InMemRepo, testLayers } from "../twins/index.js"
@@ -34,7 +34,7 @@ const config = (imports: string): string =>
34
34
  ].join("\n")
35
35
 
36
36
  const loadWith = (
37
- layer: Layer.Layer<ConfigService | Narrator | Workspace | Host | ConfigDiscovery>,
37
+ layer: Layer.Layer<ConfigService | Narrator | Workspace | Host | Path.Path | ConfigDiscovery>,
38
38
  ) =>
39
39
  Effect.runPromise(
40
40
  Effect.flatMap(ConfigService, (service) => service.load).pipe(
@@ -63,15 +63,16 @@ const live = (): Tier => {
63
63
  }
64
64
  const host = Host.layer({ root, home: root, env: {} })
65
65
  const git = GitService.Live.pipe(Layer.provide(Layer.merge(host, NodeServices.layer)))
66
- const workspace = Workspace.Live.pipe(Layer.provide(Layer.merge(host, git)))
66
+ const workspace = Workspace.Live.pipe(
67
+ Layer.provide(Layer.mergeAll(host, git, NodeServices.layer)),
68
+ )
67
69
  return loadWith(
68
70
  Layer.mergeAll(
69
71
  ConfigService.Live,
70
72
  ConfigDiscovery.Live,
71
- host,
72
73
  workspace,
73
74
  Narrator.layer(() => {}, false),
74
- ),
75
+ ).pipe(Layer.provideMerge(host), Layer.provideMerge(NodeServices.layer)),
75
76
  )
76
77
  },
77
78
  dispose: () => rmSync(root, { recursive: true, force: true }),
@@ -1,6 +1,5 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs"
2
- import { dirname, join } from "node:path"
3
- import { Context, Effect, Layer } from "effect"
1
+ import { Context, Effect, Layer, Path } from "effect"
2
+ import { Host, nodeSyncIo, type SyncIo } from "../platform/index.js"
4
3
 
5
4
  /** The workflow module found on the cwd→home walk, with its source. */
6
5
  export interface WorkflowModule {
@@ -50,17 +49,21 @@ export const LEGACY_RC_FILES = [
50
49
  ]
51
50
 
52
51
  /** The directories searched for a leftover rc file: the walk, plus `home` when the walk misses it. */
53
- export const legacyRcDirs = (root: string, home: string): readonly string[] => {
54
- const chain = walkUp(root, home)
52
+ export const legacyRcDirs = (path: Path.Path, root: string, home: string): readonly string[] => {
53
+ const chain = walkUp(path, root, home)
55
54
  return chain.includes(home) ? chain : [...chain, home]
56
55
  }
57
56
 
58
- const legacyRcFileAt = (path: string): LegacyRcFile | undefined => {
57
+ const legacyRcFileAt = (
58
+ io: SyncIo,
59
+ path: string,
60
+ uid: number | undefined,
61
+ ): LegacyRcFile | undefined => {
59
62
  try {
60
- const stat = statSync(path)
61
- if (!stat.isFile()) return undefined
63
+ const stat = io.stat(path)
64
+ if (stat?.type !== "file") return undefined
62
65
  // Above home, a shared directory (`/tmp`) can hold another user's file.
63
- return { path, owned: process.getuid === undefined || stat.uid === process.getuid() }
66
+ return { path, owned: uid === undefined || stat.uid === uid }
64
67
  } catch {
65
68
  return undefined
66
69
  }
@@ -74,40 +77,43 @@ const legacyRcFileAt = (path: string): LegacyRcFile | undefined => {
74
77
  * shared implementation, not a second copy in `src/twins/Layers.ts` that
75
78
  * could silently diverge.
76
79
  */
77
- export const walkUp = (from: string, home: string): ReadonlyArray<string> => {
80
+ export const walkUp = (path: Path.Path, from: string, home: string): ReadonlyArray<string> => {
78
81
  const chain: Array<string> = []
79
82
  let dir = from
80
83
  while (true) {
81
84
  chain.push(dir)
82
85
  if (dir === home) break
83
- const parent = dirname(dir)
86
+ const parent = path.dirname(dir)
84
87
  if (parent === dir) break // filesystem root
85
88
  dir = parent
86
89
  }
87
90
  return chain
88
91
  }
89
92
 
90
- const legacyRcFiles = (root: string, home: string): Effect.Effect<readonly LegacyRcFile[], Error> =>
91
- Effect.sync(() =>
92
- legacyRcDirs(root, home).flatMap((dir) =>
93
- LEGACY_RC_FILES.flatMap((name) => legacyRcFileAt(join(dir, name)) ?? []),
93
+ const liveDiscovery = (
94
+ io: SyncIo,
95
+ path: Path.Path,
96
+ uid: number | undefined,
97
+ ): ConfigDiscoveryOps => ({
98
+ legacyRcFiles: (root, home) =>
99
+ Effect.sync(() =>
100
+ legacyRcDirs(path, root, home).flatMap((dir) =>
101
+ LEGACY_RC_FILES.flatMap((name) => legacyRcFileAt(io, path.join(dir, name), uid) ?? []),
102
+ ),
94
103
  ),
95
- )
96
-
97
- const workflowModule = (
98
- root: string,
99
- home: string,
100
- ): Effect.Effect<WorkflowModule | undefined, Error> =>
101
- Effect.try({
102
- try: () => {
103
- for (const dir of walkUp(root, home)) {
104
- const filepath = join(dir, WORKFLOW_MODULE)
105
- if (existsSync(filepath)) return { filepath, source: readFileSync(filepath, "utf8") }
106
- }
107
- return undefined
108
- },
109
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
110
- })
104
+ workflowModule: (root, home) =>
105
+ Effect.try({
106
+ try: () => {
107
+ for (const dir of walkUp(path, root, home)) {
108
+ const filepath = path.join(dir, WORKFLOW_MODULE)
109
+ const source = io.readText(filepath)
110
+ if (source !== undefined) return { filepath, source }
111
+ }
112
+ return undefined
113
+ },
114
+ catch: (e) => (e instanceof Error ? e : new Error(String(e))),
115
+ }),
116
+ })
111
117
 
112
118
  /**
113
119
  * The discovery seam — a `Context.Service`, not part of `WorkflowFiles` (that
@@ -122,5 +128,10 @@ const workflowModule = (
122
128
  export class ConfigDiscovery extends Context.Service<ConfigDiscovery, ConfigDiscoveryOps>()(
123
129
  "ConfigDiscovery",
124
130
  ) {
125
- static Live = Layer.succeed(ConfigDiscovery, { legacyRcFiles, workflowModule })
131
+ static Live = Layer.effect(
132
+ ConfigDiscovery,
133
+ Effect.gen(function* () {
134
+ return liveDiscovery(nodeSyncIo, yield* Path.Path, (yield* Host).uid)
135
+ }),
136
+ )
126
137
  }
@@ -9,6 +9,8 @@ import {
9
9
  import { tmpdir } from "node:os"
10
10
  import { join } from "node:path"
11
11
  import { describe, expect, it } from "vitest"
12
+ import { nodeSyncIo } from "../platform/index.js"
13
+ import { inMemPath } from "../twins/index.js"
12
14
  import { changedConfigFiles, configFingerprint } from "./fingerprint.js"
13
15
 
14
16
  const read = (path: string): string | undefined => {
@@ -27,6 +29,8 @@ describe("the config fingerprint", () => {
27
29
  writeFileSync(join(root, "lib", "flow.ts"), "export const a = 1\n")
28
30
 
29
31
  const fingerprint = configFingerprint(
32
+ nodeSyncIo,
33
+ inMemPath,
30
34
  [join(root, "gtd.config.ts"), join(root, "lib", "flow.ts"), join(root, "gone.ts")],
31
35
  root,
32
36
  read,
@@ -38,7 +42,13 @@ describe("the config fingerprint", () => {
38
42
  expect(
39
43
  changedConfigFiles(
40
44
  fingerprint,
41
- configFingerprint([join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")], root, read),
45
+ configFingerprint(
46
+ nodeSyncIo,
47
+ inMemPath,
48
+ [join(root, "gtd.config.ts"), join(root, "lib", "flow.ts")],
49
+ root,
50
+ read,
51
+ ),
42
52
  ),
43
53
  ).toEqual(["lib/flow.ts"])
44
54
  })
@@ -50,7 +60,15 @@ describe("the config fingerprint", () => {
50
60
  symlinkSync(real, link)
51
61
 
52
62
  expect(
53
- Object.keys(configFingerprint([join(realpathSync(real), "gtd.config.ts")], link, read)),
63
+ Object.keys(
64
+ configFingerprint(
65
+ nodeSyncIo,
66
+ inMemPath,
67
+ [join(realpathSync(real), "gtd.config.ts")],
68
+ link,
69
+ read,
70
+ ),
71
+ ),
54
72
  ).toEqual(["gtd.config.ts"])
55
73
  })
56
74
 
@@ -58,7 +76,9 @@ describe("the config fingerprint", () => {
58
76
  const root = mkdtempSync(join(tmpdir(), "gtd-fingerprint-"))
59
77
  writeFileSync(join(root, "a\x1b.ts"), "export const a = 1\n")
60
78
 
61
- expect(configFingerprint([join(root, "a\x1b.ts")], root, read)).toEqual({})
79
+ expect(configFingerprint(nodeSyncIo, inMemPath, [join(root, "a\x1b.ts")], root, read)).toEqual(
80
+ {},
81
+ )
62
82
  })
63
83
 
64
84
  it("names a file only one side has as changed", () => {
@@ -1,6 +1,5 @@
1
- import { createHash } from "node:crypto"
2
- import { realpathSync } from "node:fs"
3
- import { relative, sep } from "node:path"
1
+ import type { Path } from "effect"
2
+ import type { SyncIo } from "../platform/index.js"
4
3
 
5
4
  /** Path (relative to `root`) → content hash of every file a config load touched. */
6
5
  export type ConfigFingerprint = Readonly<Record<string, string>>
@@ -12,12 +11,9 @@ const HASH_LENGTH = 16
12
11
  // eslint-disable-next-line no-control-regex -- the control range IS the point
13
12
  const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/
14
13
 
15
- const contentHash = (content: string | Buffer): string =>
16
- createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH)
17
-
18
- const realOr = (path: string): string => {
14
+ const realOr = (io: SyncIo, path: string): string => {
19
15
  try {
20
- return realpathSync(path)
16
+ return io.realPath(path)
21
17
  } catch {
22
18
  return path
23
19
  }
@@ -28,17 +24,19 @@ const realOr = (path: string): string => {
28
24
  // Paths are real paths: a loader may report `/private/var/…` for a root
29
25
  // reached through the `/var` symlink, and the two must still relate.
30
26
  export const configFingerprint = (
27
+ io: SyncIo,
28
+ paths: Path.Path,
31
29
  files: readonly string[],
32
30
  root: string,
33
31
  read: (path: string) => string | undefined,
34
32
  ): ConfigFingerprint => {
35
33
  const fingerprint: Record<string, string> = {}
36
- const realRoot = realOr(root)
34
+ const realRoot = realOr(io, root)
37
35
  for (const file of files) {
38
- const path = relative(realRoot, realOr(file)).split(sep).join("/")
36
+ const path = paths.relative(realRoot, realOr(io, file)).split(paths.sep).join("/")
39
37
  if (CONTROL_CHARACTER.test(path)) continue
40
38
  const content = read(file)
41
- if (content !== undefined) fingerprint[path] = contentHash(content)
39
+ if (content !== undefined) fingerprint[path] = io.hash("sha256", content).slice(0, HASH_LENGTH)
42
40
  }
43
41
  return fingerprint
44
42
  }
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync
3
3
  import { join } from "node:path"
4
4
  import { tmpdir } from "node:os"
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest"
6
- import { Cause, Effect, Exit, Layer } from "effect"
6
+ import { Cause, Effect, Exit, Layer, type Path } from "effect"
7
7
  import { Narrator, GitService, Host, Workspace, NodeServices } from "../platform/index.js"
8
8
  import {
9
9
  ConfigDiscovery,
@@ -23,7 +23,9 @@ type Env = Readonly<Record<string, string>>
23
23
  const baseLayer = (dir: string, env: Env = {}) => {
24
24
  const hostLayer = Host.layer({ root: dir, home: homeDir, env })
25
25
  const gitLayer = GitService.Live.pipe(Layer.provide(Layer.merge(hostLayer, NodeServices.layer)))
26
- const workspaceLayer = Workspace.Live.pipe(Layer.provide(Layer.merge(hostLayer, gitLayer)))
26
+ const workspaceLayer = Workspace.Live.pipe(
27
+ Layer.provide(Layer.mergeAll(hostLayer, gitLayer, NodeServices.layer)),
28
+ )
27
29
  return Layer.mergeAll(hostLayer, gitLayer, workspaceLayer)
28
30
  }
29
31
 
@@ -34,18 +36,25 @@ const layer = (dir: string, env: Env = {}) =>
34
36
  Layer.mergeAll(
35
37
  ConfigService.Live,
36
38
  ConfigDiscovery.Live,
37
- baseLayer(dir, env),
38
39
  Narrator.layer(() => {}, false),
39
- )
40
+ ).pipe(Layer.provideMerge(baseLayer(dir, env)), Layer.provideMerge(NodeServices.layer))
40
41
 
41
42
  const run = <A>(
42
- eff: Effect.Effect<A, Error, ConfigService | ConfigDiscovery | Narrator | Workspace | Host>,
43
+ eff: Effect.Effect<
44
+ A,
45
+ Error,
46
+ ConfigService | ConfigDiscovery | Narrator | Workspace | Host | Path.Path
47
+ >,
43
48
  dir: string = projectDir,
44
49
  env: Env = {},
45
50
  ) => Effect.runPromise(eff.pipe(Effect.provide(layer(dir, env))))
46
51
 
47
52
  const runExit = <A>(
48
- eff: Effect.Effect<A, Error, ConfigService | ConfigDiscovery | Narrator | Workspace | Host>,
53
+ eff: Effect.Effect<
54
+ A,
55
+ Error,
56
+ ConfigService | ConfigDiscovery | Narrator | Workspace | Host | Path.Path
57
+ >,
49
58
  dir: string = projectDir,
50
59
  ) => Effect.runPromiseExit(eff.pipe(Effect.provide(layer(dir))))
51
60
 
@@ -265,6 +274,7 @@ describe("ConfigService", () => {
265
274
  others,
266
275
  baseLayer(projectDir),
267
276
  Narrator.layer((m) => warnings.push(m), false),
277
+ NodeServices.layer,
268
278
  ),
269
279
  ),
270
280
  ),