dorfl 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/advance-drivers.d.ts.map +1 -1
  2. package/dist/advance-drivers.js +11 -2
  3. package/dist/advance-drivers.js.map +1 -1
  4. package/dist/bootstrap-forward.d.ts +224 -0
  5. package/dist/bootstrap-forward.d.ts.map +1 -0
  6. package/dist/bootstrap-forward.js +247 -0
  7. package/dist/bootstrap-forward.js.map +1 -0
  8. package/dist/cli.d.ts +9 -0
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +21 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/config.d.ts +50 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +42 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/cwd-section.d.ts.map +1 -1
  17. package/dist/cwd-section.js +11 -2
  18. package/dist/cwd-section.js.map +1 -1
  19. package/dist/env-config.d.ts.map +1 -1
  20. package/dist/env-config.js +8 -0
  21. package/dist/env-config.js.map +1 -1
  22. package/dist/github.d.ts +32 -1
  23. package/dist/github.d.ts.map +1 -1
  24. package/dist/github.js +115 -0
  25. package/dist/github.js.map +1 -1
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/install-ci-core.d.ts.map +1 -1
  31. package/dist/install-ci-core.js +20 -48
  32. package/dist/install-ci-core.js.map +1 -1
  33. package/dist/integration-core.d.ts.map +1 -1
  34. package/dist/integration-core.js +9 -0
  35. package/dist/integration-core.js.map +1 -1
  36. package/dist/integrator.d.ts +48 -0
  37. package/dist/integrator.d.ts.map +1 -1
  38. package/dist/integrator.js +12 -0
  39. package/dist/integrator.js.map +1 -1
  40. package/dist/item-lock.d.ts +26 -0
  41. package/dist/item-lock.d.ts.map +1 -1
  42. package/dist/item-lock.js +39 -0
  43. package/dist/item-lock.js.map +1 -1
  44. package/dist/repo-config.d.ts +1 -1
  45. package/dist/repo-config.d.ts.map +1 -1
  46. package/dist/repo-config.js +20 -1
  47. package/dist/repo-config.js.map +1 -1
  48. package/dist/scan.d.ts +24 -2
  49. package/dist/scan.d.ts.map +1 -1
  50. package/dist/scan.js +28 -4
  51. package/dist/scan.js.map +1 -1
  52. package/dist/skills/setup/SKILL.md +3 -0
  53. package/dist/tasking.d.ts.map +1 -1
  54. package/dist/tasking.js +124 -65
  55. package/dist/tasking.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/advance-drivers.ts +17 -8
  58. package/src/bootstrap-forward.ts +359 -0
  59. package/src/cli.ts +21 -1
  60. package/src/config.ts +75 -0
  61. package/src/cwd-section.ts +21 -2
  62. package/src/env-config.ts +8 -0
  63. package/src/github.ts +149 -0
  64. package/src/index.ts +24 -0
  65. package/src/install-ci-core.ts +20 -48
  66. package/src/integration-core.ts +10 -0
  67. package/src/integrator.ts +62 -0
  68. package/src/item-lock.ts +50 -0
  69. package/src/repo-config.ts +20 -0
  70. package/src/scan.ts +32 -8
  71. package/src/tasking.ts +154 -68
