@dev-loops/core 1.0.4-pre.0 → 1.0.4-pre.2
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 +2 -1
- package/src/analysis/change-classifier.mjs +16 -30
- package/src/analysis/diff-analyzer.mjs +38 -15
- package/src/claude/asset-generation.mjs +36 -1
- package/src/config/config.mjs +158 -81
- package/src/config/extension-defaults.yaml +18 -6
- package/src/github/copilot-helpers.mjs +22 -8
- package/src/github/gh.mjs +14 -1
- package/src/github/review-threads.mjs +6 -0
- package/src/loop/copilot-loop-state.mjs +44 -4
- package/src/loop/finding-cluster.mjs +9 -9
- package/src/loop/gate-fanin.mjs +21 -0
- package/src/loop/main-checkout-ff.mjs +169 -17
- package/src/loop/merge-approval.mjs +122 -26
- package/src/loop/pr-gate-coordination.mjs +283 -62
- package/src/loop/refinement-grill-state.mjs +120 -8
- package/src/loop/review-operation.mjs +161 -0
- package/src/loop/spec-authority.mjs +21 -0
- package/src/projects/move-queue-item.mjs +5 -79
- package/src/projects/projects-access.mjs +124 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* synthesis enter ONLY as a bounded input consumed at the `await_answers`
|
|
8
8
|
* state (and reflected in the `synthesized` snapshot flag), never as hidden
|
|
9
9
|
* orchestration inside a deterministic coordinator script (keeps
|
|
10
|
-
* OPS-NO-INLINE-INTERPRETER
|
|
10
|
+
* OPS-NO-INLINE-INTERPRETER clean).
|
|
11
11
|
*
|
|
12
12
|
* Mirrors the shape of `reviewer-loop-state.mjs` / `copilot-loop-state.mjs`:
|
|
13
13
|
* a frozen STATE vocabulary, a frozen TRANSITIONS adjacency table, a
|
|
@@ -17,6 +17,15 @@
|
|
|
17
17
|
* Honest handoff: when a gap is genuinely unanswerable (only-`inferred`, no
|
|
18
18
|
* citation), the machine reaches `needs_human_handoff` naming the question
|
|
19
19
|
* rather than fabricating an answer to force convergence.
|
|
20
|
+
*
|
|
21
|
+
* Zero-gap provenance (see ADR 0084, which amends ADR 0029): a zero-open-gap
|
|
22
|
+
* `detect_gaps` pass resolves to `grill_clean` only for a `plan` surface
|
|
23
|
+
* (shape-only, no comment surface) or when a `🔬 Grill / refinement results`
|
|
24
|
+
* comment is already recorded on the target; otherwise it stays at
|
|
25
|
+
* `detect_gaps` so the semantic pass still runs and records its own
|
|
26
|
+
* provenance, including a zero-gap outcome. `detectIssueRefinementArtifact`
|
|
27
|
+
* stays the sole shape/completeness predicate; provenance is a separate
|
|
28
|
+
* recorded fact, not a second refinedness detector.
|
|
20
29
|
*/
|
|
21
30
|
|
|
22
31
|
import { trimmedOrNull } from "./normalize.mjs";
|
|
@@ -36,7 +45,10 @@ export const GRILL_STATE = Object.freeze({
|
|
|
36
45
|
// re_grill, with re_grill either re-entering detect_gaps (a new answerable gap
|
|
37
46
|
// surfaced) or terminating at grill_clean (fixed point). Any I/O/parse failure
|
|
38
47
|
// fails closed to blocked_needs_user_decision; any unresolved (uncitable) gap
|
|
39
|
-
// terminates honestly at needs_human_handoff.
|
|
48
|
+
// terminates honestly at needs_human_handoff. A zero-open-gap detect_gaps pass
|
|
49
|
+
// terminates at grill_clean only with recorded provenance (plan surface, or a
|
|
50
|
+
// posted results comment); otherwise it stays at detect_gaps for the owed
|
|
51
|
+
// semantic pass (ADR 0084).
|
|
40
52
|
export const GRILL_TRANSITIONS = Object.freeze({
|
|
41
53
|
[GRILL_STATE.LOAD_TARGET]: [
|
|
42
54
|
GRILL_STATE.DETECT_GAPS,
|
|
@@ -81,12 +93,76 @@ const GRILL_NEXT_ACTIONS = Object.freeze({
|
|
|
81
93
|
|
|
82
94
|
const VALID_SURFACES = new Set(["issue", "pr", "plan"]);
|
|
83
95
|
|
|
96
|
+
// The exact comment title provenance is keyed on (GRILL-SUBLOOP-RATIONALE-COMMENT).
|
|
97
|
+
const RESULTS_COMMENT_TITLE = "🔬 Grill / refinement results";
|
|
98
|
+
// The exact first-line heading a results comment must carry -- SKILL.md Step 4
|
|
99
|
+
// and the output artifact contract require exactly "## " (one hash pair, one
|
|
100
|
+
// space), never a bare title or a different heading level.
|
|
101
|
+
const RESULTS_COMMENT_HEADING = `## ${RESULTS_COMMENT_TITLE}`;
|
|
102
|
+
// A results comment's recorded bypass line: "bypass: operator-authorized by <handle>",
|
|
103
|
+
// with an optional leading @ before the handle and a case-insensitive "bypass:" key.
|
|
104
|
+
const BYPASS_LINE_RE = /^bypass: operator-authorized by @?([A-Za-z0-9][A-Za-z0-9-]{0,38})\s*$/i;
|
|
105
|
+
|
|
84
106
|
function normalizeCount(value) {
|
|
85
107
|
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
86
108
|
? Math.floor(value)
|
|
87
109
|
: 0;
|
|
88
110
|
}
|
|
89
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Detect recorded grill provenance from a target's comments: a durable
|
|
114
|
+
* `🔬 Grill / refinement results` comment, optionally carrying an
|
|
115
|
+
* `bypass: operator-authorized by <handle>` line. The ephemeral
|
|
116
|
+
* `tmp/issues/issue-<n>/grill/` transcript is never a comment, so it never
|
|
117
|
+
* counts here.
|
|
118
|
+
*
|
|
119
|
+
* @param {Array<string|{body?: string}>} comments
|
|
120
|
+
* @returns {{provenanceRecorded: boolean, bypass: boolean, bypassBy: string|null}}
|
|
121
|
+
*/
|
|
122
|
+
export function detectGrillProvenance(comments) {
|
|
123
|
+
if (!Array.isArray(comments)) {
|
|
124
|
+
return { provenanceRecorded: false, bypass: false, bypassBy: null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let provenanceRecorded = false;
|
|
128
|
+
let bypass = false;
|
|
129
|
+
let bypassBy = null;
|
|
130
|
+
|
|
131
|
+
for (const comment of comments) {
|
|
132
|
+
const body = typeof comment === "string"
|
|
133
|
+
? comment
|
|
134
|
+
: (comment && typeof comment.body === "string" ? comment.body : null);
|
|
135
|
+
if (body === null) continue;
|
|
136
|
+
|
|
137
|
+
const lines = body.split(/\r?\n/);
|
|
138
|
+
// A comment counts as a results comment only when its FIRST non-empty
|
|
139
|
+
// line, trimmed, is EXACTLY the "## " heading -- this rejects a bare
|
|
140
|
+
// title, a different heading level (`###`), a missing space (`##🔬`),
|
|
141
|
+
// the title merely quoted in a code fence, or the title appearing later
|
|
142
|
+
// in an unrelated reply.
|
|
143
|
+
const firstNonEmpty = lines.find((line) => line.trim().length > 0);
|
|
144
|
+
const isResultsComment = firstNonEmpty !== undefined
|
|
145
|
+
&& firstNonEmpty.trim() === RESULTS_COMMENT_HEADING;
|
|
146
|
+
if (!isResultsComment) continue;
|
|
147
|
+
|
|
148
|
+
provenanceRecorded = true;
|
|
149
|
+
if (!bypass) {
|
|
150
|
+
// Take the first bypass-line match across all results comments; a
|
|
151
|
+
// later comment's bypass line never overwrites an earlier one.
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
const match = line.trim().match(BYPASS_LINE_RE);
|
|
154
|
+
if (match) {
|
|
155
|
+
bypass = true;
|
|
156
|
+
bypassBy = match[1];
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { provenanceRecorded, bypass, bypassBy };
|
|
164
|
+
}
|
|
165
|
+
|
|
90
166
|
/**
|
|
91
167
|
* Canonicalize a raw grill snapshot into a deterministic shape.
|
|
92
168
|
*
|
|
@@ -119,19 +195,32 @@ export function normalizeGrillSnapshot(raw) {
|
|
|
119
195
|
// post-synthesis re-grill fixed-point signals
|
|
120
196
|
reGrillRan: Boolean(raw.reGrillRan),
|
|
121
197
|
reGrillFixedPoint: Boolean(raw.reGrillFixedPoint),
|
|
198
|
+
|
|
199
|
+
// recorded provenance: a posted `🔬 Grill / refinement results` comment
|
|
200
|
+
// (see detectGrillProvenance), and whether it carries a recorded bypass line.
|
|
201
|
+
// A bypass line only ever means anything alongside a recorded comment, so
|
|
202
|
+
// provenanceBypass is forced false when provenanceRecorded is false --
|
|
203
|
+
// never a standalone shortcut to grill_clean.
|
|
204
|
+
provenanceRecorded: Boolean(raw.provenanceRecorded),
|
|
205
|
+
provenanceBypass: Boolean(raw.provenanceRecorded) && Boolean(raw.provenanceBypass),
|
|
122
206
|
};
|
|
123
207
|
}
|
|
124
208
|
|
|
209
|
+
const PROVENANCE_MISSING_NEXT_ACTION =
|
|
210
|
+
"Run the semantic gap pass on the loaded spec, then post the \"🔬 Grill / refinement results\" comment recording the outcome — including a zero-gap pass, which states that no gaps were found";
|
|
211
|
+
|
|
125
212
|
/**
|
|
126
213
|
* Deterministically interpret the current refinement-grill state.
|
|
127
214
|
*
|
|
128
215
|
* @param {object} snapshot
|
|
129
|
-
* @returns {{state: string, allowedTransitions: string[], nextAction: string}}
|
|
216
|
+
* @returns {{state: string, allowedTransitions: string[], nextAction: string, reason: string|null, bypass: boolean}}
|
|
130
217
|
*/
|
|
131
218
|
export function interpretRefinementGrillState(snapshot) {
|
|
132
219
|
const s = normalizeGrillSnapshot(snapshot);
|
|
133
220
|
|
|
134
221
|
let state;
|
|
222
|
+
let reason = null;
|
|
223
|
+
let bypass = false;
|
|
135
224
|
|
|
136
225
|
if (s.loadFailed) {
|
|
137
226
|
// Fail closed on any load/parse failure, from any point in the loop.
|
|
@@ -155,17 +244,40 @@ export function interpretRefinementGrillState(snapshot) {
|
|
|
155
244
|
// Bounded answer input present -> apply synthesis.
|
|
156
245
|
state = GRILL_STATE.SYNTHESIZE;
|
|
157
246
|
} else if (s.detectRan) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
247
|
+
if (s.openGapCount > 0) {
|
|
248
|
+
// Detection ran; answerable gaps still open -> await answers.
|
|
249
|
+
state = GRILL_STATE.AWAIT_ANSWERS;
|
|
250
|
+
} else if (s.surface === "plan") {
|
|
251
|
+
// Local plan files have no comment surface: shape-only, zero-iteration clean.
|
|
252
|
+
state = GRILL_STATE.GRILL_CLEAN;
|
|
253
|
+
reason = "plan_shape_only";
|
|
254
|
+
} else if (s.provenanceBypass) {
|
|
255
|
+
// A recorded bypass line still counts as recorded provenance.
|
|
256
|
+
state = GRILL_STATE.GRILL_CLEAN;
|
|
257
|
+
reason = "provenance_bypass_recorded";
|
|
258
|
+
bypass = true;
|
|
259
|
+
} else if (s.provenanceRecorded) {
|
|
260
|
+
state = GRILL_STATE.GRILL_CLEAN;
|
|
261
|
+
reason = "provenance_recorded";
|
|
262
|
+
} else {
|
|
263
|
+
// Zero open gaps but no recorded provenance: the semantic pass is still
|
|
264
|
+
// owed (ADR 0084) — stay at detect_gaps rather than short-circuiting.
|
|
265
|
+
state = GRILL_STATE.DETECT_GAPS;
|
|
266
|
+
reason = "provenance_missing";
|
|
267
|
+
}
|
|
162
268
|
} else {
|
|
163
269
|
state = GRILL_STATE.DETECT_GAPS;
|
|
164
270
|
}
|
|
165
271
|
|
|
272
|
+
const nextAction = reason === "provenance_missing"
|
|
273
|
+
? PROVENANCE_MISSING_NEXT_ACTION
|
|
274
|
+
: GRILL_NEXT_ACTIONS[state];
|
|
275
|
+
|
|
166
276
|
return {
|
|
167
277
|
state,
|
|
168
278
|
allowedTransitions: [...GRILL_TRANSITIONS[state]],
|
|
169
|
-
nextAction
|
|
279
|
+
nextAction,
|
|
280
|
+
reason,
|
|
281
|
+
bypass,
|
|
170
282
|
};
|
|
171
283
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-operation
|
|
3
|
+
*
|
|
4
|
+
* The ONE operation-scoped authority for the legal dispatchable-angle pool and
|
|
5
|
+
* the reviewer role it authorizes. write-gate-context.mjs's standalone-review
|
|
6
|
+
* angle union and the `dev-loops gate resolve-role` CLI both consume this
|
|
7
|
+
* module instead of reconstructing membership/eligibility rules of their own.
|
|
8
|
+
* The draft/pre-approval/spike dispatch paths keep calling
|
|
9
|
+
* `resolveGateAngleContract` directly for their pool, which this module wraps
|
|
10
|
+
* (see `resolveOperationAnglePool` below) rather than duplicating.
|
|
11
|
+
*
|
|
12
|
+
* `resolveOperationAnglePool` owns only the legal CANDIDATE catalog for an
|
|
13
|
+
* operation. Diff-/tier-/PR-fact-driven SELECTION from that catalog (which
|
|
14
|
+
* angles actually run this round) and any later spec-of-record pruning stay
|
|
15
|
+
* downstream dispatch/planning concerns (see write-gate-context.mjs's
|
|
16
|
+
* `resolveReviewGateAngles`, which imports this module for its union instead
|
|
17
|
+
* of recomputing it).
|
|
18
|
+
*
|
|
19
|
+
* `resolveOperationReviewerRole` is the authoritative exit-code boundary the
|
|
20
|
+
* `dev-loops gate resolve-role` CLI consumes for standalone/defensive role
|
|
21
|
+
* resolution: `ok` is fail-closed true only when the merged config loaded
|
|
22
|
+
* with no errors AND the requested angle is a legal member of the named
|
|
23
|
+
* operation's pool. `status` is a DIAGNOSTIC field only (never a second
|
|
24
|
+
* reviewer decision surface); see skills/docs/gate-review-comment-contract.md
|
|
25
|
+
* and skills/docs/gate-review-sub-loop-contract.md.
|
|
26
|
+
*/
|
|
27
|
+
import { resolveGateAngleContract, resolveGateAngles, resolveReviewerRole, resolveRoleModel } from "../config/config.mjs";
|
|
28
|
+
import { GATE_CONFIG_KEY } from "./gate-fanin.mjs";
|
|
29
|
+
|
|
30
|
+
/** The closed review-operation vocabulary. Standalone `review` is only ever selected by name, never inferred. */
|
|
31
|
+
export const REVIEW_OPERATIONS = Object.freeze(["draft_gate", "pre_approval_gate", "review", "spike"]);
|
|
32
|
+
|
|
33
|
+
// GATE_CONFIG_KEY (gate-fanin.mjs) has no `spike` entry — other code relies on
|
|
34
|
+
// its absence of review/spike — so this operation-scoped table adds it locally
|
|
35
|
+
// instead of widening the shared one.
|
|
36
|
+
const OPERATION_GATE_KEY = Object.freeze({ ...GATE_CONFIG_KEY, spike: "spike" });
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the legal candidate angle pool for a review operation from the
|
|
40
|
+
* fully merged config:
|
|
41
|
+
* - draft_gate / pre_approval_gate / spike: the effective `gates.<key>`
|
|
42
|
+
* pool (`resolveGateAngleContract(config, key).pool` — mandatory/static/
|
|
43
|
+
* additive rules applied, disabled/excluded angles removed).
|
|
44
|
+
* - review: the de-duplicated, order-stable union of the static draft and
|
|
45
|
+
* pre-approval angle sets (`resolveGateAngles`, NOT the additive/tier
|
|
46
|
+
* pool) — standalone review's existing dedicated semantics; it gains no
|
|
47
|
+
* diff-tier or additive selection here.
|
|
48
|
+
* An unrecognized operation throws (arg-identity error, not a config-layer
|
|
49
|
+
* concern; callers validate `--gate` before ever reaching config load).
|
|
50
|
+
* @param {import("../config/config.mjs").DevLoopConfig} config
|
|
51
|
+
* @param {"draft_gate"|"pre_approval_gate"|"review"|"spike"} operation
|
|
52
|
+
* @returns {string[]}
|
|
53
|
+
*/
|
|
54
|
+
export function resolveOperationAnglePool(config, operation) {
|
|
55
|
+
if (!REVIEW_OPERATIONS.includes(operation)) {
|
|
56
|
+
throw new Error(`Unknown review operation: ${JSON.stringify(operation)} (expected one of ${REVIEW_OPERATIONS.join(", ")})`);
|
|
57
|
+
}
|
|
58
|
+
if (operation === "review") {
|
|
59
|
+
return [...new Set([
|
|
60
|
+
...(resolveGateAngles(config, "draft") ?? []),
|
|
61
|
+
...(resolveGateAngles(config, "preApproval") ?? []),
|
|
62
|
+
])];
|
|
63
|
+
}
|
|
64
|
+
return resolveGateAngleContract(config, OPERATION_GATE_KEY[operation]).pool ?? [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} OperationReviewerRoleResult
|
|
69
|
+
* @property {boolean} ok - Fail-closed reviewer safety boundary: `configErrors.length === 0 && pool.includes(angle)`.
|
|
70
|
+
* @property {"draft_gate"|"pre_approval_gate"|"review"|"spike"} operation
|
|
71
|
+
* @property {string} angle
|
|
72
|
+
* @property {"claude"|"pi"} harness
|
|
73
|
+
* @property {string} persona
|
|
74
|
+
* @property {string|null} prompt
|
|
75
|
+
* @property {string|null} model - Authoritative merged model tier (`resolveRoleModel(..., { kind: "angle" })`).
|
|
76
|
+
* @property {boolean} fallback
|
|
77
|
+
* @property {"config-error"|"non-member"|"fallback"|"prompt-missing"|"resolved"} status - Diagnostic only; never a reviewer decision branch.
|
|
78
|
+
* @property {string[]} warnings
|
|
79
|
+
* @property {Array<unknown>} configErrors
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolve one angle's reviewer role for a review operation, authorized
|
|
84
|
+
* against `resolveOperationAnglePool`. This is the shared authority a CLI
|
|
85
|
+
* (`gate resolve-role`), dispatch/planning, or defensive validation all call —
|
|
86
|
+
* none of them may reimplement membership/union/additive/disabled/spike
|
|
87
|
+
* classification locally.
|
|
88
|
+
* @param {{ config: import("../config/config.mjs").DevLoopConfig, errors?: Array<unknown> }} loadResult - the `{ config, errors }` shape `loadDevLoopConfig` returns.
|
|
89
|
+
* @param {{ operation: "draft_gate"|"pre_approval_gate"|"review"|"spike", angle: string, harness: "claude"|"pi" }} params
|
|
90
|
+
* @returns {OperationReviewerRoleResult}
|
|
91
|
+
*/
|
|
92
|
+
export function resolveOperationReviewerRole(loadResult, { operation, angle, harness }) {
|
|
93
|
+
const config = loadResult?.config;
|
|
94
|
+
const configErrors = Array.isArray(loadResult?.errors) ? loadResult.errors : [];
|
|
95
|
+
const configErrorPresent = configErrors.length > 0;
|
|
96
|
+
// An unrecognized operation is a closed-vocabulary argument failure, not a
|
|
97
|
+
// config-layer concern; it must throw unconditionally, even when a config
|
|
98
|
+
// error is also on record — never degraded to a misleading config-error
|
|
99
|
+
// result below. Validate before the try so this throw is never swallowed.
|
|
100
|
+
if (!REVIEW_OPERATIONS.includes(operation)) {
|
|
101
|
+
throw new Error(`Unknown review operation: ${JSON.stringify(operation)} (expected one of ${REVIEW_OPERATIONS.join(", ")})`);
|
|
102
|
+
}
|
|
103
|
+
let pool;
|
|
104
|
+
try {
|
|
105
|
+
pool = resolveOperationAnglePool(config, operation);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// A config that already failed merged schema validation (non-empty
|
|
108
|
+
// `errors` from loadDevLoopConfig) can still reach here with a gate shape
|
|
109
|
+
// resolveGateConfig itself rejects (e.g. an invalid
|
|
110
|
+
// `gates.<gate>.blockCleanOnFindingSeverities`). That is a config-layer
|
|
111
|
+
// failure the caller's own config-error fail-closed status already
|
|
112
|
+
// covers, so degrade to an empty pool instead of an unhandled exception
|
|
113
|
+
// reaching the reviewer boundary. With no config error on record, this is
|
|
114
|
+
// a real schema-invalid gate shape a caller must see: rethrow.
|
|
115
|
+
if (!configErrorPresent) throw error;
|
|
116
|
+
pool = [];
|
|
117
|
+
}
|
|
118
|
+
const member = pool.includes(angle);
|
|
119
|
+
const ok = !configErrorPresent && member;
|
|
120
|
+
|
|
121
|
+
const role = resolveReviewerRole(config, angle);
|
|
122
|
+
const model = resolveRoleModel(config, { role: angle, harness, kind: "angle" });
|
|
123
|
+
|
|
124
|
+
const warnings = [];
|
|
125
|
+
let status;
|
|
126
|
+
if (configErrorPresent) {
|
|
127
|
+
status = "config-error";
|
|
128
|
+
warnings.push(
|
|
129
|
+
`${configErrors.length} config-layer error(s); the resolved role may be a shipped default and must not be trusted.`,
|
|
130
|
+
);
|
|
131
|
+
} else if (!member) {
|
|
132
|
+
status = "non-member";
|
|
133
|
+
warnings.push(`angle '${angle}' is not a member of the '${operation}' operation's legal candidate pool.`);
|
|
134
|
+
} else if (role.fallback) {
|
|
135
|
+
status = "fallback";
|
|
136
|
+
warnings.push(
|
|
137
|
+
`angle '${angle}' is authorized for '${operation}' but has no dedicated persona/prompt entry; the default-reviewer persona is returned.`,
|
|
138
|
+
);
|
|
139
|
+
} else if (typeof role.prompt !== "string" || role.prompt.trim() === "") {
|
|
140
|
+
status = "prompt-missing";
|
|
141
|
+
warnings.push(
|
|
142
|
+
`angle '${angle}' resolved persona '${role.persona}' but its focus prompt is null/empty; review with no angle-specific focus instruction.`,
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
status = "resolved";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok,
|
|
150
|
+
operation,
|
|
151
|
+
angle,
|
|
152
|
+
harness,
|
|
153
|
+
persona: role.persona,
|
|
154
|
+
prompt: role.prompt,
|
|
155
|
+
model,
|
|
156
|
+
fallback: role.fallback,
|
|
157
|
+
status,
|
|
158
|
+
warnings,
|
|
159
|
+
configErrors,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -498,6 +498,27 @@ export function validateSpecAuthorityVerdict(verdict, { findingsCount, criterion
|
|
|
498
498
|
};
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Dispose every spec-authority `finding_conflicts` finding `reject`, whatever
|
|
503
|
+
* its relevance disposition. Mutates the judge-enriched findings in place; the
|
|
504
|
+
* judge pass and the durable findings-log writer share it so the ledger's
|
|
505
|
+
* `judgeDisposition` matches what the judge pass enforces.
|
|
506
|
+
*
|
|
507
|
+
* @param {object[]} findings — judge-enriched findings, indexed as the verdict
|
|
508
|
+
* @param {Iterable<number>} conflictIndices — the `finding_conflicts` indexes
|
|
509
|
+
* @returns {object[]} the same `findings` array
|
|
510
|
+
*/
|
|
511
|
+
export function rejectFindingConflicts(findings, conflictIndices) {
|
|
512
|
+
const rejected = new Set(conflictIndices);
|
|
513
|
+
for (const [i, f] of findings.entries()) {
|
|
514
|
+
if (rejected.has(i) && f.judgeDisposition !== "reject") {
|
|
515
|
+
f.judgeRationale = `spec-authority finding_conflicts: rejected against the spec (was relevance-${f.judgeDisposition}) — ${f.judgeRationale ?? ""}`.trim();
|
|
516
|
+
f.judgeDisposition = "reject";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return findings;
|
|
520
|
+
}
|
|
521
|
+
|
|
501
522
|
/**
|
|
502
523
|
* Resolve which prior criterion approvals survive a revision change. This is the
|
|
503
524
|
* one authority for both invalidation rules:
|
|
@@ -3,7 +3,7 @@ import { runPickupRefinementGate } from "../loop/issue-refinement-artifact.mjs";
|
|
|
3
3
|
import { loadStateColumnMap, LOGICAL_COLUMN } from "../loop/queue-board-sync.mjs";
|
|
4
4
|
import { resolveProjectSelector, findProject, parseItemRef } from "./resolve-project.mjs";
|
|
5
5
|
import { ghGraphql, resolveOwner } from "../github/gh.mjs";
|
|
6
|
-
import { validateProjectsRepo, discoverProjects, listProjectFields,
|
|
6
|
+
import { validateProjectsRepo, discoverProjects, listProjectFields, extractStatus, resolveProjectItem } from "./projects-access.mjs";
|
|
7
7
|
|
|
8
8
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
9
9
|
|
|
@@ -11,33 +11,6 @@ const validateRepo = validateProjectsRepo;
|
|
|
11
11
|
|
|
12
12
|
// ── GraphQL fragments ────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
const GET_PROJECT_ITEMS_BY_CONTENT = [
|
|
15
|
-
"query($projectId:ID!, $after:String) {",
|
|
16
|
-
" node(id:$projectId) {",
|
|
17
|
-
" ... on ProjectV2 {",
|
|
18
|
-
" items(first:100, after:$after, orderBy:{field:POSITION, direction:ASC}) {",
|
|
19
|
-
" pageInfo { hasNextPage endCursor }",
|
|
20
|
-
" nodes {",
|
|
21
|
-
" id",
|
|
22
|
-
" fieldValues(first:20) {",
|
|
23
|
-
" nodes {",
|
|
24
|
-
" ... on ProjectV2ItemFieldSingleSelectValue {",
|
|
25
|
-
" field { ... on ProjectV2SingleSelectField { id name } }",
|
|
26
|
-
" name",
|
|
27
|
-
" }",
|
|
28
|
-
" }",
|
|
29
|
-
" }",
|
|
30
|
-
" content {",
|
|
31
|
-
" ... on Issue { __typename number repository { nameWithOwner } }",
|
|
32
|
-
" ... on PullRequest { __typename number repository { nameWithOwner } }",
|
|
33
|
-
" }",
|
|
34
|
-
" }",
|
|
35
|
-
" }",
|
|
36
|
-
" }",
|
|
37
|
-
" }",
|
|
38
|
-
"}"
|
|
39
|
-
].join("\n");
|
|
40
|
-
|
|
41
14
|
const UPDATE_ITEM_FIELD = [
|
|
42
15
|
"mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) {",
|
|
43
16
|
" updateProjectV2ItemFieldValue(input:{projectId:$projectId, itemId:$itemId, fieldId:$fieldId, value:{singleSelectOptionId:$optionId}}) {",
|
|
@@ -53,19 +26,6 @@ const UPDATE_ITEM_FIELD = [
|
|
|
53
26
|
const listAllProjects = discoverProjects;
|
|
54
27
|
const listAllFields = listProjectFields;
|
|
55
28
|
|
|
56
|
-
// ── Paginated item listing (position order) ──────────────────────────────
|
|
57
|
-
|
|
58
|
-
function fetchAllItems(projectId, env, runChild) {
|
|
59
|
-
return paginateNodes({
|
|
60
|
-
query: GET_PROJECT_ITEMS_BY_CONTENT,
|
|
61
|
-
variables: { projectId },
|
|
62
|
-
selectConnection: (payload) => payload?.data?.node?.items,
|
|
63
|
-
env,
|
|
64
|
-
runChild,
|
|
65
|
-
entity: "items",
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
29
|
const statusOf = extractStatus;
|
|
70
30
|
|
|
71
31
|
// ── Exit code classification ────────────────────────────────────────────
|
|
@@ -84,7 +44,7 @@ function classifyExitCode(err) {
|
|
|
84
44
|
async function main(args, { env = process.env, runChild, cwd = null } = {}) {
|
|
85
45
|
const child = runChild ?? _runChild;
|
|
86
46
|
const repo = validateRepo(args.repo);
|
|
87
|
-
const [owner
|
|
47
|
+
const [owner] = repo.split("/");
|
|
88
48
|
const selector = resolveProjectSelector(args);
|
|
89
49
|
const itemRef = parseItemRef(args.item);
|
|
90
50
|
const toColumn = (args.toColumn ?? "").trim();
|
|
@@ -122,43 +82,9 @@ async function main(args, { env = process.env, runChild, cwd = null } = {}) {
|
|
|
122
82
|
);
|
|
123
83
|
}
|
|
124
84
|
|
|
125
|
-
// 4. Find the item
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
// BOTH ref kinds against it. This reuses the proven pattern from
|
|
129
|
-
// reorder-queue-item / list-queue-items: a node-id ref matches by item.id, a
|
|
130
|
-
// number ref matches by content.number. Both are scoped to the requested repo
|
|
131
|
-
// so a cross-project ref fails closed with ITEM_NOT_FOUND. (The previous code
|
|
132
|
-
// used `ProjectV2.item` — a field that does not exist — for the node-id path,
|
|
133
|
-
// and a single non-paginated `items(first:10)` page for the number path, so it
|
|
134
|
-
// could not find items beyond the first page.)
|
|
135
|
-
const allItems = await fetchAllItems(project.id, env, child);
|
|
136
|
-
|
|
137
|
-
let match;
|
|
138
|
-
if (itemRef.kind === "id") {
|
|
139
|
-
match = allItems.find(
|
|
140
|
-
(it) => it.id === itemRef.value && it.content?.repository?.nameWithOwner === repo,
|
|
141
|
-
);
|
|
142
|
-
if (!match) {
|
|
143
|
-
throw Object.assign(
|
|
144
|
-
new Error(`Item "${itemRef.value}" not found in project "${project.title}" for repo "${repo}"`),
|
|
145
|
-
{ code: "ITEM_NOT_FOUND" },
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
} else {
|
|
149
|
-
match = allItems.find(
|
|
150
|
-
(it) =>
|
|
151
|
-
it.content &&
|
|
152
|
-
it.content.repository?.nameWithOwner === repo &&
|
|
153
|
-
it.content.number === itemRef.value,
|
|
154
|
-
);
|
|
155
|
-
if (!match) {
|
|
156
|
-
throw Object.assign(
|
|
157
|
-
new Error(`Item #${itemRef.value} not found in project "${project.title}" for repo "${repo}"`),
|
|
158
|
-
{ code: "ITEM_NOT_FOUND" },
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
85
|
+
// 4. Find the item from the issue side or by node, never from the board
|
|
86
|
+
// listing: `ProjectV2.items` can lag behind GitHub and omit new items.
|
|
87
|
+
const match = await resolveProjectItem({ projectId: project.id, projectTitle: project.title, repo, itemRef, env, runChild: child });
|
|
162
88
|
|
|
163
89
|
const itemId = match.id;
|
|
164
90
|
const previousColumn = statusOf(match);
|
|
@@ -200,3 +200,127 @@ export function extractStatus(node) {
|
|
|
200
200
|
}
|
|
201
201
|
return null;
|
|
202
202
|
}
|
|
203
|
+
|
|
204
|
+
// ── Single item resolution ─────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
// Item projection shared by both lookups. It matches the board listing node
|
|
207
|
+
// shape (id, fieldValues, content) so `extractStatus` works on the result.
|
|
208
|
+
const ITEM_FIELDS = [
|
|
209
|
+
"id isArchived project { id }",
|
|
210
|
+
"fieldValues(first:20) {",
|
|
211
|
+
" nodes {",
|
|
212
|
+
" ... on ProjectV2ItemFieldSingleSelectValue {",
|
|
213
|
+
" field { ... on ProjectV2SingleSelectField { id name } }",
|
|
214
|
+
" name",
|
|
215
|
+
" }",
|
|
216
|
+
" }",
|
|
217
|
+
"}",
|
|
218
|
+
"content {",
|
|
219
|
+
" ... on Issue { __typename number repository { nameWithOwner } }",
|
|
220
|
+
" ... on PullRequest { __typename number repository { nameWithOwner } }",
|
|
221
|
+
"}",
|
|
222
|
+
].join("\n");
|
|
223
|
+
|
|
224
|
+
const GET_ITEMS_BY_CONTENT_NUMBER = [
|
|
225
|
+
"query($owner:String!, $name:String!, $number:Int!, $after:String) {",
|
|
226
|
+
" repository(owner:$owner, name:$name) {",
|
|
227
|
+
" issueOrPullRequest(number:$number) {",
|
|
228
|
+
` ... on Issue { projectItems(first:100, after:$after, includeArchived:false) { pageInfo { hasNextPage endCursor } nodes { ${ITEM_FIELDS} } } }`,
|
|
229
|
+
` ... on PullRequest { projectItems(first:100, after:$after, includeArchived:false) { pageInfo { hasNextPage endCursor } nodes { ${ITEM_FIELDS} } } }`,
|
|
230
|
+
" }",
|
|
231
|
+
" }",
|
|
232
|
+
"}",
|
|
233
|
+
].join("\n");
|
|
234
|
+
|
|
235
|
+
const GET_ITEM_BY_ID = [
|
|
236
|
+
"query($id:ID!) {",
|
|
237
|
+
` node(id:$id) { ... on ProjectV2Item { ${ITEM_FIELDS} } }`,
|
|
238
|
+
"}",
|
|
239
|
+
].join("\n");
|
|
240
|
+
|
|
241
|
+
function itemNotFound(message) {
|
|
242
|
+
return Object.assign(new Error(message), { code: "ITEM_NOT_FOUND" });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Throw GRAPHQL_ERROR for any GraphQL error in `payload` other than NOT_FOUND.
|
|
247
|
+
* Use it on a `ghGraphql(..., { allowErrors: true })` payload where NOT_FOUND
|
|
248
|
+
* means "no such entity" and every other error must keep its message.
|
|
249
|
+
*/
|
|
250
|
+
export function assertOnlyNotFoundErrors(payload) {
|
|
251
|
+
const other = (payload?.errors ?? []).filter((e) => e?.type !== "NOT_FOUND");
|
|
252
|
+
if (other.length > 0) {
|
|
253
|
+
throw Object.assign(
|
|
254
|
+
new Error(`GraphQL errors: ${other.map((e) => e.message).join("; ")}`),
|
|
255
|
+
{ code: "GRAPHQL_ERROR" },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve one project item without the whole-board `ProjectV2.items` listing,
|
|
262
|
+
* which can lag behind GitHub by hours and omit newly added items.
|
|
263
|
+
*
|
|
264
|
+
* - number ref: read the issue or PR's own `projectItems` and pick the
|
|
265
|
+
* unarchived item on `projectId`.
|
|
266
|
+
* - id ref: look the item node up directly, then verify that it belongs to
|
|
267
|
+
* `projectId` and that its content is in `repo`.
|
|
268
|
+
*
|
|
269
|
+
* Every miss or mismatch fails closed with code ITEM_NOT_FOUND. Returns a node
|
|
270
|
+
* in the listing shape: `{ id, isArchived, project, fieldValues, content }`.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} opts
|
|
273
|
+
* @param {string} opts.projectId
|
|
274
|
+
* @param {string} [opts.projectTitle] shown in ITEM_NOT_FOUND messages when present
|
|
275
|
+
* @param {string} opts.repo validated `owner/name`
|
|
276
|
+
* @param {{kind:"number"|"id", value:number|string}} opts.itemRef from parseItemRef
|
|
277
|
+
* @param {object} opts.env
|
|
278
|
+
* @param {Function} opts.runChild
|
|
279
|
+
*/
|
|
280
|
+
export async function resolveProjectItem({ projectId, projectTitle, repo, itemRef, env, runChild }) {
|
|
281
|
+
const projectLabel = projectTitle ?? projectId;
|
|
282
|
+
if (itemRef.kind === "number") {
|
|
283
|
+
const [owner, name] = repo.split("/");
|
|
284
|
+
let after = null;
|
|
285
|
+
while (true) {
|
|
286
|
+
const vars = { owner, name, number: itemRef.value };
|
|
287
|
+
if (after) vars.after = after;
|
|
288
|
+
const payload = await ghGraphql(GET_ITEMS_BY_CONTENT_NUMBER, vars, env, runChild, { allowErrors: true });
|
|
289
|
+
const connection = payload?.data?.repository?.issueOrPullRequest?.projectItems;
|
|
290
|
+
const match = (connection?.nodes ?? []).find((n) => n && n.project?.id === projectId && !n.isArchived);
|
|
291
|
+
// A partial response can carry errors for other boards the token cannot
|
|
292
|
+
// read (e.g. FORBIDDEN). A match on the configured board wins; the error
|
|
293
|
+
// is raised only when no page holds a match.
|
|
294
|
+
if (match) return match;
|
|
295
|
+
const pageInfo = connection?.pageInfo ?? {};
|
|
296
|
+
if (!pageInfo.hasNextPage) {
|
|
297
|
+
assertOnlyNotFoundErrors(payload);
|
|
298
|
+
throw itemNotFound(`Item #${itemRef.value} not found in project "${projectLabel}" for repo "${repo}"`);
|
|
299
|
+
}
|
|
300
|
+
if (!pageInfo.endCursor) {
|
|
301
|
+
throw Object.assign(
|
|
302
|
+
new Error("Invalid projectItems payload: hasNextPage is true but endCursor is missing"),
|
|
303
|
+
{ code: "GH_API_ERROR" },
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
after = pageInfo.endCursor;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const payload = await ghGraphql(GET_ITEM_BY_ID, { id: itemRef.value }, env, runChild, { allowErrors: true });
|
|
311
|
+
assertOnlyNotFoundErrors(payload);
|
|
312
|
+
const node = payload?.data?.node;
|
|
313
|
+
if (!node?.id || node.isArchived) {
|
|
314
|
+
throw itemNotFound(`Item "${itemRef.value}" not found in project "${projectLabel}" for repo "${repo}"`);
|
|
315
|
+
}
|
|
316
|
+
if (node.project?.id !== projectId) {
|
|
317
|
+
throw itemNotFound(
|
|
318
|
+
`Item "${itemRef.value}" belongs to project "${node.project?.id ?? "(unknown)"}", not "${projectId}"`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
const itemRepo = node.content?.repository?.nameWithOwner ?? null;
|
|
322
|
+
if (itemRepo !== repo) {
|
|
323
|
+
throw itemNotFound(`Item "${itemRef.value}" is for repo "${itemRepo ?? "(none)"}", not "${repo}"`);
|
|
324
|
+
}
|
|
325
|
+
return node;
|
|
326
|
+
}
|