@pmelab/gtd 24.0.0 → 24.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 +31 -5
- package/claude/hooks/access.ts +1 -1
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43339 -55575
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +8 -6
- package/package.json +69 -8
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { StateName } from "../workflow/index.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The core's git effect, as DATA — never shell text. `src/emit/GitScript.ts` is
|
|
5
|
+
* the only place a `GitWrite` becomes a command; `src/step/` never imports
|
|
6
|
+
* that module, which is what makes writing a live git call inside the core
|
|
7
|
+
* impossible rather than merely discouraged.
|
|
8
|
+
*/
|
|
9
|
+
export type GitWrite = { readonly kind: "commitAll"; readonly message: string }
|
|
10
|
+
|
|
11
|
+
/** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
|
|
12
|
+
export type Outcome =
|
|
13
|
+
| { readonly kind: "transition"; readonly from: StateName; readonly to: StateName }
|
|
14
|
+
| { readonly kind: "commit"; readonly subject: StateName | string }
|
|
15
|
+
| { readonly kind: "note"; readonly text: string }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One step of a landing script, as DATA — no step carries a literal shell
|
|
19
|
+
* string; `GitScript.ts`'s `ScriptSurface` is the only place any of these
|
|
20
|
+
* becomes shell text (and the only place a bare path becomes a quoted
|
|
21
|
+
* argument). `"uncheck"` is the human-review-gate's reset command
|
|
22
|
+
* (`gtd uncheck <file>`, ahead of the commit); `"command"`'s own `command`
|
|
23
|
+
* is a driver-facing shell fragment `src/step/` never constructs today (no
|
|
24
|
+
* current emitter produces one) but the shape stays available for a future
|
|
25
|
+
* step that genuinely needs an arbitrary command.
|
|
26
|
+
*/
|
|
27
|
+
export type LandStep =
|
|
28
|
+
| { readonly kind: "gitWrite"; readonly write: GitWrite }
|
|
29
|
+
| { readonly kind: "uncheck"; readonly file: string }
|
|
30
|
+
| { readonly kind: "command"; readonly command: string; readonly onFailure?: string }
|
|
31
|
+
| { readonly kind: "outcome"; readonly outcome: Outcome }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CommitSpec } from "../replay/index.js"
|
|
2
|
+
import type { PendingChange, StateName, StepDef } from "../workflow/index.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What landing the pending turn does, decided at the edge by replaying the
|
|
6
|
+
* episode with the working tree as the pending turn.
|
|
7
|
+
*/
|
|
8
|
+
export type Landing =
|
|
9
|
+
| { readonly kind: "refusal"; readonly message: string }
|
|
10
|
+
/** Nothing to land. `settled` when re-running the rest cannot change that. */
|
|
11
|
+
| { readonly kind: "noop"; readonly settled: boolean }
|
|
12
|
+
/** An empty agent turn: recorded, but the process stays at the step. */
|
|
13
|
+
| { readonly kind: "attempt"; readonly subject: string }
|
|
14
|
+
| { readonly kind: "commit"; readonly to: StateName; readonly spec: CommitSpec }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Everything `planStep` needs, already resolved
|
|
18
|
+
* from git/config/workspace ONCE, before any perform — a frozen value cannot
|
|
19
|
+
* observe a perform.
|
|
20
|
+
*/
|
|
21
|
+
export interface RepoSnapshot {
|
|
22
|
+
readonly stepDef: StepDef
|
|
23
|
+
readonly state: StateName
|
|
24
|
+
readonly actor: string
|
|
25
|
+
readonly changes: readonly PendingChange[]
|
|
26
|
+
/** The resting step's steering file, `undefined` when it declares none. */
|
|
27
|
+
readonly file: string | undefined
|
|
28
|
+
readonly landing: Landing
|
|
29
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { ACCESS_REFUSAL } from "../wire/index.js"
|
|
3
|
+
import { accessRefusal } from "./accessRefusal.js"
|
|
4
|
+
|
|
5
|
+
describe("accessRefusal", () => {
|
|
6
|
+
it("allows changes inside the write globs", () => {
|
|
7
|
+
expect(
|
|
8
|
+
accessRefusal(
|
|
9
|
+
[
|
|
10
|
+
{ status: "M", path: ".gtd/REVIEW.md" },
|
|
11
|
+
{ status: "A", path: "docs/deep/a.md" },
|
|
12
|
+
],
|
|
13
|
+
[".gtd/REVIEW.md", "docs/**"],
|
|
14
|
+
),
|
|
15
|
+
).toBeUndefined()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("refuses a path outside, naming it and the allowed globs", () => {
|
|
19
|
+
const message = accessRefusal(
|
|
20
|
+
[
|
|
21
|
+
{ status: "M", path: "src/foo.ts" },
|
|
22
|
+
{ status: "M", path: ".gtd/REVIEW.md" },
|
|
23
|
+
],
|
|
24
|
+
[".gtd/REVIEW.md"],
|
|
25
|
+
)
|
|
26
|
+
expect(message).toBe(`${ACCESS_REFUSAL}\n - src/foo.ts\nallowed: .gtd/REVIEW.md`)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("refuses a delete outside", () => {
|
|
30
|
+
expect(accessRefusal([{ status: "D", path: "src/gone.ts" }], ["docs/**"])).toContain(
|
|
31
|
+
" - src/gone.ts",
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("refuses only the side of a rename that lands outside the write globs", () => {
|
|
36
|
+
const message = accessRefusal(
|
|
37
|
+
[
|
|
38
|
+
{ status: "D", path: "docs/a.md" },
|
|
39
|
+
{ status: "A", path: "src/a.md" },
|
|
40
|
+
],
|
|
41
|
+
["docs/**"],
|
|
42
|
+
)
|
|
43
|
+
expect(message).toContain(" - src/a.md")
|
|
44
|
+
expect(message).not.toContain(" - docs/a.md")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("never refuses a null write", () => {
|
|
48
|
+
expect(accessRefusal([{ status: "M", path: "anything" }], null)).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("says nothing is allowed for an empty list", () => {
|
|
52
|
+
expect(accessRefusal([{ status: "A", path: "x" }], [])).toContain("allowed: (nothing)")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("starts with the marker line", () => {
|
|
56
|
+
expect(accessRefusal([{ status: "A", path: "x" }], ["y"])?.split("\n")[0]).toBe(ACCESS_REFUSAL)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { globMatches } from "../wire/index.js"
|
|
2
|
+
import type { PendingChange } from "../workflow/index.js"
|
|
3
|
+
import { ACCESS_REFUSAL } from "../wire/index.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The landing refusal for changes outside `write`, or `undefined` when every
|
|
7
|
+
* path is allowed. `null` is unrestricted. The message is the fix prompt: gtd
|
|
8
|
+
* runs no fix turn of its own.
|
|
9
|
+
*/
|
|
10
|
+
export const accessRefusal = (
|
|
11
|
+
changes: readonly PendingChange[],
|
|
12
|
+
write: readonly string[] | null,
|
|
13
|
+
): string | undefined => {
|
|
14
|
+
if (write === null) return undefined
|
|
15
|
+
const offending = [
|
|
16
|
+
...new Set(
|
|
17
|
+
changes.filter((c) => !write.some((glob) => globMatches(c.path, glob))).map((c) => c.path),
|
|
18
|
+
),
|
|
19
|
+
]
|
|
20
|
+
if (offending.length === 0) return undefined
|
|
21
|
+
return [
|
|
22
|
+
ACCESS_REFUSAL,
|
|
23
|
+
...offending.map((path) => ` - ${path}`),
|
|
24
|
+
`allowed: ${write.length === 0 ? "(nothing)" : write.join(", ")}`,
|
|
25
|
+
].join("\n")
|
|
26
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { planStep, type JudgeVerdict } from "./planStep.js"
|
|
2
|
+
export { accessRefusal } from "./accessRefusal.js"
|
|
3
|
+
export { planStart } from "./planStart.js"
|
|
4
|
+
export type { GitWrite, LandStep, Outcome } from "./LandStep.js"
|
|
5
|
+
export type { Landing, RepoSnapshot } from "./RepoSnapshot.js"
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { planStart, type StartOutcome } from "./planStart.js"
|
|
4
|
+
import { InMemRepo, testLayers } from "../testkit/index.js"
|
|
5
|
+
import { ConfigService } from "../workflow/index.js"
|
|
6
|
+
|
|
7
|
+
const WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
|
|
8
|
+
|
|
9
|
+
export default async () => {
|
|
10
|
+
await human("idle", { message: "hi" })
|
|
11
|
+
await agent("work", "work-prompt")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const working = async () => {
|
|
15
|
+
await agent("work", "work-prompt")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const reviewcheck = async () => {
|
|
19
|
+
await agent("work", "work-prompt")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const defaults = { base: "" }
|
|
23
|
+
|
|
24
|
+
export const base = (workflow, vars) => (workflow === "reviewcheck" ? (vars.base ?? "") : undefined)
|
|
25
|
+
`
|
|
26
|
+
|
|
27
|
+
const repoAt = (): InMemRepo => {
|
|
28
|
+
const repo = new InMemRepo()
|
|
29
|
+
repo.writeFile("gtd.config.ts", WORKFLOW)
|
|
30
|
+
repo.commitAllWithPrefix("chore: add custom workflow")
|
|
31
|
+
return repo
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Loads the real compiled workflows (via `ConfigService`, same as `Edge.ts`'s `restAt`) so `planStart`'s checks see the loaded workflow, then runs `planStart` against it. */
|
|
35
|
+
const start = (
|
|
36
|
+
repo: InMemRepo,
|
|
37
|
+
state: string,
|
|
38
|
+
actor: string,
|
|
39
|
+
req: {
|
|
40
|
+
readonly workflow: string
|
|
41
|
+
readonly commandLabel: string
|
|
42
|
+
readonly vars: Record<string, string>
|
|
43
|
+
},
|
|
44
|
+
startRefusal?: string,
|
|
45
|
+
): Promise<StartOutcome> =>
|
|
46
|
+
Effect.runPromise(
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const config = yield* (yield* ConfigService).load
|
|
49
|
+
const workflow = config.workflowNamed(req.workflow)
|
|
50
|
+
if (workflow === undefined) throw new Error(`no workflow ${req.workflow}`)
|
|
51
|
+
return yield* planStart({ state, idle: state === config.workflow.initial }, actor, {
|
|
52
|
+
workflow,
|
|
53
|
+
first: startRefusal === undefined ? { firstStep: "work" } : { refusal: startRefusal },
|
|
54
|
+
commandLabel: req.commandLabel,
|
|
55
|
+
vars: req.vars,
|
|
56
|
+
config: {},
|
|
57
|
+
})
|
|
58
|
+
}).pipe(Effect.provide(testLayers(repo))),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
describe("planStart", () => {
|
|
62
|
+
it("refuses when a process is already underway", async () => {
|
|
63
|
+
const repo = repoAt()
|
|
64
|
+
const plan = await start(repo, "working", "human", {
|
|
65
|
+
workflow: "working",
|
|
66
|
+
commandLabel: "gtd test",
|
|
67
|
+
vars: {},
|
|
68
|
+
})
|
|
69
|
+
expect(plan.kind).toBe("refusal")
|
|
70
|
+
expect(plan.kind === "refusal" && plan.message).toContain("already underway")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("refuses, under the command's label, a workflow that will not start", async () => {
|
|
74
|
+
const repo = repoAt()
|
|
75
|
+
const plan = await start(
|
|
76
|
+
repo,
|
|
77
|
+
"idle",
|
|
78
|
+
"human",
|
|
79
|
+
{ workflow: "working", commandLabel: "gtd test", vars: {} },
|
|
80
|
+
'workflow "working" cannot start — it reaches no step',
|
|
81
|
+
)
|
|
82
|
+
expect(plan).toEqual({
|
|
83
|
+
kind: "refusal",
|
|
84
|
+
message: 'gtd test: workflow "working" cannot start — it reaches no step',
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("refuses an undeclared --var name", async () => {
|
|
89
|
+
const repo = repoAt()
|
|
90
|
+
const plan = await start(repo, "idle", "human", {
|
|
91
|
+
workflow: "working",
|
|
92
|
+
commandLabel: "gtd test",
|
|
93
|
+
vars: { nope: "x" },
|
|
94
|
+
})
|
|
95
|
+
expect(plan.kind).toBe("refusal")
|
|
96
|
+
expect(plan.kind === "refusal" && plan.message).toContain("not declared by this workflow")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("a workflow's diff base", () => {
|
|
100
|
+
const REVIEW = { workflow: "reviewcheck", commandLabel: "gtd test" }
|
|
101
|
+
|
|
102
|
+
it("resolves a blank base to the default branch's merge-base with HEAD", async () => {
|
|
103
|
+
const repo = repoAt()
|
|
104
|
+
const fork = repo.resolveRef("HEAD")!
|
|
105
|
+
repo.updateRef("main", fork)
|
|
106
|
+
repo.writeFile("a.txt", "a")
|
|
107
|
+
repo.commitAllWithPrefix("feat: ahead of main")
|
|
108
|
+
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
|
|
109
|
+
if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
|
|
110
|
+
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
111
|
+
if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
|
|
112
|
+
expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("pins the merge-base, not the named base, when the base has diverged", async () => {
|
|
116
|
+
const repo = repoAt()
|
|
117
|
+
const fork = repo.resolveRef("HEAD")!
|
|
118
|
+
repo.writeFile("other.txt", "o")
|
|
119
|
+
repo.commitAllWithPrefix("feat: other branch")
|
|
120
|
+
repo.updateRef("other", repo.resolveRef("HEAD")!)
|
|
121
|
+
repo.hardResetTo(fork)
|
|
122
|
+
repo.writeFile("mine.txt", "m")
|
|
123
|
+
repo.commitAllWithPrefix("feat: my branch")
|
|
124
|
+
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "other" } })
|
|
125
|
+
if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
|
|
126
|
+
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
127
|
+
if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
|
|
128
|
+
expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("refuses a base that does not resolve", async () => {
|
|
132
|
+
const plan = await start(repoAt(), "idle", "human", { ...REVIEW, vars: { base: "nope" } })
|
|
133
|
+
expect(plan).toEqual({
|
|
134
|
+
kind: "refusal",
|
|
135
|
+
message: 'gtd test: "nope" does not resolve to a commit',
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("refuses a base that shares no common ancestor with HEAD", async () => {
|
|
140
|
+
const repo = repoAt()
|
|
141
|
+
repo.updateRef("old-root", repo.resolveRef("HEAD")!)
|
|
142
|
+
repo.mixedResetTo("4b825dc642cb6eb9a060e54bf8d69288fbee4904")
|
|
143
|
+
repo.writeFile("gtd.config.ts", WORKFLOW)
|
|
144
|
+
repo.commitAllWithPrefix("chore: a second root")
|
|
145
|
+
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "old-root" } })
|
|
146
|
+
expect(plan).toEqual({
|
|
147
|
+
kind: "refusal",
|
|
148
|
+
message: 'gtd test: "old-root" shares no common ancestor with HEAD',
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("refuses when HEAD has no commits beyond the base", async () => {
|
|
153
|
+
const repo = repoAt()
|
|
154
|
+
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
|
|
155
|
+
expect(plan).toEqual({
|
|
156
|
+
kind: "refusal",
|
|
157
|
+
message: "gtd test: nothing to review: HEAD has no commits beyond main",
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("succeeds and emits a commitAll + commit-outcome LandStep pair, data only", async () => {
|
|
163
|
+
const repo = repoAt()
|
|
164
|
+
const plan = await start(repo, "idle", "human", {
|
|
165
|
+
workflow: "working",
|
|
166
|
+
commandLabel: "gtd test",
|
|
167
|
+
vars: {},
|
|
168
|
+
})
|
|
169
|
+
if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
|
|
170
|
+
expect(plan.subject).toBe("gtd(human): work")
|
|
171
|
+
expect(plan.steps).toEqual([
|
|
172
|
+
{
|
|
173
|
+
kind: "gitWrite",
|
|
174
|
+
write: {
|
|
175
|
+
kind: "commitAll",
|
|
176
|
+
message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format: 1",
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{ kind: "outcome", outcome: { kind: "commit", subject: "gtd(human): work" } },
|
|
180
|
+
])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("folds --var overrides into a Gtd-Var: trailer on the commit message, never the outcome subject", async () => {
|
|
184
|
+
const repo = repoAt()
|
|
185
|
+
const plan = await start(repo, "idle", "human", {
|
|
186
|
+
workflow: "working",
|
|
187
|
+
commandLabel: "gtd test",
|
|
188
|
+
vars: { base: "main" },
|
|
189
|
+
})
|
|
190
|
+
if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
|
|
191
|
+
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
192
|
+
if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
|
|
193
|
+
expect(write.write.message).toBe(
|
|
194
|
+
"gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format: 1",
|
|
195
|
+
)
|
|
196
|
+
const outcome = plan.steps.find((s) => s.kind === "outcome")
|
|
197
|
+
expect(outcome).toEqual({
|
|
198
|
+
kind: "outcome",
|
|
199
|
+
outcome: { kind: "commit", subject: "gtd(human): work" },
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Effect, Option } from "effect"
|
|
2
|
+
import { GtdUsageError, Narrator, GitService, Host, Workspace } from "../platform/index.js"
|
|
3
|
+
import {
|
|
4
|
+
ConfigDiscovery,
|
|
5
|
+
ConfigService,
|
|
6
|
+
multilineSetting,
|
|
7
|
+
resolveVars,
|
|
8
|
+
type ConfigFingerprint,
|
|
9
|
+
type ResolvedWorkflow,
|
|
10
|
+
} from "../workflow/index.js"
|
|
11
|
+
import { formatCommitMessage, formatSubject } from "../replay/index.js"
|
|
12
|
+
import type { LandStep } from "./LandStep.js"
|
|
13
|
+
|
|
14
|
+
/** Just enough of the current rest for `planStart`'s checks. */
|
|
15
|
+
export interface StartCurrent {
|
|
16
|
+
/** The step the process rests at. */
|
|
17
|
+
readonly state: string
|
|
18
|
+
/** Whether no process is underway: the default workflow's first step, first visit. */
|
|
19
|
+
readonly idle: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type StartOutcome =
|
|
23
|
+
| { readonly kind: "refusal"; readonly message: string }
|
|
24
|
+
| {
|
|
25
|
+
readonly kind: "start"
|
|
26
|
+
readonly workflow: string
|
|
27
|
+
readonly subject: string
|
|
28
|
+
/** The `LandStep`s a driver runs to write the opening commit — data, never shell text. */
|
|
29
|
+
readonly steps: readonly LandStep[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Refused = Extract<StartOutcome, { kind: "refusal" }>
|
|
33
|
+
|
|
34
|
+
const refusal = (message: string): Refused => ({ kind: "refusal", message })
|
|
35
|
+
|
|
36
|
+
const checkOverrides = (
|
|
37
|
+
commandLabel: string,
|
|
38
|
+
varOverrides: Record<string, string>,
|
|
39
|
+
declaredNames: readonly string[],
|
|
40
|
+
envNames: readonly string[],
|
|
41
|
+
): Effect.Effect<StartOutcome | undefined, Error> =>
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
const environmentOnly = Object.keys(varOverrides).filter(
|
|
44
|
+
(n) => !declaredNames.includes(n) && envNames.includes(n),
|
|
45
|
+
)
|
|
46
|
+
if (environmentOnly.length > 0) {
|
|
47
|
+
return yield* Effect.fail(
|
|
48
|
+
new GtdUsageError(
|
|
49
|
+
`${commandLabel}: --var pins process settings only; ${environmentOnly.join(", ")} ${
|
|
50
|
+
environmentOnly.length === 1 ? "is an environment setting" : "are environment settings"
|
|
51
|
+
} — set it as a workflow option in gtd.config.ts or with a GTD_<NAME> environment variable`,
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n))
|
|
56
|
+
if (undeclared.length > 0) {
|
|
57
|
+
return refusal(
|
|
58
|
+
`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(
|
|
59
|
+
", ",
|
|
60
|
+
)} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `gtd --workflow <name>`: start a brand NEW process on a workflow, writing an
|
|
68
|
+
* opening commit that names the workflow's first step and carries a
|
|
69
|
+
* `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
|
|
70
|
+
* a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base,
|
|
71
|
+
* and a `Gtd-Config:` trailer per config file.
|
|
72
|
+
* All validation is a REFUSAL, not an Effect failure.
|
|
73
|
+
*/
|
|
74
|
+
export const planStart = (
|
|
75
|
+
current: StartCurrent,
|
|
76
|
+
actor: string,
|
|
77
|
+
start: {
|
|
78
|
+
readonly workflow: ResolvedWorkflow
|
|
79
|
+
/** The workflow's first step, or why it will not start a process. */
|
|
80
|
+
readonly first: { readonly firstStep: string } | { readonly refusal: string }
|
|
81
|
+
readonly commandLabel: string
|
|
82
|
+
readonly vars: Record<string, string>
|
|
83
|
+
readonly config: ConfigFingerprint
|
|
84
|
+
},
|
|
85
|
+
): Effect.Effect<
|
|
86
|
+
StartOutcome,
|
|
87
|
+
Error,
|
|
88
|
+
GitService | ConfigService | ConfigDiscovery | Workspace | Host | Narrator
|
|
89
|
+
> =>
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
const { workflow, first, commandLabel, vars: varOverrides } = start
|
|
92
|
+
const name = workflow.name
|
|
93
|
+
|
|
94
|
+
if (!current.idle) {
|
|
95
|
+
return refusal(
|
|
96
|
+
`${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before starting another`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ("refusal" in first) return refusal(`${commandLabel}: ${first.refusal}`)
|
|
101
|
+
const { firstStep } = first
|
|
102
|
+
|
|
103
|
+
const overrideRefusal = yield* checkOverrides(
|
|
104
|
+
commandLabel,
|
|
105
|
+
varOverrides,
|
|
106
|
+
Object.keys(workflow.vars),
|
|
107
|
+
Object.keys(workflow.env),
|
|
108
|
+
)
|
|
109
|
+
if (overrideRefusal !== undefined) return overrideRefusal
|
|
110
|
+
|
|
111
|
+
const baseOf = workflow.def.base
|
|
112
|
+
const vars = resolveVars(workflow.vars, varOverrides, (yield* Host).env)
|
|
113
|
+
const multiline = multilineSetting(vars)
|
|
114
|
+
if (multiline !== undefined) {
|
|
115
|
+
return refusal(
|
|
116
|
+
`${commandLabel}: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
const template = yield* Effect.try({
|
|
120
|
+
try: () => baseOf?.(name, vars),
|
|
121
|
+
catch: (e) => new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`),
|
|
122
|
+
})
|
|
123
|
+
const base = template === undefined ? undefined : yield* resolveBase(commandLabel, template)
|
|
124
|
+
if (typeof base === "object") return base
|
|
125
|
+
|
|
126
|
+
const subject = formatSubject(actor, firstStep)
|
|
127
|
+
const message = formatCommitMessage({
|
|
128
|
+
actor,
|
|
129
|
+
to: firstStep,
|
|
130
|
+
workflow: name,
|
|
131
|
+
...(base !== undefined ? { reviewBase: base } : {}),
|
|
132
|
+
vars,
|
|
133
|
+
config: start.config,
|
|
134
|
+
})
|
|
135
|
+
const steps: readonly LandStep[] = [
|
|
136
|
+
{ kind: "gitWrite", write: { kind: "commitAll", message } },
|
|
137
|
+
{ kind: "outcome", outcome: { kind: "commit", subject } },
|
|
138
|
+
]
|
|
139
|
+
return { kind: "start", workflow: name, subject, steps } as const
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The workflow's diff base: `git merge-base <base> HEAD`, where a blank base
|
|
144
|
+
* means the default branch. The merge-base is what gets pinned, so a base on
|
|
145
|
+
* another branch reviews exactly this branch's own commits.
|
|
146
|
+
*/
|
|
147
|
+
const resolveBase = (
|
|
148
|
+
commandLabel: string,
|
|
149
|
+
value: string,
|
|
150
|
+
): Effect.Effect<string | Refused, Error, GitService> =>
|
|
151
|
+
Effect.gen(function* () {
|
|
152
|
+
const git = yield* GitService
|
|
153
|
+
const rendered = value.trim()
|
|
154
|
+
const target = rendered === "" ? yield* git.defaultBranch() : rendered
|
|
155
|
+
const resolvedBase = yield* Effect.either(git.resolveRef(target))
|
|
156
|
+
if (resolvedBase._tag === "Left") {
|
|
157
|
+
return refusal(`${commandLabel}: "${target}" does not resolve to a commit`)
|
|
158
|
+
}
|
|
159
|
+
const mergeBase = yield* git.mergeBase(resolvedBase.right, "HEAD")
|
|
160
|
+
if (Option.isNone(mergeBase)) {
|
|
161
|
+
return refusal(`${commandLabel}: "${target}" shares no common ancestor with HEAD`)
|
|
162
|
+
}
|
|
163
|
+
if (mergeBase.value === (yield* git.resolveRef("HEAD"))) {
|
|
164
|
+
return refusal(`${commandLabel}: nothing to review: HEAD has no commits beyond ${target}`)
|
|
165
|
+
}
|
|
166
|
+
return mergeBase.value
|
|
167
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { planStep } from "./planStep.js"
|
|
3
|
+
import { snapshot } from "./snapshot.fixture.js"
|
|
4
|
+
|
|
5
|
+
const commitTo = (from: string, to: string, actor = "human") =>
|
|
6
|
+
({
|
|
7
|
+
kind: "commit",
|
|
8
|
+
to,
|
|
9
|
+
spec: { actor, from, to, step: { name: from, occurrence: 1 } },
|
|
10
|
+
}) as const
|
|
11
|
+
|
|
12
|
+
describe("planStep — refusal and no-op pass through", () => {
|
|
13
|
+
it("a refused landing becomes a refusal outcome", () => {
|
|
14
|
+
const s = snapshot({
|
|
15
|
+
state: "building",
|
|
16
|
+
stepDef: {},
|
|
17
|
+
landing: { kind: "refusal", message: "gtd land: nope" },
|
|
18
|
+
})
|
|
19
|
+
expect(planStep(s)).toEqual({ kind: "refusal", message: "gtd land: nope" })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("a no-op keeps its settled flag", () => {
|
|
23
|
+
const s = snapshot({ state: "gate", stepDef: {}, landing: { kind: "noop", settled: true } })
|
|
24
|
+
expect(planStep(s)).toEqual({ kind: "noop", state: "gate", settled: true })
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("planStep — commit", () => {
|
|
29
|
+
it("an attempt commits the subject with no step trailer and bypasses guards", () => {
|
|
30
|
+
const s = snapshot({
|
|
31
|
+
state: "await-answers",
|
|
32
|
+
stepDef: { actor: "agent", kind: "prompt", mode: "qa" },
|
|
33
|
+
actor: "agent",
|
|
34
|
+
landing: { kind: "attempt", subject: "gtd(agent): await-answers" },
|
|
35
|
+
})
|
|
36
|
+
const outcome = planStep(s)
|
|
37
|
+
if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
|
|
38
|
+
expect(outcome.to).toBe("await-answers")
|
|
39
|
+
expect(outcome.steps).toEqual([
|
|
40
|
+
{
|
|
41
|
+
kind: "gitWrite",
|
|
42
|
+
write: { kind: "commitAll", message: "gtd(agent): await-answers\n\nGtd-Format: 1" },
|
|
43
|
+
},
|
|
44
|
+
{ kind: "outcome", outcome: { kind: "commit", subject: "gtd(agent): await-answers" } },
|
|
45
|
+
])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("a transition commit carries the step trailer and reports both steps", () => {
|
|
49
|
+
const s = snapshot({
|
|
50
|
+
state: "building",
|
|
51
|
+
stepDef: {},
|
|
52
|
+
changes: [{ status: "M", path: ".gtd/FILE.md" }],
|
|
53
|
+
landing: commitTo("building", "done"),
|
|
54
|
+
})
|
|
55
|
+
const outcome = planStep(s)
|
|
56
|
+
if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
|
|
57
|
+
expect(outcome.subject).toBe("gtd(human): building → done")
|
|
58
|
+
expect(outcome.steps[0]).toEqual({
|
|
59
|
+
kind: "gitWrite",
|
|
60
|
+
write: {
|
|
61
|
+
kind: "commitAll",
|
|
62
|
+
message: "gtd(human): building → done\n\nGtd-Step: building#1\nGtd-Format: 1",
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
expect(outcome.steps.at(-1)).toEqual({
|
|
66
|
+
kind: "outcome",
|
|
67
|
+
outcome: { kind: "transition", from: "building", to: "done" },
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("prepends `gtd uncheck` at the human review gate", () => {
|
|
72
|
+
const s = snapshot({
|
|
73
|
+
state: "await-review",
|
|
74
|
+
stepDef: { mode: "review", file: ".gtd/REVIEW.md" },
|
|
75
|
+
changes: [{ status: "M", path: "src/a.ts" }],
|
|
76
|
+
landing: commitTo("await-review", "deciding"),
|
|
77
|
+
})
|
|
78
|
+
const outcome = planStep(s)
|
|
79
|
+
if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
|
|
80
|
+
expect(outcome.steps[0]).toEqual({ kind: "uncheck", file: ".gtd/REVIEW.md" })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("records Gtd-Cost, then one Gtd-Judge per verdict", () => {
|
|
84
|
+
const s = snapshot({ state: "a", stepDef: {}, landing: commitTo("a", "b", "judge") })
|
|
85
|
+
const outcome = planStep(s, {
|
|
86
|
+
cost: 1.5,
|
|
87
|
+
model: "m",
|
|
88
|
+
judge: [
|
|
89
|
+
{ id: "q1", answer: true, p: 0.9 },
|
|
90
|
+
{ id: "q2", answer: "x", p: 0.4 },
|
|
91
|
+
],
|
|
92
|
+
})
|
|
93
|
+
if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
|
|
94
|
+
const write = outcome.steps.find((step) => step.kind === "gitWrite")
|
|
95
|
+
if (write?.kind !== "gitWrite") throw new Error("expected a git write")
|
|
96
|
+
const trailers = write.write.message.split("\n").slice(2)
|
|
97
|
+
expect(trailers[0]).toBe("Gtd-Step: a#1")
|
|
98
|
+
expect(trailers[1]).toBe("Gtd-Cost: 1.5 m")
|
|
99
|
+
expect(trailers[2]).toBe('Gtd-Judge: {"id":"q1","answer":true,"p":0.9}')
|
|
100
|
+
expect(trailers[3]).toBe('Gtd-Judge: {"id":"q2","answer":"x","p":0.4}')
|
|
101
|
+
expect(trailers[4]).toBe("Gtd-Format: 1")
|
|
102
|
+
expect(trailers).toHaveLength(5)
|
|
103
|
+
})
|
|
104
|
+
})
|