@@ -0,0 +1,359 @@
1
+ import {spawnSync} from 'node:child_process';
2
+ import {brand} from './brand.js';
3
+ import {
4
+ resolveRepoConfigPath,
5
+ loadRepoConfig,
6
+ REPO_CONFIG_FILENAME,
7
+ } from './repo-config.js';
8
+
9
+ /**
10
+ * The bootstrap self-forward (spec `dorfl-self-version-pinning-and-bootstrap-forward`
11
+ * §2/§4; task `dorfl-bootstrap-self-forward`, stories 1/4/5).
12
+ *
13
+ * The globally-installed `dorfl` is a thin BOOTSTRAP. On startup, BEFORE any
14
+ * command dispatch, it reads the nearest repo `dorfl.json`; if that repo declares
15
+ * a `dorflCmd` (and we are not already the forwarded process), it `exec`s that
16
+ * command with the ORIGINAL argv + env inherited, after a one-line STDERR notice,
17
+ * and returns the child's exit code (transparent passthrough).
18
+ *
19
+ * This module is the INJECTABLE seam: {@link decideForward} makes the pure
20
+ * decision (run-self / forward / error / opt-out) from argv + env + a config
21
+ * reader, and {@link performForward} does the exec through an injected spawn
22
+ * function — so the whole path is unit-testable WITHOUT re-execing a real second
23
+ * dorfl or hitting the network (mirroring how git/agent seams are injected in
24
+ * tests). {@link maybeForward} wires the two for the CLI entry point.
25
+ *
26
+ * Load-bearing safety fact (spec §2, task): the forward decision fires ONCE, at
27
+ * bare-`dorfl` startup, in the CHECKOUT ROOT — it is NOT recursive. The gate
28
+ * worktree that runs `prepare`/`verify` is created + prepared by the
29
+ * ALREADY-RUNNING dorfl, which runs the repo's commands via
30
+ * `spawn('bash', ['-c', cmd], {cwd: worktreeDir})` (`prepare.ts`/`verify.ts`) —
31
+ * it never launches a new `dorfl`, so a fresh worktree's empty `node_modules`
32
+ * never re-triggers the forward. Therefore a declared-but-ABSENT `dorflCmd` at
33
+ * the top level is almost always a MISCONFIGURATION (the repo's install did not
34
+ * run), and we FAIL LOUD (decision 2026-07-21, option B) rather than silently
35
+ * degrade to the global and run the WRONG version.
36
+ */
37
+
38
+ /**
39
+ * The env marker set on the FORWARDED child so it does not forward AGAIN forever
40
+ * (loop-safe). The forwarded dorfl reads the SAME `dorfl.json` (same `dorflCmd`);
41
+ * seeing this marker it runs in-process instead of re-execing. `DORFL_` prefixed
42
+ * from the single brand identity so a rename flips it in lockstep.
43
+ */
44
+ export const FORWARDED_ENV_MARKER = `${brand.envPrefix}FORWARDED`;
45
+
46
+ /**
47
+ * The opt-out env var (spec §4). `DORFL_NO_FORWARD=1` (or any truthy value)
48
+ * DISABLES forwarding AND suppresses the notice — the bootstrap/global runs
49
+ * as-is, so a user can always reach the bootstrap dorfl directly.
50
+ */
51
+ export const NO_FORWARD_ENV = `${brand.envPrefix}NO_FORWARD`;
52
+
53
+ /**
54
+ * The opt-out CLI flag (spec §4). `--no-forward` DISABLES forwarding AND
55
+ * suppresses the notice, exactly like {@link NO_FORWARD_ENV}. It is honoured
56
+ * BEFORE command dispatch and STRIPPED from the argv commander parses (commander
57
+ * never sees it — it is a bootstrap-level flag, not a per-command option).
58
+ */
59
+ export const NO_FORWARD_FLAG = '--no-forward';
60
+
61
+ /** The pure forward DECISION — what the bootstrap should do at startup. */
62
+ export type ForwardDecision =
63
+ | {kind: 'run-self'; reason: 'no-cmd' | 'already-forwarded' | 'opted-out'}
64
+ | {kind: 'forward'; cmd: string; configPath: string}
65
+ | {kind: 'error'; message: string};
66
+
67
+ /**
68
+ * Read the nearest repo's declared `dorflCmd` for `cwd`. Returns the resolved
69
+ * command (trimmed / empty⇒undefined via the config layer) and the `dorfl.json`
70
+ * path it was read from. A missing/config-less repo ⇒ `{cmd: undefined}`. The
71
+ * default reader; tests inject a stub instead of touching the filesystem.
72
+ */
73
+ export interface RepoCmdReader {
74
+ (cwd: string): {cmd: string | undefined; configPath: string};
75
+ }
76
+
77
+ /** The default {@link RepoCmdReader}: read the cwd's `dorfl.json` `dorflCmd`. */
78
+ export function defaultRepoCmdReader(cwd: string): {
79
+ cmd: string | undefined;
80
+ configPath: string;
81
+ } {
82
+ const configPath = resolveRepoConfigPath(cwd);
83
+ const loaded = loadRepoConfig(cwd);
84
+ // `loadRepoConfig` carries `dorflCmd` verbatim from the file; the config-layer
85
+ // trim/empty⇒unset normalisation runs at full resolution, but for the bootstrap
86
+ // decision we only need the raw presence + trimmed value, so normalise here too.
87
+ const raw = loaded.config.dorflCmd;
88
+ const cmd =
89
+ typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : undefined;
90
+ return {cmd, configPath: loaded.path ?? configPath};
91
+ }
92
+
93
+ /**
94
+ * True iff the ORIGINAL argv carries the {@link NO_FORWARD_FLAG} opt-out. Read
95
+ * from raw argv BEFORE commander parses (commander never sees this bootstrap
96
+ * flag). Only the exact `--no-forward` token counts.
97
+ */
98
+ export function argvHasNoForward(argv: readonly string[]): boolean {
99
+ return argv.includes(NO_FORWARD_FLAG);
100
+ }
101
+
102
+ /** True iff the env carries a truthy {@link NO_FORWARD_ENV} opt-out. */
103
+ export function envHasNoForward(env: NodeJS.ProcessEnv): boolean {
104
+ const value = env[NO_FORWARD_ENV];
105
+ return value !== undefined && value !== '' && value !== '0';
106
+ }
107
+
108
+ /** True iff the env carries the {@link FORWARDED_ENV_MARKER} (we ARE the child). */
109
+ export function envIsForwarded(env: NodeJS.ProcessEnv): boolean {
110
+ const value = env[FORWARDED_ENV_MARKER];
111
+ return value !== undefined && value !== '' && value !== '0';
112
+ }
113
+
114
+ /**
115
+ * Return `argv` with EVERY {@link NO_FORWARD_FLAG} token removed, so commander
116
+ * (which does not declare `--no-forward` as an option) never sees it. Preserves
117
+ * order + all other tokens. Used to hand a clean argv to the in-process run when
118
+ * the opt-out is set.
119
+ */
120
+ export function stripNoForwardFlag(argv: readonly string[]): string[] {
121
+ return argv.filter((token) => token !== NO_FORWARD_FLAG);
122
+ }
123
+
124
+ /**
125
+ * The PURE forward decision (no I/O beyond the injected `readRepoCmd`). Given the
126
+ * original argv + env + cwd, decide whether the bootstrap should run itself,
127
+ * forward to a `dorflCmd`, or (a declared-but-unusable command) surface an error.
128
+ * The forward is fired ONCE here at startup; the returned decision is acted on by
129
+ * {@link performForward}.
130
+ *
131
+ * Order of precedence:
132
+ * 1. OPT-OUT (`--no-forward` OR `DORFL_NO_FORWARD`) ⇒ run-self, silent.
133
+ * 2. ALREADY-FORWARDED (the child's env marker) ⇒ run-self, silent (loop-safe).
134
+ * 3. NO `dorflCmd` declared ⇒ run-self (onboarding-safe; never chicken-and-egg).
135
+ * 4. A `dorflCmd` is declared ⇒ forward to it.
136
+ *
137
+ * NOTE: this decision never itself resolves whether the target BINARY exists —
138
+ * that is the exec's job ({@link performForward}), which FAILS LOUD on a spawn
139
+ * error (the absent / present-but-broken pin). Keeping the decision pure lets a
140
+ * test drive every branch without a filesystem probe.
141
+ */
142
+ export function decideForward(options: {
143
+ argv: readonly string[];
144
+ env: NodeJS.ProcessEnv;
145
+ cwd: string;
146
+ readRepoCmd: RepoCmdReader;
147
+ }): ForwardDecision {
148
+ const {argv, env, cwd, readRepoCmd} = options;
149
+
150
+ if (argvHasNoForward(argv) || envHasNoForward(env)) {
151
+ return {kind: 'run-self', reason: 'opted-out'};
152
+ }
153
+ if (envIsForwarded(env)) {
154
+ return {kind: 'run-self', reason: 'already-forwarded'};
155
+ }
156
+
157
+ const {cmd, configPath} = readRepoCmd(cwd);
158
+ if (cmd === undefined) {
159
+ return {kind: 'run-self', reason: 'no-cmd'};
160
+ }
161
+ return {kind: 'forward', cmd, configPath};
162
+ }
163
+
164
+ /**
165
+ * The injected exec seam. Runs `cmd` (through the shell so `npx dorfl@x` /
166
+ * `mise exec dorfl@x --` / `node_modules/.bin/dorfl` all behave as written) with
167
+ * the given argv appended, the given env (which carries the
168
+ * {@link FORWARDED_ENV_MARKER} so the child does not forward again), inheriting
169
+ * stdio. Returns the child's exit code, or a spawn ERROR (target absent / not
170
+ * executable) which {@link performForward} turns into the loud, actionable error.
171
+ *
172
+ * Tests inject a stub that never touches a real process; the default
173
+ * ({@link defaultForwardSpawn}) uses `spawnSync('bash', ['-c', ...])`.
174
+ */
175
+ export interface ForwardSpawn {
176
+ (options: {
177
+ cmd: string;
178
+ forwardedArgs: readonly string[];
179
+ env: NodeJS.ProcessEnv;
180
+ }): ForwardSpawnResult;
181
+ }
182
+
183
+ /** The result of the injected {@link ForwardSpawn}. */
184
+ export type ForwardSpawnResult =
185
+ | {kind: 'exited'; code: number}
186
+ | {kind: 'spawn-error'; message: string};
187
+
188
+ /**
189
+ * The default {@link ForwardSpawn}: run `<cmd> <forwardedArgs...>` via
190
+ * `bash -c "<cmd> \"$@\"" bash <args...>` with inherited stdio, returning the
191
+ * child's exit code. A spawn error (`error` set — e.g. bash missing) or a shell
192
+ * "command not found" (exit 127) is reported as `spawn-error` so an absent /
193
+ * broken `dorflCmd` fails loud rather than silently degrading.
194
+ */
195
+ export function defaultForwardSpawn(options: {
196
+ cmd: string;
197
+ forwardedArgs: readonly string[];
198
+ env: NodeJS.ProcessEnv;
199
+ }): ForwardSpawnResult {
200
+ // `bash -c '<cmd> "$@"' bash <forwardedArgs...>` passes the ORIGINAL argv
201
+ // through as positional params (quoting-safe) AFTER the (possibly multi-token)
202
+ // dorflCmd — so `npx dorfl@x <args>` and `./bin/dorfl <args>` both work.
203
+ const result = spawnSync(
204
+ 'bash',
205
+ ['-c', `${options.cmd} "$@"`, 'bash', ...options.forwardedArgs],
206
+ {env: options.env, stdio: 'inherit'},
207
+ );
208
+ if (result.error) {
209
+ return {kind: 'spawn-error', message: result.error.message};
210
+ }
211
+ // 127 is the shell's "command not found" — the ABSENT-target case (e.g.
212
+ // `node_modules/.bin/dorfl` before install). Surface it as a spawn error so it
213
+ // fails loud, NOT a silent passthrough of a misleading exit code.
214
+ if (result.status === 127) {
215
+ return {kind: 'spawn-error', message: `command not found: ${options.cmd}`};
216
+ }
217
+ if (result.signal) {
218
+ return {
219
+ kind: 'spawn-error',
220
+ message: `terminated by signal ${result.signal}`,
221
+ };
222
+ }
223
+ return {kind: 'exited', code: result.status ?? 1};
224
+ }
225
+
226
+ /** The one-line STDERR notice announcing a forward (never stdout — must not
227
+ * corrupt `--json`). Shape per spec §4. */
228
+ export function forwardNotice(cmd: string, configPath: string): string {
229
+ return `${brand.bin}: forwarding to \`${cmd}\` (from ${configPath})`;
230
+ }
231
+
232
+ /**
233
+ * The loud, actionable error for a declared-but-unusable `dorflCmd` (absent
234
+ * target, or a present binary that spawn-errors). Names the `dorflCmd` value +
235
+ * the `dorfl.json` path + the FIX (run the dependency install first) + the
236
+ * `--no-forward` / `DORFL_NO_FORWARD` bypass — so it is never a silent degrade to
237
+ * the global (which would run the WRONG version and defeat the pin).
238
+ */
239
+ export function forwardFailureMessage(options: {
240
+ cmd: string;
241
+ configPath: string;
242
+ detail: string;
243
+ }): string {
244
+ const {cmd, configPath, detail} = options;
245
+ return (
246
+ `${brand.bin}: could NOT run the repo-declared dorflCmd \`${cmd}\` ` +
247
+ `(from ${configPath}): ${detail}.\n` +
248
+ `This usually means the repo's dependencies are not installed yet ` +
249
+ `(e.g. \`${cmd}\` points at node_modules/.bin/dorfl before install). ` +
250
+ `Run the repo's dependency install first (e.g. \`pnpm install\`, or the ` +
251
+ `CI project-setup hook), then re-run.\n` +
252
+ `To bypass the forward and run the bootstrap ${brand.bin} directly, pass ` +
253
+ `${NO_FORWARD_FLAG} or set ${NO_FORWARD_ENV}=1 ` +
254
+ `(this runs whatever ${brand.bin} is on PATH — NOT the pinned version).`
255
+ );
256
+ }
257
+
258
+ /** What {@link performForward} tells the caller to do next. */
259
+ export type ForwardOutcome =
260
+ /** A forward happened; exit the process with this code (transparent passthrough). */
261
+ | {kind: 'forwarded'; exitCode: number}
262
+ /** The forward could not run; print `message` to stderr and exit non-zero. */
263
+ | {kind: 'error'; message: string; exitCode: number}
264
+ /** No forward — the caller runs the bootstrap itself with `argv`. */
265
+ | {kind: 'run-self'; argv: string[]};
266
+
267
+ /**
268
+ * Act on a {@link ForwardDecision}: for a `forward`, print the notice to STDERR,
269
+ * spawn the child (via the injected `spawn`) with the {@link FORWARDED_ENV_MARKER}
270
+ * set + the ORIGINAL argv appended, and return the child's exit code — or, on a
271
+ * spawn error, the loud actionable failure. For `run-self`, return the (opt-out-
272
+ * stripped) argv for the caller to parse in-process.
273
+ *
274
+ * `argv` here is the FULL process argv (`[node, script, ...userArgs]`); only the
275
+ * USER args (index 2+) are forwarded to the child, since the child provides its
276
+ * own node + script.
277
+ */
278
+ export function performForward(options: {
279
+ decision: ForwardDecision;
280
+ argv: readonly string[];
281
+ env: NodeJS.ProcessEnv;
282
+ spawn: ForwardSpawn;
283
+ writeNotice: (line: string) => void;
284
+ }): ForwardOutcome {
285
+ const {decision, argv, env, spawn, writeNotice} = options;
286
+
287
+ if (decision.kind === 'run-self') {
288
+ return {kind: 'run-self', argv: stripNoForwardFlag(argv)};
289
+ }
290
+ if (decision.kind === 'error') {
291
+ return {kind: 'error', message: decision.message, exitCode: 1};
292
+ }
293
+
294
+ // A forward. The user args are everything after `[node, script]`.
295
+ const forwardedArgs = argv.slice(2);
296
+ writeNotice(forwardNotice(decision.cmd, decision.configPath));
297
+
298
+ const childEnv: NodeJS.ProcessEnv = {...env, [FORWARDED_ENV_MARKER]: '1'};
299
+ const result = spawn({cmd: decision.cmd, forwardedArgs, env: childEnv});
300
+
301
+ if (result.kind === 'spawn-error') {
302
+ return {
303
+ kind: 'error',
304
+ message: forwardFailureMessage({
305
+ cmd: decision.cmd,
306
+ configPath: decision.configPath,
307
+ detail: result.message,
308
+ }),
309
+ exitCode: 1,
310
+ };
311
+ }
312
+ return {kind: 'forwarded', exitCode: result.code};
313
+ }
314
+
315
+ /**
316
+ * The CLI-entry wiring: decide + (if forwarding) exec, all through the default
317
+ * seams. Returns the {@link ForwardOutcome} the entry point acts on — a
318
+ * `forwarded`/`error` outcome means "exit with this code now"; a `run-self`
319
+ * outcome means "continue into commander with this argv". Kept thin so the entry
320
+ * point stays a two-line hook; the testable logic lives in {@link decideForward}
321
+ * + {@link performForward}.
322
+ */
323
+ export function maybeForward(options: {
324
+ argv?: readonly string[];
325
+ env?: NodeJS.ProcessEnv;
326
+ cwd?: string;
327
+ readRepoCmd?: RepoCmdReader;
328
+ spawn?: ForwardSpawn;
329
+ writeNotice?: (line: string) => void;
330
+ }): ForwardOutcome {
331
+ const argv = options.argv ?? process.argv;
332
+ const env = options.env ?? process.env;
333
+ const cwd = options.cwd ?? process.cwd();
334
+ const readRepoCmd = options.readRepoCmd ?? defaultRepoCmdReader;
335
+ const spawn = options.spawn ?? defaultForwardSpawn;
336
+ const writeNotice =
337
+ options.writeNotice ??
338
+ ((line: string) => process.stderr.write(line + '\n'));
339
+
340
+ let decision: ForwardDecision;
341
+ try {
342
+ decision = decideForward({argv, env, cwd, readRepoCmd});
343
+ } catch (err) {
344
+ // A malformed `dorfl.json` (invalid JSON / non-string dorflCmd) surfaces as
345
+ // a loud error rather than a silent run-self, so a broken pin config is never
346
+ // masked. `REPO_CONFIG_FILENAME` names the file in the message.
347
+ return {
348
+ kind: 'error',
349
+ message:
350
+ `${brand.bin}: could not read ${REPO_CONFIG_FILENAME} to decide the ` +
351
+ `bootstrap forward: ${err instanceof Error ? err.message : String(err)}. ` +
352
+ `Fix ${REPO_CONFIG_FILENAME}, or pass ${NO_FORWARD_FLAG} / set ` +
353
+ `${NO_FORWARD_ENV}=1 to run the bootstrap ${brand.bin} directly.`,
354
+ exitCode: 1,
355
+ };
356
+ }
357
+
358
+ return performForward({decision, argv, env, spawn, writeNotice});
359
+ }
package/src/cli.ts CHANGED
@@ -149,6 +149,7 @@ import {
149
149
  SlugResolutionError,
150
150
  } from './slug-namespace.js';
