@dev-loops/core 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
+ "engines": {
6
+ "node": ">=24"
7
+ },
5
8
  "description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
6
9
  "exports": {
7
10
  "./bash-exit-one": "./src/bash-exit-one.mjs",
@@ -75,6 +75,30 @@ const AutonomyConfig = z.strictObject({
75
75
  stopAt: z.array(
76
76
  z.enum(["refinement", "draft-pr", "pre-approval", "merge"])
77
77
  ),
78
+ // When true, merge is a fixed, non-overridable human action: the agent never
79
+ // runs `gh pr merge`, `resolveAutonomyStopAt` always includes "merge", and
80
+ // any per-run merge authorization (envelope flag / explicit instruction) is
81
+ // ignored — it fails closed. See resolveHumanMergeOnly / resolveEffectiveMergeAuthorized.
82
+ humanMergeOnly: z.boolean().optional(),
83
+ });
84
+
85
+ /**
86
+ * Human-handoff config (#920, Request B of #910): at the pre-approval /
87
+ * merge-handoff boundary, OFFER to assign the PR to a named human
88
+ * reviewer/assignee. Opt-in (default off). Pairs with autonomy.humanMergeOnly.
89
+ * `candidatesFrom` selects which sources the resolver queries; `assignees` is a
90
+ * static highest-priority candidate list. Absent/empty = disabled no-op.
91
+ */
92
+ const HumanHandoffConfig = z.strictObject({
93
+ enabled: z.boolean().default(false),
94
+ candidatesFrom: z
95
+ .array(z.enum(["codeowners", "recent-committers"]))
96
+ .optional(),
97
+ assignees: z.array(z.string().trim().min(1)).optional(),
98
+ });
99
+
100
+ const ApprovalConfig = z.strictObject({
101
+ humanHandoff: HumanHandoffConfig.optional(),
78
102
  });
79
103
 
80
104
  const WorkflowConfig = z.strictObject({
@@ -104,6 +128,18 @@ const QueueConfig = z.strictObject({
104
128
  archiveOlderThanDays: z.number().int().positive().optional(),
105
129
  });
106
130
 
131
+ /**
132
+ * Worktree lifecycle config (#909): which gitignored files/dirs to provision
133
+ * into a fresh worktree from the main checkout. Entries are repo-relative
134
+ * literal paths OR glob patterns. `copyOnInit` → `fs.cp` (isolated per
135
+ * worktree); `linkOnInit` → absolute symlink into the main checkout (read-only
136
+ * data). Both optional; empty/absent is a valid no-op.
137
+ */
138
+ const WorktreeConfig = z.strictObject({
139
+ copyOnInit: z.array(z.string().trim().min(1)).optional(),
140
+ linkOnInit: z.array(z.string().trim().min(1)).optional(),
141
+ });
142
+
107
143
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
108
144
  const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
109
145
 
@@ -147,11 +183,13 @@ export const DevLoopConfigSchema = z.strictObject({
147
183
  refinement: RefinementConfig.optional(),
148
184
  gates: GatesConfig.optional(),
149
185
  autonomy: AutonomyConfig.optional(),
186
+ approval: ApprovalConfig.optional(),
150
187
  workflow: WorkflowConfig.optional(),
151
188
  localImplementation: LocalImplementationConfig.optional(),
152
189
  queue: QueueConfig.optional(),
153
190
  personas: PersonasConfig.optional(),
154
191
  internalPathPatterns: InternalPatternsConfig.optional(),
192
+ worktree: WorktreeConfig.optional(),
155
193
  });
156
194
 
157
195
  // ============================================================================
@@ -165,7 +203,14 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
165
203
  models: Object.freeze({}),
166
204
  refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, stopOnLowSignal: false, lowSignalRoundThreshold: 3, lowSignalMaxComments: 2 }),
167
205
  gates: Object.freeze({}),
168
- autonomy: Object.freeze({ stopAt: Object.freeze(["merge"]) }),
206
+ autonomy: Object.freeze({ stopAt: Object.freeze(["merge"]), humanMergeOnly: false }),
207
+ approval: Object.freeze({
208
+ humanHandoff: Object.freeze({
209
+ enabled: false,
210
+ candidatesFrom: Object.freeze([]),
211
+ assignees: Object.freeze([]),
212
+ }),
213
+ }),
169
214
  workflow: Object.freeze({
170
215
  asyncStartMode: "required",
171
216
  requireRetrospective: false,
@@ -193,6 +238,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
193
238
  "^\\.github/",
194
239
  "^test/",
195
240
  ]),
241
+ worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
196
242
  });
