@pmelab/gtd 63.0.0 → 64.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -11
- 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-Cem5i6Xl.mjs} +422 -2
- package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-DfWZQCWQ.mjs} +1073 -328
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +4372 -1310
- package/dist/gtd.core.mjs +126 -118
- package/dist/steering.mjs +2 -2
- 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 +22 -28
- package/src/cli/Output.ts +2 -1
- package/src/cli/Program.test.ts +55 -53
- package/src/cli/Program.ts +34 -34
- package/src/cli/ToolCommands.ts +3 -4
- package/src/edge/Edge.test.ts +38 -12
- package/src/edge/Edge.ts +53 -19
- package/src/emit/SteeringMode.test.ts +4 -2
- package/src/emit/SteeringMode.ts +31 -10
- package/src/flows/index.ts +1 -0
- package/src/flows/runtime.test.ts +2 -1
- package/src/flows/runtime.ts +8 -2
- 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 +16 -13
- package/src/replay/Format.ts +4 -2
- package/src/replay/Replay.test.ts +25 -5
- package/src/replay/Replay.ts +10 -6
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +23 -1
- package/src/replay/index.ts +1 -0
- package/src/steering/Declared.test.ts +218 -0
- package/src/steering/Declared.ts +607 -0
- package/src/steering/FORMATS.md +3 -0
- package/src/steering/index.ts +12 -0
- package/src/steering/internal.ts +1 -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/EmittedScriptRecognizer.ts +5 -3
- package/src/twins/EmittedScripts.test.ts +19 -0
- 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/SteeringFormats.ts +4 -0
- package/src/workflow/Workflow.ts +2 -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/index.ts +5 -1
- 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/cli/Program.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Option, Schema } from "effect"
|
|
1
|
+
import { Effect, Option, type Path, Schema } from "effect"
|
|
2
2
|
import {
|
|
3
3
|
GtdError,
|
|
4
4
|
GtdUsageError,
|
|
@@ -70,6 +70,7 @@ import { formatCommitMessage, PIN_STATES } from "../replay/index.js"
|
|
|
70
70
|
import { planStart, planStep as planStepPure, type JudgeVerdict } from "../step/index.js"
|
|
71
71
|
import {
|
|
72
72
|
BUILT_IN_MODE_NAMES,
|
|
73
|
+
formatFromSpec,
|
|
73
74
|
steeringFormatFor,
|
|
74
75
|
openThreads,
|
|
75
76
|
parseCodeThreads,
|
|
@@ -99,7 +100,7 @@ import {
|
|
|
99
100
|
restoredOutcome,
|
|
100
101
|
} from "../emit/index.js"
|
|
101
102
|
import { GTD_VERSION, renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
|
|
102
|
-
import { ToolCommands
|
|
103
|
+
import { ToolCommands } from "./ToolCommands.js"
|
|
103
104
|
import {
|
|
104
105
|
normalizeScriptNewline,
|
|
105
106
|
SelectorUsageError,
|
|
@@ -169,6 +170,8 @@ export type Command =
|
|
|
169
170
|
readonly file?: string
|
|
170
171
|
readonly openQuestions?: boolean
|
|
171
172
|
readonly openThreads?: boolean
|
|
173
|
+
/** A declared format's spec: `mode` names it, and no built-in is looked up. */
|
|
174
|
+
readonly spec?: string
|
|
172
175
|
}
|
|
173
176
|
| { readonly kind: "uncheck"; readonly file: string }
|
|
174
177
|
| { readonly kind: "guide" }
|
|
@@ -210,14 +213,12 @@ export type Command =
|
|
|
210
213
|
*/
|
|
211
214
|
export type Needs = "pure" | "removed" | "none" | "fs" | "state"
|
|
212
215
|
|
|
213
|
-
const selfGtd = (): SelfGtd => {
|
|
214
|
-
const script = process.argv[1]
|
|
215
|
-
return script === undefined ? ["gtd"] : [process.execPath, script]
|
|
216
|
-
}
|
|
217
|
-
|
|
218
216
|
/** `needs: "none"` skips the repo-root guard — the server is keyed on file name, not workflow state. */
|
|
219
|
-
const runLspCommand = (): Effect.Effect<void, Error, ToolCommands> =>
|
|
220
|
-
Effect.
|
|
217
|
+
const runLspCommand = (): Effect.Effect<void, Error, ToolCommands | Host> =>
|
|
218
|
+
Effect.gen(function* () {
|
|
219
|
+
const { self } = yield* Host
|
|
220
|
+
return yield* (yield* ToolCommands).lsp(self)
|
|
221
|
+
})
|
|
221
222
|
|
|
222
223
|
/**
|
|
223
224
|
* Prints the briefing, emitted by the binary itself so it's always current.
|
|
@@ -246,7 +247,7 @@ const runBundleCommand = (
|
|
|
246
247
|
): Effect.Effect<
|
|
247
248
|
void,
|
|
248
249
|
Error,
|
|
249
|
-
ConfigService | ConfigDiscovery | EngineCore | Host | Narrator | Workspace
|
|
250
|
+
ConfigService | ConfigDiscovery | EngineCore | Host | Path.Path | Narrator | Workspace
|
|
250
251
|
> =>
|
|
251
252
|
Effect.gen(function* () {
|
|
252
253
|
yield* (yield* ConfigService).load
|
|
@@ -430,23 +431,6 @@ const parseJudgeDocument = (rendered: string): Effect.Effect<JudgeDocument, Erro
|
|
|
430
431
|
),
|
|
431
432
|
})
|
|
432
433
|
|
|
433
|
-
/**
|
|
434
|
-
* The first stdin-consuming command outside `src/lsp/Lsp.ts` (which reads LSP's
|
|
435
|
-
* own length-prefixed frames off `process.stdin`, not a one-shot read) —
|
|
436
|
-
* reads stdin to completion and decodes it as UTF-8 text, no framing.
|
|
437
|
-
*/
|
|
438
|
-
const readStdin = (): Effect.Effect<string, Error> =>
|
|
439
|
-
Effect.tryPromise({
|
|
440
|
-
try: async () => {
|
|
441
|
-
const chunks: Buffer[] = []
|
|
442
|
-
for await (const chunk of process.stdin as AsyncIterable<Buffer>) {
|
|
443
|
-
chunks.push(chunk)
|
|
444
|
-
}
|
|
445
|
-
return Buffer.concat(chunks).toString("utf8")
|
|
446
|
-
},
|
|
447
|
-
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
|
448
|
-
})
|
|
449
|
-
|
|
450
434
|
/** A stdin that is no judgment is the caller's mistake, a usage error like `gtd judge answer`'s. */
|
|
451
435
|
const judgmentShape = (input: string): Effect.Effect<void, GtdUsageError> => {
|
|
452
436
|
let doc: unknown
|
|
@@ -470,7 +454,7 @@ const runJudgeRunCommand = (
|
|
|
470
454
|
const host = yield* Host
|
|
471
455
|
const tools = yield* ToolCommands
|
|
472
456
|
yield* refuseLegacyRc
|
|
473
|
-
const input = yield*
|
|
457
|
+
const input = yield* host.stdin
|
|
474
458
|
yield* judgmentShape(input)
|
|
475
459
|
const verdict = yield* Effect.tryPromise({
|
|
476
460
|
try: () =>
|
|
@@ -535,7 +519,7 @@ const runJudgeAnswerCommand = (
|
|
|
535
519
|
const document = yield* parseJudgeDocument(rendered.judge)
|
|
536
520
|
const ids = document.questions.map((q) => q.id)
|
|
537
521
|
|
|
538
|
-
const raw = yield*
|
|
522
|
+
const raw = yield* (yield* Host).stdin
|
|
539
523
|
const parsed = yield* Effect.try({
|
|
540
524
|
try: () => JSON.parse(raw) as unknown,
|
|
541
525
|
catch: () => new SelectorUsageError("gtd judge answer: stdin is not valid JSON"),
|
|
@@ -922,8 +906,9 @@ const selfValidateCommand = (
|
|
|
922
906
|
state: string,
|
|
923
907
|
mode: StateMode,
|
|
924
908
|
file: string,
|
|
909
|
+
spec: string | undefined,
|
|
925
910
|
): string => {
|
|
926
|
-
const resolved = resolveMode(def, state, mode)
|
|
911
|
+
const resolved = resolveMode(def, state, mode, spec)
|
|
927
912
|
const validate = resolved.kind === "resolved" ? resolved.validate : undefined
|
|
928
913
|
const command = validate?.kind === "command" ? validate.command : seededValidateCommand(mode)
|
|
929
914
|
return commandForFile(command, file)
|
|
@@ -946,14 +931,14 @@ const resolveValidateScript = (
|
|
|
946
931
|
): Effect.Effect<
|
|
947
932
|
{ readonly file: string; readonly mode: StateMode; readonly script: string } | undefined,
|
|
948
933
|
Error,
|
|
949
|
-
Host
|
|
934
|
+
Host | Path.Path
|
|
950
935
|
> =>
|
|
951
936
|
Effect.gen(function* () {
|
|
952
937
|
const file = rest.hints.file
|
|
953
938
|
const mode = rest.stepDef.mode
|
|
954
939
|
if (file === undefined || mode === undefined) return undefined
|
|
955
940
|
|
|
956
|
-
const resolved = resolveMode(rest.def, rest.state, mode)
|
|
941
|
+
const resolved = resolveMode(rest.def, rest.state, mode, rest.stepDef.modeSpec)
|
|
957
942
|
if (resolved.kind === "unknown") {
|
|
958
943
|
return yield* Effect.fail(new Error(resolved.message))
|
|
959
944
|
}
|
|
@@ -995,7 +980,13 @@ const runNextCommand = (
|
|
|
995
980
|
yield* writeSelection(out, fields, json.path)
|
|
996
981
|
} else {
|
|
997
982
|
const validateCommand = emitsValidatablePrompt(rendered)
|
|
998
|
-
? selfValidateCommand(
|
|
983
|
+
? selfValidateCommand(
|
|
984
|
+
rest.def,
|
|
985
|
+
rest.state,
|
|
986
|
+
rendered.mode!,
|
|
987
|
+
rendered.file!,
|
|
988
|
+
rest.stepDef.modeSpec,
|
|
989
|
+
)
|
|
999
990
|
: undefined
|
|
1000
991
|
if (DEMAND_BRIEFING[fields.kind].suppressHeader) {
|
|
1001
992
|
// A prompt's stdout is the agent's input, so its header goes to stderr;
|
|
@@ -1038,9 +1029,10 @@ const runCheckCommand = (
|
|
|
1038
1029
|
file: string,
|
|
1039
1030
|
openQuestions: boolean,
|
|
1040
1031
|
openThreadsFlag: boolean,
|
|
1032
|
+
spec: string | undefined,
|
|
1041
1033
|
): Effect.Effect<void, Error, Workspace> =>
|
|
1042
1034
|
Effect.gen(function* () {
|
|
1043
|
-
const format = steeringFormatFor(mode)
|
|
1035
|
+
const format = spec !== undefined ? yield* declaredFormat(spec) : steeringFormatFor(mode)
|
|
1044
1036
|
if (format === undefined) {
|
|
1045
1037
|
return yield* Effect.fail(
|
|
1046
1038
|
new GtdUsageError(
|
|
@@ -1090,6 +1082,13 @@ const runCheckCommand = (
|
|
|
1090
1082
|
)
|
|
1091
1083
|
})
|
|
1092
1084
|
|
|
1085
|
+
const declaredFormat = (spec: string): Effect.Effect<SteeringFormat, GtdUsageError> =>
|
|
1086
|
+
Effect.try({
|
|
1087
|
+
try: () => formatFromSpec(spec),
|
|
1088
|
+
catch: (error) =>
|
|
1089
|
+
new GtdUsageError(`gtd check: --spec is not a declared format: ${(error as Error).message}`),
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1093
1092
|
const existingFile = (file: string): Effect.Effect<string, Error, Workspace> =>
|
|
1094
1093
|
Effect.flatMap(Workspace, (workspace) => {
|
|
1095
1094
|
const content = workspace.atPath(file)
|
|
@@ -1403,6 +1402,7 @@ const dispatchVoidCommand = (
|
|
|
1403
1402
|
command.file,
|
|
1404
1403
|
command.openQuestions ?? false,
|
|
1405
1404
|
command.openThreads ?? false,
|
|
1405
|
+
command.spec,
|
|
1406
1406
|
)
|
|
1407
1407
|
case "uncheck":
|
|
1408
1408
|
return runUncheckCommand(command.file)
|
package/src/cli/ToolCommands.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { Context } from "effect"
|
|
2
2
|
import type { Effect } from "effect"
|
|
3
3
|
import type { UiSettings } from "../flows/index.js"
|
|
4
|
-
|
|
5
|
-
/** How to run THIS gtd — a tool (the LSP) never reads a different version's state than the one it ships with. */
|
|
6
|
-
export type SelfGtd = readonly [string, ...string[]]
|
|
4
|
+
import type { HostOps } from "../platform/index.js"
|
|
7
5
|
|
|
8
6
|
export interface UiRequest {
|
|
9
7
|
readonly flags: {
|
|
@@ -36,7 +34,8 @@ export class ToolCommands extends Context.Service<
|
|
|
36
34
|
request: UiRequest,
|
|
37
35
|
out: { readonly write: (chunk: string) => void; readonly flush: () => void },
|
|
38
36
|
) => Effect.Effect<void, Error>
|
|
39
|
-
|
|
37
|
+
/** `gtd` runs THIS gtd, so the LSP never reads a different version's state than the one it ships with. */
|
|
38
|
+
readonly lsp: (gtd: HostOps["self"]) => Effect.Effect<void, Error>
|
|
40
39
|
readonly judge: (request: JudgeRequest) => Promise<string>
|
|
41
40
|
}
|
|
42
41
|
>()("ToolCommands") {}
|
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,
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
treeFromRecord,
|
|
43
43
|
type EpisodeCommit,
|
|
44
44
|
type JudgeVerdict,
|
|
45
|
+
LEGACY_PIN_STATES,
|
|
45
46
|
type PinChange,
|
|
46
47
|
PIN_STATES,
|
|
47
48
|
type ReachedStep,
|
|
@@ -62,7 +63,6 @@ import {
|
|
|
62
63
|
|
|
63
64
|
import { GTD_VERSION } from "../install/index.js"
|
|
64
65
|
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
65
|
-
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
66
66
|
import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
|
|
67
67
|
|
|
68
68
|
export { UNATTRIBUTED_MODEL }
|
|
@@ -337,8 +337,14 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
|
|
|
337
337
|
const parsed = parseCommitMessage(subject).parsed
|
|
338
338
|
if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
|
|
339
339
|
if (parsed.actor === "human" && parsed.from === undefined) return false
|
|
340
|
-
// A pin commit's subject can name the first step; it bounds nothing.
|
|
341
|
-
|
|
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
|
+
}
|
|
342
348
|
return parsed.to === def.initial
|
|
343
349
|
}
|
|
344
350
|
|
|
@@ -421,7 +427,14 @@ const computeProcessRun = (
|
|
|
421
427
|
runOf(history, locateEpisode(def, history)),
|
|
422
428
|
)
|
|
423
429
|
|
|
424
|
-
type ConfigRequirements =
|
|
430
|
+
type ConfigRequirements =
|
|
431
|
+
| GitService
|
|
432
|
+
| ConfigService
|
|
433
|
+
| ConfigDiscovery
|
|
434
|
+
| Narrator
|
|
435
|
+
| Workspace
|
|
436
|
+
| Host
|
|
437
|
+
| Path.Path
|
|
425
438
|
|
|
426
439
|
/** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
|
|
427
440
|
export const currentRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
|
|
@@ -692,7 +705,7 @@ const DEFAULT_JUDGE_MESSAGE =
|
|
|
692
705
|
const optional = <K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } =>
|
|
693
706
|
(value === undefined ? {} : { [key]: value }) as { [P in K]?: V }
|
|
694
707
|
|
|
695
|
-
type StepCommon = Pick<StepDef, "actor" | "label" | "file" | "mode">
|
|
708
|
+
type StepCommon = Pick<StepDef, "actor" | "label" | "file" | "mode" | "modeSpec">
|
|
696
709
|
|
|
697
710
|
const commonOf = (step: ReachedStep): StepCommon => {
|
|
698
711
|
const options = step.request.options
|
|
@@ -700,7 +713,9 @@ const commonOf = (step: ReachedStep): StepCommon => {
|
|
|
700
713
|
actor: step.actor,
|
|
701
714
|
...optional("label", options.label),
|
|
702
715
|
...optional("file", options.file),
|
|
703
|
-
...
|
|
716
|
+
...(typeof options.mode === "object"
|
|
717
|
+
? { mode: options.mode.name, modeSpec: options.mode.spec }
|
|
718
|
+
: optional("mode", options.mode)),
|
|
704
719
|
}
|
|
705
720
|
}
|
|
706
721
|
|
|
@@ -820,6 +835,23 @@ const hintsOf = (def: StepDef): RestHints => ({
|
|
|
820
835
|
...optional("access", def.access),
|
|
821
836
|
})
|
|
822
837
|
|
|
838
|
+
/** A step's mode is a name the workflow knows, or a declared format named apart from all of them. */
|
|
839
|
+
const modeFaultOf = (
|
|
840
|
+
def: WorkflowDefinition,
|
|
841
|
+
stepName: string,
|
|
842
|
+
stepDef: StepDef,
|
|
843
|
+
): string | undefined => {
|
|
844
|
+
if (stepDef.mode === undefined) return undefined
|
|
845
|
+
const known = def.modes[stepDef.mode] !== undefined
|
|
846
|
+
if (stepDef.modeSpec !== undefined)
|
|
847
|
+
return known
|
|
848
|
+
? `gtd: config: step "${stepName}": declared format "${stepDef.mode}" has the name of a mode this workflow already knows — rename the format`
|
|
849
|
+
: undefined
|
|
850
|
+
return known
|
|
851
|
+
? undefined
|
|
852
|
+
: `gtd: config: step "${stepName}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`
|
|
853
|
+
}
|
|
854
|
+
|
|
823
855
|
const normalizeStatus = (raw: string): ChangeStatus => (raw === "A" ? "A" : raw === "D" ? "D" : "M")
|
|
824
856
|
|
|
825
857
|
/** The currently rested step and its description — enough for the viewer and the LSP. */
|
|
@@ -984,13 +1016,8 @@ export const restAt = (
|
|
|
984
1016
|
const { reached, step } = resting
|
|
985
1017
|
const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run)
|
|
986
1018
|
yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`)
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
new Error(
|
|
990
|
-
`gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
|
|
991
|
-
),
|
|
992
|
-
)
|
|
993
|
-
}
|
|
1019
|
+
const modeFault = modeFaultOf(def, step.name, stepDef)
|
|
1020
|
+
if (modeFault !== undefined) return yield* Effect.fail(new Error(modeFault))
|
|
994
1021
|
const changes =
|
|
995
1022
|
ref === undefined
|
|
996
1023
|
? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
|
|
@@ -1564,10 +1591,16 @@ export interface HistoryTrail {
|
|
|
1564
1591
|
* repository cannot be read at a ref, so it is left out. A file only the old
|
|
1565
1592
|
* config read needs no check: whichever file imported it differs too.
|
|
1566
1593
|
*/
|
|
1567
|
-
const inRepoConfigPaths = (
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1594
|
+
const inRepoConfigPaths = (
|
|
1595
|
+
path: Path.Path,
|
|
1596
|
+
root: string,
|
|
1597
|
+
files: readonly string[],
|
|
1598
|
+
): readonly string[] => {
|
|
1599
|
+
const found = new Set<string>()
|
|
1600
|
+
for (const file of files) {
|
|
1601
|
+
found.add(path.relative(root, path.resolve(root, file)).split(path.sep).join("/"))
|
|
1602
|
+
}
|
|
1603
|
+
return [...found].filter((p) => p !== "" && !p.startsWith("..") && !path.isAbsolute(p)).sort()
|
|
1571
1604
|
}
|
|
1572
1605
|
|
|
1573
1606
|
const configRefusal = (
|
|
@@ -1589,7 +1622,8 @@ const differingConfigPaths = (
|
|
|
1589
1622
|
) =>
|
|
1590
1623
|
Effect.gen(function* () {
|
|
1591
1624
|
const differing: string[] = []
|
|
1592
|
-
|
|
1625
|
+
const configPaths = inRepoConfigPaths(yield* Path.Path, root, Object.keys(config.fingerprint))
|
|
1626
|
+
for (const path of configPaths) {
|
|
1593
1627
|
const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
|
|
1594
1628
|
if (atTip !== workspace.readSync(path)) differing.push(path)
|
|
1595
1629
|
}
|
|
@@ -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,13 +1,18 @@
|
|
|
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 {
|
|
4
|
+
declaredValidateCommand,
|
|
5
5
|
isSeededValidateCommand,
|
|
6
6
|
knownModes,
|
|
7
7
|
type StateMode,
|
|
8
8
|
type WorkflowDefinition,
|
|
9
9
|
} from "../workflow/index.js"
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
formatFromSpec,
|
|
12
|
+
steeringFormatFor,
|
|
13
|
+
type SteeringFinding,
|
|
14
|
+
type SteeringFormat,
|
|
15
|
+
} from "../steering/internal.js"
|
|
11
16
|
import { buildModeContradictionCheck, modeContradictionSkipNotice } from "./ModeContradiction.js"
|
|
12
17
|
import {
|
|
13
18
|
binaryGuard,
|
|
@@ -102,12 +107,26 @@ const capabilitiesFor = (
|
|
|
102
107
|
* The `"unknown"` arm carries the whole refusal message, naming what IS known
|
|
103
108
|
* when `def` is given — this is the one place that message is built, so it
|
|
104
109
|
* can never drift from what actually resolved.
|
|
110
|
+
*
|
|
111
|
+
* `spec` is a declared format's: it alone decides, validating through
|
|
112
|
+
* `gtd check --spec` with no `format:` half.
|
|
105
113
|
*/
|
|
106
114
|
export const resolveMode = (
|
|
107
115
|
def: Pick<WorkflowDefinition, "modes"> | undefined,
|
|
108
116
|
state: string,
|
|
109
117
|
mode: StateMode,
|
|
118
|
+
spec?: string,
|
|
110
119
|
): ModeResolution => {
|
|
120
|
+
if (spec !== undefined) {
|
|
121
|
+
const format = formatFromSpec(spec)
|
|
122
|
+
return {
|
|
123
|
+
kind: "resolved",
|
|
124
|
+
mode,
|
|
125
|
+
format,
|
|
126
|
+
validate: { kind: "command", command: declaredValidateCommand(mode, spec) },
|
|
127
|
+
capabilities: { format, liveValidate: format.validate },
|
|
128
|
+
}
|
|
129
|
+
}
|
|
111
130
|
const declared = def?.modes?.[mode]
|
|
112
131
|
const format = steeringFormatFor(mode)
|
|
113
132
|
if (declared === undefined && format === undefined) {
|
|
@@ -191,8 +210,11 @@ const modeCommandSteps = (resolved: ResolvedMode, file: string): readonly EmitSt
|
|
|
191
210
|
}
|
|
192
211
|
|
|
193
212
|
/** `<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
|
-
|
|
213
|
+
const scratchSamplePath = (
|
|
214
|
+
path: Path.Path,
|
|
215
|
+
{ scratchDir, pid }: HostOps,
|
|
216
|
+
mode: StateMode,
|
|
217
|
+
): string => path.join(scratchDir, `gtd-mode-sample-${mode}-${pid}.md`)
|
|
196
218
|
|
|
197
219
|
/**
|
|
198
220
|
* The contradiction round-trip/skip-notice steps for `resolved`'s `mode:`.
|
|
@@ -204,13 +226,12 @@ const scratchSamplePath = (scratchDir: string, mode: StateMode): string =>
|
|
|
204
226
|
*/
|
|
205
227
|
const modeContradictionSteps = (
|
|
206
228
|
resolved: ResolvedMode,
|
|
207
|
-
): Effect.Effect<readonly EmitStep[], Error, Host> =>
|
|
229
|
+
): Effect.Effect<readonly EmitStep[], Error, Host | Path.Path> =>
|
|
208
230
|
Effect.gen(function* () {
|
|
209
231
|
if (resolved.formatCommand === undefined) return []
|
|
210
232
|
const { capabilities } = resolved
|
|
211
233
|
if (capabilities.format !== undefined && capabilities.liveValidate !== undefined) {
|
|
212
|
-
const
|
|
213
|
-
const samplePath = scratchSamplePath(scratchDir, resolved.mode)
|
|
234
|
+
const samplePath = scratchSamplePath(yield* Path.Path, yield* Host, resolved.mode)
|
|
214
235
|
const formatCommand = withFileVar(resolved.formatCommand, samplePath)
|
|
215
236
|
return [
|
|
216
237
|
{
|
|
@@ -245,7 +266,7 @@ export interface ValidateScript {
|
|
|
245
266
|
export const validateScriptFor = (
|
|
246
267
|
resolved: ResolvedMode,
|
|
247
268
|
file: string,
|
|
248
|
-
): Effect.Effect<ValidateScript, Error, Host> =>
|
|
269
|
+
): Effect.Effect<ValidateScript, Error, Host | Path.Path> =>
|
|
249
270
|
Effect.gen(function* () {
|
|
250
271
|
const steps: EmitStep[] = [
|
|
251
272
|
...(yield* modeContradictionSteps(resolved)),
|
package/src/flows/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type AgentOptions,
|
|
20
20
|
type HumanOptions,
|
|
21
21
|
type Judgment,
|
|
22
|
+
type DeclaredMode,
|
|
22
23
|
type ModeName,
|
|
23
24
|
type RunOptions,
|
|
24
25
|
type Settings,
|
|
@@ -315,6 +316,6 @@ describe("mode", () => {
|
|
|
315
316
|
expectTypeOf<"qa">().toMatchTypeOf<ModeName>()
|
|
316
317
|
expectTypeOf<string>().toMatchTypeOf<ModeName>()
|
|
317
318
|
expectTypeOf<ModeName>().toMatchTypeOf<string>()
|
|
318
|
-
expectTypeOf<SteeringOptions["mode"]>().toEqualTypeOf<ModeName | undefined>()
|
|
319
|
+
expectTypeOf<SteeringOptions["mode"]>().toEqualTypeOf<ModeName | DeclaredMode | undefined>()
|
|
319
320
|
})
|
|
320
321
|
})
|
package/src/flows/runtime.ts
CHANGED
|
@@ -8,6 +8,12 @@ import type { WorkflowBuild } from "./factory.js"
|
|
|
8
8
|
/** A steering-file mode: a built-in format's name or a `config.modes` entry. */
|
|
9
9
|
export type ModeName = "qa" | "review" | "findings" | (string & {})
|
|
10
10
|
|
|
11
|
+
/** A format declared with `listFormat`/`sectionFormat` from `@pmelab/gtd/steering`, used as a step's mode. */
|
|
12
|
+
export interface DeclaredMode {
|
|
13
|
+
readonly name: string
|
|
14
|
+
readonly spec: string
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
interface SteeringCommon {
|
|
12
18
|
/** Display name for drivers and viewers. */
|
|
13
19
|
readonly label?: string | undefined
|
|
@@ -24,8 +30,8 @@ type SteeringFile =
|
|
|
24
30
|
}
|
|
25
31
|
| {
|
|
26
32
|
readonly file: string
|
|
27
|
-
/** The steering file's mode — a built-in name
|
|
28
|
-
readonly mode: ModeName
|
|
33
|
+
/** The steering file's mode — a built-in name, a `modes:` entry, or a declared format. Requires `file`. */
|
|
34
|
+
readonly mode: ModeName | DeclaredMode
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** A steering-file declaration shared by the steps that rest on one. `mode` requires `file`. */
|
|
@@ -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"
|