@pmelab/gtd 60.1.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 +16 -0
- package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-XQeYCNMn.mjs} +1670 -1470
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +991 -627
- package/dist/gtd.core.mjs +117 -117
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.ts +6 -1
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Launch.test.ts +52 -0
- package/src/cli/Launch.ts +143 -0
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Program.test.ts +30 -20
- package/src/cli/Program.ts +4 -2
- package/src/cli/Strays.ts +3 -2
- package/src/cli/index.ts +1 -0
- package/src/edge/Edge.test.ts +79 -26
- package/src/edge/Edge.ts +178 -99
- package/src/emit/GitScript.ts +21 -3
- package/src/emit/index.ts +3 -0
- package/src/gherkin/messages.test.ts +1 -0
- package/src/gherkin/module-config.feature +1 -0
- package/src/gherkin/more-steps.feature +1 -0
- package/src/gherkin/testkit.feature +2 -1
- package/src/install/Install.ts +9 -2
- package/src/platform/PinFile.ts +61 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Format.test.ts +35 -8
- package/src/replay/Format.ts +7 -4
- package/src/replay/Replay.test.ts +54 -0
- package/src/replay/Replay.ts +61 -17
- package/src/replay/Trailers.test.ts +11 -48
- package/src/replay/Trailers.ts +36 -42
- package/src/replay/Tree.ts +11 -0
- package/src/replay/index.ts +3 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/planStart.test.ts +2 -3
- package/src/step/planStart.ts +1 -5
- package/src/step/planStep.test.ts +4 -4
- package/src/step/planStep.ts +10 -8
- package/src/testkit/TestRepo.test.ts +4 -1
- package/src/testkit/TestRepo.ts +3 -1
- package/src/twins/EmittedScriptRecognizer.ts +32 -1
- package/src/twins/GitTiers.ts +23 -0
- package/src/twins/InMemRepo.ts +14 -0
- package/src/twins/PinBeats.ts +33 -0
- package/src/twins/PinFile.test.ts +107 -0
- package/src/twins/PinFile.ts +12 -0
- package/src/twins/index.ts +2 -0
- package/src/wire/BeatDocument.ts +26 -5
- package/src/wire/Demand.ts +16 -6
- package/src/wire/constants.ts +3 -0
- package/src/wire/types.ts +4 -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/dist/wire.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, renderScriptJson, resolveSession, scriptFields, selectPath, stallDiagnosis, statusOf };
|
|
1
|
+
import { A as UNATTRIBUTED_MODEL, C as EXIT_RUNTIME_ERROR, D as EXIT_USAGE_ERROR, E as EXIT_STRAYS_FOUND, M as renderFormat, O as ACCESS_REFUSAL, S as EXIT_OK, T as EXIT_SIGTERM, _ as BEAT_KINDS, a as renderScriptJson, b as stallDiagnosis, c as landProseText, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, j as WIRE_SCHEMA, k as PIN_PATH, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, w as EXIT_SIGINT, x as EXIT_CODES, y as demandOf } from "./chunks/wire-CPNSm_9N.mjs";
|
|
2
|
+
export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, PIN_PATH, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, renderScriptJson, resolveSession, scriptFields, selectPath, stallDiagnosis, statusOf };
|
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
|
package/src/cli/History.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Effect, Exit } from "effect"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
applyEmittedScript,
|
|
5
|
+
InMemRepo,
|
|
6
|
+
makeCapturingCliIo,
|
|
7
|
+
takePinBeat,
|
|
8
|
+
testLayers,
|
|
9
|
+
} from "../twins/index.js"
|
|
4
10
|
import { runCli } from "./index.js"
|
|
5
11
|
import { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
|
|
6
12
|
|
|
@@ -12,7 +18,9 @@ export default async () => {
|
|
|
12
18
|
}
|
|
13
19
|
`
|
|
14
20
|
|
|
21
|
+
/** Lands `files` as one turn, taking a pin beat before it and an unpin beat after it as a driver does. */
|
|
15
22
|
const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
|
|
23
|
+
await takePinBeat(repo, "add")
|
|
16
24
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
17
25
|
const { io, result } = makeCapturingCliIo(repo)
|
|
18
26
|
await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
|
|
@@ -24,6 +32,7 @@ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<str
|
|
|
24
32
|
(JSON.parse(stdout) as { script: string }).script,
|
|
25
33
|
)
|
|
26
34
|
if (!outcome.ok) throw new Error(outcome.error)
|
|
35
|
+
await takePinBeat(repo, "remove")
|
|
27
36
|
return repo.resolveRef("HEAD")!
|
|
28
37
|
}
|
|
29
38
|
|
|
@@ -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/Metrics.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
applyEmittedScript,
|
|
5
|
+
InMemRepo,
|
|
6
|
+
makeCapturingCliIo,
|
|
7
|
+
takePinBeat,
|
|
8
|
+
testLayers,
|
|
9
|
+
} from "../twins/index.js"
|
|
4
10
|
import { runCli } from "./index.js"
|
|
5
11
|
import { processMetrics, readProcessMetrics, totalMetrics, type MetricCommit } from "./Metrics.js"
|
|
6
12
|
|
|
@@ -213,7 +219,9 @@ export default async () => {
|
|
|
213
219
|
}
|
|
214
220
|
`
|
|
215
221
|
|
|
222
|
+
/** Lands `files` as one turn, taking a pin beat before it and an unpin beat after it as a driver does. */
|
|
216
223
|
const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
|
|
224
|
+
await takePinBeat(repo, "add")
|
|
217
225
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
218
226
|
const { io, result } = makeCapturingCliIo(repo)
|
|
219
227
|
await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
|
|
@@ -225,6 +233,7 @@ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<str
|
|
|
225
233
|
(JSON.parse(stdout) as { script: string }).script,
|
|
226
234
|
)
|
|
227
235
|
if (!outcome.ok) throw new Error(outcome.error)
|
|
236
|
+
await takePinBeat(repo, "remove")
|
|
228
237
|
return repo.resolveRef("HEAD")!
|
|
229
238
|
}
|
|
230
239
|
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -18,7 +18,13 @@ vi.mock("node:os", async (importOriginal) => ({
|
|
|
18
18
|
import { runCli, type Command } from "./index.js"
|
|
19
19
|
import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
|
|
20
20
|
import { needsOf, runCommand, SelectorUsageError, type CommandRequirements } from "./Program.js"
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
InMemRepo,
|
|
23
|
+
makeCapturingCliIo,
|
|
24
|
+
testLayers,
|
|
25
|
+
applyEmittedScript,
|
|
26
|
+
takePinBeat,
|
|
27
|
+
} from "../twins/index.js"
|
|
22
28
|
import {
|
|
23
29
|
commitAll,
|
|
24
30
|
DID_NOT_RUN_COMMENT,
|
|
@@ -27,7 +33,6 @@ import {
|
|
|
27
33
|
restoredOutcome,
|
|
28
34
|
} from "../emit/index.js"
|
|
29
35
|
import { GitService, HISTORY_REF, Host } from "../platform/index.js"
|
|
30
|
-
import { contentHash } from "../workflow/index.js"
|
|
31
36
|
import { WORKFLOWS_REPO } from "../install/index.js"
|
|
32
37
|
|
|
33
38
|
const run = async (
|
|
@@ -39,13 +44,14 @@ const run = async (
|
|
|
39
44
|
return result()
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
/** Lands via `gtd land --json` and applies the emitted script, as a driver would. */
|
|
47
|
+
/** Lands via `gtd land --json` and applies the emitted script, then takes the pin or unpin beat it leads to, as a driver would. */
|
|
43
48
|
const landAndApply = async (
|
|
44
49
|
repo: InMemRepo,
|
|
45
50
|
): Promise<{ readonly exitCode: number; readonly script: string }> => {
|
|
46
51
|
const { stdout, exitCode } = await run(repo, "land", "--json")
|
|
47
52
|
const script = exitCode === 0 ? (JSON.parse(stdout) as { readonly script: string }).script : ""
|
|
48
53
|
if (exitCode === 0) expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
|
|
54
|
+
if (exitCode === 0) await takePinBeat(repo)
|
|
49
55
|
return { exitCode, script }
|
|
50
56
|
}
|
|
51
57
|
|
|
@@ -104,7 +110,7 @@ export const hotfix = workflow(
|
|
|
104
110
|
expect(exitCode).toBe(0)
|
|
105
111
|
expect(stdout).toContain(
|
|
106
112
|
commitAll(
|
|
107
|
-
`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`,
|
|
108
114
|
),
|
|
109
115
|
)
|
|
110
116
|
expect(repo.commitHistory()).toHaveLength(before)
|
|
@@ -190,6 +196,9 @@ export const hotfix = workflow(
|
|
|
190
196
|
const { stdout } = await run(repo, "start", "hotfix")
|
|
191
197
|
expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
|
|
192
198
|
expect(repo.lastCommitSubject()).toBe("gtd(human): working")
|
|
199
|
+
const pin = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
|
|
200
|
+
expect(pin).toMatchObject({ kind: "script", state: "pin", actor: "check", pin: "add" })
|
|
201
|
+
expect(await takePinBeat(repo, "add")).toBe(true)
|
|
193
202
|
const next = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
|
|
194
203
|
expect(next.kind).toBe("prompt")
|
|
195
204
|
expect(next.state).toBe("working")
|
|
@@ -496,6 +505,7 @@ export const resume = workflow(work)
|
|
|
496
505
|
const repo = seed(WORKFLOW)
|
|
497
506
|
const { stdout: entry } = await run(repo, "start", "resume")
|
|
498
507
|
expect(applyEmittedScript(repo, new Map(), entry).ok).toBe(true)
|
|
508
|
+
await takePinBeat(repo, "add")
|
|
499
509
|
const { stdout, exitCode } = await run(repo, "next", "--json")
|
|
500
510
|
expect(exitCode).toBe(0)
|
|
501
511
|
expect(JSON.parse(stdout).kind).toBe("prompt")
|
|
@@ -775,20 +785,20 @@ describe("gtd next — refuses when history diverges from what replay reaches",
|
|
|
775
785
|
expect(repo.commitHistory()).toHaveLength(before)
|
|
776
786
|
|
|
777
787
|
expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
|
|
778
|
-
expect(repo.commitHistory()).toHaveLength(before -
|
|
788
|
+
expect(repo.commitHistory()).toHaveLength(before - 3)
|
|
779
789
|
})
|
|
780
790
|
})
|
|
781
791
|
|
|
782
792
|
describe("a history written in a format this gtd does not read", () => {
|
|
783
793
|
// The exact wording is pinned in Format.test.ts; this is the part that proves the format gate fired.
|
|
784
|
-
const FOREIGN = "is written in history format
|
|
794
|
+
const FOREIGN = "is written in history format 3, but this gtd reads formats 1 to 2"
|
|
785
795
|
|
|
786
796
|
/** A step commit that would also diverge, so only the format gate can explain the refusal. */
|
|
787
797
|
const foreignStep = async (): Promise<InMemRepo> => {
|
|
788
798
|
const repo = seed(IDLE_THEN_WORKING)
|
|
789
799
|
await landTurn(repo, { "NOTE.md": "a note\n" })
|
|
790
800
|
repo.writeFile("WORK.md", "done\n")
|
|
791
|
-
repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format:
|
|
801
|
+
repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 3")
|
|
792
802
|
return repo
|
|
793
803
|
}
|
|
794
804
|
|
|
@@ -813,7 +823,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
813
823
|
it("refuses even when a foreign subject hides the commit from the episode", async () => {
|
|
814
824
|
const repo = seed(IDLE_THEN_WORKING)
|
|
815
825
|
repo.writeFile("NOTE.md", "a note\n")
|
|
816
|
-
repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format:
|
|
826
|
+
repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 3")
|
|
817
827
|
for (const command of ["next", "abandon"]) {
|
|
818
828
|
const { exitCode, stderr } = await run(repo, command)
|
|
819
829
|
expect(exitCode).toBe(1)
|
|
@@ -824,7 +834,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
824
834
|
it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
|
|
825
835
|
const repo = seed(IDLE_THEN_WORKING)
|
|
826
836
|
repo.writeFile("NOTE.md", "a note\n")
|
|
827
|
-
repo.seedCommit("feat: x\n\nGtd-Format:
|
|
837
|
+
repo.seedCommit("feat: x\n\nGtd-Format: 3")
|
|
828
838
|
expect((await run(repo, "next")).exitCode).toBe(0)
|
|
829
839
|
})
|
|
830
840
|
|
|
@@ -832,7 +842,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
832
842
|
const repo = seed(IDLE_THEN_WORKING)
|
|
833
843
|
const base = repo.resolveRef("HEAD")!
|
|
834
844
|
repo.writeFile("NOTE.md", "a note\n")
|
|
835
|
-
repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format:
|
|
845
|
+
repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 3")
|
|
836
846
|
repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
|
|
837
847
|
repo.hardResetTo(base)
|
|
838
848
|
const { exitCode, stderr } = await run(repo, "restore")
|
|
@@ -1390,10 +1400,10 @@ export default async () => {
|
|
|
1390
1400
|
expect(await nextOf(repo)).toEqual({ target: "accepted" })
|
|
1391
1401
|
})
|
|
1392
1402
|
|
|
1393
|
-
it("a turn that finishes
|
|
1403
|
+
it("a turn that finishes a pinned flow targets the unpin", async () => {
|
|
1394
1404
|
const repo = await atWorking()
|
|
1395
1405
|
repo.writeFile("REVIEW.md", "an updated review\n")
|
|
1396
|
-
expect(await nextOf(repo)).toMatchObject({ target: "
|
|
1406
|
+
expect(await nextOf(repo)).toMatchObject({ target: "unpin" })
|
|
1397
1407
|
})
|
|
1398
1408
|
|
|
1399
1409
|
it("plain gtd next shows the same preview as a `Next:` line", async () => {
|
|
@@ -1476,7 +1486,7 @@ export default async () => {
|
|
|
1476
1486
|
expect((JSON.parse(stdout) as { readonly script: string }).script).toContain("git commit")
|
|
1477
1487
|
})
|
|
1478
1488
|
|
|
1479
|
-
it("a clean run that finishes
|
|
1489
|
+
it("a clean run that finishes a pinned flow lands an ordinary commit at the unpin", async () => {
|
|
1480
1490
|
const repo = await atChecking(REENTRY_WORKFLOW)
|
|
1481
1491
|
const { stdout, exitCode } = await run(repo, "land", "--json")
|
|
1482
1492
|
expect(exitCode).toBe(0)
|
|
@@ -1485,7 +1495,7 @@ export default async () => {
|
|
|
1485
1495
|
expect(script).not.toContain("git reset --mixed")
|
|
1486
1496
|
expect(script).not.toContain("nothing to retain")
|
|
1487
1497
|
expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
|
|
1488
|
-
expect(repo.lastCommitSubject()).toBe("gtd(check): checking →
|
|
1498
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): checking → unpin")
|
|
1489
1499
|
})
|
|
1490
1500
|
|
|
1491
1501
|
it("the same rest with a pending change lands an ordinary commit too", async () => {
|
|
@@ -1523,7 +1533,7 @@ export default async () => {
|
|
|
1523
1533
|
expect(parsed.script).toContain("nothing to do")
|
|
1524
1534
|
})
|
|
1525
1535
|
|
|
1526
|
-
it("gtd land --json a decision that
|
|
1536
|
+
it("gtd land --json a decision that ends a pinned flow reports settled:false, idle:false, state:unpin", async () => {
|
|
1527
1537
|
const repo = await atChecking(REENTRY_WORKFLOW)
|
|
1528
1538
|
const { stdout, exitCode } = await run(repo, "land", "--json")
|
|
1529
1539
|
expect(exitCode).toBe(0)
|
|
@@ -1533,8 +1543,8 @@ export default async () => {
|
|
|
1533
1543
|
readonly state: string
|
|
1534
1544
|
}
|
|
1535
1545
|
expect(parsed.settled).toBe(false)
|
|
1536
|
-
expect(parsed.idle).toBe(
|
|
1537
|
-
expect(parsed.state).toBe("
|
|
1546
|
+
expect(parsed.idle).toBe(false)
|
|
1547
|
+
expect(parsed.state).toBe("unpin")
|
|
1538
1548
|
})
|
|
1539
1549
|
|
|
1540
1550
|
it("gtd land --json an ordinary commit reports settled:false", async () => {
|
|
@@ -1683,7 +1693,7 @@ export default async () => {
|
|
|
1683
1693
|
const { exitCode, script } = await landAndApply(repo)
|
|
1684
1694
|
expect(exitCode).toBe(0)
|
|
1685
1695
|
expect(script).not.toContain("fmt-notes")
|
|
1686
|
-
expect(repo.lastCommitSubject()).toBe("gtd(agent): drafting →
|
|
1696
|
+
expect(repo.lastCommitSubject("HEAD~1")).toBe("gtd(agent): drafting → unpin")
|
|
1687
1697
|
})
|
|
1688
1698
|
|
|
1689
1699
|
it("`gtd next --json`'s `validate` field carries the mode's format command", async () => {
|
|
@@ -1867,7 +1877,7 @@ export default workflow(
|
|
|
1867
1877
|
it("writes the rendered summary, with a trailing newline, counting the human turns after the entry commit", async () => {
|
|
1868
1878
|
const repo = await reviewed()
|
|
1869
1879
|
const history = repo.commitHistory()
|
|
1870
|
-
const entryHash = history[history.length -
|
|
1880
|
+
const entryHash = history[history.length - 4]!.hash
|
|
1871
1881
|
const tipHash = history[history.length - 1]!.hash
|
|
1872
1882
|
|
|
1873
1883
|
const { exitCode, stdout } = await run(repo, "summary")
|
|
@@ -1961,7 +1971,7 @@ export default async () => {
|
|
|
1961
1971
|
it("refuses once the process has closed and HEAD rests at the initial step again", async () => {
|
|
1962
1972
|
const repo = await atDeciding()
|
|
1963
1973
|
await landTurn(repo)
|
|
1964
|
-
expect(repo.lastCommitSubject()).toBe("gtd(
|
|
1974
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
|
|
1965
1975
|
|
|
1966
1976
|
const { exitCode, stdout, stderr } = await run(repo, "base")
|
|
1967
1977
|
expect(exitCode).toBe(1)
|
package/src/cli/Program.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
type StatusChange,
|
|
48
48
|
selectPath,
|
|
49
49
|
EXIT_OK,
|
|
50
|
+
PIN_PATH,
|
|
50
51
|
} from "../wire/index.js"
|
|
51
52
|
import {
|
|
52
53
|
currentRest,
|
|
@@ -80,6 +81,7 @@ import {
|
|
|
80
81
|
deleteRef,
|
|
81
82
|
hardResetTo,
|
|
82
83
|
mixedResetTo,
|
|
84
|
+
unpinScript,
|
|
83
85
|
ScriptSurface,
|
|
84
86
|
updateRef,
|
|
85
87
|
type RunnableScript,
|
|
@@ -617,7 +619,6 @@ const startWorkflow = (
|
|
|
617
619
|
first: started,
|
|
618
620
|
commandLabel,
|
|
619
621
|
vars: varOverrides,
|
|
620
|
-
config: rest.config,
|
|
621
622
|
},
|
|
622
623
|
)
|
|
623
624
|
if (plan.kind === "refusal") {
|
|
@@ -799,6 +800,7 @@ const runAbandonCommand = (
|
|
|
799
800
|
const steps: EmitStep[] = [
|
|
800
801
|
{ kind: "gitWrite", command: updateRef(HISTORY_REF, tip) },
|
|
801
802
|
{ kind: "gitWrite", command: mixedResetTo(run.startParentHash) },
|
|
803
|
+
{ kind: "gitWrite", command: unpinScript(PIN_PATH) },
|
|
802
804
|
{ kind: "outcome", command: abandonedOutcome(restState, run.startParentHash, initial) },
|
|
803
805
|
]
|
|
804
806
|
const required = emitScripts(steps).required
|
|
@@ -1060,7 +1062,7 @@ const runCodeThreadsCheckCommand = (): Effect.Effect<void, Error, CommandRequire
|
|
|
1060
1062
|
const workspace = yield* Workspace
|
|
1061
1063
|
const changed = yield* git.changedPaths(rest.run.diffBase)
|
|
1062
1064
|
const lines = changed
|
|
1063
|
-
.filter((c) => c.status !== "D")
|
|
1065
|
+
.filter((c) => c.status !== "D" && c.path !== PIN_PATH)
|
|
1064
1066
|
.flatMap(({ path }) => {
|
|
1065
1067
|
const { threads, findings } = parseCodeThreads(path, workspace.atPath(path) ?? "")
|
|
1066
1068
|
return [
|
package/src/cli/Strays.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { GitService, GtdError, Narrator, type GitOperations } from "../platform/index.js"
|
|
3
3
|
import { strayPaths } from "../step/index.js"
|
|
4
|
-
import { EXIT_OK, EXIT_STRAYS_FOUND } from "../wire/index.js"
|
|
4
|
+
import { EXIT_OK, EXIT_STRAYS_FOUND, PIN_PATH } from "../wire/index.js"
|
|
5
5
|
import {
|
|
6
6
|
SelectorUsageError,
|
|
7
7
|
writeSelection,
|
|
@@ -31,7 +31,8 @@ export const runStraysCommand = (
|
|
|
31
31
|
const narrator = yield* Narrator
|
|
32
32
|
const base = yield* resolveSide(git, command.base)
|
|
33
33
|
const tip = yield* resolveSide(git, command.tip)
|
|
34
|
-
|
|
34
|
+
// The pin is the engine's, never a turn's, so it is never a stray either.
|
|
35
|
+
const touched = (yield* git.diffPaths(base, tip)).filter((path) => path !== PIN_PATH)
|
|
35
36
|
const strays = strayPaths(touched, command.globs)
|
|
36
37
|
const total = touched.length
|
|
37
38
|
|
package/src/cli/index.ts
CHANGED