151
151
  import {brand} from './brand.js';
152
+ import {maybeForward} from './bootstrap-forward.js';
152
153
  import {installCI, type WizardPrompts} from './install-ci.js';
153
154
  import {GitHubCIContext} from './install-ci-github.js';
154
155
  import {loadCapabilityRegistry} from './install-ci-core.js';
@@ -4894,11 +4895,30 @@ function promptMultiSelect(repos: string[]): Promise<string[]> {
4894
4895
  * so tests can build + introspect/parse the program WITHOUT triggering a real
4895
4896
  * argv parse + `process.exit` on import (the module-level bootstrap below only
4896
4897
  * fires when this file is the process entry point).
4898
+ *
4899
+ * BEFORE any command dispatch it runs the bootstrap SELF-FORWARD hook
4900
+ * ({@link maybeForward}): if the nearest repo `dorfl.json` declares a `dorflCmd`
4901
+ * (and we are not already the forwarded child / opted out), it execs that command
4902
+ * with the ORIGINAL argv + env inherited and exits with the child's code
4903
+ * (transparent passthrough). A declared-but-unusable `dorflCmd` FAILS LOUD. On
4904
+ * the run-self branch (no `dorflCmd`, opted out, or the forwarded child) it parses
4905
+ * the (opt-out-stripped) argv in-process, exactly as before. See
4906
+ * `bootstrap-forward.ts` for the full model + the injectable seam.
4897
4907
  */
4898
4908
  export async function runCli(argv: string[] = process.argv): Promise<void> {
4909
+ const outcome = maybeForward({argv});
4910
+ if (outcome.kind === 'forwarded') {
4911
+ process.exit(outcome.exitCode);
4912
+ }
4913
+ if (outcome.kind === 'error') {
4914
+ console.error(outcome.message);
4915
+ process.exit(outcome.exitCode);
4916
+ }
4917
+ // run-self: parse the argv commander sees (with any `--no-forward` stripped,
4918
+ // since commander does not declare that bootstrap-level flag).
4899
4919
  const program = buildProgram();
4900
4920
  try {
4901
- await program.parseAsync(argv);
4921
+ await program.parseAsync(outcome.argv);
4902
4922
  } catch (err: unknown) {
4903
4923
  console.error(err instanceof Error ? err.message : String(err));
4904
4924
  process.exit(1);
package/src/config.ts CHANGED
@@ -406,6 +406,36 @@ export interface Config {
406
406
  * (`--no-pr`) > env > per-repo > global > default `false`.
407
407
  */
408
408
  noPR: boolean;
409
+ /**
410
+ * **The repo-declared dorfl COMMAND** (spec
411
+ * `dorfl-self-version-pinning-and-bootstrap-forward` Solution §1/§3): the single
412
+ * command string a repo's committed `dorfl.json` declares so that bare
413
+ * `dorfl` (a thin bootstrap) can self-forward to the EXACT dorfl that repo runs
414
+ * with — e.g. `"node_modules/.bin/dorfl"`, `"npx dorfl@0.7.0"`, `"./bin/dorfl"`,
415
+ * `"mise exec dorfl@0.7.0 --"`. It is honoured VERBATIM: dorfl does NOT parse a
416
+ * version, resolve/download/cache one, or shell-split the command here (a version
417
+ * is expressed by the user writing `npx dorfl@<version>` themselves; the FORWARD
418
+ * task, `dorfl-bootstrap-self-forward`, owns exec semantics). Optional with NO
419
+ * default so "unset" is meaningful: unset/empty/whitespace-only ⇒ absent (the
420
+ * bootstrap runs itself — never an error), so onboarding (`setup`/`install-ci` in
421
+ * a repo with no pin yet) is never chicken-and-egg. Leading/trailing whitespace is
422
+ * trimmed; a non-string value FAILS LOUD at config load
423
+ * ({@link validateDorflCmdConfig}).
424
+ *
425
+ * **Deliberate host-only EXCEPTION (ADR
426
+ * `dorfl-cmd-repo-settable-exception-to-host-only`).** `dorflCmd` names which
427
+ * executable runs — definitionally the same class as the machine-command keys
428
+ * `agentCmd`/`piBin`/`sessionsDir` that ADR §13 (see
429
+ * `execution-substrate-decisions.md`) keeps HOST-ONLY (`REPO_REJECTED_KEYS`: a
430
+ * committed repo file must not redirect where the host runs). `dorflCmd` REVERSES
431
+ * that rule for this ONE key — it is in `REPO_ALLOWED_KEYS`, repo-settable — because
432
+ * its purpose is repo-declared REPRODUCIBILITY, it carries no more trust than the
433
+ * committed `verify` command the repo already runs, and the forward is ANNOUNCED on
434
+ * stderr (unlike a silent `piBin`). There is NO trust gate. See the ADR for the full
435
+ * why. Resolved per-repo like `integration`/`verify`: flag > env (`DORFL_DORFL_CMD`)
436
+ * > per-repo > global > default (unset).
437
+ */
438
+ dorflCmd?: string;
409
439
  /**
410
440
  * The command the runner shells out to for one task. The runner appends the
411
441
  * built prompt on stdin; the command does NO git ops on the repo (the runner
@@ -900,6 +930,47 @@ export function validateDeadlineConfig(config: Config): void {
900
930
  }
901
931
  }
902
932
 
933
+ /**
934
+ * Validate + NORMALISE the repo-declared `dorflCmd` (spec
935
+ * `dorfl-self-version-pinning-and-bootstrap-forward` §1/§3; ADR
936
+ * `dorfl-cmd-repo-settable-exception-to-host-only`). Called at the SAME two
937
+ * resolution FINAL points as {@link validateDeadlineConfig} (`loadConfig` for the
938
+ * global chain; `resolveRepoConfigFromLoaded` for the per-repo chain), so a
939
+ * malformed value from ANY layer (flag / env / per-repo / global) surfaces the
940
+ * same way. Mutates `config` in place:
941
+ *
942
+ * - a NON-STRING value (number/array/object/boolean/null) FAILS LOUD with a
943
+ * clear message naming the field — the config layer's existing fail-loud
944
+ * error path (mirrors {@link validateDeadlineConfig} / the identity check),
945
+ * never a crash;
946
+ * - a string is TRIMMED (leading/trailing whitespace removed) and carried
947
+ * VERBATIM otherwise (no shell-splitting — the forward task owns exec);
948
+ * - an empty / whitespace-only string resolves to UNSET (the field is deleted),
949
+ * so "absent" and "the bootstrap runs itself" are the same state — never an
950
+ * error.
951
+ */
952
+ export function validateDorflCmdConfig(config: Config): void {
953
+ const value = config.dorflCmd;
954
+ if (value === undefined) {
955
+ return;
956
+ }
957
+ if (typeof value !== 'string') {
958
+ throw new Error(
959
+ `dorflCmd must be a string (got ${typeof value}). It is the exact dorfl ` +
960
+ `COMMAND this repo runs with, forwarded verbatim by the bootstrap ` +
961
+ `(e.g. "node_modules/.bin/dorfl", "npx dorfl@0.7.0", "./bin/dorfl"). ` +
962
+ `Leave it unset to run the bootstrap dorfl itself.`,
963
+ );
964
+ }
965
+ const trimmed = value.trim();
966
+ if (trimmed === '') {
967
+ // Empty / whitespace-only ⇒ UNSET (never an error): the bootstrap runs itself.
968
+ delete config.dorflCmd;
969
+ return;
970
+ }
971
+ config.dorflCmd = trimmed;
972
+ }
973
+
903
974
  /** The conventional config location (`~/.config/dorfl/config.json`). */
904
975
  export function defaultConfigPath(): string {
905
976
  return join(homedir(), '.config', brand.configDirName, 'config.json');
@@ -973,5 +1044,9 @@ export function loadConfig(path: string = defaultConfigPath()): Config {
973
1044
  }
974
1045
  const merged = mergeConfig(parsed);
975
1046
  validateDeadlineConfig(merged);
1047
+ // Validate + normalise (trim; empty ⇒ unset; non-string ⇒ fail-loud) the
1048
+ // repo-declared dorfl command from the global chain (ADR
1049
+ // `dorfl-cmd-repo-settable-exception-to-host-only`).
1050
+ validateDorflCmdConfig(merged);
976
1051
  return merged;
977
1052
  }
@@ -4,7 +4,11 @@ import {scanRepoPaths, type RepoReport} from './scan.js';
4
4
  import {arbiterStatus} from './arbiter.js';
5
5
  import {listMirrors} from './registry.js';
6
6
  import {encodeRepoKey, mirrorPath} from './repo-mirror.js';
7
- import {heldTaskSlugsStrict, listItemLockEntries} from './item-lock.js';
7
+ import {
8
+ heldTaskSlugsStrict,
9
+ heldSpecSlugsStrict,
10
+ listItemLockEntries,
11
+ } from './item-lock.js';
8
12
  import type {Config} from './config.js';
9
13
  import type {ConfigOverrideMap} from './config-override.js';
10
14
 
@@ -260,12 +264,27 @@ export async function resolveCwdSection(
260
264
  const heldSlugs = hasLockRemote
261
265
  ? await heldTaskSlugsStrict(cwd, lockRemote, env)
262
266
  : new Set<string>();
267
+ // The held-SPEC set (fix
268
+ // `propose-tasking-releases-lock-so-spec-is-retasked-and-pr-force-pushed-every-tick`):
269
+ // a spec whose `spec-<slug>` lock is held has an in-flight tasking PR (propose keeps
270
+ // the lock across the open PR). SUBTRACT it from the taskable pool so CI does not
271
+ // re-task it every tick. Read fail-CLOSED from the SAME coordination remote as the
272
+ // task held set (SELECTION must not enumerate an untrusted pool).
273
+ const heldSpecSlugs = hasLockRemote
274
+ ? await heldSpecSlugsStrict(cwd, lockRemote, env)
275
+ : new Set<string>();
263
276
 
264
277
  // 3. The cwd's `work/` lifecycle from the LOCAL WORKING TREE (not a mirror ref),
265
278
  // with the arbiter-read held set SUBTRACTED so in-flight (lock-held) items are
266
279
  // not reported eligible. Thread the per-machine override so the cwd section's
267
280
  // eligibility matches what `do`/`advance` autopick will actually select.
268
- const localReport = scanRepoPaths([cwd], config, heldSlugs, options.override);
281
+ const localReport = scanRepoPaths(
282
+ [cwd],
283
+ config,
284
+ heldSlugs,
285
+ options.override,
286
+ heldSpecSlugs,
287
+ );
269
288
  const repo = localReport.repos[0];
270
289
 
271
290
  // 3b. The PER-ITEM LOCK in-flight SURFACE for the cwd (spec
package/src/env-config.ts CHANGED
@@ -121,6 +121,14 @@ const KEY_COERCIONS: {[K in keyof Config]?: Coercion} = {
121
121
  // with a deprecation warning — see `envOverrides`).
122
122
  noPR: 'boolean',
123
123
  agentCmd: 'string',
124
+ // `dorflCmd` (the repo-declared dorfl COMMAND bare `dorfl` self-forwards to —
125
+ // spec `dorfl-self-version-pinning-and-bootstrap-forward`) coerces as a STRING
126
+ // (carried verbatim, no shell-splitting), so `DORFL_DORFL_CMD="npx dorfl@0.7.0"`
127
+ // works. Unlike `piBin`/`agentCmd` (host-only) it is ALSO repo-settable (ADR
128
+ // `dorfl-cmd-repo-settable-exception-to-host-only`); env is simply the
129
+ // per-machine layer above the committed file in the shared precedence chain.
130
+ // A non-string / empty value is normalised at load (`validateDorflCmdConfig`).
131
+ dorflCmd: 'string',
124
132
  model: 'string',
125
133
  harness: {enum: ['null', 'pi']},
126
134
  piBin: 'string',