@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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +8 -0
  3. package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
  4. package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-DM5W_LAt.mjs} +64 -3
  5. package/dist/chunks/{tail-iLAMoigl.mjs → tail-6gvLXXcg.mjs} +1 -1
  6. package/dist/flows.mjs +4 -2
  7. package/dist/gtd.bundle.mjs +44 -9
  8. package/dist/testkit.mjs +43 -10
  9. package/dist/workflows/cleanup.mjs +2 -2
  10. package/dist/workflows/feature.mjs +140 -64
  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 +346 -107
  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 +45 -1
  19. package/src/edge/Edge.ts +15 -4
  20. package/src/flags/index.test.ts +14 -1
  21. package/src/flags/index.ts +14 -3
  22. package/src/flows/helpers.ts +7 -3
  23. package/src/replay/judgeDiffShape.test.ts +43 -0
  24. package/src/step/planStart.ts +3 -2
  25. package/src/testkit/TestRepo.ts +3 -0
  26. package/src/testkit/steps.ts +18 -0
  27. package/src/workflow/index.ts +8 -1
  28. package/src/workflow/vars.test.ts +23 -1
  29. package/src/workflow/vars.ts +24 -0
  30. package/src/workflows/feature/feature.ts +9 -2
  31. package/src/workflows/feature/index.ts +1 -0
  32. package/src/workflows/feature/seam-pin.feature +52 -0
  33. package/src/workflows/feature/seam.feature +387 -0
  34. package/src/workflows/feature/seam.test.ts +88 -0
  35. package/src/workflows/feature/seam.ts +110 -0
  36. package/src/workflows/index.ts +1 -0
  37. package/src/workflows/shared/options.test.ts +12 -1
  38. package/src/workflows/shared/options.ts +1 -0
  39. package/src/workflows/shared/skills.test.ts +20 -2
  40. package/src/workflows/shared/skills.ts +6 -0
  41. package/src/workflows/shared/text.test.ts +59 -0
  42. package/src/workflows/shared/text.ts +86 -0
  43. package/src/workflows/shared/vars.ts +4 -0
  44. package/src/workflows/shared/workflows.steps.ts +39 -1
@@ -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
- ...resolveVars(workflow.vars, startOverrides, hostEnv),
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 { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides)
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
 
@@ -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
  })
@@ -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,9 @@ 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
+ ] 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"]))
@@ -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
 
@@ -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", {
@@ -4,7 +4,8 @@ import {
4
4
  ConfigDiscovery,
5
5
  ConfigService,
6
6
  multilineSetting,
7
- resolveVars,
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 = resolveVars(workflow.vars, varOverrides, (yield* Host).env)
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(
@@ -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
  }
@@ -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
  })
@@ -13,7 +13,14 @@ export {
13
13
  walkUp,
14
14
  } from "./discovery.js"
15
15
  export { formatDiagnostic } from "./Diagnostic.js"
16
- export { resolveVars, multilineSetting, isSettingName, SETTING_NAME_RULE } from "./vars.js"
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
+ })
@@ -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
- await architecture()
58
- await packages()
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"