@pmelab/gtd 23.2.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 +44 -11
- 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 +43830 -56044
- 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 +1953 -1724
- package/package.json +70 -8
- package/skills/authoring/SKILL.md +5 -4
- 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/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -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/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +32 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +74 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +36 -2
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +99 -0
- package/src/workflows/review/review.ts +33 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/shared/workflows.steps.ts +7 -0
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -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 "_"'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { escalation, untilSettled } from "./health.js"
|
|
3
|
+
|
|
4
|
+
describe("escalation", () => {
|
|
5
|
+
it("describes and stops until `limit` rounds are spent, then reports exhaustion", async () => {
|
|
6
|
+
const log: string[] = []
|
|
7
|
+
const steps = {
|
|
8
|
+
limit: 2,
|
|
9
|
+
describe: async () => void log.push("describe"),
|
|
10
|
+
stop: async () => void log.push("stop"),
|
|
11
|
+
exhausted: async () => void log.push("exhausted"),
|
|
12
|
+
}
|
|
13
|
+
const count = { rounds: 0 }
|
|
14
|
+
for (let i = 0; i < 3; i++) await escalation(count, steps)
|
|
15
|
+
expect(log).toEqual(["describe", "stop", "describe", "stop", "exhausted"])
|
|
16
|
+
expect(count.rounds).toBe(2)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe("untilSettled", () => {
|
|
21
|
+
it("reruns while pending and resolves the first run that counts", async () => {
|
|
22
|
+
const results = [false, false, true]
|
|
23
|
+
let pending = 2
|
|
24
|
+
const green = await untilSettled(
|
|
25
|
+
async () => results.shift()!,
|
|
26
|
+
() => pending-- > 0,
|
|
27
|
+
)
|
|
28
|
+
expect(green).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { read, scope } from "../flows/index.js"
|
|
2
|
+
|
|
3
|
+
/** How many escalation rounds a run of red checks has spent — reset once the suite goes green. */
|
|
4
|
+
export interface EscalationCount {
|
|
5
|
+
rounds: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface EscalationSteps {
|
|
9
|
+
/** Rounds a person narrows before `exhausted` replaces them. */
|
|
10
|
+
readonly limit: number
|
|
11
|
+
readonly describe: () => Promise<void>
|
|
12
|
+
readonly stop: () => Promise<void>
|
|
13
|
+
readonly exhausted: () => Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
|
|
17
|
+
export const escalation = async (count: EscalationCount, steps: EscalationSteps): Promise<void> => {
|
|
18
|
+
if (count.rounds >= steps.limit) return steps.exhausted()
|
|
19
|
+
count.rounds++
|
|
20
|
+
await steps.describe()
|
|
21
|
+
await steps.stop()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Run `check` again while `pending()` holds — a run then does not count — and resolve the first run that does. */
|
|
25
|
+
export const untilSettled = async (
|
|
26
|
+
check: () => Promise<boolean>,
|
|
27
|
+
pending: () => boolean,
|
|
28
|
+
): Promise<boolean> => {
|
|
29
|
+
for (;;) {
|
|
30
|
+
const green = await check()
|
|
31
|
+
if (!pending()) return green
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Health {
|
|
36
|
+
/** One counted run of the suite; resolves `true` when green. */
|
|
37
|
+
readonly check: () => Promise<boolean>
|
|
38
|
+
/** Where `check` leaves a red run's output. */
|
|
39
|
+
readonly report: string
|
|
40
|
+
readonly fixCap: number
|
|
41
|
+
readonly fixesSoFar?: number
|
|
42
|
+
/** Whether two consecutive red reports are the same failure; `true` escalates at once. */
|
|
43
|
+
readonly sameFailure: (previous: string, current: string) => Promise<boolean>
|
|
44
|
+
/** Shared with other callers in the same run of red checks. */
|
|
45
|
+
readonly escalations: EscalationCount
|
|
46
|
+
readonly escalation: EscalationSteps
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// check() stamps the report with the commit it ran at, so equal failures differ there.
|
|
50
|
+
const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/
|
|
51
|
+
const stripStamp = (text: string): string => text.replace(STAMP, "\n")
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Loop until the suite is green: check, and on red run `fix` — escalating
|
|
55
|
+
* first once `fixCap` fixes are spent, or `sameFailure` calls the failure
|
|
56
|
+
* identical to the previous round's.
|
|
57
|
+
*/
|
|
58
|
+
export const healthy = async (fix: () => Promise<void>, health: Health): Promise<void> => {
|
|
59
|
+
let fixes = health.fixesSoFar ?? 0
|
|
60
|
+
let previous: string | undefined
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (await health.check()) {
|
|
63
|
+
health.escalations.rounds = 0
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const current = stripStamp(read(health.report) ?? "")
|
|
67
|
+
const stuck = previous !== undefined && (await health.sameFailure(previous, current))
|
|
68
|
+
previous = current
|
|
69
|
+
if (stuck || fixes >= health.fixCap) {
|
|
70
|
+
await escalation(health.escalations, health.escalation)
|
|
71
|
+
fixes = 0
|
|
72
|
+
}
|
|
73
|
+
fixes++
|
|
74
|
+
await fix()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Hold the process in scope `name` until `passes` resolves `true`, resting at `blocked` between tries. */
|
|
79
|
+
export const gate = (
|
|
80
|
+
name: string,
|
|
81
|
+
passes: () => Promise<boolean>,
|
|
82
|
+
blocked: () => Promise<void>,
|
|
83
|
+
): Promise<void> =>
|
|
84
|
+
scope(name, async () => {
|
|
85
|
+
while (!(await passes())) await blocked()
|
|
86
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Building blocks the shipped workflows share. Policy-free: every skill list,
|
|
2
|
+
// lens, prompt, step, path and threshold arrives as a parameter, so a new
|
|
3
|
+
// workflow composes these without inheriting the shipped one's choices.
|
|
4
|
+
export * from "./health.js"
|
|
5
|
+
export * from "./quality.js"
|
|
6
|
+
export * from "./scenarios.js"
|
|
7
|
+
export * from "./review.js"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { lensesOf, lensScopes } from "./quality.js"
|
|
3
|
+
|
|
4
|
+
describe("lensesOf", () => {
|
|
5
|
+
it("splits on commas, trims, drops blanks", () => {
|
|
6
|
+
expect(lensesOf(" a ,, b,")).toEqual(["a", "b"])
|
|
7
|
+
})
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe("lensScopes", () => {
|
|
11
|
+
it("keys one entry per lens under the given scope", () => {
|
|
12
|
+
expect(lensScopes("x.quality", ["a", "b"], (lens) => [lens])).toEqual({
|
|
13
|
+
"x.quality.a": ["a"],
|
|
14
|
+
"x.quality.b": ["b"],
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { read } from "../flows/index.js"
|
|
2
|
+
|
|
3
|
+
/** Split a comma-separated lens list: trimmed, blanks dropped. */
|
|
4
|
+
export const lensesOf = (value: string): readonly string[] =>
|
|
5
|
+
value
|
|
6
|
+
.split(",")
|
|
7
|
+
.map((lens) => lens.trim())
|
|
8
|
+
.filter((lens) => lens.length > 0)
|
|
9
|
+
|
|
10
|
+
/** One `<scope>.<lens>` entry per lens — the per-lens part of a skills or access export. */
|
|
11
|
+
export const lensScopes = <T>(
|
|
12
|
+
scope: string,
|
|
13
|
+
lenses: readonly string[],
|
|
14
|
+
entry: (lens: string) => T,
|
|
15
|
+
): Record<string, T> => Object.fromEntries(lenses.map((lens) => [`${scope}.${lens}`, entry(lens)]))
|
|
16
|
+
|
|
17
|
+
/** One `review` turn per lens, each appending its findings to `findings`; resolves `"findings"` when that file has any. */
|
|
18
|
+
export const qualityLap = async (
|
|
19
|
+
lenses: readonly string[],
|
|
20
|
+
review: (lens: string) => Promise<void>,
|
|
21
|
+
findings: string,
|
|
22
|
+
): Promise<"clean" | "findings"> => {
|
|
23
|
+
for (const lens of lenses) await review(lens)
|
|
24
|
+
return (read(findings) ?? "").length > 0 ? "findings" : "clean"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FindingsFix {
|
|
28
|
+
/** One fix turn; it removes `findings` once every finding is resolved. */
|
|
29
|
+
readonly fix: () => Promise<void>
|
|
30
|
+
readonly findings: string
|
|
31
|
+
readonly cap: number
|
|
32
|
+
/** Runs once `cap` turns left findings. */
|
|
33
|
+
readonly escalate: () => Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolves `true` once `findings` is gone, `false` when `cap` turns escalated instead. */
|
|
37
|
+
export const fixFindings = async (f: FindingsFix): Promise<boolean> => {
|
|
38
|
+
for (let turns = 0; turns < f.cap; turns++) {
|
|
39
|
+
await f.fix()
|
|
40
|
+
// Not `changes()`: a wording gate after the fix would be the last step.
|
|
41
|
+
if (read(f.findings) === undefined) return true
|
|
42
|
+
}
|
|
43
|
+
await f.escalate()
|
|
44
|
+
return false
|
|
45
|
+
}
|