@dev-loops/core 0.3.0 → 0.5.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.5.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",
@@ -34,6 +37,9 @@
34
37
  "./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
35
38
  "./loop/phase-files": "./src/loop/phase-files.mjs",
36
39
  "./loop/policy-constants": "./src/loop/policy-constants.mjs",
40
+ "./loop/plan-file-intake-contract": "./src/loop/plan-file-intake-contract.mjs",
41
+ "./loop/plan-file-promote-contract": "./src/loop/plan-file-promote-contract.mjs",
42
+ "./loop/plan-file-refine-contract": "./src/loop/plan-file-refine-contract.mjs",
37
43
  "./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
38
44
  "./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
39
45
  "./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
@@ -45,6 +51,8 @@
45
51
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
46
52
  "./loop/run-context": "./src/loop/run-context.mjs",
47
53
  "./loop/run-inspection": "./src/loop/run-inspection.mjs",
54
+ "./loop/spike-exit-contract": "./src/loop/spike-exit-contract.mjs",
55
+ "./loop/spike-intake-contract": "./src/loop/spike-intake-contract.mjs",
48
56
  "./loop/steering": "./src/loop/steering.mjs",
49
57
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
50
58
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
@@ -53,6 +53,12 @@ const GatesConfig = z.strictObject({
53
53
  // `requireCi` is only behaviorally configurable for the draft gate.
54
54
  // preApproval always requires CI even if config repeats `requireCi`.
55
55
  preApproval: GateConfig.optional(),
56
+ // Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
57
+ // not production code, so it should not carry the full draft → pre-approval →
58
+ // Copilot production set. Resolved through the same config-merge layering and
59
+ // the same resolveGateConfig path as draft/preApproval — no new strategy→knob
60
+ // resolver. Absent for non-spike work, so production gates are unaffected.
61
+ spike: GateConfig.optional(),
56
62
  // Fail-closed enforcement that a gate verdict was produced by the
57
63
  // fan-out/fan-in review sub-loop (executionMode === "fanout_fanin" plus a
58
64
  // durable findings-log ledger), not an inline single-agent run. Default
@@ -75,12 +81,41 @@ const AutonomyConfig = z.strictObject({
75
81
  stopAt: z.array(
76
82
  z.enum(["refinement", "draft-pr", "pre-approval", "merge"])
77
83
  ),
84
+ // When true, merge is a fixed, non-overridable human action: the agent never
85
+ // runs `gh pr merge`, `resolveAutonomyStopAt` always includes "merge", and
86
+ // any per-run merge authorization (envelope flag / explicit instruction) is
87
+ // ignored — it fails closed. See resolveHumanMergeOnly / resolveEffectiveMergeAuthorized.
88
+ humanMergeOnly: z.boolean().optional(),
89
+ });
90
+
91
+ /**
92
+ * Human-handoff config (#920, Request B of #910): at the pre-approval /
93
+ * merge-handoff boundary, OFFER to assign the PR to a named human
94
+ * reviewer/assignee. Opt-in (default off). Pairs with autonomy.humanMergeOnly.
95
+ * `candidatesFrom` selects which sources the resolver queries; `assignees` is a
96
+ * static highest-priority candidate list. Absent/empty = disabled no-op.
97
+ */
98
+ const HumanHandoffConfig = z.strictObject({
99
+ enabled: z.boolean().default(false),
100
+ candidatesFrom: z
101
+ .array(z.enum(["codeowners", "recent-committers"]))
102
+ .optional(),
103
+ assignees: z.array(z.string().trim().min(1)).optional(),
104
+ });
105
+
106
+ const ApprovalConfig = z.strictObject({
107
+ humanHandoff: HumanHandoffConfig.optional(),
78
108
  });
79
109
 
80
110
  const WorkflowConfig = z.strictObject({
81
111
  asyncStartMode: z.enum(["required", "allowed"]).default("required"),
82
112
  requireRetrospective: z.boolean(),
83
113
  requireRetrospectiveGate: z.boolean().default(false),
114
+ // Developer-mode retro step (#982): enforce internal-tooling-only execution
115
+ // (no agent-level raw gh/python/node -e) in the retrospective gate. This is the
116
+ // dev-loops maintainers' own dogfooding discipline — opt-in, default OFF so
117
+ // consumers of the extension are never blocked by it.
118
+ requireRetrospectiveInternalTooling: z.boolean().default(false),
84
119
  requireDraftFirst: z.boolean(),
85
120
  devModeDefault: z.boolean(),
86
121
  });
@@ -104,6 +139,28 @@ const QueueConfig = z.strictObject({
104
139
  archiveOlderThanDays: z.number().int().positive().optional(),
105
140
  });
106
141
 
142
+ /**
143
+ * Worktree lifecycle config (#909): which gitignored files/dirs to provision
144
+ * into a fresh worktree from the main checkout. Entries are repo-relative
145
+ * literal paths OR glob patterns. `copyOnInit` → `fs.cp` (isolated per
146
+ * worktree); `linkOnInit` → absolute symlink into the main checkout (read-only
147
+ * data). Both optional; empty/absent is a valid no-op.
148
+ */
149
+ const WorktreeConfig = z.strictObject({
150
+ copyOnInit: z.array(z.string().trim().min(1)).optional(),
151
+ linkOnInit: z.array(z.string().trim().min(1)).optional(),
152
+ });
153
+
154
+ /**
155
+ * Local-planning config (#949): where persisted markdown plan files (phase-doc
156
+ * format) live when work originates from a plan file rather than a tracker
157
+ * issue. `plansDir` is a repo-relative directory; defaults to the existing
158
+ * phase-docs directory. See skills/docs/plan-file-contract.md.
159
+ */
160
+ const LocalPlanningConfig = z.strictObject({
161
+ plansDir: z.string().trim().min(1).optional(),
162
+ });
163
+
107
164
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
108
165
  const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
109
166
 
@@ -123,6 +180,7 @@ const FileGateConfig = GateConfig.partial();
123
180
  const FileGatesConfig = z.strictObject({
124
181
  draft: FileGateConfig.optional(),
125
182
  preApproval: FileGateConfig.optional(),
183
+ spike: FileGateConfig.optional(),
126
184
  requireFanoutEvidence: z.boolean().optional(),
127
185
  maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
128
186
  postFindingsComments: z.boolean().optional(),
@@ -147,11 +205,14 @@ export const DevLoopConfigSchema = z.strictObject({
147
205
  refinement: RefinementConfig.optional(),
148
206
  gates: GatesConfig.optional(),
149
207
  autonomy: AutonomyConfig.optional(),
208
+ approval: ApprovalConfig.optional(),
150
209
  workflow: WorkflowConfig.optional(),
151
210
  localImplementation: LocalImplementationConfig.optional(),
152
211
  queue: QueueConfig.optional(),
153
212
  personas: PersonasConfig.optional(),
154
213
  internalPathPatterns: InternalPatternsConfig.optional(),
214
+ worktree: WorktreeConfig.optional(),
215
+ localPlanning: LocalPlanningConfig.optional(),
155
216
  });
156
217
 
157
218
  // ============================================================================
@@ -165,11 +226,19 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
165
226
  models: Object.freeze({}),
166
227
  refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, stopOnLowSignal: false, lowSignalRoundThreshold: 3, lowSignalMaxComments: 2 }),
167
228
  gates: Object.freeze({}),
168
- autonomy: Object.freeze({ stopAt: Object.freeze(["merge"]) }),
229
+ autonomy: Object.freeze({ stopAt: Object.freeze(["merge"]), humanMergeOnly: false }),
230
+ approval: Object.freeze({
231
+ humanHandoff: Object.freeze({
232
+ enabled: false,
233
+ candidatesFrom: Object.freeze([]),
234
+ assignees: Object.freeze([]),
235
+ }),
236
+ }),
169
237
  workflow: Object.freeze({
170
238
  asyncStartMode: "required",
171
239
  requireRetrospective: false,
172
240
  requireRetrospectiveGate: false,
241
+ requireRetrospectiveInternalTooling: false,
173
242
  requireDraftFirst: false,
174
243
  devModeDefault: false,
175
244
  }),
@@ -193,6 +262,8 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
193
262
  "^\\.github/",
194
263
  "^test/",
195
264
  ]),
