@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
package/package.json
CHANGED
package/src/cli/Cli.test.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
import { EXIT_CODES, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
|
|
26
26
|
import { Host } from "../platform/index.js"
|
|
27
27
|
import { type CommandRequirements } from "./Cli.js"
|
|
28
|
-
import { InMemRepo, testLayers } from "../testkit/index.js"
|
|
28
|
+
import { InMemRepo, makeCapturingCliIo, testLayers } from "../testkit/index.js"
|
|
29
29
|
import { CONFIG_SCAFFOLD } from "../install/index.js"
|
|
30
30
|
|
|
31
31
|
const FLAG_NAMES = [
|
|
@@ -1241,6 +1241,54 @@ describe("runCli — a garbled GTD_FLAG_* value", () => {
|
|
|
1241
1241
|
})
|
|
1242
1242
|
})
|
|
1243
1243
|
|
|
1244
|
+
describe("runCli — StartFlags", () => {
|
|
1245
|
+
const CONFIG = `import { agent, human } from "@pmelab/gtd/flows"
|
|
1246
|
+
|
|
1247
|
+
export default async () => {
|
|
1248
|
+
await human("idle")
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
export const side = async () => {
|
|
1252
|
+
await agent("working", "w")
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
export const defaults = { flag_naive_pass: "0" }
|
|
1256
|
+
`
|
|
1257
|
+
const start = async (
|
|
1258
|
+
flags?: CliIo["flags"],
|
|
1259
|
+
env: Record<string, string> = {},
|
|
1260
|
+
): Promise<string> => {
|
|
1261
|
+
const repo = new InMemRepo()
|
|
1262
|
+
repo.writeFile("gtd.config.ts", CONFIG)
|
|
1263
|
+
repo.seedCommit("chore: add workflow")
|
|
1264
|
+
const { io, result } = makeCapturingCliIo(repo, env)
|
|
1265
|
+
await Effect.runPromise(
|
|
1266
|
+
runCli(["node", "gtd.js", "--workflow", "side"], { ...io, ...(flags ? { flags } : {}) }),
|
|
1267
|
+
)
|
|
1268
|
+
return result().stdout
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
it("pins flag_naive_pass=1 with the flag on", async () => {
|
|
1272
|
+
expect(await start(undefined, { GTD_FLAG_NAIVE_PASS: "1" })).toContain("flag_naive_pass=1")
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1275
|
+
it("honours defaultOn with GTD_FLAG_* unset", async () => {
|
|
1276
|
+
const out = await start(
|
|
1277
|
+
[
|
|
1278
|
+
{
|
|
1279
|
+
name: "naive_pass",
|
|
1280
|
+
owner: "pmelab",
|
|
1281
|
+
expiry: "2099-01-01",
|
|
1282
|
+
kind: "release",
|
|
1283
|
+
defaultOn: true,
|
|
1284
|
+
},
|
|
1285
|
+
],
|
|
1286
|
+
{ GTD_FLAG_NAIVE_PASS: "" },
|
|
1287
|
+
)
|
|
1288
|
+
expect(out).toContain("flag_naive_pass=1")
|
|
1289
|
+
})
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1244
1292
|
describe("runCli — exit codes", () => {
|
|
1245
1293
|
it("output plan: exit is never called (success) — --help and --version still exit 0", async () => {
|
|
1246
1294
|
const { io, captured } = capturingIo(throwingLayers)
|
package/src/cli/Cli.ts
CHANGED
|
@@ -10,13 +10,19 @@ import {
|
|
|
10
10
|
Workspace,
|
|
11
11
|
CommandRunner,
|
|
12
12
|
} from "../platform/index.js"
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
flagSettings,
|
|
15
|
+
flagSettingValue,
|
|
16
|
+
resolveFlags,
|
|
17
|
+
type FlagDeclaration,
|
|
18
|
+
} from "../flags/index.js"
|
|
14
19
|
import { GTD_VERSION } from "../install/index.js"
|
|
15
20
|
import {
|
|
16
21
|
ConfigDiscovery,
|
|
17
22
|
ConfigService,
|
|
18
23
|
isSettingName,
|
|
19
24
|
SETTING_NAME_RULE,
|
|
25
|
+
StartFlags,
|
|
20
26
|
} from "../workflow/index.js"
|
|
21
27
|
// The shared command types live in `Program.ts`, which imports nothing from
|
|
22
28
|
// here, so this dependency stays one-directional.
|
|
@@ -282,6 +288,14 @@ const FLAGS: readonly FlagRow[] = [
|
|
|
282
288
|
return Either.left(`gtd: --var ${name} specified more than once`)
|
|
283
289
|
}
|
|
284
290
|
seen.add(name)
|
|
291
|
+
if (name.startsWith("flag_")) {
|
|
292
|
+
const pinned = flagSettingValue(value)
|
|
293
|
+
if (pinned === undefined) {
|
|
294
|
+
return Either.left(`gtd: --var ${name} must be 1, 0, true or false`)
|
|
295
|
+
}
|
|
296
|
+
vars[name] = pinned
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
285
299
|
vars[name] = value
|
|
286
300
|
}
|
|
287
301
|
return Either.right(vars)
|
|
@@ -1451,12 +1465,15 @@ export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void,
|
|
|
1451
1465
|
const out = bufferedArtifactOut(io)
|
|
1452
1466
|
return Effect.flatMap(Host, ({ env }) =>
|
|
1453
1467
|
Effect.try({
|
|
1454
|
-
// Validate only: the result is discarded so a garbled value fails before any command runs.
|
|
1455
1468
|
try: () => resolveFlags(env, io.flags),
|
|
1456
1469
|
catch: (e) => new GtdError((e as Error).message),
|
|
1457
1470
|
}),
|
|
1458
1471
|
).pipe(
|
|
1459
|
-
Effect.flatMap(() =>
|
|
1472
|
+
Effect.flatMap((flags) =>
|
|
1473
|
+
runCommand(plan.command, plan.json, out).pipe(
|
|
1474
|
+
Effect.provideService(StartFlags, flagSettings(flags)),
|
|
1475
|
+
),
|
|
1476
|
+
),
|
|
1460
1477
|
// `flush()` fires here, on success, BEFORE `io.exit` — the one point
|
|
1461
1478
|
// where the whole buffered artifact is known-complete and safe to hand
|
|
1462
1479
|
// to stdout (a failure discards the buffer instead — see
|
package/src/edge/Edge.test.ts
CHANGED
|
@@ -21,8 +21,9 @@ import {
|
|
|
21
21
|
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
|
+
import type { Mark } from "../flows/index.js"
|
|
24
25
|
import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
|
|
25
|
-
import { contentHash } from "../workflow/index.js"
|
|
26
|
+
import { contentHash, StartFlags } from "../workflow/index.js"
|
|
26
27
|
import {
|
|
27
28
|
fakeGitOperations,
|
|
28
29
|
InMemRepo,
|
|
@@ -537,6 +538,64 @@ describe("summaryRun", () => {
|
|
|
537
538
|
})
|
|
538
539
|
})
|
|
539
540
|
|
|
541
|
+
describe("process marks", () => {
|
|
542
|
+
const SUMMARISED = `${LINEAR}
|
|
543
|
+
export const summary = (it) => it.marks.map((m) => m.name + "=" + m.value).join("|")
|
|
544
|
+
`
|
|
545
|
+
const opening = (marks: readonly Mark[]): CommitSpec => ({
|
|
546
|
+
actor: "human",
|
|
547
|
+
from: "idle",
|
|
548
|
+
to: "building",
|
|
549
|
+
step: { name: "idle", occurrence: 1 },
|
|
550
|
+
marks,
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it("buildRun collects marks from the process's commits only, oldest first", async () => {
|
|
554
|
+
const repo = repoWith(SUMMARISED)
|
|
555
|
+
commit(
|
|
556
|
+
repo,
|
|
557
|
+
{
|
|
558
|
+
actor: "agent",
|
|
559
|
+
from: "checking",
|
|
560
|
+
to: "idle",
|
|
561
|
+
marks: [{ name: "risk-tier", value: "high" }],
|
|
562
|
+
},
|
|
563
|
+
{ "old.txt": "o\n" },
|
|
564
|
+
)
|
|
565
|
+
commit(
|
|
566
|
+
repo,
|
|
567
|
+
opening([
|
|
568
|
+
{ name: "production-signal", value: "one" },
|
|
569
|
+
{ name: "production-signal", value: "two" },
|
|
570
|
+
]),
|
|
571
|
+
{ "a.txt": "a\n" },
|
|
572
|
+
)
|
|
573
|
+
commit(
|
|
574
|
+
repo,
|
|
575
|
+
{
|
|
576
|
+
actor: "agent",
|
|
577
|
+
from: "building",
|
|
578
|
+
to: "checking",
|
|
579
|
+
step: { name: "building", occurrence: 1 },
|
|
580
|
+
marks: [{ name: "production-signal", value: "three" }],
|
|
581
|
+
},
|
|
582
|
+
{ "b.txt": "b\n" },
|
|
583
|
+
)
|
|
584
|
+
const run = await provide(currentRun, repo)
|
|
585
|
+
expect(run.marks).toEqual([
|
|
586
|
+
{ name: "production-signal", value: "one" },
|
|
587
|
+
{ name: "production-signal", value: "two" },
|
|
588
|
+
{ name: "production-signal", value: "three" },
|
|
589
|
+
])
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it("is empty for a process whose commits carry no marks", async () => {
|
|
593
|
+
const repo = repoWith(SUMMARISED)
|
|
594
|
+
await land(repo, { "a.txt": "a\n" })
|
|
595
|
+
expect((await provide(currentRun, repo)).marks).toEqual([])
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
540
599
|
describe("restAt", () => {
|
|
541
600
|
it("restAt(ref) resolves where the process rested at that commit", async () => {
|
|
542
601
|
const repo = repoWith(LINEAR)
|
|
@@ -781,6 +840,50 @@ export const defaults = { deployTarget: "npm test", reviewer: "nobody" }
|
|
|
781
840
|
})
|
|
782
841
|
})
|
|
783
842
|
|
|
843
|
+
describe("StartFlags", () => {
|
|
844
|
+
const FLAGGED = `import { agent, human } from "@pmelab/gtd/flows"
|
|
845
|
+
|
|
846
|
+
export default async () => {
|
|
847
|
+
await human("idle")
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export const side = async () => {
|
|
851
|
+
await agent("working", "w")
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export const defaults = { flag_naive_pass: "0", other: "x" }
|
|
855
|
+
`
|
|
856
|
+
const withFlags = <A>(
|
|
857
|
+
eff: Effect.Effect<A, Error, RestRequirements>,
|
|
858
|
+
repo: InMemRepo,
|
|
859
|
+
flags: Record<string, string>,
|
|
860
|
+
): Promise<A> =>
|
|
861
|
+
Effect.runPromise(
|
|
862
|
+
eff.pipe(Effect.provideService(StartFlags, flags), Effect.provide(testLayers(repo, {}))),
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
it("a starting process takes the declared flag from StartFlags, never an undeclared one", async () => {
|
|
866
|
+
const rest = await withFlags(currentRest, repoWith(FLAGGED), {
|
|
867
|
+
flag_naive_pass: "1",
|
|
868
|
+
flag_other: "1",
|
|
869
|
+
})
|
|
870
|
+
expect(rest.vars).toEqual({ flag_naive_pass: "1", other: "x" })
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
it("a running process missing the pin resolves the declared default, never the live flag", async () => {
|
|
874
|
+
const repo = repoWith(FLAGGED.replace('flag_naive_pass: "0", ', ""))
|
|
875
|
+
await enter(repo, "side")
|
|
876
|
+
// a process started before the flag existed: its opening commit lacks the pin
|
|
877
|
+
repo.writeFile("gtd.config.ts", FLAGGED)
|
|
878
|
+
const rest = await withFlags(currentRest, repo, { flag_naive_pass: "1" })
|
|
879
|
+
expect(rest.vars.flag_naive_pass).toBe("0")
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it("with no StartFlags provided, resolves the declared default", async () => {
|
|
883
|
+
expect((await provide(currentRest, repoWith(FLAGGED))).vars.flag_naive_pass).toBe("0")
|
|
884
|
+
})
|
|
885
|
+
})
|
|
886
|
+
|
|
784
887
|
describe("environment settings", () => {
|
|
785
888
|
const ENVV = `import { agent, human } from "@pmelab/gtd/flows"
|
|
786
889
|
|
|
@@ -1529,3 +1632,55 @@ export default async () => {
|
|
|
1529
1632
|
expect((await provide(currentRest, repo)).state).toBe("after")
|
|
1530
1633
|
})
|
|
1531
1634
|
})
|
|
1635
|
+
|
|
1636
|
+
describe("marks on a landing", () => {
|
|
1637
|
+
const MARKING = `import { agent, human, mark } from "@pmelab/gtd/flows"
|
|
1638
|
+
|
|
1639
|
+
export default async () => {
|
|
1640
|
+
await human("idle", { message: "idle-message" })
|
|
1641
|
+
mark("risk-tier", "high")
|
|
1642
|
+
mark("production-signal", "error rate")
|
|
1643
|
+
await agent("building", "build-prompt")
|
|
1644
|
+
await agent("checking", "check-prompt")
|
|
1645
|
+
}
|
|
1646
|
+
`
|
|
1647
|
+
|
|
1648
|
+
const landingOf = async (repo: InMemRepo) => {
|
|
1649
|
+
const rest = await provide(currentRest, repo)
|
|
1650
|
+
const snapshot = await provide(snapshotFromRest(rest), repo)
|
|
1651
|
+
const landing = snapshot.landing
|
|
1652
|
+
if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
|
|
1653
|
+
return landing
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
it("writes the outcome's marks, in order, into the landing's commit spec", async () => {
|
|
1657
|
+
const repo = repoWith(MARKING)
|
|
1658
|
+
repo.writeFile("TODO.md", "go\n")
|
|
1659
|
+
const landing = await landingOf(repo)
|
|
1660
|
+
expect(landing.spec.marks).toEqual([
|
|
1661
|
+
{ name: "risk-tier", value: "high" },
|
|
1662
|
+
{ name: "production-signal", value: "error rate" },
|
|
1663
|
+
])
|
|
1664
|
+
})
|
|
1665
|
+
|
|
1666
|
+
it("writes the marks of a flow that ends right after marking", async () => {
|
|
1667
|
+
const repo = repoWith(`import { human, mark } from "@pmelab/gtd/flows"
|
|
1668
|
+
|
|
1669
|
+
export default async () => {
|
|
1670
|
+
await human("idle", { message: "idle-message" })
|
|
1671
|
+
mark("done", "yes")
|
|
1672
|
+
}
|
|
1673
|
+
`)
|
|
1674
|
+
repo.writeFile("TODO.md", "go\n")
|
|
1675
|
+
const landing = await landingOf(repo)
|
|
1676
|
+
expect(landing.spec.marks).toEqual([{ name: "done", value: "yes" }])
|
|
1677
|
+
})
|
|
1678
|
+
|
|
1679
|
+
it("leaves marks off a landing the flow marked nothing on", async () => {
|
|
1680
|
+
const repo = repoWith(MARKING)
|
|
1681
|
+
await land(repo, { "TODO.md": "go\n" })
|
|
1682
|
+
repo.writeFile("a.txt", "a\n")
|
|
1683
|
+
const landing = await landingOf(repo)
|
|
1684
|
+
expect(landing.spec.marks).toBeUndefined()
|
|
1685
|
+
})
|
|
1686
|
+
})
|
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,
|
|
@@ -42,7 +44,7 @@ import {
|
|
|
42
44
|
type ReplayOutcome,
|
|
43
45
|
type TreeView,
|
|
44
46
|
} from "../replay/index.js"
|
|
45
|
-
import type { ScopeAccess } from "../flows/index.js"
|
|
47
|
+
import type { Mark, ScopeAccess } from "../flows/index.js"
|
|
46
48
|
import { steeringFormatFor } from "../steering/index.js"
|
|
47
49
|
import {
|
|
48
50
|
judgeJson,
|
|
@@ -151,6 +153,8 @@ export interface ProcessRun {
|
|
|
151
153
|
readonly trace: readonly TraceEntry[]
|
|
152
154
|
readonly costEntries: readonly CostEntry[]
|
|
153
155
|
readonly judgeVerdicts: readonly JudgeVerdictEntry[]
|
|
156
|
+
/** Every process commit's `Gtd-Mark`s, oldest first. */
|
|
157
|
+
readonly marks: readonly Mark[]
|
|
154
158
|
/** Oldest first. */
|
|
155
159
|
readonly approvals: readonly ApprovalEntry[]
|
|
156
160
|
/** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
|
|
@@ -274,6 +278,7 @@ const buildRun = (
|
|
|
274
278
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
275
279
|
costEntries: parsed.flatMap(costEntriesOf),
|
|
276
280
|
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
281
|
+
marks: parsed.flatMap((m) => m.marks),
|
|
277
282
|
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
|
|
278
283
|
pinnedVars: { ...first?.vars },
|
|
279
284
|
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
@@ -562,9 +567,11 @@ const settingsFor = (
|
|
|
562
567
|
pinnedVars: Readonly<Record<string, string>>,
|
|
563
568
|
hostEnv: Readonly<Record<string, string | undefined>>,
|
|
564
569
|
startOverrides: Readonly<Record<string, string>> = {},
|
|
570
|
+
/** The live flags a STARTING process pins; a running one passes `{}` and replays its pins. */
|
|
571
|
+
startFlags: Readonly<Record<string, string>>,
|
|
565
572
|
): { readonly vars: Record<string, string>; readonly env: Record<string, string> } => ({
|
|
566
573
|
vars: {
|
|
567
|
-
...
|
|
574
|
+
...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
|
|
568
575
|
...pinnedVars,
|
|
569
576
|
},
|
|
570
577
|
env: resolveVars(workflow.env, {}, hostEnv),
|
|
@@ -912,7 +919,14 @@ const setupFor = (
|
|
|
912
919
|
Effect.gen(function* () {
|
|
913
920
|
const resolved = yield* workflowOf(config, run)
|
|
914
921
|
const def = resolved.def
|
|
915
|
-
const
|
|
922
|
+
const startFlags = run.trace.length === 0 ? yield* StartFlags : {}
|
|
923
|
+
const { vars, env } = settingsFor(
|
|
924
|
+
resolved,
|
|
925
|
+
run.pinnedVars,
|
|
926
|
+
host.env,
|
|
927
|
+
startOverrides,
|
|
928
|
+
startFlags,
|
|
929
|
+
)
|
|
916
930
|
const skills = yield* resolveScopeSkills(def, vars)
|
|
917
931
|
const access = yield* resolveScopeAccess(def, skills, vars)
|
|
918
932
|
const budget = yield* Effect.try({
|
|
@@ -1067,7 +1081,7 @@ export const startRefusal = (
|
|
|
1067
1081
|
> =>
|
|
1068
1082
|
Effect.gen(function* () {
|
|
1069
1083
|
const host = yield* Host
|
|
1070
|
-
const { vars, env } = settingsFor(resolved, {}, host.env, startVars)
|
|
1084
|
+
const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags)
|
|
1071
1085
|
const skills = yield* resolveScopeSkills(resolved.def, vars)
|
|
1072
1086
|
const access = yield* resolveScopeAccess(resolved.def, skills, vars)
|
|
1073
1087
|
const workspace = rest.setup.workspace
|
|
@@ -1256,6 +1270,7 @@ const landingTo = (
|
|
|
1256
1270
|
to: StateName,
|
|
1257
1271
|
branch: Option.Option<string>,
|
|
1258
1272
|
approver: Option.Option<AuthorIdent>,
|
|
1273
|
+
marks: readonly Mark[],
|
|
1259
1274
|
): Landing => {
|
|
1260
1275
|
if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
|
|
1261
1276
|
// The landing that leaves the flow's first step starts the process: it
|
|
@@ -1276,8 +1291,9 @@ const landingTo = (
|
|
|
1276
1291
|
from: rest.state,
|
|
1277
1292
|
to,
|
|
1278
1293
|
step: rest.step.id,
|
|
1294
|
+
...(marks.length > 0 && { marks }),
|
|
1279
1295
|
...approvedTrailer(rest, approver),
|
|
1280
|
-
...(
|
|
1296
|
+
...startTrailers(rest, starts),
|
|
1281
1297
|
...(finishes ? closingTrailers(branch) : {}),
|
|
1282
1298
|
},
|
|
1283
1299
|
...(finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}),
|
|
@@ -1285,6 +1301,12 @@ const landingTo = (
|
|
|
1285
1301
|
}
|
|
1286
1302
|
}
|
|
1287
1303
|
|
|
1304
|
+
const startTrailers = (rest: Rest, starts: boolean) =>
|
|
1305
|
+
starts ? { vars: rest.vars, config: rest.config } : {}
|
|
1306
|
+
|
|
1307
|
+
const outcomeMarks = (outcome: ReplayOutcome): readonly Mark[] =>
|
|
1308
|
+
outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : []
|
|
1309
|
+
|
|
1288
1310
|
/**
|
|
1289
1311
|
* What landing the pending turn at `rest` does — a pure decision from a
|
|
1290
1312
|
* replay with the working tree as the pending turn. The target step is
|
|
@@ -1317,7 +1339,7 @@ const decideLanding = (
|
|
|
1317
1339
|
)
|
|
1318
1340
|
const to = yield* landingTarget(outcome, rest)
|
|
1319
1341
|
if (typeof to !== "string") return to
|
|
1320
|
-
return landingTo(rest, to, branch, approver)
|
|
1342
|
+
return landingTo(rest, to, branch, approver, outcomeMarks(outcome))
|
|
1321
1343
|
})
|
|
1322
1344
|
|
|
1323
1345
|
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
@@ -1369,7 +1391,8 @@ export const summaryFor = (
|
|
|
1369
1391
|
.map((entry) => ({ hash: entry.hash, state: entry.state })),
|
|
1370
1392
|
processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
|
|
1371
1393
|
processCostByModel: costByModel(run.costEntries),
|
|
1372
|
-
|
|
1394
|
+
marks: run.marks,
|
|
1395
|
+
...settingsFor(resolved, run.pinnedVars, host.env, {}, {}),
|
|
1373
1396
|
})
|
|
1374
1397
|
})
|
|
1375
1398
|
|
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,30 @@ 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
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe("risk_tier", () => {
|
|
111
|
+
it("is a release flag, off by default, resolved from GTD_FLAG_RISK_TIER", () => {
|
|
112
|
+
expect(FLAGS.find((f) => f.name === "risk_tier")).toMatchObject({ kind: "release" })
|
|
113
|
+
expect(resolveFlags({}).risk_tier).toBe(false)
|
|
114
|
+
expect(resolveFlags({ GTD_FLAG_RISK_TIER: "1" }).risk_tier).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("is pinned as flag_risk_tier", () => {
|
|
118
|
+
expect(flagSettings(resolveFlags({ GTD_FLAG_RISK_TIER: "1" })).flag_risk_tier).toBe("1")
|
|
119
|
+
expect(flagSettings(resolveFlags({})).flag_risk_tier).toBe("0")
|
|
120
|
+
})
|
|
95
121
|
})
|
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,10 @@ 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
|
+
{ name: "risk_tier", owner: "pmelab", expiry: "2027-01-15", kind: "release" },
|
|
19
|
+
] as const satisfies readonly FlagDeclaration[]
|
|
17
20
|
|
|
18
21
|
/** A misspelt name is a compile error. */
|
|
19
22
|
export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
@@ -22,6 +25,12 @@ export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
|
22
25
|
|
|
23
26
|
const BOOL: Record<string, boolean> = { "0": false, false: false, "1": true, true: true }
|
|
24
27
|
|
|
28
|
+
/** A `flag_<name>` setting's value as the pinned `"1"`/`"0"`, or undefined when garbled. */
|
|
29
|
+
export const flagSettingValue = (raw: string): "0" | "1" | undefined => {
|
|
30
|
+
const key = raw.toLowerCase()
|
|
31
|
+
return Object.hasOwn(BOOL, key) ? (BOOL[key] ? "1" : "0") : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof FLAGS>(
|
|
26
35
|
env: Readonly<Record<string, string | undefined>>,
|
|
27
36
|
registry: R = FLAGS as unknown as R,
|
|
@@ -41,3 +50,6 @@ export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof
|
|
|
41
50
|
}
|
|
42
51
|
return out as Flags<R>
|
|
43
52
|
}
|
|
53
|
+
|
|
54
|
+
export const flagSettings = (flags: Readonly<Record<string, boolean>>): Record<string, string> =>
|
|
55
|
+
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
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
head,
|
|
6
6
|
installContext,
|
|
7
7
|
kept,
|
|
8
|
+
mark,
|
|
8
9
|
protocolMismatch,
|
|
9
10
|
type FlowContext,
|
|
10
11
|
} from "./runtime.js"
|
|
@@ -54,6 +55,47 @@ describe("the flows protocol handshake", () => {
|
|
|
54
55
|
})
|
|
55
56
|
})
|
|
56
57
|
|
|
58
|
+
describe("mark()", () => {
|
|
59
|
+
const withMark = () => {
|
|
60
|
+
const calls: [string, string][] = []
|
|
61
|
+
installContext({ mark: (n: string, v: string) => calls.push([n, v]) } as unknown as FlowContext)
|
|
62
|
+
return calls
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
it("forwards name and value to the context", () => {
|
|
66
|
+
const calls = withMark()
|
|
67
|
+
mark("risk-tier", "high")
|
|
68
|
+
mark("production-signal", "error rate")
|
|
69
|
+
expect(calls).toEqual([
|
|
70
|
+
["risk-tier", "high"],
|
|
71
|
+
["production-signal", "error rate"],
|
|
72
|
+
])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("throws the upgrade message on a context without mark, protocol still 1", () => {
|
|
76
|
+
installContext(context)
|
|
77
|
+
expect(() => mark("a", "b")).toThrow(
|
|
78
|
+
"gtd: mark() needs a newer gtd — upgrade the gtd that runs it",
|
|
79
|
+
)
|
|
80
|
+
expect(FLOWS_PROTOCOL).toBe(1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it.each(["", "Risk", "1a", "a_b", "-a", "risk-tier\n"])("rejects the name %j", (name) => {
|
|
84
|
+
const calls = withMark()
|
|
85
|
+
expect(() => mark(name, "v")).toThrow(/mark\(\)/)
|
|
86
|
+
expect(calls).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it.each(["", " ", " x ", "x\t", "a\nGtd-Approved: {}", "a\rb", "a\u001bb", "a\u2028b", "high\n"])(
|
|
90
|
+
"rejects the value %j",
|
|
91
|
+
(value) => {
|
|
92
|
+
const calls = withMark()
|
|
93
|
+
expect(() => mark("risk-tier", value)).toThrow(/one line/)
|
|
94
|
+
expect(calls).toEqual([])
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
57
99
|
describe("kept()", () => {
|
|
58
100
|
const change = { path: "a.ts", status: "added", before: undefined, after: "a" } as const
|
|
59
101
|
const withKept = (tip: unknown) =>
|
package/src/flows/runtime.ts
CHANGED
|
@@ -170,6 +170,8 @@ export interface FlowContext {
|
|
|
170
170
|
readonly skillsFor: (localName: string) => readonly string[]
|
|
171
171
|
/** The folded access the memory scope `localName` lands in resolves to; `file` is the step's own steering file. */
|
|
172
172
|
readonly accessFor: (localName: string, file?: string) => AccessDef
|
|
173
|
+
/** Absent on an engine older than `mark`; FLOWS_PROTOCOL stays 1. */
|
|
174
|
+
readonly mark?: (name: string, value: string) => void
|
|
173
175
|
/** Absent on an engine older than `kept`. */
|
|
174
176
|
readonly kept?: (
|
|
175
177
|
name: string,
|
|
@@ -343,6 +345,38 @@ export const kept = (name: string): KeptTip | undefined => {
|
|
|
343
345
|
return tip === undefined ? undefined : { hash: tip.hash, changes: toChanges(tip.changes) }
|
|
344
346
|
}
|
|
345
347
|
|
|
348
|
+
export interface Mark {
|
|
349
|
+
readonly name: string
|
|
350
|
+
readonly value: string
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const MARK_NAME = /^[a-z][a-z0-9-]*$/
|
|
354
|
+
const MARK_VALUE =
|
|
355
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
356
|
+
/^[^\s\x00-\x1f\x7f-\x9f\u2028\u2029]([^\x00-\x1f\x7f-\x9f\u2028\u2029]*[^\s\x00-\x1f\x7f-\x9f\u2028\u2029])?$/
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Record a fact on the landing that leaves the current rest: one
|
|
360
|
+
* `Gtd-Mark: <name>=<value>` trailer per call, in call order. Throws on an
|
|
361
|
+
* invalid name or value (a newline would inject a trailer) and on an older engine.
|
|
362
|
+
* A settled no-op landing (a clean script step landing on itself) drops its marks.
|
|
363
|
+
*/
|
|
364
|
+
export const mark = (name: string, value: string): void => {
|
|
365
|
+
const context = ctx()
|
|
366
|
+
if (typeof context.mark !== "function") {
|
|
367
|
+
throw new Error("gtd: mark() needs a newer gtd — upgrade the gtd that runs it")
|
|
368
|
+
}
|
|
369
|
+
if (!MARK_NAME.test(name)) {
|
|
370
|
+
throw new Error(`gtd: mark(): name ${JSON.stringify(name)} must match ${MARK_NAME}`)
|
|
371
|
+
}
|
|
372
|
+
if (!MARK_VALUE.test(value)) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`gtd: mark(${name}): value must be one line, no control characters, no surrounding whitespace`,
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
context.mark(name, value)
|
|
378
|
+
}
|
|
379
|
+
|
|
346
380
|
/** The commit the process stands on at this point of the flow. */
|
|
347
381
|
export const head = (): string => ctx().head()
|
|
348
382
|
|
|
@@ -445,6 +479,8 @@ export interface SummaryContext {
|
|
|
445
479
|
readonly humanCommits: readonly { readonly hash: string; readonly state: string }[]
|
|
446
480
|
readonly processCost: number
|
|
447
481
|
readonly processCostByModel: readonly { readonly model: string; readonly cost: number }[]
|
|
482
|
+
/** Every `Gtd-Mark` of the process's commits, oldest first. */
|
|
483
|
+
readonly marks: readonly Mark[]
|
|
448
484
|
readonly vars: Readonly<Record<string, string>>
|
|
449
485
|
readonly env: Readonly<Record<string, string>>
|
|
450
486
|
}
|