dorfl 0.10.1 → 0.11.1

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/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +99 -21
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +73 -12
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +28 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/do-config.d.ts +21 -1
  9. package/dist/do-config.d.ts.map +1 -1
  10. package/dist/do-config.js +19 -0
  11. package/dist/do-config.js.map +1 -1
  12. package/dist/do.d.ts +3 -1
  13. package/dist/do.d.ts.map +1 -1
  14. package/dist/do.js.map +1 -1
  15. package/dist/env-config.d.ts.map +1 -1
  16. package/dist/env-config.js +3 -0
  17. package/dist/env-config.js.map +1 -1
  18. package/dist/gc.d.ts +42 -0
  19. package/dist/gc.d.ts.map +1 -1
  20. package/dist/gc.js +71 -1
  21. package/dist/gc.js.map +1 -1
  22. package/dist/protocol/WORK-CONTRACT.md +11 -2
  23. package/dist/protocol/spec-template.md +1 -1
  24. package/dist/protocol/task-template.md +1 -1
  25. package/dist/repo-config.d.ts +1 -1
  26. package/dist/repo-config.d.ts.map +1 -1
  27. package/dist/repo-config.js +19 -2
  28. package/dist/repo-config.js.map +1 -1
  29. package/dist/run.d.ts.map +1 -1
  30. package/dist/run.js +4 -3
  31. package/dist/run.js.map +1 -1
  32. package/dist/skills/drive-tasks/SKILL.md +21 -3
  33. package/dist/skills/setup/protocol/WORK-CONTRACT.md +11 -2
  34. package/dist/skills/setup/protocol/spec-template.md +1 -1
  35. package/dist/skills/setup/protocol/task-template.md +1 -1
  36. package/package.json +1 -1
  37. package/src/cli.ts +143 -21
  38. package/src/config.ts +90 -12
  39. package/src/do-config.ts +38 -0
  40. package/src/do.ts +3 -1
  41. package/src/env-config.ts +3 -0
  42. package/src/gc.ts +108 -1
  43. package/src/repo-config.ts +19 -1
  44. package/src/run.ts +4 -3
package/src/cli.ts CHANGED
@@ -99,6 +99,7 @@ import {
99
99
  freshWorktreeGateFlagOverrides,
100
100
  mergeRetriesFlagOverrides,
101
101
  noPRFlagOverrides,
102
+ triageModelFlagOverrides,
102
103
  } from './do-config.js';
103
104
  import {harnessReviewGate, harnessTaskAcceptanceGate} from './review-gate.js';
104
105
  import {harnessSurfaceGate} from './surface-gate.js';
@@ -107,8 +108,13 @@ import {harnessApplyDecider} from './apply-decide.js';
107
108
  import {harnessTaskReviewGate} from './tasker-review-loop.js';
108
109
  import {runVerify} from './verify.js';
109
110
  import {renderPrompt} from './prompt.js';
