@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 +4 -1
- package/src/config/config.mjs +163 -4
- package/src/loop/async-start-contract.mjs +8 -27
- package/src/loop/handoff-envelope.mjs +50 -8
- package/src/loop/lifecycle-state.mjs +13 -1
- package/src/loop/queue-board-ordering.mjs +5 -1
- package/src/loop/queue-driver.mjs +25 -3
- package/src/loop/run-context.mjs +9 -16
- package/src/loop/worktree-guard.mjs +6 -16
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "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",
|
package/src/config/config.mjs
CHANGED
|
@@ -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
|
-
|
|
731
|
-
|
|
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
|
|
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
|
|
14
|
-
*
|
|
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
|
|
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
|
|
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 (
|
|
134
|
-
for (const marker of
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
247
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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) {
|
package/src/loop/run-context.mjs
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Neutral run-id / async-context contract.
|
|
3
3
|
*
|
|
4
|
-
* The dev-loop async path
|
|
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)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
20
|
+
* The neutral `DEVLOOPS_RUN_ID` is the sole marker.
|
|
25
21
|
*/
|
|
26
|
-
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_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
|
|
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
|
|
158
|
-
*
|
|
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 `
|
|
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
|
-
|
|
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.
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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}
|