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/gc.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {existsSync, lstatSync, readdirSync, rmSync, statSync} from 'node:fs';
2
2
  import {join} from 'node:path';
3
3
  import {git, run} from './git.js';
4
+ import {encodeRepoKey, readOriginUrl} from './repo-mirror.js';
4
5
  import {
5
6
  JOB_RECORD_FILENAME,
6
7
  jobRecordPath,
@@ -251,6 +252,17 @@ export interface RetainedJob extends GcJob {
251
252
  export interface GcOptions {
252
253
  /** The execution working area (config `workspacesDir`, default `~/.dorfl`). */
253
254
  workspacesDir: string;
255
+ /**
256
+ * SCOPE the sweep to a SINGLE arbiter: only worktrees whose hub mirror's
257
+ * `origin` encodes to this hub key ({@link encodeRepoKey}) are considered.
258
+ * This is the DEFAULT operating mode of the `gc` CLI (the arbiter resolved
259
+ * from the cwd) — a `gc` run from repo A must NEVER touch repo B's worktrees.
260
+ *
261
+ * When UNSET the sweep is GLOBAL (every arbiter under `workspacesDir/work/*`).
262
+ * Global is the destructive, cross-repo mode: the CLI only reaches it behind
263
+ * the explicit + loud `--all-arbiters` flag, NEVER by defaulting here.
264
+ */
265
+ arbiterKey?: string;
254
266
  /**
255
267
  * Override the predicate for EVERY job: discard un-saved work. Loud +
256
268
  * explicit (the CLI guards it behind a confirmation) — NEVER the default.
@@ -295,20 +307,42 @@ export interface GcResult {
295
307
  * `force: true` overrides the predicate for every job (discard un-saved work) —
296
308
  * loud + explicit (the CLI confirms first), NEVER the default. A job whose work
297
309
  * is NOT on the arbiter is never auto-removed without `force`.
310
+ *
311
+ * SCOPE: when `arbiterKey` is set the sweep considers ONLY worktrees belonging
312
+ * to that arbiter (the CLI's default, resolved from the cwd) — so a `gc` in repo
313
+ * A cannot reap repo B's worktrees. An UNSET `arbiterKey` sweeps GLOBALLY across
314
+ * every arbiter (the CLI reaches that only behind the loud `--all-arbiters`).
298
315
  */
299
316
  export function gc(options: GcOptions): GcResult {
300
317
  const note = options.note ?? (() => {});
301
318
  const env = options.env;
319
+ const arbiterKey = options.arbiterKey;
302
320
  const reaped: ReapedJob[] = [];
303
321
  const retained: RetainedJob[] = [];
304
322
 
305
323
  // First, self-heal any record-less ORPHAN `work/*` entry (a dangling symlink
306
324
  // or a dir git never registered) so a half-set-up claim does not linger unseen
307
- // by the job loop below and wedge the next `worktree add`.
325
+ // by the job loop below and wedge the next `worktree add`. Orphans carry NO
326
+ // durable work and are NOT any repo's real worktree (no record ⇒ no mirror to
327
+ // attribute to an arbiter), so the arbiter scope does not gate them — they are
328
+ // bounded, worthless residue either way.
308
329
  const sweptOrphans = sweepOrphans(options.workspacesDir, note);
309
330
 
310
331
  for (const job of discoverJobs(options.workspacesDir)) {
311
332
  const mirrorPath = resolveMirrorPath(options.workspacesDir, job);
333
+
334
+ // ARBITER SCOPE (the default `gc` mode): when `arbiterKey` is set, consider
335
+ // ONLY worktrees whose mirror `origin` encodes to that hub key — a `gc` run
336
+ // from repo A must never reap repo B's worktrees. A job whose mirror origin
337
+ // is unreadable (a malformed mirror) is SKIPPED under scope (the safe
338
+ // direction: we cannot prove it belongs to this arbiter, so leave it).
339
+ if (
340
+ arbiterKey !== undefined &&
341
+ jobArbiterKey(mirrorPath, env) !== arbiterKey
342
+ ) {
343
+ continue;
344
+ }
345
+
312
346
  const result = reapJob({
313
347
  dir: job.dir,
314
348
  branch: job.branch,
@@ -479,6 +513,79 @@ function resolveMirrorPath(workspacesDir: string, job: GcJob): string {
479
513
  return join(workspacesDir, 'repos');
480
514
  }
481
515
 
516
+ /**
517
+ * The hub KEY ({@link encodeRepoKey}) of the arbiter a job worktree belongs to,
518
+ * read from its hub mirror's `origin` URL (the arbiter URL the mirror was cloned
519
+ * from). This is the SAME identity `do --isolated` / `ensureMirror` key a repo's
520
+ * arbiter under (`mirrorPath` = `<workspacesDir>/repos/<key>.git`), so a `gc`
521
+ * scoped to the cwd's arbiter key matches exactly the worktrees cut for that
522
+ * arbiter. Returns `undefined` when the mirror `origin` cannot be read (a
523
+ * malformed mirror) — the caller then treats the job as NOT belonging to the
524
+ * scoped arbiter (the safe direction under a scoped sweep).
525
+ */
526
+ function jobArbiterKey(
527
+ mirrorPath: string,
528
+ env: NodeJS.ProcessEnv | undefined,
529
+ ): string | undefined {
530
+ const originUrl = readOriginUrl(mirrorPath, env);
531
+ return originUrl === undefined ? undefined : encodeRepoKey(originUrl);
532
+ }
533
+
534
+ /** One distinct arbiter with worktrees under the work area (for the loud
535
+ * `--all-arbiters` banner). */
536
+ export interface WorkspaceArbiter {
537
+ /** The hub key ({@link encodeRepoKey}) — or `undefined` for a malformed mirror. */
538
+ key?: string;
539
+ /** The arbiter `origin` URL of the hub mirror, when readable. */
540
+ url?: string;
541
+ }
542
+
543
+ /**
544
+ * Enumerate the DISTINCT arbiters that currently own a worktree under
545
+ * `<workspacesDir>/work/*` — i.e. the set a GLOBAL (`--all-arbiters`) sweep would
546
+ * touch. Reads each discovered job's hub mirror `origin` URL and de-duplicates
547
+ * by hub key. Used ONLY to print the loud banner before a cross-arbiter sweep;
548
+ * the sweep itself re-derives per-job scope in {@link gc}.
549
+ */
550
+ export function enumerateWorkspaceArbiters(
551
+ workspacesDir: string,
552
+ env: NodeJS.ProcessEnv | undefined,
553
+ ): WorkspaceArbiter[] {
554
+ const seen = new Map<string, WorkspaceArbiter>();
555
+ for (const job of discoverJobs(workspacesDir)) {
556
+ const mirrorPath = resolveMirrorPath(workspacesDir, job);
557
+ const url = readOriginUrl(mirrorPath, env);
558
+ const key = url === undefined ? undefined : encodeRepoKey(url);
559
+ const dedupe = key ?? `path:${mirrorPath}`;
560
+ if (!seen.has(dedupe)) {
561
+ seen.set(dedupe, {key, url});
562
+ }
563
+ }
564
+ return [...seen.values()];
565
+ }
566
+
567
+ /**
568
+ * The hub KEY ({@link encodeRepoKey}) of the arbiter reachable from a working
569
+ * repo `cwd` via its `<arbiter>` remote (default `origin`), i.e. `encodeRepoKey`
570
+ * of `git -C <cwd> remote get-url <arbiter>`. This is how the `gc` CLI resolves
571
+ * the DEFAULT scope from the current directory — the SAME arbiter-resolution the
572
+ * mirror / `do --isolated` path uses (`ensureMirror`'s `fromRepo` + `arbiter`).
573
+ * Returns `undefined` when `cwd` is not a git repo or has no such remote (the
574
+ * CLI then errors rather than silently going global).
575
+ */
576
+ export function resolveArbiterKeyFromCwd(
577
+ cwd: string,
578
+ arbiter: string,
579
+ env: NodeJS.ProcessEnv | undefined,
580
+ ): string | undefined {
581
+ const res = run('git', ['remote', 'get-url', arbiter], cwd, {env});
582
+ if (res.status !== 0) {
583
+ return undefined;
584
+ }
585
+ const url = res.stdout.trim();
586
+ return url === '' ? undefined : encodeRepoKey(url);
587
+ }
588
+
482
589
  /**
483
590
  * True iff the working tree has NO uncommitted WORK (tracked, staged, or
484
591
  * untracked). The agent's edits and any untracked artefacts count: untracked
@@ -2,6 +2,7 @@ import {readFileSync, existsSync} from 'node:fs';
2
2
  import {join} from 'node:path';
3
3
  import {
4
4
  mergeConfig,
5
+ applyModelFallbacks,
5
6
  validateDeadlineConfig,
6
7
  validateDorflCmdConfig,
7
8
  warnDeprecatedConfigKeys,
@@ -209,8 +210,12 @@ export const REPO_ALLOWED_KEYS = [
209
210
  // `model` (which model this repo's work runs on) and `harness` (which adapter)
210
211
  // are legitimate repo properties (ADR §13) — model is routing intent, not auth,
211
212
  // and a repo may prefer a given harness. `piBin`/`agentCmd` stay host-only
212
- // (machine paths/commands), so they are rejected below.
213
+ // (machine paths/commands), so they are rejected below. `buildModel` is the
214
+ // builder-specific override of the general `model` (falls back to `model` when
215
+ // unset at all levels — see `applyModelFallbacks`); like `model` it is routing
216
+ // intent (not auth), so it is repo-appropriate.
213
217
  'model',
218
+ 'buildModel',
214
219
  'harness',
215
220
  // Gate 2 (PR/code review) policy is a genuine repo property (GATES spec
216
221
  // `work/specs/tasked/review.md`), resolved per-repo like `integration`/`autoBuild`:
@@ -230,6 +235,14 @@ export const REPO_ALLOWED_KEYS = [
230
235
  'taskerLoop',
231
236
  'taskerLoopMax',
232
237
  'taskerLoopModel',
238
+ // `triageModel` (the model the advance lifecycle gates — surface, apply,
239
+ // triage — run on) is a genuine repo property like `reviewModel`/
240
+ // `taskerLoopModel`: routing intent (not auth), resolved per-repo through the
241
+ // same chain. `intakeModel` (the front-door intake decision agent's model) is
242
+ // likewise repo-appropriate. Each falls back to `model` when unset at all
243
+ // levels (see `applyModelFallbacks`).
244
+ 'triageModel',
245
+ 'intakeModel',
233
246
  // `freshWorktreeGate` (run the acceptance gate against the REBASED tip in a
234
247
  // clean throwaway worktree, ON by default) is a genuine repo property exactly
235
248
  // like `verify`/`prepare`/`review`: whether this repo's gate tests the merged
@@ -646,6 +659,11 @@ export function resolveRepoConfigFromLoaded(
646
659
  // (flag / env / per-repo / global) surfaces the same clear error (ADR
647
660
  // `dorfl-cmd-repo-settable-exception-to-host-only`).
648
661
  validateDorflCmdConfig(config);
662
+ // Apply the per-role model fallbacks: `buildModel`, `reviewModel`, and
663
+ // `taskerLoopModel` each inherit the general `model` when unset at EVERY level
664
+ // (flag / env / per-repo / global). Applied HERE — the resolution FINAL point —
665
+ // so the fallback uses the FULLY resolved `model`, never an intermediate layer.
666
+ applyModelFallbacks(config);
649
667
  return {
650
668
  config,
651
669
  rejected: repo.rejected,
package/src/run.ts CHANGED
@@ -834,8 +834,9 @@ async function runOneItem(
834
834
 
835
835
  // 4. Run the agent — via the injected runner (tests) or the harness seam
836
836
  // (null adapter by default), shelling out to the configured agentCmd. The
837
- // resolved per-repo `model` (ADR §13) flows through the seam to the adapter;
838
- // a `{model}`-in-agentCmd misconfiguration surfaces as agent-failed.
837
+ // resolved per-repo `buildModel` (ADR §13, which inherits the general
838
+ // `model` when unset) flows through the seam to the adapter; a
839
+ // `{model}`-in-agentCmd misconfiguration surfaces as agent-failed.
839
840
  let agent: {ok: boolean; detail?: string; output?: string};
840
841
  try {
841
842
  agent = runAgent(
@@ -844,7 +845,7 @@ async function runOneItem(
844
845
  prompt,
845
846
  slug,
846
847
  config.agentCmd,
847
- config.model,
848
+ config.buildModel,
848
849
  config.sessionsDir,
849
850
  );
850
851
  } catch (err) {