@pmelab/gtd 27.4.0 → 27.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -0
- package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
- package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-DM5W_LAt.mjs} +64 -3
- package/dist/chunks/{tail-iLAMoigl.mjs → tail-6gvLXXcg.mjs} +1 -1
- package/dist/flows.mjs +4 -2
- package/dist/gtd.bundle.mjs +44 -9
- package/dist/testkit.mjs +43 -10
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +140 -64
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +346 -107
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +49 -1
- package/src/cli/Cli.ts +20 -3
- package/src/edge/Edge.test.ts +45 -1
- package/src/edge/Edge.ts +15 -4
- package/src/flags/index.test.ts +14 -1
- package/src/flags/index.ts +14 -3
- package/src/flows/helpers.ts +7 -3
- package/src/replay/judgeDiffShape.test.ts +43 -0
- package/src/step/planStart.ts +3 -2
- package/src/testkit/TestRepo.ts +3 -0
- package/src/testkit/steps.ts +18 -0
- package/src/workflow/index.ts +8 -1
- package/src/workflow/vars.test.ts +23 -1
- package/src/workflow/vars.ts +24 -0
- package/src/workflows/feature/feature.ts +9 -2
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/seam-pin.feature +52 -0
- package/src/workflows/feature/seam.feature +387 -0
- package/src/workflows/feature/seam.test.ts +88 -0
- package/src/workflows/feature/seam.ts +110 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/options.test.ts +12 -1
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/skills.test.ts +20 -2
- package/src/workflows/shared/skills.ts +6 -0
- package/src/workflows/shared/text.test.ts +59 -0
- package/src/workflows/shared/text.ts +86 -0
- package/src/workflows/shared/vars.ts +4 -0
- package/src/workflows/shared/workflows.steps.ts +39 -1
package/src/edge/Edge.test.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { GTD_VERSION } from "../install/index.js"
|
|
|
22
22
|
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
23
23
|
import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
|
|
24
24
|
import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
|
|
25
|
-
import { contentHash } from "../workflow/index.js"
|
|
25
|
+
import { contentHash, StartFlags } from "../workflow/index.js"
|
|
26
26
|
import {
|
|
27
27
|
fakeGitOperations,
|
|
28
28
|
InMemRepo,
|
|
@@ -781,6 +781,50 @@ export const defaults = { deployTarget: "npm test", reviewer: "nobody" }
|
|
|
781
781
|
})
|
|
782
782
|
})
|
|
783
783
|
|
|
784
|
+
describe("StartFlags", () => {
|
|
785
|
+
const FLAGGED = `import { agent, human } from "@pmelab/gtd/flows"
|
|
786
|
+
|
|
787
|
+
export default async () => {
|
|
788
|
+
await human("idle")
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export const side = async () => {
|
|
792
|
+
await agent("working", "w")
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export const defaults = { flag_naive_pass: "0", other: "x" }
|
|
796
|
+
`
|
|
797
|
+
const withFlags = <A>(
|
|
798
|
+
eff: Effect.Effect<A, Error, RestRequirements>,
|
|
799
|
+
repo: InMemRepo,
|
|
800
|
+
flags: Record<string, string>,
|
|
801
|
+
): Promise<A> =>
|
|
802
|
+
Effect.runPromise(
|
|
803
|
+
eff.pipe(Effect.provideService(StartFlags, flags), Effect.provide(testLayers(repo, {}))),
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
it("a starting process takes the declared flag from StartFlags, never an undeclared one", async () => {
|
|
807
|
+
const rest = await withFlags(currentRest, repoWith(FLAGGED), {
|
|
808
|
+
flag_naive_pass: "1",
|
|
809
|
+
flag_other: "1",
|
|
810
|
+
})
|
|
811
|
+
expect(rest.vars).toEqual({ flag_naive_pass: "1", other: "x" })
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
it("a running process missing the pin resolves the declared default, never the live flag", async () => {
|
|
815
|
+
const repo = repoWith(FLAGGED.replace('flag_naive_pass: "0", ', ""))
|
|
816
|
+
await enter(repo, "side")
|
|
817
|
+
// a process started before the flag existed: its opening commit lacks the pin
|
|
818
|
+
repo.writeFile("gtd.config.ts", FLAGGED)
|
|
819
|
+
const rest = await withFlags(currentRest, repo, { flag_naive_pass: "1" })
|
|
820
|
+
expect(rest.vars.flag_naive_pass).toBe("0")
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
it("with no StartFlags provided, resolves the declared default", async () => {
|
|
824
|
+
expect((await provide(currentRest, repoWith(FLAGGED))).vars.flag_naive_pass).toBe("0")
|
|
825
|
+
})
|
|
826
|
+
})
|
|
827
|
+
|
|
784
828
|
describe("environment settings", () => {
|
|
785
829
|
const ENVV = `import { agent, human } from "@pmelab/gtd/flows"
|
|
786
830
|
|
package/src/edge/Edge.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
contentHash,
|
|
17
17
|
multilineSetting,
|
|
18
18
|
resolveVars,
|
|
19
|
+
resolveStartVars,
|
|
20
|
+
StartFlags,
|
|
19
21
|
resolveScopeAccess,
|
|
20
22
|
resolveScopeSkills,
|
|
21
23
|
type ConfigFingerprint,
|
|
@@ -562,9 +564,11 @@ const settingsFor = (
|
|
|
562
564
|
pinnedVars: Readonly<Record<string, string>>,
|
|
563
565
|
hostEnv: Readonly<Record<string, string | undefined>>,
|
|
564
566
|
startOverrides: Readonly<Record<string, string>> = {},
|
|
567
|
+
/** The live flags a STARTING process pins; a running one passes `{}` and replays its pins. */
|
|
568
|
+
startFlags: Readonly<Record<string, string>>,
|
|
565
569
|
): { readonly vars: Record<string, string>; readonly env: Record<string, string> } => ({
|
|
566
570
|
vars: {
|
|
567
|
-
...
|
|
571
|
+
...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
|
|
568
572
|
...pinnedVars,
|
|
569
573
|
},
|
|
570
574
|
env: resolveVars(workflow.env, {}, hostEnv),
|
|
@@ -912,7 +916,14 @@ const setupFor = (
|
|
|
912
916
|
Effect.gen(function* () {
|
|
913
917
|
const resolved = yield* workflowOf(config, run)
|
|
914
918
|
const def = resolved.def
|
|
915
|
-
const
|
|
919
|
+
const startFlags = run.trace.length === 0 ? yield* StartFlags : {}
|
|
920
|
+
const { vars, env } = settingsFor(
|
|
921
|
+
resolved,
|
|
922
|
+
run.pinnedVars,
|
|
923
|
+
host.env,
|
|
924
|
+
startOverrides,
|
|
925
|
+
startFlags,
|
|
926
|
+
)
|
|
916
927
|
const skills = yield* resolveScopeSkills(def, vars)
|
|
917
928
|
const access = yield* resolveScopeAccess(def, skills, vars)
|
|
918
929
|
const budget = yield* Effect.try({
|
|
@@ -1067,7 +1078,7 @@ export const startRefusal = (
|
|
|
1067
1078
|
> =>
|
|
1068
1079
|
Effect.gen(function* () {
|
|
1069
1080
|
const host = yield* Host
|
|
1070
|
-
const { vars, env } = settingsFor(resolved, {}, host.env, startVars)
|
|
1081
|
+
const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags)
|
|
1071
1082
|
const skills = yield* resolveScopeSkills(resolved.def, vars)
|
|
1072
1083
|
const access = yield* resolveScopeAccess(resolved.def, skills, vars)
|
|
1073
1084
|
const workspace = rest.setup.workspace
|
|
@@ -1369,7 +1380,7 @@ export const summaryFor = (
|
|
|
1369
1380
|
.map((entry) => ({ hash: entry.hash, state: entry.state })),
|
|
1370
1381
|
processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
|
|
1371
1382
|
processCostByModel: costByModel(run.costEntries),
|
|
1372
|
-
...settingsFor(resolved, run.pinnedVars, host.env),
|
|
1383
|
+
...settingsFor(resolved, run.pinnedVars, host.env, {}, {}),
|
|
1373
1384
|
})
|
|
1374
1385
|
})
|
|
1375
1386
|
|
package/src/flags/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { FLAGS, resolveFlags, type FlagDeclaration, type Flags } from "./index.js"
|
|
2
|
+
import { FLAGS, flagSettings, resolveFlags, type FlagDeclaration, type Flags } from "./index.js"
|
|
3
3
|
|
|
4
4
|
const registry = [
|
|
5
5
|
{ name: "alpha", owner: "pmelab", expiry: "2020-01-01", kind: "release" },
|
|
@@ -92,4 +92,17 @@ describe("FLAGS", () => {
|
|
|
92
92
|
})
|
|
93
93
|
expect(new Set(names).size).toBe(names.length)
|
|
94
94
|
})
|
|
95
|
+
|
|
96
|
+
it("leaves naive_pass off by default", () => {
|
|
97
|
+
expect(resolveFlags({}).naive_pass).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("flagSettings", () => {
|
|
102
|
+
it('maps every flag to "1" or "0" under flag_<name>', () => {
|
|
103
|
+
expect(flagSettings({ alpha: true, new_review: false })).toEqual({
|
|
104
|
+
flag_alpha: "1",
|
|
105
|
+
flag_new_review: "0",
|
|
106
|
+
})
|
|
107
|
+
})
|
|
95
108
|
})
|
package/src/flags/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Flags are not recorded in history, so replay must not read the live value: flow code may
|
|
2
|
+
// branch on a flag only through its pinned `flag_<name>` setting. No lint rule enforces this.
|
|
3
3
|
|
|
4
4
|
export type FlagKind = "release" | "ops" | "experiment"
|
|
5
5
|
|
|
@@ -13,7 +13,9 @@ export interface FlagDeclaration {
|
|
|
13
13
|
readonly defaultOn?: boolean
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export const FLAGS = [
|
|
16
|
+
export const FLAGS = [
|
|
17
|
+
{ name: "naive_pass", owner: "pmelab", expiry: "2027-01-15", kind: "release" },
|
|
18
|
+
] as const satisfies readonly FlagDeclaration[]
|
|
17
19
|
|
|
18
20
|
/** A misspelt name is a compile error. */
|
|
19
21
|
export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
@@ -22,6 +24,12 @@ export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
|
22
24
|
|
|
23
25
|
const BOOL: Record<string, boolean> = { "0": false, false: false, "1": true, true: true }
|
|
24
26
|
|
|
27
|
+
/** A `flag_<name>` setting's value as the pinned `"1"`/`"0"`, or undefined when garbled. */
|
|
28
|
+
export const flagSettingValue = (raw: string): "0" | "1" | undefined => {
|
|
29
|
+
const key = raw.toLowerCase()
|
|
30
|
+
return Object.hasOwn(BOOL, key) ? (BOOL[key] ? "1" : "0") : undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof FLAGS>(
|
|
26
34
|
env: Readonly<Record<string, string | undefined>>,
|
|
27
35
|
registry: R = FLAGS as unknown as R,
|
|
@@ -41,3 +49,6 @@ export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof
|
|
|
41
49
|
}
|
|
42
50
|
return out as Flags<R>
|
|
43
51
|
}
|
|
52
|
+
|
|
53
|
+
export const flagSettings = (flags: Readonly<Record<string, boolean>>): Record<string, string> =>
|
|
54
|
+
Object.fromEntries(Object.entries(flags).map(([name, on]) => [`flag_${name}`, on ? "1" : "0"]))
|
package/src/flows/helpers.ts
CHANGED
|
@@ -152,12 +152,14 @@ export interface DiffShapeOptions {
|
|
|
152
152
|
readonly changes: readonly Change[]
|
|
153
153
|
/** Globs in the `changes(pattern)` / `gtd strays` dialect. */
|
|
154
154
|
readonly allow: readonly string[]
|
|
155
|
+
/** Exact paths never strays; compared by equality, so a `*` in one covers nothing else. */
|
|
156
|
+
readonly exempt?: readonly string[] | undefined
|
|
155
157
|
readonly minP?: number | undefined
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
export interface DiffShapeResult {
|
|
159
161
|
readonly choice: DiffShape
|
|
160
|
-
/** Sorted, equal to `gtd strays` for the same range and globs
|
|
162
|
+
/** Sorted, equal to `gtd strays` for the same range and globs, minus `exempt`. */
|
|
161
163
|
readonly strays: readonly string[]
|
|
162
164
|
/** Strays with a side that could not be read (sorted); `refactor-first` skipped the judge. */
|
|
163
165
|
readonly unreadable: readonly string[]
|
|
@@ -198,7 +200,9 @@ export const judgeDiffShape = async (
|
|
|
198
200
|
options: DiffShapeOptions,
|
|
199
201
|
): Promise<DiffShapeResult> => {
|
|
200
202
|
const { changes: all, allow } = options
|
|
201
|
-
const
|
|
203
|
+
const exempt = options.exempt ?? []
|
|
204
|
+
const isStray = (c: Change): boolean =>
|
|
205
|
+
!exempt.includes(c.path) && !allow.some((pattern) => matches(c.path, pattern))
|
|
202
206
|
const strayChanges: Change[] = all
|
|
203
207
|
.filter(isStray)
|
|
204
208
|
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
@@ -252,7 +256,7 @@ export const judgeDiffShape = async (
|
|
|
252
256
|
return { choice, strays, unreadable: [], judgment }
|
|
253
257
|
}
|
|
254
258
|
|
|
255
|
-
const displayPath = (path: string): string =>
|
|
259
|
+
export const displayPath = (path: string): string =>
|
|
256
260
|
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
257
261
|
/[\x00-\x1f\x7f-\x9f]/.test(path) ? JSON.stringify(path) : path
|
|
258
262
|
|
|
@@ -50,6 +50,49 @@ const naive = [
|
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
describe("judgeDiffShape", () => {
|
|
53
|
+
describe("exempt", () => {
|
|
54
|
+
const changes = [
|
|
55
|
+
change("src/foo*bar.ts", "added", undefined, "new"),
|
|
56
|
+
change("src/foobar.ts", "modified", "a", "b"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
it("never lets an exempt path glob over a modified sibling", async () => {
|
|
60
|
+
install({ shape: { answer: "land", p: 0.9 } })
|
|
61
|
+
const result = await judgeDiffShape("shape", {
|
|
62
|
+
changes,
|
|
63
|
+
allow: [],
|
|
64
|
+
exempt: ["src/foo*bar.ts"],
|
|
65
|
+
})
|
|
66
|
+
expect(result.strays).toEqual(["src/foobar.ts"])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("lists exempt paths as in-allowlist evidence and keeps allow to the globs", async () => {
|
|
70
|
+
const requests = install({ shape: { answer: "land", p: 0.9 } })
|
|
71
|
+
await judgeDiffShape("shape", { changes, allow: ["docs/**"], exempt: ["src/foo*bar.ts"] })
|
|
72
|
+
const request = requests[0]
|
|
73
|
+
if (request?.kind !== "judge") throw new Error("expected a judge step")
|
|
74
|
+
expect(request.evidence.allow).toBe(JSON.stringify(["docs/**"], null, 2))
|
|
75
|
+
expect(request.evidence.inAllowlist).toBe(JSON.stringify(["src/foo*bar.ts"], null, 2))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("lands with no judge step when every stray is exempt", async () => {
|
|
79
|
+
const requests = install("none")
|
|
80
|
+
const result = await judgeDiffShape("shape", {
|
|
81
|
+
changes: [changes[0]!],
|
|
82
|
+
allow: [],
|
|
83
|
+
exempt: ["src/foo*bar.ts"],
|
|
84
|
+
})
|
|
85
|
+
expect(result.choice).toBe("land")
|
|
86
|
+
expect(requests).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("changes nothing when omitted", async () => {
|
|
90
|
+
install({ shape: { answer: "land", p: 0.9 } })
|
|
91
|
+
const result = await judgeDiffShape("shape", { changes, allow: [] })
|
|
92
|
+
expect(result.strays).toEqual(["src/foo*bar.ts", "src/foobar.ts"])
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
53
96
|
it("answers land with no judge step when nothing strays", async () => {
|
|
54
97
|
const requests = install("none")
|
|
55
98
|
const result = await judgeDiffShape("shape", {
|
package/src/step/planStart.ts
CHANGED
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
ConfigDiscovery,
|
|
5
5
|
ConfigService,
|
|
6
6
|
multilineSetting,
|
|
7
|
-
|
|
7
|
+
resolveStartVars,
|
|
8
|
+
StartFlags,
|
|
8
9
|
type ConfigFingerprint,
|
|
9
10
|
type ResolvedWorkflow,
|
|
10
11
|
} from "../workflow/index.js"
|
|
@@ -109,7 +110,7 @@ export const planStart = (
|
|
|
109
110
|
if (overrideRefusal !== undefined) return overrideRefusal
|
|
110
111
|
|
|
111
112
|
const baseOf = workflow.def.base
|
|
112
|
-
const vars =
|
|
113
|
+
const vars = resolveStartVars(workflow.vars, yield* StartFlags, varOverrides, (yield* Host).env)
|
|
113
114
|
const multiline = multilineSetting(vars)
|
|
114
115
|
if (multiline !== undefined) {
|
|
115
116
|
return refusal(
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -70,6 +70,8 @@ export interface TestRepo {
|
|
|
70
70
|
drive(actors?: Actors, options?: DriveOptions): Promise<BeatDocument>
|
|
71
71
|
/** Oldest first. */
|
|
72
72
|
subjects(): readonly string[]
|
|
73
|
+
/** Every commit's full message, subject and trailers, oldest first. */
|
|
74
|
+
messages(): readonly string[]
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
const REGISTRY = Symbol.for("@pmelab/gtd/testkit:modules")
|
|
@@ -272,6 +274,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
272
274
|
throw new Error(`the process did not rest within ${maxBeats} beats`)
|
|
273
275
|
},
|
|
274
276
|
subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
|
|
277
|
+
messages: () => repo.commitHistory().map(({ message }) => message),
|
|
275
278
|
}
|
|
276
279
|
return self
|
|
277
280
|
}
|
package/src/testkit/steps.ts
CHANGED
|
@@ -36,6 +36,12 @@ export class TestkitWorld extends QuickPickleWorld {
|
|
|
36
36
|
this.gtdConfig = config
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
extendConfig(tail: string): void {
|
|
40
|
+
this.assertNotCreated("a gtd config")
|
|
41
|
+
if (this.gtdConfig === undefined) throw new Error("no gtd config to extend")
|
|
42
|
+
this.gtdConfig += `\n${tail}`
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
setEnv(name: string, value: string): void {
|
|
40
46
|
this.assertNotCreated(`the environment variable ${name}`)
|
|
41
47
|
this.env[name] = value
|
|
@@ -79,6 +85,10 @@ Given("a gtd config with:", (world: TestkitWorld, source: string) => {
|
|
|
79
85
|
world.configure(withNewline(source))
|
|
80
86
|
})
|
|
81
87
|
|
|
88
|
+
Given("the gtd config ends with:", (world: TestkitWorld, source: string) => {
|
|
89
|
+
world.extendConfig(withNewline(source))
|
|
90
|
+
})
|
|
91
|
+
|
|
82
92
|
Given(
|
|
83
93
|
"an environment variable {string} set to {string}",
|
|
84
94
|
(world: TestkitWorld, name: string, value: string) => {
|
|
@@ -180,6 +190,14 @@ Then("the resting step's content contains {string}", async (world: TestkitWorld,
|
|
|
180
190
|
assert.ok(content.includes(text), content)
|
|
181
191
|
})
|
|
182
192
|
|
|
193
|
+
Then(
|
|
194
|
+
"the resting step's content does not contain {string}",
|
|
195
|
+
async (world: TestkitWorld, text: string) => {
|
|
196
|
+
const { content } = await world.repo().next()
|
|
197
|
+
assert.ok(!content.includes(text), content)
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
183
201
|
Then("the last commit subject is {string}", (world: TestkitWorld, subject: string) => {
|
|
184
202
|
assert.strictEqual(world.repo().subjects().at(-1), subject)
|
|
185
203
|
})
|
package/src/workflow/index.ts
CHANGED
|
@@ -13,7 +13,14 @@ export {
|
|
|
13
13
|
walkUp,
|
|
14
14
|
} from "./discovery.js"
|
|
15
15
|
export { formatDiagnostic } from "./Diagnostic.js"
|
|
16
|
-
export {
|
|
16
|
+
export {
|
|
17
|
+
resolveVars,
|
|
18
|
+
resolveStartVars,
|
|
19
|
+
StartFlags,
|
|
20
|
+
multilineSetting,
|
|
21
|
+
isSettingName,
|
|
22
|
+
SETTING_NAME_RULE,
|
|
23
|
+
} from "./vars.js"
|
|
17
24
|
export { resolveScopeAccess, resolveScopeSkills } from "./skills.js"
|
|
18
25
|
export { seededValidateCommand, isSeededValidateCommand } from "./SteeringFormats.js"
|
|
19
26
|
export {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { resolveVars } from "./vars.js"
|
|
2
|
+
import { resolveStartVars, resolveVars } from "./vars.js"
|
|
3
3
|
|
|
4
4
|
describe("resolveVars — GTD_FLAG_* belongs to feature flags", () => {
|
|
5
5
|
it.each(["flag_x", "FLAG_X", "Flag_x"])("%s keeps its default with GTD_FLAG_X set", (name) => {
|
|
@@ -14,3 +14,25 @@ describe("resolveVars — GTD_FLAG_* belongs to feature flags", () => {
|
|
|
14
14
|
expect(resolveVars({ [name]: "off" }, {}, { [key]: "on" })).toEqual({ [name]: "on" })
|
|
15
15
|
})
|
|
16
16
|
})
|
|
17
|
+
|
|
18
|
+
describe("resolveStartVars", () => {
|
|
19
|
+
it("a declared flag_x takes the StartFlags value", () => {
|
|
20
|
+
expect(resolveStartVars({ flag_x: "0" }, { flag_x: "1" }, {}, {})).toEqual({ flag_x: "1" })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("never introduces an undeclared flag", () => {
|
|
24
|
+
expect(resolveStartVars({ a: "1" }, { flag_x: "1" }, {}, {})).toEqual({ a: "1" })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("--var flag_x beats the flag", () => {
|
|
28
|
+
expect(resolveStartVars({ flag_x: "0" }, { flag_x: "1" }, { flag_x: "0" }, {})).toEqual({
|
|
29
|
+
flag_x: "0",
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("GTD_FLAG_X never overrides, while GTD_<NAME> still does for other names", () => {
|
|
34
|
+
expect(
|
|
35
|
+
resolveStartVars({ flag_x: "0", a: "1" }, {}, {}, { GTD_FLAG_X: "1", GTD_A: "2" }),
|
|
36
|
+
).toEqual({ flag_x: "0", a: "2" })
|
|
37
|
+
})
|
|
38
|
+
})
|
package/src/workflow/vars.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import { Context } from "effect"
|
|
2
|
+
|
|
1
3
|
const PREFIX = "GTD_"
|
|
2
4
|
|
|
5
|
+
/** The live release flags as `flag_<name>` settings, provided by the CLI; absent means no flags. */
|
|
6
|
+
export class StartFlags extends Context.Reference<StartFlags>()("StartFlags", {
|
|
7
|
+
defaultValue: (): Readonly<Record<string, string>> => ({}),
|
|
8
|
+
}) {}
|
|
9
|
+
|
|
3
10
|
/**
|
|
4
11
|
* One kind of setting, later wins: the workflow's own defaults, `--var`
|
|
5
12
|
* overrides (process settings only; environment settings pass `{}`), then
|
|
@@ -22,6 +29,23 @@ export const resolveVars = (
|
|
|
22
29
|
return merged
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
/**
|
|
33
|
+
* `resolveVars` for a process being STARTED: `flags` land between the workflow
|
|
34
|
+
* defaults and `--var`, for already-declared names only. A running process never
|
|
35
|
+
* calls this — it replays its pinned record.
|
|
36
|
+
*/
|
|
37
|
+
export const resolveStartVars = (
|
|
38
|
+
workflowVars: Readonly<Record<string, string>>,
|
|
39
|
+
flags: Readonly<Record<string, string>>,
|
|
40
|
+
entryVars: Readonly<Record<string, string>>,
|
|
41
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
42
|
+
): Record<string, string> => {
|
|
43
|
+
const declared = Object.fromEntries(
|
|
44
|
+
Object.entries(flags).filter(([name]) => Object.hasOwn(workflowVars, name)),
|
|
45
|
+
)
|
|
46
|
+
return resolveVars({ ...workflowVars, ...declared }, entryVars, env)
|
|
47
|
+
}
|
|
48
|
+
|
|
25
49
|
/** The first process setting whose value spans lines — a `Gtd-Var:` trailer cannot carry it. */
|
|
26
50
|
export const multilineSetting = (vars: Readonly<Record<string, string>>): string | undefined =>
|
|
27
51
|
Object.keys(vars)
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
revertScript,
|
|
8
8
|
run,
|
|
9
9
|
start,
|
|
10
|
+
vars,
|
|
10
11
|
} from "../../flows/index.js"
|
|
11
12
|
import { gate } from "../shared/health.js"
|
|
12
13
|
import { shipped } from "../shared/options.js"
|
|
@@ -16,6 +17,7 @@ import * as t from "../shared/text.js"
|
|
|
16
17
|
import { packages } from "./packages.js"
|
|
17
18
|
import { architecture, design } from "./planning.js"
|
|
18
19
|
import { buildTail, type ReviewOutcome } from "./review.js"
|
|
20
|
+
import { seamBuild } from "./seam.js"
|
|
19
21
|
|
|
20
22
|
// The ordinary start. Any change to the tree starts a process: `idle` →
|
|
21
23
|
// `unwind` reverts the sketch (its intent survives in history) → a
|
|
@@ -54,8 +56,13 @@ export const planAndBuild = async (firstBase: string): Promise<void> => {
|
|
|
54
56
|
let base = firstBase
|
|
55
57
|
for (;;) {
|
|
56
58
|
await design(base)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
// GTD_FLAG_NAIVE_PASS: the record is pinned at the process start, so a mid-process flip never moves a process.
|
|
60
|
+
if (vars.flag_naive_pass === "1") {
|
|
61
|
+
await seamBuild()
|
|
62
|
+
} else {
|
|
63
|
+
await architecture()
|
|
64
|
+
await packages()
|
|
65
|
+
}
|
|
59
66
|
const outcome = await buildTail(false, base, true)
|
|
60
67
|
if (outcome.verdict === "signoff") return
|
|
61
68
|
await reUnwind(outcome)
|
|
@@ -4,6 +4,7 @@ export * from "./steps.js"
|
|
|
4
4
|
export * from "./planning.js"
|
|
5
5
|
export * from "./packages.js"
|
|
6
6
|
export * from "./refactor.js"
|
|
7
|
+
export * from "./seam.js"
|
|
7
8
|
export { buildTail, type ReviewOutcome } from "./review.js"
|
|
8
9
|
export * from "./feature.js"
|
|
9
10
|
export * from "../shared/reserved.js"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
@inmem
|
|
2
|
+
Feature: a process keeps the path it started on when GTD_FLAG_NAIVE_PASS changes
|
|
3
|
+
|
|
4
|
+
The flag is recorded in the opening commit, so flipping it mid-process never
|
|
5
|
+
moves a process between the architecture path and the naive-pass path.
|
|
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
|
+
And a file "TODO.md" with:
|
|
16
|
+
"""
|
|
17
|
+
# Add subtract
|
|
18
|
+
|
|
19
|
+
subtract(a, b) returns the difference.
|
|
20
|
+
"""
|
|
21
|
+
And step "unwind" changes nothing
|
|
22
|
+
And step "start-gate.check" changes nothing
|
|
23
|
+
And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
24
|
+
"""
|
|
25
|
+
# Requirements
|
|
26
|
+
|
|
27
|
+
## Concerns
|
|
28
|
+
|
|
29
|
+
### Subtract
|
|
30
|
+
|
|
31
|
+
- `subtract(a, b)` returns `a - b`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
Scenario: a process started with the flag off stays on architecture after the flag turns on
|
|
35
|
+
Given the ambient environment variable "GTD_FLAG_NAIVE_PASS" is "0"
|
|
36
|
+
When the process is driven
|
|
37
|
+
Then the process rests at "design.gate.answer"
|
|
38
|
+
Given the ambient environment variable "GTD_FLAG_NAIVE_PASS" is "1"
|
|
39
|
+
And step "design.gate.answer" changes nothing
|
|
40
|
+
When the process is driven
|
|
41
|
+
Then the process rests at "architecture.author"
|
|
42
|
+
And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_naive_pass=0"
|
|
43
|
+
|
|
44
|
+
Scenario: a process started with the flag on stays on the naive pass after the flag turns off
|
|
45
|
+
Given the ambient environment variable "GTD_FLAG_NAIVE_PASS" is "1"
|
|
46
|
+
When the process is driven
|
|
47
|
+
Then the process rests at "design.gate.answer"
|
|
48
|
+
Given the ambient environment variable "GTD_FLAG_NAIVE_PASS" is "0"
|
|
49
|
+
And step "design.gate.answer" changes nothing
|
|
50
|
+
When the process is driven
|
|
51
|
+
Then the process rests at "seam.naive"
|
|
52
|
+
And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_naive_pass=1"
|