197
243
 
198
244
  // ============================================================================
@@ -207,11 +253,13 @@ export const FileConfigSchema = z.strictObject({
207
253
  refinement: RefinementConfig.partial().optional(),
208
254
  gates: FileGatesConfig.optional(),
209
255
  autonomy: AutonomyConfig.partial().optional(),
256
+ approval: ApprovalConfig.partial().optional(),
210
257
  workflow: WorkflowConfig.partial().optional(),
211
258
  localImplementation: LocalImplementationConfig.partial().optional(),
212
259
  queue: QueueConfig.partial().optional(),
213
260
  personas: FilePersonasConfig.optional(),
214
261
  internalPathPatterns: InternalPatternsConfig.optional(),
262
+ worktree: WorktreeConfig.partial().optional(),
215
263
  });
216
264
 
217
265
  // ============================================================================
@@ -727,10 +775,66 @@ export function resolveConductorModel(config) {
727
775
  * @returns {string[]}
728
776
  */
729
777
  export function resolveAutonomyStopAt(config) {
730
- if (config?.autonomy?.stopAt && Array.isArray(config.autonomy.stopAt)) {
731
- return [...config.autonomy.stopAt];
778
+ const base = (config?.autonomy?.stopAt && Array.isArray(config.autonomy.stopAt))
779
+ ? [...config.autonomy.stopAt]
780
+ : ["merge"];
781
+ // Fail closed: humanMergeOnly forces a human stop at merge regardless of
782
+ // what stopAt is configured (even an explicit []).
783
+ if (resolveHumanMergeOnly(config) && !base.includes("merge")) {
784
+ base.push("merge");
732
785
  }
733
- return ["merge"];
786
+ return base;
787
+ }
788
+
789
+ /**
790
+ * Resolve the fixed human-merge-only invariant from the merged dev-loop config.
791
+ *
792
+ * When true, the agent must never perform the merge itself: `gh pr merge` is a
793
+ * human-only action and any per-run merge authorization is ignored. Defaults to
794
+ * false (the agent may merge once authorized).
795
+ *
796
+ * @param {DevLoopConfig} config
797
+ * @returns {boolean}
798
+ */
799
+ export function resolveHumanMergeOnly(config) {
800
+ return config?.autonomy?.humanMergeOnly === true;
801
+ }
802
+
803
+ /**
804
+ * Authoritative gate: resolve the effective merge authorization for the agent.
805
+ *
806
+ * This is the single chokepoint that decides whether the agent is cleared to
807
+ * run `gh pr merge`. When `humanMergeOnly` is set on the repo config, this
808
+ * ALWAYS returns false — the per-run `mergeAuthorized` flag (envelope flag or
809
+ * explicit "merge" instruction) cannot override the repo invariant. Fails
810
+ * closed: a non-boolean `mergeAuthorized` is treated as not authorized.
811
+ *
812
+ * @param {boolean} mergeAuthorized per-run authorization signal
813
+ * @param {DevLoopConfig} config merged dev-loop config
814
+ * @returns {boolean}
815
+ */
816
+ export function resolveEffectiveMergeAuthorized(mergeAuthorized, config) {
817
+ if (resolveHumanMergeOnly(config)) return false;
818
+ return mergeAuthorized === true;
819
+ }
820
+
821
+ /**
822
+ * Authoritative gate for callers that load the config themselves and hold its
823
+ * `{ config, errors }` load result. FAILS CLOSED on any config load/validation
824
+ * error: `loadDevLoopConfig` never throws (it returns an `errors` array), so a
825
+ * caller must not assume "no exception" means "config is safe". If the config
826
+ * could not be loaded/validated, the `.devloops` file declaring `humanMergeOnly`
827
+ * may be the very one that failed — so merge authorization is denied rather than
828
+ * silently granted from a fallback config that lacks the invariant.
829
+ *
830
+ * @param {boolean} mergeAuthorized per-run authorization signal
831
+ * @param {{ config?: DevLoopConfig, errors?: Array<unknown> }} loadResult result of `loadDevLoopConfig`
832
+ * @returns {boolean}
833
+ */
834
+ export function resolveEffectiveMergeAuthorizedFromLoad(mergeAuthorized, loadResult) {
835
+ const errors = loadResult?.errors ?? [];
836
+ if (errors.length > 0) return false;
837
+ return resolveEffectiveMergeAuthorized(mergeAuthorized, loadResult?.config);
734
838
  }
735
839
 
736
840
  const DEFAULT_REFINEMENT_CONFIG = BUILT_IN_DEFAULTS.refinement;
@@ -1053,6 +1157,61 @@ const DEFAULT_INTERNAL_PATH_PATTERNS = BUILT_IN_DEFAULTS.internalPathPatterns;
1053
1157
  * @param {DevLoopConfig} config
1054
1158
  * @returns {string[]}
1055
1159
  */
1160
+ /**
1161
+ * Resolve the worktree lifecycle config from the merged dev-loop config.
1162
+ *
1163
+ * Returns `{ copyOnInit, linkOnInit }` with empty-array defaults when the
1164
+ * config omits the `worktree` section or either list. Entries are trimmed,
1165
+ * repo-relative literal paths or glob patterns expanded against the main
1166
+ * checkout at provision time. See scripts/loop/provision-worktree.mjs.
1167
+ *
1168
+ * @param {DevLoopConfig} config
1169
+ * @returns {{ copyOnInit: string[], linkOnInit: string[] }}
1170
+ */
1171
+ export function resolveWorktreeConfig(config) {
1172
+ const wt = config?.worktree;
1173
+ const list = (v) =>
1174
+ Array.isArray(v)
1175
+ ? v.map((s) => (typeof s === "string" ? s.trim() : "")).filter((s) => s.length > 0)
1176
+ : [];
1177
+ return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
1178
+ }
1179
+
1180
+ /**
1181
+ * Resolve the human-handoff config from the merged dev-loop config (#920).
1182
+ *
1183
+ * Returns a normalized `{ enabled, candidatesFrom, assignees }`. Defaults to
1184
+ * disabled with empty arrays when the `approval.humanHandoff` section is absent.
1185
+ * When disabled (default), this is a no-op: callers must not source candidates
1186
+ * or assign anyone. Pairs with `autonomy.humanMergeOnly`: when human-merge is
1187
+ * enforced, this names who should take the merge.
1188
+ *
1189
+ * @param {DevLoopConfig} config
1190
+ * @returns {{ enabled: boolean, candidatesFrom: ("codeowners"|"recent-committers")[], assignees: string[] }}
1191
+ */
1192
+ export function resolveHumanHandoffConfig(config) {
1193
+ const hh = config?.approval?.humanHandoff;
1194
+ const enabled = hh?.enabled === true;
1195
+ const list = (v) =>
1196
+ Array.isArray(v)
1197
+ ? v.map((s) => (typeof s === "string" ? s.trim() : "")).filter((s) => s.length > 0)
1198
+ : [];
1199
+ const candidatesFrom = list(hh?.candidatesFrom).filter(
1200
+ (s) => s === "codeowners" || s === "recent-committers"
1201
+ );
1202
+ // Normalize assignees: strip a leading `@`, trim, and drop empties so an empty
1203
+ // login (e.g. config value of `"@"` or `""`) can never leak downstream into
1204
+ // `gh pr edit --add-assignee ""`.
1205
+ const assignees = list(hh?.assignees)
1206
+ .map((s) => s.replace(/^@/, "").trim())
1207
+ .filter((s) => s.length > 0);
1208
+ return {
1209
+ enabled,
1210
+ candidatesFrom: enabled ? candidatesFrom : [],
1211
+ assignees: enabled ? assignees : [],
1212
+ };
1213
+ }
1214
+
1056
1215
  export function resolveInternalPathPatterns(config) {
1057
1216
  if (
1058
1217
  config?.internalPathPatterns &&
@@ -10,10 +10,9 @@
10
10
  * async context marker. When the marker is absent, the check fails closed
11
11
  * and returns a machine-readable rejection rather than silently proceeding.
12
12
  *
13
- * Async context markers (required when workflow.asyncStartMode is `required`),
14
- * neutral-first — see `@dev-loops/core/loop/run-context`:
13
+ * Async context marker (required when workflow.asyncStartMode is `required`)
14
+ * — see `@dev-loops/core/loop/run-context`:
15
15
  * - DEVLOOPS_RUN_ID env var (neutral, harness-agnostic)
16
- * - PI_SUBAGENT_RUN_ID env var (Pi subagent framework; retained as a compatibility alias)
17
16
  *
18
17
  * Allowed modes:
19
18
  * - workflow.asyncStartMode: required | allowed
@@ -30,11 +29,10 @@ import { RUN_ID_MARKERS, isClaudeHarness } from "./run-context.mjs";
30
29
  // ---------------------------------------------------------------------------
31
30
 
32
31
  /**
33
- * Environment variable names that indicate an async context, neutral-first.
32
+ * Environment variable names that indicate an async context.
34
33
  * Sourced from the shared run-context contract so the markers stay in one place.
35
- * The historical name is kept for back-compat; it now includes DEVLOOPS_RUN_ID.
36
34
  */
37
- export const PI_ASYNC_CONTEXT_MARKERS = RUN_ID_MARKERS;
35
+ export const ASYNC_CONTEXT_MARKERS = RUN_ID_MARKERS;
38
36
 
39
37
  /** Supported workflow async-start modes. */
40
38
  export const ASYNC_START_MODE = Object.freeze({
@@ -130,8 +128,8 @@ export function validateAsyncStartContext({
130
128
  };
131
129
  }
132
130
 
133
- // Check for any async context marker (neutral DEVLOOPS_RUN_ID or the Pi alias)
134
- for (const marker of PI_ASYNC_CONTEXT_MARKERS) {
131
+ // Check for any async context marker (DEVLOOPS_RUN_ID)
132
+ for (const marker of ASYNC_CONTEXT_MARKERS) {
135
133
  const value = env[marker];
136
134
  if (typeof value === "string" && value.trim().length > 0) {
137
135
  return {
@@ -150,24 +148,7 @@ export function validateAsyncStartContext({
150
148
  };
151
149
  }
152
150
 
153
- const sessionOnlyMarker =
154
- (typeof env.PI_SESSION_ID === "string" && env.PI_SESSION_ID.trim().length > 0)
155
- ? "PI_SESSION_ID"
156
- : ((typeof env.PI_ASYNC_CONTEXT === "string" && env.PI_ASYNC_CONTEXT.trim().length > 0)
157
- ? "PI_ASYNC_CONTEXT"
158
- : null);
159
- if (sessionOnlyMarker !== null) {
160
- return {
161
- status: ASYNC_START_STATUS.REJECTED,
162
- reason:
163
- `Detected ${sessionOnlyMarker}, but GitHub-first async-start requires a visible ` +
164
- "subagent run id for inspectable startup/resume evidence. " +
165
- "Set DEVLOOPS_RUN_ID (or the PI_SUBAGENT_RUN_ID alias) to proceed. Any exception must come from repository-maintained workflow policy.",
166
- detectedMarker: null,
167
- };
168
- }
169
-
170
- if (env.PI_DEV_LOOP_DETACHED === "1") {
151
+ if (env.DEVLOOPS_DETACHED === "1") {
171
152
  return {
172
153
  status: ASYNC_START_STATUS.REJECTED,
173
154
  reason:
@@ -185,7 +166,7 @@ export function validateAsyncStartContext({
185
166
  "No async context detected. " +
186
167
  "The dev-loop must run within a visible async subagent session, " +
187
168
  "not as a detached local process. " +
188
- `Set ${PI_ASYNC_CONTEXT_MARKERS[0]} (or the PI_SUBAGENT_RUN_ID alias) to proceed. ` +
169
+ `Set ${ASYNC_CONTEXT_MARKERS[0]} to proceed. ` +
189
170
  "Repository-maintained workflow policy controls any exceptions.",
190
171
  detectedMarker: null,
191
172
  };
@@ -21,6 +21,7 @@ import {
21
21
  import { normalizeRepoSlug } from "../github/repo-slug.mjs";
22
22
  import { COPILOT_REVIEW_WAIT_TIMEOUT_MS } from "./policy-constants.mjs";
23
23
  import { resolveEffectiveAsyncStartMode } from "./async-start-contract.mjs";
24
+ import { resolveHumanMergeOnly } from "../config/config.mjs";
24
25
 
25
26
  // ---------------------------------------------------------------------------
26
27
  // Constants
@@ -243,10 +244,18 @@ function deriveTarget(bundle, repo) {
243
244
  // ---------------------------------------------------------------------------
244
245
 
245
246
  function deriveStopRules(settings, strategy) {
246
- if (settings?.autonomy?.stopAt && Array.isArray(settings.autonomy.stopAt)) {
247
- return [...settings.autonomy.stopAt];
247
+ const base = (settings?.autonomy?.stopAt && Array.isArray(settings.autonomy.stopAt))
248
+ ? [...settings.autonomy.stopAt]
249
+ : [...(STRATEGY_DEFAULT_STOP_RULES[strategy] ?? [])];
250
+ // Fail closed: humanMergeOnly forces "merge" into the dispatched agent's
251
+ // stopRules regardless of configured stopAt, mirroring the authoritative
252
+ // resolveAutonomyStopAt(config) invariant. Without this, a custom
253
+ // stopAt that omits "merge" (e.g. [] or ["draft-pr"]) would tell the agent
254
+ // NOT to stop at merge — a direct humanMergeOnly bypass.
255
+ if (resolveHumanMergeOnly(settings) && !base.includes("merge")) {
256
+ base.push("merge");
248
257
  }
249
- return [...(STRATEGY_DEFAULT_STOP_RULES[strategy] ?? [])];
258
+ return base;
250
259
  }
251
260
 
252
261
  // ---------------------------------------------------------------------------
@@ -316,28 +325,61 @@ function deriveCwd(bundle, options = {}) {
316
325
  const kind = normalizeTargetKind(artifact.kind);
317
326
 
318
327
  if (root) {
328
+ // issue/pr go through the single source of truth (resolveWorktreePath); other
329
+ // slug kinds (local_branch/local_phase) still use the namespace + slug.
330
+ if (kind === DEV_LOOP_TARGET_KIND.ISSUE && Number.isInteger(artifact.issue) && artifact.issue > 0) {
331
+ return resolveWorktreePath({ repoRoot: root, kind: "issue", number: artifact.issue });
332
+ }
333
+ if (kind === DEV_LOOP_TARGET_KIND.PR && Number.isInteger(artifact.pr) && artifact.pr > 0) {
334
+ return resolveWorktreePath({ repoRoot: root, kind: "pr", number: artifact.pr });
335
+ }
319
336
  const slug = buildWorktreeSlug(artifact, kind);
320
337
  if (slug) {
321
- return `${root}/tmp/worktrees/${slug}`;
338
+ return `${root}/${WORKTREE_NAMESPACE}/${slug}`;
322
339
  }
323
340
  }
324
341
 
325
342
  return null;
326
343
  }
327
344
 
345
+ /** Repo-relative root for loop-owned worktrees. The `dev-loops/` namespace */
346
+ /** marks them so cleanup can only ever remove its own (issue #909). */
347
+ export const WORKTREE_NAMESPACE = "tmp/worktrees/dev-loops";
348
+
349
+ /**
350
+ * Resolve the canonical, namespaced worktree path for an issue/PR. Sole source
351
+ * of truth shared by create, provision, and cleanup. No branch suffix, so the
352
+ * path is recomputable from the issue/PR number alone.
353
+ *
354
+ * @param {{ repoRoot: string, kind: "issue"|"pr", number: number }} args
355
+ * @returns {string} Absolute path `<repoRoot>/tmp/worktrees/dev-loops/<kind>-<number>`
356
+ */
357
+ export function resolveWorktreePath({ repoRoot, kind, number } = {}) {
358
+ const root = normalizeString(repoRoot);
359
+ if (!root) throw new Error("resolveWorktreePath: repoRoot is required and must be a non-empty string");
360
+ const k = typeof kind === "string" ? kind.trim().toLowerCase() : "";
361
+ if (k !== DEV_LOOP_TARGET_KIND.ISSUE && k !== DEV_LOOP_TARGET_KIND.PR) {
362
+ throw new Error(`resolveWorktreePath: kind must be "issue" or "pr", got "${kind}"`);
363
+ }
364
+ if (!Number.isInteger(number) || number < 1) {
365
+ throw new Error(`resolveWorktreePath: number must be a positive integer, got ${number}`);
366
+ }
367
+ return `${root}/${WORKTREE_NAMESPACE}/${k}-${number}`;
368
+ }
369
+
328
370
  function flattenSlugSegment(s) {
329
371
  if (typeof s !== "string") return "";
330
372
  return s.replace(/[/\\]/g, "-").replace(/[^a-zA-Z0-9._-]/g, "");
331
373
  }
332
374
 
333
375
  function buildWorktreeSlug(artifact, kind) {
376
+ // Canonical naming is namespaced + no branch suffix (issue #909) so the path
377
+ // is recomputable from the issue/PR number alone (cleanup can find it).
334
378
  if (kind === DEV_LOOP_TARGET_KIND.ISSUE && Number.isInteger(artifact.issue) && artifact.issue > 0) {
335
- const branch = normalizeString(artifact.branch);
336
- return branch ? `issue-${artifact.issue}-${flattenSlugSegment(branch)}` : `issue-${artifact.issue}`;
379
+ return `issue-${artifact.issue}`;
337
380
  }
338
381
  if (kind === DEV_LOOP_TARGET_KIND.PR && Number.isInteger(artifact.pr) && artifact.pr > 0) {
339
- const branch = normalizeString(artifact.branch);
340
- return branch ? `pr-${artifact.pr}-${flattenSlugSegment(branch)}` : `pr-${artifact.pr}`;
382
+ return `pr-${artifact.pr}`;
341
383
  }
342
384
  if (kind === DEV_LOOP_TARGET_KIND.LOCAL_BRANCH) {
343
385
  const branch = normalizeString(artifact.branch);
@@ -162,6 +162,7 @@ function normalizeLifecycleState(value) {
162
162
  * hasUnresolvedThreads, // boolean: unresolved review threads exist
163
163
  * preApprovalGatePassed, // boolean: current-head pre_approval_gate clean
164
164
  * mergeAuthorized, // boolean: explicit merge authorization granted
165
+ * humanMergeOnly, // boolean: repo invariant — agent may never merge (fails closed)
165
166
  * isMerged, // boolean: PR has been merged
166
167
  * }
167
168
  * ```
@@ -194,9 +195,18 @@ export function resolveLifecycleState(input = {}) {
194
195
  hasUnresolvedThreads = false,
195
196
  preApprovalGatePassed = false,
196
197
  mergeAuthorized = false,
198
+ humanMergeOnly = false,
197
199
  isMerged = false,
198
200
  } = input;
199
201
 
202
+ // Fail closed: when the repo enforces human-only merge, the agent is never
203
+ // cleared to advance to the merge action — the per-run mergeAuthorized signal
204
+ // is ignored. Also fail closed on a non-boolean `mergeAuthorized` (only an
205
+ // exact `true` clears merge), matching the authoritative
206
+ // `resolveEffectiveMergeAuthorized` gate. An already-merged PR (isMerged) is
207
+ // still terminal below.
208
+ const effectiveMergeAuthorized = humanMergeOnly !== true && mergeAuthorized === true;
209
+
200
210
  // 1. Explicit phase override — canonical or fail closed
201
211
  if (phase !== null && phase !== undefined) {
202
212
  const normalized = normalizeLifecycleState(phase);
@@ -212,7 +222,9 @@ export function resolveLifecycleState(input = {}) {
212
222
  }
213
223
 
214
224
  // 3. Merge authorized with pre-approval + linked PR → merge
215
- if (mergeAuthorized && preApprovalGatePassed && hasLinkedPr) {
225
+ // (humanMergeOnly forces effectiveMergeAuthorized=false above, so the loop
226
+ // stays at the pre_approval_gate human-merge handoff instead.)
227
+ if (effectiveMergeAuthorized && preApprovalGatePassed && hasLinkedPr) {
216
228
  return buildResult(LIFECYCLE_STATE.MERGE);
217
229
  }
218
230
 
@@ -25,7 +25,11 @@ export async function resolveNextUpOrder(
25
25
  const listItems = dependencies.listQueueItems ?? listQueueItemsMain;
26
26
  try {
27
27
  const result = await listItems(
28
- { repo, project: projectNumber, column: "Next Up" },
28
+ // list-queue-items validates `project` as a string ref (CLI contract);
29
+ // resolveProjectNumber yields a number, so stringify it. Passing the raw
30
+ // number trips parseProjectRef's `typeof raw !== "string"` guard, which
31
+ // surfaces as a misleading "--project is required" (#901).
32
+ { repo, project: String(projectNumber), column: "Next Up" },
29
33
  { env, runChild: dependencies.runChild },
30
34
  );
31
35
  const order = (result?.items ?? [])
@@ -57,6 +57,28 @@ export async function runQueue(repoRoot, repo, options = {}) {
57
57
  const opts = { ...DEFAULT_QUEUE_DRIVER_OPTIONS, ...options };
58
58
  const queue = await readQueue(repoRoot);
59
59
 
60
+ // Data-integrity guard (#913): this driver is a deterministic ADAPTER over the
61
+ // board, not the orchestration harness. Completion (`done` / move to Done) may
62
+ // only ever REFLECT a real terminal signal supplied by an orchestrator via
63
+ // `runEntry` (e.g. a merged PR). With no `runEntry` wired in the current
64
+ // harness there is nothing that can produce a verifiable terminal state, so
65
+ // the run MUST be a no-op: leave every entry and board column untouched and
66
+ // report the reason. Previously the missing-orchestrator path fell back to a
67
+ // fabricated `{ ok: true, pr: null }` per entry, which silently marked an
68
+ // entire Next Up `done` and moved it to Done without any work happening.
69
+ if (typeof opts.runEntry !== "function") {
70
+ return {
71
+ ok: true,
72
+ noop: true,
73
+ reason: "no-orchestrator",
74
+ message:
75
+ "queue run is a deterministic adapter with no orchestrator wired (no runEntry); " +
76
+ "leaving board columns unchanged. Items move to Done only on a real terminal signal.",
77
+ results: [],
78
+ queue,
79
+ };
80
+ }
81
+
60
82
  // Config-driven loop-state → board-column mapping (#793, AC1/AC3). Loaded
61
83
  // once per run; resolves logical columns to configured display names, with
62
84
  // the AC1 defaults when no `queue.statusColumns`/`queue.stateColumnMap` is set.
@@ -135,9 +157,9 @@ export async function runQueue(repoRoot, repo, options = {}) {
135
157
  await syncColumn(entry.target, columnFor("implementation"));
136
158
 
137
159
  try {
138
- const entryResult = opts.runEntry
139
- ? await opts.runEntry(entry, repo, opts)
140
- : { ok: true, pr: null };
160
+ // runEntry is guaranteed a function here (guarded at function entry):
161
+ // the adapter never fabricates a terminal result for an undispatched item.
162
+ const entryResult = await opts.runEntry(entry, repo, opts);
141
163
 
142
164
  if (entryResult.ok) {
143
165
  if (entryResult.pr) {
@@ -1,15 +1,11 @@
1
1
  /**
2
2
  * Neutral run-id / async-context contract.
3
3
  *
4
- * The dev-loop async path historically keyed off Pi's `PI_SUBAGENT_RUN_ID` env var to
4
+ * The dev-loop async path keys off the harness-neutral `DEVLOOPS_RUN_ID` env var to
5
5
  * identify an inspectable per-subagent run (runner ownership, async-start enforcement,
6
- * human-comment gating). This module generalizes that into a harness-neutral
7
- * `DEVLOOPS_RUN_ID`, keeping `PI_SUBAGENT_RUN_ID` as a backward-compatible alias, and
8
- * provides a mint-and-propagate path for harnesses (e.g. Claude Code) that inject no
9
- * native per-subagent run id.
10
- *
11
- * Marker precedence is neutral-first: a present `DEVLOOPS_RUN_ID` wins; otherwise the Pi
12
- * alias is honored. Existing Pi runs that set only `PI_SUBAGENT_RUN_ID` behave identically.
6
+ * human-comment gating), and provides a mint-and-propagate path for harnesses (e.g. Claude
7
+ * Code) that inject no native per-subagent run id. The harness sets `DEVLOOPS_RUN_ID` when
8
+ * dispatching an async subagent.
13
9
  *
14
10
  * This module is pure except for the explicit file/IO helpers (writeRunContext/readRunContext),
15
11
  * which take an injectable `fs` and `root` for testability.
@@ -21,16 +17,13 @@ import path from "node:path";
21
17
 
22
18
  /**
23
19
  * Env var names that carry the async-context run id, in resolution precedence order.
24
- * Neutral `DEVLOOPS_RUN_ID` first; Pi `PI_SUBAGENT_RUN_ID` retained as a compatibility alias.
20
+ * The neutral `DEVLOOPS_RUN_ID` is the sole marker.
25
21
  */
26
- export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID", "PI_SUBAGENT_RUN_ID"]);
22
+ export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID"]);
27
23
 
28
24
  /** Neutral env var name used when minting/propagating a run id. */
29
25
  export const NEUTRAL_RUN_ID_VAR = "DEVLOOPS_RUN_ID";
30
26
 
31
- /** Pi-compatibility alias env var name. */
32
- export const PI_RUN_ID_ALIAS_VAR = "PI_SUBAGENT_RUN_ID";
33
-
34
27
  /** State-file name (under `.pi/`, consistent with existing dev-loop checkpoint files). */
35
28
  export const RUN_CONTEXT_FILENAME = "dev-loop-run-context.json";
36
29
 
@@ -56,7 +49,7 @@ export function isClaudeHarness(env = process.env) {
56
49
  }
57
50
 
58
51
  /**
59
- * Resolve the active run id from the environment, neutral marker first.
52
+ * Resolve the active run id from the environment.
60
53
  *
61
54
  * @param {Record<string, string|undefined>} [env]
62
55
  * @returns {string|null} The trimmed run id, or null when none is set.
@@ -154,8 +147,8 @@ export function readRunContext({ root, fs = fsDefault }) {
154
147
  * Resolve the active run id, or mint one and persist a run-context state file.
155
148
  *
156
149
  * This is the "mint at startup and propagate" primitive a Claude dev-loop agent (or a
157
- * headless entry) calls before dispatching child work. When the env already carries a run
158
- * id (Pi alias or neutral), it is reused and no new id is minted.
150
+ * headless entry) calls before dispatching child work. When the env already carries a
151
+ * `DEVLOOPS_RUN_ID`, it is reused and no new id is minted.
159
152
  *
160
153
  * @param {object} [params]
161
154
  * @param {Record<string, string|undefined>} [params.env]
@@ -121,32 +121,22 @@ export function isListedWorktree(cwd, worktreePaths) {
121
121
  * Neutral environment variable name checked by `detectSubagentAvailability`.
122
122
  *
123
123
  * Set `DEVLOOPS_SUBAGENT_AVAILABLE=1` when the runtime supports subagent dispatch.
124
- * This is consistent with the `PI_WORKTREE_BYPASS` pattern and other repo-local
124
+ * This is consistent with the `DEVLOOPS_WORKTREE_BYPASS` pattern and other repo-local
125
125
  * runtime configuration gates already present in the repo.
126
126
  */
127
127
  export const DEVLOOPS_SUBAGENT_AVAILABLE_VAR = "DEVLOOPS_SUBAGENT_AVAILABLE";
128
128
 
129
- /**
130
- * Pi-compatibility alias for {@link DEVLOOPS_SUBAGENT_AVAILABLE_VAR}; honored when the
131
- * neutral var is unset so existing Pi runtimes keep working unchanged.
132
- */
133
- export const PI_SUBAGENT_AVAILABLE_VAR = "PI_SUBAGENT_AVAILABLE";
134
-
135
- /** Availability env var names, neutral-first. */
136
- export const SUBAGENT_AVAILABLE_VARS = Object.freeze([
137
- DEVLOOPS_SUBAGENT_AVAILABLE_VAR,
138
- PI_SUBAGENT_AVAILABLE_VAR,
139
- ]);
129
+ /** Availability env var names. */
130
+ export const SUBAGENT_AVAILABLE_VARS = Object.freeze([DEVLOOPS_SUBAGENT_AVAILABLE_VAR]);
140
131
 
141
132
  /**
142
133
  * Detect whether subagent dispatch is available in the current runtime.
143
134
  *
144
135
  * This is an env-var-based heuristic, consistent with other bypass/availability
145
136
  * patterns in the repo. It is intentionally simple — the gate's subagent check
146
- * is advisory (fails-open) and never hard-blocks on subagent absence. Precedence is
147
- * neutral-first: the first var that is *set* (non-blank) is authoritative — so an explicit
148
- * `DEVLOOPS_SUBAGENT_AVAILABLE=0` is respected even when `PI_SUBAGENT_AVAILABLE=1`. The Pi
149
- * alias is only consulted when the neutral var is unset/blank.
137
+ * is advisory (fails-open) and never hard-blocks on subagent absence. The var that is
138
+ * *set* (non-blank) is authoritative — so an explicit `DEVLOOPS_SUBAGENT_AVAILABLE=0` is
139
+ * respected as a hard "not available".
150
140
  *
151
141
  * @param {{ env?: Record<string, string | undefined> }} [options]
152
142
  * @returns {boolean}