@pmelab/gtd 61.0.0 → 62.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.
@@ -0,0 +1,61 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { existsSync, readFileSync } from "node:fs"
3
+ import { registerHooks } from "node:module"
4
+ import { join } from "node:path"
5
+ import { pathToFileURL } from "node:url"
6
+
7
+ /** A process's pin as the launcher found it: the file's bytes and where its engine runs from. */
8
+ export interface FoundPin {
9
+ readonly path: string
10
+ readonly bytes: Uint8Array
11
+ }
12
+
13
+ /**
14
+ * Where the launcher looks for a pin: the worktree's copy, else HEAD's — so
15
+ * the landing after an unpin beat deleted the file still runs on the pin.
16
+ */
17
+ export type PinFile = () => FoundPin | undefined
18
+
19
+ // `cwd`, as the engine's own root is: gtd runs only at the worktree root, and
20
+ // a relocated `$GIT_DIR` leaves no `.git` to walk up to. A file that cannot be
21
+ // read counts as no pin, so the launcher refuses it rather than crashing.
22
+ export const livePinFile =
23
+ (cwd: string, path: string): PinFile =>
24
+ () => {
25
+ const file = join(cwd, path)
26
+ if (existsSync(file)) {
27
+ try {
28
+ return { path: file, bytes: readFileSync(file) }
29
+ } catch {
30
+ return { path: file, bytes: new Uint8Array() }
31
+ }
32
+ }
33
+ try {
34
+ const bytes = execFileSync("git", ["show", `HEAD:${path}`], {
35
+ cwd,
36
+ stdio: ["ignore", "pipe", "ignore"],
37
+ })
38
+ return { path: file, bytes }
39
+ } catch {
40
+ return undefined
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Runs a pin's module as this process's main program. Node refuses an unknown
46
+ * extension, so a load hook serves the unpacked source under the pin's own
47
+ * URL — `import.meta.url` stays a file in the repository.
48
+ */
49
+ export const runPinModule = async (path: string, source: string): Promise<void> => {
50
+ const url = pathToFileURL(path).href
51
+ // A pin read from HEAD has no file behind its URL, which Node's own resolve refuses.
52
+ registerHooks({
53
+ resolve: (specifier, context, nextResolve) =>
54
+ specifier === url ? { url, shortCircuit: true } : nextResolve(specifier, context),
55
+ load: (requested, context, nextLoad) =>
56
+ requested === url
57
+ ? { format: "module", source, shortCircuit: true }
58
+ : nextLoad(requested, context),
59
+ })
60
+ await import(url)
61
+ }
@@ -29,3 +29,4 @@ export {
29
29
  readRetainedHistory,
30
30
  restorability,
31
31
  } from "./RetainedHistory.js"
32
+ export { livePinFile, runPinModule, type PinFile } from "./PinFile.js"
@@ -60,43 +60,6 @@ describe("the commit-message codec", () => {
60
60
  expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
61
61
  })
62
62
 
63
- it("writes Gtd-Config trailers sorted by path, after Gtd-Var, and reads paths with spaces back", () => {
64
- const message = formatCommitMessage({
65
- actor: "human",
66
- to: "x",
67
- vars: { a: "1" },
68
- config: { "lib/my flow.ts": "0123456789abcdef", "gtd.config.ts": "deadbeefdeadbeef" },
69
- })
70
- expect(message).toBe(
71
- "gtd(human): x\n\nGtd-Var: a=1\nGtd-Config: deadbeefdeadbeef gtd.config.ts\nGtd-Config: 0123456789abcdef lib/my flow.ts\nGtd-Format: 2",
72
- )
73
- expect(parseCommitMessage(message).config).toEqual({
74
- "gtd.config.ts": "deadbeefdeadbeef",
75
- "lib/my flow.ts": "0123456789abcdef",
76
- })
77
- })
78
-
79
- it("round-trips a Gtd-Config path that git or the trailer syntax would alter", () => {
80
- const config = {
81
- " lead.ts": "0123456789abcdef",
82
- "trail.ts ": "0123456789abcdef",
83
- '"q.ts': "0123456789abcdef",
84
- }
85
- const message = formatCommitMessage({ actor: "human", to: "x", config })
86
- expect(message).toContain('Gtd-Config: 0123456789abcdef " lead.ts"')
87
- expect(parseCommitMessage(message).config).toEqual(config)
88
- })
89
-
90
- it("ignores a Gtd-Config trailer without a 16-digit hex hash, without a path, or with a control character in its path", () => {
91
- const read = (value: string) =>
92
- parseCommitMessage(`gtd(human): x\n\nGtd-Config: ${value}`).config
93
- expect(read("zzzzzzzzzzzzzzzz gtd.config.ts")).toEqual({})
94
- expect(read("deadbeef gtd.config.ts")).toEqual({})
95
- expect(read("deadbeefdeadbeef")).toEqual({})
96
- expect(read("deadbeefdeadbeef a\x1b]0;x\x07.ts")).toEqual({})
97
- expect(read('deadbeefdeadbeef "a\\u001b.ts"')).toEqual({})
98
- })
99
-
100
63
  it("round-trips Gtd-Branch and Gtd-Version, after Gtd-Judge and before Gtd-Format", () => {
101
64
  const message = formatCommitMessage({
102
65
  actor: "agent",
@@ -83,8 +83,6 @@ export interface CommitMessage {
83
83
  /** `Gtd-Mark`, in order; a malformed or control-character line is skipped. */
84
84
  readonly marks: readonly Mark[]
85
85
  readonly vars: Readonly<Record<string, string>>
86
- /** Written only on a process's first commit. */
87
- readonly config: Readonly<Record<string, string>>
88
86
  readonly reviewBase: string | undefined
89
87
  readonly cost: readonly CostEntry[]
90
88
  /** Written only on a process's finishing landing. */
@@ -120,20 +118,10 @@ const parseJson = (raw: string): unknown => {
120
118
  const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm
121
119
  const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/
122
120
  const VAR_RE = /^([^=\s]+)=(.*)$/
123
- const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/
124
- // A path git's message cleanup or the trailer syntax would alter travels as a
125
- // JSON string. A control character is refused on read: history written by
126
- // someone else must never put escape sequences on a terminal.
121
+ // A control character is refused on read: history written by someone else
122
+ // must never put escape sequences on a terminal.
127
123
  // eslint-disable-next-line no-control-regex -- the control range IS the point
128
124
  const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/
129
- const encodePath = (path: string): string =>
130
- path.trim() !== path || path.startsWith('"') || CONTROL_CHARACTER.test(path)
131
- ? JSON.stringify(path)
132
- : path
133
- const decodePath = (raw: string): string | undefined => {
134
- const path = raw.startsWith('"') ? parseJson(raw) : raw
135
- return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
136
- }
137
125
  const decodeApproval = (raw: string): Approval | undefined => {
138
126
  const parsed = parseJson(raw)
139
127
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
@@ -168,7 +156,6 @@ interface Trailers {
168
156
  readonly judge: JudgeVerdict[]
169
157
  readonly marks: Mark[]
170
158
  readonly vars: Record<string, string>
171
- readonly config: Record<string, string>
172
159
  readonly cost: CostEntry[]
173
160
  format: number | undefined
174
161
  }
@@ -193,11 +180,6 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
193
180
  const kv = VAR_RE.exec(value)
194
181
  if (kv !== null) into.vars[kv[1]!] = kv[2]!
195
182
  },
196
- "Gtd-Config": (value, into) => {
197
- const entry = CONFIG_RE.exec(value)
198
- const path = entry === null ? undefined : decodePath(entry[2]!)
199
- if (path !== undefined) into.config[path] = entry![1]!
200
- },
201
183
  "Gtd-Review-Base": (value, into) => {
202
184
  into.reviewBase ??= value.split(/\s/)[0]
203
185
  },
@@ -242,7 +224,6 @@ export const parseCommitMessage = (message: string): CommitMessage => {
242
224
  judge: [],
243
225
  marks: [],
244
226
  vars: {},
245
- config: {},
246
227
  cost: [],
247
228
  format: undefined,
248
229
  }
@@ -262,7 +243,6 @@ export interface CommitSpec {
262
243
  readonly workflow?: string
263
244
  readonly reviewBase?: string
264
245
  readonly vars?: Readonly<Record<string, string>>
265
- readonly config?: Readonly<Record<string, string>>
266
246
  readonly cost?: { readonly cost: number; readonly model?: string }
267
247
  readonly judge?: readonly JudgeVerdict[]
268
248
  /** Written after `Gtd-Judge`, before `Gtd-Approved`. */
@@ -290,7 +270,6 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
290
270
  ...trailer("Workflow", spec.workflow),
291
271
  ...trailer("Review-Base", spec.reviewBase),
292
272
  ...sortedEntries(spec.vars).map(([name, value]) => `Gtd-Var: ${name}=${value}`),
293
- ...sortedEntries(spec.config).map(([path, hash]) => `Gtd-Config: ${hash} ${encodePath(path)}`),
294
273
  ...trailer("Cost", costValue(spec.cost)),
295
274
  ...(spec.judge ?? []).map((verdict) => `Gtd-Judge: ${JSON.stringify(verdict)}`),
296
275
  ...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
@@ -56,7 +56,6 @@ const start = (
56
56
  first: startRefusal === undefined ? { firstStep: "work" } : { refusal: startRefusal },
57
57
  commandLabel: req.commandLabel,
58
58
  vars: req.vars,
59
- config: {},
60
59
  })
61
60
  }).pipe(Effect.provide(testLayers(repo))),
62
61
  )
@@ -7,7 +7,6 @@ import {
7
7
  ownFlagRefusal,
8
8
  resolveStartVars,
9
9
  StartFlags,
10
- type ConfigFingerprint,
11
10
  type ResolvedWorkflow,
12
11
  } from "../workflow/index.js"
13
12
  import { formatCommitMessage, formatSubject } from "../replay/index.js"
@@ -70,8 +69,7 @@ const checkOverrides = (
70
69
  * `gtd start <name>`: start a brand NEW process on a workflow, writing an
71
70
  * opening commit that names the workflow's first step and carries a
72
71
  * `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
73
- * a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base,
74
- * and a `Gtd-Config:` trailer per config file.
72
+ * and a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base.
75
73
  * All validation is a REFUSAL, not an Effect failure.
76
74
  */
77
75
  export const planStart = (
@@ -83,7 +81,6 @@ export const planStart = (
83
81
  readonly first: { readonly firstStep: string } | { readonly refusal: string }
84
82
  readonly commandLabel: string
85
83
  readonly vars: Record<string, string>
86
- readonly config: ConfigFingerprint
87
84
  },
88
85
  ): Effect.Effect<
89
86
  StartOutcome,
@@ -136,7 +133,6 @@ export const planStart = (
136
133
  workflow: name,
137
134
  ...(base !== undefined ? { reviewBase: base } : {}),
138
135
  vars,
139
- config: start.config,
140
136
  })
141
137
  const steps: readonly LandStep[] = [
142
138
  { kind: "gitWrite", write: { kind: "commitAll", message } },
@@ -26,6 +26,7 @@ import {
26
26
  modeContradictionSkipNotice,
27
27
  OUTCOME_MARKER,
28
28
  pinScript,
29
+ repinScript,
29
30
  unpinScript,
30
31
  } from "../emit/index.js"
31
32
  import {
@@ -272,9 +273,9 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
272
273
  /** What the in-memory `gtd bundle` prints: a pin header, and no engine behind it. */
273
274
  const IN_MEMORY_PIN = "gtdpin 1 gtd in-memory\n"
274
275
 
275
- /** A pin or unpin beat's script. */
276
+ /** A pin or unpin beat's script, or `gtd repin`'s. */
276
277
  const recognizePinScripts = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
277
- if (block === pinScript(PIN_PATH)) {
278
+ if (block === pinScript(PIN_PATH) || block === repinScript(PIN_PATH)) {
278
279
  repo.writeFile(PIN_PATH, IN_MEMORY_PIN)
279
280
  return { kind: "applied" }
280
281
  }
@@ -0,0 +1,107 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { dirname, join } from "node:path"
5
+ import { afterEach, describe, expect, it } from "vitest"
6
+ import { livePinFile, type PinFile } from "../platform/index.js"
7
+ import { InMemRepo } from "./index.js"
8
+ import { inMemPinFile } from "./PinFile.js"
9
+
10
+ const PIN = ".gtd/process.gtdpin"
11
+
12
+ interface Tier {
13
+ readonly root: string
14
+ /** The pin a launcher starting at the root finds. */
15
+ readonly pinFile: () => PinFile
16
+ readonly write: (path: string, content: string) => void
17
+ readonly remove: (path: string) => void
18
+ readonly commit: () => void
19
+ }
20
+
21
+ const liveTier = (): Tier => {
22
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-pinfile-")))
23
+ cleanups.push(() => rmSync(root, { recursive: true, force: true }))
24
+ const git = (...args: string[]) => execFileSync("git", args, { cwd: root, stdio: "ignore" })
25
+ git("init", "-q")
26
+ return {
27
+ root,
28
+ pinFile: () => livePinFile(root, PIN),
29
+ write: (path, content) => {
30
+ mkdirSync(dirname(join(root, path)), { recursive: true })
31
+ writeFileSync(join(root, path), content)
32
+ },
33
+ remove: (path) => rmSync(join(root, path)),
34
+ commit: () => {
35
+ git("add", "-A")
36
+ git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "c")
37
+ },
38
+ }
39
+ }
40
+
41
+ const inMemTier = (): Tier => {
42
+ const repo = new InMemRepo()
43
+ const root = "/repo"
44
+ return {
45
+ root,
46
+ pinFile: () => inMemPinFile(repo, root, PIN),
47
+ write: (path, content) => repo.writeFile(path, content),
48
+ remove: (path) => repo.deleteFile(path),
49
+ commit: () => repo.seedCommit("c"),
50
+ }
51
+ }
52
+
53
+ const cleanups: (() => void)[] = []
54
+ afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
55
+
56
+ const text = (pinFile: PinFile) => {
57
+ const found = pinFile()
58
+ return found && { path: found.path, content: Buffer.from(found.bytes).toString() }
59
+ }
60
+
61
+ describe.each([
62
+ ["live", liveTier],
63
+ ["in-memory", inMemTier],
64
+ ])("PinFile contract: %s", (_, make) => {
65
+ it("finds nothing without a pin", () => {
66
+ const tier = make()
67
+ tier.write("README.md", "x")
68
+ tier.commit()
69
+ expect(text(tier.pinFile())).toBeUndefined()
70
+ })
71
+
72
+ it("finds the worktree's pin, committed or not", () => {
73
+ const tier = make()
74
+ tier.write(PIN, "pinned")
75
+ expect(text(tier.pinFile())).toEqual({ path: join(tier.root, PIN), content: "pinned" })
76
+ })
77
+
78
+ it("prefers the worktree's pin over HEAD's", () => {
79
+ const tier = make()
80
+ tier.write(PIN, "committed")
81
+ tier.commit()
82
+ tier.write(PIN, "rewritten")
83
+ expect(text(tier.pinFile())?.content).toBe("rewritten")
84
+ })
85
+
86
+ it("falls back to HEAD's pin once the worktree's is deleted", () => {
87
+ const tier = make()
88
+ tier.write(PIN, "committed")
89
+ tier.commit()
90
+ tier.remove(PIN)
91
+ expect(text(tier.pinFile())).toEqual({ path: join(tier.root, PIN), content: "committed" })
92
+ })
93
+ })
94
+
95
+ describe("livePinFile", () => {
96
+ it("reads an unreadable pin as no pin's bytes", () => {
97
+ const tier = liveTier()
98
+ tier.write(`${PIN}/inside`, "x")
99
+ expect(text(tier.pinFile())).toEqual({ path: join(tier.root, PIN), content: "" })
100
+ })
101
+
102
+ it("finds nothing outside a repository", () => {
103
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "gtd-pinfile-")))
104
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }))
105
+ expect(livePinFile(dir, PIN)()).toBeUndefined()
106
+ })
107
+ })
@@ -0,0 +1,12 @@
1
+ import { join } from "node:path"
2
+ import type { PinFile } from "../platform/index.js"
3
+ import type { InMemRepo } from "./InMemRepo.js"
4
+
5
+ export const inMemPinFile =
6
+ (repo: InMemRepo, root: string, path: string): PinFile =>
7
+ () => {
8
+ const content = repo.readFile(path) ?? repo.fileAtRef("HEAD", path) ?? undefined
9
+ return content === undefined
10
+ ? undefined
11
+ : { path: join(root, path), bytes: Buffer.from(content) }
12
+ }
@@ -12,3 +12,4 @@ export { gitTiers, runGitServiceContract, runGitScriptContract, type GitTier } f
12
12
  export { strictGitOperations } from "./StrictGitOperations.js"
13
13
  export { fakeGitOperations } from "./FakeGitOperations.js"
14
14
  export { takePinBeat } from "./PinBeats.js"
15
+ export { inMemPinFile } from "./PinFile.js"
@@ -7,12 +7,12 @@ export type ConfigFingerprint = Readonly<Record<string, string>>
7
7
 
8
8
  const HASH_LENGTH = 16
9
9
 
10
- // A path the trailer cannot carry printably is never recorded, so it can never
10
+ // A path with a control character is never recorded, so it can never
11
11
  // reach a terminal from history.
12
12
  // eslint-disable-next-line no-control-regex -- the control range IS the point
13
13
  const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/
14
14
 
15
- export const contentHash = (content: string | Buffer): string =>
15
+ const contentHash = (content: string | Buffer): string =>
16
16
  createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH)
17
17
 
18
18
  const realOr = (path: string): string => {
@@ -33,5 +33,5 @@ export {
33
33
  type StateMode,
34
34
  type StateName,
35
35
  } from "./Workflow.js"
36
- export { changedConfigFiles, contentHash, type ConfigFingerprint } from "./fingerprint.js"
37
- export { EngineCore, pin, readPin } from "./pin.js"
36
+ export type { ConfigFingerprint } from "./fingerprint.js"
37
+ export { EngineCore, pin, pinHeader, PIN_FORMAT, readPin } from "./pin.js"
@@ -17,7 +17,7 @@ import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
17
17
  import { compileConfig, type CompiledConfig } from "./compile.js"
18
18
  import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
19
19
  import { ConfigDiscovery, WORKFLOW_MODULE, type WorkflowModule } from "./discovery.js"
20
- import { configFingerprint, type ConfigFingerprint } from "./fingerprint.js"
20
+ import { changedConfigFiles, configFingerprint, type ConfigFingerprint } from "./fingerprint.js"
21
21
  import { PIN_KEY } from "../install/index.js"
22
22
  import {
23
23
  dedupeDiagnostics,
@@ -50,6 +50,8 @@ export interface ConfigOperations {
50
50
  readonly warnings: readonly Diagnostic[]
51
51
  /** The content hash of every file `gtd.config.ts`'s evaluation touched — itself plus any relative import — by path relative to the root. */
52
52
  readonly fingerprint: ConfigFingerprint
53
+ /** Inside a pin, the files it was bundled from that differ on disk now. */
54
+ readonly drift: readonly string[]
53
55
  }
54
56
 
55
57
  // Everything lives in `message` (not `GtdError.detail`) — `renderFailure`
@@ -204,6 +206,17 @@ export const load: Effect.Effect<
204
206
  fingerprint:
205
207
  pinned?.fingerprint ??
206
208
  configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk),
209
+ drift:
210
+ pinned === undefined
211
+ ? []
212
+ : changedConfigFiles(
213
+ pinned.fingerprint,
214
+ configFingerprint(
215
+ Object.keys(pinned.fingerprint).map((file) => resolvePath(host.root, file)),
216
+ host.root,
217
+ readFromDisk,
218
+ ),
219
+ ),
207
220
  }
208
221
  })
209
222
 
@@ -7,7 +7,7 @@ import { ownPackage, pinnedWorkflowSource } from "./load.js"
7
7
  import type { WorkflowModule } from "./discovery.js"
8
8
 
9
9
  /** Bumped when a launcher could no longer run an older pin the same way. */
10
- const PIN_FORMAT = 1
10
+ export const PIN_FORMAT = 1
11
11
 
12
12
  /**
13
13
  * The engine a pin carries, as ES module source. Live, the `gtd.core.mjs` the
@@ -57,16 +57,24 @@ export const pin = (
57
57
  ])
58
58
  })
59
59
 
60
+ /** A pin's header line, read without unpacking it; undefined when the bytes are no pin. */
61
+ export const pinHeader = (
62
+ bytes: Uint8Array,
63
+ ): { readonly format: number; readonly version: string } | undefined => {
64
+ const end = bytes.indexOf(0x0a)
65
+ const header =
66
+ end < 0 ? null : /^gtdpin (\d+) gtd (\S+)$/.exec(Buffer.from(bytes.subarray(0, end)).toString())
67
+ return header === null ? undefined : { format: Number(header[1]), version: header[2]! }
68
+ }
69
+
60
70
  /** A pin's header and module source. */
61
71
  export const readPin = (
62
72
  bytes: Uint8Array,
63
73
  ): { readonly format: number; readonly version: string; readonly source: string } => {
64
- const end = bytes.indexOf(0x0a)
65
- const header = /^gtdpin (\d+) gtd (\S+)$/.exec(Buffer.from(bytes.subarray(0, end)).toString())
66
- if (end < 0 || header === null) throw new Error("not a gtd pin")
74
+ const header = pinHeader(bytes)
75
+ if (header === undefined) throw new Error("not a gtd pin")
67
76
  return {
68
- format: Number(header[1]),
69
- version: header[2]!,
70
- source: gunzipSync(bytes.subarray(end + 1)).toString("utf8"),
77
+ ...header,
78
+ source: gunzipSync(bytes.subarray(bytes.indexOf(0x0a) + 1)).toString("utf8"),
71
79
  }
72
80
  }