@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 +9 -1
- package/src/config/config.mjs +213 -6
- package/src/config/extension-defaults.yaml +32 -1
- 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/plan-file-intake-contract.mjs +56 -0
- package/src/loop/plan-file-promote-contract.mjs +234 -0
- package/src/loop/plan-file-refine-contract.mjs +229 -0
- package/src/loop/pr-gate-coordination.mjs +114 -9
- 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/spike-exit-contract.mjs +138 -0
- package/src/loop/spike-intake-contract.mjs +52 -0
- 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.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",
|
package/src/config/config.mjs
CHANGED
|
@@ -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
|
-
|
|
731
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|