@pmelab/gtd 60.0.0 → 61.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 +21 -2
- package/dist/chunks/testkit-C9sq0x07.mjs +30758 -0
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +12238 -20706
- package/dist/gtd.core.mjs +593 -0
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +2 -2
- package/package.json +7 -7
- package/src/cli/Cli.test.ts +2 -2
- package/src/cli/Cli.ts +68 -51
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Output.test.ts +1 -1
- package/src/cli/Output.ts +3 -2
- package/src/cli/Program.test.ts +44 -25
- package/src/cli/Program.ts +39 -11
- package/src/cli/Strays.ts +3 -2
- package/src/cli/ToolCommands.ts +2 -2
- package/src/edge/Edge.test.ts +86 -31
- package/src/edge/Edge.ts +164 -31
- package/src/emit/GitScript.ts +21 -3
- package/src/emit/index.ts +3 -0
- package/src/flows/runtime.ts +1 -1
- 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/install/Version.ts +9 -3
- package/src/install/index.ts +1 -1
- package/src/platform/CommandRunner.ts +13 -11
- package/src/platform/Commentary.ts +2 -2
- package/src/platform/Git.test.ts +4 -5
- package/src/platform/Git.ts +42 -38
- package/src/platform/Host.ts +1 -1
- package/src/platform/NodeServices.ts +11 -0
- package/src/platform/Workspace.test.ts +4 -5
- package/src/platform/Workspace.ts +3 -3
- package/src/platform/WorktreeState.test.ts +2 -3
- package/src/platform/WorktreeState.ts +1 -1
- package/src/platform/index.ts +2 -1
- 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 +12 -12
- package/src/replay/Trailers.ts +35 -20
- 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 -2
- package/src/step/planStart.ts +3 -3
- 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/EmittedScripts.test.ts +1 -1
- package/src/twins/GitTiers.ts +29 -7
- package/src/twins/InMemRepo.ts +14 -0
- package/src/twins/Layers.test.ts +1 -1
- package/src/twins/Layers.ts +10 -3
- package/src/twins/PinBeats.ts +33 -0
- package/src/twins/cliIo.ts +9 -4
- package/src/twins/index.ts +1 -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/discovery.test.ts +24 -7
- package/src/workflow/discovery.ts +5 -6
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +60 -3
- package/src/workflow/load.ts +316 -166
- package/src/workflow/pin.test.ts +159 -0
- package/src/workflow/pin.ts +72 -0
- package/src/workflow/vars.ts +3 -3
- package/dist/chunks/testkit-BvecVjQ0.mjs +0 -32355
|
@@ -61,10 +61,12 @@ describe("testRepo", () => {
|
|
|
61
61
|
expect(repo.subjects()).toEqual([
|
|
62
62
|
"chore: initial commit",
|
|
63
63
|
"gtd(agent): plan → build",
|
|
64
|
+
"gtd(check): pin → build",
|
|
64
65
|
"gtd(agent): build → check",
|
|
65
66
|
"gtd(check): check → sign-off",
|
|
66
67
|
])
|
|
67
68
|
expect(repo.read("src/add.ts")).toContain("a + b")
|
|
69
|
+
expect(repo.read(".gtd/process.gtdpin")).toBeDefined()
|
|
68
70
|
})
|
|
69
71
|
|
|
70
72
|
it("runs a workflow given as gtd.config.ts source", async () => {
|
|
@@ -550,7 +552,8 @@ describe("testRepo", () => {
|
|
|
550
552
|
trailers: { Refs: ["#1", "#2"] },
|
|
551
553
|
})
|
|
552
554
|
await repo.drive({ plan: (_, r) => r.write("PLAN.md", "p\n") })
|
|
553
|
-
expect(repo.commits().at(-
|
|
555
|
+
expect(repo.commits().at(-2)!.trailers["Gtd-Step"]).toEqual(["plan#1"])
|
|
556
|
+
expect(repo.commits().at(-1)!.trailers["Gtd-Pin"]).toEqual(["add"])
|
|
554
557
|
})
|
|
555
558
|
|
|
556
559
|
const greeter = {
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -353,13 +353,15 @@ const expectedVerdict = (doc: BeatDocument): string => {
|
|
|
353
353
|
|
|
354
354
|
const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(", ")
|
|
355
355
|
|
|
356
|
-
/** Who acts at `doc`: its scripted actor, or
|
|
356
|
+
/** Who acts at `doc`: its scripted actor, or its own script at a pin beat or a check under `checks: "run"`. A stall has none. */
|
|
357
357
|
const actorAt = (
|
|
358
358
|
doc: BeatDocument,
|
|
359
359
|
actors: Actors,
|
|
360
360
|
checks: DriveOptions["checks"],
|
|
361
361
|
): { readonly scripted: boolean; readonly actor: ScriptedActor | undefined } => {
|
|
362
362
|
if (doc.kind === "stalled") return { scripted: false, actor: undefined }
|
|
363
|
+
// A drive is a driver: pinning is its own business, not a step to script.
|
|
364
|
+
if (doc.pin !== undefined) return { scripted: false, actor: runsItsScript }
|
|
363
365
|
if (Object.hasOwn(actors, doc.state)) return { scripted: true, actor: actors[doc.state] }
|
|
364
366
|
const runs = checks === "run" && doc.actor === "check"
|
|
365
367
|
return { scripted: false, actor: runs ? runsItsScript : undefined }
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
commitAll,
|
|
8
8
|
commitAsIs,
|
|
9
9
|
commitClean,
|
|
10
|
+
commitPath,
|
|
10
11
|
deleteRef,
|
|
11
12
|
discardPending,
|
|
12
13
|
hardResetTo,
|
|
@@ -24,6 +25,8 @@ import {
|
|
|
24
25
|
contradictionMessage,
|
|
25
26
|
modeContradictionSkipNotice,
|
|
26
27
|
OUTCOME_MARKER,
|
|
28
|
+
pinScript,
|
|
29
|
+
unpinScript,
|
|
27
30
|
} from "../emit/index.js"
|
|
28
31
|
import {
|
|
29
32
|
BUILT_IN_MODE_NAMES,
|
|
@@ -32,7 +35,7 @@ import {
|
|
|
32
35
|
type SteeringFormat,
|
|
33
36
|
} from "../steering/internal.js"
|
|
34
37
|
import { refNameFault } from "../replay/index.js"
|
|
35
|
-
import { renderFormat } from "../wire/index.js"
|
|
38
|
+
import { PIN_PATH, renderFormat } from "../wire/index.js"
|
|
36
39
|
import { applyFlowScript, rewritten } from "./FlowScripts.js"
|
|
37
40
|
import type { InMemRepo } from "./InMemRepo.js"
|
|
38
41
|
import type { CommandTable } from "./Layers.js"
|
|
@@ -174,6 +177,17 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
|
|
|
174
177
|
|
|
175
178
|
if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block)
|
|
176
179
|
|
|
180
|
+
if (block.startsWith("git add -A -- ")) {
|
|
181
|
+
const [path, message] = extractQuotedTokens(block)
|
|
182
|
+
if (path === undefined || message === undefined || commitPath(path, message) !== block) {
|
|
183
|
+
return undefined
|
|
184
|
+
}
|
|
185
|
+
const failure = repo.commitPath(path, message)
|
|
186
|
+
return failure === undefined
|
|
187
|
+
? { kind: "applied" }
|
|
188
|
+
: { kind: "failed", error: failure, status: 1 }
|
|
189
|
+
}
|
|
190
|
+
|
|
177
191
|
if (block.startsWith("git add -A || exit 1\n")) {
|
|
178
192
|
const [message] = extractQuotedTokens(conditionLine)
|
|
179
193
|
if (message !== undefined && commitAll(message) === block) {
|
|
@@ -255,6 +269,22 @@ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | un
|
|
|
255
269
|
return undefined
|
|
256
270
|
}
|
|
257
271
|
|
|
272
|
+
/** What the in-memory `gtd bundle` prints: a pin header, and no engine behind it. */
|
|
273
|
+
const IN_MEMORY_PIN = "gtdpin 1 gtd in-memory\n"
|
|
274
|
+
|
|
275
|
+
/** A pin or unpin beat's script. */
|
|
276
|
+
const recognizePinScripts = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
277
|
+
if (block === pinScript(PIN_PATH)) {
|
|
278
|
+
repo.writeFile(PIN_PATH, IN_MEMORY_PIN)
|
|
279
|
+
return { kind: "applied" }
|
|
280
|
+
}
|
|
281
|
+
if (block === unpinScript(PIN_PATH)) {
|
|
282
|
+
if (repo.readFile(PIN_PATH) !== undefined) repo.deleteFile(PIN_PATH)
|
|
283
|
+
return { kind: "applied" }
|
|
284
|
+
}
|
|
285
|
+
return undefined
|
|
286
|
+
}
|
|
287
|
+
|
|
258
288
|
/** The REAL `fileExistsGuard` block, re-derived from the extracted path and string-compared. The one recognizer that can report `{ kind: "stopped" }`: a real `exit 0` there ends the script successfully. */
|
|
259
289
|
const recognizeFileExistsGuard = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
260
290
|
const [file] = extractQuotedTokens(block)
|
|
@@ -694,6 +724,7 @@ const recognizersFor = (
|
|
|
694
724
|
(block) => recognizeBinaryGuard(commands, block),
|
|
695
725
|
(block) => (git ? recognizeGitBuilders(repo, block) : undefined),
|
|
696
726
|
(block) => recognizeWriteLines(repo, block),
|
|
727
|
+
(block) => recognizePinScripts(repo, block),
|
|
697
728
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
698
729
|
(block) => recognizePresentationSubshell(repo, commands, block, git),
|
|
699
730
|
(block) => recognizeModeContradictionSkipNotice(block),
|
|
@@ -180,7 +180,7 @@ for (const makeTier of gitTiers) {
|
|
|
180
180
|
// As a landing renders it: under `set -eu`, which a failing update-ref relies on to stop.
|
|
181
181
|
const keepTurn = (ref: string, message: string) =>
|
|
182
182
|
ScriptSurface.render([{ kind: "gitWrite", write: { kind: "keepTurn", ref, message } }])
|
|
183
|
-
const git = <A>(f: (g: GitService["
|
|
183
|
+
const git = <A>(f: (g: GitService["Service"]) => Effect.Effect<A, Error>) =>
|
|
184
184
|
t.provide(Effect.flatMap(GitService, f))
|
|
185
185
|
const keptTip = async () => {
|
|
186
186
|
const marker = await git((g) => g.lastCommitMessage())
|
package/src/twins/GitTiers.ts
CHANGED
|
@@ -18,10 +18,11 @@ import { execFileSync, execSync, spawnSync } from "node:child_process"
|
|
|
18
18
|
// `TEST_DOUBLE_SENTINEL` check.
|
|
19
19
|
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
20
20
|
import { Effect, Exit, Option } from "effect"
|
|
21
|
-
import {
|
|
22
|
-
import { Narrator, GitService, Host, type GitOperations } from "../platform/index.js"
|
|
21
|
+
import { Narrator, GitService, Host, type GitOperations, NodeServices } from "../platform/index.js"
|
|
23
22
|
import {
|
|
24
23
|
commitAll,
|
|
24
|
+
commitPath,
|
|
25
|
+
unpinScript,
|
|
25
26
|
commitAsIs,
|
|
26
27
|
commitClean,
|
|
27
28
|
softResetTo,
|
|
@@ -165,7 +166,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
165
166
|
eff.pipe(
|
|
166
167
|
Effect.provide(GitService.Live),
|
|
167
168
|
Effect.provide(Host.layer({ root, home: root, env: {} })),
|
|
168
|
-
Effect.provide(
|
|
169
|
+
Effect.provide(NodeServices.layer),
|
|
169
170
|
Effect.provide(noopNarratorLayer),
|
|
170
171
|
),
|
|
171
172
|
)
|
|
@@ -177,7 +178,7 @@ const makeLiveTier = (initialCommit = true): GitTier => {
|
|
|
177
178
|
eff.pipe(
|
|
178
179
|
Effect.provide(GitService.Live),
|
|
179
180
|
Effect.provide(Host.layer({ root, home: root, env: {} })),
|
|
180
|
-
Effect.provide(
|
|
181
|
+
Effect.provide(NodeServices.layer),
|
|
181
182
|
Effect.provide(noopNarratorLayer),
|
|
182
183
|
),
|
|
183
184
|
)
|
|
@@ -1266,7 +1267,7 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
1266
1267
|
Effect.flatMap(GitService, (g) => g.gitDir()).pipe(
|
|
1267
1268
|
Effect.provide(GitService.Live),
|
|
1268
1269
|
Effect.provide(Host.layer({ root: siblingDir, home: siblingDir, env: {} })),
|
|
1269
|
-
Effect.provide(
|
|
1270
|
+
Effect.provide(NodeServices.layer),
|
|
1270
1271
|
),
|
|
1271
1272
|
)
|
|
1272
1273
|
expect(siblingGitDir).not.toBe(mainGitDir)
|
|
@@ -1297,14 +1298,14 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
|
|
|
1297
1298
|
Effect.flatMap(GitService, (g) => g.gitCommonDir()).pipe(
|
|
1298
1299
|
Effect.provide(GitService.Live),
|
|
1299
1300
|
Effect.provide(Host.layer({ root: siblingDir, home: siblingDir, env: {} })),
|
|
1300
|
-
Effect.provide(
|
|
1301
|
+
Effect.provide(NodeServices.layer),
|
|
1301
1302
|
),
|
|
1302
1303
|
)
|
|
1303
1304
|
const siblingGitDir = await Effect.runPromise(
|
|
1304
1305
|
Effect.flatMap(GitService, (g) => g.gitDir()).pipe(
|
|
1305
1306
|
Effect.provide(GitService.Live),
|
|
1306
1307
|
Effect.provide(Host.layer({ root: siblingDir, home: siblingDir, env: {} })),
|
|
1307
|
-
Effect.provide(
|
|
1308
|
+
Effect.provide(NodeServices.layer),
|
|
1308
1309
|
),
|
|
1309
1310
|
)
|
|
1310
1311
|
expect(siblingCommonDir).toBe(mainCommonDir)
|
|
@@ -1357,6 +1358,27 @@ export const runGitScriptContract = (makeTier: () => GitTier): void => {
|
|
|
1357
1358
|
})
|
|
1358
1359
|
})
|
|
1359
1360
|
|
|
1361
|
+
describe("commitPath", () => {
|
|
1362
|
+
it("commits the one path; every other pending change stays pending", () => {
|
|
1363
|
+
t.seed.writeFile(".gtd/process.gtdpin", "pin")
|
|
1364
|
+
t.seed.writeFile("other.ts", "export const y = 1")
|
|
1365
|
+
run(t, commitPath(".gtd/process.gtdpin", "gtd(check): pin → building"))
|
|
1366
|
+
expect(t.observe.headSubject()).toBe("gtd(check): pin → building")
|
|
1367
|
+
expect(t.observe.headTouchedPaths()).toEqual([".gtd/process.gtdpin"])
|
|
1368
|
+
expect(t.observe.statusPorcelain()).toContain("other.ts")
|
|
1369
|
+
expect(t.observe.statusPorcelain()).not.toContain("process.gtdpin")
|
|
1370
|
+
})
|
|
1371
|
+
|
|
1372
|
+
it("commits the path's deletion", () => {
|
|
1373
|
+
t.seed.commit("feat: pinned", { ".gtd/process.gtdpin": "pin" })
|
|
1374
|
+
run(t, unpinScript(".gtd/process.gtdpin"))
|
|
1375
|
+
run(t, commitPath(".gtd/process.gtdpin", "gtd(check): unpin → idle"))
|
|
1376
|
+
expect(t.observe.headTouchedPaths()).toEqual([".gtd/process.gtdpin"])
|
|
1377
|
+
expect(t.observe.existsPath(".gtd/process.gtdpin")).toBe(false)
|
|
1378
|
+
expect(t.observe.statusPorcelain().trim()).toBe("")
|
|
1379
|
+
})
|
|
1380
|
+
})
|
|
1381
|
+
|
|
1360
1382
|
describe("commitClean", () => {
|
|
1361
1383
|
it("on a clean tree commits an empty commit with the message", () => {
|
|
1362
1384
|
const headBefore = t.observe.resolveRef("HEAD")
|
package/src/twins/InMemRepo.ts
CHANGED
|
@@ -474,6 +474,20 @@ export class InMemRepo {
|
|
|
474
474
|
this.resetHard()
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
+
/** Twin of `GitScript.commitPath`: commits `path` as the worktree has it, every other staged change left staged. */
|
|
478
|
+
commitPath(path: string, message: string): string | undefined {
|
|
479
|
+
if (!this.worktree.has(path) && !this.headTree().has(path) && !this.index.has(path)) {
|
|
480
|
+
return `error: pathspec '${path}' did not match any file(s) known to git`
|
|
481
|
+
}
|
|
482
|
+
const staged = new Map(this.index)
|
|
483
|
+
this.index = new Map(this.headTree())
|
|
484
|
+
this.stage(path)
|
|
485
|
+
const failure = this.commitAsIs(message)
|
|
486
|
+
this.index = staged
|
|
487
|
+
this.stage(path)
|
|
488
|
+
return failure
|
|
489
|
+
}
|
|
490
|
+
|
|
477
491
|
commitAllWithPrefix(prefix: string): string | undefined {
|
|
478
492
|
this.index = new Map(this.worktree)
|
|
479
493
|
return this.commitAsIs(prefix)
|
package/src/twins/Layers.test.ts
CHANGED
|
@@ -23,7 +23,7 @@ const seed = (repo: InMemRepo, dir: string, name: string, content: string): void
|
|
|
23
23
|
|
|
24
24
|
const runDiscovery = <A>(
|
|
25
25
|
repo: InMemRepo,
|
|
26
|
-
f: (d: ConfigDiscovery["
|
|
26
|
+
f: (d: ConfigDiscovery["Service"]) => Effect.Effect<A, Error>,
|
|
27
27
|
) =>
|
|
28
28
|
Effect.runPromise(
|
|
29
29
|
Effect.gen(function* () {
|
package/src/twins/Layers.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import {
|
|
22
22
|
ConfigDiscovery,
|
|
23
23
|
ConfigService,
|
|
24
|
+
EngineCore,
|
|
24
25
|
LEGACY_RC_FILES,
|
|
25
26
|
legacyRcDirs,
|
|
26
27
|
WORKFLOW_MODULE,
|
|
@@ -30,7 +31,7 @@ import { fakeGitOperations } from "./FakeGitOperations.js"
|
|
|
30
31
|
import { InMemRepo } from "./InMemRepo.js"
|
|
31
32
|
|
|
32
33
|
// Read off the CLI's own port rather than imported from the composition root.
|
|
33
|
-
type CommandRequirements = Layer.
|
|
34
|
+
type CommandRequirements = Layer.Success<ReturnType<CliIo["layers"]>>
|
|
34
35
|
import { ToolCommands, type CliIo } from "../cli/index.js"
|
|
35
36
|
|
|
36
37
|
const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
|
|
@@ -167,6 +168,11 @@ const makeInMemoryConfigDiscovery = (
|
|
|
167
168
|
})
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/** The in-memory counterpart to the shipped `gtd.core.mjs`: an ES module that needs nothing but Node. */
|
|
172
|
+
const IN_MEMORY_ENGINE = `#!/usr/bin/env node
|
|
173
|
+
import "node:process"
|
|
174
|
+
`
|
|
175
|
+
|
|
170
176
|
/** One scripted `bash` command's canned behavior, keyed by the RENDERED command string a scenario's `Given` step declares. */
|
|
171
177
|
export type ScriptedCommand =
|
|
172
178
|
| {
|
|
@@ -207,13 +213,13 @@ export interface TestWorldOptions {
|
|
|
207
213
|
/** Gates `narrate` above — defaults to `true` (fires whenever a sink is given) since these tests aren't exercising `Cli.ts`'s own `--verbose` gate. `src/twins/cliIo.ts` overrides this with the real decoded flag. */
|
|
208
214
|
readonly verbose?: boolean
|
|
209
215
|
/** Real or fake driver and tool commands — each one left out fails when called. */
|
|
210
|
-
readonly tools?: Partial<ToolCommands["
|
|
216
|
+
readonly tools?: Partial<ToolCommands["Service"]>
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
const missingTool = (name: string) => () =>
|
|
214
220
|
Effect.fail(new Error(`gtd ${name}: ToolCommands has no test double wired into testLayers()`))
|
|
215
221
|
|
|
216
|
-
const missingToolCommands: ToolCommands["
|
|
222
|
+
const missingToolCommands: ToolCommands["Service"] = {
|
|
217
223
|
ui: missingTool("ui"),
|
|
218
224
|
lsp: missingTool("lsp"),
|
|
219
225
|
judge: () =>
|
|
@@ -264,6 +270,7 @@ export function testLayers(
|
|
|
264
270
|
}),
|
|
265
271
|
ConfigService.Live,
|
|
266
272
|
makeInMemoryConfigDiscovery(repo, root),
|
|
273
|
+
Layer.succeed(EngineCore, { source: Effect.succeed(IN_MEMORY_ENGINE) }),
|
|
267
274
|
Narrator.layer(opts.narrate ?? (() => {}), opts.verbose ?? true),
|
|
268
275
|
// Drivers and tools are unit-tested in their own folders; a call reaching
|
|
269
276
|
// one of these is a test gap, not silence. A test opts into one via `tools`.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { runCli } from "../cli/index.js"
|
|
3
|
+
import type { PinMark } from "../wire/index.js"
|
|
4
|
+
import { makeCapturingCliIo } from "./cliIo.js"
|
|
5
|
+
import { applyEmittedScript } from "./EmittedScriptRecognizer.js"
|
|
6
|
+
import type { InMemRepo } from "./InMemRepo.js"
|
|
7
|
+
|
|
8
|
+
const gtd = async (repo: InMemRepo, ...args: string[]) => {
|
|
9
|
+
const { io, result } = makeCapturingCliIo(repo)
|
|
10
|
+
await Effect.runPromise(runCli(["node", "gtd.js", ...args], io))
|
|
11
|
+
return result()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const apply = (repo: InMemRepo, script: string): void => {
|
|
15
|
+
const outcome = applyEmittedScript(repo, new Map(), script)
|
|
16
|
+
if (!outcome.ok) throw new Error(outcome.error)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Takes the pending pin or unpin beat — `mark` alone when given — the way a
|
|
21
|
+
* driver does: its script, then its landing. Whether it took one.
|
|
22
|
+
*/
|
|
23
|
+
export const takePinBeat = async (repo: InMemRepo, mark?: PinMark): Promise<boolean> => {
|
|
24
|
+
const next = await gtd(repo, "next", "--json")
|
|
25
|
+
if (next.exitCode !== 0) return false
|
|
26
|
+
const beat = JSON.parse(next.stdout) as { readonly pin?: PinMark; readonly content: string }
|
|
27
|
+
if (beat.pin === undefined || (mark !== undefined && beat.pin !== mark)) return false
|
|
28
|
+
apply(repo, beat.content)
|
|
29
|
+
const land = await gtd(repo, "land", "--json")
|
|
30
|
+
if (land.exitCode !== 0) throw new Error(`gtd land exited ${land.exitCode}: ${land.stderr}`)
|
|
31
|
+
apply(repo, (JSON.parse(land.stdout) as { readonly script: string }).script)
|
|
32
|
+
return true
|
|
33
|
+
}
|
package/src/twins/cliIo.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { testLayers } from "./Layers.js"
|
|
|
4
4
|
|
|
5
5
|
export interface CapturedCliResult {
|
|
6
6
|
readonly stdout: string
|
|
7
|
+
/** stdout as written, for a binary artifact `stdout` would decode. */
|
|
8
|
+
readonly stdoutBytes: Uint8Array
|
|
7
9
|
readonly stderr: string
|
|
8
10
|
readonly exitCode: number
|
|
9
11
|
}
|
|
@@ -11,14 +13,14 @@ export interface CapturedCliResult {
|
|
|
11
13
|
export const makeCapturingCliIo = (
|
|
12
14
|
repo: InMemRepo,
|
|
13
15
|
env: Readonly<Record<string, string | undefined>> = {},
|
|
14
|
-
tools: Partial<ToolCommands["
|
|
16
|
+
tools: Partial<ToolCommands["Service"]> = {},
|
|
15
17
|
): { readonly io: CliIo; readonly result: () => CapturedCliResult } => {
|
|
16
|
-
let stdout =
|
|
18
|
+
let stdout: Buffer = Buffer.alloc(0)
|
|
17
19
|
let stderr = ""
|
|
18
20
|
let exitCode = 0
|
|
19
21
|
const io: CliIo = {
|
|
20
22
|
stdout: (chunk) => {
|
|
21
|
-
stdout
|
|
23
|
+
stdout = Buffer.concat([stdout, Buffer.from(chunk)])
|
|
22
24
|
},
|
|
23
25
|
stderr: (chunk) => {
|
|
24
26
|
stderr += chunk
|
|
@@ -32,5 +34,8 @@ export const makeCapturingCliIo = (
|
|
|
32
34
|
layers: (verbose) =>
|
|
33
35
|
testLayers(repo, { env, narrate: (line) => (stderr += line), verbose, tools }),
|
|
34
36
|
}
|
|
35
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
io,
|
|
39
|
+
result: () => ({ stdout: stdout.toString("utf8"), stdoutBytes: stdout, stderr, exitCode }),
|
|
40
|
+
}
|
|
36
41
|
}
|
package/src/twins/index.ts
CHANGED
|
@@ -11,3 +11,4 @@ export { applyFlowScript } from "./FlowScripts.js"
|
|
|
11
11
|
export { gitTiers, runGitServiceContract, runGitScriptContract, type GitTier } from "./GitTiers.js"
|
|
12
12
|
export { strictGitOperations } from "./StrictGitOperations.js"
|
|
13
13
|
export { fakeGitOperations } from "./FakeGitOperations.js"
|
|
14
|
+
export { takePinBeat } from "./PinBeats.js"
|
package/src/wire/BeatDocument.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { BeatKind, Demand, DemandSession } from "./Demand.js"
|
|
2
2
|
import type { BeatStatus, NextMatch, StatusChange } from "./BeatStatus.js"
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
Actor,
|
|
5
|
+
ApprovalEntry,
|
|
6
|
+
ModelCost,
|
|
7
|
+
PinMark,
|
|
8
|
+
StateMode,
|
|
9
|
+
StateName,
|
|
10
|
+
StepAccess,
|
|
11
|
+
} from "./types.js"
|
|
4
12
|
import { WIRE_SCHEMA } from "./constants.js"
|
|
5
13
|
|
|
6
14
|
/** `gtd next --json`'s `next` key — `null` when landing would commit nothing. */
|
|
@@ -27,20 +35,30 @@ const dispatchFieldsOf = (
|
|
|
27
35
|
readonly validate: string | undefined
|
|
28
36
|
readonly skills: readonly string[] | undefined
|
|
29
37
|
readonly access: StepAccess | undefined
|
|
38
|
+
readonly pin: PinMark | undefined
|
|
30
39
|
} => {
|
|
40
|
+
const none = {
|
|
41
|
+
session: undefined,
|
|
42
|
+
validate: undefined,
|
|
43
|
+
skills: undefined,
|
|
44
|
+
access: undefined,
|
|
45
|
+
pin: undefined,
|
|
46
|
+
}
|
|
31
47
|
switch (demand.kind) {
|
|
32
48
|
case "prompt":
|
|
33
49
|
return {
|
|
50
|
+
...none,
|
|
34
51
|
session: demand.session,
|
|
35
52
|
validate: demand.validate,
|
|
36
53
|
skills: demand.skills,
|
|
37
54
|
access: demand.access,
|
|
38
55
|
}
|
|
56
|
+
case "script":
|
|
57
|
+
return { ...none, pin: demand.pin }
|
|
39
58
|
case "capture":
|
|
40
59
|
case "message":
|
|
41
|
-
case "script":
|
|
42
60
|
case "stalled":
|
|
43
|
-
return
|
|
61
|
+
return none
|
|
44
62
|
default:
|
|
45
63
|
return assertNeverDemand(demand)
|
|
46
64
|
}
|
|
@@ -57,6 +75,8 @@ export interface BeatDocument {
|
|
|
57
75
|
readonly model: string | undefined
|
|
58
76
|
readonly system: string | undefined
|
|
59
77
|
readonly validate: string | undefined
|
|
78
|
+
/** On a pin or unpin beat only: what its script does to `.gtd/process.gtdpin`. */
|
|
79
|
+
readonly pin: PinMark | undefined
|
|
60
80
|
readonly log: string
|
|
61
81
|
readonly state: StateName
|
|
62
82
|
readonly actor: Actor
|
|
@@ -76,13 +96,13 @@ export interface BeatDocument {
|
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
/**
|
|
79
|
-
* Flatten a `Demand` plus its `BeatStatus` into the single
|
|
99
|
+
* Flatten a `Demand` plus its `BeatStatus` into the single 26-key document
|
|
80
100
|
* `gtd next --json` emits — the ONLY place the two are joined. `cost`/
|
|
81
101
|
* `costByModel` are omitted together, exactly when no cost was recorded
|
|
82
102
|
* (`cost <= 0`).
|
|
83
103
|
*/
|
|
84
104
|
export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument => {
|
|
85
|
-
const { session, validate, skills, access } = dispatchFieldsOf(demand)
|
|
105
|
+
const { session, validate, skills, access, pin } = dispatchFieldsOf(demand)
|
|
86
106
|
const hasCost = status.cost > 0
|
|
87
107
|
return {
|
|
88
108
|
schema: WIRE_SCHEMA,
|
|
@@ -94,6 +114,7 @@ export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument =
|
|
|
94
114
|
model: status.model,
|
|
95
115
|
system: status.system,
|
|
96
116
|
validate,
|
|
117
|
+
pin,
|
|
97
118
|
log: status.log,
|
|
98
119
|
state: status.state,
|
|
99
120
|
actor: status.actor,
|
package/src/wire/Demand.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Actor,
|
|
3
|
+
ContentKind,
|
|
4
|
+
PinMark,
|
|
5
|
+
RenderedDemandSource,
|
|
6
|
+
StateName,
|
|
7
|
+
StepAccess,
|
|
8
|
+
} from "./types.js"
|
|
2
9
|
|
|
3
10
|
/** The whole beat vocabulary a driver acts on — what to DO with a rest, not merely whether dispatching it is safe. */
|
|
4
11
|
export type BeatKind = "capture" | "message" | "script" | "prompt" | "stalled"
|
|
@@ -72,7 +79,7 @@ export interface DemandSession {
|
|
|
72
79
|
export type Demand =
|
|
73
80
|
| { readonly kind: "capture"; readonly content: string }
|
|
74
81
|
| { readonly kind: "message"; readonly content: string }
|
|
75
|
-
| { readonly kind: "script"; readonly content: string }
|
|
82
|
+
| { readonly kind: "script"; readonly content: string; readonly pin?: PinMark }
|
|
76
83
|
| { readonly kind: "stalled"; readonly content: string }
|
|
77
84
|
| {
|
|
78
85
|
readonly kind: "prompt"
|
|
@@ -91,7 +98,10 @@ export type Demand =
|
|
|
91
98
|
* any other kind.
|
|
92
99
|
*/
|
|
93
100
|
export const demandOf = (input: {
|
|
94
|
-
readonly rendered: Pick<
|
|
101
|
+
readonly rendered: Pick<
|
|
102
|
+
RenderedDemandSource,
|
|
103
|
+
"actor" | "content" | "state" | "skills" | "access" | "pin"
|
|
104
|
+
>
|
|
95
105
|
readonly kind: BeatKind
|
|
96
106
|
readonly session?: DemandSession
|
|
97
107
|
readonly validate?: string
|
|
@@ -102,7 +112,7 @@ export const demandOf = (input: {
|
|
|
102
112
|
const skills =
|
|
103
113
|
rendered.skills !== undefined && rendered.skills.length > 0 ? rendered.skills : undefined
|
|
104
114
|
const access = rendered.access ?? { read: null, write: null }
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
if (kind === "prompt") return { kind, content, session, validate, skills, access }
|
|
116
|
+
if (kind === "script" && rendered.pin !== undefined) return { kind, content, pin: rendered.pin }
|
|
117
|
+
return { kind, content }
|
|
108
118
|
}
|
package/src/wire/constants.ts
CHANGED
|
@@ -20,3 +20,6 @@ export const renderFormat = (fmt: string, ...args: readonly string[]): string =>
|
|
|
20
20
|
/** The first line of the landing refusal for a turn that wrote outside its `write` access — a stable marker drivers and tests grep for. */
|
|
21
21
|
export const ACCESS_REFUSAL =
|
|
22
22
|
"gtd land: this turn wrote outside its write access — revert these paths, then run `gtd land` again:"
|
|
23
|
+
|
|
24
|
+
/** Where a process keeps its pin: the engine and workflow it runs on, as `gtd bundle` prints them. */
|
|
25
|
+
export const PIN_PATH = ".gtd/process.gtdpin"
|
package/src/wire/types.ts
CHANGED
|
@@ -10,6 +10,9 @@ export type StateName = string
|
|
|
10
10
|
/** The three content kinds a state can carry — exactly one per state. */
|
|
11
11
|
export type ContentKind = "script" | "prompt" | "message"
|
|
12
12
|
|
|
13
|
+
/** What a pin or unpin beat does to the pin; a driver may do it its own way instead of running the script. */
|
|
14
|
+
export type PinMark = "add" | "remove"
|
|
15
|
+
|
|
13
16
|
/** The name of a steering-file mode. Not a closed vocabulary: the valid set derives from the active definition (`BUILT_IN_MODES` plus whatever `modes:` declares). */
|
|
14
17
|
export type StateMode = string
|
|
15
18
|
|
|
@@ -51,4 +54,5 @@ export interface RenderedDemandSource {
|
|
|
51
54
|
readonly judge?: string
|
|
52
55
|
readonly skills?: readonly string[]
|
|
53
56
|
readonly access?: StepAccess
|
|
57
|
+
readonly pin?: PinMark
|
|
54
58
|
}
|
|
@@ -2,15 +2,14 @@ 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 { NodeContext } from "@effect/platform-node"
|
|
6
5
|
import { Effect, Layer } from "effect"
|
|
7
6
|
import { afterEach, describe, expect, it } from "vitest"
|
|
8
|
-
import { GitService, Host, Narrator, Workspace } from "../platform/index.js"
|
|
7
|
+
import { GitService, Host, Narrator, Workspace, NodeServices } from "../platform/index.js"
|
|
9
8
|
import { InMemRepo, testLayers } from "../twins/index.js"
|
|
10
9
|
import { ConfigDiscovery, ConfigService } from "./index.js"
|
|
11
10
|
|
|
12
11
|
// A config and the local modules it imports load the same from disk, where
|
|
13
|
-
//
|
|
12
|
+
// rolldown resolves them, and from an in-memory discovery, where the loader
|
|
14
13
|
// resolves them itself — the same workflows, the same fingerprint, the same
|
|
15
14
|
// failure for a module that is not there.
|
|
16
15
|
type Files = Readonly<Record<string, string>>
|
|
@@ -63,7 +62,7 @@ const live = (): Tier => {
|
|
|
63
62
|
writeFileSync(join(root, path), content)
|
|
64
63
|
}
|
|
65
64
|
const host = Host.layer({ root, home: root, env: {} })
|
|
66
|
-
const git = GitService.Live.pipe(Layer.provide(Layer.merge(host,
|
|
65
|
+
const git = GitService.Live.pipe(Layer.provide(Layer.merge(host, NodeServices.layer)))
|
|
67
66
|
const workspace = Workspace.Live.pipe(Layer.provide(Layer.merge(host, git)))
|
|
68
67
|
return loadWith(
|
|
69
68
|
Layer.mergeAll(
|
|
@@ -121,7 +120,24 @@ for (const make of [live, inMemory]) {
|
|
|
121
120
|
})
|
|
122
121
|
})
|
|
123
122
|
|
|
124
|
-
it("
|
|
123
|
+
it("imports a local module and a gtd subpath dynamically as the flow runs", async () => {
|
|
124
|
+
t = make()
|
|
125
|
+
const loaded = await t.load({
|
|
126
|
+
"gtd.config.ts": [
|
|
127
|
+
'import { human } from "@pmelab/gtd/flows"',
|
|
128
|
+
"export default async () => {",
|
|
129
|
+
' const { WIRE_SCHEMA } = await import("@pmelab/gtd/wire")',
|
|
130
|
+
' const { first } = await import("./first.js")',
|
|
131
|
+
" await human(`${first}-${WIRE_SCHEMA}`)",
|
|
132
|
+
"}",
|
|
133
|
+
"",
|
|
134
|
+
].join("\n"),
|
|
135
|
+
"first.ts": 'export const first = "idle"\n',
|
|
136
|
+
})
|
|
137
|
+
expect(Object.keys(loaded.fingerprint).sort()).toEqual(["first.ts", "gtd.config.ts"])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("leaves a specifier in a string alone, and runs a skipped require never", async () => {
|
|
125
141
|
t = make()
|
|
126
142
|
const loaded = await t.load({
|
|
127
143
|
"gtd.config.ts": config(
|
|
@@ -134,14 +150,15 @@ for (const make of [live, inMemory]) {
|
|
|
134
150
|
"optional.ts": 'throw new Error("optional ran")\n',
|
|
135
151
|
})
|
|
136
152
|
expect(loaded.names).toEqual(["named"])
|
|
137
|
-
|
|
153
|
+
// Bundled, so watched, though it never runs.
|
|
154
|
+
expect(Object.keys(loaded.fingerprint).sort()).toEqual(["gtd.config.ts", "optional.ts"])
|
|
138
155
|
})
|
|
139
156
|
|
|
140
157
|
it("fails on an import that is not there, naming it", async () => {
|
|
141
158
|
t = make()
|
|
142
159
|
await expect(
|
|
143
160
|
t.load({ "gtd.config.ts": config('import { brief } from "./missing.js"') }),
|
|
144
|
-
).rejects.toThrow(/
|
|
161
|
+
).rejects.toThrow(/Could not resolve '\.\/missing\.js'/)
|
|
145
162
|
})
|
|
146
163
|
})
|
|
147
164
|
}
|
|
@@ -110,18 +110,17 @@ const workflowModule = (
|
|
|
110
110
|
})
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* The discovery seam — a `Context.
|
|
113
|
+
* The discovery seam — a `Context.Service`, not part of `WorkflowFiles` (that
|
|
114
114
|
* port is a plain sync record for `compileWorkflow`'s own content-file-ref
|
|
115
115
|
* reads; discovery is effectful and environment-dependent: `.Live` reads
|
|
116
116
|
* REAL disk, so an `@inmem` scenario's fake
|
|
117
117
|
* `Workspace` — a pure in-memory `Map`, no real files anywhere on disk —
|
|
118
118
|
* cannot back it. `src/twins/Layers.ts` provides the in-memory
|
|
119
119
|
* counterpart instead, mirroring how `Workspace`/`Host`/`GitService` already
|
|
120
|
-
* each have a production and a fake layer behind one
|
|
120
|
+
* each have a production and a fake layer behind one service.
|
|
121
121
|
*/
|
|
122
|
-
export class ConfigDiscovery extends Context.
|
|
123
|
-
ConfigDiscovery,
|
|
124
|
-
|
|
125
|
-
>() {
|
|
122
|
+
export class ConfigDiscovery extends Context.Service<ConfigDiscovery, ConfigDiscoveryOps>()(
|
|
123
|
+
"ConfigDiscovery",
|
|
124
|
+
) {
|
|
126
125
|
static Live = Layer.succeed(ConfigDiscovery, { legacyRcFiles, workflowModule })
|
|
127
126
|
}
|
package/src/workflow/index.ts
CHANGED