@pmelab/gtd 16.0.0 → 17.0.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/README.md CHANGED
@@ -21,7 +21,9 @@ npm install -g @pmelab/gtd
21
21
 
22
22
  Or run without installing (prefix every `gtd` below with `npx`) — see
23
23
  [Configuration](https://github.com/pmelab/gtd/blob/main/docs/configuration.md)
24
- for the settings most projects tune.
24
+ for the settings most projects tune. A process setting's value is committed to
25
+ Git history; keep secrets in an environment setting (see
26
+ [Settings](https://github.com/pmelab/gtd/blob/main/docs/configuration.md#settings)).
25
27
 
26
28
  Also install all three skill sources the bundled workflow names in its prompts —
27
29
  none is optional. See
@@ -359,8 +361,11 @@ noted in steps 2, 3, and 4 below.
359
361
  users only; model `haiku`, `--model <name>` overrides) and reuses its login.
360
362
  With no `--provider`, `gtd judge run` picks jev when `TYPESAFE_API_KEY` is
361
363
  set and non-empty and no `--model` is given, else llm — never falling back
362
- across them; `--model` with `--provider fixed`/`jev` is refused. jev and llm
363
- exit 1 with nothing on stdout when they cannot answer every question):
364
+ across them; a `.gtdrc` `judge: { provider, model }` key and the
365
+ `GTD_JUDGE_PROVIDER` / `GTD_JUDGE_MODEL` environment variables choose the
366
+ judge without editing the driver (flags win over both); `--model` with
367
+ `--provider fixed`/`jev` is refused. jev and llm exit 1 with nothing on
368
+ stdout when they cannot answer every question):
364
369
  - Every red round after the first: was the failure identical, new, or
365
370
  progress?
366
371
  - Before spending a review turn on a package: does the code already satisfy
@@ -30968,17 +30968,24 @@ const head = () => ctx().head();
30968
30968
  const start = () => ctx().start();
30969
30969
  /** The skill list `localName` (scoped from here, same as `agent()`) resolves to — for a prompt preamble. See `FlowContext.skillsFor` for `ownSkills`. */
30970
30970
  const skillsFor = (localName, ownSkills) => ctx().skillsFor(localName, ownSkills);