265
+ worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
266
+ localPlanning: Object.freeze({ plansDir: "docs/phases/" }),
196
267
  });
197
268
 
198
269
  // ============================================================================
@@ -207,11 +278,14 @@ export const FileConfigSchema = z.strictObject({
207
278
  refinement: RefinementConfig.partial().optional(),
208
279
  gates: FileGatesConfig.optional(),
209
280
  autonomy: AutonomyConfig.partial().optional(),
281
+ approval: ApprovalConfig.partial().optional(),
210
282
  workflow: WorkflowConfig.partial().optional(),
211
283
  localImplementation: LocalImplementationConfig.partial().optional(),
212
284
  queue: QueueConfig.partial().optional(),
213
285
  personas: FilePersonasConfig.optional(),
214
286
  internalPathPatterns: InternalPatternsConfig.optional(),
287
+ worktree: WorktreeConfig.partial().optional(),
288
+ localPlanning: LocalPlanningConfig.partial().optional(),
215
289
  });
216
290
 
217
291
  // ============================================================================
@@ -727,10 +801,66 @@ export function resolveConductorModel(config) {
727
801
  * @returns {string[]}
728
802
  */
729
803
  export function resolveAutonomyStopAt(config) {
730
- if (config?.autonomy?.stopAt && Array.isArray(config.autonomy.stopAt)) {
731
- return [...config.autonomy.stopAt];
804
+ const base = (config?.autonomy?.stopAt && Array.isArray(config.autonomy.stopAt))
805
+ ? [...config.autonomy.stopAt]
806
+ : ["merge"];
807
+ // Fail closed: humanMergeOnly forces a human stop at merge regardless of
808
+ // what stopAt is configured (even an explicit []).
809
+ if (resolveHumanMergeOnly(config) && !base.includes("merge")) {
810
+ base.push("merge");
732
811
  }
733
- return ["merge"];
812
+ return base;
813
+ }
814
+
815
+ /**
816
+ * Resolve the fixed human-merge-only invariant from the merged dev-loop config.
817
+ *
818
+ * When true, the agent must never perform the merge itself: `gh pr merge` is a
819
+ * human-only action and any per-run merge authorization is ignored. Defaults to
820
+ * false (the agent may merge once authorized).
821
+ *
822
+ * @param {DevLoopConfig} config
823
+ * @returns {boolean}
824
+ */
825
+ export function resolveHumanMergeOnly(config) {
826
+ return config?.autonomy?.humanMergeOnly === true;
827
+ }
828
+
829
+ /**
830
+ * Authoritative gate: resolve the effective merge authorization for the agent.
831
+ *
832
+ * This is the single chokepoint that decides whether the agent is cleared to
833
+ * run `gh pr merge`. When `humanMergeOnly` is set on the repo config, this
834
+ * ALWAYS returns false — the per-run `mergeAuthorized` flag (envelope flag or
835
+ * explicit "merge" instruction) cannot override the repo invariant. Fails
836
+ * closed: a non-boolean `mergeAuthorized` is treated as not authorized.
837
+ *
838
+ * @param {boolean} mergeAuthorized per-run authorization signal
839
+ * @param {DevLoopConfig} config merged dev-loop config
840
+ * @returns {boolean}
841
+ */
842
+ export function resolveEffectiveMergeAuthorized(mergeAuthorized, config) {
843
+ if (resolveHumanMergeOnly(config)) return false;
844
+ return mergeAuthorized === true;
845
+ }
846
+
847
+ /**
848
+ * Authoritative gate for callers that load the config themselves and hold its
849
+ * `{ config, errors }` load result. FAILS CLOSED on any config load/validation
850
+ * error: `loadDevLoopConfig` never throws (it returns an `errors` array), so a
851
+ * caller must not assume "no exception" means "config is safe". If the config
852
+ * could not be loaded/validated, the `.devloops` file declaring `humanMergeOnly`
853
+ * may be the very one that failed — so merge authorization is denied rather than
854
+ * silently granted from a fallback config that lacks the invariant.
855
+ *
856
+ * @param {boolean} mergeAuthorized per-run authorization signal
857
+ * @param {{ config?: DevLoopConfig, errors?: Array<unknown> }} loadResult result of `loadDevLoopConfig`
858
+ * @returns {boolean}
859
+ */
860
+ export function resolveEffectiveMergeAuthorizedFromLoad(mergeAuthorized, loadResult) {
861
+ const errors = loadResult?.errors ?? [];
862
+ if (errors.length > 0) return false;
863
+ return resolveEffectiveMergeAuthorized(mergeAuthorized, loadResult?.config);
734
864
  }
735
865
 
736
866
  const DEFAULT_REFINEMENT_CONFIG = BUILT_IN_DEFAULTS.refinement;
@@ -812,7 +942,7 @@ export function resolveRefinement(config) {
812
942
  * flags always resolve to stable defaults.
813
943
  *
814
944
  * @param {DevLoopConfig} config
815
- * @param {"draft"|"preApproval"} gate
945
+ * @param {"draft"|"preApproval"|"spike"} gate
816
946
  * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean }}
817
947
  */
818
948
  export function resolveGateConfig(config, gate) {
@@ -1012,7 +1142,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
1012
1142
  * for the requested key.
1013
1143
  *
1014
1144
  * @param {DevLoopConfig} config
1015
- * @param {"asyncStartMode"|"requireRetrospective"|"requireRetrospectiveGate"|"requireDraftFirst"|"devModeDefault"} key
1145
+ * @param {"asyncStartMode"|"requireRetrospective"|"requireRetrospectiveGate"|"requireRetrospectiveInternalTooling"|"requireDraftFirst"|"devModeDefault"} key
1016
1146
  * @returns {string|boolean}
1017
1147
  */
1018
1148
  export function resolveWorkflowConfig(config, key) {
@@ -1028,6 +1158,10 @@ export function resolveWorkflowConfig(config, key) {
1028
1158
  return config?.workflow?.requireRetrospectiveGate ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveGate;
1029
1159
  }
1030
1160
 
1161
+ if (key === "requireRetrospectiveInternalTooling") {
1162
+ return config?.workflow?.requireRetrospectiveInternalTooling ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveInternalTooling;
1163
+ }
1164
+
1031
1165
  if (key === "requireDraftFirst") {
1032
1166
  return config?.workflow?.requireDraftFirst ?? DEFAULT_WORKFLOW_CONFIG.requireDraftFirst;
1033
1167
  }
@@ -1053,6 +1187,79 @@ const DEFAULT_INTERNAL_PATH_PATTERNS = BUILT_IN_DEFAULTS.internalPathPatterns;
1053
1187
  * @param {DevLoopConfig} config
1054
1188
  * @returns {string[]}
1055
1189
  */
1190
+ /**
1191
+ * Resolve the worktree lifecycle config from the merged dev-loop config.
1192
+ *
1193
+ * Returns `{ copyOnInit, linkOnInit }` with empty-array defaults when the
1194
+ * config omits the `worktree` section or either list. Entries are trimmed,
1195
+ * repo-relative literal paths or glob patterns expanded against the main
1196
+ * checkout at provision time. See scripts/loop/provision-worktree.mjs.
1197
+ *
1198
+ * @param {DevLoopConfig} config
1199
+ * @returns {{ copyOnInit: string[], linkOnInit: string[] }}
1200
+ */
1201
+ export function resolveWorktreeConfig(config) {
1202
+ const wt = config?.worktree;
1203
+ const list = (v) =>
1204
+ Array.isArray(v)
1205
+ ? v.map((s) => (typeof s === "string" ? s.trim() : "")).filter((s) => s.length > 0)
1206
+ : [];
1207
+ return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
1208
+ }
1209
+
1210
+ /**
1211
+ * Resolve the local-planning plans directory from the merged dev-loop config.
1212
+ *
1213
+ * Returns the configured `localPlanning.plansDir` (trimmed) when present and
1214
+ * non-empty, otherwise the built-in default (`docs/phases/`) — the existing
1215
+ * phase-docs directory. See skills/docs/plan-file-contract.md.
1216
+ *
1217
+ * @param {DevLoopConfig} config
1218
+ * @returns {string}
1219
+ */
1220
+ export function resolvePlansDir(config) {
1221
+ const raw = config?.localPlanning?.plansDir;
1222
+ if (typeof raw === "string" && raw.trim().length > 0) {
1223
+ return raw.trim();
1224
+ }
1225
+ return BUILT_IN_DEFAULTS.localPlanning.plansDir;
1226
+ }
1227
+
1228
+ /**
1229
+ * Resolve the human-handoff config from the merged dev-loop config (#920).
1230
+ *
1231
+ * Returns a normalized `{ enabled, candidatesFrom, assignees }`. Defaults to
1232
+ * disabled with empty arrays when the `approval.humanHandoff` section is absent.
1233
+ * When disabled (default), this is a no-op: callers must not source candidates
1234
+ * or assign anyone. Pairs with `autonomy.humanMergeOnly`: when human-merge is
1235
+ * enforced, this names who should take the merge.
1236
+ *
1237
+ * @param {DevLoopConfig} config
1238
+ * @returns {{ enabled: boolean, candidatesFrom: ("codeowners"|"recent-committers")[], assignees: string[] }}
1239
+ */
1240
+ export function resolveHumanHandoffConfig(config) {
1241
+ const hh = config?.approval?.humanHandoff;
1242
+ const enabled = hh?.enabled === true;
1243
+ const list = (v) =>
1244
+ Array.isArray(v)
1245
+ ? v.map((s) => (typeof s === "string" ? s.trim() : "")).filter((s) => s.length > 0)
1246
+ : [];
1247
+ const candidatesFrom = list(hh?.candidatesFrom).filter(
1248
+ (s) => s === "codeowners" || s === "recent-committers"
1249
+ );
1250
+ // Normalize assignees: strip a leading `@`, trim, and drop empties so an empty
1251
+ // login (e.g. config value of `"@"` or `""`) can never leak downstream into
1252
+ // `gh pr edit --add-assignee ""`.
1253
+ const assignees = list(hh?.assignees)
1254
+ .map((s) => s.replace(/^@/, "").trim())
1255
+ .filter((s) => s.length > 0);
1256
+ return {
1257
+ enabled,
1258
+ candidatesFrom: enabled ? candidatesFrom : [],
1259
+ assignees: enabled ? assignees : [],
1260
+ };
1261
+ }
1262
+
1056
1263
  export function resolveInternalPathPatterns(config) {
1057
1264
  if (
1058
1265
  config?.internalPathPatterns &&
@@ -49,6 +49,9 @@ gates:
49
49
  requireCi: true
50
50
  mandatoryAngles:
51
51
  - pr-description
52
+ # Gate findings comments live ON the PR (the local-first spec-of-record /
53
+ # human-review surface), so they are evidence, not tracker noise — keep them on.
54
+ postFindingsComments: true
52
55
  preApproval:
53
56
  angles:
54
57
  - dry
@@ -67,11 +70,27 @@ gates:
67
70
  required: true
68
71
  mandatoryAngles:
69
72
  - pr-checklist-matrix
73
+ # Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
74
+ # not production code, so it is intentionally lighter than the production
75
+ # draft -> pre-approval -> Copilot set: a small docs-first angle set, not a
76
+ # required gate, and no CI prerequisite. Resolved through the same
77
+ # config-merge layering and resolveGateConfig path as draft/preApproval; only
78
+ # applies to spike-mode work, so production gates are unaffected.
79
+ spike:
80
+ angles:
81
+ - scope
82
+ - docs
83
+ excludeAngles: []
84
+ required: false
85
+ requireCi: false
86
+ mandatoryAngles: []
70
87
 
71
88
  # Autonomy: only merge requires operator confirmation by default.
72
89
  autonomy:
73
90
  stopAt:
74
91
  - merge
92
+ # Local-first never auto-merges; a human always merges.
93
+ humanMergeOnly: true
75
94
 
76
95
  # Workflow enforcement defaults.
77
96
  workflow:
@@ -82,6 +101,11 @@ workflow:
82
101
  # repo-root .devloops, which takes precedence over these extension defaults.
83
102
  requireRetrospective: false
84
103
  requireRetrospectiveGate: false
104
+ # Internal-tooling-only retro check (#982) is a DEVELOPER-MODE step — the dev-loops
105
+ # maintainers' own dogfooding discipline. It must never block a consumer's state
106
+ # changes (consumers may legitimately use raw gh/python/node -e), so it ships OFF.
107
+ # The dev-loops repo opts in via its own repo-root .devloops (takes precedence here).
108
+ requireRetrospectiveInternalTooling: false
85
109
  requireDraftFirst: true
86
110
  # Dev mode is the dev-loop self-improvement mode — it edits the loop's own skill/agent prompts
87
111
  # after a phase, which is only meaningful in the dev-loops repo. Shipped defaults must not force
@@ -89,6 +113,11 @@ workflow:
89
113
  # via its own repo-root .devloops (which takes precedence over these extension defaults).
90
114
  devModeDefault: false
91
115
 
116
+ # Local-planning: where persisted markdown plan files (phase-doc format) live
117
+ # when work originates from a plan file rather than a tracker issue (#949).
118
+ localPlanning:
119
+ plansDir: docs/phases/
120
+
92
121
  # Light-mode threshold for small local changes.
93
122
  localImplementation:
94
123
  lightMode:
@@ -99,7 +128,9 @@ localImplementation:
99
128
  # Queue defaults (repo-specific projectNumber/boardTitle omitted by design).
100
129
  queue:
101
130
  maxParallel: 3
102
- maxAutoFiledIssues: 10
131
+ # Local-first is PR-first (issues are skipped, #952), so auto-filing issues is
132
+ # near-zero; a low cap keeps tracker noise minimal, especially early.
133
+ maxAutoFiledIssues: 1
103
134
  reDispatchMaxRetries: 1
104
135
 
105
136
  # Persona registry used by gate review angle resolution.
@@ -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