@dev-loops/core 0.2.7 → 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 +7 -1
- package/src/config/config.mjs +245 -4
- package/src/config/extension-defaults.yaml +12 -0
- package/src/github/copilot-helpers.mjs +65 -0
- package/src/loop/async-start-contract.mjs +8 -27
- package/src/loop/gate-fanin.mjs +222 -0
- package/src/loop/handoff-envelope.mjs +50 -8
- package/src/loop/lifecycle-state.mjs +13 -1
- package/src/loop/pr-gate-coordination.mjs +183 -3
- package/src/loop/queue-board-ordering.mjs +5 -1
- package/src/loop/queue-board-sync.mjs +4 -1
- package/src/loop/queue-driver.mjs +25 -3
- package/src/loop/queue-membership.mjs +145 -0
- package/src/loop/queue-state.mjs +56 -1
- 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",
|
|
@@ -28,6 +31,7 @@
|
|
|
28
31
|
"./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
|
|
29
32
|
"./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
|
|
30
33
|
"./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
|
|
34
|
+
"./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
|
|
31
35
|
"./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
|
|
32
36
|
"./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
|
|
33
37
|
"./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
|
|
@@ -36,7 +40,9 @@
|
|
|
36
40
|
"./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
|
|
37
41
|
"./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
|
|
38
42
|
"./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
|
|
43
|
+
"./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
|
|
39
44
|
"./loop/queue-driver": "./src/loop/queue-driver.mjs",
|
|
45
|
+
"./loop/queue-membership": "./src/loop/queue-membership.mjs",
|
|
40
46
|
"./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
|
|
41
47
|
"./loop/queue-state": "./src/loop/queue-state.mjs",
|
|
42
48
|
"./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
|
package/src/config/config.mjs
CHANGED
|
@@ -53,12 +53,52 @@ 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
|
+
// Fail-closed enforcement that a gate verdict was produced by the
|
|
57
|
+
// fan-out/fan-in review sub-loop (executionMode === "fanout_fanin" plus a
|
|
58
|
+
// durable findings-log ledger), not an inline single-agent run. Default
|
|
59
|
+
// true (opt-out): a clean gate verdict requires fan-out/fan-in evidence
|
|
60
|
+
// unless explicitly disabled. See docs/gate-review-sub-loop-contract.md.
|
|
61
|
+
requireFanoutEvidence: z.boolean().default(true),
|
|
62
|
+
// Cap on how many scoped `review` reviewers the gate fan-out spawns in
|
|
63
|
+
// parallel. When the resolved angle set exceeds this cap, the overflow runs
|
|
64
|
+
// in sequential batches and the degradation is recorded in the gate evidence.
|
|
65
|
+
maxFanoutReviewers: z.number().int().min(1).max(64).default(8),
|
|
66
|
+
// Post the consolidated gate fan-out findings as a visible, marker-tagged PR
|
|
67
|
+
// comment so they are auditable and Copilot/humans are aware of them. Default
|
|
68
|
+
// true (opt-out). The disposition ledger is written regardless; this flag only
|
|
69
|
+
// suppresses the PR comment when explicitly false. See
|
|
70
|
+
// docs/gate-review-sub-loop-contract.md.
|
|
71
|
+
postFindingsComments: z.boolean().default(true),
|
|
56
72
|
});
|
|
57
73
|
|
|
58
74
|
const AutonomyConfig = z.strictObject({
|
|
59
75
|
stopAt: z.array(
|
|
60
76
|
z.enum(["refinement", "draft-pr", "pre-approval", "merge"])
|
|
61
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(),
|
|
62
102
|
});
|
|
63
103
|
|
|
64
104
|
const WorkflowConfig = z.strictObject({
|
|
@@ -85,6 +125,19 @@ const QueueConfig = z.strictObject({
|
|
|
85
125
|
reDispatchMaxRetries: z.number().int().min(0).max(10).default(1),
|
|
86
126
|
projectNumber: z.number().int().positive().optional(),
|
|
87
127
|
boardTitle: z.string().trim().min(1).optional(),
|
|
128
|
+
archiveOlderThanDays: z.number().int().positive().optional(),
|
|
129
|
+
});
|
|
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(),
|
|
88
141
|
});
|
|
89
142
|
|
|
90
143
|
/** Internal path whitelist for internal-only PR detection — flat array of regex strings */
|
|
@@ -106,6 +159,9 @@ const FileGateConfig = GateConfig.partial();
|
|
|
106
159
|
const FileGatesConfig = z.strictObject({
|
|
107
160
|
draft: FileGateConfig.optional(),
|
|
108
161
|
preApproval: FileGateConfig.optional(),
|
|
162
|
+
requireFanoutEvidence: z.boolean().optional(),
|
|
163
|
+
maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
|
|
164
|
+
postFindingsComments: z.boolean().optional(),
|
|
109
165
|
});
|
|
110
166
|
|
|
111
167
|
// Partial persona entries for file-level config (allows omitting fields)
|
|
@@ -127,11 +183,13 @@ export const DevLoopConfigSchema = z.strictObject({
|
|
|
127
183
|
refinement: RefinementConfig.optional(),
|
|
128
184
|
gates: GatesConfig.optional(),
|
|
129
185
|
autonomy: AutonomyConfig.optional(),
|
|
186
|
+
approval: ApprovalConfig.optional(),
|
|
130
187
|
workflow: WorkflowConfig.optional(),
|
|
131
188
|
localImplementation: LocalImplementationConfig.optional(),
|
|
132
189
|
queue: QueueConfig.optional(),
|
|
133
190
|
personas: PersonasConfig.optional(),
|
|
134
191
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
192
|
+
worktree: WorktreeConfig.optional(),
|
|
135
193
|
});
|
|
136
194
|
|
|
137
195
|
// ============================================================================
|
|
@@ -145,7 +203,14 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
145
203
|
models: Object.freeze({}),
|
|
146
204
|
refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, stopOnLowSignal: false, lowSignalRoundThreshold: 3, lowSignalMaxComments: 2 }),
|
|
147
205
|
gates: Object.freeze({}),
|
|
148
|
-
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
|
+
}),
|
|
149
214
|
workflow: Object.freeze({
|
|
150
215
|
asyncStartMode: "required",
|
|
151
216
|
requireRetrospective: false,
|
|
@@ -173,6 +238,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
173
238
|
"^\\.github/",
|
|
174
239
|
"^test/",
|
|
175
240
|
]),
|
|
241
|
+
worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
|
|
176
242
|
});
|
|
177
243
|
|
|
178
244
|
// ============================================================================
|
|
@@ -187,11 +253,13 @@ export const FileConfigSchema = z.strictObject({
|
|
|
187
253
|
refinement: RefinementConfig.partial().optional(),
|
|
188
254
|
gates: FileGatesConfig.optional(),
|
|
189
255
|
autonomy: AutonomyConfig.partial().optional(),
|
|
256
|
+
approval: ApprovalConfig.partial().optional(),
|
|
190
257
|
workflow: WorkflowConfig.partial().optional(),
|
|
191
258
|
localImplementation: LocalImplementationConfig.partial().optional(),
|
|
192
259
|
queue: QueueConfig.partial().optional(),
|
|
193
260
|
personas: FilePersonasConfig.optional(),
|
|
194
261
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
262
|
+
worktree: WorktreeConfig.partial().optional(),
|
|
195
263
|
});
|
|
196
264
|
|
|
197
265
|
// ============================================================================
|
|
@@ -230,6 +298,8 @@ const BUILTIN_PERSONAS = Object.freeze({
|
|
|
230
298
|
"state-concurrency": { persona: "review", defaultModel: null },
|
|
231
299
|
"renderer-security": { persona: "review", defaultModel: null },
|
|
232
300
|
determinism: { persona: "review", defaultModel: null },
|
|
301
|
+
"acceptance-criteria": { persona: "review", defaultModel: null },
|
|
302
|
+
"ac-dod": { persona: "review", defaultModel: null },
|
|
233
303
|
});
|
|
234
304
|
|
|
235
305
|
const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
|
|
@@ -705,10 +775,66 @@ export function resolveConductorModel(config) {
|
|
|
705
775
|
* @returns {string[]}
|
|
706
776
|
*/
|
|
707
777
|
export function resolveAutonomyStopAt(config) {
|
|
708
|
-
|
|
709
|
-
|
|
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");
|
|
710
785
|
}
|
|
711
|
-
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);
|
|
712
838
|
}
|
|
713
839
|
|
|
714
840
|
const DEFAULT_REFINEMENT_CONFIG = BUILT_IN_DEFAULTS.refinement;
|
|
@@ -814,6 +940,66 @@ export function resolveGateConfig(config, gate) {
|
|
|
814
940
|
};
|
|
815
941
|
}
|
|
816
942
|
|
|
943
|
+
/**
|
|
944
|
+
* Resolve whether fan-out/fan-in review evidence is required for a gate verdict.
|
|
945
|
+
*
|
|
946
|
+
* Default-on (opt-out): enforcement is ON unless `gates.requireFanoutEvidence`
|
|
947
|
+
* is explicitly set to false. When ON, the pre-merge evidence check fails
|
|
948
|
+
* closed unless a required gate's recorded executionMode is "fanout_fanin" and
|
|
949
|
+
* a durable findings-log ledger exists for that gate + head SHA. Using a
|
|
950
|
+
* `!== false` test (rather than `=== true`) keeps the opt-out semantics robust
|
|
951
|
+
* for programmatically-built config objects that bypass schema defaulting. See
|
|
952
|
+
* docs/gate-review-sub-loop-contract.md.
|
|
953
|
+
*
|
|
954
|
+
* @param {DevLoopConfig} config
|
|
955
|
+
* @returns {boolean}
|
|
956
|
+
*/
|
|
957
|
+
export function resolveRequireFanoutEvidence(config) {
|
|
958
|
+
return config?.gates?.requireFanoutEvidence !== false;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Default parallel fan-out reviewer cap (mirrors GatesConfig.maxFanoutReviewers). */
|
|
962
|
+
export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Resolve the parallel fan-out reviewer cap for the gate sub-loop.
|
|
966
|
+
*
|
|
967
|
+
* Returns the configured `gates.maxFanoutReviewers` when it is an integer in
|
|
968
|
+
* the schema-bounded range 1..64; otherwise the built-in default (8). Clamping
|
|
969
|
+
* here (not just the Zod schema) keeps programmatically-constructed config
|
|
970
|
+
* objects that bypass schema validation within the same bound. The fan-out
|
|
971
|
+
* spawns at most this many scoped `review` reviewers in parallel; overflow runs
|
|
972
|
+
* sequentially.
|
|
973
|
+
*
|
|
974
|
+
* @param {DevLoopConfig} config
|
|
975
|
+
* @returns {number}
|
|
976
|
+
*/
|
|
977
|
+
export function resolveMaxFanoutReviewers(config) {
|
|
978
|
+
const raw = config?.gates?.maxFanoutReviewers;
|
|
979
|
+
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 64) {
|
|
980
|
+
return raw;
|
|
981
|
+
}
|
|
982
|
+
return DEFAULT_MAX_FANOUT_REVIEWERS;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Resolve whether the consolidated gate fan-out findings should be posted as a
|
|
987
|
+
* visible, marker-tagged PR comment.
|
|
988
|
+
*
|
|
989
|
+
* Returns true (post the comment) unless `gates.postFindingsComments` is
|
|
990
|
+
* explicitly set to false. Using a `!== false` test (rather than `=== true`)
|
|
991
|
+
* keeps the opt-out semantics robust for programmatically-built config objects
|
|
992
|
+
* that bypass schema defaulting. The disposition ledger is written regardless;
|
|
993
|
+
* this flag only suppresses the auditable PR comment. See
|
|
994
|
+
* docs/gate-review-sub-loop-contract.md.
|
|
995
|
+
*
|
|
996
|
+
* @param {DevLoopConfig} config
|
|
997
|
+
* @returns {boolean}
|
|
998
|
+
*/
|
|
999
|
+
export function resolveGatePostFindingsComments(config) {
|
|
1000
|
+
return config?.gates?.postFindingsComments !== false;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
817
1003
|
/**
|
|
818
1004
|
* Resolve local implementation light mode config.
|
|
819
1005
|
*
|
|
@@ -971,6 +1157,61 @@ const DEFAULT_INTERNAL_PATH_PATTERNS = BUILT_IN_DEFAULTS.internalPathPatterns;
|
|
|
971
1157
|
* @param {DevLoopConfig} config
|
|
972
1158
|
* @returns {string[]}
|
|
973
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
|
+
|
|
974
1215
|
export function resolveInternalPathPatterns(config) {
|
|
975
1216
|
if (
|
|
976
1217
|
config?.internalPathPatterns &&
|
|
@@ -382,6 +382,18 @@ personas:
|
|
|
382
382
|
Do not block on formatting preferences other than checkbox correctness.
|
|
383
383
|
defaultModel: null
|
|
384
384
|
|
|
385
|
+
acceptance-criteria:
|
|
386
|
+
persona: review
|
|
387
|
+
prompt: >-
|
|
388
|
+
Verify that each acceptance criterion and definition-of-done item from the
|
|
389
|
+
linked issue/PR is actually satisfied by the implementation — not merely
|
|
390
|
+
listed. For every criterion, cite the concrete code/test/behavior evidence
|
|
391
|
+
that meets it; flag any criterion that is unmet, only partially met, or
|
|
392
|
+
unverifiable from the diff as a blocking finding. Confirm definition-of-done
|
|
393
|
+
items (tests, docs, validation) are done and that declared non-goals are
|
|
394
|
+
respected (no scope creep).
|
|
395
|
+
defaultModel: null
|
|
396
|
+
|
|
385
397
|
pr-checklist-matrix:
|
|
386
398
|
persona: review
|
|
387
399
|
prompt: >-
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
10
10
|
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
11
11
|
const GATE_REVIEW_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
|
|
12
|
+
const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
|
|
12
13
|
|
|
13
14
|
export function isCopilotLogin(login) {
|
|
14
15
|
return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
|
|
@@ -63,6 +64,11 @@ function normalizeGateReviewHeadSha(value) {
|
|
|
63
64
|
return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
function normalizeGateExecutionMode(value) {
|
|
68
|
+
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
69
|
+
return GATE_EXECUTION_MODES.has(normalized) ? normalized : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
function parseGateReviewCommentFields(body) {
|
|
67
73
|
if (typeof body !== "string" || body.trim().length === 0) {
|
|
68
74
|
return null;
|
|
@@ -74,6 +80,8 @@ function parseGateReviewCommentFields(body) {
|
|
|
74
80
|
verdict: null,
|
|
75
81
|
findingsSummary: null,
|
|
76
82
|
nextAction: null,
|
|
83
|
+
executionMode: null,
|
|
84
|
+
inlineReason: null,
|
|
77
85
|
};
|
|
78
86
|
|
|
79
87
|
for (const rawLine of body.split(/\r?\n/u)) {
|
|
@@ -112,6 +120,24 @@ function parseGateReviewCommentFields(body) {
|
|
|
112
120
|
fields.nextAction = match[1].trim();
|
|
113
121
|
continue;
|
|
114
122
|
}
|
|
123
|
+
|
|
124
|
+
match = line.match(/^(?:[-*]\s*)?execution\s+mode\s*:\s*(.+)$/iu);
|
|
125
|
+
if (match) {
|
|
126
|
+
const rest = match[1].trim();
|
|
127
|
+
// Split on the first em-dash / en-dash / " - " separator to recover an
|
|
128
|
+
// optional inline reason: "inline_single_agent — <reason>".
|
|
129
|
+
const sepMatch = rest.match(/^(.*?)\s*(?:[—–]|\s-\s)\s*(.*)$/u);
|
|
130
|
+
const modeToken = sepMatch ? sepMatch[1].trim() : rest;
|
|
131
|
+
const reasonToken = sepMatch ? sepMatch[2].trim() : "";
|
|
132
|
+
fields.executionMode = normalizeGateExecutionMode(modeToken);
|
|
133
|
+
// Only record an inline reason for inline_single_agent. A trailing
|
|
134
|
+
// "— text" on a fanout_fanin (or invalid) mode line must not surface an
|
|
135
|
+
// inconsistent mode/reason pair, so leave inlineReason null otherwise.
|
|
136
|
+
if (reasonToken.length > 0 && fields.executionMode === "inline_single_agent") {
|
|
137
|
+
fields.inlineReason = reasonToken;
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
115
141
|
}
|
|
116
142
|
|
|
117
143
|
// Lenient fallback: detect gate name and head SHA anywhere in body
|
|
@@ -178,6 +204,8 @@ export function parseGateReviewCommentMarkerBody(body) {
|
|
|
178
204
|
verdict: fields.verdict,
|
|
179
205
|
findingsSummary: fields.findingsSummary,
|
|
180
206
|
nextAction: fields.nextAction,
|
|
207
|
+
executionMode: fields.executionMode,
|
|
208
|
+
inlineReason: fields.inlineReason,
|
|
181
209
|
contractComplete: Boolean(fields.verdict && fields.findingsSummary && fields.nextAction),
|
|
182
210
|
};
|
|
183
211
|
}
|
|
@@ -205,6 +233,8 @@ export function summarizeGateReviewComments(comments) {
|
|
|
205
233
|
verdict: parsed.verdict,
|
|
206
234
|
findingsSummary: parsed.findingsSummary,
|
|
207
235
|
nextAction: parsed.nextAction,
|
|
236
|
+
executionMode: parsed.executionMode ?? null,
|
|
237
|
+
inlineReason: parsed.inlineReason ?? null,
|
|
208
238
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
209
239
|
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
210
240
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
@@ -253,6 +283,8 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
253
283
|
verdict: parsed.verdict,
|
|
254
284
|
findingsSummary: parsed.findingsSummary,
|
|
255
285
|
nextAction: parsed.nextAction,
|
|
286
|
+
executionMode: parsed.executionMode ?? null,
|
|
287
|
+
inlineReason: parsed.inlineReason ?? null,
|
|
256
288
|
contractComplete: parsed.contractComplete,
|
|
257
289
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
258
290
|
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
@@ -274,6 +306,39 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
274
306
|
return summary;
|
|
275
307
|
}
|
|
276
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Resolve the draft-gate round-reset timestamp (ms) used to suppress stale Copilot
|
|
311
|
+
* review rounds from the count (#896 consistency).
|
|
312
|
+
*
|
|
313
|
+
* When the draft gate was re-passed clean on a DIFFERENT head than the current one,
|
|
314
|
+
* only Copilot reviews submitted after that re-pass should count toward the round
|
|
315
|
+
* cap. Returning the re-pass `updatedAt` (ms) lets {@link summarizeCopilotReviews}
|
|
316
|
+
* drop earlier rounds. Returns null when no reset applies (no clean draft gate, or
|
|
317
|
+
* the clean draft gate is already on the current head).
|
|
318
|
+
*
|
|
319
|
+
* Both detect-pr-gate-coordination-state and request-copilot-review must derive the
|
|
320
|
+
* reset identically, or the two scripts disagree on the completed round count and
|
|
321
|
+
* the cap (the inconsistency reported in #896). This is the single shared source.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} params
|
|
324
|
+
* @param {{ verdict?: string|null, headSha?: string|null, updatedAt?: string|null }|null} params.draftGate
|
|
325
|
+
* @param {string|null} params.currentHeadSha
|
|
326
|
+
* @returns {number|null} reset timestamp in ms, or null
|
|
327
|
+
*/
|
|
328
|
+
export function resolveDraftGateRoundResetMs({ draftGate, currentHeadSha } = {}) {
|
|
329
|
+
const draftGateHeadSha = typeof draftGate?.headSha === "string" ? draftGate.headSha : null;
|
|
330
|
+
const draftGateOnCurrentHead = typeof draftGateHeadSha === "string"
|
|
331
|
+
&& typeof currentHeadSha === "string"
|
|
332
|
+
&& currentHeadSha.startsWith(draftGateHeadSha);
|
|
333
|
+
if (draftGate?.verdict === "clean"
|
|
334
|
+
&& typeof draftGateHeadSha === "string"
|
|
335
|
+
&& !draftGateOnCurrentHead
|
|
336
|
+
&& typeof draftGate?.updatedAt === "string") {
|
|
337
|
+
return normalizeTimestamp(draftGate.updatedAt);
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
277
342
|
export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs } = {}) {
|
|
278
343
|
const allReviews = Array.isArray(reviews) ? reviews : [];
|
|
279
344
|
const copilotReviews = allReviews.filter((review) => isCopilotLogin(review?.author?.login));
|
|
@@ -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
|
};
|