30971
- /** The merged workflow variables. */
30972
- const vars = new Proxy({}, {
30973
- get: (_target, key) => typeof key === "string" ? ctx().vars[key] : void 0,
30974
- has: (_target, key) => typeof key === "string" && key in ctx().vars,
30975
- ownKeys: () => Object.keys(ctx().vars),
30976
- getOwnPropertyDescriptor: (_target, key) => typeof key === "string" && key in ctx().vars ? {
30971
+ const settingsProxy = (read) => new Proxy({}, {
30972
+ get: (_target, key) => typeof key === "string" ? read()[key] : void 0,
30973
+ has: (_target, key) => typeof key === "string" && key in read(),
30974
+ ownKeys: () => Object.keys(read()),
30975
+ getOwnPropertyDescriptor: (_target, key) => typeof key === "string" && key in read() ? {
30977
30976
  enumerable: true,
30978
30977
  configurable: true,
30979
- value: ctx().vars[key]
30978
+ value: read()[key]
30980
30979
  } : void 0
30981
30980
  });
30981
+ /** The process settings: pinned at process start, so flow code may branch on them. */
30982
+ const vars = settingsProxy(() => ctx().vars);
30983
+ /**
30984
+ * The environment settings: resolved live on every invocation. Flow code must
30985
+ * read them only where they cannot change the next step (step options, script
30986
+ * bodies) — replay cannot enforce this.
30987
+ */
30988
+ const env = settingsProxy(() => ctx().env);
30982
30989
  /** The top-level `## ` heading texts of markdown `text`. */
30983
30990
  const sections = (text) => ctx().sections(text);
30984
30991
  /** The top-level `## ` sections of markdown `text`, each with its own markdown. */
@@ -31162,6 +31169,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
31162
31169
  checkScript: () => checkScript,
31163
31170
  codeChanges: () => codeChanges,
31164
31171
  codeThreads: () => codeThreads,
31172
+ env: () => env,
31165
31173
  glob: () => glob,
31166
31174
  hasThreadFor: () => hasThreadFor,
31167
31175
  head: () => head,
@@ -47614,7 +47622,7 @@ const formatCommitMessage = (spec) => {
47614
47622
  const lines = [];
47615
47623
  if (spec.step !== void 0) lines.push(`Gtd-Step: ${formatStepId(spec.step)}`);
47616
47624
  if (spec.reviewBase !== void 0) lines.push(`Gtd-Review-Base: ${spec.reviewBase}`);
47617
- for (const [name, value] of Object.entries(spec.vars ?? {})) lines.push(`Gtd-Var: ${name}=${value}`);
47625
+ for (const [name, value] of Object.entries(spec.vars ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) lines.push(`Gtd-Var: ${name}=${value}`);
47618
47626
  if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
47619
47627
  for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`);
47620
47628
  const subject = formatSubject(spec.actor, spec.to, spec.from);
@@ -47975,6 +47983,7 @@ const replay = async (input) => {
47975
47983
  }));
47976
47984
  }),
47977
47985
  vars: input.vars,
47986
+ env: input.env,
47978
47987
  start: () => input.start,
47979
47988
  skillsFor: (localName, ownSkills) => resolveSkills(scoped(localName), ownSkills) ?? [],
47980
47989
  head: () => {
@@ -48387,7 +48396,7 @@ ${questionBarReturn}
48387
48396
  never re-open one. A genuinely open PRODUCT point may still
48388
48397
  raise a fresh \`## Open Questions\` entry: the product gate sits
48389
48398
  on this path exactly as on the first lap
48390
- - Before grouping anything, run \`${vars.testCommand}\`
48399
+ - Before grouping anything, run \`${env.testCommand}\`
48391
48400
  yourself — a loop-back runs no green-baseline gate, so the
48392
48401
  tree may already be red (reverting the edit can undo a fix it
48393
48402
  made). If red, make that breakage the first concern, ahead of
@@ -48932,8 +48941,8 @@ const ARCHITECTURE = ".gtd/ARCHITECTURE.md";
48932
48941
  const REVIEW = ".gtd/REVIEW.md";
48933
48942
  const QUALITY = ".gtd/QUALITY.md";
48934
48943
  const SPEC_FEEDBACK = ".gtd/SPEC_FEEDBACK.md";
48935
- const planner = () => vars.plannerModel ?? "";
48936
- const coder = () => vars.coderModel ?? "";
48944
+ const planner = () => env.plannerModel ?? "";
48945
+ const coder = () => env.coderModel ?? "";
48937
48946
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
48938
48947
  const triage$1 = (base) => agentWithSkills("triage", designTriagePrompt(base), {
48939
48948
  label: "Triaging the change",
@@ -49087,7 +49096,7 @@ const collecting = (capture) => agentWithSkills("review.collecting", buildReview
49087
49096
  allowEmpty: true
49088
49097
  });
49089
49098
  /** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
49090
- const baseline = (name, label = "Checking the baseline") => check(name, vars.testCommand ?? "", {
49099
+ const baseline = (name, label = "Checking the baseline") => check(name, env.testCommand ?? "", {
49091
49100
  report: FEEDBACK,
49092
49101
  label
49093
49102
  });
@@ -49139,7 +49148,7 @@ const healthy = async (fix, options = {}) => {
49139
49148
  let fixes = options.fixesSoFar ?? 0;
49140
49149
  let previous;
49141
49150
  for (;;) {
49142
- if (await check("health.check", vars.testCommand ?? "", {
49151
+ if (await check("health.check", env.testCommand ?? "", {
49143
49152
  report: ".gtd/FEEDBACK.md",
49144
49153
  label: "Running checks",
49145
49154
  sweepOnGreen: [".gtd/ESCALATION.md"]
@@ -49766,10 +49775,8 @@ const buildTail = (fixFirst, base) => scope("build", async () => {
49766
49775
  });
49767
49776
  //#endregion
49768
49777
  //#region src/workflows/vars.ts
49778
+ /** Process settings. */
49769
49779
  const defaults = {
49770
- testCommand: "npm test",
49771
- plannerModel: "smart",
49772
- coderModel: "base",
49773
49780
  reviewBase: "",
49774
49781
  judgeBudgetBytes: "32768",
49775
49782
  judgeIdenticalMinP: "0.7",
@@ -49778,6 +49785,12 @@ const defaults = {
49778
49785
  architectureSkipMinP: "0.85",
49779
49786
  qualityReviews: "owasp-security, ponytail-review, test-audit"
49780
49787
  };
49788
+ /** Environment settings. */
49789
+ const envDefaults = {
49790
+ testCommand: "npm test",
49791
+ plannerModel: "smart",
49792
+ coderModel: "base"
49793
+ };
49781
49794
  //#endregion
49782
49795
  //#region src/workflows/skills.ts
49783
49796
  const skills = {
@@ -49832,6 +49845,7 @@ var unified_exports = /* @__PURE__ */ __exportAll({
49832
49845
  defaults: () => defaults,
49833
49846
  describeEscalation: () => describeEscalation,
49834
49847
  design: () => design,
49848
+ envDefaults: () => envDefaults,
49835
49849
  escalate: () => escalate,
49836
49850
  escalation: () => escalation,
49837
49851
  escalationExhausted: () => escalationExhausted,
@@ -50646,7 +50660,17 @@ var Workspace = class Workspace extends Tag("Workspace")() {
50646
50660
  /** The `vars:` shape: a flat name -> scalar map (`compileVarsMap` coerces every scalar to a string). */
50647
50661
  const varsJsonSchema = {
50648
50662
  type: "object",
50649
- description: "Flat name -> scalar map merged into the workflow's vars. Scalars are coerced to strings.",
50663
+ description: "Flat name -> scalar map merged into the workflow's process settings (defaults), pinned for the whole process at its start. Scalars are coerced to strings.",
50664
+ additionalProperties: { type: [
50665
+ "string",
50666
+ "number",
50667
+ "boolean"
50668
+ ] }
50669
+ };
50670
+ /** The `env:` shape: environment settings, read live on every invocation and never pinned to a process. */
50671
+ const envJsonSchema = {
50672
+ type: "object",
50673
+ description: "Flat name -> scalar map merged into the workflow's environment settings (envDefaults) — values that change how a step runs on this machine, like the test command or a model hint. Read fresh on every gtd call, never pinned to a process. GTD_<NAME> environment variables override these entries. Scalars are coerced to strings.",
50650
50674
  additionalProperties: { type: [
50651
50675
  "string",
50652
50676
  "number",
@@ -50700,13 +50724,46 @@ const UiSchema = Struct({
50700
50724
  key: optional$1(String$.annotations({ description: "Path to a TLS private key file, enabling HTTPS. Requires `cert` too." })),
50701
50725
  format: optional$1(String$.annotations({ description: "Shell command run after every UI write, before it resolves ($GTD_FILE is the written file's absolute path). A non-zero exit or missing binary never reverts the write or refuses it — it's reported to the client as a notice naming the command and its exit code. Absent means no command runs at all." }))
50702
50726
  }).annotations({ description: "Settings for `gtd ui`: where it listens, and optional TLS." });
50727
+ const JudgeSchema = Struct({
50728
+ provider: optional$1(Literal("fixed", "jev", "llm").annotations({ description: "Judge provider `gtd judge run` uses when no --provider flag and no GTD_JUDGE_PROVIDER is set. Absent means auto: jev when TYPESAFE_API_KEY is set, otherwise llm." })),
50729
+ model: optional$1(String$.annotations({ description: "Model for provider llm, used when no --model flag and no GTD_JUDGE_MODEL is set. Pairing it with another provider is an error." }))
50730
+ }).annotations({ description: "Which judge `gtd judge run` uses, when no flag or GTD_JUDGE_* variable says." });
50703
50731
  const ConfigSchema = Struct({
50704
50732
  vars: optional$1(Unknown.annotations({ jsonSchema: varsJsonSchema })),
50733
+ env: optional$1(Unknown.annotations({ jsonSchema: envJsonSchema })),
50734
+ judge: optional$1(JudgeSchema),
50705
50735
  modes: optional$1(Unknown.annotations({ jsonSchema: modesJsonSchema })),
50706
50736
  ui: optional$1(UiSchema),
50707
50737
  skills: optional$1(Unknown.annotations({ jsonSchema: skillsJsonSchema }))
50708
50738
  });
50709
50739
  //#endregion
50740
+ //#region src/workflow/vars.ts
50741
+ const PREFIX = "GTD_";
50742
+ /**
50743
+ * One kind of setting, later wins: the workflow's own defaults, the `.gtdrc`
50744
+ * map (`vars:` or `env:`), `--var` overrides (process settings only; environment
50745
+ * settings pass `{}`), then `GTD_<NAME>` for any name an earlier layer declared
50746
+ * (a shell variable never introduces a name).
50747
+ */
50748
+ const resolveVars = (workflowVars, rcVars, entryVars, env) => {
50749
+ const merged = {
50750
+ ...workflowVars,
50751
+ ...rcVars,
50752
+ ...entryVars
50753
+ };
50754
+ for (const name of Object.keys(merged)) {
50755
+ const value = env[PREFIX + name.toUpperCase()];
50756
+ if (value !== void 0) merged[name] = value;
50757
+ }
50758
+ return merged;
50759
+ };
50760
+ /** The first process setting whose value spans lines — a `Gtd-Var:` trailer cannot carry it. */
50761
+ const multilineSetting = (vars) => Object.keys(vars).sort().find((name) => /[\r\n]/.test(vars[name]));
50762
+ const SETTING_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
50763
+ /** A name is written verbatim into a `Gtd-Var: <name>=<value>` trailer, so `=` or a newline would corrupt it. */
50764
+ const isSettingName = (name) => SETTING_NAME.test(name);
50765
+ const SETTING_NAME_RULE = "a setting name is a letter or \"_\", then letters, digits or \"_\"";
50766
+ //#endregion
50710
50767
  //#region src/SteeringFormats.ts
50711
50768
  /** The unrendered `validate:` template a workflow compiler seeds for a built-in mode — quoted so a path with spaces survives interpolation. */
50712
50769
  const seededValidateCommand = (mode) => `gtd check ${mode} "$GTD_FILE"`;
@@ -50791,14 +50848,14 @@ const err = (path, message) => ({
50791
50848
  origin: ""
50792
50849
  });
50793
50850
  /** A flat `name -> scalar` map; a malformed value is a load error and is dropped. */
50794
- const compileVarsMap = (raw) => {
50851
+ const compileVarsMap = (raw, keyName) => {
50795
50852
  const diagnostics = [];
50796
50853
  if (raw === void 0) return {
50797
50854
  vars: {},
50798
50855
  diagnostics
50799
50856
  };
50800
50857
  if (!isPlainObject$3(raw)) {
50801
- diagnostics.push(err(["vars"], `"vars" must be a mapping of name -> scalar value, got ${describeType(raw)}`));
50858
+ diagnostics.push(err([keyName], `"${keyName}" must be a mapping of name -> scalar value, got ${describeType(raw)}`));
50802
50859
  return {
50803
50860
  vars: {},
50804
50861
  diagnostics
@@ -50806,8 +50863,12 @@ const compileVarsMap = (raw) => {
50806
50863
  }
50807
50864
  const vars = {};
50808
50865
  for (const [key, value] of Object.entries(raw)) {
50866
+ if (!isSettingName(key)) {
50867
+ diagnostics.push(err([keyName], `"${keyName}" key ${JSON.stringify(key)} is not a valid setting name — ${SETTING_NAME_RULE}`));
50868
+ continue;
50869
+ }
50809
50870
  if (!isScalar(value)) {
50810
- diagnostics.push(err(["vars", key], `"vars.${key}" must be a string, number, or boolean, got ${describeType(value)}`));
50871
+ diagnostics.push(err([keyName, key], `"${keyName}.${key}" must be a string, number, or boolean, got ${describeType(value)}`));
50811
50872
  continue;
50812
50873
  }
50813
50874
  vars[key] = String(value);
@@ -51002,9 +51063,16 @@ const withOrigin = (diagnostics, lookup) => diagnostics.map((d) => ({
51002
51063
  ...d,
51003
51064
  origin: lookup(d.path)
51004
51065
  }));
51066
+ const layerKeys = (layers, name) => layers.flatMap((layer) => {
51067
+ const own = isPlainObject$3(layer.value) ? layer.value[name] : void 0;
51068
+ return isPlainObject$3(own) ? Object.keys(own).map((key) => ({
51069
+ key,
51070
+ origin: layer.origin
51071
+ })) : [];
51072
+ });
51005
51073
  /**
51006
51074
  * Merge every layer outermost→innermost and compile what a `.gtdrc` may carry:
51007
- * `vars`, `modes` and `ui`. A workflow is defined only in `gtd.config.ts`, so a
51075
+ * `vars`, `env`, `judge`, `modes` and `ui`. A workflow is defined only in `gtd.config.ts`, so a
51008
51076
  * leftover `workflow:` key is an error pointing there. Pure and total.
51009
51077
  */
51010
51078
  const compileConfig = (layers) => {
@@ -51021,8 +51089,13 @@ const compileConfig = (layers) => {
51021
51089
  const mergedConfig = merged;
51022
51090
  const lookupIn = (path) => originAt(originTree, path);
51023
51091
  const ui = mergedConfig["ui"];
51024
- const { vars: rcVars, diagnostics: varsDiagnostics } = compileVarsMap(mergedConfig["vars"]);
51092
+ const judge = mergedConfig["judge"];
51093
+ const { vars: rcVars, diagnostics: varsDiagnostics } = compileVarsMap(mergedConfig["vars"], "vars");
51025
51094
  diagnostics.push(...withOrigin(varsDiagnostics, lookupIn));
51095
+ const { vars: rcEnv, diagnostics: envDiagnostics } = compileVarsMap(mergedConfig["env"], "env");
51096
+ diagnostics.push(...withOrigin(envDiagnostics, lookupIn));
51097
+ const varsKeys = layerKeys(layers, "vars");
51098
+ const envKeys = layerKeys(layers, "env");
51026
51099
  diagnostics.push(...withOrigin(deadSkillsVarDiagnostics(mergedConfig["vars"]), lookupIn));
51027
51100
  const { modes: rcModes, diagnostics: modesDiagnostics } = compileModesMap(mergedConfig["modes"]);
51028
51101
  diagnostics.push(...withOrigin(modesDiagnostics, lookupIn));
@@ -51043,6 +51116,10 @@ const compileConfig = (layers) => {
51043
51116
  const seeded = Object.fromEntries(BUILT_IN_MODE_NAMES.map((name) => [name, { validate: seededValidateCommand(name) }]));
51044
51117
  return {
51045
51118
  rcVars,
51119
+ rcEnv,
51120
+ varsKeys,
51121
+ envKeys,
51122
+ ...judge !== void 0 ? { judge } : {},
51046
51123
  modes: mergeModes(seeded, rcModes),
51047
51124
  ...ui !== void 0 ? { ui } : {},
51048
51125
  rcSkills,
@@ -51108,26 +51185,6 @@ const interpolate = (config, env) => {
51108
51185
  };
51109
51186
  };
51110
51187
  //#endregion
51111
- //#region src/workflow/vars.ts
51112
- const PREFIX = "GTD_";
51113
- /**
51114
- * The merged vars, later wins: the workflow's own defaults, `.gtdrc` `vars:`,
51115
- * the process's entry vars, then `GTD_<NAME>` for any name an earlier layer
51116
- * declared (an env var never introduces a name).
51117
- */
51118
- const resolveVars = (workflowVars, rcVars, entryVars, env) => {
51119
- const merged = {
51120
- ...workflowVars,
51121
- ...rcVars,
51122
- ...entryVars
51123
- };
51124
- for (const name of Object.keys(merged)) {
51125
- const value = env[PREFIX + name.toUpperCase()];
51126
- if (value !== void 0) merged[name] = value;
51127
- }
51128
- return merged;
51129
- };
51130
- //#endregion
51131
51188
  //#region node_modules/yaml/dist/nodes/identity.js
51132
51189
  var require_identity = /* @__PURE__ */ __commonJSMin(((exports) => {
51133
51190
  const ALIAS = Symbol.for("yaml.alias");
@@ -57932,31 +57989,49 @@ const decodeLevel = (level, env) => gen(function* () {
57932
57989
  }))
57933
57990
  };
57934
57991
  });
57992
+ /** Everything the `.gtdrc` half of a load reads, findings not yet judged. */
57993
+ const readRc = gen(function* () {
57994
+ const narrator = yield* Narrator;
57995
+ const host = yield* Host;
57996
+ const levels = yield* (yield* ConfigDiscovery).levels(host.root, host.home);
57997
+ for (const level of levels) yield* narrator.narrate(`config: layer ${level.filepath}`);
57998
+ const decoded = yield* forEach$2(levels, (level) => decodeLevel(level, host.env));
57999
+ return {
58000
+ levels,
58001
+ compiled: compileConfig(decoded.flatMap((d) => d.layer !== void 0 ? [d.layer] : [])),
58002
+ decodeDiagnostics: decoded.flatMap((d) => d.diagnostics)
58003
+ };
58004
+ });
58005
+ const failOnErrors = (diagnostics) => {
58006
+ const fatal = diagnostics.filter((d) => d.severity === "error");
58007
+ if (fatal.length === 0) return _void;
58008
+ const lines = fatal.map(formatDiagnostic);
58009
+ return fail$6(new GtdError(`gtd config:\n${lines.map((line) => ` - ${line}`).join("\n")}`));
58010
+ };
58011
+ /**
58012
+ * The `.gtdrc` half of `load`: no repository, no `gtd.config.ts` evaluation.
58013
+ * Any error diagnostic fails it.
58014
+ */
58015
+ const loadRcConfig = gen(function* () {
58016
+ const { levels, compiled, decodeDiagnostics } = yield* readRc;
58017
+ yield* failOnErrors(dedupeDiagnostics(sortDiagnostics([...decodeDiagnostics, ...compiled.diagnostics], levels.map((level) => level.filepath))));
58018
+ return compiled;
58019
+ });
57935
58020
  /**
57936
58021
  * Build the whole config pipeline — discover levels by walking `Host`'s
57937
58022
  * cwd→home chain (`ConfigDiscovery`, kept behind a `Context.Tag` since its
57938
58023
  * production implementation reads real disk through cosmiconfig directly,
57939
58024
  * incompatible with an in-memory `@inmem` `Workspace`), decode each level
57940
- * individually against `ConfigSchema`, then hand every successfully-decoded
57941
- * layer to the pure `compileWorkflow` boundary (`src/workflow/compile.ts`),
57942
- * which does its own merging and `./`/`../` file-ref inlining (through
57943
- * `Workspace`, which — unlike discovery — both production and an in-memory
57944
- * scenario back identically). A decode failure never short-circuits the
58025
+ * individually against `ConfigSchema`, then compile every successfully-decoded
58026
+ * layer (`src/workflow/compile.ts`). A decode failure never short-circuits the
57945
58027
  * whole load: every level's own findings — decode AND compile alike — are
57946
- * sorted/deduped together (`levels`' own order is the layer order, decode
57947
- * failures included) into ONE report, so a config broken at two ancestor
58028
+ * sorted/deduped together into ONE report, so a config broken at two ancestor
57948
58029
  * layers reports both instead of just the first one reached.
57949
58030
  */
57950
58031
  const load = gen(function* () {
57951
- const narrator = yield* Narrator;
57952
58032
  const host = yield* Host;
57953
58033
  const discovery = yield* ConfigDiscovery;
57954
- const levels = yield* discovery.levels(host.root, host.home);
57955
- for (const level of levels) yield* narrator.narrate(`config: layer ${level.filepath}`);
57956
- const decoded = yield* forEach$2(levels, (level) => decodeLevel(level, host.env));
57957
- const layers = decoded.flatMap((d) => d.layer !== void 0 ? [d.layer] : []);
57958
- const decodeDiagnostics = decoded.flatMap((d) => d.diagnostics);
57959
- const compiled = compileConfig(layers);
58034
+ const { levels, compiled, decodeDiagnostics } = yield* readRc;
57960
58035
  const module = yield* discovery.workflowModule(host.root, host.home);
57961
58036
  const loaded = yield* try_({
57962
58037
  try: () => loadWorkflow(module),
@@ -57973,14 +58048,11 @@ const load = gen(function* () {
57973
58048
  const diagnostics = dedupeDiagnostics(sortDiagnostics([
57974
58049
  ...decodeDiagnostics,
57975
58050
  ...compiled.diagnostics,
57976
- ...unknownSkillDiagnostics
58051
+ ...unknownSkillDiagnostics,
58052
+ ...wrongKindDiagnostics(loaded, compiled)
57977
58053
  ], layerOrder));
57978
- const fatal = diagnostics.filter((d) => d.severity === "error");
57979
- if (fatal.length > 0) {
57980
- const lines = fatal.map(formatDiagnostic);
57981
- return yield* fail$6(new GtdError(`gtd config:\n${lines.map((line) => ` - ${line}`).join("\n")}`));
57982
- }
57983
- const initial = yield* firstStep(loaded, resolveVars(loaded.defaults, compiled.rcVars, {}, host.env), headTree(yield* Workspace));
58054
+ yield* failOnErrors(diagnostics);
58055
+ const initial = yield* firstStep(loaded, headTree(yield* Workspace));
57984
58056
  return {
57985
58057
  workflow: {
57986
58058
  flow: loaded.flow,
@@ -57994,12 +58066,30 @@ const load = gen(function* () {
57994
58066
  },
57995
58067
  workflowVars: { ...loaded.defaults },
57996
58068
  rcVars: compiled.rcVars,
58069
+ workflowEnv: { ...loaded.envDefaults },
58070
+ rcEnv: compiled.rcEnv,
57997
58071
  ...compiled.ui !== void 0 ? { ui: compiled.ui } : {},
57998
58072
  warnings: diagnostics.filter((d) => d.severity === "warning"),
57999
58073
  configFiles: levels.map((level) => level.filepath),
58000
58074
  workflowFiles: loaded.dependencyFiles
58001
58075
  };
58002
58076
  });
58077
+ /** A name under the wrong key (or under both) in a `.gtdrc` layer: each is an error at its own origin. */
58078
+ const wrongKindDiagnostics = (loaded, compiled) => {
58079
+ const error = (path, message, origin) => ({
58080
+ severity: "error",
58081
+ path,
58082
+ message,
58083
+ origin
58084
+ });
58085
+ const diagnostics = [];
58086
+ for (const { key, origin } of compiled.varsKeys) if (Object.hasOwn(loaded.envDefaults, key)) diagnostics.push(error(["vars", key], `"vars.${key}" is an environment setting — move it under "env:"`, origin));
58087
+ for (const { key, origin } of compiled.envKeys) {
58088
+ if (Object.hasOwn(loaded.defaults, key)) diagnostics.push(error(["env", key], `"env.${key}" is a process setting — move it under "vars:"`, origin));
58089
+ if (compiled.varsKeys.some((v) => v.key === key)) diagnostics.push(error(["env", key], `"${key}" is declared under both "vars:" and "env:" — a setting is one kind or the other`, origin));
58090
+ }
58091
+ return diagnostics;
58092
+ };
58003
58093
  const TRANSFORM_MEMO_SIZE = 512;
58004
58094
  const memoized = (transform) => {
58005
58095
  const memo = /* @__PURE__ */ new Map();
@@ -58051,15 +58141,24 @@ const optionalFunction = (exports, name) => {
58051
58141
  };
58052
58142
  /**
58053
58143
  * Read a workflow module: the default export is the flow; `defaults`,
58054
- * `summary`, `base` and `steering` are optional; any other export is ignored.
58144
+ * `envDefaults`, `summary`, `base` and `steering` are optional; any other export is ignored.
58055
58145
  */
58056
58146
  const fromModule = (exported, origin, dependencyFiles) => {
58057
58147
  const exports = typeof exported === "object" && exported !== null ? exported : {};
58058
58148
  const flow = exports.default;
58059
58149
  if (typeof flow !== "function") throw new Error("the default export is not a flow — export default an async function");
58150
+ const defaults = stringRecord(exports, "defaults");
58151
+ const envDefaults = stringRecord(exports, "envDefaults");
58152
+ for (const [exportName, record] of [["defaults", defaults], ["envDefaults", envDefaults]]) {
58153
+ const bad = Object.keys(record).find((name) => !isSettingName(name));
58154
+ if (bad !== void 0) throw new Error(`the "${exportName}" export declares ${JSON.stringify(bad)}, not a valid setting name — ${SETTING_NAME_RULE}`);
58155
+ }
58156
+ const both = Object.keys(defaults).filter((name) => Object.hasOwn(envDefaults, name));
58157
+ if (both.length > 0) throw new Error(`${both.map((name) => `"${name}"`).join(", ")} declared in both "defaults" and "envDefaults" — a setting is a process setting or an environment setting, not both`);
58060
58158
  return {
58061
58159
  flow,
58062
- defaults: stringRecord(exports, "defaults"),
58160
+ defaults,
58161
+ envDefaults,
58063
58162
  steering: stringRecord(exports, "steering"),
58064
58163
  skills: skillsRecord(exports),
58065
58164
  summary: optionalFunction(exports, "summary"),
@@ -58106,7 +58205,38 @@ const watchedEmptyTree = () => {
58106
58205
  read: () => read
58107
58206
  };
58108
58207
  };
58109
- const replayToFirstStep = (loaded, vars, tree) => flatMap$4(promise(() => replay({
58208
+ /** An empty settings record that logs every read: a named `get`/`has`, or an enumeration. */
58209
+ const watchedEmptySettings = () => {
58210
+ const names = /* @__PURE__ */ new Set();
58211
+ let enumerated = false;
58212
+ return {
58213
+ settings: new Proxy({}, {
58214
+ get: (_target, key) => {
58215
+ if (typeof key === "string") names.add(key);
58216
+ },
58217
+ has: (_target, key) => {
58218
+ if (typeof key === "string") names.add(key);
58219
+ return false;
58220
+ },
58221
+ ownKeys: () => {
58222
+ enumerated = true;
58223
+ return [];
58224
+ }
58225
+ }),
58226
+ names,
58227
+ enumerated: () => enumerated
58228
+ };
58229
+ };
58230
+ const settingReads = (kind, watch) => {
58231
+ const reads = [];
58232
+ if (watch.names.size > 0) {
58233
+ const sorted = [...watch.names].sort().map((name) => JSON.stringify(name));
58234
+ reads.push(`the ${kind} setting${sorted.length > 1 ? "s" : ""} ${sorted.join(", ")}`);
58235
+ }
58236
+ if (watch.enumerated()) reads.push(`every ${kind} setting`);
58237
+ return reads;
58238
+ };
58239
+ const replayToFirstStep = (loaded, vars, env, tree) => flatMap$4(promise(() => replay({
58110
58240
  flow: loaded.flow,
58111
58241
  episode: {
58112
58242
  entry: void 0,
@@ -58117,6 +58247,7 @@ const replayToFirstStep = (loaded, vars, tree) => flatMap$4(promise(() => replay
58117
58247
  commits: []
58118
58248
  },
58119
58249
  vars,
58250
+ env,
58120
58251
  start: "",
58121
58252
  budgetBytes: Number.MAX_SAFE_INTEGER
58122
58253
  })), (outcome) => {
@@ -58127,15 +58258,22 @@ const replayToFirstStep = (loaded, vars, tree) => flatMap$4(promise(() => replay
58127
58258
  /**
58128
58259
  * The default entry's first step — where a finished process waits — found the
58129
58260
  * only way a flow can be read: by replaying it over an empty history. Episode
58130
- * boundaries are that step's commits, so it must not move with the tree: a
58131
- * flow that reads the repository before reaching it is replayed again over
58132
- * HEAD, and must reach the same step there.
58261
+ * boundaries are that step's commits, so it must depend on neither a setting
58262
+ * (edited mid-process, it would move the boundary before pinned values are
58263
+ * read) nor the tree: a flow that reads the repository before reaching it is
58264
+ * replayed again over HEAD, and must reach the same step there.
58133
58265
  */
58134
- const firstStep = (loaded, vars, head) => gen(function* () {
58266
+ const firstStep = (loaded, head) => gen(function* () {
58135
58267
  const empty = watchedEmptyTree();
58136
- const name = yield* replayToFirstStep(loaded, vars, empty.tree);
58268
+ const vars = watchedEmptySettings();
58269
+ const env = watchedEmptySettings();
58270
+ const outcome = yield* either(replayToFirstStep(loaded, vars.settings, env.settings, empty.tree));
58271
+ const reads = [...settingReads("process", vars), ...settingReads("environment", env)];
58272
+ if (reads.length > 0) return yield* fail$6(new GtdError(`gtd config:\n - ${loaded.origin}: the flow's first step on an ordinary start reads ${reads.join(" and ")} — that step is where a finished process waits, so it must not depend on a setting`));
58273
+ if (outcome._tag === "Left") return yield* fail$6(outcome.left);
58274
+ const name = outcome.right;
58137
58275
  if (!empty.read()) return name;
58138
- const atHead = yield* replayToFirstStep(loaded, vars, head);
58276
+ const atHead = yield* replayToFirstStep(loaded, vars.settings, env.settings, head);
58139
58277
  if (atHead === name) return name;
58140
58278
  return yield* fail$6(new GtdError(`gtd config:\n - ${loaded.origin}: the flow's first step on an ordinary start depends on the repository's files ("${name}" without them, "${atHead}" at HEAD) — that step is where a finished process waits, so it must not change as files do`));
58141
58279
  });
@@ -58145,14 +58283,14 @@ var ConfigService = class ConfigService extends Tag("ConfigService")() {
58145
58283
  //#endregion
58146
58284
  //#region src/workflow/init.ts
58147
58285
  const SCHEMA_URL = "https://cdn.jsdelivr.net/npm/@pmelab/gtd/schema.json";
58148
- const INIT_VARS = { testCommand: "npm test" };
58286
+ const INIT_ENV = { testCommand: "npm test" };
58149
58287
  const MODES_SUGGESTION = {
58150
58288
  qa: { format: "npx prettier --write \"$GTD_FILE\"" },
58151
58289
  review: { format: "npx prettier --write \"$GTD_FILE\"" }
58152
58290
  };
58153
58291
  const renderInitScaffold = () => ({ config: JSON.stringify({
58154
58292
  $schema: SCHEMA_URL,
58155
- vars: INIT_VARS,
58293
+ env: INIT_ENV,
58156
58294
  modes: MODES_SUGGESTION
58157
58295
  }, null, 2) + "\n" });
58158
58296
  //#endregion
@@ -58449,12 +58587,37 @@ const llm = ({ env, cwd, model = DEFAULT_MODEL, timeoutMs = TIMEOUT_MS }) => asy
58449
58587
  };
58450
58588
  //#endregion
58451
58589
  //#region src/judges/run.ts
58590
+ const PROVIDERS = [
58591
+ "fixed",
58592
+ "jev",
58593
+ "llm"
58594
+ ];
58452
58595
  const fail = (message) => {
58453
58596
  throw new Error(`gtd judge run: ${message}`);
58454
58597
  };
58598
+ const envProvider = (env) => {
58599
+ const value = env["GTD_JUDGE_PROVIDER"] || void 0;
58600
+ if (value !== void 0 && !PROVIDERS.includes(value)) return fail(`GTD_JUDGE_PROVIDER must be one of ${PROVIDERS.join(", ")} — got "${value}"`);
58601
+ return value;
58602
+ };
58603
+ const checkPairing = (resolved) => resolved.model !== void 0 && resolved.provider !== void 0 && resolved.provider !== "llm" ? fail(`a model only applies to provider llm, not ${resolved.provider}`) : resolved;
58604
+ /**
58605
+ * Per field, the first one set wins: flag, then `GTD_JUDGE_PROVIDER` /
58606
+ * `GTD_JUDGE_MODEL` (empty counts as unset), then `.gtdrc` `judge:`. The
58607
+ * provider/model pairing is checked on the resolved pair, whichever layer
58608
+ * supplied each half.
58609
+ */
58610
+ const selectJudge = ({ provider, model, configured, env }) => {
58611
+ const resolved = {
58612
+ provider: provider ?? envProvider(env) ?? configured?.provider,
58613
+ model: model ?? (env["GTD_JUDGE_MODEL"] || void 0) ?? configured?.model
58614
+ };
58615
+ return checkPairing(resolved);
58616
+ };
58455
58617
  /** The only place auto selection lives; it never falls back across providers. */
58456
- const answererFor = ({ provider, answers, env, model, cwd }) => {
58457
- if (model !== void 0 && provider !== void 0 && provider !== "llm") return fail(`--model only applies to --provider llm, not ${provider}`);
58618
+ const answererFor = (opts) => {
58619
+ const { answers, env, cwd } = opts;
58620
+ const { provider, model } = selectJudge(opts);
58458
58621
  if (provider === "fixed") return fixed({
58459
58622
  answersPath: answers,
58460
58623
  env
@@ -63801,7 +63964,7 @@ const runOf = (history, location, closingHash = void 0) => {
63801
63964
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
63802
63965
  costEntries: parsed.flatMap(costEntriesOf),
63803
63966
  judgeVerdicts: parsed.flatMap((m) => m.judge),
63804
- entryVars: location.entry === void 0 ? {} : { ...first?.vars },
63967
+ pinnedVars: { ...first?.vars },
63805
63968
  headTurn: headTurnOf(head),
63806
63969
  closingHash,
63807
63970
  episode: {
@@ -63957,6 +64120,20 @@ const pendingTree = (workspace, entries, rewrite) => {
63957
64120
  };
63958
64121
  };
63959
64122
  /**
64123
+ * The one resolver of both setting kinds. Process settings: the recorded
64124
+ * (pinned) value wins per name, anything unrecorded resolves live — the
64125
+ * fallback for a process started before pinning existed. Environment settings
64126
+ * are always live. `entryOverrides` are `--var` values, passed only while a
64127
+ * process is being entered.
64128
+ */
64129
+ const settingsFor = (config, pinnedVars, hostEnv, entryOverrides = {}) => ({
64130
+ vars: {
64131
+ ...resolveVars(config.workflowVars, config.rcVars, entryOverrides, hostEnv),
64132
+ ...pinnedVars
64133
+ },
64134
+ env: resolveVars(config.workflowEnv, config.rcEnv, {}, hostEnv)
64135
+ });
64136
+ /**
63960
64137
  * `judgeBudgetBytes` is the one var that refuses rather than disabling its
63961
64138
  * mechanism when blanked: without a bound the judge payload is rejected
63962
64139
  * anyway. Absent falls back to a conservative default.
@@ -63977,6 +64154,7 @@ const replayFor = (setup, pending) => replay({
63977
64154
  commits: setup.episode
63978
64155
  },
63979
64156
  vars: setup.vars,
64157
+ env: setup.env,
63980
64158
  start: setup.run.diffBase,
63981
64159
  startTree: setup.run.diffBase === EMPTY_TREE$1 ? treeFromRecord({}) : commitTree(setup.workspace, setup.run.diffBase),
63982
64160
  budgetBytes: setup.budget,
@@ -64119,7 +64297,7 @@ const restAt = (ref) => gen(function* () {
64119
64297
  const host = yield* Host;
64120
64298
  const def = config.workflow;
64121
64299
  const run = yield* computeProcessRun(git, def, ref);
64122
- const vars = resolveVars(config.workflowVars, config.rcVars, run.entryVars, host.env);
64300
+ const { vars, env } = settingsFor(config, run.pinnedVars, host.env);
64123
64301
  const budget = yield* try_({
64124
64302
  try: () => judgeBudgetBytes(vars),
64125
64303
  catch: (e) => e instanceof Error ? e : new Error(String(e))
@@ -64142,6 +64320,7 @@ const restAt = (ref) => gen(function* () {
64142
64320
  def,
64143
64321
  run,
64144
64322
  vars,
64323
+ env,
64145
64324
  budget,
64146
64325
  workspace,
64147
64326
  base,
@@ -64169,6 +64348,7 @@ const restAt = (ref) => gen(function* () {
64169
64348
  trace: outcome.trace,
64170
64349
  run,
64171
64350
  vars,
64351
+ env,
64172
64352
  changes,
64173
64353
  memory: memory.key,
64174
64354
  memoryResumed: memory.resumed,
@@ -64189,7 +64369,7 @@ const currentRest = restAt(void 0);
64189
64369
  const entryRefusal = (rest, name, entryVars) => gen(function* () {
64190
64370
  const config = yield* (yield* ConfigService).load;
64191
64371
  const host = yield* Host;
64192
- const vars = resolveVars(config.workflowVars, config.rcVars, entryVars, host.env);
64372
+ const { vars, env } = settingsFor(config, {}, host.env, entryVars);
64193
64373
  const workspace = rest.setup.workspace;
64194
64374
  const outcome = yield* promise(() => replay({
64195
64375
  flow: rest.def.flow,
@@ -64202,6 +64382,7 @@ const entryRefusal = (rest, name, entryVars) => gen(function* () {
64202
64382
  commits: []
64203
64383
  },
64204
64384
  vars,
64385
+ env,
64205
64386
  start: "",
64206
64387
  budgetBytes: rest.setup.budget,
64207
64388
  skills: rest.def.skills,
@@ -64254,28 +64435,29 @@ const reviewGateRewrite = (def) => {
64254
64435
  apply: (content) => reviewFormat.clearTicks(content)
64255
64436
  };
64256
64437
  };
64257
- /**
64258
- * What landing the pending turn at `rest` does — a pure decision from a
64259
- * replay with the working tree as the pending turn. The target step is
64260
- * computed by replaying, never matched.
64261
- */
64262
- const decideLanding = (rest, verdicts) => gen(function* () {
64263
- const early = cleanLanding(rest);
64264
- if (early !== void 0) return early;
64265
- const outcome = yield* promise(() => replayFor(rest.setup, {
64266
- tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
64267
- ...verdicts !== void 0 ? { verdicts } : {}
64268
- }));
64269
- if (outcome.kind === "refused") return {
64438
+ const multilineRefusal = (vars) => {
64439
+ const multiline = multilineSetting(vars);
64440
+ return multiline === void 0 ? void 0 : {
64270
64441
  kind: "refusal",
64271
- message: outcome.message
64442
+ message: `gtd: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`
64272
64443
  };
64273
- if (outcome.kind === "divergence" || outcome.kind === "failed") return yield* fail$6(new Error(outcome.message));
64274
- const to = outcome.kind === "rest" ? outcome.rest.name : rest.def.initial;
64444
+ };
64445
+ const landingTarget = (outcome, rest) => {
64446
+ if (outcome.kind === "refused") return succeed$3({
64447
+ kind: "refusal",
64448
+ message: outcome.message
64449
+ });
64450
+ if (outcome.kind === "divergence" || outcome.kind === "failed") return fail$6(new Error(outcome.message));
64451
+ return succeed$3(outcome.kind === "rest" ? outcome.rest.name : rest.def.initial);
64452
+ };
64453
+ const landingTo = (rest, to) => {
64275
64454
  if (rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state) return {
64276
64455
  kind: "noop",
64277
64456
  settled: true
64278
64457
  };
64458
+ const starts = noProcessUnderway(rest) && to !== rest.def.initial;
64459
+ const refused = starts ? multilineRefusal(rest.vars) : void 0;
64460
+ if (refused !== void 0) return refused;
64279
64461
  return {
64280
64462
  kind: "commit",
64281
64463
  to,
@@ -64283,9 +64465,26 @@ const decideLanding = (rest, verdicts) => gen(function* () {
64283
64465
  actor: rest.actor,
64284
64466
  from: rest.state,
64285
64467
  to,
64286
- step: rest.step.id
64468
+ step: rest.step.id,
64469
+ ...starts ? { vars: rest.vars } : {}
64287
64470
  }
64288
64471
  };
64472
+ };
64473
+ /**
64474
+ * What landing the pending turn at `rest` does — a pure decision from a
64475
+ * replay with the working tree as the pending turn. The target step is
64476
+ * computed by replaying, never matched.
64477
+ */
64478
+ const decideLanding = (rest, verdicts) => gen(function* () {
64479
+ const early = cleanLanding(rest);
64480
+ if (early !== void 0) return early;
64481
+ const outcome = yield* promise(() => replayFor(rest.setup, {
64482
+ tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
64483
+ ...verdicts !== void 0 ? { verdicts } : {}
64484
+ }));
64485
+ const to = yield* landingTarget(outcome, rest);
64486
+ if (typeof to !== "string") return to;
64487
+ return landingTo(rest, to);
64289
64488
  });
64290
64489
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
64291
64490
  const previewLanding = (rest) => decideLanding(rest, void 0).pipe(map$4((landing) => landing.kind === "commit" ? landing.to : void 0), catchAll(() => succeed$3(void 0)));
@@ -64318,7 +64517,7 @@ const summaryFor = (run) => gen(function* () {
64318
64517
  })),
64319
64518
  processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
64320
64519
  processCostByModel: costByModel(run.costEntries),
64321
- vars: resolveVars(config.workflowVars, config.rcVars, run.entryVars, host.env)
64520
+ ...settingsFor(config, run.pinnedVars, host.env)
64322
64521
  });
64323
64522
  });
64324
64523
  //#endregion
@@ -64417,9 +64616,15 @@ const refusal = (message) => ({
64417
64616
  kind: "refusal",
64418
64617
  message
64419
64618
  });
64619
+ const checkOverrides = (commandLabel, varOverrides, declaredNames, envNames) => gen(function* () {
64620
+ const environmentOnly = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n) && envNames.includes(n));
64621
+ if (environmentOnly.length > 0) return yield* fail$6(new GtdUsageError(`${commandLabel}: --var pins process settings only; ${environmentOnly.join(", ")} ${environmentOnly.length === 1 ? "is an environment setting" : "are environment settings"} — set it under "env:" in .gtdrc or with a GTD_<NAME> environment variable`));
64622
+ const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n));
64623
+ if (undeclared.length > 0) return refusal(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`);
64624
+ });
64420
64625
  /**
64421
64626
  * `gtd --entry <name>`: start a brand NEW process at a workflow entry, writing
64422
- * an opening commit that carries zero or more `Gtd-Var:` trailers, plus a
64627
+ * an opening commit that carries every process setting as a `Gtd-Var:` trailer, plus a
64423
64628
  * `Gtd-Review-Base:` trailer when the entry fixes the process's diff base.
64424
64629
  * All validation is a REFUSAL, not an Effect failure.
64425
64630
  */
@@ -64432,11 +64637,16 @@ const planEntry = (current, actor, entry) => gen(function* () {
64432
64637
  ...config.workflowVars,
64433
64638
  ...config.rcVars
64434
64639
  });
64435
- const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n));
64436
- if (undeclared.length > 0) return refusal(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`);
64640
+ const overrideRefusal = yield* checkOverrides(commandLabel, varOverrides, declaredNames, Object.keys({
64641
+ ...config.workflowEnv,
64642
+ ...config.rcEnv
64643
+ }));
64644
+ if (overrideRefusal !== void 0) return overrideRefusal;
64437
64645
  let base;
64438
64646
  const baseOf = current.def.base;
64439
64647
  const vars = resolveVars(config.workflowVars, config.rcVars, varOverrides, (yield* Host).env);
64648
+ const multiline = multilineSetting(vars);
64649
+ if (multiline !== void 0) return refusal(`${commandLabel}: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`);
64440
64650
  const template = yield* try_({
64441
64651
  try: () => baseOf?.(name, vars),
64442
64652
  catch: (e) => /* @__PURE__ */ new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`)
@@ -64459,7 +64669,7 @@ const planEntry = (current, actor, entry) => gen(function* () {
64459
64669
  actor,
64460
64670
  to: name,
64461
64671
  ...base !== void 0 ? { reviewBase: base } : {},
64462
- vars: varOverrides
64672
+ vars
64463
64673
  })
64464
64674
  }
64465
64675
  }, {
@@ -77045,10 +77255,12 @@ const readStdin = () => tryPromise({
77045
77255
  /** Stdout is written once, after the answerer resolves — a rejection leaves it empty. */
77046
77256
  const runJudgeRunCommand = (command, out) => gen(function* () {
77047
77257
  const host = yield* Host;
77258
+ const { judge } = yield* loadRcConfig;
77048
77259
  const input = yield* readStdin();
77049
77260
  const verdict = yield* tryPromise({
77050
77261
  try: () => runJudge({
77051
77262
  provider: command.provider,
77263
+ configured: judge,
77052
77264
  answers: command.answers,
77053
77265
  model: command.model,
77054
77266
  cwd: host.scratchDir,
@@ -77616,7 +77828,11 @@ const FLAGS = [
77616
77828
  decode: ([raw]) => raw === "fixed" || raw === "jev" || raw === "llm" ? right(raw) : left(`gtd: --provider must be one of fixed, jev, llm — got "${raw ?? ""}"`),
77617
77829
  scopeError: "gtd: --provider is only valid for `gtd judge run`",
77618
77830
  valueHint: "<name>",
77619
- help: ["(gtd judge run only) the answerer: fixed | jev | llm;", "omitted: jev when TYPESAFE_API_KEY is set, else llm"]
77831
+ help: [
77832
+ "(gtd judge run only) the answerer: fixed | jev | llm;",
77833
+ "omitted: GTD_JUDGE_PROVIDER, then .gtdrc judge:, then jev",
77834
+ "when TYPESAFE_API_KEY is set, else llm"
77835
+ ]
77620
77836
  },
77621
77837
  {
77622
77838
  name: "--answers",
@@ -77650,7 +77866,11 @@ const FLAGS = [
77650
77866
  decode: ([raw]) => raw === void 0 || raw.trim() === "" || /[\r\n]/.test(raw) ? left("gtd: --model must be a non-empty, single-line value") : right(raw),
77651
77867
  scopeError: "gtd: --model is only valid for `gtd land` (with --cost) or `gtd judge run`",
77652
77868
  valueHint: "<name>",
77653
- help: ["(gtd land, with --cost) tag that cost's model", "(gtd judge run, llm answerer) the claude model; default haiku"]
77869
+ help: [
77870
+ "(gtd land, with --cost) tag that cost's model",
77871
+ "(gtd judge run, llm answerer) the claude model; omitted:",
77872
+ "GTD_JUDGE_MODEL, then .gtdrc judge:, then haiku"
77873
+ ]
77654
77874
  },
77655
77875
  {
77656
77876
  name: "--entry",
@@ -77679,6 +77899,7 @@ const FLAGS = [
77679
77899
  if (eq <= 0) return left(`gtd: --var must be <name>=<value> with a non-empty name — got "${raw}"`);
77680
77900
  const name = raw.slice(0, eq);
77681
77901
  const value = raw.slice(eq + 1);
77902
+ if (!isSettingName(name)) return left(`gtd: --var ${JSON.stringify(name)} is not a valid setting name — ${SETTING_NAME_RULE}`);
77682
77903
  if (/[\r\n]/.test(value)) return left(`gtd: --var ${name} must be a single-line value`);
77683
77904
  if (seen.has(name)) return left(`gtd: --var ${name} specified more than once`);
77684
77905
  seen.add(name);
@@ -77689,9 +77910,10 @@ const FLAGS = [
77689
77910
  scopeError: "gtd: --var requires --entry",
77690
77911
  valueHint: "<name>=<value>",
77691
77912
  help: [
77692
- "(with --entry; repeatable) supply a fixed variable",
77693
- "override for the new process; the name must already be",
77694
- "declared by the workflow's defaults or the .gtdrc vars:"
77913
+ "(with --entry; repeatable) pin a process setting for the",
77914
+ "new process; the name must already be declared by the",
77915
+ "workflow's defaults or the .gtdrc vars: (environment",
77916
+ "settings are not pinnable)"
77695
77917
  ]
77696
77918
  },
77697
77919
  {
@@ -77764,8 +77986,8 @@ const COMMAND_ROWS = [
77764
77986
  arity: "none",
77765
77987
  details: [
77766
77988
  "Scaffold a minimal .gtdrc.json for this repo, seeding the",
77767
- "default variables you are most likely to change (the test",
77768
- "command) and a Prettier formatting suggestion. gtd runs its",
77989
+ "environment setting you are most likely to change (the test",
77990
+ "command, under env:) and a Prettier formatting suggestion. gtd runs its",
77769
77991
  "built-in workflow by default, so no workflow is written —",
77770
77992
  "write a gtd.config.ts only to customize the workflow itself.",
77771
77993
  "Takes no argument. Run once per repo; refuses if a gtd",
@@ -78009,8 +78231,11 @@ const COMMAND_ROWS = [
78009
78231
  "does not cover are left out. --provider jev asks TypeSafe",
78010
78232
  "(TYPESAFE_API_KEY). --provider llm asks the `claude` CLI",
78011
78233
  "on PATH (Claude Code only), model haiku unless --model.",
78012
- "No --provider: jev when TYPESAFE_API_KEY is set and no",
78013
- "--model is given, else llm. Needs no repository. jev and",
78234
+ "No --provider: GTD_JUDGE_PROVIDER, then .gtdrc judge:",
78235
+ "provider, else jev when TYPESAFE_API_KEY is set and no",
78236
+ "model is given, else llm; --model likewise falls back to",
78237
+ "GTD_JUDGE_MODEL, then judge: model. Needs no repository,",
78238
+ "only .gtdrc files. jev and",
78014
78239
  "llm exit 1 when they cannot answer every question, with",
78015
78240
  "nothing on stdout"
78016
78241
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "16.0.0",
3
+ "version": "17.0.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": {
package/schema.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "properties": {
6
6
  "vars": {
7
7
  "type": "object",
8
- "description": "Flat name -> scalar map merged into the workflow's vars. Scalars are coerced to strings.",
8
+ "description": "Flat name -> scalar map merged into the workflow's process settings (defaults), pinned for the whole process at its start. Scalars are coerced to strings.",
9
9
  "additionalProperties": {
10
10
  "type": [
11
11
  "string",
@@ -14,6 +14,38 @@
14
14
  ]
15
15
  }
16
16
  },
17
+ "env": {
18
+ "type": "object",
19
+ "description": "Flat name -> scalar map merged into the workflow's environment settings (envDefaults) — values that change how a step runs on this machine, like the test command or a model hint. Read fresh on every gtd call, never pinned to a process. GTD_<NAME> environment variables override these entries. Scalars are coerced to strings.",
20
+ "additionalProperties": {
21
+ "type": [
22
+ "string",
23
+ "number",
24
+ "boolean"
25
+ ]
26
+ }
27
+ },
28
+ "judge": {
29
+ "type": "object",
30
+ "required": [],
31
+ "properties": {
32
+ "provider": {
33
+ "type": "string",
34
+ "enum": [
35
+ "fixed",
36
+ "jev",
37
+ "llm"
38
+ ],
39
+ "description": "Judge provider `gtd judge run` uses when no --provider flag and no GTD_JUDGE_PROVIDER is set. Absent means auto: jev when TYPESAFE_API_KEY is set, otherwise llm."
40
+ },
41
+ "model": {
42
+ "type": "string",
43
+ "description": "Model for provider llm, used when no --model flag and no GTD_JUDGE_MODEL is set. Pairing it with another provider is an error."
44
+ }
45
+ },
46
+ "additionalProperties": false,
47
+ "description": "Which judge `gtd judge run` uses, when no flag or GTD_JUDGE_* variable says."
48
+ },
17
49
  "modes": {
18
50
  "type": "object",
19
51
  "description": "Steering-file modes a workflow step's mode: may name. Each entry declares at least one of format/validate: shell commands gtd runs via bash with $GTD_FILE set to the steering file's path. format rewrites the file in place; validate exits 0 when valid, non-zero with findings on stdout/stderr otherwise. The halves layer independently, so naming a built-in mode (qa/review) and declaring only format: adds formatting while keeping gtd's own validation. gtd ships no formatter — bring your own (prettier, dprint, a script).",
@@ -136,6 +136,7 @@ export interface FlowContext {
136
136
  readonly threads: (text: string) => readonly ThreadInfo[]
137
137
  readonly codeThreads: () => readonly CodeThreadInfo[]
138
138
  readonly vars: Readonly<Record<string, string>>
139
+ readonly env: Readonly<Record<string, string>>
139
140
  readonly head: () => string
140
141
  readonly start: () => string
141
142
  /**
@@ -256,19 +257,31 @@ export const start = (): string => ctx().start()
256
257
  export const skillsFor = (localName: string, ownSkills?: readonly string[]): readonly string[] =>
257
258
  ctx().skillsFor(localName, ownSkills)
258
259
 
259
- /** The merged workflow variables. */
260
- export const vars: Readonly<Record<string, string>> = new Proxy(
261
- {},
262
- {
263
- get: (_target, key) => (typeof key === "string" ? ctx().vars[key] : undefined),
264
- has: (_target, key) => typeof key === "string" && key in ctx().vars,
265
- ownKeys: () => Object.keys(ctx().vars),
266
- getOwnPropertyDescriptor: (_target, key) =>
267
- typeof key === "string" && key in ctx().vars
268
- ? { enumerable: true, configurable: true, value: ctx().vars[key] }
269
- : undefined,
270
- },
271
- )
260
+ const settingsProxy = (
261
+ read: () => Readonly<Record<string, string>>,
262
+ ): Readonly<Record<string, string>> =>
263
+ new Proxy(
264
+ {},
265
+ {
266
+ get: (_target, key) => (typeof key === "string" ? read()[key] : undefined),
267
+ has: (_target, key) => typeof key === "string" && key in read(),
268
+ ownKeys: () => Object.keys(read()),
269
+ getOwnPropertyDescriptor: (_target, key) =>
270
+ typeof key === "string" && key in read()
271
+ ? { enumerable: true, configurable: true, value: read()[key] }
272
+ : undefined,
273
+ },
274
+ )
275
+
276
+ /** The process settings: pinned at process start, so flow code may branch on them. */
277
+ export const vars: Readonly<Record<string, string>> = settingsProxy(() => ctx().vars)
278
+
279
+ /**
280
+ * The environment settings: resolved live on every invocation. Flow code must
281
+ * read them only where they cannot change the next step (step options, script
282
+ * bodies) — replay cannot enforce this.
283
+ */
284
+ export const env: Readonly<Record<string, string>> = settingsProxy(() => ctx().env)
272
285
 
273
286
  // ── Text utilities ──────────────────────────────────────────────────────────
274
287
 
@@ -343,6 +356,7 @@ export interface SummaryContext {
343
356
  readonly processCost: number
344
357
  readonly processCostByModel: readonly { readonly model: string; readonly cost: number }[]
345
358
  readonly vars: Readonly<Record<string, string>>
359
+ readonly env: Readonly<Record<string, string>>
346
360
  }
347
361
 
348
362
  /** `gtd summary`'s prompt — a workflow module's optional `summary` export. */
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  answered,
3
3
  check,
4
+ env,
4
5
  human,
5
6
  judge,
6
7
  numeric,
@@ -17,7 +18,7 @@ export const FIX_CAP = 3
17
18
 
18
19
  /** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
19
20
  export const baseline = (name: string, label = "Checking the baseline"): Promise<boolean> =>
20
- check(name, vars.testCommand ?? "", { report: FEEDBACK, label })
21
+ check(name, env.testCommand ?? "", { report: FEEDBACK, label })
21
22
 
22
23
  /** How many escalation rounds a run of red checks has spent — reset once the suite goes green. */
23
24
  export interface EscalationCount {
@@ -95,7 +96,7 @@ export const healthy = async (
95
96
  let fixes = options.fixesSoFar ?? 0
96
97
  let previous: string | undefined
97
98
  for (;;) {
98
- const green = await check("health.check", vars.testCommand ?? "", {
99
+ const green = await check("health.check", env.testCommand ?? "", {
99
100
  report: FEEDBACK,
100
101
  label: "Running checks",
101
102
  // Swept only on green: an unresolved analysis survives every retry.
@@ -1,4 +1,4 @@
1
- import { human, vars } from "../flows/index.js"
1
+ import { env, human } from "../flows/index.js"
2
2
  import * as t from "./text.js"
3
3
 
4
4
  // The bundled workflow's single steps. A step's name is relative to the
@@ -14,8 +14,8 @@ export const REVIEW = ".gtd/REVIEW.md"
14
14
  export const QUALITY = ".gtd/QUALITY.md"
15
15
  export const SPEC_FEEDBACK = ".gtd/SPEC_FEEDBACK.md"
16
16
 
17
- const planner = (): string => vars.plannerModel ?? ""
18
- const coder = (): string => vars.coderModel ?? ""
17
+ const planner = (): string => env.plannerModel ?? ""
18
+ const coder = (): string => env.coderModel ?? ""
19
19
 
20
20
  // ── Planning ────────────────────────────────────────────────────────────────
21
21
 
@@ -5,10 +5,11 @@ import {
5
5
  type StepRequest,
6
6
  } from "../flows/index.js"
7
7
  import { skills as bundledSkills } from "./skills.js"
8
- import { defaults } from "./vars.js"
8
+ import { defaults, envDefaults } from "./vars.js"
9
9
 
10
10
  export interface TextContext {
11
11
  readonly vars?: Readonly<Record<string, string>>
12
+ readonly env?: Readonly<Record<string, string>>
12
13
  readonly head?: string
13
14
  readonly start?: string
14
15
  readonly codeThreads?: readonly CodeThreadInfo[]
@@ -73,6 +74,7 @@ export const fixtureContext = (
73
74
  threads: () => [],
74
75
  codeThreads: () => context.codeThreads ?? [],
75
76
  vars: { ...defaults, ...context.vars },
77
+ env: { ...envDefaults, ...context.env },
76
78
  head: () => context.head ?? "",
77
79
  start: () => context.start ?? "",
78
80
  skillsFor: skillsForOf(context),
@@ -129,6 +129,7 @@ describe("summaryPrompt", () => {
129
129
  { model: "base", cost: 7 },
130
130
  ],
131
131
  vars: {},
132
+ env: {},
132
133
  })
133
134
  expect(prompt).toContain("Token cost: 12\n- smart: 5\n- base: 7\n\nPrint the closing message")
134
135
  })
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  agent,
3
3
  codeThreads,
4
+ env,
4
5
  head,
5
6
  skillsFor,
6
7
  start,
7
- vars,
8
8
  type AgentOptions,
9
9
  type SummaryContext,
10
10
  } from "../flows/index.js"
@@ -195,7 +195,7 @@ ${questionBarReturn}
195
195
  never re-open one. A genuinely open PRODUCT point may still
196
196
  raise a fresh \`## Open Questions\` entry: the product gate sits
197
197
  on this path exactly as on the first lap
198
- - Before grouping anything, run \`${vars.testCommand}\`
198
+ - Before grouping anything, run \`${env.testCommand}\`
199
199
  yourself — a loop-back runs no green-baseline gate, so the
200
200
  tree may already be red (reverting the edit can undo a fix it
201
201
  made). If red, make that breakage the first concern, ahead of
@@ -31,7 +31,7 @@ import * as t from "./text.js"
31
31
  // re-exported below.
32
32
 
33
33
  export { threads, type ThreadInfo } from "../flows/index.js"
34
- export { defaults } from "./vars.js"
34
+ export { defaults, envDefaults } from "./vars.js"
35
35
  export { skills } from "./skills.js"
36
36
  export { agentWithSkills } from "./text.js"
37
37
  export * from "./steps.js"
@@ -1,21 +1,27 @@
1
- // The bundled workflow's variable defaults. `.gtdrc` `vars:`, `--var` and
2
- // `GTD_<NAME>` override any of them.
1
+ // The bundled workflow's settings, in two kinds. A process setting changes
2
+ // which step comes next, so it is pinned for the whole process at its start;
3
+ // an environment setting only changes how a step runs on this machine, so it
4
+ // is read live on every invocation. `.gtdrc` `vars:`/`env:`, `--var` (process
5
+ // settings only) and `GTD_<NAME>` override either.
6
+
7
+ /** Process settings. */
3
8
  export const defaults: Readonly<Record<string, string>> = {
4
- testCommand: "npm test",
5
- plannerModel: "smart",
6
- coderModel: "base",
7
9
  reviewBase: "",
8
10
  judgeBudgetBytes: "32768",
9
11
  judgeIdenticalMinP: "0.7",
10
12
  specPreJudge: "0.9",
11
13
  reviewNoteActionable: "0.7",
12
14
  architectureSkipMinP: "0.85",
13
- // The per-step skill lists formerly declared here as `*Skills` vars now
14
- // live in `./skills.ts`, addressed per step by `.gtdrc` `skills:`.
15
- // `qualityReviews` is the one exception: it fans out into one turn per
16
- // entry (`qualityLenses`, in `./review.ts`) rather than naming one step's
17
- // skill list, so it stays a var — pairing with `build.quality.reviewing`'s
18
- // entry in `./skills.ts`, which replaces the lens on every one of those
19
- // turns.
15
+ // `qualityReviews` fans out into one turn per entry (`qualityLenses`, in
16
+ // `./review.ts`) rather than naming one step's skill list, so it is a
17
+ // setting — pairing with `build.quality.reviewing`'s entry in
18
+ // `./skills.ts`, which replaces the lens on every one of those turns.
20
19
  qualityReviews: "owasp-security, ponytail-review, test-audit",
21
20
  }
21
+
22
+ /** Environment settings. */
23
+ export const envDefaults: Readonly<Record<string, string>> = {
24
+ testCommand: "npm test",
25
+ plannerModel: "smart",
26
+ coderModel: "base",
27
+ }