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
package/src/github.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  type PostPRCommentInput,
11
11
  type PostPRCommentOnBranchInput,
12
12
  type PostPRCommentResult,
13
+ type CloseRequestOnBranchInput,
14
+ type CloseRequestOnBranchResult,
13
15
  } from './integrator.js';
14
16
 
15
17
  /**
@@ -185,6 +187,27 @@ export class GitHubProvider implements ReviewProvider {
185
187
  * record / `status`).
186
188
  */
187
189
  async openRequest(input: OpenRequestInput): Promise<OpenRequestResult> {
190
+ // REOPEN a CLOSED PR before trying to CREATE one (task
191
+ // `tasking-disapprove-closes-existing-pr-keeps-branch`): a spec whose earlier
192
+ // tasking review DISAPPROVED had its PR CLOSED (branch kept). A later re-task
193
+ // that now APPROVES must REOPEN that SAME PR (preserving its history +
194
+ // closing-comment thread) rather than leaving a new duplicate. `gh pr create`
195
+ // on a branch with a closed PR would open a SECOND PR, so we reopen first.
196
+ // Best-effort + never-throw; a failed reopen falls through to the normal
197
+ // create path (the branch is already pushed, so the work is safe).
198
+ const existingForReopen = this.resolvePrForBranch(
199
+ input.branch,
200
+ input.cwd,
201
+ input.env,
202
+ );
203
+ if (existingForReopen?.state === 'CLOSED') {
204
+ const reopened = this.reopenExistingRequest(input, existingForReopen.url);
205
+ if (reopened !== undefined) {
206
+ return reopened;
207
+ }
208
+ // Could not reopen (transient `gh` failure) — fall through to create.
209
+ }
210
+
188
211
  const args = [
189
212
  'pr',
190
213
  'create',
@@ -307,6 +330,93 @@ export class GitHubProvider implements ReviewProvider {
307
330
  };
308
331
  }
309
332
 
333
+ /**
334
+ * REOPEN a previously-CLOSED PR for the branch (task
335
+ * `tasking-disapprove-closes-existing-pr-keeps-branch`): `gh pr reopen <url>`,
336
+ * then refresh its title/body (reusing {@link updateExistingRequest}'s edit so
337
+ * the reopened PR carries this run's content). Returns `{opened: true, url}` on
338
+ * a successful reopen, or `undefined` when the reopen itself failed (the caller
339
+ * then falls through to `gh pr create`). Best-effort edit — a failed refresh
340
+ * still returns the reopened PR. NEVER throws.
341
+ */
342
+ private reopenExistingRequest(
343
+ input: OpenRequestInput,
344
+ url: string,
345
+ ): OpenRequestResult | undefined {
346
+ const reopen = this.runGh(['pr', 'reopen', url], input.cwd, input.env);
347
+ if (reopen === undefined || reopen.status !== 0) {
348
+ return undefined;
349
+ }
350
+ // Refresh title/body on the reopened PR (best-effort; only when supplied).
351
+ if (input.title !== undefined || input.body !== undefined) {
352
+ const editArgs = ['pr', 'edit', url];
353
+ if (input.title !== undefined) {
354
+ editArgs.push('--title', input.title);
355
+ }
356
+ if (input.body !== undefined) {
357
+ editArgs.push('--body', input.body);
358
+ }
359
+ this.runGh(editArgs, input.cwd, input.env);
360
+ }
361
+ return {
362
+ opened: true,
363
+ url,
364
+ instruction: `Reopened the existing GitHub PR for ${input.branch}: ${url}`,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * CLOSE the branch's OPEN PR (keeping the branch) with the disapproving review
370
+ * as the closing comment — the disapprove artefact-cleanup path (task
371
+ * `tasking-disapprove-closes-existing-pr-keeps-branch`). Resolves the PR from
372
+ * the branch; when there is NO OPEN PR (none at all, already closed/merged, or
373
+ * `gh` missing) it is a clean no-op (`closed: false`) — we never OPEN a PR just
374
+ * to close it. `gh pr close <url> --comment <review>` (NO `--delete-branch`, so
375
+ * the branch — the recovery point — survives for a later approving re-task to
376
+ * REOPEN). NEVER throws, NEVER `--force`s (ADR §6).
377
+ */
378
+ async closeRequestOnBranch(
379
+ input: CloseRequestOnBranchInput,
380
+ ): Promise<CloseRequestOnBranchResult> {
381
+ const existing = this.resolvePrForBranch(
382
+ input.branch,
383
+ input.cwd,
384
+ input.env,
385
+ );
386
+ if (existing === undefined || existing.state !== 'OPEN') {
387
+ // No OPEN PR to close (none, already closed/merged, or gh unavailable):
388
+ // honest no-op AFTER trying. Surface the review so it is never lost.
389
+ return {
390
+ closed: false,
391
+ instruction:
392
+ `No open PR to close for ${input.branch} (the branch is kept). ` +
393
+ `The disapproving review:\n${input.comment}`,
394
+ };
395
+ }
396
+ const closed = this.runGh(
397
+ ['pr', 'close', existing.url, '--comment', input.comment],
398
+ input.cwd,
399
+ input.env,
400
+ );
401
+ if (closed === undefined || closed.status !== 0) {
402
+ // The close failed (transient) — the branch + PR are untouched; surface
403
+ // the review text and report no-close. The item is still surfaced to
404
+ // needs-attention by the caller regardless.
405
+ return {
406
+ closed: false,
407
+ instruction:
408
+ `Could not close the PR for ${input.branch} (the branch is kept). ` +
409
+ `The disapproving review:\n${input.comment}`,
410
+ };
411
+ }
412
+ return {
413
+ closed: true,
414
+ instruction:
415
+ `Closed the stale PR for ${input.branch} with the disapproving ` +
416
+ `review as its closing comment (branch kept): ${existing.url}`,
417
+ };
418
+ }
419
+
310
420
  /** Map a successful `gh pr create` RunResult to an OpenRequestResult. */
311
421
  private parseOpened(
312
422
  input: OpenRequestInput,
@@ -429,6 +539,45 @@ export class GitHubProvider implements ReviewProvider {
429
539
  return parsePrUrl(result.stdout);
430
540
  }
431
541
 
542
+ /**
543
+ * Resolve the branch's PR url AND state (`OPEN` / `CLOSED` / `MERGED`) via
544
+ * `gh pr view <branch> --json url,state`, or `undefined` when no PR exists /
545
+ * `gh` is missing. Needed by {@link openRequest} to REOPEN a CLOSED PR (the
546
+ * disapprove-close artefact — a later approving re-task reopens the SAME PR
547
+ * instead of leaving a new one) and by {@link closeRequestOnBranch} to no-op
548
+ * when there is no OPEN PR to close. `gh pr view <branch>` reports the most
549
+ * recent PR for the branch, including a closed one. Read-only.
550
+ */
551
+ private resolvePrForBranch(
552
+ branch: string,
553
+ cwd: string,
554
+ env: NodeJS.ProcessEnv | undefined,
555
+ ): {url: string; state: string} | undefined {
556
+ const result = this.runGh(
557
+ [
558
+ 'pr',
559
+ 'view',
560
+ branch,
561
+ '--json',
562
+ 'url,state',
563
+ '--jq',
564
+ '.url + " " + .state',
565
+ ],
566
+ cwd,
567
+ env,
568
+ );
569
+ if (result === undefined || result.status !== 0) {
570
+ return undefined;
571
+ }
572
+ const url = parsePrUrl(result.stdout);
573
+ if (url === undefined) {
574
+ return undefined;
575
+ }
576
+ // The `--jq` prints `<url> <STATE>`; the state is the last whitespace token.
577
+ const state = result.stdout.trim().split(/\s+/).pop() ?? '';
578
+ return {url, state};
579
+ }
580
+
432
581
  /**
433
582
  * Is `gh` available AND authenticated (so a PR can actually be opened)?
434
583
  * `gh auth status` exits 0 when authenticated. A missing `gh` (spawn failure)
package/src/index.ts CHANGED
@@ -35,6 +35,30 @@ export {
35
35
  resolveRepoConfig,
36
36
  } from './repo-config.js';
37
37
 
38
+ export type {
39
+ ForwardDecision,
40
+ ForwardOutcome,
41
+ ForwardSpawn,
42
+ ForwardSpawnResult,
43
+ RepoCmdReader,
44
+ } from './bootstrap-forward.js';
45
+ export {
46
+ FORWARDED_ENV_MARKER,
47
+ NO_FORWARD_ENV,
48
+ NO_FORWARD_FLAG,
49
+ decideForward,
50
+ performForward,
51
+ maybeForward,
52
+ defaultRepoCmdReader,
53
+ defaultForwardSpawn,
54
+ forwardNotice,
55
+ forwardFailureMessage,
56
+ argvHasNoForward,
57
+ envHasNoForward,
58
+ envIsForwarded,
59
+ stripNoForwardFlag,
60
+ } from './bootstrap-forward.js';
61
+
38
62
  export type {ConfigOverrideMap} from './config-override.js';
39
63
  export {
40
64
  defaultConfigOverridePath,
@@ -828,54 +828,26 @@ ${indent(modelsJsonStr, 8)}
828
828
  run: npm install -g dorfl${installHarness}`;
829
829
  }
830
830
 
831
- // PREFER-LOCAL RESOLVER (task install-ci-prefer-project-local-dorfl,
832
- // SPEC install-ci-project-provisioning axis C1+C3): emit a `dorfl` SHIM
833
- // that resolves a project-local `node_modules/.bin/dorfl` FIRST (the repo's
834
- // devDep pin, populated by the project-setup hook's `pnpm install` — C3),
835
- // and falls back to the global bootstrap dorfl this composite action just
836
- // installed (C1). Prepended to `$GITHUB_PATH` so EVERY downstream
837
- // `dorfl <verb>` step in EVERY capability workflow inherits the resolver
838
- // without each template editing its literal `dorfl` invocation (single
839
- // shared prefix, no per-capability drift). The captured global path is
840
- // written to a sibling file so the shim's `command -v` never recurses into
841
- // itself once the shim dir is on PATH. Applied uniformly in both registry
842
- // and workspace modes; the workspace-mode install path itself is untouched
843
- // (the shim runs AFTER the install).
844
- const resolverStep = `\
845
-
846
- # Generated by install-ci (task install-ci-prefer-project-local-dorfl):
847
- # a single shared shim that prefers a project-local node_modules/.bin/dorfl
848
- # (the repo's devDep pin, C3) over the global bootstrap dorfl just installed
849
- # (C1), so CI runs the dorfl the repo DECLARES, not a skewed global.
850
- - name: Install dorfl resolver (prefer project-local over global)
851
- shell: bash
852
- run: |
853
- global_dorfl="$(command -v dorfl || true)"
854
- if [ -z "$global_dorfl" ]; then
855
- echo "ERROR: dorfl bootstrap not found on PATH after install" >&2
856
- exit 1
857
- fi
858
- shim_dir="\${RUNNER_TEMP:-/tmp}/dorfl-resolver"
859
- mkdir -p "$shim_dir"
860
- printf '%s\\n' "$global_dorfl" > "$shim_dir/global-path"
861
- # Write the shim via printf (not a heredoc) so the shebang lands at
862
- # column 0 — a YAML \`run: |\` block's common-indent would otherwise
863
- # prefix every heredoc line and break the shebang.
864
- printf '%s\\n' \\
865
- '#!/usr/bin/env bash' \\
866
- '# Prefer the project-pinned dorfl (devDep, populated by the' \\
867
- "# project's dependency install) over the global bootstrap." \\
868
- '# Generated by install-ci.' \\
869
- 'local_bin="\${GITHUB_WORKSPACE:-$PWD}/node_modules/.bin/dorfl"' \\
870
- 'if [ -x "$local_bin" ]; then' \\
871
- ' exec "$local_bin" "$@"' \\
872
- 'fi' \\
873
- 'global_path_file="$(dirname "$0")/global-path"' \\
874
- 'exec "$(cat "$global_path_file")" "$@"' \\
875
- > "$shim_dir/dorfl"
876
- chmod +x "$shim_dir/dorfl"
877
- echo "$shim_dir" >> "$GITHUB_PATH"`;
878
- installSteps = installSteps + resolverStep;
831
+ // NO bespoke CI resolver shim (task install-ci-shim-converges-on-dorfl-cmd,
832
+ // spec `dorfl-self-version-pinning-and-bootstrap-forward` §6 / story 4).
833
+ // Historically this action emitted a `$PATH` shim that preferred a
834
+ // project-local `node_modules/.bin/dorfl` over the global bootstrap (task
835
+ // install-ci-prefer-project-local-dorfl). That shim was CI-only AND
836
+ // JS-specific (it hardcoded `node_modules/.bin`). Now that the global
837
+ // bootstrap dorfl SELF-FORWARDS to the repo-declared `dorflCmd` on its own
838
+ // (task `dorfl-bootstrap-self-forward`), the shim is redundant: CI's global
839
+ // `dorfl` forwards to the repo's declared `dorflCmd` by the SAME generic
840
+ // mechanism the laptop uses ONE code path, JS and non-JS alike. A JS repo
841
+ // that pinned via a devDep declares `dorflCmd: "node_modules/.bin/dorfl"`
842
+ // (one line; `setup` nudges it) and gets the pin in CI via the forward. A
843
+ // repo with NO `dorflCmd` runs the global bootstrap — identically on CI and
844
+ // the laptop (onboarding-safe). Keeping a JS-only no-`dorflCmd` fallback was
845
+ // deliberately REJECTED (see the ## Decisions note in the done record): it
846
+ // would re-introduce the JS-specific CI-only special case the convergence
847
+ // removes, AND when a repo declares BOTH the devDep AND `dorflCmd` the shim
848
+ // would exec the local bin which then forwards AGAIN a confusing
849
+ // double-resolution. The install step below leaves `dorfl` on `$PATH` as the
850
+ // bootstrap; the forward does the pinning.
879
851
 
880
852
  // One optional ACTION INPUT per provider key (models-json mode), named
881
853
  // identically to the secret/env var. Optional + default '' so a workflow that
@@ -2256,6 +2256,16 @@ function bridgeProvider(
2256
2256
  req.body,
2257
2257
  };
2258
2258
  },
2259
+ // The legacy bridge has no PR-close capability — a clean no-op (keep the
2260
+ // branch), surfacing the disapproving review text.
2261
+ async closeRequestOnBranch(req) {
2262
+ return {
2263
+ closed: false,
2264
+ instruction:
2265
+ 'The legacy review bridge cannot close a PR (branch kept); the ' +
2266
+ `disapproving review:\n${req.comment}`,
2267
+ };
2268
+ },
2259
2269
  };
2260
2270
  }
2261
2271
 
package/src/integrator.ts CHANGED
@@ -81,6 +81,52 @@ export interface ReviewProvider {
81
81
  * review — ADR §6).
82
82
  */
83
83
  postPRCommentOnBranch(input: PostPRCommentOnBranchInput): PostPRCommentResult;
84
+ /**
85
+ * CLOSE the PR opened for an already-pushed `work/<slug>` BRANCH — WITHOUT
86
+ * deleting the branch — posting {@link CloseRequestOnBranchInput.comment} (the
87
+ * review that disapproved) as the closing comment. RESOLVES the PR from the
88
+ * branch (the disapprove path holds the branch, not the PR url).
89
+ *
90
+ * Used when a tasking review DISAPPROVES a spec whose PR is already open (the
91
+ * multi-run artefact): the stale PR is closed with the review as its closing
92
+ * comment (so the reason is visible ON the PR), while the branch is KEPT — the
93
+ * safety-bearing recovery point — so a later re-task that APPROVES can REOPEN
94
+ * the same PR (via {@link openRequest}, which reopens an existing-closed PR).
95
+ *
96
+ * ONLY-IF-EXISTS: a real provider first resolves the branch's PR and, when
97
+ * there is NO open PR to close, cleanly no-ops (`closed: false`) — the disapprove
98
+ * path never OPENS a PR just to close it.
99
+ *
100
+ * ADVISORY — it gates nothing; like the other PR-text methods it must NEVER
101
+ * throw (a missing/unauthenticated `gh`, the `none` provider, or no resolvable
102
+ * open PR all DEGRADE: surface the text in the result, close nothing, keep the
103
+ * branch — ADR §6).
104
+ */
105
+ closeRequestOnBranch(
106
+ input: CloseRequestOnBranchInput,
107
+ ): Promise<CloseRequestOnBranchResult>;
108
+ }
109
+
110
+ export interface CloseRequestOnBranchInput {
111
+ cwd: string;
112
+ /**
113
+ * The pushed `work/<slug>` branch whose OPEN PR to close (keeping the branch).
114
+ * The GitHub provider resolves the PR via `gh pr view <branch>` and closes it
115
+ * with `gh pr close <branch>` (NO `--delete-branch`).
116
+ */
117
+ branch: string;
118
+ /** The arbiter remote the branch was pushed to. */
119
+ arbiter: string;
120
+ /** The closing comment — the disapproving review prose (JSON block stripped). */
121
+ comment: string;
122
+ env?: NodeJS.ProcessEnv;
123
+ }
124
+
125
+ export interface CloseRequestOnBranchResult {
126
+ /** True iff an open PR was actually resolved from the branch AND closed. */
127
+ closed: boolean;
128
+ /** Human-readable confirmation / fallback (the review text on degrade/no-op). */
129
+ instruction: string;
84
130
  }
85
131
 
86
132
  export interface PostPRCommentInput {
@@ -216,6 +262,22 @@ export class NoneProvider implements ReviewProvider {
216
262
  `comment. The review:\n${input.body}`,
217
263
  };
218
264
  }
265
+
266
+ /**
267
+ * No API to resolve/close a PR (a local `--bare` arbiter has no review
268
+ * concept), so DEGRADE: close nothing, keep the branch, surface the review
269
+ * text, never throw. A clean no-op for the PR.
270
+ */
271
+ async closeRequestOnBranch(
272
+ input: CloseRequestOnBranchInput,
273
+ ): Promise<CloseRequestOnBranchResult> {
274
+ return {
275
+ closed: false,
276
+ instruction:
277
+ 'No review provider configured — no PR was closed (the branch is ' +
278
+ `kept). The disapproving review:\n${input.comment}`,
279
+ };
280
+ }
219
281
  }
220
282
 
221
283
  export interface IntegrateInput {
package/src/item-lock.ts CHANGED
@@ -1940,6 +1940,56 @@ export async function heldTaskSlugsStrict(
1940
1940
  );
1941
1941
  }
1942
1942
 
1943
+ /**
1944
+ * List the SPEC slugs currently lock-held on the arbiter — the held-SPEC set the
1945
+ * TASKABLE-spec pool readers SUBTRACT (fix
1946
+ * `propose-tasking-releases-lock-so-spec-is-retasked-and-pr-force-pushed-every-tick`).
1947
+ * The SPEC analogue of {@link heldTaskSlugsStrict}: enumerates {@link listItemLocks}
1948
+ * and keeps only the `spec-<slug>` entries (a task/observation lock does not gate
1949
+ * the SPEC pool), mapping each to its bare `<slug>`.
1950
+ *
1951
+ * LOAD-BEARING for the propose-mode tasking loop: an `advance spec:<slug> --propose`
1952
+ * that opens a PR now KEEPS the `spec:<slug>` lock HELD across the open PR (the
1953
+ * durable `specs/ready → specs/tasked` move lives only on the branch, so `main`
1954
+ * residence does NOT yet signal tasked-ness). This held-spec set is the ONLY thing
1955
+ * that keeps such an in-flight spec out of the taskable pool — WITHOUT it the spec
1956
+ * is re-tasked every tick and its PR force-pushed. Symmetric to the task-pool
1957
+ * subtraction; the same fail-open/fail-closed split applies (see
1958
+ * {@link heldSpecSlugs} vs this strict twin).
1959
+ */
1960
+ export async function heldSpecSlugsStrict(
1961
+ cwd: string,
1962
+ arbiter = 'origin',
1963
+ env?: NodeJS.ProcessEnv,
1964
+ ): Promise<Set<string>> {
1965
+ const entries = await listItemLocks(cwd, arbiter, env);
1966
+ const prefix = 'spec-';
1967
+ return new Set(
1968
+ entries
1969
+ .filter((e) => e.startsWith(prefix))
1970
+ .map((e) => e.slice(prefix.length)),
1971
+ );
1972
+ }
1973
+
1974
+ /**
1975
+ * GRACEFUL (fail-OPEN) twin of {@link heldSpecSlugsStrict} for read-only SURFACE
1976
+ * paths and the local autopick driver: a fetch fault yields an EMPTY set rather
1977
+ * than throwing (the follow-on tasking-lock CAS is the load-bearing safety net
1978
+ * locally, exactly as {@link heldTaskSlugs} relies on the claim CAS). The SELECTION
1979
+ * path that must refuse an untrusted pool uses the strict twin.
1980
+ */
1981
+ export async function heldSpecSlugs(
1982
+ cwd: string,
1983
+ arbiter = 'origin',
1984
+ env?: NodeJS.ProcessEnv,
1985
+ ): Promise<Set<string>> {
1986
+ try {
1987
+ return await heldSpecSlugsStrict(cwd, arbiter, env);
1988
+ } catch {
1989
+ return new Set();
1990
+ }
1991
+ }
1992
+
1943
1993
  /**
1944
1994
  * Parse a serialised lock entry body back into a {@link LockEntry} — the exact
1945
1995
  * inverse of {@link serialiseLockEntry}. Post-`retire-stuck-lock-state` a lock
@@ -3,6 +3,7 @@ import {join} from 'node:path';
3
3
  import {
4
4
  mergeConfig,
5
5
  validateDeadlineConfig,
6
+ validateDorflCmdConfig,
6
7
  warnDeprecatedConfigKeys,
7
8
  type Config,
8
9
  type PartialConfig,
@@ -110,6 +111,20 @@ export const REPO_ALLOWED_KEYS = [
110
111
  // through the SAME chain as `verify`. Install belongs HERE, never baked into
111
112
  // `verify`.
112
113
  'prepare',
114
+ // `dorflCmd` (the repo-declared dorfl COMMAND bare `dorfl` self-forwards to —
115
+ // spec `dorfl-self-version-pinning-and-bootstrap-forward` §1/§3) is a genuine
116
+ // repo property exactly like `verify`/`prepare`: WHICH dorfl this repo builds/
117
+ // advances/intakes with is agreed by all collaborators + travels with the repo,
118
+ // for reproducibility. It is the DELIBERATE, ADR-recorded EXCEPTION to ADR §13's
119
+ // host-only rule: a machine-command key (same class as the REJECTED
120
+ // `agentCmd`/`piBin`/`sessionsDir` below) that IS repo-settable, because its
121
+ // purpose is repo-declared reproducibility, it carries no more trust than the
122
+ // committed `verify` command the repo already runs, and the forward is ANNOUNCED
123
+ // on stderr (unlike a silent `piBin`). There is NO trust gate. See ADR
124
+ // `dorfl-cmd-repo-settable-exception-to-host-only` for the full why + the
125
+ // reversal of §13 for this one key. Resolved per-repo through the SAME chain as
126
+ // `verify` (flag > env `DORFL_DORFL_CMD` > per-repo > global > default unset).
127
+ 'dorflCmd',
113
128
  'defaultArbiter',
114
129
  // `autoBuild` (may an agent auto-BUILD undeclared, not-`humanOnly` tasks in
115
130
  // this repo?) is a genuine repo property — the build member of the symmetric
@@ -591,6 +606,11 @@ export function resolveRepoConfigFromLoaded(
591
606
  // (flag / env / per-repo / global) throws with a clear message naming the
592
607
  // field + range — NEVER silently clamped.
593
608
  validateDeadlineConfig(config);
609
+ // Validate + normalise the repo-declared dorfl command (trim; empty ⇒ unset;
610
+ // non-string ⇒ fail-loud) after layering, so a malformed value from ANY layer
611
+ // (flag / env / per-repo / global) surfaces the same clear error (ADR
612
+ // `dorfl-cmd-repo-settable-exception-to-host-only`).
613
+ validateDorflCmdConfig(config);
594
614
  return {
595
615
  config,
596
616
  rejected: repo.rejected,
package/src/scan.ts CHANGED
@@ -252,16 +252,30 @@ export function scoreSpecs(
252
252
  repoPath: string,
253
253
  pool: LedgerSpecPool,
254
254
  autoTask: boolean,
255
+ /**
256
+ * The held-SPEC set to SUBTRACT from the taskable pool (fix
257
+ * `propose-tasking-releases-lock-so-spec-is-retasked-and-pr-force-pushed-every-tick`):
258
+ * a spec whose `refs/dorfl/lock/spec-<slug>` is HELD is IN-FLIGHT (a propose-mode
259
+ * tasking PR is open, or a merge tasking is mid-flight) and must NOT be enumerated
260
+ * as taskable — otherwise CI re-tasks it every tick, force-pushing the open PR.
261
+ * Symmetric to {@link scoreItems}'s task held-slug subtraction (specs took NONE
262
+ * before this fix). Defaults empty (offline read — the caller supplies the set,
263
+ * fail-CLOSED for selection via {@link heldSpecSlugsStrict}, graceful for the
264
+ * surface).
265
+ */
266
+ heldSpecSlugs: Set<string> = new Set(),
255
267
  ): ScannedSpec[] {
256
268
  const taskable = new Set(
257
269
  taskableSpecs({
258
- candidates: pool.specs.map((p) => ({
259
- repoPath,
260
- slug: p.slug,
261
- humanOnly: p.humanOnly,
262
- needsAnswers: p.needsAnswers,
263
- taskedAfter: p.taskedAfter,
264
- })),
270
+ candidates: pool.specs
271
+ .filter((p) => !heldSpecSlugs.has(p.slug))
272
+ .map((p) => ({
273
+ repoPath,
274
+ slug: p.slug,
275
+ humanOnly: p.humanOnly,
276
+ needsAnswers: p.needsAnswers,
277
+ taskedAfter: p.taskedAfter,
278
+ })),
265
279
  taskedSlugs: pool.taskedSlugs,
266
280
  autoTask,
267
281
  }).map((p) => p.slug),
@@ -567,6 +581,16 @@ export function scanRepoPaths(
567
581
  * empty (no override).
568
582
  */
569
583
  override?: ConfigOverrideMap,
584
+ /**
585
+ * The held-SPEC set to SUBTRACT from each repo's TASKABLE-spec pool (fix
586
+ * `propose-tasking-releases-lock-so-spec-is-retasked-and-pr-force-pushed-every-tick`):
587
+ * passed into {@link scoreSpecs} so a spec whose per-item lock is HELD (a
588
+ * propose-mode tasking PR is open, or a merge tasking is mid-flight) never leaks
589
+ * into the propose matrix's `spec:` legs — the SPEC counterpart of the `heldSlugs`
590
+ * task subtraction. Supplied by the in-place caller (offline scan has no arbiter
591
+ * handle); DEFAULTS empty.
592
+ */
593
+ heldSpecSlugs: Set<string> = new Set(),
570
594
  ): ScanReport {
571
595
  const repos: RepoReport[] = [];
572
596
  const counts = {totalItems: 0, totalEligible: 0};
@@ -585,7 +609,7 @@ export function scanRepoPaths(
585
609
  // predicate. This is what makes the propose-mode CI matrix enumerate `spec:`
586
610
  // legs (see `ci-propose-matrix-must-enumerate-sliceable-prds-not-only-slices`).
587
611
  const specPool = ledgerRead.resolveSpecPool({repoPath: path});
588
- const specs = scoreSpecs(path, specPool, resolved.autoTask);
612
+ const specs = scoreSpecs(path, specPool, resolved.autoTask, heldSpecSlugs);
589
613
  // The per-repo LIFECYCLE pool (`ci-propose-matrix-enumerates-lifecycle-items`),
590
614
  // gated by this working tree's `observationTriage` / `surfaceBlockers` (resolved
591
615
  // the same way as `autoBuild`/`autoTask`) and computed by REUSING