@dev-loops/core 0.7.1 → 0.8.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 +6 -1
- package/src/claude/hook-decisions.mjs +5 -4
- package/src/config/config.mjs +80 -1
- package/src/config/extension-defaults.yaml +6 -0
- package/src/github/copilot-helpers.mjs +143 -0
- package/src/loop/bash-command-classify.mjs +7 -6
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/handoff-envelope.mjs +3 -0
- package/src/loop/issue-refinement-artifact.mjs +70 -10
- package/src/loop/plan-file-promote-contract.mjs +23 -1
- package/src/loop/pr-gate-coordination.mjs +129 -2
- package/src/loop/pr-lifecycle.mjs +79 -0
- package/src/loop/queue-board-ordering.mjs +1 -1
- package/src/loop/queue-board-sync.mjs +1 -1
- package/src/loop/refinement-grill-state.mjs +173 -0
- package/src/loop/reviewer-loop-state.mjs +20 -2
- package/src/projects/list-queue-items.mjs +380 -0
- package/src/projects/move-queue-item.mjs +394 -0
- package/src/projects/resolve-project.mjs +183 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -38,8 +38,10 @@
|
|
|
38
38
|
"./loop/plan-file-promote-contract": "./src/loop/plan-file-promote-contract.mjs",
|
|
39
39
|
"./loop/plan-file-refine-contract": "./src/loop/plan-file-refine-contract.mjs",
|
|
40
40
|
"./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
|
|
41
|
+
"./loop/pr-lifecycle": "./src/loop/pr-lifecycle.mjs",
|
|
41
42
|
"./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
|
|
42
43
|
"./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
|
|
44
|
+
"./loop/refinement-grill-state": "./src/loop/refinement-grill-state.mjs",
|
|
43
45
|
"./loop/queue-board-ordering": "./src/loop/queue-board-ordering.mjs",
|
|
44
46
|
"./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
|
|
45
47
|
"./loop/queue-driver": "./src/loop/queue-driver.mjs",
|
|
@@ -55,6 +57,9 @@
|
|
|
55
57
|
"./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
|
|
56
58
|
"./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
|
|
57
59
|
"./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
|
|
60
|
+
"./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
|
|
61
|
+
"./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
|
|
62
|
+
"./projects/resolve-project": "./src/projects/resolve-project.mjs",
|
|
58
63
|
"./harness": "./src/harness/index.mjs",
|
|
59
64
|
"./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
|
|
60
65
|
"./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
|
|
@@ -61,7 +61,7 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
|
|
|
61
61
|
* clean current-head draft_gate + pre_approval_gate). The loop runs this check before merging;
|
|
62
62
|
* gating it here closes the hole where a hand-run `gh pr merge` skips the pre-approval gate
|
|
63
63
|
* entirely. Everything else passes through.
|
|
64
|
-
* - raw `gh issue create` / `gh issue comment` / `gh pr comment` — blocked ONLY when the call
|
|
64
|
+
* - raw `gh issue create` / `gh issue comment` / `gh issue edit` / `gh pr comment` — blocked ONLY when the call
|
|
65
65
|
* originates from a SUBAGENT context (`agentType` is a non-null string) and targets the repo.
|
|
66
66
|
* Sanctioned external writes flow through node wrappers (gate-verdict comments via
|
|
67
67
|
* `upsert-checkpoint-verdict.mjs`, review replies via `reply-resolve*.mjs`, board sync,
|
|
@@ -85,7 +85,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
85
85
|
return ALLOW;
|
|
86
86
|
}
|
|
87
87
|
// Subagent-scoped external-write guard: block ad-hoc `gh issue create`/`gh issue comment`/
|
|
88
|
-
// `gh pr comment` on the target repo from a subagent, so external writes flow through the
|
|
88
|
+
// `gh issue edit`/`gh pr comment` on the target repo from a subagent, so external writes flow through the
|
|
89
89
|
// sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected (#1051).
|
|
90
90
|
if (typeof agentType === "string" && commandContainsRawExternalWrite(command)) {
|
|
91
91
|
const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
|
|
@@ -101,9 +101,10 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
101
101
|
return {
|
|
102
102
|
decision: "deny",
|
|
103
103
|
reason:
|
|
104
|
-
"Ad-hoc GitHub issue/PR creation and
|
|
104
|
+
"Ad-hoc GitHub issue/PR creation, comments, and edits from a subagent are blocked. Use the sanctioned " +
|
|
105
105
|
"node wrappers instead — gate-verdict comments via scripts/github/upsert-checkpoint-verdict.mjs, " +
|
|
106
|
-
"review-thread replies via scripts/github/reply-resolve*.mjs, board sync,
|
|
106
|
+
"review-thread replies via scripts/github/reply-resolve*.mjs, board sync, issue comments via " +
|
|
107
|
+
"scripts/github/comment-issue.mjs, or issue-body edits via scripts/github/edit-issue.mjs. " +
|
|
107
108
|
"Direct `gh issue create` is reserved for the main agent / operator.",
|
|
108
109
|
};
|
|
109
110
|
}
|
package/src/config/config.mjs
CHANGED
|
@@ -98,6 +98,12 @@ const GatesConfig = z.strictObject({
|
|
|
98
98
|
// and every angle configured across this config's own draft/preApproval/
|
|
99
99
|
// spike gates (angles + mandatoryAngles).
|
|
100
100
|
anglePool: z.array(z.string().trim().min(1)).optional(),
|
|
101
|
+
// Fail-closed enforcement that a fanout_fanin gate's recorded per-angle
|
|
102
|
+
// provenance names only angles in the gate's configured pool (angles +
|
|
103
|
+
// mandatoryAngles) — ad-hoc/foreign angle labels are rejected rather than
|
|
104
|
+
// silently accepted. Default true (reject); set false to warn instead of
|
|
105
|
+
// fail. See resolveRejectForeignAngles / docs/gate-review-sub-loop-contract.md.
|
|
106
|
+
rejectForeignAngles: z.boolean().default(true),
|
|
101
107
|
});
|
|
102
108
|
|
|
103
109
|
const AutonomyConfig = z.strictObject({
|
|
@@ -143,6 +149,10 @@ const LocalImplementationConfig = z.strictObject({
|
|
|
143
149
|
enabled: z.boolean(),
|
|
144
150
|
maxFiles: z.number().int().min(1),
|
|
145
151
|
maxLines: z.number().int().min(1),
|
|
152
|
+
// Copilot review round cap for light-dispatched PRs (#1210). Composes with
|
|
153
|
+
// (does not replace) refinement.maxCopilotRounds — see
|
|
154
|
+
// resolveEffectiveCopilotRoundCap.
|
|
155
|
+
maxCopilotRounds: z.number().int().nonnegative().default(1),
|
|
146
156
|
}).optional(),
|
|
147
157
|
});
|
|
148
158
|
|
|
@@ -193,6 +203,7 @@ const FileGatesConfig = z.strictObject({
|
|
|
193
203
|
maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
|
|
194
204
|
postFindingsComments: z.boolean().optional(),
|
|
195
205
|
anglePool: z.array(z.string().trim().min(1)).optional(),
|
|
206
|
+
rejectForeignAngles: z.boolean().optional(),
|
|
196
207
|
});
|
|
197
208
|
|
|
198
209
|
// Partial persona entries for file-level config (allows omitting fields)
|
|
@@ -252,7 +263,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
252
263
|
devModeDefault: false,
|
|
253
264
|
}),
|
|
254
265
|
localImplementation: Object.freeze({
|
|
255
|
-
lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200 }),
|
|
266
|
+
lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200, maxCopilotRounds: 1 }),
|
|
256
267
|
}),
|
|
257
268
|
queue: Object.freeze({
|
|
258
269
|
maxParallel: 3,
|
|
@@ -1021,6 +1032,17 @@ export function resolveRequireFanoutProvenance(config) {
|
|
|
1021
1032
|
return config?.gates?.requireFanoutProvenance === true;
|
|
1022
1033
|
}
|
|
1023
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* Resolve whether a fan-out provenance entry naming an angle outside the
|
|
1037
|
+
* gate's configured pool should FAIL (default) or only WARN.
|
|
1038
|
+
*
|
|
1039
|
+
* @param {DevLoopConfig} config
|
|
1040
|
+
* @returns {boolean}
|
|
1041
|
+
*/
|
|
1042
|
+
export function resolveRejectForeignAngles(config) {
|
|
1043
|
+
return config?.gates?.rejectForeignAngles !== false;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1024
1046
|
/**
|
|
1025
1047
|
* Resolve whether the consolidated gate fan-out findings should be posted as a
|
|
1026
1048
|
* visible, marker-tagged PR comment.
|
|
@@ -1061,6 +1083,33 @@ export function resolveLightMode(config) {
|
|
|
1061
1083
|
};
|
|
1062
1084
|
}
|
|
1063
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* Resolve the effective Copilot review round cap for a PR (#1210).
|
|
1088
|
+
*
|
|
1089
|
+
* Full PRs (lightweight=false) use `refinement.maxCopilotRounds` unchanged
|
|
1090
|
+
* (default 5). Light-dispatched PRs compose with it rather than replacing it:
|
|
1091
|
+
* `effective = min(localImplementation.lightMode.maxCopilotRounds ?? 1,
|
|
1092
|
+
* refinement.maxCopilotRounds)` — so setting `refinement.maxCopilotRounds: 0`
|
|
1093
|
+
* disables Copilot rounds everywhere, including lightweight, with that one
|
|
1094
|
+
* setting.
|
|
1095
|
+
*
|
|
1096
|
+
* @param {DevLoopConfig} config
|
|
1097
|
+
* @param {{ lightweight?: boolean }} [options]
|
|
1098
|
+
* @returns {number}
|
|
1099
|
+
*/
|
|
1100
|
+
export function resolveEffectiveCopilotRoundCap(config, { lightweight = false } = {}) {
|
|
1101
|
+
// Clamp here, not only in the zod schema: programmatically-built config
|
|
1102
|
+
// objects bypass schema defaulting/validation, and a negative cap must never
|
|
1103
|
+
// reach round-cap comparisons.
|
|
1104
|
+
const maxCopilotRounds = Math.max(0, /** @type {number} */ (resolveRefinementConfig(config, "maxCopilotRounds")));
|
|
1105
|
+
if (!lightweight) return maxCopilotRounds;
|
|
1106
|
+
const lightMaxRounds = config?.localImplementation?.lightMode?.maxCopilotRounds;
|
|
1107
|
+
const effectiveLightCap = typeof lightMaxRounds === "number" && Number.isFinite(lightMaxRounds)
|
|
1108
|
+
? Math.max(0, lightMaxRounds)
|
|
1109
|
+
: 1;
|
|
1110
|
+
return Math.min(effectiveLightCap, maxCopilotRounds);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1064
1113
|
/** Label that forces full fan-out regardless of change size. */
|
|
1065
1114
|
export const GATE_FULL_LABEL = "gate:full";
|
|
1066
1115
|
|
|
@@ -1161,6 +1210,36 @@ export function resolveAnglePool(config) {
|
|
|
1161
1210
|
return [...new Set([...Object.keys(BUILTIN_PERSONAS), ...configured])];
|
|
1162
1211
|
}
|
|
1163
1212
|
|
|
1213
|
+
/**
|
|
1214
|
+
* Resolve a gate's ANGLE ENFORCEMENT CONTRACT: the mandatory angles a
|
|
1215
|
+
* fanout_fanin verdict must cover and the pool its recorded angles must stay
|
|
1216
|
+
* within. Single source of truth for all angle-coverage enforcement consumers
|
|
1217
|
+
* (ledger write, verdict-comment write, merge-evidence read) so they agree.
|
|
1218
|
+
*
|
|
1219
|
+
* - `mandatoryAngles` is filtered through `excludeAngles`: a config that
|
|
1220
|
+
* excludes a mandatory angle must not deadlock every fanout write (the
|
|
1221
|
+
* angle would be missing-mandatory if omitted yet foreign if recorded).
|
|
1222
|
+
* - `pool` is `resolveGateAngles` (configured angles ∪ mandatoryAngles, minus
|
|
1223
|
+
* excludeAngles); when `additiveAngles` is enabled it widens to the global
|
|
1224
|
+
* lens catalog (`resolveAnglePool`) too — dynamic resolution may
|
|
1225
|
+
* legitimately dispatch catalog angles then — with `excludeAngles` still a
|
|
1226
|
+
* hard ceiling. A null pool skips the foreign-angle check entirely.
|
|
1227
|
+
*
|
|
1228
|
+
* @param {DevLoopConfig} config
|
|
1229
|
+
* @param {"draft"|"preApproval"|"spike"} gate
|
|
1230
|
+
* @returns {{ mandatoryAngles: string[], pool: string[]|null }}
|
|
1231
|
+
*/
|
|
1232
|
+
export function resolveGateAngleContract(config, gate) {
|
|
1233
|
+
const gateConfig = resolveGateConfig(config, gate);
|
|
1234
|
+
const excluded = new Set(gateConfig.excludeAngles);
|
|
1235
|
+
const mandatoryAngles = gateConfig.mandatoryAngles.filter((a) => !excluded.has(a));
|
|
1236
|
+
let pool = resolveGateAngles(config, gate);
|
|
1237
|
+
if (gateConfig.additiveAngles && pool !== null) {
|
|
1238
|
+
pool = [...new Set([...pool, ...resolveAnglePool(config)])].filter((a) => !excluded.has(a));
|
|
1239
|
+
}
|
|
1240
|
+
return { mandatoryAngles, pool };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1164
1243
|
/**
|
|
1165
1244
|
* Resolve gate angles dynamically when `dynamicAngles` is enabled in config.
|
|
1166
1245
|
*
|
|
@@ -44,6 +44,9 @@ gates:
|
|
|
44
44
|
- renderer-security
|
|
45
45
|
- determinism
|
|
46
46
|
- pr-comments
|
|
47
|
+
- contradiction-lens
|
|
48
|
+
- code-conformance
|
|
49
|
+
- semantic-drift
|
|
47
50
|
excludeAngles: []
|
|
48
51
|
required: true
|
|
49
52
|
requireCi: true
|
|
@@ -66,6 +69,9 @@ gates:
|
|
|
66
69
|
- dip
|
|
67
70
|
- docs
|
|
68
71
|
- pr-checklist-matrix
|
|
72
|
+
- contradiction-lens
|
|
73
|
+
- correctness-final
|
|
74
|
+
- ui-validation
|
|
69
75
|
excludeAngles: []
|
|
70
76
|
required: true
|
|
71
77
|
mandatoryAngles:
|
|
@@ -15,6 +15,149 @@ export function isCopilotLogin(login) {
|
|
|
15
15
|
return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Anti-summon literal: bare-text `@copilot` or a `/copilot*` slash command. Both
|
|
19
|
+
// the write-side sanitizer and the read-side guard scan key off this shape so a
|
|
20
|
+
// gate-evidence comment can quote the rule (inside a code span/fenced block)
|
|
21
|
+
// without arming the request-copilot-review.mjs anti-summon guard. The token
|
|
22
|
+
// regex carries the same left word-boundary as the guard regex so the sanitizer
|
|
23
|
+
// never mangles text the guard would not arm on (e.g. user@copilot.example).
|
|
24
|
+
const COPILOT_SUMMON_TOKEN_RE = /(?<=^|\W)(@copilot|\/copilot[a-z0-9_-]*)/gi;
|
|
25
|
+
const COPILOT_SUMMON_WORD_BOUNDARY_RE = /(?:^|\W)(@copilot|\/copilot)(?:$|\W)/i;
|
|
26
|
+
// GFM inline code span: an N-backtick run, lazy content, closed by a same-length
|
|
27
|
+
// run. Covers single-backtick spans as well as double-backtick spans wrapping a
|
|
28
|
+
// literal backtick.
|
|
29
|
+
const INLINE_CODE_SPAN_RE = /(`+)[\s\S]*?\1(?!`)/g;
|
|
30
|
+
const ZERO_WIDTH_JOINER = "\u200D";
|
|
31
|
+
|
|
32
|
+
// Apply `transformLine` to every markdown line OUTSIDE a fenced code block
|
|
33
|
+
// (```/~~~), leaving fence-delimiter lines and fenced content untouched.
|
|
34
|
+
// Mirrors the fenced-block tracking scripts/docs/validate-rule-ownership.mjs
|
|
35
|
+
// uses for its own lexical scan.
|
|
36
|
+
function transformNonFencedLines(text, transformLine) {
|
|
37
|
+
const lines = String(text).split(/\r?\n/);
|
|
38
|
+
let inFencedBlock = false;
|
|
39
|
+
let fencedDelimiter = "";
|
|
40
|
+
const transformed = lines.map((line) => {
|
|
41
|
+
const rawTrimmed = line.trim();
|
|
42
|
+
const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
|
|
43
|
+
if (fenceMatch) {
|
|
44
|
+
if (!inFencedBlock) {
|
|
45
|
+
inFencedBlock = true;
|
|
46
|
+
fencedDelimiter = fenceMatch[1];
|
|
47
|
+
return line;
|
|
48
|
+
}
|
|
49
|
+
if (rawTrimmed.startsWith(fencedDelimiter)) {
|
|
50
|
+
inFencedBlock = false;
|
|
51
|
+
fencedDelimiter = "";
|
|
52
|
+
return line;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (inFencedBlock) {
|
|
56
|
+
return line;
|
|
57
|
+
}
|
|
58
|
+
return transformLine(line);
|
|
59
|
+
});
|
|
60
|
+
return transformed.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Apply `replaceSegment` to every part of a line that lies OUTSIDE an inline
|
|
64
|
+
// code span (any N-backtick GFM span), leaving span content untouched.
|
|
65
|
+
function transformOutsideSpans(line, replaceSegment) {
|
|
66
|
+
let result = "";
|
|
67
|
+
let last = 0;
|
|
68
|
+
for (const span of line.matchAll(INLINE_CODE_SPAN_RE)) {
|
|
69
|
+
result += replaceSegment(line.slice(last, span.index));
|
|
70
|
+
result += span[0];
|
|
71
|
+
last = span.index + span[0].length;
|
|
72
|
+
}
|
|
73
|
+
return result + replaceSegment(line.slice(last));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Wrap bare `@copilot`/`/copilot*` tokens in backticks so a comment can quote the
|
|
77
|
+
// anti-summon rule without arming it. Tokens already inside an inline code span
|
|
78
|
+
// are left untouched.
|
|
79
|
+
function wrapBareSummonTokensInLine(line) {
|
|
80
|
+
return transformOutsideSpans(line, (segment) => segment.replace(COPILOT_SUMMON_TOKEN_RE, "`$1`"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Does this single (non-fenced) line still arm the guard scan after inline code
|
|
84
|
+
// spans are dropped? Mirrors stripMarkdownCodeForScan's per-line step. Spans are
|
|
85
|
+
// replaced with a SPACE, not the empty string: the fragments flanking a span
|
|
86
|
+
// must never be rejoined into a token that was not present ("@copi`x`lot" is not
|
|
87
|
+
// a summon), while a token directly abutting a span ("text`x`@copilot", which
|
|
88
|
+
// GitHub renders as a real mention) still arms.
|
|
89
|
+
function lineArmsSummonGuard(line) {
|
|
90
|
+
return COPILOT_SUMMON_WORD_BOUNDARY_RE.test(line.replace(INLINE_CODE_SPAN_RE, " "));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ZWJ_FALLBACK_RE = /(?<=^|\W)([@/])(copilot)/gi;
|
|
94
|
+
|
|
95
|
+
// Sanitize one line, verifying against the guard scan. Backtick-wrapping is the
|
|
96
|
+
// primary neutralization (visible, greppable), but pre-existing backticks on the
|
|
97
|
+
// line can destabilize it two ways: an UNBALANCED stray backtick pairs with an
|
|
98
|
+
// inserted one and re-exposes the token to the guard's span-stripping, and
|
|
99
|
+
// adjacent spans (e.g. a span ending right before the token's new wrap) can make
|
|
100
|
+
// the wrapped line re-tokenize differently on the next pass, re-wrapping the
|
|
101
|
+
// token and growing the comment by one backtick per rewrite. The wrapped result
|
|
102
|
+
// is therefore accepted only when it is BOTH guard-inert AND a fixed point of
|
|
103
|
+
// the wrapper (re-wrapping it changes nothing); otherwise fall back to inserting
|
|
104
|
+
// a zero-width joiner into the residual tokens still outside the wrapped line's
|
|
105
|
+
// spans — invisible, guard-inert, and idempotent (the joined token no longer
|
|
106
|
+
// matches the summon shape). Working on the wrapped line (not the original)
|
|
107
|
+
// preserves every stable backtick wrap and keeps the joiner out of legitimate
|
|
108
|
+
// pre-existing code spans.
|
|
109
|
+
function sanitizeSummonLine(line) {
|
|
110
|
+
const wrapped = wrapBareSummonTokensInLine(line);
|
|
111
|
+
if (!lineArmsSummonGuard(wrapped) && wrapBareSummonTokensInLine(wrapped) === wrapped) {
|
|
112
|
+
return wrapped;
|
|
113
|
+
}
|
|
114
|
+
return transformOutsideSpans(wrapped, (segment) => segment.replace(ZWJ_FALLBACK_RE, `$1${ZERO_WIDTH_JOINER}$2`));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function sanitizeCopilotSummonTokens(text) {
|
|
118
|
+
return transformNonFencedLines(String(text), sanitizeSummonLine);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Drop all markdown code content (fenced blocks entirely, inline code spans
|
|
122
|
+
// per line) from `text`, leaving only the bare-text markdown to scan. Unlike
|
|
123
|
+
// transformNonFencedLines (which leaves fenced lines verbatim — correct for
|
|
124
|
+
// sanitizing, where code content must not be rewritten), fenced content here
|
|
125
|
+
// must be REMOVED rather than kept: leaving it in place would let bare text
|
|
126
|
+
// inside a fence still match the anti-summon scan.
|
|
127
|
+
function stripMarkdownCodeForScan(text) {
|
|
128
|
+
const lines = String(text).split(/\r?\n/);
|
|
129
|
+
let inFencedBlock = false;
|
|
130
|
+
let fencedDelimiter = "";
|
|
131
|
+
const kept = [];
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const rawTrimmed = line.trim();
|
|
134
|
+
const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
|
|
135
|
+
if (fenceMatch) {
|
|
136
|
+
if (!inFencedBlock) {
|
|
137
|
+
inFencedBlock = true;
|
|
138
|
+
fencedDelimiter = fenceMatch[1];
|
|
139
|
+
} else if (rawTrimmed.startsWith(fencedDelimiter)) {
|
|
140
|
+
inFencedBlock = false;
|
|
141
|
+
fencedDelimiter = "";
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (inFencedBlock) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// Space (not empty-string) replacement: see lineArmsSummonGuard.
|
|
149
|
+
kept.push(line.replace(INLINE_CODE_SPAN_RE, " "));
|
|
150
|
+
}
|
|
151
|
+
return kept.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The request-copilot-review.mjs anti-summon guard scan: true when `text`
|
|
155
|
+
// contains a bare-text (not code-spanned/fenced) `@copilot` or `/copilot`
|
|
156
|
+
// occurrence. Quoting the rule inside backticks or a fenced block is exempt.
|
|
157
|
+
export function containsBareCopilotSummon(text) {
|
|
158
|
+
return COPILOT_SUMMON_WORD_BOUNDARY_RE.test(stripMarkdownCodeForScan(text));
|
|
159
|
+
}
|
|
160
|
+
|
|
18
161
|
export function normalizeTimestamp(value) {
|
|
19
162
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
20
163
|
return null;
|
|
@@ -234,18 +234,19 @@ function extractRepoFlagsFromGhSubcmdVerbSegments(command, subcmd, verb) {
|
|
|
234
234
|
|
|
235
235
|
/**
|
|
236
236
|
* The raw external-write verb forms that must be blocked when originating from a subagent:
|
|
237
|
-
* ad-hoc GitHub issue/PR creation and
|
|
238
|
-
* wrappers). Each entry is `[subcmd, verb]`.
|
|
237
|
+
* ad-hoc GitHub issue/PR creation, comments, and edits run directly via `gh` (not the sanctioned
|
|
238
|
+
* node wrappers). Each entry is `[subcmd, verb]`.
|
|
239
239
|
*/
|
|
240
240
|
const EXTERNAL_WRITE_VERB_FORMS = Object.freeze([
|
|
241
241
|
["issue", "create"],
|
|
242
242
|
["issue", "comment"],
|
|
243
|
+
["issue", "edit"],
|
|
243
244
|
["pr", "comment"],
|
|
244
245
|
]);
|
|
245
246
|
|
|
246
247
|
/**
|
|
247
|
-
* Whether `command` contains a raw `gh issue create`, `gh issue comment`,
|
|
248
|
-
* invocation in ANY shell segment (ignoring --help/-h). PreToolUse gate use only — the gate
|
|
248
|
+
* Whether `command` contains a raw `gh issue create`, `gh issue comment`, `gh issue edit`, or
|
|
249
|
+
* `gh pr comment` invocation in ANY shell segment (ignoring --help/-h). PreToolUse gate use only — the gate
|
|
249
250
|
* blocks these when they originate from a subagent context. Node-wrapper commands
|
|
250
251
|
* (`node scripts/github/comment-issue.mjs …`) never match (first token is `node`, not `gh`).
|
|
251
252
|
* @param {string} command @returns {boolean}
|
|
@@ -255,8 +256,8 @@ export function commandContainsRawExternalWrite(command) {
|
|
|
255
256
|
}
|
|
256
257
|
|
|
257
258
|
/**
|
|
258
|
-
* Return `{ segment, explicitRepo }` for every raw external-write segment across all
|
|
259
|
-
* forms (`gh issue create` / `gh issue comment` / `gh pr comment`). PreToolUse gate use only —
|
|
259
|
+
* Return `{ segment, explicitRepo }` for every raw external-write segment across all four verb
|
|
260
|
+
* forms (`gh issue create` / `gh issue comment` / `gh issue edit` / `gh pr comment`). PreToolUse gate use only —
|
|
260
261
|
* lets the gate decide in-scope-ness per segment so a leading out-of-scope write can't shield a
|
|
261
262
|
* later in-scope one. `explicitRepo` is the segment's `--repo`/`-R` value or null.
|
|
262
263
|
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -116,6 +116,51 @@ export function provenanceConsistencyError(prov) {
|
|
|
116
116
|
return null;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Base angle name for a delta-suffixed re-review entry (`<angle>-delta-at-...`,
|
|
121
|
+
* e.g. `pr-checklist-matrix-delta-at-current-head`): a re-review scoped to only
|
|
122
|
+
* the current head's delta still counts toward its base angle for both
|
|
123
|
+
* mandatory-angle coverage and pool-membership checks.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} angle
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
function baseAngleName(angle) {
|
|
129
|
+
return angle.replace(/-delta-at-.+$/, "");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate a recorded fan-out angle list against a gate's configured angle
|
|
134
|
+
* contract: every mandatory angle must be represented, and — when a pool is
|
|
135
|
+
* supplied — every recorded angle must be a member of it (delta-suffixed
|
|
136
|
+
* angles count toward their {@link baseAngleName}). Pure; shared by the write
|
|
137
|
+
* path (write-gate-findings-log's `provenance.perAngle`, upsert-checkpoint-verdict's
|
|
138
|
+
* `--findings-json` per-angle results) and the merge-evidence read path
|
|
139
|
+
* (detect-checkpoint-evidence re-validating the ledger's `provenance.perAngle`)
|
|
140
|
+
* so all three enforce identically.
|
|
141
|
+
*
|
|
142
|
+
* @param {unknown} recordedAngles — array of `{ angle: string, ... }` entries (provenance.perAngle or normalized per-angle findings)
|
|
143
|
+
* @param {object} [gateAngleContract]
|
|
144
|
+
* @param {string[]} [gateAngleContract.mandatoryAngles] — angles that must always be represented
|
|
145
|
+
* @param {string[]|null} [gateAngleContract.pool] — configured angle pool; null/omitted skips the foreign-angle check
|
|
146
|
+
* @returns {{ missingMandatory: string[], foreignAngles: string[] }}
|
|
147
|
+
*/
|
|
148
|
+
export function checkFanoutAngleCoverage(recordedAngles, { mandatoryAngles = [], pool = null } = {}) {
|
|
149
|
+
const recorded = Array.isArray(recordedAngles)
|
|
150
|
+
? recordedAngles
|
|
151
|
+
.map((e) => (e && typeof e === "object" && typeof e.angle === "string" ? e.angle.trim() : ""))
|
|
152
|
+
.filter((a) => a.length > 0)
|
|
153
|
+
: [];
|
|
154
|
+
const recordedBases = new Set(recorded.map(baseAngleName));
|
|
155
|
+
const missingMandatory = mandatoryAngles.filter((a) => !recordedBases.has(a));
|
|
156
|
+
let foreignAngles = [];
|
|
157
|
+
if (Array.isArray(pool) && pool.length > 0) {
|
|
158
|
+
const poolSet = new Set(pool);
|
|
159
|
+
foreignAngles = [...new Set(recorded.filter((a) => !poolSet.has(baseAngleName(a))))];
|
|
160
|
+
}
|
|
161
|
+
return { missingMandatory, foreignAngles };
|
|
162
|
+
}
|
|
163
|
+
|
|
119
164
|
/**
|
|
120
165
|
* Default cap on parallel fan-out reviewers when a caller does not supply one.
|
|
121
166
|
* Mirrors the config default (gates.maxFanoutReviewers).
|
|
@@ -280,6 +280,9 @@ function deriveRequiredReads(bundle, resolverOutput) {
|
|
|
280
280
|
* (scripts/github/resolve-tracker-local-spec.mjs), which the envelope does not
|
|
281
281
|
* model (deriveSpecSource coerces it to null).
|
|
282
282
|
*/
|
|
283
|
+
// Distinct from refinementArtifact.specSource (linked_issue|pr_body|plan_file,
|
|
284
|
+
// REFINEMENT_ARTIFACT_SPEC_SOURCE in packages/core/src/loop/pr-gate-coordination.mjs):
|
|
285
|
+
// same field name, different object, different value space — intentionally separate enums.
|
|
283
286
|
export const CANONICAL_SPEC_SOURCE = Object.freeze({
|
|
284
287
|
PHASE_DOC: "phase_doc",
|
|
285
288
|
PR_BODY: "pr_body",
|
|
@@ -378,6 +378,8 @@ function extractClosingIssueNumbers(body) {
|
|
|
378
378
|
// backtick-run-delimited span (equal-length runs pair, so ``a `b` c`` works).
|
|
379
379
|
// ponytail: not full CommonMark span matching; an unbalanced stray backtick
|
|
380
380
|
// over-strips toward fail-closed, which is the safe direction for this gate.
|
|
381
|
+
// Revisit with a real CommonMark span parser only if valid closing refs in
|
|
382
|
+
// backtick-heavy bodies start being over-stripped into false negatives.
|
|
381
383
|
const text = unfenced.join("\n").replace(/(`+)[\s\S]*?\1/gu, " ");
|
|
382
384
|
const seen = new Set();
|
|
383
385
|
const numbers = [];
|
|
@@ -411,18 +413,34 @@ function sectionHasBody(section) {
|
|
|
411
413
|
* Validate that a PR body carries every invariant required to serve as the
|
|
412
414
|
* lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
|
|
413
415
|
* testable Acceptance criteria (>=1 checklist item), Definition of done
|
|
414
|
-
* (>=1 checklist item), Open questions/risks, and
|
|
415
|
-
* issue
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* Pure; no side effects.
|
|
416
|
+
* (>=1 checklist item), Open questions/risks, and — unless explicit
|
|
417
|
+
* issue-less mode is requested — a GitHub closing-keyword issue reference
|
|
418
|
+
* (`Closes #N` and GitHub's other accepted forms — the lightweight path's
|
|
419
|
+
* `Closes #N` linkage, issue #1181). Reuses the generic markdown logic
|
|
420
|
+
* (parseMarkdownSections / AC + DoD patterns / extractChecklistItems) so
|
|
421
|
+
* there is no parallel validator. Fails closed: every missing invariant is
|
|
422
|
+
* reported under its distinct `missing_*` code. Pure; no side effects.
|
|
421
423
|
*
|
|
422
|
-
*
|
|
424
|
+
* Issue-less mode (`issueLess: true`, issue #1210): the narrative invariants
|
|
425
|
+
* stay unconditional, but the closing-issue linkage flips from REQUIRED to
|
|
426
|
+
* FORBIDDEN — the PR is the sole artifact, so it MUST NOT carry a closing
|
|
427
|
+
* reference to an issue that doesn't back it. A present reference in this
|
|
428
|
+
* mode fails closed under `unexpected_closing_issue_reference`, distinct
|
|
429
|
+
* from `missing_closing_issue_reference` (tracker-backed mode, the default)
|
|
430
|
+
* so callers can tell "no issue expected" apart from "issue expected but
|
|
431
|
+
* absent". `expectedIssue` and `issueLess` are mutually exclusive; callers
|
|
432
|
+
* pick exactly one mode (tracker-backed, with or without a specific
|
|
433
|
+
* expected issue) or issue-less — never both.
|
|
434
|
+
*
|
|
435
|
+
* @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
|
|
423
436
|
* @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
|
|
424
437
|
*/
|
|
425
|
-
export function validatePrBodySpec({ body = "", expectedIssue = null } = {}) {
|
|
438
|
+
export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
|
|
439
|
+
if (issueLess && Number.isInteger(expectedIssue)) {
|
|
440
|
+
// Fail closed at the library boundary too (not just the CLI): the two modes
|
|
441
|
+
// are contradictory and silently preferring one would hide caller bugs.
|
|
442
|
+
throw new Error("validatePrBodySpec: issueLess and expectedIssue are mutually exclusive; pass exactly one issue-linkage mode");
|
|
443
|
+
}
|
|
426
444
|
const bodyText = typeof body === "string" ? body : "";
|
|
427
445
|
const sections = parseMarkdownSections(bodyText);
|
|
428
446
|
const errors = [];
|
|
@@ -453,7 +471,14 @@ export function validatePrBodySpec({ body = "", expectedIssue = null } = {}) {
|
|
|
453
471
|
}
|
|
454
472
|
|
|
455
473
|
const closesIssues = extractClosingIssueNumbers(bodyText);
|
|
456
|
-
if (
|
|
474
|
+
if (issueLess) {
|
|
475
|
+
if (closesIssues.length > 0) {
|
|
476
|
+
errors.push({
|
|
477
|
+
code: "unexpected_closing_issue_reference",
|
|
478
|
+
message: `Issue-less PR body MUST NOT carry a closing reference to an issue that doesn't back it (found ${closesIssues.map((n) => `#${n}`).join(", ")}).`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
} else if (closesIssues.length === 0) {
|
|
457
482
|
errors.push({
|
|
458
483
|
code: "missing_closing_issue_reference",
|
|
459
484
|
message: "Missing a GitHub closing-keyword issue reference (e.g. `Closes #123`).",
|
|
@@ -476,6 +501,41 @@ export function validatePrBodySpec({ body = "", expectedIssue = null } = {}) {
|
|
|
476
501
|
};
|
|
477
502
|
}
|
|
478
503
|
|
|
504
|
+
/**
|
|
505
|
+
* Decide what an enqueue caller should do with a refinement-artifact result,
|
|
506
|
+
* so an un-refined item never lands in the Next Up pickup column in the first
|
|
507
|
+
* place. The draft gate remains the backstop for whatever slips through.
|
|
508
|
+
*
|
|
509
|
+
* Pure decision table, no I/O:
|
|
510
|
+
* - target isn't the pickup column, or the artifact is present → enqueue
|
|
511
|
+
* as requested.
|
|
512
|
+
* - pickup target, artifact missing, interactive caller → block (caller
|
|
513
|
+
* throws; no mutation).
|
|
514
|
+
* - pickup target, artifact missing, headless/auto caller → divert (caller
|
|
515
|
+
* parks the item in the non-pickup column instead of failing the run).
|
|
516
|
+
*
|
|
517
|
+
* @param {{ artifact: ReturnType<typeof detectIssueRefinementArtifact>, targetIsPickup: boolean, auto?: boolean }} input
|
|
518
|
+
* @returns {{ action: "enqueue" } | { action: "block"|"divert", reason: string, missing: string[] }}
|
|
519
|
+
*/
|
|
520
|
+
export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
|
|
521
|
+
// `artifact.finding === null` is the explicit "has ANY refinement artifact"
|
|
522
|
+
// signal (AC checklist OR DoD checklist OR linked doc) — clearer than reading
|
|
523
|
+
// `hasACs`, whose name understates that a DoD or linked doc also satisfies it.
|
|
524
|
+
if (!targetIsPickup || artifact.finding === null) {
|
|
525
|
+
return { action: "enqueue" };
|
|
526
|
+
}
|
|
527
|
+
const missing = [
|
|
528
|
+
"Acceptance criteria section",
|
|
529
|
+
"Definition of done section",
|
|
530
|
+
"linked refinement doc",
|
|
531
|
+
];
|
|
532
|
+
const reason =
|
|
533
|
+
`Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
|
|
534
|
+
"Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
|
|
535
|
+
"(e.g. run `/loop-grill <issue> --auto`, or the refiner) — before it enters the pickup queue.";
|
|
536
|
+
return { action: auto ? "divert" : "block", reason, missing };
|
|
537
|
+
}
|
|
538
|
+
|
|
479
539
|
/**
|
|
480
540
|
* Map a draft-gate refinement check to the result surface consumed by
|
|
481
541
|
* `evaluatePrGateCoordination`. The mapping keeps the contract
|
|
@@ -43,6 +43,28 @@ export const PLAN_FILE_PROMOTE_ACTION = Object.freeze({
|
|
|
43
43
|
*/
|
|
44
44
|
export const PLAN_FILE_PR_FRONT_MATTER_KEY = "prNumber";
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Build the plan-file promotion marker sentence: the single source of truth
|
|
48
|
+
* for the PR-body text that names the committed plan doc as the spec-of-record.
|
|
49
|
+
* `buildPromotionPrBody` emits it and `PLAN_FILE_PROMOTION_DOC_PATH_PATTERN`
|
|
50
|
+
* (below) parses it back out — keep the two in lockstep.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} docPath repo-relative path of the committed plan doc
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function buildPlanFilePromotionMarker(docPath) {
|
|
56
|
+
return `Spec-of-record: the committed plan doc \`${docPath}\` is the authority for this work.`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Matches the marker sentence `buildPlanFilePromotionMarker` produces and
|
|
61
|
+
* captures the plan doc path. The captured path is bounded to a single line
|
|
62
|
+
* and a `.md` suffix so a multi-line/unbounded body cannot smuggle an
|
|
63
|
+
* oversized or cross-line "path".
|
|
64
|
+
*/
|
|
65
|
+
export const PLAN_FILE_PROMOTION_DOC_PATH_PATTERN =
|
|
66
|
+
/Spec-of-record: the committed plan doc `([^`\n]{1,200}?\.md)`/u;
|
|
67
|
+
|
|
46
68
|
/**
|
|
47
69
|
* Minimal additive front-matter support for plan files (an escalated extension
|
|
48
70
|
* to P1's format): a leading `---\n...\n---\n` block of simple `key: value`
|
|
@@ -219,7 +241,7 @@ export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definiti
|
|
|
219
241
|
const safeAc = neutralizeIssueCloseKeywords(ac);
|
|
220
242
|
const safeDod = neutralizeIssueCloseKeywords(dod);
|
|
221
243
|
return [
|
|
222
|
-
|
|
244
|
+
buildPlanFilePromotionMarker(docPath),
|
|
223
245
|
"This PR was opened by PR-FIRST promotion; no tracker issue exists.",
|
|
224
246
|
"",
|
|
225
247
|
"## Acceptance criteria",
|