@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.
Files changed (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +17 -0
  3. package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
  4. package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-Df8GeSHw.mjs} +97 -8
  5. package/dist/chunks/{tail-iLAMoigl.mjs → tail-DmIfEAI4.mjs} +1 -1
  6. package/dist/flows.mjs +20 -2
  7. package/dist/gtd.bundle.mjs +97 -17
  8. package/dist/testkit.mjs +80 -18
  9. package/dist/workflows/cleanup.mjs +2 -2
  10. package/dist/workflows/feature.mjs +193 -66
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +2 -2
  13. package/dist/workflows/rollout.mjs +1 -1
  14. package/dist/workflows.mjs +447 -114
  15. package/package.json +1 -1
  16. package/src/cli/Cli.test.ts +49 -1
  17. package/src/cli/Cli.ts +20 -3
  18. package/src/edge/Edge.test.ts +156 -1
  19. package/src/edge/Edge.ts +30 -7
  20. package/src/flags/index.test.ts +27 -1
  21. package/src/flags/index.ts +15 -3
  22. package/src/flows/helpers.ts +7 -3
  23. package/src/flows/runtime.test.ts +42 -0
  24. package/src/flows/runtime.ts +36 -0
  25. package/src/replay/Replay.test.ts +43 -0
  26. package/src/replay/Replay.ts +10 -2
  27. package/src/replay/Trailers.test.ts +54 -0
  28. package/src/replay/Trailers.ts +15 -1
  29. package/src/replay/judgeDiffShape.test.ts +43 -0
  30. package/src/step/planStart.ts +3 -2
  31. package/src/testkit/TestRepo.ts +3 -0
  32. package/src/testkit/steps.ts +18 -0
  33. package/src/workflow/index.ts +8 -1
  34. package/src/workflow/vars.test.ts +23 -1
  35. package/src/workflow/vars.ts +24 -0
  36. package/src/workflows/feature/feature.ts +11 -2
  37. package/src/workflows/feature/index.ts +1 -0
  38. package/src/workflows/feature/risk-tier.feature +301 -0
  39. package/src/workflows/feature/seam-pin.feature +52 -0
  40. package/src/workflows/feature/seam.feature +387 -0
  41. package/src/workflows/feature/seam.test.ts +88 -0
  42. package/src/workflows/feature/seam.ts +111 -0
  43. package/src/workflows/feature/steps.ts +3 -2
  44. package/src/workflows/feature/tier.test.ts +164 -0
  45. package/src/workflows/feature/tier.ts +74 -0
  46. package/src/workflows/index.ts +1 -0
  47. package/src/workflows/shared/options.test.ts +17 -1
  48. package/src/workflows/shared/options.ts +1 -0
  49. package/src/workflows/shared/skills.test.ts +20 -2
  50. package/src/workflows/shared/skills.ts +6 -0
  51. package/src/workflows/shared/text.test.ts +188 -0
  52. package/src/workflows/shared/text.ts +139 -5
  53. package/src/workflows/shared/vars.ts +6 -0
  54. package/src/workflows/shared/workflows.steps.ts +47 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "27.4.0",
3
+ "version": "27.6.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -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 { resolveFlags, type FlagDeclaration } from "../flags/index.js"
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(() => runCommand(plan.command, plan.json, out)),
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
@@ -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
- ...resolveVars(workflow.vars, startOverrides, hostEnv),
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 { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides)
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
- ...(starts ? { vars: rest.vars, config: rest.config } : {}),
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
- ...settingsFor(resolved, run.pinnedVars, host.env),
1394
+ marks: run.marks,
1395
+ ...settingsFor(resolved, run.pinnedVars, host.env, {}, {}),
1373
1396
  })
1374
1397
  })
1375
1398
 
@@ -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
  })
@@ -1,5 +1,5 @@
1
- // A flag may change how a step runs, never which step comes next: flags are not recorded in
2
- // history, so replay must not read them. No lint rule enforces this.
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 = [] as const satisfies readonly FlagDeclaration[]
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"]))
@@ -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 isStray = (c: Change): boolean => !allow.some((pattern) => matches(c.path, pattern))
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) =>
@@ -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
  }