@pmelab/gtd 24.0.0 → 24.2.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 +42 -13
- package/claude/hooks/access.ts +26 -1
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- 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,109 @@
|
|
|
1
|
+
import { Cause, Effect, Exit } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { resolveScopeAccess, resolveScopeSkills } from "./skills.js"
|
|
4
|
+
|
|
5
|
+
const FILE = "/repo/gtd.config.ts"
|
|
6
|
+
|
|
7
|
+
const failure = async (skills: (vars: Record<string, string>) => unknown): Promise<string> => {
|
|
8
|
+
const exit = await Effect.runPromiseExit(
|
|
9
|
+
resolveScopeSkills({ skills: skills as never, configuredSkills: {}, skillsOrigin: FILE }, {}),
|
|
10
|
+
)
|
|
11
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
12
|
+
return Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : ""
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("resolveScopeSkills", () => {
|
|
16
|
+
it("wraps a throwing export with the config prefix and file", async () => {
|
|
17
|
+
const message = await failure(() => {
|
|
18
|
+
throw new Error("boom")
|
|
19
|
+
})
|
|
20
|
+
expect(message).toContain(`gtd config:\n - ${FILE}: boom`)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("rejects a non-object return", async () => {
|
|
24
|
+
expect(await failure(() => ["a"])).toContain(
|
|
25
|
+
`gtd config:\n - ${FILE}: skills export must return an object of scope -> skill names`,
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("rejects a non-array value", async () => {
|
|
30
|
+
expect(await failure(() => ({ a: "x" }))).toContain(
|
|
31
|
+
`gtd config:\n - ${FILE}: skills export: "a" must be an array of skill names`,
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("rejects a non-string entry", async () => {
|
|
36
|
+
expect(await failure(() => ({ a: ["x", 1] }))).toContain(
|
|
37
|
+
`gtd config:\n - ${FILE}: skills export: "a" must be an array of skill names`,
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("reports an unknown config key at gtd.config.ts", async () => {
|
|
42
|
+
const exit = await Effect.runPromiseExit(
|
|
43
|
+
resolveScopeSkills(
|
|
44
|
+
{ skills: () => ({ a: ["x"] }), configuredSkills: { nope: [] }, skillsOrigin: FILE },
|
|
45
|
+
{},
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
expect(String(Exit.isFailure(exit) ? Cause.squash(exit.cause) : "")).toContain(
|
|
49
|
+
`${FILE}: config.skills.nope: "skills.nope" is not a scope that runs a turn — known scopes: a`,
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("resolveScopeAccess", () => {
|
|
55
|
+
const run = (
|
|
56
|
+
def: {
|
|
57
|
+
access: (vars: Record<string, string>) => unknown
|
|
58
|
+
configuredAccess?: Record<string, object>
|
|
59
|
+
},
|
|
60
|
+
skills: Record<string, readonly string[]> = {},
|
|
61
|
+
) =>
|
|
62
|
+
Effect.runPromiseExit(
|
|
63
|
+
resolveScopeAccess(
|
|
64
|
+
{
|
|
65
|
+
access: def.access as never,
|
|
66
|
+
configuredAccess: def.configuredAccess ?? {},
|
|
67
|
+
skillsOrigin: FILE,
|
|
68
|
+
},
|
|
69
|
+
skills,
|
|
70
|
+
{},
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
const message = (exit: Awaited<ReturnType<typeof run>>): string =>
|
|
74
|
+
Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : ""
|
|
75
|
+
|
|
76
|
+
it("returns a well-shaped record", async () => {
|
|
77
|
+
const exit = await run({ access: () => ({ a: { write: ["x"] }, b: {} }) })
|
|
78
|
+
expect(Exit.isSuccess(exit) && exit.value).toEqual({ a: { write: ["x"] }, b: {} })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("rejects a malformed export with the file", async () => {
|
|
82
|
+
expect(message(await run({ access: () => ["a"] }))).toContain(
|
|
83
|
+
`gtd config:\n - ${FILE}: access export must return an object`,
|
|
84
|
+
)
|
|
85
|
+
expect(message(await run({ access: () => ({ a: { write: "x" } }) }))).toContain(
|
|
86
|
+
`${FILE}: access export: "a": access.write must be an array`,
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("knows the scopes of both the skills and access exports", async () => {
|
|
91
|
+
const exit = await run(
|
|
92
|
+
{
|
|
93
|
+
access: () => ({ fromAccess: {} }),
|
|
94
|
+
configuredAccess: { fromSkills: {}, fromAccess: {} },
|
|
95
|
+
},
|
|
96
|
+
{ fromSkills: ["s"] },
|
|
97
|
+
)
|
|
98
|
+
expect(Exit.isSuccess(exit)).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("reports an unknown key with the exact message and known scopes", async () => {
|
|
102
|
+
const text = message(
|
|
103
|
+
await run({ access: () => ({ b: {} }), configuredAccess: { nope: {} } }, { a: ["x"] }),
|
|
104
|
+
)
|
|
105
|
+
expect(text).toContain(
|
|
106
|
+
`${FILE}: config.access.nope: "access.nope" is not a scope that runs a turn — known scopes: a, b`,
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { GtdError } from "../platform/index.js"
|
|
3
|
+
import type { WorkflowDefinition } from "./Workflow.js"
|
|
4
|
+
import { accessShapeFault } from "../replay/index.js"
|
|
5
|
+
import type { ScopeAccess } from "../flows/index.js"
|
|
6
|
+
import type { Diagnostic } from "./Diagnostic.js"
|
|
7
|
+
import { failOnErrors } from "./load.js"
|
|
8
|
+
|
|
9
|
+
const fail = (origin: string, message: string): GtdError =>
|
|
10
|
+
new GtdError(`gtd config:\n - ${origin}: ${message}`)
|
|
11
|
+
|
|
12
|
+
const checkShape = (value: unknown): Record<string, readonly string[]> => {
|
|
13
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
14
|
+
throw new Error("skills export must return an object of scope -> skill names")
|
|
15
|
+
}
|
|
16
|
+
for (const [scope, names] of Object.entries(value)) {
|
|
17
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) {
|
|
18
|
+
throw new Error(`skills export: "${scope}" must be an array of skill names`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return value as Record<string, readonly string[]>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every `config` key that names no known scope is a load error. */
|
|
25
|
+
const checkKeys = (
|
|
26
|
+
configured: Readonly<Record<string, unknown>>,
|
|
27
|
+
origin: string,
|
|
28
|
+
known: readonly string[],
|
|
29
|
+
section: "skills" | "access",
|
|
30
|
+
): Effect.Effect<void, GtdError> => {
|
|
31
|
+
const sorted = [...known].sort()
|
|
32
|
+
const diagnostics: Diagnostic[] = Object.keys(configured)
|
|
33
|
+
.filter((key) => !sorted.includes(key))
|
|
34
|
+
.sort()
|
|
35
|
+
.map((key) => ({
|
|
36
|
+
severity: "error" as const,
|
|
37
|
+
path: ["config", section, key],
|
|
38
|
+
message: `"${section}.${key}" is not a scope that runs a turn — known scopes: ${sorted.join(", ")}`,
|
|
39
|
+
origin,
|
|
40
|
+
}))
|
|
41
|
+
return failOnErrors(diagnostics)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Skill lists for the settings actually in use. `config.skills` keys are
|
|
46
|
+
* judged here, not at config load: only now are the pinned/entry vars known.
|
|
47
|
+
*/
|
|
48
|
+
export const resolveScopeSkills = (
|
|
49
|
+
def: Pick<WorkflowDefinition, "skills" | "configuredSkills" | "skillsOrigin">,
|
|
50
|
+
vars: Readonly<Record<string, string>>,
|
|
51
|
+
): Effect.Effect<Record<string, readonly string[]>, GtdError> =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const skills = yield* Effect.try({
|
|
54
|
+
try: () => checkShape(def.skills(vars)),
|
|
55
|
+
catch: (e) => fail(def.skillsOrigin, e instanceof Error ? e.message : String(e)),
|
|
56
|
+
})
|
|
57
|
+
const known = Object.keys(skills)
|
|
58
|
+
yield* checkKeys(def.configuredSkills, def.skillsOrigin, known, "skills")
|
|
59
|
+
return skills
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const checkAccessShape = (value: unknown): Record<string, ScopeAccess> => {
|
|
63
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
64
|
+
throw new Error("access export must return an object of scope -> { read?, write? }")
|
|
65
|
+
}
|
|
66
|
+
for (const [scope, access] of Object.entries(value)) {
|
|
67
|
+
const fault = accessShapeFault(access)
|
|
68
|
+
if (fault !== undefined) throw new Error(`access export: "${scope}": ${fault}`)
|
|
69
|
+
}
|
|
70
|
+
return value as Record<string, ScopeAccess>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Access for the settings actually in use. A `config.access` key may name
|
|
75
|
+
* any scope the workflow gives skills or access to.
|
|
76
|
+
*/
|
|
77
|
+
export const resolveScopeAccess = (
|
|
78
|
+
def: Pick<WorkflowDefinition, "access" | "configuredAccess" | "skillsOrigin">,
|
|
79
|
+
skills: Readonly<Record<string, readonly string[]>>,
|
|
80
|
+
vars: Readonly<Record<string, string>>,
|
|
81
|
+
): Effect.Effect<Record<string, ScopeAccess>, GtdError> =>
|
|
82
|
+
Effect.gen(function* () {
|
|
83
|
+
const access = yield* Effect.try({
|
|
84
|
+
try: () => checkAccessShape(def.access(vars)),
|
|
85
|
+
catch: (e) => fail(def.skillsOrigin, e instanceof Error ? e.message : String(e)),
|
|
86
|
+
})
|
|
87
|
+
const known = [...new Set([...Object.keys(skills), ...Object.keys(access)])]
|
|
88
|
+
yield* checkKeys(def.configuredAccess, def.skillsOrigin, known, "access")
|
|
89
|
+
return access
|
|
90
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const PREFIX = "GTD_"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One kind of setting, later wins: the workflow's own defaults, `--var`
|
|
5
|
+
* overrides (process settings only; environment settings pass `{}`), then
|
|
6
|
+
* `GTD_<NAME>` for any name an earlier layer declared (a shell variable never
|
|
7
|
+
* introduces a name).
|
|
8
|
+
*/
|
|
9
|
+
export const resolveVars = (
|
|
10
|
+
workflowVars: Readonly<Record<string, string>>,
|
|
11
|
+
entryVars: Readonly<Record<string, string>>,
|
|
12
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
13
|
+
): Record<string, string> => {
|
|
14
|
+
const merged = { ...workflowVars, ...entryVars }
|
|
15
|
+
for (const name of Object.keys(merged)) {
|
|
16
|
+
const value = env[PREFIX + name.toUpperCase()]
|
|
17
|
+
if (value !== undefined) merged[name] = value
|
|
18
|
+
}
|
|
19
|
+
return merged
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The first process setting whose value spans lines — a `Gtd-Var:` trailer cannot carry it. */
|
|
23
|
+
export const multilineSetting = (vars: Readonly<Record<string, string>>): string | undefined =>
|
|
24
|
+
Object.keys(vars)
|
|
25
|
+
.sort()
|
|
26
|
+
.find((name) => /[\r\n]/.test(vars[name]!))
|
|
27
|
+
|
|
28
|
+
const SETTING_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
29
|
+
|
|
30
|
+
/** A name is written verbatim into a `Gtd-Var: <name>=<value>` trailer, so `=` or a newline would corrupt it. */
|
|
31
|
+
export const isSettingName = (name: string): boolean => SETTING_NAME.test(name)
|
|
32
|
+
|
|
33
|
+
export const SETTING_NAME_RULE = 'a setting name is a letter or "_", then letters, digits or "_"'
|
|
@@ -1,38 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
afterEach(() => installContext(undefined))
|
|
7
|
-
|
|
8
|
-
const FILES: Readonly<Record<string, string>> = {
|
|
9
|
-
".gtd/REVIEW.md": "# Review: abc1234\n",
|
|
10
|
-
".gtd/REVIEW.orig.md": "# Review: abc1234\n",
|
|
11
|
-
}
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { testRepo } from "../../testkit/index.js"
|
|
3
|
+
import { CONFIG_SCAFFOLD } from "../../install/index.js"
|
|
4
|
+
import { reviewDoc } from "../shared/config.fixture.js"
|
|
12
5
|
|
|
13
6
|
describe("an ordinary feature start", () => {
|
|
7
|
+
it("unwinds a captured TODO, checks the baseline and starts design from it", async () => {
|
|
8
|
+
const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
|
|
9
|
+
repo.write("TODO.md", "# Add numbers\n\nadd(a, b) returns the sum.\n")
|
|
10
|
+
const rest = await repo.drive({ unwind: () => undefined, "start-gate.check": () => undefined })
|
|
11
|
+
expect(rest).toMatchObject({
|
|
12
|
+
state: "design.triage",
|
|
13
|
+
actor: "agent",
|
|
14
|
+
file: ".gtd/REQUIREMENTS.md",
|
|
15
|
+
})
|
|
16
|
+
expect(repo.subjects().slice(1)).toEqual([
|
|
17
|
+
"gtd(human): idle → unwind",
|
|
18
|
+
"gtd(check): unwind → start-gate.check",
|
|
19
|
+
"gtd(check): start-gate.check → design.triage",
|
|
20
|
+
])
|
|
21
|
+
})
|
|
22
|
+
|
|
14
23
|
it("refuses a pending hand-off right after idle, before unwinding", async () => {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return Promise.resolve()
|
|
23
|
-
},
|
|
24
|
-
refuse: (message: string): never => {
|
|
25
|
-
throw new Error(message)
|
|
26
|
-
},
|
|
27
|
-
pushScope: () => undefined,
|
|
28
|
-
popScope: () => undefined,
|
|
29
|
-
read: (path) => FILES[path],
|
|
30
|
-
head: () => "c1",
|
|
31
|
-
start: () => "c0",
|
|
32
|
-
},
|
|
33
|
-
),
|
|
34
|
-
)
|
|
35
|
-
await expect(ordinaryStart()).rejects.toThrow(".gtd/REVIEW.orig.md is a pending hand-off")
|
|
36
|
-
expect(steps.map((s) => ("name" in s ? s.name : s.kind))).toEqual(["idle"])
|
|
24
|
+
const repo = testRepo({
|
|
25
|
+
workflow: CONFIG_SCAFFOLD,
|
|
26
|
+
files: { ".gtd/REVIEW.md": reviewDoc("note"), ".gtd/REVIEW.orig.md": reviewDoc("orig") },
|
|
27
|
+
})
|
|
28
|
+
repo.write("TODO.md", "# Add numbers\n")
|
|
29
|
+
await expect(repo.land()).rejects.toThrow(".gtd/REVIEW.orig.md is a pending hand-off")
|
|
30
|
+
expect(repo.subjects()).toEqual(["chore: initial commit"])
|
|
37
31
|
})
|
|
38
32
|
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { feature } from "./feature.js"
|
|
2
|
+
|
|
3
|
+
export * from "./steps.js"
|
|
4
|
+
export * from "./planning.js"
|
|
5
|
+
export * from "./packages.js"
|
|
6
|
+
export { buildTail, type ReviewOutcome } from "./review.js"
|
|
7
|
+
export * from "./feature.js"
|
|
8
|
+
export * from "../shared/reserved.js"
|
|
9
|
+
|
|
10
|
+
export default feature
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Feature: fix fixes the notes review hands it
|
|
2
|
+
|
|
3
|
+
`review` and `fix` meet only in `.gtd/REVIEW.md`: a review the human leaves
|
|
4
|
+
a note in ends with the reviewer's copy kept beside it, and `gtd door fix`
|
|
5
|
+
picks both up.
|
|
6
|
+
|
|
7
|
+
Background:
|
|
8
|
+
Given the shipped workflows, as gtd install scaffolds them
|
|
9
|
+
And an environment variable "GTD_QUALITYREVIEWS" set to ""
|
|
10
|
+
And a file "src/calc.ts" with:
|
|
11
|
+
"""
|
|
12
|
+
export const add = (a: number, b: number) => a + b
|
|
13
|
+
"""
|
|
14
|
+
And the working tree is committed as "feat: add calculator"
|
|
15
|
+
|
|
16
|
+
Scenario: a note left in review is fixed, and both review files are removed
|
|
17
|
+
Given step "review-gate.check" changes nothing
|
|
18
|
+
And step "build.health.check" changes nothing
|
|
19
|
+
And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
|
|
20
|
+
"""
|
|
21
|
+
# Review: abc1234
|
|
22
|
+
|
|
23
|
+
<!-- base: 0000000 -->
|
|
24
|
+
|
|
25
|
+
## calc
|
|
26
|
+
|
|
27
|
+
- [ ] ./src/calc.ts#1-1
|
|
28
|
+
new add function
|
|
29
|
+
"""
|
|
30
|
+
When gtd opens door "review" with "HEAD~1"
|
|
31
|
+
And the process is driven
|
|
32
|
+
Then the process rests at "build.review.await-review"
|
|
33
|
+
|
|
34
|
+
Given a file ".gtd/REVIEW.md" with:
|
|
35
|
+
"""
|
|
36
|
+
# Review: abc1234
|
|
37
|
+
|
|
38
|
+
<!-- base: 0000000 -->
|
|
39
|
+
|
|
40
|
+
## calc
|
|
41
|
+
|
|
42
|
+
- [ ] ./src/calc.ts#1-1
|
|
43
|
+
new add function — rename it to sum
|
|
44
|
+
"""
|
|
45
|
+
And step "build.review.handing-off" runs its script
|
|
46
|
+
When gtd lands
|
|
47
|
+
And the process is driven
|
|
48
|
+
Then the process rests at "idle"
|
|
49
|
+
And ".gtd/REVIEW.orig.md" contains "new add function"
|
|
50
|
+
|
|
51
|
+
Given step "notes.fixing" writes "src/calc.ts" with:
|
|
52
|
+
"""
|
|
53
|
+
export const sum = (a: number, b: number) => a + b
|
|
54
|
+
"""
|
|
55
|
+
And step "notes.closing" runs its script
|
|
56
|
+
When gtd opens door "fix"
|
|
57
|
+
Then the resting step's content contains "rename it to sum"
|
|
58
|
+
# The fix goes to its own review, which the same scripted reviewer writes
|
|
59
|
+
# afresh — the handed-off review itself is gone, as the reviewer's copy shows.
|
|
60
|
+
When the process is driven
|
|
61
|
+
Then the process rests at "build.review.await-review"
|
|
62
|
+
And the commit subjects end with:
|
|
63
|
+
"""
|
|
64
|
+
gtd(human): notes.fixing
|
|
65
|
+
gtd(agent): notes.fixing → notes.closing
|
|
66
|
+
gtd(check): notes.closing → build.health.check
|
|
67
|
+
gtd(check): build.health.check → build.review.reviewing
|
|
68
|
+
gtd(agent): build.review.reviewing → build.review.await-review
|
|
69
|
+
"""
|
|
70
|
+
And ".gtd/REVIEW.orig.md" does not exist
|
|
71
|
+
And ".gtd/REVIEW.md" contains "new add function"
|
|
72
|
+
And "src/calc.ts" contains "sum"
|
|
73
|
+
|
|
74
|
+
Scenario: a reviewer's copy with no review is refused
|
|
75
|
+
Given a file ".gtd/REVIEW.orig.md" with:
|
|
76
|
+
"""
|
|
77
|
+
# Review: abc1234
|
|
78
|
+
"""
|
|
79
|
+
And the working tree is committed as "chore: stray copy"
|
|
80
|
+
Then opening door "fix" is refused with "has no .gtd/REVIEW.md"
|
|
@@ -1,94 +1,74 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
afterEach(() => installContext(undefined))
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { runsItsScript, testRepo } from "../../testkit/index.js"
|
|
3
|
+
import { CONFIG_SCAFFOLD } from "../../install/index.js"
|
|
4
|
+
import { reviewDoc } from "../shared/config.fixture.js"
|
|
7
5
|
|
|
8
6
|
const REVIEW = ".gtd/REVIEW.md"
|
|
9
7
|
const ORIG = ".gtd/REVIEW.orig.md"
|
|
8
|
+
const CALC = "export const add = (a: number, b: number) => a + b\n"
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class Stop extends Error {}
|
|
23
|
-
|
|
24
|
-
/** Run `fix` over `files` until its third step; returns the steps it asked for. */
|
|
25
|
-
const drive = async (files: Readonly<Record<string, string>>): Promise<StepRequest[]> => {
|
|
26
|
-
const steps: StepRequest[] = []
|
|
27
|
-
installContext(
|
|
28
|
-
fixtureContext(
|
|
29
|
-
{},
|
|
30
|
-
{
|
|
31
|
-
step: (request: StepRequest) => {
|
|
32
|
-
steps.push(request)
|
|
33
|
-
if (steps.length >= 3) throw new Stop()
|
|
34
|
-
return Promise.resolve()
|
|
35
|
-
},
|
|
36
|
-
refuse: (message: string): never => {
|
|
37
|
-
throw new Error(message)
|
|
38
|
-
},
|
|
39
|
-
pushScope: () => undefined,
|
|
40
|
-
popScope: () => undefined,
|
|
41
|
-
// Closing removes both review files, as the real tree would show.
|
|
42
|
-
read: (path) =>
|
|
43
|
-
steps.some((s) => "name" in s && s.name === "closing") ? undefined : files[path],
|
|
44
|
-
head: () => "c1",
|
|
45
|
-
start: () => "c0",
|
|
10
|
+
describe("the fix workflow", () => {
|
|
11
|
+
it("with no handed-off review, repairs a red baseline and hands it to review", async () => {
|
|
12
|
+
const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
|
|
13
|
+
await repo.start("fix")
|
|
14
|
+
const rest = await repo.drive({
|
|
15
|
+
"fix-precheck": (_, r) => r.write(".gtd/FEEDBACK.md", "1 test failing\n"),
|
|
16
|
+
"build.fix": (beat, r) => {
|
|
17
|
+
expect(beat.content).toContain(".gtd/FEEDBACK.md")
|
|
18
|
+
r.remove(".gtd/FEEDBACK.md")
|
|
19
|
+
r.write("src/repair.ts", "export const repaired = true\n")
|
|
46
20
|
},
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
21
|
+
"build.health.check": () => undefined,
|
|
22
|
+
})
|
|
23
|
+
expect(rest).toMatchObject({ state: "build.review.reviewing", actor: "agent" })
|
|
24
|
+
expect(repo.subjects().slice(1)).toEqual([
|
|
25
|
+
"gtd(human): fix-precheck",
|
|
26
|
+
"gtd(check): fix-precheck → build.fix",
|
|
27
|
+
"gtd(agent): build.fix → build.health.check",
|
|
28
|
+
"gtd(check): build.health.check → build.review.reviewing",
|
|
29
|
+
])
|
|
51
30
|
})
|
|
52
|
-
return steps
|
|
53
|
-
}
|
|
54
31
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
32
|
+
it("with a green baseline, has nothing to fix", async () => {
|
|
33
|
+
const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
|
|
34
|
+
await repo.start("fix")
|
|
35
|
+
const rest = await repo.drive({ "fix-precheck": () => undefined })
|
|
36
|
+
expect(rest.state).toBe("idle")
|
|
37
|
+
expect(repo.subjects().at(-1)).toBe("gtd(check): fix-precheck → idle")
|
|
59
38
|
})
|
|
60
39
|
|
|
61
|
-
it("fixes a handed-off review's notes and risks in one turn,
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
40
|
+
it("fixes a handed-off review's notes and risks in one turn, removes both review files, then checks health", async () => {
|
|
41
|
+
const repo = testRepo({
|
|
42
|
+
workflow: CONFIG_SCAFFOLD,
|
|
43
|
+
env: { GTD_QUALITYREVIEWS: "" },
|
|
44
|
+
files: {
|
|
45
|
+
"src/calc.ts": CALC,
|
|
46
|
+
[REVIEW]: reviewDoc("add — rename it to sum", "Risk: drops the carry"),
|
|
47
|
+
[ORIG]: reviewDoc("add", "Risk: drops the carry"),
|
|
48
|
+
},
|
|
65
49
|
})
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
["run", "closing"],
|
|
81
|
-
["run", "health.check"],
|
|
50
|
+
await repo.door("fix")
|
|
51
|
+
const rest = await repo.drive({
|
|
52
|
+
"notes.fixing": (beat, r) => {
|
|
53
|
+
expect(beat.content).toContain("rename it to sum")
|
|
54
|
+
expect(beat.content).toContain("Risk: drops the carry")
|
|
55
|
+
r.write("src/calc.ts", CALC.replace("add", "sum"))
|
|
56
|
+
},
|
|
57
|
+
"notes.closing": runsItsScript,
|
|
58
|
+
})
|
|
59
|
+
expect(rest.state).toBe("build.health.check")
|
|
60
|
+
expect(repo.subjects().slice(1)).toEqual([
|
|
61
|
+
"gtd(human): notes.fixing",
|
|
62
|
+
"gtd(agent): notes.fixing → notes.closing",
|
|
63
|
+
"gtd(check): notes.closing → build.health.check",
|
|
82
64
|
])
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
await expect(drive({ [ORIG]: reviewDoc("add") })).rejects.toThrow(/has no \.gtd\/REVIEW\.md/)
|
|
65
|
+
expect(repo.read("src/calc.ts")).toContain("sum")
|
|
66
|
+
expect(repo.read(REVIEW)).toBeUndefined()
|
|
67
|
+
expect(repo.read(ORIG)).toBeUndefined()
|
|
87
68
|
})
|
|
88
69
|
|
|
89
70
|
it("refuses a review with no reviewer's copy, so no note is deleted unread", async () => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
)
|
|
71
|
+
const repo = testRepo({ workflow: CONFIG_SCAFFOLD, files: { [REVIEW]: reviewDoc("add") } })
|
|
72
|
+
await expect(repo.door("fix")).rejects.toThrow(/not handed off/)
|
|
93
73
|
})
|
|
94
74
|
})
|
package/src/workflows/index.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
// The `@pmelab/gtd/workflows` entry, built on its own: core never imports it,
|
|
2
2
|
// a repository's `gtd.config.ts` does.
|
|
3
|
-
import type { Doors
|
|
3
|
+
import type { Doors } from "../flows/index.js"
|
|
4
4
|
import { feature } from "./feature/feature.js"
|
|
5
|
-
import * as t from "./shared/text.js"
|
|
6
|
-
import { ARCHITECTURE, REQUIREMENTS, REVIEW } from "./shared/steps.js"
|
|
7
5
|
|
|
8
6
|
export { threads, type ThreadInfo } from "../flows/index.js"
|
|
9
|
-
export
|
|
7
|
+
export * from "./shared/reserved.js"
|
|
10
8
|
export { ShippedOptions } from "./shared/options.js"
|
|
11
|
-
export { skills } from "./shared/skills.js"
|
|
12
|
-
export { access } from "./shared/access.js"
|
|
13
9
|
export { agentWithSkills } from "./shared/text.js"
|
|
14
10
|
export * from "./shared/steps.js"
|
|
15
11
|
export * from "./shared/health.js"
|
|
@@ -35,10 +31,6 @@ export { fix, fixFlow, fixReviewNotes } from "./fix/fix.js"
|
|
|
35
31
|
|
|
36
32
|
export default feature
|
|
37
33
|
|
|
38
|
-
export const summary: Summary = t.summaryPrompt
|
|
39
|
-
|
|
40
|
-
export const steering = { [REQUIREMENTS]: "qa", [ARCHITECTURE]: "qa", [REVIEW]: "review" }
|
|
41
|
-
|
|
42
34
|
/**
|
|
43
35
|
* @deprecated Empty: each shipped workflow builds its own doors, so a config
|
|
44
36
|
* gets `fix` and `review [base]` by exporting `fix` and `review`.
|