110
- import {resolvePromptGuidance} from './config.js';
111
- import {gc, RETAIN_REASON_TEXT} from './gc.js';
111
+ import {resolvePromptGuidance, applyModelFallbacks} from './config.js';
112
+ import {
113
+ gc,
114
+ resolveArbiterKeyFromCwd,
115
+ enumerateWorkspaceArbiters,
116
+ RETAIN_REASON_TEXT,
117
+ } from './gc.js';
112
118
  import {
113
119
  runPrdToSpec,
114
120
  type PrdToSpecResult,
@@ -211,7 +217,9 @@ function resolveGlobalConfig(
211
217
  fileConfig: PartialConfig,
212
218
  flags: PartialConfig,
213
219
  ): Config {
214
- return mergeConfig({...fileConfig, ...envOverrides(), ...flags});
220
+ const config = mergeConfig({...fileConfig, ...envOverrides(), ...flags});
221
+ applyModelFallbacks(config);
222
+ return config;
215
223
  }
216
224
 
217
225
  /**
@@ -418,7 +426,7 @@ function buildRegistrySetAdvanceTick(options: {
418
426
  noPR: config.noPR,
419
427
  harness,
420
428
  agentCmd: config.agentCmd,
421
- model: config.model,
429
+ model: config.buildModel,
422
430
  sessionsDir: config.sessionsDir,
423
431
  review: config.review,
424
432
  reviewModel: config.reviewModel,
@@ -443,12 +451,12 @@ function buildRegistrySetAdvanceTick(options: {
443
451
  arbiter: arbiter ?? config.defaultArbiter,
444
452
  doOptions,
445
453
  surfaceGate: harnessSurfaceGate({harness, agentCmd: config.agentCmd}),
446
- surfaceModel: config.model,
454
+ surfaceModel: config.triageModel,
447
455
  applyDecide: harnessApplyDecider({harness, agentCmd: config.agentCmd}),
448
- applyModel: config.model,
456
+ applyModel: config.triageModel,
449
457
  observationTriage: config.observationTriage,
450
458
  triageGate: harnessTriageGate({harness, agentCmd: config.agentCmd}),
451
- triageModel: config.model,
459
+ triageModel: config.triageModel,
452
460
  // The ANSWERED-MERGE LAND DISPATCH context (task
453
461
  // `apply-rung-merge-disposition`, spec `land-time-reverify-and-parallel-
454
462
  // merge-ceiling`): the dispatcher cuts a per-job worktree via
@@ -546,6 +554,8 @@ interface RunFlags extends ScanFlags {
546
554
  pr?: boolean;
547
555
  agentCmd?: string;
548
556
  model?: string;
557
+ /** `--build-model <id>` — the builder-specific model override (falls back to `--model`). */
558
+ buildModel?: string;
549
559
  harness?: string;
550
560
  piBin?: string;
551
561
  sessionsDir?: string;
@@ -553,6 +563,8 @@ interface RunFlags extends ScanFlags {
553
563
  review?: boolean;
554
564
  reviewModel?: string;
555
565
  reviewMaxRounds?: string;
566
+ /** `--triage-model <id>` — the advance lifecycle gates' model (surface, apply, triage). */
567
+ triageModel?: string;
556
568
  /** `--fresh-worktree-gate` / `--no-fresh-worktree-gate` — gate the REBASED tip in a clean throwaway worktree (ON by default). */
557
569
  freshWorktreeGate?: boolean;
558
570
  /** `--merge-retries <n>` — the cross-job merge-serialiser CAS-retry cap (spec `land-time-reverify-and-parallel-merge-ceiling` Story 5 / Applied Answer q1 (a)). */
@@ -588,6 +600,8 @@ function runFlagOverrides(flags: RunFlags, command?: Commander): PartialConfig {
588
600
  // flag > env > per-repo > global > default — mirroring the `do` command (the
589
601
  // fleet inherits the review gate via the converged `performIntegration` core).
590
602
  Object.assign(overrides, reviewFlagOverrides(flags));
603
+ // `--triage-model` (the advance lifecycle gates' model) rides the SAME chain.
604
+ Object.assign(overrides, triageModelFlagOverrides(flags));
591
605
  // `--fresh-worktree-gate`/`--no-fresh-worktree-gate` rides the SAME chain: gate
592
606
  // the REBASED tip in a clean throwaway worktree (ON by default). The `run` fleet
593
607
  // caller additionally downgrades it to today's gate at `perRepoMax > 1` (the
@@ -764,6 +778,8 @@ interface DoFlags {
764
778
  allowBacklog?: boolean;
765
779
  agentCmd?: string;
766
780
  model?: string;
781
+ /** `--build-model <id>` — the builder-specific model override (falls back to `--model`). */
782
+ buildModel?: string;
767
783
  harness?: string;
768
784
  piBin?: string;
769
785
  sessionsDir?: string;
@@ -771,6 +787,8 @@ interface DoFlags {
771
787
  review?: boolean;
772
788
  reviewModel?: string;
773
789
  reviewMaxRounds?: string;
790
+ /** `--triage-model <id>` — the advance lifecycle gates' model (surface, apply, triage). */
791
+ triageModel?: string;
774
792
  /** `--tasker-loop` / `--no-tasker-loop` — the tasker improver loop on/off toggle (`do spec:` path). Resolves into the `taskerLoop` config key. */
775
793
  taskerLoop?: boolean;
776
794
  /** `--tasker-loop-max <n>` — the tasker improver loop's in-context convergence cap (`do spec:` path). Resolves into the `taskerLoopMax` config key. */
@@ -808,6 +826,8 @@ interface IntakeFlags {
808
826
  tasksLandIn?: string;
809
827
  agentCmd?: string;
810
828
  model?: string;
829
+ /** `--intake-model <id>` — the intake decision agent's model (falls back to `--model`). */
830
+ intakeModel?: string;
811
831
  harness?: string;
812
832
  piBin?: string;
813
833
  sessionsDir?: string;
@@ -822,6 +842,7 @@ interface GcFlags {
822
842
  ledger?: string;
823
843
  remoteBranches?: boolean;
824
844
  arbiter?: string;
845
+ allArbiters?: boolean;
825
846
  cwd?: string;
826
847
  dryRun?: boolean;
827
848
  reapStaleLocks?: boolean;
@@ -1485,6 +1506,10 @@ export function buildProgram(): Command {
1485
1506
  '--model <id>',
1486
1507
  'model the agent runs on (routing intent; auth/keys stay the harness\u2019s job). pi: passed as --model; null/shell: substitutes a {model} placeholder in agentCmd. Resolved flag > env > per-repo > global > default (unset).',
1487
1508
  )
1509
+ .option(
1510
+ '--build-model <id>',
1511
+ 'model the BUILD agent runs on (the builder-specific override of --model). Unset at every level inherits --model. Resolved flag > env > per-repo > global > fallback to model > default (unset).',
1512
+ )
1488
1513
  .option(
1489
1514
  '--harness <adapter>',
1490
1515
  'harness adapter that launches the agent + reports liveness: null (default, shells out to agentCmd) or pi (the pi CLI)',
@@ -1510,6 +1535,10 @@ export function buildProgram(): Command {
1510
1535
  '--review-model <id>',
1511
1536
  'model the Gate-2 review agent runs on (de-correlated from the builder; routing intent). Resolved flag > env > per-repo > global > default.',
1512
1537
  )
1538
+ .option(
1539
+ '--triage-model <id>',
1540
+ 'model the advance lifecycle gates (surface, apply, triage) run on. Unset at every level inherits --model. Resolved flag > env > per-repo > global > fallback to model > default (unset).',
1541
+ )
1513
1542
  .option(
1514
1543
  '--review-max-rounds <n>',
1515
1544
  'bound the revise/review loop; on exhaustion force needs-attention (default 2)',
@@ -2317,6 +2346,10 @@ export function buildProgram(): Command {
2317
2346
  '--model <id>',
2318
2347
  'model the agent runs on (routing intent; resolved flag > env > per-repo > global > default)',
2319
2348
  )
2349
+ .option(
2350
+ '--build-model <id>',
2351
+ 'model the BUILD agent runs on (the builder-specific override of --model). Unset at every level inherits --model. Resolved flag > env > per-repo > global > fallback to model > default (unset).',
2352
+ )
2320
2353
  .option(
2321
2354
  '--harness <adapter>',
2322
2355
  'harness adapter that launches the agent: null (default, shells out to agentCmd) or pi (the pi CLI)',
@@ -2345,6 +2378,10 @@ export function buildProgram(): Command {
2345
2378
  '--review-model <id>',
2346
2379
  'model the Gate-2 review agent runs on (de-correlated from the builder; routing intent). Resolved flag > env > per-repo > global > default.',
2347
2380
  )
2381
+ .option(
2382
+ '--triage-model <id>',
2383
+ 'model the advance lifecycle gates (surface, apply, triage) run on. Unset at every level inherits --model. Resolved flag > env > per-repo > global > fallback to model > default (unset).',
2384
+ )
2348
2385
  .option(
2349
2386
  '--review-max-rounds <n>',
2350
2387
  'bound the revise/review loop; on exhaustion force needs-attention (default 2)',
@@ -2614,7 +2651,7 @@ export function buildProgram(): Command {
2614
2651
  noPR: remoteConfig.noPR,
2615
2652
  harness: remoteHarness,
2616
2653
  agentCmd: remoteConfig.agentCmd,
2617
- model: remoteConfig.model,
2654
+ model: remoteConfig.buildModel,
2618
2655
  sessionsDir: remoteConfig.sessionsDir,
2619
2656
  review: remoteConfig.review,
2620
2657
  reviewModel: remoteConfig.reviewModel,
@@ -2793,7 +2830,7 @@ export function buildProgram(): Command {
2793
2830
  noPR: config.noPR,
2794
2831
  harness,
2795
2832
  agentCmd: config.agentCmd,
2796
- model: config.model,
2833
+ model: config.buildModel,
2797
2834
  // The HOST-ONLY sessions root (resolved Config → DoOptions bridge, like
2798
2835
  // model/agentCmd): the path generator turns it into
2799
2836
  // `<sessionsDir>/<id>.jsonl` for `--session`. Without this map the key
@@ -3126,7 +3163,7 @@ export function buildProgram(): Command {
3126
3163
  noPR: remoteConfig.noPR,
3127
3164
  harness: isoHarness,
3128
3165
  agentCmd: remoteConfig.agentCmd,
3129
- model: remoteConfig.model,
3166
+ model: remoteConfig.buildModel,
3130
3167
  sessionsDir: remoteConfig.sessionsDir,
3131
3168
  review: remoteConfig.review,
3132
3169
  reviewModel: remoteConfig.reviewModel,
@@ -3168,18 +3205,18 @@ export function buildProgram(): Command {
3168
3205
  harness: isoHarness,
3169
3206
  agentCmd: remoteConfig.agentCmd,
3170
3207
  }),
3171
- surfaceModel: remoteConfig.model,
3208
+ surfaceModel: remoteConfig.triageModel,
3172
3209
  applyDecide: harnessApplyDecider({
3173
3210
  harness: isoHarness,
3174
3211
  agentCmd: remoteConfig.agentCmd,
3175
3212
  }),
3176
- applyModel: remoteConfig.model,
3213
+ applyModel: remoteConfig.triageModel,
3177
3214
  observationTriage: remoteConfig.observationTriage,
3178
3215
  triageGate: harnessTriageGate({
3179
3216
  harness: isoHarness,
3180
3217
  agentCmd: remoteConfig.agentCmd,
3181
3218
  }),
3182
- triageModel: remoteConfig.model,
3219
+ triageModel: remoteConfig.triageModel,
3183
3220
  note: (message) => console.error(`>> ${message}`),
3184
3221
  env: process.env,
3185
3222
  };
@@ -3270,7 +3307,7 @@ export function buildProgram(): Command {
3270
3307
  noPR: config.noPR,
3271
3308
  harness,
3272
3309
  agentCmd: config.agentCmd,
3273
- model: config.model,
3310
+ model: config.buildModel,
3274
3311
  sessionsDir: config.sessionsDir,
3275
3312
  review: config.review,
3276
3313
  reviewModel: config.reviewModel,
@@ -3306,12 +3343,12 @@ export function buildProgram(): Command {
3306
3343
  arbiter: flags.arbiter ?? config.defaultArbiter,
3307
3344
  doOptions,
3308
3345
  surfaceGate: harnessSurfaceGate({harness, agentCmd: config.agentCmd}),
3309
- surfaceModel: config.model,
3346
+ surfaceModel: config.triageModel,
3310
3347
  applyDecide: harnessApplyDecider({harness, agentCmd: config.agentCmd}),
3311
- applyModel: config.model,
3348
+ applyModel: config.triageModel,
3312
3349
  observationTriage: config.observationTriage,
3313
3350
  triageGate: harnessTriageGate({harness, agentCmd: config.agentCmd}),
3314
- triageModel: config.model,
3351
+ triageModel: config.triageModel,
3315
3352
  note: (message) => console.error(`>> ${message}`),
3316
3353
  };
3317
3354
 
@@ -3391,7 +3428,7 @@ export function buildProgram(): Command {
3391
3428
  .command('gc')
3392
3429
  .helpGroup(ADVANCED_GROUP)
3393
3430
  .description(
3394
- 'Re-apply the provably-safe deletion predicate (ADR \u00a74) across every job worktree under workspacesDir/work/*: reap the provably-safe ones (clean tree AND branch tip reachable on the arbiter \u2014 merged or pushed) via git worktree remove (+ prune, never rm -rf), and report each RETAINED one with a reason. The catch-up for when end-of-job auto-reap did not run (runner crash/kill). --force overrides the predicate (discards un-saved work) \u2014 loud, never default.',
3431
+ 'Re-apply the provably-safe deletion predicate (ADR \u00a74) to job worktrees under workspacesDir/work/*: reap the provably-safe ones (clean tree AND branch tip reachable on the arbiter \u2014 merged or pushed) via git worktree remove (+ prune, never rm -rf), and report each RETAINED one with a reason. The catch-up for when end-of-job auto-reap did not run (runner crash/kill). SCOPE: by DEFAULT this is ARBITER-SCOPED \u2014 it acts ONLY on worktrees belonging to the arbiter resolved from the cwd (the same arbiter do --isolated/the mirror uses), so a gc in repo A can never reap repo B\u2019s worktrees. Pass --all-arbiters to sweep across EVERY arbiter (loud + global), or --arbiter <remote-or-url> to target a specific one. --force overrides the predicate (discards un-saved work) and requires --yes \u2014 loud, never default; --all-arbiters --force must be opted into explicitly, never reached by default.',
3395
3432
  )
3396
3433
  .option('-c, --config <path>', 'config file path', defaultConfigPath())
3397
3434
  .option(
@@ -3416,8 +3453,12 @@ export function buildProgram(): Command {
3416
3453
  'SWEEP the arbiter’s remote work/* BRANCHES instead of job worktrees: delete (via git push --delete, NEVER --force) exactly those PROVABLY MERGED into <arbiter>/main (git merge-base --is-ancestor, the SAME predicate the worktree reaper uses), and RETAIN the rest with a reason. An in-flight/un-merged branch (the recovery point) is NEVER touched. Provider-agnostic plain git — works on a --bare arbiter. The merged-only complement of `requeue --reset`.',
3417
3454
  )
3418
3455
  .option(
3419
- '--arbiter <remote>',
3420
- '(with --remote-branches) the arbiter git remote whose work/* branches to sweep (default: origin); resolved from --cwd',
3456
+ '--arbiter <remote-or-url>',
3457
+ 'SCOPE the worktree sweep to a SPECIFIC arbiter (a remote NAME resolved from --cwd, or a direct arbiter URL) instead of the cwd\u2019s. (With --remote-branches / --ledger it names the arbiter git remote whose work/* branches / locks to sweep; default there: origin.)',
3458
+ )
3459
+ .option(
3460
+ '--all-arbiters',
3461
+ 'GLOBAL, LOUD override: sweep worktrees across EVERY arbiter under workspacesDir/work/* (today\u2019s pre-scoping behaviour), not just the cwd\u2019s. Prints a banner naming every arbiter it will act on before doing anything; combining it with --force still requires --yes. This is the destructive cross-repo path \u2014 opt into it explicitly.',
3421
3462
  )
3422
3463
  .option(
3423
3464
  '--cwd <dir>',
@@ -3614,6 +3655,77 @@ export function buildProgram(): Command {
3614
3655
  return;
3615
3656
  }
3616
3657
 
3658
+ // SCOPE RESOLUTION (this task): the worktree reaper is ARBITER-SCOPED by
3659
+ // default. `--all-arbiters` restores the GLOBAL (cross-arbiter) sweep behind
3660
+ // a LOUD banner; `--arbiter <remote-or-url>` targets a specific arbiter; the
3661
+ // bare default resolves the arbiter from the cwd (the SAME resolution
3662
+ // `do --isolated` / the mirror uses). With NO resolvable cwd arbiter and
3663
+ // neither flag, we REFUSE (never silently fall back to global) — gc used to
3664
+ // default to global, and a global `gc --force --yes` from repo A could
3665
+ // irreversibly discard repo B's un-pushed work.
3666
+ let arbiterKey: string | undefined;
3667
+ if (flags.allArbiters !== true) {
3668
+ if (flags.arbiter !== undefined && flags.arbiter.trim() !== '') {
3669
+ // An explicit arbiter: a direct URL (contains `://`, a scp-like `:`,
3670
+ // or an absolute path) is keyed directly; otherwise it is a remote NAME
3671
+ // resolved from the cwd (default `origin`).
3672
+ const spec = flags.arbiter.trim();
3673
+ const looksLikeUrl =
3674
+ spec.includes('://') ||
3675
+ spec.startsWith('/') ||
3676
+ /^[^/]+@/.test(spec);
3677
+ // A remote NAME resolves from --cwd if given (consistent with the
3678
+ // other gc sub-modes), else the process cwd.
3679
+ const resolveFrom = flags.cwd ?? process.cwd();
3680
+ arbiterKey = looksLikeUrl
3681
+ ? encodeRepoKey(spec)
3682
+ : resolveArbiterKeyFromCwd(resolveFrom, spec, process.env);
3683
+ if (arbiterKey === undefined) {
3684
+ console.error(
3685
+ `refusing: could not resolve arbiter '${spec}' \u2014 not a URL and ` +
3686
+ 'no git remote with that name in the cwd. Pass a remote name that ' +
3687
+ 'exists here, an arbiter URL, or --all-arbiters.',
3688
+ );
3689
+ process.exit(1);
3690
+ }
3691
+ } else {
3692
+ arbiterKey = resolveArbiterKeyFromCwd(
3693
+ flags.cwd ?? process.cwd(),
3694
+ config.defaultArbiter,
3695
+ process.env,
3696
+ );
3697
+ if (arbiterKey === undefined) {
3698
+ console.error(
3699
+ 'refusing: `gc` is now ARBITER-SCOPED and could not resolve an ' +
3700
+ `arbiter from the current directory (no '${config.defaultArbiter}' ` +
3701
+ 'git remote, or not inside a repo). It will NOT fall back to a ' +
3702
+ 'global cross-arbiter sweep. Run it from a repo with an arbiter ' +
3703
+ 'remote, pass --arbiter <remote-or-url>, or --all-arbiters to sweep ' +
3704
+ 'every arbiter.',
3705
+ );
3706
+ process.exit(1);
3707
+ }
3708
+ }
3709
+ } else {
3710
+ // GLOBAL sweep: LOUD banner naming EVERY arbiter it will act on before
3711
+ // doing anything (this task's "loud" requirement). Global + --force must be
3712
+ // opted into explicitly (the --yes gate below still applies), never reached
3713
+ // by default.
3714
+ const arbiters = enumerateWorkspaceArbiters(workspacesDir, process.env);
3715
+ console.error(
3716
+ '>> --all-arbiters: operating GLOBALLY across ALL arbiters under ' +
3717
+ `${workspacesDir}/work/* (NOT scoped to the current repo).`,
3718
+ );
3719
+ if (arbiters.length > 0) {
3720
+ console.error('>> arbiters in scope:');
3721
+ for (const a of arbiters) {
3722
+ console.error(`>> - ${a.url ?? a.key ?? '(malformed mirror)'}`);
3723
+ }
3724
+ } else {
3725
+ console.error('>> (no worktrees found under any arbiter)');
3726
+ }
3727
+ }
3728
+
3617
3729
  // `--force` discards un-saved work, so it is gated behind an explicit
3618
3730
  // confirmation (`--yes`) — loud + intentional, NEVER the default (ADR §4).
3619
3731
  if (flags.force && !flags.yes) {
@@ -3633,6 +3745,7 @@ export function buildProgram(): Command {
3633
3745
 
3634
3746
  const result = gc({
3635
3747
  workspacesDir,
3748
+ arbiterKey,
3636
3749
  force: flags.force === true,
3637
3750
  note: (message) => console.error(`>> ${message}`),
3638
3751
  });
@@ -4304,6 +4417,10 @@ export function buildProgram(): Command {
4304
4417
  '--model <id>',
4305
4418
  'model the decision agent runs on (routing intent; resolved flag > env > per-repo > global > default)',
4306
4419
  )
4420
+ .option(
4421
+ '--intake-model <id>',
4422
+ 'model the intake decision agent runs on (the builder-specific override of --model). Unset at every level inherits --model. Resolved flag > env > per-repo > global > fallback to model > default (unset).',
4423
+ )
4307
4424
  .option(
4308
4425
  '--harness <adapter>',
4309
4426
  'harness adapter that launches the decision agent: null (default) or pi',
@@ -4338,6 +4455,11 @@ export function buildProgram(): Command {
4338
4455
  ...harnessFlagOverrides(flags),
4339
4456
  // `--no-pr` (the PR-INTENT axis) rides the SAME chain.
4340
4457
  ...noPRFlagOverrides(flags),
4458
+ // `--intake-model` rides the SAME chain (falls back to `model`
4459
+ // when unset at all levels via `applyModelFallbacks`).
4460
+ ...(flags.intakeModel !== undefined
4461
+ ? {intakeModel: flags.intakeModel}
4462
+ : {}),
4341
4463
  },
4342
4464
  });
4343
4465
  if (resolved.message) {
@@ -4452,7 +4574,7 @@ export function buildProgram(): Command {
4452
4574
  explicitTasksLandIn,
4453
4575
  harness,
4454
4576
  agentCmd: config.agentCmd,
4455
- model: config.model,
4577
+ model: config.intakeModel,
4456
4578
  sessionsDir: config.sessionsDir,
4457
4579
  // Host-only runner IDENTITY — scopes intake's `gh`/git ops (not the
4458
4580
  // decision/review AGENT launches); absent ⇒ ambient.
package/src/config.ts CHANGED
@@ -528,9 +528,25 @@ export interface Config {
528
528
  * (`LaunchInput.model`); the ADAPTER decides HOW it reaches its tool — the pi
529
529
  * adapter passes `--model <model>` natively, the null/shell adapter substitutes
530
530
  * a `{model}` placeholder in `agentCmd`. Resolved per-repo like `integration`:
531
- * flag (`--model`) > env > per-repo > global > default (unset).
531
+ * flag (`--model`) > env > per-repo > global > default (unset). The fallback
532
+ * to this field from `buildModel` / `reviewModel` / `taskerLoopModel` is
533
+ * applied AFTER the full precedence chain resolves (in `applyModelFallbacks`),
534
+ * so a per-role knob set at ANY level (even global config) is NEVER reset by a
535
+ * higher-precedence `--model`.
532
536
  */
533
537
  model?: string;
538
+ /**
539
+ * The model the BUILD agent runs on (the builder-specific override of the
540
+ * general `model`). Optional with NO default so "unset" means "inherit the
541
+ * general `model`" (via `applyModelFallbacks`, applied at the resolution FINAL
542
+ * point — after the full flag > env > per-repo > global chain). Carried to the
543
+ * build launch through the same harness seam (`LaunchInput.model`). Resolved
544
+ * per-repo like `model`: flag (`--build-model`) > env (`DORFL_BUILD_MODEL`) >
545
+ * per-repo > global > fallback to `model` > default (unset). DISTINCT from
546
+ * `reviewModel` (Gate 2) and `taskerLoopModel` (the improver loop) — each has
547
+ * its OWN knob and each falls back to `model` independently.
548
+ */
549
+ buildModel?: string;
534
550
  /**
535
551
  * Which harness adapter launches + reports liveness for a job's agent (the
536
552
  * harness seam, ADR §5): `null` (default — shells out to `agentCmd`,
@@ -600,11 +616,14 @@ export interface Config {
600
616
  review: boolean;
601
617
  /**
602
618
  * The model the REVIEW agent runs on (de-correlation from the builder's
603
- * `model`). Optional with NO default so "unset" means "no forced review model"
604
- * (the harness's own default). Carried to the review-agent launch through the
605
- * EXISTING harness seam (`LaunchInput.model` / `substituteModel`) — NOT a new
606
- * mechanism. Resolved like `model`: flag (`--review-model`) > env > per-repo >
607
- * global > default (unset). Distinct from the builder's `model`.
619
+ * `buildModel` when explicitly set). Optional with NO default so "unset" means
620
+ * "inherit the general `model`" (via `applyModelFallbacks`, applied at the
621
+ * resolution FINAL point — after the full flag > env > per-repo > global chain).
622
+ * Carried to the review-agent launch through the EXISTING harness seam
623
+ * (`LaunchInput.model` / `substituteModel`) — NOT a new mechanism. Resolved like
624
+ * `model`: flag (`--review-model`) > env > per-repo > global > fallback to
625
+ * `model` > default (unset). Distinct from the builder's `buildModel` — each
626
+ * has its OWN knob and each falls back to `model` independently.
608
627
  */
609
628
  reviewModel?: string;
610
629
  /**
@@ -650,14 +669,44 @@ export interface Config {
650
669
  taskerLoopMax: number;
651
670
  /**
652
671
  * The model the tasker IMPROVER loop's review agent runs on (de-correlation
653
- * from the tasker). Optional with NO default so "unset" means "no forced model"
654
- * (the harness's own default). Carried to the review-agent launch through the
655
- * EXISTING harness seam (`LaunchInput.model` / `substituteModel`). Resolved like
656
- * `model`: flag (`--tasker-loop-model`) > env > per-repo > global > default
657
- * (unset). DISTINCT from the acceptance gate's `reviewModel` (build
658
- * `--review-model`).
672
+ * from the tasker when explicitly set). Optional with NO default so "unset"
673
+ * means "inherit the general `model`" (via `applyModelFallbacks`, applied at
674
+ * the resolution FINAL point — after the full flag > env > per-repo > global
675
+ * chain). Carried to the review-agent launch through the EXISTING harness seam
676
+ * (`LaunchInput.model` / `substituteModel`). Resolved like `model`: flag
677
+ * (`--tasker-loop-model`) > env > per-repo > global > fallback to `model` >
678
+ * default (unset). DISTINCT from the acceptance gate's `reviewModel` (build
679
+ * `--review-model`) and from `buildModel` — each has its OWN knob and each falls
680
+ * back to `model` independently.
659
681
  */
660
682
  taskerLoopModel?: string;
683
+ /**
684
+ * The model the ADVANCE LIFECYCLE GATES (surface, apply, triage) run on —
685
+ * the fresh-context judgement agents that surface questions from blocked work
686
+ * (`surface`), interpret answered questions and decide what to mint (`apply`),
687
+ * and auto-disposition no-question observations (`triage`). Optional with NO
688
+ * default so "unset" means "inherit the general `model`" (via
689
+ * `applyModelFallbacks`, applied at the resolution FINAL point). Carried to each
690
+ * gate's launch through the EXISTING harness seam (`LaunchInput.model`). Resolved
691
+ * like `model`: flag (`--triage-model`) > env (`DORFL_TRIAGE_MODEL`) > per-repo >
692
+ * global > fallback to `model` > default (unset). DISTINCT from `reviewModel`,
693
+ * `taskerLoopModel`, `buildModel`, and `intakeModel` — each has its OWN knob and
694
+ * each falls back to `model` independently.
695
+ */
696
+ triageModel?: string;
697
+ /**
698
+ * The model the INTAKE decision agent runs on — the front-door fresh-context
699
+ * agent that reads an issue/PR and decides whether to mint a task, mint a spec,
700
+ * or ask a follow-up question. Optional with NO default so "unset" means
701
+ * "inherit the general `model`" (via `applyModelFallbacks`, applied at the
702
+ * resolution FINAL point). Carried to the intake-agent launch through the
703
+ * EXISTING harness seam (`LaunchInput.model`). Resolved like `model`: flag
704
+ * (`--intake-model`) > env (`DORFL_INTAKE_MODEL`) > per-repo > global > fallback
705
+ * to `model` > default (unset). DISTINCT from the advance lifecycle gates'
706
+ * `triageModel` and from `buildModel`/`reviewModel`/`taskerLoopModel` — each has
707
+ * its OWN knob and each falls back to `model` independently.
708
+ */
709
+ intakeModel?: string;
661
710
  /**
662
711
  * **The fresh-worktree acceptance-gate toggle** (`--fresh-worktree-gate` /
663
712
  * `--no-fresh-worktree-gate`). When ON (the default), the acceptance gate
@@ -1078,6 +1127,35 @@ export function mergeConfig(overrides: PartialConfig): Config {
1078
1127
  return merged;
1079
1128
  }
1080
1129
 
1130
+ /**
1131
+ * Apply the per-role model fallbacks IN PLACE on a FULLY RESOLVED {@link Config}:
1132
+ * `buildModel`, `reviewModel`, and `taskerLoopModel` each inherit the general
1133
+ * `model` when they are unset (i.e. were not set at ANY precedence level — flag,
1134
+ * env, per-repo, or global). This is the single place the fallback lives; it is
1135
+ * called at the resolution FINAL points (`resolveRepoConfigFromLoaded` for the
1136
+ * per-repo chain; `resolveGlobalConfig` for the global-only chain) so the
1137
+ * fallback uses the FINAL resolved `model`, never an intermediate layer's. A
1138
+ * per-role knob set at ANY level is NEVER reset — `applyModelFallbacks` only
1139
+ * touches fields that are still `undefined` after the full chain resolved.
1140
+ */
1141
+ export function applyModelFallbacks(config: Config): void {
1142
+ if (config.buildModel === undefined) {
1143
+ config.buildModel = config.model;
1144
+ }
1145
+ if (config.reviewModel === undefined) {
1146
+ config.reviewModel = config.model;
1147
+ }
1148
+ if (config.taskerLoopModel === undefined) {
1149
+ config.taskerLoopModel = config.model;
1150
+ }
1151
+ if (config.triageModel === undefined) {
1152
+ config.triageModel = config.model;
1153
+ }
1154
+ if (config.intakeModel === undefined) {
1155
+ config.intakeModel = config.model;
1156
+ }
1157
+ }
1158
+
1081
1159
  /**
1082
1160
  * Persist `config` to `path` as pretty JSON (creating the parent dir). Used by
1083
1161
  * `work-on` to SAVE the prompted `humanWorktreesDir` on first use so the human is
package/src/do-config.ts CHANGED
@@ -17,6 +17,8 @@ import type {
17
17
  export interface HarnessFlags {
18
18
  agentCmd?: string;
19
19
  model?: string;
20
+ /** `--build-model <id>` — the builder-specific model override (falls back to `--model`). */
21
+ buildModel?: string;
20
22
  harness?: string;
21
23
  piBin?: string;
22
24
  /**
@@ -43,6 +45,9 @@ export function harnessFlagOverrides(flags: HarnessFlags): PartialConfig {
43
45
  if (flags.model !== undefined) {
44
46
  overrides.model = flags.model;
45
47
  }
48
+ if (flags.buildModel !== undefined) {
49
+ overrides.buildModel = flags.buildModel;
50
+ }
46
51
  if (flags.harness === 'null' || flags.harness === 'pi') {
47
52
  overrides.harness = flags.harness;
48
53
  }
@@ -69,6 +74,7 @@ export function doFlagOverrides(
69
74
  flags: HarnessFlags &
70
75
  ReviewFlags &
71
76
  TaskerLoopFlags &
77
+ TriageModelFlags &
72
78
  FreshWorktreeGateFlags &
73
79
  SelectionOrderFlags &
74
80
  ObservationTriageFlags &
@@ -115,6 +121,10 @@ export function doFlagOverrides(
115
121
  // family from the gate's `--review*`, never sharing a flag/key/field name.
116
122
  // The flag fields bridge onto the `taskerLoop*` config keys.
117
123
  ...taskerLoopFlagOverrides(flags),
124
+ // `--triage-model <id>` (the advance lifecycle gates' model — surface, apply,
125
+ // triage) rides the SAME chain. DISTINCT from `--review-model`/
126
+ // `--tasker-loop-model`; falls back to `model` when unset at all levels.
127
+ ...triageModelFlagOverrides(flags),
118
128
  // `--fresh-worktree-gate`/`--no-fresh-worktree-gate` (run the acceptance gate
119
129
  // against the REBASED tip in a clean throwaway worktree, ON by default) rides
120
130
  // the SAME chain — a DISTINCT family from `--review*`/`--tasker-loop*`.
@@ -232,6 +242,34 @@ export function taskerLoopFlagOverrides(flags: TaskerLoopFlags): PartialConfig {
232
242
  return overrides;
233
243
  }
234
244
 
245
+ /**
246
+ * The `--triage-model` flag — the model the advance lifecycle gates (surface,
247
+ * apply, triage) run on. A single string flag, distinct from the harness
248
+ * `--model`/`--build-model` family and from `--review-model`/
249
+ * `--tasker-loop-model`. Resolved through the SAME `flag > env > per-repo >
250
+ * global > default` chain; falls back to `model` when unset at all levels (via
251
+ * `applyModelFallbacks`).
252
+ */
253
+ export interface TriageModelFlags {
254
+ /** `--triage-model <id>` — the advance lifecycle gates' model (routing intent). */
255
+ triageModel?: string;
256
+ }
257
+
258
+ /**
259
+ * Map the `--triage-model` flag into a {@link PartialConfig} override. Only
260
+ * contributes when the flag is actually present (absent flag => absent key), so
261
+ * the override layer never clobbers a lower-precedence source with `undefined`.
262
+ */
263
+ export function triageModelFlagOverrides(
264
+ flags: TriageModelFlags,
265
+ ): PartialConfig {
266
+ const overrides: PartialConfig = {};
267
+ if (flags.triageModel !== undefined) {
268
+ overrides.triageModel = flags.triageModel;
269
+ }
270
+ return overrides;
271
+ }
272
+
235
273
  /**
236
274
  * The fresh-worktree acceptance-gate CLI flag (`do`/`complete`/`run`):
237
275
  * `--fresh-worktree-gate` / `--no-fresh-worktree-gate`, the POSITIVE boolean
package/src/do.ts CHANGED
@@ -549,7 +549,9 @@ export interface DoOptions {
549
549
  watch?: boolean;
550
550
  /** The configured agent command the harness shells out to (null adapter). */
551
551
  agentCmd?: string;
552
- /** The model routing intent forwarded to the harness (ADR §13). */
552
+ /** The model routing intent forwarded to the harness (ADR §13). Sourced
553
+ * from `config.buildModel` (which inherits the general `config.model` when
554
+ * unset via `applyModelFallbacks`). */
553
555
  model?: string;
554
556
  /**
555
557
  * The HOST-ONLY root folder under which this run's pi session FILE is generated
package/src/env-config.ts CHANGED
@@ -152,6 +152,9 @@ const KEY_COERCIONS: {[K in keyof Config]?: Coercion} = {
152
152
  // A non-string / empty value is normalised at load (`validateDorflCmdConfig`).
153
153
  dorflCmd: 'string',
154
154
  model: 'string',
155
+ buildModel: 'string',
156
+ triageModel: 'string',
157
+ intakeModel: 'string',
155
158
  harness: {enum: ['null', 'pi']},
156
159
  piBin: 'string',
157
160
  sessionsDir: 'string',