@pmelab/gtd 27.4.0 → 27.6.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 +17 -0
- package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
- package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-Df8GeSHw.mjs} +97 -8
- package/dist/chunks/{tail-iLAMoigl.mjs → tail-DmIfEAI4.mjs} +1 -1
- package/dist/flows.mjs +20 -2
- package/dist/gtd.bundle.mjs +97 -17
- package/dist/testkit.mjs +80 -18
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +193 -66
- 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 +447 -114
- 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 +156 -1
- package/src/edge/Edge.ts +30 -7
- package/src/flags/index.test.ts +27 -1
- package/src/flags/index.ts +15 -3
- package/src/flows/helpers.ts +7 -3
- package/src/flows/runtime.test.ts +42 -0
- package/src/flows/runtime.ts +36 -0
- package/src/replay/Replay.test.ts +43 -0
- package/src/replay/Replay.ts +10 -2
- package/src/replay/Trailers.test.ts +54 -0
- package/src/replay/Trailers.ts +15 -1
- 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 +11 -2
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/risk-tier.feature +301 -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 +111 -0
- package/src/workflows/feature/steps.ts +3 -2
- package/src/workflows/feature/tier.test.ts +164 -0
- package/src/workflows/feature/tier.ts +74 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/options.test.ts +17 -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 +188 -0
- package/src/workflows/shared/text.ts +139 -5
- package/src/workflows/shared/vars.ts +6 -0
- package/src/workflows/shared/workflows.steps.ts +47 -1
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
human,
|
|
10
10
|
judge,
|
|
11
11
|
kept,
|
|
12
|
+
mark,
|
|
12
13
|
read,
|
|
13
14
|
refuse,
|
|
14
15
|
restart,
|
|
@@ -389,6 +390,48 @@ describe("replay", () => {
|
|
|
389
390
|
expect(restName(outcome)).toBe("fix#1")
|
|
390
391
|
})
|
|
391
392
|
|
|
393
|
+
it("collects marks made after the pending turn's step resolved, in call order", async () => {
|
|
394
|
+
const wf = async () => {
|
|
395
|
+
mark("before", "no-turn-yet")
|
|
396
|
+
await human("idle")
|
|
397
|
+
mark("risk-tier", "high")
|
|
398
|
+
mark("production-signal", "a")
|
|
399
|
+
mark("production-signal", "b")
|
|
400
|
+
await agent("next", "go")
|
|
401
|
+
}
|
|
402
|
+
const h = new History()
|
|
403
|
+
const outcome = await replayOf(wf, h, { pending: { "TODO.md": "go" } })
|
|
404
|
+
expect(outcome.kind === "rest" && outcome.marks).toEqual([
|
|
405
|
+
{ name: "risk-tier", value: "high" },
|
|
406
|
+
{ name: "production-signal", value: "a" },
|
|
407
|
+
{ name: "production-signal", value: "b" },
|
|
408
|
+
])
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it("ignores marks made while replaying history, and has none without a pending turn", async () => {
|
|
412
|
+
const wf = async () => {
|
|
413
|
+
await human("idle")
|
|
414
|
+
mark("risk-tier", "high")
|
|
415
|
+
await agent("next", "go")
|
|
416
|
+
mark("late", "x")
|
|
417
|
+
await agent("last", "go")
|
|
418
|
+
}
|
|
419
|
+
const h = new History().land("human", "idle", 1, "next", { "TODO.md": "go" })
|
|
420
|
+
const resting = await replayOf(wf, h)
|
|
421
|
+
expect(resting.kind === "rest" && resting.marks).toEqual([])
|
|
422
|
+
const landing = await replayOf(wf, h, { pending: { "a.txt": "a" } })
|
|
423
|
+
expect(landing.kind === "rest" && landing.marks).toEqual([{ name: "late", value: "x" }])
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it("carries marks on an ended outcome", async () => {
|
|
427
|
+
const wf = async () => {
|
|
428
|
+
await human("idle")
|
|
429
|
+
mark("done", "yes")
|
|
430
|
+
}
|
|
431
|
+
const outcome = await replayOf(wf, new History(), { pending: { "TODO.md": "go" } })
|
|
432
|
+
expect(outcome.kind === "ended" && outcome.marks).toEqual([{ name: "done", value: "yes" }])
|
|
433
|
+
})
|
|
434
|
+
|
|
392
435
|
it("reads helpers against the replay position, not the working tree", async () => {
|
|
393
436
|
const wf = async () => {
|
|
394
437
|
await human("write")
|
package/src/replay/Replay.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ScopeOptions,
|
|
11
11
|
type StepRequest,
|
|
12
12
|
type Flow,
|
|
13
|
+
type Mark,
|
|
13
14
|
} from "../flows/index.js"
|
|
14
15
|
import {
|
|
15
16
|
headingSectionBodies,
|
|
@@ -114,12 +115,15 @@ export type ReplayOutcome =
|
|
|
114
115
|
readonly trace: readonly ReachedStep[]
|
|
115
116
|
/** The step the pending turn completed, when one was supplied. */
|
|
116
117
|
readonly landed: ReachedStep | undefined
|
|
118
|
+
/** Marks the flow made after the pending turn's step resolved; [] without a pending turn. */
|
|
119
|
+
readonly marks: readonly Mark[]
|
|
117
120
|
}
|
|
118
121
|
| {
|
|
119
122
|
readonly kind: "ended"
|
|
120
123
|
readonly via: "return" | "restart"
|
|
121
124
|
readonly trace: readonly ReachedStep[]
|
|
122
125
|
readonly landed: ReachedStep | undefined
|
|
126
|
+
readonly marks: readonly Mark[]
|
|
123
127
|
}
|
|
124
128
|
| { readonly kind: "divergence"; readonly message: string }
|
|
125
129
|
| { readonly kind: "refused"; readonly message: string }
|
|
@@ -292,6 +296,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
292
296
|
{ readonly hash: string; readonly before: TreeView; readonly after: TreeView }
|
|
293
297
|
>()
|
|
294
298
|
let landed: ReachedStep | undefined
|
|
299
|
+
const marks: Mark[] = []
|
|
295
300
|
let outcome: ReplayOutcome | undefined
|
|
296
301
|
let signal: (() => void) | undefined
|
|
297
302
|
const settled = new Promise<void>((resolve) => {
|
|
@@ -553,7 +558,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
553
558
|
const commit = commits[cursor]
|
|
554
559
|
if (commit !== undefined) return consumeCommit(step, commit)
|
|
555
560
|
if (input.pending === undefined || pendingUsed) {
|
|
556
|
-
return finish({ kind: "rest", rest: step, trace, landed })
|
|
561
|
+
return finish({ kind: "rest", rest: step, trace, landed, marks })
|
|
557
562
|
}
|
|
558
563
|
return landPending(step, input.pending)
|
|
559
564
|
}
|
|
@@ -598,7 +603,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
598
603
|
kind: "divergence",
|
|
599
604
|
message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
|
|
600
605
|
}
|
|
601
|
-
: { kind: "ended", via, trace, landed }
|
|
606
|
+
: { kind: "ended", via, trace, landed, marks }
|
|
602
607
|
|
|
603
608
|
const changesSince = (hash: string): readonly Change[] => {
|
|
604
609
|
const tree = treeAt(hash)
|
|
@@ -670,6 +675,9 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
670
675
|
.map((f) => f.message),
|
|
671
676
|
}))
|
|
672
677
|
}),
|
|
678
|
+
mark: (name, value) => {
|
|
679
|
+
if (pendingUsed) marks.push({ name, value })
|
|
680
|
+
},
|
|
673
681
|
kept: (name) => {
|
|
674
682
|
const tip = keptTips.get(scoped(name))
|
|
675
683
|
return tip === undefined
|
|
@@ -282,3 +282,57 @@ describe("isRecordableApproval", () => {
|
|
|
282
282
|
expect(isRecordableApproval(of("Ada", "a@x.io", 0))).toBe(false)
|
|
283
283
|
})
|
|
284
284
|
})
|
|
285
|
+
|
|
286
|
+
describe("Gtd-Mark", () => {
|
|
287
|
+
const body = (...lines: string[]) => `gtd(human): a → b\n\n${lines.join("\n")}`
|
|
288
|
+
|
|
289
|
+
it("round-trips in order, names repeating", () => {
|
|
290
|
+
const message = formatCommitMessage({
|
|
291
|
+
actor: "human",
|
|
292
|
+
from: "a",
|
|
293
|
+
to: "b",
|
|
294
|
+
marks: [
|
|
295
|
+
{ name: "risk-tier", value: "high" },
|
|
296
|
+
{ name: "production-signal", value: "error rate = 0" },
|
|
297
|
+
{ name: "production-signal", value: "p95" },
|
|
298
|
+
],
|
|
299
|
+
})
|
|
300
|
+
expect(parseCommitMessage(message).marks).toEqual([
|
|
301
|
+
{ name: "risk-tier", value: "high" },
|
|
302
|
+
{ name: "production-signal", value: "error rate = 0" },
|
|
303
|
+
{ name: "production-signal", value: "p95" },
|
|
304
|
+
])
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it("is written after Gtd-Judge and before Gtd-Approved, with no Gtd-Format bump", () => {
|
|
308
|
+
const lines = formatCommitMessage({
|
|
309
|
+
actor: "human",
|
|
310
|
+
to: "b",
|
|
311
|
+
judge: [{ id: "v", answer: "x", p: 1 }],
|
|
312
|
+
marks: [{ name: "risk-tier", value: "high" }],
|
|
313
|
+
approved: { step: { name: "s", occurrence: 1 }, name: "Ada", email: "a@x.io" },
|
|
314
|
+
}).split("\n")
|
|
315
|
+
const at = lines.findIndex((l) => l.startsWith("Gtd-Mark:"))
|
|
316
|
+
expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
|
|
317
|
+
expect(lines[at + 1]).toMatch(/^Gtd-Approved:/)
|
|
318
|
+
expect(lines).toContain("Gtd-Format: 1")
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it("writes nothing without marks", () => {
|
|
322
|
+
expect(formatCommitMessage({ actor: "human", to: "b" })).not.toContain("Gtd-Mark")
|
|
323
|
+
expect(parseCommitMessage("gtd(human): a → b").marks).toEqual([])
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it("skips a malformed line, a missing =, an empty value or a control character", () => {
|
|
327
|
+
const parsed = parseCommitMessage(
|
|
328
|
+
body(
|
|
329
|
+
"Gtd-Mark: no-equals",
|
|
330
|
+
"Gtd-Mark: Bad_Name=x",
|
|
331
|
+
"Gtd-Mark: empty=",
|
|
332
|
+
"Gtd-Mark: ctl=a\u001bb",
|
|
333
|
+
"Gtd-Mark: ok=fine",
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
expect(parsed.marks).toEqual([{ name: "ok", value: "fine" }])
|
|
337
|
+
})
|
|
338
|
+
})
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// written by gtd) is skipped, not fatal — a corrupt line must never make every
|
|
4
4
|
// command unusable.
|
|
5
5
|
|
|
6
|
+
import type { Mark } from "../flows/index.js"
|
|
6
7
|
import { HISTORY_FORMAT } from "./Format.js"
|
|
7
8
|
|
|
8
9
|
const TRANSITION_SEP = " → "
|
|
@@ -73,6 +74,8 @@ export interface CommitMessage {
|
|
|
73
74
|
/** Written only on a process's opening commit. */
|
|
74
75
|
readonly workflow: string | undefined
|
|
75
76
|
readonly judge: readonly JudgeVerdict[]
|
|
77
|
+
/** `Gtd-Mark`, in order; a malformed or control-character line is skipped. */
|
|
78
|
+
readonly marks: readonly Mark[]
|
|
76
79
|
readonly vars: Readonly<Record<string, string>>
|
|
77
80
|
/** Written only on a process's first commit. */
|
|
78
81
|
readonly config: Readonly<Record<string, string>>
|
|
@@ -141,6 +144,7 @@ export const isRecordableApproval = (approval: Approval): boolean => {
|
|
|
141
144
|
const back = decodeApproval(encodeApproval(approval))
|
|
142
145
|
return back !== undefined && encodeApproval(back) === encodeApproval(approval)
|
|
143
146
|
}
|
|
147
|
+
const MARK_RE = /^([a-z][a-z0-9-]*)=(.+)$/
|
|
144
148
|
const FORMAT_RE = /^[1-9][0-9]*$/
|
|
145
149
|
const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
146
150
|
|
|
@@ -153,6 +157,7 @@ interface Trailers {
|
|
|
153
157
|
kept: string | undefined
|
|
154
158
|
approved: Approval | undefined
|
|
155
159
|
readonly judge: JudgeVerdict[]
|
|
160
|
+
readonly marks: Mark[]
|
|
156
161
|
readonly vars: Record<string, string>
|
|
157
162
|
readonly config: Record<string, string>
|
|
158
163
|
readonly cost: CostEntry[]
|
|
@@ -170,6 +175,11 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
170
175
|
const parsed = parseJson(value)
|
|
171
176
|
if (isVerdict(parsed)) into.judge.push(parsed)
|
|
172
177
|
},
|
|
178
|
+
"Gtd-Mark": (value, into) => {
|
|
179
|
+
const kv = MARK_RE.exec(value)
|
|
180
|
+
if (kv !== null && !CONTROL_CHARACTER.test(value))
|
|
181
|
+
into.marks.push({ name: kv[1]!, value: kv[2]! })
|
|
182
|
+
},
|
|
173
183
|
"Gtd-Var": (value, into) => {
|
|
174
184
|
const kv = VAR_RE.exec(value)
|
|
175
185
|
if (kv !== null) into.vars[kv[1]!] = kv[2]!
|
|
@@ -215,6 +225,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
|
|
|
215
225
|
kept: undefined,
|
|
216
226
|
approved: undefined,
|
|
217
227
|
judge: [],
|
|
228
|
+
marks: [],
|
|
218
229
|
vars: {},
|
|
219
230
|
config: {},
|
|
220
231
|
cost: [],
|
|
@@ -239,7 +250,9 @@ export interface CommitSpec {
|
|
|
239
250
|
readonly config?: Readonly<Record<string, string>>
|
|
240
251
|
readonly cost?: { readonly cost: number; readonly model?: string }
|
|
241
252
|
readonly judge?: readonly JudgeVerdict[]
|
|
242
|
-
/** Written after `Gtd-Judge`, before `Gtd-
|
|
253
|
+
/** Written after `Gtd-Judge`, before `Gtd-Approved`. */
|
|
254
|
+
readonly marks?: readonly Mark[]
|
|
255
|
+
/** Written after `Gtd-Mark`, before `Gtd-Branch`. */
|
|
243
256
|
readonly approved?: Approval
|
|
244
257
|
readonly branch?: string
|
|
245
258
|
readonly version?: string
|
|
@@ -263,6 +276,7 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
|
|
|
263
276
|
)
|
|
264
277
|
}
|
|
265
278
|
for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`)
|
|
279
|
+
for (const m of spec.marks ?? []) lines.push(`Gtd-Mark: ${m.name}=${m.value}`)
|
|
266
280
|
if (spec.approved !== undefined) {
|
|
267
281
|
lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`)
|
|
268
282
|
}
|
|
@@ -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,8 @@ 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"
|
|
21
|
+
import { recordRisk, riskTierOn } from "./tier.js"
|
|
19
22
|
|
|
20
23
|
// The ordinary start. Any change to the tree starts a process: `idle` →
|
|
21
24
|
// `unwind` reverts the sketch (its intent survives in history) → a
|
|
@@ -54,8 +57,14 @@ export const planAndBuild = async (firstBase: string): Promise<void> => {
|
|
|
54
57
|
let base = firstBase
|
|
55
58
|
for (;;) {
|
|
56
59
|
await design(base)
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
const tier = riskTierOn() ? recordRisk() : undefined
|
|
61
|
+
// GTD_FLAG_NAIVE_PASS: the record is pinned at the process start, so a mid-process flip never moves a process.
|
|
62
|
+
if (vars.flag_naive_pass === "1") {
|
|
63
|
+
await seamBuild(tier)
|
|
64
|
+
} else {
|
|
65
|
+
await architecture()
|
|
66
|
+
await packages()
|
|
67
|
+
}
|
|
59
68
|
const outcome = await buildTail(false, base, true)
|
|
60
69
|
if (outcome.verdict === "signoff") return
|
|
61
70
|
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"
|