@pmelab/gtd 62.1.0 → 64.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -3
- package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
- package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
- package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
- package/dist/chunks/{testkit-CPzWnl3P.mjs → testkit-zGjNeQNL.mjs} +1144 -345
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +4023 -1326
- package/dist/gtd.core.mjs +120 -119
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +2 -2
- package/dist/workflow-kit.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +5 -8
- package/src/cli/Cli.ts +10 -28
- package/src/cli/Output.ts +2 -1
- package/src/cli/Program.test.ts +55 -53
- package/src/cli/Program.ts +36 -45
- package/src/cli/ToolCommands.ts +3 -4
- package/src/edge/Edge.test.ts +38 -12
- package/src/edge/Edge.ts +98 -13
- package/src/edge/index.ts +1 -0
- package/src/emit/SteeringMode.test.ts +4 -2
- package/src/emit/SteeringMode.ts +10 -9
- package/src/gherkin/messages.test.ts +1 -1
- package/src/gherkin/module-config.feature +1 -1
- package/src/gherkin/testkit.feature +2 -2
- package/src/install/Version.ts +14 -13
- package/src/platform/Host.ts +75 -22
- package/src/platform/NodePinFile.ts +44 -0
- package/src/platform/NodeRuntime.ts +19 -0
- package/src/platform/NodeSync.ts +64 -0
- package/src/platform/PinFile.ts +0 -50
- package/src/platform/Sha.ts +121 -0
- package/src/platform/SyncIo.ts +35 -0
- package/src/platform/Workspace.test.ts +8 -3
- package/src/platform/Workspace.ts +82 -123
- package/src/platform/WorktreeState.test.ts +8 -4
- package/src/platform/WorktreeState.ts +21 -19
- package/src/platform/index.ts +18 -5
- package/src/replay/Format.test.ts +32 -10
- package/src/replay/Format.ts +5 -2
- package/src/replay/Replay.test.ts +110 -3
- package/src/replay/Replay.ts +120 -28
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +32 -1
- package/src/replay/index.ts +2 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/testkit/TestRepo.test.ts +1 -1
- package/src/testkit/TestRepo.ts +4 -18
- package/src/twins/Host.test.ts +151 -0
- package/src/twins/Layers.test.ts +2 -2
- package/src/twins/Layers.ts +44 -18
- package/src/twins/Path.test.ts +52 -0
- package/src/twins/SyncIo.test.ts +153 -0
- package/src/twins/SyncIo.ts +159 -0
- package/src/twins/cliIo.ts +8 -1
- package/src/twins/index.ts +5 -0
- package/src/workflow/discovery.test.ts +6 -5
- package/src/workflow/discovery.ts +43 -32
- package/src/workflow/fingerprint.test.ts +23 -3
- package/src/workflow/fingerprint.ts +9 -11
- package/src/workflow/load.test.ts +16 -6
- package/src/workflow/load.ts +107 -69
- package/src/workflow/pin.test.ts +3 -2
- package/src/workflow/pin.ts +17 -11
package/src/edge/Edge.test.ts
CHANGED
|
@@ -185,7 +185,7 @@ describe("currentRun", () => {
|
|
|
185
185
|
expect(run.episode.base).toBe(boundary)
|
|
186
186
|
expect(run.episode.commits.map((c) => c.message.split("\n")[0])).toEqual([
|
|
187
187
|
"gtd(human): idle → building",
|
|
188
|
-
"gtd(check): pin → building",
|
|
188
|
+
"gtd(check): gtd:pin → building",
|
|
189
189
|
"gtd(agent): building → checking",
|
|
190
190
|
])
|
|
191
191
|
})
|
|
@@ -205,7 +205,7 @@ describe("currentRun", () => {
|
|
|
205
205
|
await land(repo, { "a.txt": "a\n" })
|
|
206
206
|
await land(repo, { "b.txt": "b\n" })
|
|
207
207
|
const closing = await land(repo, { "c.txt": "c\n" })
|
|
208
|
-
expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
|
|
208
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:unpin → idle")
|
|
209
209
|
const next = await land(repo, { "d.txt": "d\n" })
|
|
210
210
|
const run = await provide(currentRun, repo)
|
|
211
211
|
expect(run.startParentHash).toBe(closing)
|
|
@@ -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-Format:
|
|
220
|
+
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 4`,
|
|
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-Format:
|
|
253
|
+
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 4`,
|
|
254
254
|
)
|
|
255
255
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
256
256
|
const run = await provide(currentRun, repo)
|
|
@@ -264,6 +264,29 @@ export const again = workflow(async () => {
|
|
|
264
264
|
])
|
|
265
265
|
})
|
|
266
266
|
|
|
267
|
+
it("a format 3 pin commit naming the default's initial step bounds nothing either", async () => {
|
|
268
|
+
const repo = repoWith(`import { agent, human, workflow } from "@pmelab/gtd/flows"
|
|
269
|
+
|
|
270
|
+
export default async () => {
|
|
271
|
+
await human("idle", { message: "idle-message" })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const again = workflow(async () => {
|
|
275
|
+
await human("idle", { message: "idle-message" })
|
|
276
|
+
await agent("fixing", "fix-prompt")
|
|
277
|
+
})
|
|
278
|
+
`)
|
|
279
|
+
const before = headOf(repo)
|
|
280
|
+
repo.seedCommit("gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 3")
|
|
281
|
+
const opening = headOf(repo)
|
|
282
|
+
repo.writeFile(".gtd/process.gtdpin", "gtdpin 1 gtd in-memory\n")
|
|
283
|
+
repo.seedCommit("gtd(check): pin → idle\n\nGtd-Pin: add\nGtd-Format: 3")
|
|
284
|
+
const run = await provide(currentRun, repo)
|
|
285
|
+
expect(run.workflow).toBe("again")
|
|
286
|
+
expect(run.startHash).toBe(opening)
|
|
287
|
+
expect(run.startParentHash).toBe(before)
|
|
288
|
+
})
|
|
289
|
+
|
|
267
290
|
it("collects every process commit's Gtd-Cost, attributing a model-less one to UNATTRIBUTED_MODEL", async () => {
|
|
268
291
|
const repo = repoWith(LINEAR)
|
|
269
292
|
commit(
|
|
@@ -395,7 +418,7 @@ export const again = workflow(async () => {
|
|
|
395
418
|
it("is undefined at a pin commit, which is no turn", async () => {
|
|
396
419
|
const repo = repoWith(LINEAR)
|
|
397
420
|
await land(repo, { "a.txt": "a\n" })
|
|
398
|
-
expect(repo.lastCommitSubject()).toBe("gtd(check): pin → building")
|
|
421
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:pin → building")
|
|
399
422
|
expect((await provide(currentRun, repo)).headTurn).toBeUndefined()
|
|
400
423
|
})
|
|
401
424
|
|
|
@@ -403,7 +426,7 @@ export const again = workflow(async () => {
|
|
|
403
426
|
const repo = repoWith(LINEAR)
|
|
404
427
|
await land(repo, { "a.txt": "a\n" })
|
|
405
428
|
await land(repo)
|
|
406
|
-
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format:
|
|
429
|
+
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 4")
|
|
407
430
|
const run = await provide(currentRun, repo)
|
|
408
431
|
expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
|
|
409
432
|
})
|
|
@@ -544,7 +567,7 @@ describe("summaryRun", () => {
|
|
|
544
567
|
expect(summary.trace).toEqual([
|
|
545
568
|
{ state: "building", hash: building, actor: "human" },
|
|
546
569
|
{ state: "checking", hash: checking, actor: "agent" },
|
|
547
|
-
{ state: "unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
|
|
570
|
+
{ state: "gtd:unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
|
|
548
571
|
{ state: "idle", hash: closing, actor: "check" },
|
|
549
572
|
])
|
|
550
573
|
})
|
|
@@ -831,7 +854,7 @@ export const review = workflow(async () => {
|
|
|
831
854
|
it("is the base the resting step names", async () => {
|
|
832
855
|
const repo = repoWith(LOOP)
|
|
833
856
|
const first = await land(repo, { "a.txt": "a\n" })
|
|
834
|
-
expect(repo.lastCommitSubject()).toBe("gtd(check): pin → checkpoint")
|
|
857
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:pin → checkpoint")
|
|
835
858
|
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
|
|
836
859
|
await land(repo, { "b.txt": "b\n" })
|
|
837
860
|
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
|
|
@@ -1306,7 +1329,7 @@ describe("finishing landings keep history", () => {
|
|
|
1306
1329
|
await land(repo, { "b.txt": "b\n" })
|
|
1307
1330
|
repo.writeFile("c.txt", "c\n")
|
|
1308
1331
|
const landing = await landingOf(repo)
|
|
1309
|
-
expect(landing.to).toBe("unpin")
|
|
1332
|
+
expect(landing.to).toBe("gtd:unpin")
|
|
1310
1333
|
expect(landing.keepHistory).toBeUndefined()
|
|
1311
1334
|
expect(landing.spec.version).toBeUndefined()
|
|
1312
1335
|
})
|
|
@@ -1522,13 +1545,16 @@ describe("historyTrail", () => {
|
|
|
1522
1545
|
tip,
|
|
1523
1546
|
])
|
|
1524
1547
|
expect(trail.steps[2]).toMatchObject({
|
|
1525
|
-
subject: "gtd(agent): checking → unpin",
|
|
1548
|
+
subject: "gtd(agent): checking → gtd:unpin",
|
|
1526
1549
|
actor: "agent",
|
|
1527
1550
|
from: "checking",
|
|
1528
|
-
to: "unpin",
|
|
1551
|
+
to: "gtd:unpin",
|
|
1529
1552
|
verdicts: [],
|
|
1530
1553
|
})
|
|
1531
|
-
expect(trail.steps[3]).toMatchObject({
|
|
1554
|
+
expect(trail.steps[3]).toMatchObject({
|
|
1555
|
+
subject: "gtd(check): gtd:unpin → idle",
|
|
1556
|
+
actor: "check",
|
|
1557
|
+
})
|
|
1532
1558
|
expect(trail.steps[0]).toMatchObject({ actor: "human", to: "building" })
|
|
1533
1559
|
})
|
|
1534
1560
|
|
package/src/edge/Edge.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Option } from "effect"
|
|
1
|
+
import { Effect, Option, Path } from "effect"
|
|
2
2
|
import {
|
|
3
3
|
Narrator,
|
|
4
4
|
GitService,
|
|
@@ -36,10 +36,13 @@ import {
|
|
|
36
36
|
parseCommitMessage,
|
|
37
37
|
replay,
|
|
38
38
|
type CommitMessage,
|
|
39
|
+
formatCommitMessage,
|
|
39
40
|
scopeRunStart,
|
|
41
|
+
setAsideBy,
|
|
40
42
|
treeFromRecord,
|
|
41
43
|
type EpisodeCommit,
|
|
42
44
|
type JudgeVerdict,
|
|
45
|
+
LEGACY_PIN_STATES,
|
|
43
46
|
type PinChange,
|
|
44
47
|
PIN_STATES,
|
|
45
48
|
type ReachedStep,
|
|
@@ -60,7 +63,6 @@ import {
|
|
|
60
63
|
|
|
61
64
|
import { GTD_VERSION } from "../install/index.js"
|
|
62
65
|
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
63
|
-
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
64
66
|
import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
|
|
65
67
|
|
|
66
68
|
export { UNATTRIBUTED_MODEL }
|
|
@@ -259,6 +261,18 @@ const approvalEntryOf = (
|
|
|
259
261
|
},
|
|
260
262
|
]
|
|
261
263
|
|
|
264
|
+
// What an anchor set aside answers no step: it records no mark, approval or
|
|
265
|
+
// verdict. An anchor naming no commit is replay's to refuse.
|
|
266
|
+
const setAsideIn = (history: History, location: EpisodeLocation): ReadonlySet<string> => {
|
|
267
|
+
const anchored = setAsideBy(
|
|
268
|
+
hashAt(history, location.baseIndex) ?? EMPTY_TREE,
|
|
269
|
+
history
|
|
270
|
+
.slice(location.baseIndex + 1)
|
|
271
|
+
.map((c) => ({ hash: c.hash, parsed: parseCommitMessage(c.message) })),
|
|
272
|
+
)
|
|
273
|
+
return "setAside" in anchored ? anchored.setAside : new Set()
|
|
274
|
+
}
|
|
275
|
+
|
|
262
276
|
const buildRun = (
|
|
263
277
|
history: History,
|
|
264
278
|
location: EpisodeLocation,
|
|
@@ -269,6 +283,9 @@ const buildRun = (
|
|
|
269
283
|
// A pin commit is no turn; the unpin commit stays, as the one that closes the process.
|
|
270
284
|
const processCommits = allProcessCommits.filter((_, i) => allParsed[i]!.pin !== "add")
|
|
271
285
|
const parsed = allParsed.filter((m) => m.pin !== "add")
|
|
286
|
+
const setAside = setAsideIn(history, location)
|
|
287
|
+
const answering = parsed.filter((_, i) => !setAside.has(processCommits[i]!.hash))
|
|
288
|
+
const answeringCommits = processCommits.filter((c) => !setAside.has(c.hash))
|
|
272
289
|
const first = parsed[0]
|
|
273
290
|
const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
|
|
274
291
|
const head = history[history.length - 1]
|
|
@@ -280,10 +297,10 @@ const buildRun = (
|
|
|
280
297
|
diffBase: first?.reviewBase ?? startParentHash,
|
|
281
298
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
282
299
|
costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
|
|
283
|
-
judgeVerdicts:
|
|
300
|
+
judgeVerdicts: answering.flatMap((m) => m.judge),
|
|
284
301
|
pinCommitted: allParsed.some((m) => m.pin === "add"),
|
|
285
|
-
marks:
|
|
286
|
-
approvals:
|
|
302
|
+
marks: answering.flatMap((m) => m.marks),
|
|
303
|
+
approvals: answeringCommits.flatMap((c, i) => approvalEntryOf(c.hash, answering[i]!)),
|
|
287
304
|
pinnedVars: { ...first?.vars },
|
|
288
305
|
headTurn: headTurnOf(head),
|
|
289
306
|
closingHash,
|
|
@@ -320,8 +337,14 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
|
|
|
320
337
|
const parsed = parseCommitMessage(subject).parsed
|
|
321
338
|
if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
|
|
322
339
|
if (parsed.actor === "human" && parsed.from === undefined) return false
|
|
323
|
-
// A pin commit's subject can name the first step; it bounds nothing.
|
|
324
|
-
|
|
340
|
+
// A pin commit's subject can name the first step; it bounds nothing. The
|
|
341
|
+
// subject alone carries no format, so either spelling counts.
|
|
342
|
+
if (
|
|
343
|
+
parsed.actor === "check" &&
|
|
344
|
+
(parsed.from === PIN_STATES.add || parsed.from === LEGACY_PIN_STATES.add)
|
|
345
|
+
) {
|
|
346
|
+
return false
|
|
347
|
+
}
|
|
325
348
|
return parsed.to === def.initial
|
|
326
349
|
}
|
|
327
350
|
|
|
@@ -404,7 +427,14 @@ const computeProcessRun = (
|
|
|
404
427
|
runOf(history, locateEpisode(def, history)),
|
|
405
428
|
)
|
|
406
429
|
|
|
407
|
-
type ConfigRequirements =
|
|
430
|
+
type ConfigRequirements =
|
|
431
|
+
| GitService
|
|
432
|
+
| ConfigService
|
|
433
|
+
| ConfigDiscovery
|
|
434
|
+
| Narrator
|
|
435
|
+
| Workspace
|
|
436
|
+
| Host
|
|
437
|
+
| Path.Path
|
|
408
438
|
|
|
409
439
|
/** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
|
|
410
440
|
export const currentRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
|
|
@@ -1012,6 +1042,54 @@ export const restAt = (
|
|
|
1012
1042
|
|
|
1013
1043
|
export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
|
|
1014
1044
|
|
|
1045
|
+
/**
|
|
1046
|
+
* Where `gtd repin` anchors a pinned process the live workflow no longer
|
|
1047
|
+
* replays: the commit replay last stood on, and the step the live workflow
|
|
1048
|
+
* reaches from there on HEAD's tree — the unpin when the flow ends instead.
|
|
1049
|
+
* `undefined` when there is nothing to anchor: no divergence, or no pin.
|
|
1050
|
+
*/
|
|
1051
|
+
export const repinAnchor: Effect.Effect<
|
|
1052
|
+
{ readonly at: string; readonly to: string } | undefined,
|
|
1053
|
+
Error,
|
|
1054
|
+
RestRequirements
|
|
1055
|
+
> = Effect.gen(function* () {
|
|
1056
|
+
const git = yield* GitService
|
|
1057
|
+
const config = yield* (yield* ConfigService).load
|
|
1058
|
+
const workspace = yield* Workspace
|
|
1059
|
+
const run = yield* computeProcessRun(git, config.workflow)
|
|
1060
|
+
const { def, setup } = yield* setupFor(config, run, workspace, yield* Host)
|
|
1061
|
+
const diverged = yield* Effect.promise(() => replayFor(setup))
|
|
1062
|
+
if (diverged.kind !== "divergence" || !headHasPin(workspace, run)) return undefined
|
|
1063
|
+
// A trial anchor naming the reserved pin state diverges at whatever step the
|
|
1064
|
+
// flow reaches once it stands on HEAD's tree.
|
|
1065
|
+
const trial: EpisodeCommit = {
|
|
1066
|
+
hash: "",
|
|
1067
|
+
message: formatCommitMessage({
|
|
1068
|
+
actor: "check",
|
|
1069
|
+
from: PIN_STATES.add,
|
|
1070
|
+
to: PIN_STATES.add,
|
|
1071
|
+
pin: "add",
|
|
1072
|
+
anchor: diverged.at,
|
|
1073
|
+
}),
|
|
1074
|
+
tree: setup.episode.at(-1)?.tree ?? setup.base.tree,
|
|
1075
|
+
}
|
|
1076
|
+
const outcome = yield* Effect.promise(() =>
|
|
1077
|
+
replayFor({ ...setup, episode: [...setup.episode, trial] }),
|
|
1078
|
+
)
|
|
1079
|
+
if (outcome.kind !== "divergence") {
|
|
1080
|
+
return yield* Effect.fail(replayError(outcome) ?? new Error("gtd repin: replay found no step"))
|
|
1081
|
+
}
|
|
1082
|
+
const to = outcome.reached ?? PIN_STATES.remove
|
|
1083
|
+
if (run.workflow === undefined && to === def.initial) {
|
|
1084
|
+
return yield* Effect.fail(
|
|
1085
|
+
new Error(
|
|
1086
|
+
`gtd repin: the live workflow continues this process at "${to}", the default workflow's first step, where a finished process waits — run \`gtd abandon\` to start over`,
|
|
1087
|
+
),
|
|
1088
|
+
)
|
|
1089
|
+
}
|
|
1090
|
+
return { at: diverged.at, to }
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1015
1093
|
/** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
|
|
1016
1094
|
const restingOn = (outcome: ReplayOutcome, pin: PinChange | undefined) => {
|
|
1017
1095
|
if (outcome.kind === "rest") return { reached: outcome, step: outcome.rest }
|
|
@@ -1499,10 +1577,16 @@ export interface HistoryTrail {
|
|
|
1499
1577
|
* repository cannot be read at a ref, so it is left out. A file only the old
|
|
1500
1578
|
* config read needs no check: whichever file imported it differs too.
|
|
1501
1579
|
*/
|
|
1502
|
-
const inRepoConfigPaths = (
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1580
|
+
const inRepoConfigPaths = (
|
|
1581
|
+
path: Path.Path,
|
|
1582
|
+
root: string,
|
|
1583
|
+
files: readonly string[],
|
|
1584
|
+
): readonly string[] => {
|
|
1585
|
+
const found = new Set<string>()
|
|
1586
|
+
for (const file of files) {
|
|
1587
|
+
found.add(path.relative(root, path.resolve(root, file)).split(path.sep).join("/"))
|
|
1588
|
+
}
|
|
1589
|
+
return [...found].filter((p) => p !== "" && !p.startsWith("..") && !path.isAbsolute(p)).sort()
|
|
1506
1590
|
}
|
|
1507
1591
|
|
|
1508
1592
|
const configRefusal = (
|
|
@@ -1524,7 +1608,8 @@ const differingConfigPaths = (
|
|
|
1524
1608
|
) =>
|
|
1525
1609
|
Effect.gen(function* () {
|
|
1526
1610
|
const differing: string[] = []
|
|
1527
|
-
|
|
1611
|
+
const configPaths = inRepoConfigPaths(yield* Path.Path, root, Object.keys(config.fingerprint))
|
|
1612
|
+
for (const path of configPaths) {
|
|
1528
1613
|
const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
|
|
1529
1614
|
if (atTip !== workspace.readSync(path)) differing.push(path)
|
|
1530
1615
|
}
|
package/src/edge/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { Effect } from "effect"
|
|
2
|
+
import { Effect, Layer, Path } from "effect"
|
|
3
3
|
import { resolveMode, validateScriptFor, type ModeResolution } from "./SteeringMode.js"
|
|
4
4
|
import { Host } from "../platform/index.js"
|
|
5
5
|
import { steeringFormatFor } from "../steering/index.js"
|
|
@@ -16,7 +16,9 @@ const envVarsLayer = Host.layer({ root: "/repo", home: "/repo", env: { PATH: "/u
|
|
|
16
16
|
|
|
17
17
|
const runScript = (resolved: ModeResolution, file: string) => {
|
|
18
18
|
if (resolved.kind !== "resolved") throw new Error("expected a resolved mode")
|
|
19
|
-
return Effect.runPromise(
|
|
19
|
+
return Effect.runPromise(
|
|
20
|
+
validateScriptFor(resolved, file).pipe(Effect.provide(Layer.merge(envVarsLayer, Path.layer))),
|
|
21
|
+
)
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
describe("resolveMode", () => {
|
package/src/emit/SteeringMode.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { Host } from "../platform/index.js"
|
|
1
|
+
import { Effect, Path } from "effect"
|
|
2
|
+
import { Host, type HostOps } from "../platform/index.js"
|
|
4
3
|
import {
|
|
5
4
|
isSeededValidateCommand,
|
|
6
5
|
knownModes,
|
|
@@ -191,8 +190,11 @@ const modeCommandSteps = (resolved: ResolvedMode, file: string): readonly EmitSt
|
|
|
191
190
|
}
|
|
192
191
|
|
|
193
192
|
/** `<Host.scratchDir>/gtd-mode-sample-<mode>-<pid>.md` — an absolute literal baked in at emit time, never a shell variable. `<pid>` avoids collisions between concurrent `gtd` processes. Never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
|
|
194
|
-
const scratchSamplePath = (
|
|
195
|
-
|
|
193
|
+
const scratchSamplePath = (
|
|
194
|
+
path: Path.Path,
|
|
195
|
+
{ scratchDir, pid }: HostOps,
|
|
196
|
+
mode: StateMode,
|
|
197
|
+
): string => path.join(scratchDir, `gtd-mode-sample-${mode}-${pid}.md`)
|
|
196
198
|
|
|
197
199
|
/**
|
|
198
200
|
* The contradiction round-trip/skip-notice steps for `resolved`'s `mode:`.
|
|
@@ -204,13 +206,12 @@ const scratchSamplePath = (scratchDir: string, mode: StateMode): string =>
|
|
|
204
206
|
*/
|
|
205
207
|
const modeContradictionSteps = (
|
|
206
208
|
resolved: ResolvedMode,
|
|
207
|
-
): Effect.Effect<readonly EmitStep[], Error, Host> =>
|
|
209
|
+
): Effect.Effect<readonly EmitStep[], Error, Host | Path.Path> =>
|
|
208
210
|
Effect.gen(function* () {
|
|
209
211
|
if (resolved.formatCommand === undefined) return []
|
|
210
212
|
const { capabilities } = resolved
|
|
211
213
|
if (capabilities.format !== undefined && capabilities.liveValidate !== undefined) {
|
|
212
|
-
const
|
|
213
|
-
const samplePath = scratchSamplePath(scratchDir, resolved.mode)
|
|
214
|
+
const samplePath = scratchSamplePath(yield* Path.Path, yield* Host, resolved.mode)
|
|
214
215
|
const formatCommand = withFileVar(resolved.formatCommand, samplePath)
|
|
215
216
|
return [
|
|
216
217
|
{
|
|
@@ -245,7 +246,7 @@ export interface ValidateScript {
|
|
|
245
246
|
export const validateScriptFor = (
|
|
246
247
|
resolved: ResolvedMode,
|
|
247
248
|
file: string,
|
|
248
|
-
): Effect.Effect<ValidateScript, Error, Host> =>
|
|
249
|
+
): Effect.Effect<ValidateScript, Error, Host | Path.Path> =>
|
|
249
250
|
Effect.gen(function* () {
|
|
250
251
|
const steps: EmitStep[] = [
|
|
251
252
|
...(yield* modeContradictionSteps(resolved)),
|
|
@@ -45,7 +45,7 @@ describe("restDescription", () => {
|
|
|
45
45
|
" last commits:",
|
|
46
46
|
" chore: initial commit",
|
|
47
47
|
" gtd(agent): plan → sign-off",
|
|
48
|
-
" gtd(check): pin → sign-off",
|
|
48
|
+
" gtd(check): gtd:pin → sign-off",
|
|
49
49
|
' actors never used: "repair" (scenario), "ready" (Background)',
|
|
50
50
|
].join("\n"),
|
|
51
51
|
)
|
|
@@ -43,7 +43,7 @@ Feature: testkit drives any workflow against the real engine
|
|
|
43
43
|
And the commit subjects end with:
|
|
44
44
|
"""
|
|
45
45
|
gtd(human): plan
|
|
46
|
-
gtd(check): pin → plan
|
|
46
|
+
gtd(check): gtd:pin → plan
|
|
47
47
|
gtd(agent): plan → check
|
|
48
48
|
gtd(check): check → ready
|
|
49
49
|
gtd(judge): ready → ship
|
|
@@ -107,7 +107,7 @@ Feature: testkit drives any workflow against the real engine
|
|
|
107
107
|
When gtd starts workflow "tiny"
|
|
108
108
|
And the process is driven
|
|
109
109
|
Then the process rests at "plan"
|
|
110
|
-
And the last commit subject is "gtd(check): pin → plan"
|
|
110
|
+
And the last commit subject is "gtd(check): gtd:pin → plan"
|
|
111
111
|
|
|
112
112
|
Scenario: a prompt asserted beside a script is checked at every visit, before the step acts
|
|
113
113
|
Given step "check" is prompted with "npm test"
|
package/src/install/Version.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createRequire } from "node:module"
|
|
3
|
-
import { dirname, join } from "node:path"
|
|
4
|
-
import { fileURLToPath } from "node:url"
|
|
5
|
-
|
|
6
|
-
const _require = createRequire(import.meta.url)
|
|
1
|
+
import { NodeRuntime, nodeSyncIo } from "../platform/index.js"
|
|
7
2
|
|
|
8
3
|
// A source-relative literal (`../../package.json`) breaks the built single-file
|
|
9
4
|
// bundle: bundling doesn't rewrite `import.meta.url` per original module, so a
|
|
@@ -11,13 +6,19 @@ const _require = createRequire(import.meta.url)
|
|
|
11
6
|
// dist/ vs src/ — they differ (dist/gtd.bundle.mjs is always one level below
|
|
12
7
|
// repo root; src/install/Version.ts is two). Walking up from wherever this module
|
|
13
8
|
// actually runs from resolves correctly in both the unbundled (vitest/tsc) and
|
|
14
|
-
// bundled (dist/gtd.bundle.mjs) case.
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
// bundled (dist/gtd.bundle.mjs) case. URLs, not Path: this runs at import,
|
|
10
|
+
// before any Effect service exists.
|
|
11
|
+
const readPackageJson = (fromUrl: string): string => {
|
|
12
|
+
let dir = new URL(".", fromUrl)
|
|
17
13
|
for (let i = 0; i < 5; i++) {
|
|
18
|
-
const candidate =
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
const candidate = new URL("package.json", dir)
|
|
15
|
+
try {
|
|
16
|
+
const source = nodeSyncIo.readText(NodeRuntime.fileURLToPath(candidate))
|
|
17
|
+
if (source !== undefined) return source
|
|
18
|
+
} catch {
|
|
19
|
+
// Unreadable counts as absent, as it did for `existsSync`.
|
|
20
|
+
}
|
|
21
|
+
dir = new URL("..", dir)
|
|
21
22
|
}
|
|
22
23
|
throw new Error(`package.json not found by walking up from ${fromUrl}`)
|
|
23
24
|
}
|
|
@@ -30,4 +31,4 @@ export const PIN_KEY = Symbol.for("@pmelab/gtd/pin")
|
|
|
30
31
|
const pinned = (globalThis as { [PIN_KEY]?: { readonly version: string } })[PIN_KEY]?.version
|
|
31
32
|
|
|
32
33
|
export const GTD_VERSION: string =
|
|
33
|
-
pinned ?? (
|
|
34
|
+
pinned ?? (JSON.parse(readPackageJson(import.meta.url)) as { version: string }).version
|
package/src/platform/Host.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { realpathSync } from "node:fs"
|
|
2
|
-
import { homedir, tmpdir } from "node:os"
|
|
3
1
|
import { Context, Effect, Layer } from "effect"
|
|
2
|
+
import { homedir, tmpdir } from "./NodeRuntime.js"
|
|
3
|
+
import { nodeSyncIo } from "./NodeSync.js"
|
|
4
4
|
|
|
5
5
|
const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
|
|
6
6
|
|
|
@@ -8,35 +8,88 @@ export interface HostOps {
|
|
|
8
8
|
readonly root: string
|
|
9
9
|
readonly home: string
|
|
10
10
|
readonly env: Readonly<Record<string, string | undefined>>
|
|
11
|
-
/** `env["TMPDIR"]` when set,
|
|
11
|
+
/** `env["TMPDIR"]` when set, the host's temp dir otherwise — never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
|
|
12
12
|
readonly scratchDir: string
|
|
13
13
|
/** The real, symlink-resolved form of `path` — used to compare a possibly-symlinked cwd (macOS `/tmp` → `/private/tmp`) against git's own resolution. */
|
|
14
14
|
readonly realPath: (path: string) => Effect.Effect<string, Error>
|
|
15
|
+
/** Carried by every scratch or temp file name, so concurrent gtd runs never share one. */
|
|
16
|
+
readonly pid: number
|
|
17
|
+
/** `undefined` where the platform has no POSIX user ids. */
|
|
18
|
+
readonly uid: number | undefined
|
|
19
|
+
/** The command that runs this gtd again: its script under the running runtime, or `gtd` off `PATH` when there is none. */
|
|
20
|
+
readonly self: readonly [string, ...string[]]
|
|
21
|
+
/** Standard input read to its end, as UTF-8. */
|
|
22
|
+
readonly stdin: Effect.Effect<string, Error>
|
|
15
23
|
}
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
readonly
|
|
20
|
-
readonly
|
|
21
|
-
readonly
|
|
22
|
-
|
|
25
|
+
/** The output half of the process's stdio, which the CLI writes before any layer is built. */
|
|
26
|
+
export interface Stdio {
|
|
27
|
+
readonly stdout: (chunk: string | Uint8Array) => void
|
|
28
|
+
readonly stderr: (chunk: string) => void
|
|
29
|
+
readonly exit: (code: number) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The slice of Node's `process` the live host reads; a contract test hands in a fake one. */
|
|
33
|
+
export type NodeProcess = Pick<
|
|
34
|
+
NodeJS.Process,
|
|
35
|
+
"argv" | "execPath" | "pid" | "getuid" | "stdin" | "stdout" | "stderr" | "exitCode"
|
|
36
|
+
>
|
|
37
|
+
|
|
38
|
+
type HostOpsInput = Pick<HostOps, "root" | "home" | "env"> & Partial<HostOps>
|
|
39
|
+
|
|
40
|
+
/** `TMPDIR` when set and non-empty, else the host's own `fallback`. */
|
|
41
|
+
export const scratchDirOf = (env: HostOps["env"], fallback: string): string => {
|
|
42
|
+
const dir = env["TMPDIR"]
|
|
43
|
+
return dir !== undefined && dir.length > 0 ? dir : fallback
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const readAll = (stream: AsyncIterable<Buffer | string>): Effect.Effect<string, Error> =>
|
|
47
|
+
Effect.tryPromise({
|
|
48
|
+
try: async () => {
|
|
49
|
+
const chunks: Buffer[] = []
|
|
50
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk))
|
|
51
|
+
return Buffer.concat(chunks).toString("utf8")
|
|
52
|
+
},
|
|
53
|
+
catch: toError,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const makeHostOps = (opts: HostOpsInput, proc: NodeProcess): HostOps => {
|
|
57
|
+
const script = proc.argv[1]
|
|
58
|
+
return {
|
|
59
|
+
scratchDir: scratchDirOf(opts.env, tmpdir()),
|
|
60
|
+
realPath: (path: string) =>
|
|
61
|
+
Effect.try({ try: () => nodeSyncIo.realPath(path), catch: toError }),
|
|
62
|
+
pid: proc.pid,
|
|
63
|
+
uid: proc.getuid?.(),
|
|
64
|
+
self: script === undefined ? ["gtd"] : [proc.execPath, script],
|
|
65
|
+
// Lazy: touching `process.stdin` opens it, which the LSP and the pin hand-over must not see.
|
|
66
|
+
stdin: Effect.suspend(() => readAll(proc.stdin)),
|
|
67
|
+
...opts,
|
|
68
|
+
}
|
|
23
69
|
}
|
|
24
70
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
71
|
+
export const nodeStdio = (proc: NodeProcess = process): Stdio => ({
|
|
72
|
+
// `stdout.write` is asynchronous whenever stdout is a pipe or socket —
|
|
73
|
+
// passing a completion callback (and never force-closing, see `exit`
|
|
74
|
+
// below) keeps a queued remainder from being discarded, so a large
|
|
75
|
+
// artifact still reaches a slow reader even after a non-zero exit.
|
|
76
|
+
stdout: (chunk) => {
|
|
77
|
+
proc.stdout.write(chunk, () => {})
|
|
78
|
+
},
|
|
79
|
+
stderr: (chunk) => {
|
|
80
|
+
proc.stderr.write(chunk)
|
|
81
|
+
},
|
|
82
|
+
// Never `process.exit(code)`: that tears the process down out from under
|
|
83
|
+
// an undrained `stdout.write` above and truncates the artifact mid-pipe.
|
|
84
|
+
// Setting `exitCode` lets the event loop drain any queued write first.
|
|
85
|
+
exit: (code) => {
|
|
86
|
+
proc.exitCode = code
|
|
87
|
+
},
|
|
37
88
|
})
|
|
38
89
|
|
|
90
|
+
/** Every member not given in `opts` comes from Node: `proc`, its OS and its file system. */
|
|
39
91
|
export class Host extends Context.Service<Host, HostOps>()("Host") {
|
|
40
|
-
static layer = (opts: HostOpsInput): Layer.Layer<Host> =>
|
|
92
|
+
static layer = (opts: HostOpsInput, proc: NodeProcess = process): Layer.Layer<Host> =>
|
|
93
|
+
Layer.succeed(Host, makeHostOps(opts, proc))
|
|
41
94
|
static Live = Host.layer({ root: process.cwd(), home: homedir(), env: process.env })
|
|
42
95
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { registerHooks } from "node:module"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { pathToFileURL } from "node:url"
|
|
4
|
+
import { nodeSyncIo } from "./NodeSync.js"
|
|
5
|
+
import type { PinFile } from "./PinFile.js"
|
|
6
|
+
|
|
7
|
+
// `cwd`, as the engine's own root is: gtd runs only at the worktree root, and
|
|
8
|
+
// a relocated `$GIT_DIR` leaves no `.git` to walk up to. A file that cannot be
|
|
9
|
+
// read counts as no pin, so the launcher refuses it rather than crashing.
|
|
10
|
+
export const livePinFile =
|
|
11
|
+
(cwd: string, path: string): PinFile =>
|
|
12
|
+
() => {
|
|
13
|
+
const file = join(cwd, path)
|
|
14
|
+
try {
|
|
15
|
+
const bytes = nodeSyncIo.readFile(file)
|
|
16
|
+
if (bytes !== undefined) return { path: file, bytes }
|
|
17
|
+
} catch {
|
|
18
|
+
return { path: file, bytes: new Uint8Array() }
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return { path: file, bytes: nodeSyncIo.gitBytes(cwd, ["show", `HEAD:${path}`]) }
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Runs a pin's module as this process's main program. Node refuses an unknown
|
|
29
|
+
* extension, so a load hook serves the unpacked source under the pin's own
|
|
30
|
+
* URL — `import.meta.url` stays a file in the repository.
|
|
31
|
+
*/
|
|
32
|
+
export const runPinModule = async (path: string, source: string): Promise<void> => {
|
|
33
|
+
const url = pathToFileURL(path).href
|
|
34
|
+
// A pin read from HEAD has no file behind its URL, which Node's own resolve refuses.
|
|
35
|
+
registerHooks({
|
|
36
|
+
resolve: (specifier, context, nextResolve) =>
|
|
37
|
+
specifier === url ? { url, shortCircuit: true } : nextResolve(specifier, context),
|
|
38
|
+
load: (requested, context, nextLoad) =>
|
|
39
|
+
requested === url
|
|
40
|
+
? { format: "module", source, shortCircuit: true }
|
|
41
|
+
: nextLoad(requested, context),
|
|
42
|
+
})
|
|
43
|
+
await import(url)
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createRequire, isBuiltin } from "node:module"
|
|
2
|
+
import { homedir, tmpdir } from "node:os"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import { stripVTControlCharacters } from "node:util"
|
|
5
|
+
import { runInThisContext } from "node:vm"
|
|
6
|
+
import { gunzipSync, gzipSync } from "node:zlib"
|
|
7
|
+
|
|
8
|
+
// What the core needs of Node beyond the sync port: module loading, the
|
|
9
|
+
// user's directories, file URLs and gzip. Each is a Node fact with no in-memory twin.
|
|
10
|
+
|
|
11
|
+
export { createRequire, fileURLToPath, homedir, isBuiltin, stripVTControlCharacters, tmpdir }
|
|
12
|
+
|
|
13
|
+
export const gzip = (source: string): Uint8Array => gzipSync(source, { level: 9 })
|
|
14
|
+
|
|
15
|
+
export const gunzip = (bytes: Uint8Array): string => gunzipSync(bytes).toString("utf8")
|
|
16
|
+
|
|
17
|
+
/** Compiles `source` as a script in this realm, reporting errors against `filename`. */
|
|
18
|
+
export const compileScript = (source: string, filename: string): unknown =>
|
|
19
|
+
runInThisContext(source, { filename })
|