@pmelab/gtd 61.0.0 → 62.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -0
- package/dist/chunks/{testkit-C9sq0x07.mjs → testkit-XQeYCNMn.mjs} +20 -61
- package/dist/gtd.bundle.mjs +227 -80
- package/dist/gtd.core.mjs +110 -110
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.ts +6 -1
- package/src/cli/Launch.test.ts +52 -0
- package/src/cli/Launch.ts +143 -0
- package/src/cli/Program.test.ts +1 -2
- package/src/cli/Program.ts +0 -1
- package/src/cli/index.ts +1 -0
- package/src/edge/Edge.test.ts +3 -3
- package/src/edge/Edge.ts +17 -71
- package/src/platform/PinFile.ts +61 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Trailers.test.ts +0 -37
- package/src/replay/Trailers.ts +2 -23
- package/src/step/planStart.test.ts +0 -1
- package/src/step/planStart.ts +1 -5
- package/src/twins/PinFile.test.ts +107 -0
- package/src/twins/PinFile.ts +12 -0
- package/src/twins/index.ts +1 -0
- package/src/workflow/fingerprint.ts +2 -2
- package/src/workflow/index.ts +2 -2
- package/src/workflow/load.ts +14 -1
- package/src/workflow/pin.ts +15 -7
package/dist/testkit/gherkin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as runsItsScript, r as testRepo } from "../chunks/testkit-
|
|
1
|
+
import { n as runsItsScript, r as testRepo } from "../chunks/testkit-XQeYCNMn.mjs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
|
package/dist/testkit.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { H as installContext } from "./chunks/flows-DENt0uWV.mjs";
|
|
2
|
-
import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-
|
|
2
|
+
import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-XQeYCNMn.mjs";
|
|
3
3
|
export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
|
package/package.json
CHANGED
package/src/cli/Cli.ts
CHANGED
|
@@ -632,6 +632,7 @@ const flagHeader = (row: FlagRow): string => {
|
|
|
632
632
|
|
|
633
633
|
const GLOBAL_OPTIONS: readonly (readonly [string, string])[] = [
|
|
634
634
|
["-v, --verbose", "Narrate on stderr what gtd resolved"],
|
|
635
|
+
["--live", "Run this gtd, not the process's pin; it may misread the process"],
|
|
635
636
|
["-h, --help", "Print help; gtd <command> --help for one command"],
|
|
636
637
|
["-V, --version", "Print the version"],
|
|
637
638
|
["--", "End the options: every word after it is an argument"],
|
|
@@ -643,7 +644,11 @@ const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
|
|
|
643
644
|
"Print help for gtd or one command",
|
|
644
645
|
"Print gtd's overview, or with <command> that command's own help.",
|
|
645
646
|
],
|
|
646
|
-
[
|
|
647
|
+
[
|
|
648
|
+
"version",
|
|
649
|
+
"Print the version",
|
|
650
|
+
"Print gtd's version, and the version of a pinned process's gtd.",
|
|
651
|
+
],
|
|
647
652
|
]
|
|
648
653
|
|
|
649
654
|
const EXAMPLES = ` $ gtd start list what can start
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { GTD_VERSION, PIN_KEY } from "../install/index.js"
|
|
3
|
+
import { launchFor } from "./Launch.js"
|
|
4
|
+
|
|
5
|
+
const pin =
|
|
6
|
+
(header = "gtdpin 1 gtd 1.2.3") =>
|
|
7
|
+
() => ({
|
|
8
|
+
path: "/repo/.gtd/process.gtdpin",
|
|
9
|
+
bytes: Buffer.from(`${header}\n`),
|
|
10
|
+
})
|
|
11
|
+
const argv = (...words: string[]) => ["node", "gtd", ...words]
|
|
12
|
+
|
|
13
|
+
describe("launchFor", () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
delete (globalThis as { [PIN_KEY]?: unknown })[PIN_KEY]
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("hands a state command over to the pin", () => {
|
|
19
|
+
expect(launchFor(argv("next"), pin())).toMatchObject({ kind: "pin", version: "1.2.3" })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("never hands over from inside a pin", () => {
|
|
23
|
+
;(globalThis as { [PIN_KEY]?: unknown })[PIN_KEY] = { version: "1.2.3" }
|
|
24
|
+
expect(launchFor(argv("next"), pin())).toEqual({ kind: "live", argv: argv("next") })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("strips --live, but keeps one after --", () => {
|
|
28
|
+
expect(launchFor(argv("--live", "start", "--", "--live"), pin())).toEqual({
|
|
29
|
+
kind: "live",
|
|
30
|
+
argv: argv("start", "--", "--live"),
|
|
31
|
+
})
|
|
32
|
+
expect(launchFor(argv("start", "--", "--live"), pin())).toMatchObject({
|
|
33
|
+
kind: "pin",
|
|
34
|
+
argv: argv("start", "--", "--live"),
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("leaves the version to this gtd without a pin, and for a file that is no pin", () => {
|
|
39
|
+
expect(launchFor(argv("-V"), () => undefined)).toEqual({ kind: "live", argv: argv("-V") })
|
|
40
|
+
expect(launchFor(argv("version"), pin("garbage"))).toEqual({
|
|
41
|
+
kind: "output",
|
|
42
|
+
stdout: `${GTD_VERSION}\n`,
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("names a newer pin's version even when it cannot run it", () => {
|
|
47
|
+
expect(launchFor(argv("--version"), pin("gtdpin 9 gtd 99.0.0"))).toEqual({
|
|
48
|
+
kind: "output",
|
|
49
|
+
stdout: `${GTD_VERSION} (this process is pinned to gtd 99.0.0)\n`,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { PinFile } from "../platform/index.js"
|
|
2
|
+
import { GTD_VERSION, PIN_KEY } from "../install/index.js"
|
|
3
|
+
import { PIN_FORMAT, pinHeader, readPin } from "../workflow/index.js"
|
|
4
|
+
import { EXIT_RUNTIME_ERROR } from "../wire/index.js"
|
|
5
|
+
import { parseArgv } from "./Cli.js"
|
|
6
|
+
import type { Command } from "./Program.js"
|
|
7
|
+
|
|
8
|
+
/** What the core a pin carries lacks, or what must read the live state: these never hand over. */
|
|
9
|
+
const LIVE_KINDS: ReadonlySet<Command["kind"]> = new Set<Command["kind"]>([
|
|
10
|
+
"ui",
|
|
11
|
+
"lsp",
|
|
12
|
+
"guide",
|
|
13
|
+
"bundle",
|
|
14
|
+
"historyList",
|
|
15
|
+
"historyShow",
|
|
16
|
+
"historyMetrics",
|
|
17
|
+
"historyJudges",
|
|
18
|
+
"historyFetch",
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
export type Launch =
|
|
22
|
+
| { readonly kind: "live"; readonly argv: readonly string[] }
|
|
23
|
+
| {
|
|
24
|
+
readonly kind: "pin"
|
|
25
|
+
readonly argv: readonly string[]
|
|
26
|
+
readonly path: string
|
|
27
|
+
readonly version: string
|
|
28
|
+
/** Unpacked only when run: its failure is the hand-over's. */
|
|
29
|
+
readonly source: () => string
|
|
30
|
+
}
|
|
31
|
+
| { readonly kind: "output"; readonly stdout: string }
|
|
32
|
+
| { readonly kind: "refuse"; readonly message: string }
|
|
33
|
+
|
|
34
|
+
const LIVE_FLAG = "--live"
|
|
35
|
+
|
|
36
|
+
// Only before `--`: after it, `--live` is an argument.
|
|
37
|
+
const optionWords = (argv: readonly string[]): readonly string[] => {
|
|
38
|
+
const end = argv.indexOf("--", 2)
|
|
39
|
+
return argv.slice(2, end < 0 ? undefined : end)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const withoutLiveFlag = (argv: readonly string[]): readonly string[] => {
|
|
43
|
+
const end = argv.indexOf("--", 2)
|
|
44
|
+
return argv.filter((word, i) => word !== LIVE_FLAG || i < 2 || (end >= 0 && i > end))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether this gtd runs the command or hands it to the process's pin. A pin
|
|
49
|
+
* this gtd cannot run is refused, never guessed at; a usage error the live
|
|
50
|
+
* parser finds is still the pin's to judge, since its engine may read
|
|
51
|
+
* another CLI.
|
|
52
|
+
*/
|
|
53
|
+
export const launchFor = (argv: readonly string[], pinFile: PinFile): Launch => {
|
|
54
|
+
// The engine a pin carries is this same code: once inside one, never hand over again.
|
|
55
|
+
if ((globalThis as { [PIN_KEY]?: unknown })[PIN_KEY] !== undefined) return { kind: "live", argv }
|
|
56
|
+
const live = optionWords(argv).includes(LIVE_FLAG)
|
|
57
|
+
const rest = withoutLiveFlag(argv)
|
|
58
|
+
if (live) return { kind: "live", argv: rest }
|
|
59
|
+
const plan = parseArgv(rest)
|
|
60
|
+
const version = plan.kind === "output" && plan.stdout === `${GTD_VERSION}\n`
|
|
61
|
+
if (plan.kind === "output" && !version) return { kind: "live", argv: rest }
|
|
62
|
+
if (plan.kind === "command" && LIVE_KINDS.has(plan.command.kind))
|
|
63
|
+
return { kind: "live", argv: rest }
|
|
64
|
+
const found = pinFile()
|
|
65
|
+
if (found === undefined) return { kind: "live", argv: rest }
|
|
66
|
+
const header = pinHeader(found.bytes)
|
|
67
|
+
if (version)
|
|
68
|
+
return {
|
|
69
|
+
kind: "output",
|
|
70
|
+
stdout:
|
|
71
|
+
header === undefined
|
|
72
|
+
? plan.stdout
|
|
73
|
+
: `${GTD_VERSION} (this process is pinned to gtd ${header.version})\n`,
|
|
74
|
+
}
|
|
75
|
+
if (header === undefined || header.format !== PIN_FORMAT) {
|
|
76
|
+
const what =
|
|
77
|
+
header === undefined
|
|
78
|
+
? "is no pin this gtd can read"
|
|
79
|
+
: `is pin format ${header.format}, made by gtd ${header.version}`
|
|
80
|
+
return {
|
|
81
|
+
kind: "refuse",
|
|
82
|
+
message: `gtd: ${found.path} ${what}; gtd ${GTD_VERSION} runs pin format ${PIN_FORMAT} — upgrade gtd (\`gtd --live\` runs this gtd instead, and may misread the process)`,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
kind: "pin",
|
|
87
|
+
argv: rest,
|
|
88
|
+
path: found.path,
|
|
89
|
+
version: header.version,
|
|
90
|
+
source: () => readPin(found.bytes).source,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface Launcher {
|
|
95
|
+
readonly stdout: (chunk: string) => void
|
|
96
|
+
readonly stderr: (chunk: string) => void
|
|
97
|
+
readonly exit: (code: number) => void
|
|
98
|
+
readonly live: (argv: readonly string[]) => void | Promise<void>
|
|
99
|
+
readonly pin: (launch: Extract<Launch, { kind: "pin" }>) => Promise<void>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const isJsonFlag = (word: string): boolean => word === "--json" || word.startsWith("--json=")
|
|
103
|
+
|
|
104
|
+
// The CLI's own error envelope too, which a `--json` driver reads.
|
|
105
|
+
const refusalText = (argv: readonly string[], message: string): string =>
|
|
106
|
+
(optionWords(argv).some(isJsonFlag)
|
|
107
|
+
? `${JSON.stringify({ state: "error", prompt: message })}\n`
|
|
108
|
+
: "") + `${message}\n`
|
|
109
|
+
|
|
110
|
+
const runPin = async (
|
|
111
|
+
launch: Extract<Launch, { kind: "pin" }>,
|
|
112
|
+
launcher: Launcher,
|
|
113
|
+
): Promise<void> => {
|
|
114
|
+
const words = optionWords(launch.argv)
|
|
115
|
+
if (words.includes("--verbose") || words.includes("-v"))
|
|
116
|
+
launcher.stderr(`launcher: running the pin ${launch.path} (gtd ${launch.version})\n`)
|
|
117
|
+
try {
|
|
118
|
+
await launcher.pin(launch)
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
121
|
+
launcher.stderr(`gtd: could not run the pin ${launch.path}: ${reason}\n`)
|
|
122
|
+
launcher.exit(EXIT_RUNTIME_ERROR)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const runLauncher = async (
|
|
127
|
+
argv: readonly string[],
|
|
128
|
+
pinFile: PinFile,
|
|
129
|
+
launcher: Launcher,
|
|
130
|
+
): Promise<void> => {
|
|
131
|
+
const launch = launchFor(argv, pinFile)
|
|
132
|
+
switch (launch.kind) {
|
|
133
|
+
case "live":
|
|
134
|
+
return launcher.live(launch.argv)
|
|
135
|
+
case "output":
|
|
136
|
+
return launcher.stdout(launch.stdout)
|
|
137
|
+
case "refuse":
|
|
138
|
+
launcher.stderr(refusalText(argv, launch.message))
|
|
139
|
+
return launcher.exit(EXIT_RUNTIME_ERROR)
|
|
140
|
+
case "pin":
|
|
141
|
+
return runPin(launch, launcher)
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -33,7 +33,6 @@ import {
|
|
|
33
33
|
restoredOutcome,
|
|
34
34
|
} from "../emit/index.js"
|
|
35
35
|
import { GitService, HISTORY_REF, Host } from "../platform/index.js"
|
|
36
|
-
import { contentHash } from "../workflow/index.js"
|
|
37
36
|
import { WORKFLOWS_REPO } from "../install/index.js"
|
|
38
37
|
|
|
39
38
|
const run = async (
|
|
@@ -111,7 +110,7 @@ export const hotfix = workflow(
|
|
|
111
110
|
expect(exitCode).toBe(0)
|
|
112
111
|
expect(stdout).toContain(
|
|
113
112
|
commitAll(
|
|
114
|
-
`gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-
|
|
113
|
+
`gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 2`,
|
|
115
114
|
),
|
|
116
115
|
)
|
|
117
116
|
expect(repo.commitHistory()).toHaveLength(before)
|
package/src/cli/Program.ts
CHANGED
package/src/cli/index.ts
CHANGED
package/src/edge/Edge.test.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
|
23
23
|
import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
|
|
24
24
|
import type { Mark } from "../flows/index.js"
|
|
25
25
|
import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
|
|
26
|
-
import {
|
|
26
|
+
import { StartFlags } from "../workflow/index.js"
|
|
27
27
|
import {
|
|
28
28
|
fakeGitOperations,
|
|
29
29
|
InMemRepo,
|
|
@@ -217,7 +217,7 @@ describe("currentRun", () => {
|
|
|
217
217
|
const before = headOf(repo)
|
|
218
218
|
const opening = await enter(repo, "side")
|
|
219
219
|
expect(repo.lastCommitMessage(opening)).toBe(
|
|
220
|
-
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-
|
|
220
|
+
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 2`,
|
|
221
221
|
)
|
|
222
222
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
223
223
|
const run = await provide(currentRun, repo)
|
|
@@ -250,7 +250,7 @@ export const again = workflow(async () => {
|
|
|
250
250
|
const before = headOf(repo)
|
|
251
251
|
const opening = await enter(repo, "again")
|
|
252
252
|
expect(repo.lastCommitMessage(opening)).toBe(
|
|
253
|
-
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-
|
|
253
|
+
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 2`,
|
|
254
254
|
)
|
|
255
255
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
256
256
|
const run = await provide(currentRun, repo)
|
package/src/edge/Edge.ts
CHANGED
|
@@ -12,8 +12,6 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
ConfigDiscovery,
|
|
14
14
|
ConfigService,
|
|
15
|
-
changedConfigFiles,
|
|
16
|
-
contentHash,
|
|
17
15
|
multilineSetting,
|
|
18
16
|
resolveVars,
|
|
19
17
|
resolveStartVars,
|
|
@@ -173,8 +171,6 @@ export interface ProcessRun {
|
|
|
173
171
|
readonly approvals: readonly ApprovalEntry[]
|
|
174
172
|
/** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
|
|
175
173
|
readonly pinnedVars: Record<string, string>
|
|
176
|
-
/** Absent for a process started before recording existed. */
|
|
177
|
-
readonly pinnedConfig: { readonly commit: string; readonly files: ConfigFingerprint } | undefined
|
|
178
174
|
/** HEAD's own gtd commit, when it is one — `empty` is whether it changed nothing. */
|
|
179
175
|
readonly headTurn:
|
|
180
176
|
| {
|
|
@@ -248,17 +244,6 @@ const runOf = (
|
|
|
248
244
|
: Effect.fail(new Error(fault))
|
|
249
245
|
}
|
|
250
246
|
|
|
251
|
-
// The newest recording, not the first process commit's: the episode is
|
|
252
|
-
// located by the CURRENT config's first step, so a config that renamed it can
|
|
253
|
-
// move the boundary onto the start commit itself, or behind it.
|
|
254
|
-
const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
|
|
255
|
-
for (let i = history.length - 1; i >= 0; i--) {
|
|
256
|
-
const files = parseCommitMessage(history[i]!.message).config
|
|
257
|
-
if (Object.keys(files).length > 0) return { commit: history[i]!.hash, files }
|
|
258
|
-
}
|
|
259
|
-
return undefined
|
|
260
|
-
}
|
|
261
|
-
|
|
262
247
|
const approvalEntryOf = (
|
|
263
248
|
hash: string,
|
|
264
249
|
message: ReturnType<typeof parseCommitMessage>,
|
|
@@ -300,7 +285,6 @@ const buildRun = (
|
|
|
300
285
|
marks: parsed.flatMap((m) => m.marks),
|
|
301
286
|
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
|
|
302
287
|
pinnedVars: { ...first?.vars },
|
|
303
|
-
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
304
288
|
headTurn: headTurnOf(head),
|
|
305
289
|
closingHash,
|
|
306
290
|
episode: {
|
|
@@ -420,37 +404,6 @@ const computeProcessRun = (
|
|
|
420
404
|
runOf(history, locateEpisode(def, history)),
|
|
421
405
|
)
|
|
422
406
|
|
|
423
|
-
// A file matching its copy in the recording commit is unchanged: the hashes
|
|
424
|
-
// are taken before the commit's own hooks run, and a hook may reformat it.
|
|
425
|
-
const changedSinceStart = (
|
|
426
|
-
pinned: ProcessRun["pinnedConfig"],
|
|
427
|
-
current: ConfigFingerprint,
|
|
428
|
-
workspace: WorkspaceOps,
|
|
429
|
-
): readonly string[] => {
|
|
430
|
-
if (pinned === undefined) return []
|
|
431
|
-
return changedConfigFiles(pinned.files, current).filter((path) => {
|
|
432
|
-
const committed = workspace.readCommittedSync(path, pinned.commit)
|
|
433
|
-
return (
|
|
434
|
-
current[path] === undefined ||
|
|
435
|
-
committed === undefined ||
|
|
436
|
-
contentHash(committed) !== current[path]
|
|
437
|
-
)
|
|
438
|
-
})
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const warnConfigChanges = (
|
|
442
|
-
run: ProcessRun,
|
|
443
|
-
current: ConfigFingerprint,
|
|
444
|
-
workspace: WorkspaceOps,
|
|
445
|
-
): Effect.Effect<void, never, Narrator> =>
|
|
446
|
-
Effect.gen(function* () {
|
|
447
|
-
const changed = changedSinceStart(run.pinnedConfig, current, workspace)
|
|
448
|
-
if (changed.length === 0) return
|
|
449
|
-
yield* (yield* Narrator).warn(
|
|
450
|
-
`gtd: warning: the config changed since this process started (${changed.join(", ")}) — the rest of the process replays under the changed config`,
|
|
451
|
-
)
|
|
452
|
-
})
|
|
453
|
-
|
|
454
407
|
type ConfigRequirements = GitService | ConfigService | ConfigDiscovery | Narrator | Workspace | Host
|
|
455
408
|
|
|
456
409
|
/** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
|
|
@@ -876,8 +829,6 @@ interface Rest extends ResolvedRest {
|
|
|
876
829
|
readonly run: ProcessRun
|
|
877
830
|
readonly vars: Record<string, string>
|
|
878
831
|
readonly env: Record<string, string>
|
|
879
|
-
/** What a landing that starts the process records. */
|
|
880
|
-
readonly config: ConfigFingerprint
|
|
881
832
|
readonly changes: readonly PendingChange[]
|
|
882
833
|
readonly memory: string | undefined
|
|
883
834
|
readonly memoryResumed: boolean
|
|
@@ -1030,11 +981,13 @@ export const restAt = (
|
|
|
1030
981
|
path: e.path,
|
|
1031
982
|
}))
|
|
1032
983
|
: []
|
|
1033
|
-
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
|
|
1037
|
-
|
|
984
|
+
// Only the current rest notices, so a command must read it once — a
|
|
985
|
+
// second read notices twice. An earlier ref (restore's tip) is not the
|
|
986
|
+
// process the command acts on.
|
|
987
|
+
if (ref === undefined && config.drift.length > 0)
|
|
988
|
+
yield* (yield* Narrator).warn(
|
|
989
|
+
`gtd: notice: the live config differs from the pinned one (${config.drift.join(", ")}); \`gtd repin\` to switch`,
|
|
990
|
+
)
|
|
1038
991
|
return {
|
|
1039
992
|
def,
|
|
1040
993
|
workflowName: resolved.name,
|
|
@@ -1048,7 +1001,6 @@ export const restAt = (
|
|
|
1048
1001
|
run,
|
|
1049
1002
|
vars,
|
|
1050
1003
|
env,
|
|
1051
|
-
config: configNow,
|
|
1052
1004
|
changes,
|
|
1053
1005
|
memory: memory.key,
|
|
1054
1006
|
memoryResumed: memory.resumed,
|
|
@@ -1406,8 +1358,7 @@ const pinRefusal = (pin: PinChange): Landing => ({
|
|
|
1406
1358
|
: `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`,
|
|
1407
1359
|
})
|
|
1408
1360
|
|
|
1409
|
-
const startTrailers = (rest: Rest, starts: boolean) =>
|
|
1410
|
-
starts ? { vars: rest.vars, config: rest.config } : {}
|
|
1361
|
+
const startTrailers = (rest: Rest, starts: boolean) => (starts ? { vars: rest.vars } : {})
|
|
1411
1362
|
|
|
1412
1363
|
const outcomeMarks = (outcome: ReplayOutcome): readonly Mark[] =>
|
|
1413
1364
|
outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : []
|
|
@@ -1543,13 +1494,13 @@ export interface HistoryTrail {
|
|
|
1543
1494
|
readonly versionWarning?: string
|
|
1544
1495
|
}
|
|
1545
1496
|
|
|
1546
|
-
/**
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
): readonly string[] => {
|
|
1552
|
-
const paths = new Set<string>(
|
|
1497
|
+
/**
|
|
1498
|
+
* Repository-relative paths of today's config files; one outside the
|
|
1499
|
+
* repository cannot be read at a ref, so it is left out. A file only the old
|
|
1500
|
+
* config read needs no check: whichever file imported it differs too.
|
|
1501
|
+
*/
|
|
1502
|
+
const inRepoConfigPaths = (root: string, files: readonly string[]): readonly string[] => {
|
|
1503
|
+
const paths = new Set<string>()
|
|
1553
1504
|
for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"))
|
|
1554
1505
|
return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
|
|
1555
1506
|
}
|
|
@@ -1569,16 +1520,11 @@ const differingConfigPaths = (
|
|
|
1569
1520
|
workspace: Workspace["Service"],
|
|
1570
1521
|
root: string,
|
|
1571
1522
|
config: { readonly fingerprint: ConfigFingerprint },
|
|
1572
|
-
run: ProcessRun,
|
|
1573
1523
|
hash: string,
|
|
1574
1524
|
) =>
|
|
1575
1525
|
Effect.gen(function* () {
|
|
1576
1526
|
const differing: string[] = []
|
|
1577
|
-
for (const path of inRepoConfigPaths(
|
|
1578
|
-
root,
|
|
1579
|
-
Object.keys(config.fingerprint),
|
|
1580
|
-
run.pinnedConfig?.files ?? {},
|
|
1581
|
-
)) {
|
|
1527
|
+
for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint))) {
|
|
1582
1528
|
const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
|
|
1583
1529
|
if (atTip !== workspace.readSync(path)) differing.push(path)
|
|
1584
1530
|
}
|
|
@@ -1664,7 +1610,7 @@ const replayFinishedProcess = (
|
|
|
1664
1610
|
// A refusal is often caused by the version change, so it carries the warning.
|
|
1665
1611
|
const withWarning = (err: Error): Error =>
|
|
1666
1612
|
versionWarning === undefined ? err : new Error(`${err.message}\n${versionWarning}`)
|
|
1667
|
-
const differing = yield* differingConfigPaths(git, workspace, host.root, config,
|
|
1613
|
+
const differing = yield* differingConfigPaths(git, workspace, host.root, config, hash)
|
|
1668
1614
|
if (differing.length > 0)
|
|
1669
1615
|
return yield* Effect.fail(
|
|
1670
1616
|
withWarning(configRefusal(how.command, hash, differing, how.configReason)),
|
|
@@ -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
|
+
}
|
package/src/platform/index.ts
CHANGED
|
@@ -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",
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -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
|
-
|
|
124
|
-
//
|
|
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}`),
|