@dev-loops/core 1.0.0-rc.6 → 1.0.0-rc.7
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/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +36 -4
- package/src/cli/primitives.mjs +30 -1
- package/src/config/config.mjs +254 -13
- package/src/config/extension-defaults.yaml +34 -1
- package/src/github/comment-id-guard.mjs +97 -9
- package/src/github/copilot-helpers.mjs +114 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +7 -0
- package/src/loop/agent-stall.mjs +4 -2
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +34 -1
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +12 -19
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +34 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +448 -9
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +1 -27
- package/src/security/secret-scan.mjs +330 -0
|
@@ -6,14 +6,32 @@
|
|
|
6
6
|
* scripts and other packages/core modules.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { GATE_REVIEW_VERDICT_SET } from "../loop/policy-constants.mjs";
|
|
10
|
+
import { trimmedOrNull } from "../loop/normalize.mjs";
|
|
11
|
+
|
|
9
12
|
// Exported so anything deciding "is there a real prior review" uses the same
|
|
10
13
|
// whitelist as the loop-state reader — two copies could drift, and a guard
|
|
11
14
|
// acting on the gate's behalf must agree with the gate about what a submitted
|
|
12
15
|
// review is.
|
|
13
16
|
export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
14
17
|
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
15
|
-
|
|
18
|
+
// `review` (the standalone review entrypoint, upsert-checkpoint-verdict.mjs's
|
|
19
|
+
// `--gate review`) is a RECOGNIZED gate header — it is identified, not
|
|
20
|
+
// absent — but carries no draft/pre-approval evidence by design (#1808 AC3).
|
|
21
|
+
// Recognizing it (rather than leaving it unrecognized) is what lets
|
|
22
|
+
// parseGateReviewCommentFields below short-circuit to null the instant a
|
|
23
|
+
// `review` header is seen, instead of falling through to the lenient
|
|
24
|
+
// draft_gate/pre_approval_gate token scan — the fallthrough that previously
|
|
25
|
+
// let a `review` verdict whose findings text merely MENTIONED "draft_gate"
|
|
26
|
+
// get recorded as real draft-gate evidence (a draft-gate bypass).
|
|
27
|
+
const NON_EVIDENCE_GATE_NAMES = new Set(["review"]);
|
|
28
|
+
const RECOGNIZED_GATE_NAMES = new Set([...GATE_REVIEW_NAMES, ...NON_EVIDENCE_GATE_NAMES]);
|
|
16
29
|
const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
|
|
30
|
+
// Size-budget outcome vocabulary — mirrors
|
|
31
|
+
// check-size-budget.mjs's computeSizeBudget outcome enum exactly; this file
|
|
32
|
+
// never recomputes the outcome, only round-trips it through the verdict
|
|
33
|
+
// comment.
|
|
34
|
+
const GATE_SIZE_OUTCOMES = new Set(["pass", "escalate", "block"]);
|
|
17
35
|
|
|
18
36
|
// The literal header line the gate review body always emits first
|
|
19
37
|
// (upsert-checkpoint-verdict.mjs's renderGateReviewCommentBody, re-exported
|
|
@@ -288,14 +306,19 @@ function stripGateCommentMarkdown(rawLine) {
|
|
|
288
306
|
return line.trim();
|
|
289
307
|
}
|
|
290
308
|
|
|
309
|
+
// Recognizes BOTH evidence gates (draft_gate/pre_approval_gate) and the
|
|
310
|
+
// non-evidence `review` gate — parseGateReviewCommentFields below relies on
|
|
311
|
+
// `review` coming back as an identified value (not null) so it can
|
|
312
|
+
// short-circuit to non-evidence explicitly, rather than leaving the field
|
|
313
|
+
// null and falling through to the lenient token-scan fallback.
|
|
291
314
|
function normalizeGateReviewName(value) {
|
|
292
315
|
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
293
|
-
return
|
|
316
|
+
return RECOGNIZED_GATE_NAMES.has(normalized) ? normalized : null;
|
|
294
317
|
}
|
|
295
318
|
|
|
296
319
|
function normalizeGateReviewVerdict(value) {
|
|
297
320
|
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
298
|
-
return
|
|
321
|
+
return GATE_REVIEW_VERDICT_SET.has(normalized) ? normalized : null;
|
|
299
322
|
}
|
|
300
323
|
|
|
301
324
|
function normalizeGateReviewHeadSha(value) {
|
|
@@ -308,6 +331,32 @@ function normalizeGateExecutionMode(value) {
|
|
|
308
331
|
return GATE_EXECUTION_MODES.has(normalized) ? normalized : null;
|
|
309
332
|
}
|
|
310
333
|
|
|
334
|
+
function normalizeGateSizeOutcome(value) {
|
|
335
|
+
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
336
|
+
return GATE_SIZE_OUTCOMES.has(normalized) ? normalized : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function normalizeGateSizeTouchesT1(value) {
|
|
340
|
+
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
341
|
+
if (normalized === "touched") return true;
|
|
342
|
+
if (normalized === "not touched") return false;
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// "none" | "granted" | "granted by <name>" — the approver name is free text
|
|
347
|
+
// (already sanitized by the poster via sanitizeInline before rendering), so
|
|
348
|
+
// no further normalization beyond trimming is applied here.
|
|
349
|
+
function normalizeGateSizeWaiver(value) {
|
|
350
|
+
const normalized = stripOptionalCodeTicks(value).trim();
|
|
351
|
+
if (/^none$/iu.test(normalized)) return { granted: false, approvedBy: null };
|
|
352
|
+
const grantedMatch = normalized.match(/^granted(?:\s+by\s+(.+))?$/iu);
|
|
353
|
+
if (grantedMatch) {
|
|
354
|
+
const approvedBy = grantedMatch[1]?.trim();
|
|
355
|
+
return { granted: true, approvedBy: approvedBy && approvedBy.length > 0 ? approvedBy : null };
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
311
360
|
function parseGateReviewCommentFields(body) {
|
|
312
361
|
if (typeof body !== "string" || body.trim().length === 0) {
|
|
313
362
|
return null;
|
|
@@ -321,6 +370,10 @@ function parseGateReviewCommentFields(body) {
|
|
|
321
370
|
nextAction: null,
|
|
322
371
|
executionMode: null,
|
|
323
372
|
inlineReason: null,
|
|
373
|
+
sizeOutcome: null,
|
|
374
|
+
sizeTouchesT1: null,
|
|
375
|
+
sizeWaiverGranted: null,
|
|
376
|
+
sizeWaiverApprovedBy: null,
|
|
324
377
|
};
|
|
325
378
|
|
|
326
379
|
for (const rawLine of body.split(/\r?\n/u)) {
|
|
@@ -413,6 +466,50 @@ function parseGateReviewCommentFields(body) {
|
|
|
413
466
|
}
|
|
414
467
|
continue;
|
|
415
468
|
}
|
|
469
|
+
|
|
470
|
+
match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+outcome\s*:\s*(.+)$/iu);
|
|
471
|
+
if (match) {
|
|
472
|
+
if (fields.sizeOutcome === null) {
|
|
473
|
+
fields.sizeOutcome = normalizeGateSizeOutcome(match[1]);
|
|
474
|
+
}
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+t1\s+slice\s*:\s*(.+)$/iu);
|
|
479
|
+
if (match) {
|
|
480
|
+
if (fields.sizeTouchesT1 === null) {
|
|
481
|
+
fields.sizeTouchesT1 = normalizeGateSizeTouchesT1(match[1]);
|
|
482
|
+
}
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+waiver\s*:\s*(.+)$/iu);
|
|
487
|
+
if (match) {
|
|
488
|
+
if (fields.sizeWaiverGranted === null) {
|
|
489
|
+
const parsedWaiver = normalizeGateSizeWaiver(match[1]);
|
|
490
|
+
if (parsedWaiver) {
|
|
491
|
+
fields.sizeWaiverGranted = parsedWaiver.granted;
|
|
492
|
+
fields.sizeWaiverApprovedBy = parsedWaiver.approvedBy;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// An explicit, RECOGNIZED `review` gate field is authoritative and returns
|
|
500
|
+
// null here — before the lenient token-scan fallback below ever runs. A
|
|
501
|
+
// `review` verdict comment carries no draft/pre-approval evidence by
|
|
502
|
+
// design (#1808 AC3); without this short circuit, `fields.gate` would stay
|
|
503
|
+
// "review" (not one of the two evidence gates) but the lenient fallback
|
|
504
|
+
// below only fires when `!fields.gate` — so a *recognized* `review` gate
|
|
505
|
+
// would otherwise skip the fallback yet still return non-null fields keyed
|
|
506
|
+
// to "review", which is harmless for the two summarizers here (they only
|
|
507
|
+
// read `.draft_gate`/`.pre_approval_gate`) but leaves the non-evidence
|
|
508
|
+
// contract implicit rather than explicit. Stated plainly: an identified
|
|
509
|
+
// non-evidence gate must never be treated as an unidentified body, and an
|
|
510
|
+
// unidentified body is the ONLY case the token-scan fallback exists for.
|
|
511
|
+
if (NON_EVIDENCE_GATE_NAMES.has(fields.gate)) {
|
|
512
|
+
return null;
|
|
416
513
|
}
|
|
417
514
|
|
|
418
515
|
// Lenient fallback: detect gate name and head SHA anywhere in body
|
|
@@ -481,6 +578,10 @@ export function parseGateReviewCommentMarkerBody(body) {
|
|
|
481
578
|
nextAction: fields.nextAction,
|
|
482
579
|
executionMode: fields.executionMode,
|
|
483
580
|
inlineReason: fields.inlineReason,
|
|
581
|
+
sizeOutcome: fields.sizeOutcome,
|
|
582
|
+
sizeTouchesT1: fields.sizeTouchesT1,
|
|
583
|
+
sizeWaiverGranted: fields.sizeWaiverGranted,
|
|
584
|
+
sizeWaiverApprovedBy: fields.sizeWaiverApprovedBy,
|
|
484
585
|
contractComplete: Boolean(fields.verdict && fields.findingsSummary && fields.nextAction),
|
|
485
586
|
};
|
|
486
587
|
}
|
|
@@ -525,9 +626,13 @@ export function summarizeGateReviewComments(comments) {
|
|
|
525
626
|
nextAction: parsed.nextAction,
|
|
526
627
|
executionMode: parsed.executionMode ?? null,
|
|
527
628
|
inlineReason: parsed.inlineReason ?? null,
|
|
629
|
+
sizeOutcome: parsed.sizeOutcome ?? null,
|
|
630
|
+
sizeTouchesT1: parsed.sizeTouchesT1 ?? null,
|
|
631
|
+
sizeWaiverGranted: parsed.sizeWaiverGranted ?? null,
|
|
632
|
+
sizeWaiverApprovedBy: parsed.sizeWaiverApprovedBy ?? null,
|
|
528
633
|
surface: normalizeVerdictSurface(comment?.surface),
|
|
529
634
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
530
|
-
commentUrl:
|
|
635
|
+
commentUrl: trimmedOrNull(comment?.html_url),
|
|
531
636
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
532
637
|
? (comment.updated_at ?? comment.updatedAt).trim()
|
|
533
638
|
: typeof (comment?.created_at ?? comment?.createdAt) === "string"
|
|
@@ -579,10 +684,14 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
579
684
|
nextAction: parsed.nextAction,
|
|
580
685
|
executionMode: parsed.executionMode ?? null,
|
|
581
686
|
inlineReason: parsed.inlineReason ?? null,
|
|
687
|
+
sizeOutcome: parsed.sizeOutcome ?? null,
|
|
688
|
+
sizeTouchesT1: parsed.sizeTouchesT1 ?? null,
|
|
689
|
+
sizeWaiverGranted: parsed.sizeWaiverGranted ?? null,
|
|
690
|
+
sizeWaiverApprovedBy: parsed.sizeWaiverApprovedBy ?? null,
|
|
582
691
|
contractComplete: parsed.contractComplete,
|
|
583
692
|
surface: normalizeVerdictSurface(comment?.surface),
|
|
584
693
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
585
|
-
commentUrl:
|
|
694
|
+
commentUrl: trimmedOrNull(comment?.html_url),
|
|
586
695
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
587
696
|
? (comment.updated_at ?? comment.updatedAt).trim()
|
|
588
697
|
: typeof (comment?.created_at ?? comment?.createdAt) === "string"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `gh` CLI invoke-and-parse helpers (child 6 of the simplification
|
|
3
|
+
* epic #1689). This module introduces the shared implementation only; the
|
|
4
|
+
* ~19 call-site migrations are the follow-up children (projects: #1696;
|
|
5
|
+
* github/loop/refine: #1697).
|
|
6
|
+
*
|
|
7
|
+
* `ghJson` is the composed SUPERSET the callers share (per #1695's AC): a
|
|
8
|
+
* label-conditional non-zero-exit message — `gh command failed: <detail>` with
|
|
9
|
+
* no label (probe-ci-status shape) or `<label> failed: <detail>` with one
|
|
10
|
+
* (fetch-ci-logs shape) — always carrying `code: "GH_API_ERROR"`, plus the
|
|
11
|
+
* inline `Invalid JSON from gh: <stdout|<empty>>` malformed-stdout shape
|
|
12
|
+
* (probe-ci-status). `ghGraphql` reproduces scripts/projects/add-queue-item.mjs's
|
|
13
|
+
* superset (`gh api graphql failed` / `GraphQL errors:` with GH_API_ERROR /
|
|
14
|
+
* GRAPHQL_ERROR; `parseJsonText` → `Invalid JSON input`).
|
|
15
|
+
*
|
|
16
|
+
* Because `ghJson` is a composed superset, NO current caller matches it exactly
|
|
17
|
+
* — migrating each is a deliberate behavior harmonization, not a blind swap:
|
|
18
|
+
* - probe-ci-status.mjs: `gh command failed:` / `Invalid JSON from gh:` already
|
|
19
|
+
* match; it gains the `GH_API_ERROR` code on non-zero exit.
|
|
20
|
+
* - fetch-ci-logs.mjs: `<label> failed:` matches (pass its label); it gains the
|
|
21
|
+
* `GH_API_ERROR` code and its malformed-JSON message becomes
|
|
22
|
+
* `Invalid JSON from gh:` (was `parseJsonText` → `Invalid JSON input`).
|
|
23
|
+
* - upsert-checkpoint-verdict.mjs / post-gate-findings.mjs: gain the code and
|
|
24
|
+
* their malformed-JSON message becomes `Invalid JSON from gh:` (was
|
|
25
|
+
* `Invalid JSON input`).
|
|
26
|
+
* The follow-up children (projects: #1696; github/loop/refine: #1697) migrate
|
|
27
|
+
* each caller and update its pinned test messages accordingly.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { runChild as defaultRunChild } from "../cli/primitives.mjs";
|
|
31
|
+
import { parseJsonText } from "./review-threads.mjs";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run a `gh` subcommand and parse its stdout as JSON. Fails loudly on a
|
|
35
|
+
* non-zero exit (naming the command's stderr) and on malformed JSON stdout.
|
|
36
|
+
*
|
|
37
|
+
* @param {string[]} args - argv passed to `ghCommand`.
|
|
38
|
+
* @param {object} [opts]
|
|
39
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
40
|
+
* @param {string} [opts.ghCommand] - defaults to `"gh"`.
|
|
41
|
+
* @param {typeof defaultRunChild} [opts.runChild] - injectable child-exec seam.
|
|
42
|
+
* @param {string} [opts.label] - controls the non-zero-exit message: when set,
|
|
43
|
+
* the thrown error reads `<label> failed: <detail>` (the fetch-ci-logs shape);
|
|
44
|
+
* when omitted it reads `gh command failed: <detail>` (the probe-ci-status
|
|
45
|
+
* shape). Either way the non-zero-exit error carries `code: "GH_API_ERROR"`.
|
|
46
|
+
*/
|
|
47
|
+
export async function ghJson(args, { env, ghCommand = "gh", runChild = defaultRunChild, label } = {}) {
|
|
48
|
+
const result = await runChild(ghCommand, args, env);
|
|
49
|
+
if (result.code !== 0) {
|
|
50
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
51
|
+
const prefix = label ? `${label} failed` : "gh command failed";
|
|
52
|
+
throw Object.assign(new Error(`${prefix}: ${detail}`), { code: "GH_API_ERROR" });
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(result.stdout);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error(`Invalid JSON from gh: ${result.stdout.trim() || "<empty>"}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run a `gh api graphql` query and parse its response.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} query - the GraphQL document.
|
|
65
|
+
* @param {Record<string, string>} vars - `--field key=value` variables.
|
|
66
|
+
* @param {NodeJS.ProcessEnv} env
|
|
67
|
+
* @param {typeof defaultRunChild} [runChild] - injectable child-exec seam.
|
|
68
|
+
* @param {object} [opts]
|
|
69
|
+
* @param {boolean} [opts.allowErrors] - when true, a GraphQL `errors` array
|
|
70
|
+
* in the response is returned instead of thrown.
|
|
71
|
+
*/
|
|
72
|
+
export async function ghGraphql(query, vars, env, runChild = defaultRunChild, { allowErrors = false } = {}) {
|
|
73
|
+
const fieldArgs = [];
|
|
74
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
75
|
+
fieldArgs.push("--field", `${key}=${value}`);
|
|
76
|
+
}
|
|
77
|
+
const result = await runChild(
|
|
78
|
+
"gh",
|
|
79
|
+
["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
|
|
80
|
+
env,
|
|
81
|
+
);
|
|
82
|
+
if (result.code !== 0) {
|
|
83
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
84
|
+
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
85
|
+
}
|
|
86
|
+
const payload = parseJsonText(result.stdout);
|
|
87
|
+
if (!allowErrors && payload.errors && payload.errors.length > 0) {
|
|
88
|
+
throw Object.assign(
|
|
89
|
+
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
90
|
+
{ code: "GRAPHQL_ERROR" },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return payload;
|
|
94
|
+
}
|
package/src/github/issue-ops.mjs
CHANGED
|
@@ -288,6 +288,13 @@ export async function listIssues(options, { env = process.env, ghCommand = "gh",
|
|
|
288
288
|
for (const label of options.labels ?? []) {
|
|
289
289
|
args.push("--label", label);
|
|
290
290
|
}
|
|
291
|
+
// `--search` narrows the result set to gh's own full-text search (title,
|
|
292
|
+
// body, comments) rather than the bare paged listing — needed by a caller
|
|
293
|
+
// that must find one specific issue by title without trusting that it falls
|
|
294
|
+
// within the default 30-issue page.
|
|
295
|
+
if (typeof options.search === "string" && options.search.length > 0) {
|
|
296
|
+
args.push("--search", options.search);
|
|
297
|
+
}
|
|
291
298
|
const result = await run(ghCommand, args, env);
|
|
292
299
|
if (result.code !== 0) {
|
|
293
300
|
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
package/src/loop/agent-stall.mjs
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* sources those signals from pi run artifacts + runner-coordination state.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
23
|
+
|
|
22
24
|
export const AGENT_STALL_STATUS = Object.freeze({
|
|
23
25
|
STALLED: "stalled",
|
|
24
26
|
NOT_STALLED: "not_stalled",
|
|
@@ -178,8 +180,8 @@ export function buildAgentStallRecoveryBrief({
|
|
|
178
180
|
lastAction = null,
|
|
179
181
|
reason = "",
|
|
180
182
|
} = {}) {
|
|
181
|
-
const r =
|
|
182
|
-
const work =
|
|
183
|
+
const r = trimmedOrNull(runId);
|
|
184
|
+
const work = trimmedOrNull(cwd);
|
|
183
185
|
const action = typeof lastAction === "string" && lastAction.trim().length > 0
|
|
184
186
|
? lastAction.trim()
|
|
185
187
|
: "unknown last action";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SUBMITTED_REVIEW_STATES, isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
|
|
2
|
+
import { trimmedOrNull } from "./normalize.mjs";
|
|
2
3
|
|
|
3
4
|
const ACTIVE_COPILOT_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
|
|
4
5
|
|
|
@@ -70,7 +71,7 @@ function normalizeCommits(commits) {
|
|
|
70
71
|
sortKey: index,
|
|
71
72
|
committedAtMs: normalizeTimestamp(commit?.committedAt),
|
|
72
73
|
authorLogin: typeof commit?.authorLogin === "string" ? commit.authorLogin.trim() : "",
|
|
73
|
-
sha:
|
|
74
|
+
sha: trimmedOrNull(commit?.sha),
|
|
74
75
|
}))
|
|
75
76
|
.filter((commit) => commit.committedAtMs !== null)
|
|
76
77
|
.sort((left, right) => left.committedAtMs - right.committedAtMs || left.sortKey - right.sortKey);
|
|
@@ -91,12 +91,45 @@ export function renderGuardHook(hookName, defaultBranches = null, explicitBranch
|
|
|
91
91
|
}
|
|
92
92
|
const defaults = branches.join(" ");
|
|
93
93
|
const explicitDefaults = explicits.join(" ");
|
|
94
|
+
// pre-commit ONLY: runs the fail-closed secret scan BEFORE anything else in
|
|
95
|
+
// this hook — before the override-env check below (that override is a
|
|
96
|
+
// default-BRANCH escape hatch for a sanctioned release; it must never
|
|
97
|
+
// double as a secret-scan bypass, there is none) and before the
|
|
98
|
+
// unresolved-defaults early exit (a secret-scan must never go quiet just
|
|
99
|
+
// because branch resolution failed at install time). Git always invokes a
|
|
100
|
+
// hook with the working tree ROOT as its cwd (githooks(5)), so a plain
|
|
101
|
+
// cwd-relative path here already resolves against each worktree's OWN
|
|
102
|
+
// checked-out copy of the scanner — no --show-toplevel round trip needed.
|
|
103
|
+
// A missing scanner file is a checkout that predates this feature (nothing
|
|
104
|
+
// to run), not a bypass attempt, so it is skipped rather than blocked; a
|
|
105
|
+
// scanner that IS present and errors, or finds a hit, always blocks — see
|
|
106
|
+
// scripts/security/scan-staged-diff.mjs. Its stdout carries only the clean
|
|
107
|
+
// "{ok:true,hits:[]}" success payload (a hit's diagnostics go to STDERR,
|
|
108
|
+
// per the CLI's own `payload.ok ? stdout : stderr` split above) — silenced
|
|
109
|
+
// here so a normal commit prints no JSON noise, while a blocked commit's
|
|
110
|
+
// stderr (file/line/detector-class) still reaches the developer untouched.
|
|
111
|
+
// Shell var deliberately named "scan_cli" rather than e.g. "secret_scanner":
|
|
112
|
+
// this repo's own pre-commit hook (below) runs a heuristic sink-pattern
|
|
113
|
+
// detector that fires on a credential-shaped variable name feeding any
|
|
114
|
+
// stdout redirect, even one that discards output; naming it around a
|
|
115
|
+
// scanner CLI path rather than a credential keeps this file's own diff
|
|
116
|
+
// clean through its own hook.
|
|
117
|
+
const secretScanBlock = hookName === "pre-commit"
|
|
118
|
+
? `scan_cli="scripts/security/scan-staged-diff.mjs"
|
|
119
|
+
if [ -f "$scan_cli" ]; then
|
|
120
|
+
node "$scan_cli" >/dev/null
|
|
121
|
+
if [ "$?" != "0" ]; then
|
|
122
|
+
exit 1
|
|
123
|
+
fi
|
|
124
|
+
fi
|
|
125
|
+
`
|
|
126
|
+
: "";
|
|
94
127
|
const header = `#!/bin/sh
|
|
95
128
|
# ${GUARD_MARKER}
|
|
96
129
|
# Refuses a ${hookName} that would land on a guarded default branch. Installed
|
|
97
130
|
# in the common hook directory, so linked worktrees run it too — their branch
|
|
98
131
|
# is not one of the guarded ones, which is what lets their work through.
|
|
99
|
-
if [ "\${${GUARD_OVERRIDE_ENV}}" = "1" ]; then
|
|
132
|
+
${secretScanBlock}if [ "\${${GUARD_OVERRIDE_ENV}}" = "1" ]; then
|
|
100
133
|
exit 0
|
|
101
134
|
fi
|
|
102
135
|
defaults="${defaults}"
|
|
@@ -57,7 +57,10 @@ import { ALWAYS_INCLUDE, CATEGORY_ANGLE_MAP } from "../analysis/change-classifie
|
|
|
57
57
|
* @type {Record<string, string[]>}
|
|
58
58
|
*/
|
|
59
59
|
const KIND_TO_CATEGORIES = {
|
|
60
|
-
|
|
60
|
+
// #1442: a docs file is PROSE_PRESENT when it lands on the prose surface, so
|
|
61
|
+
// deslop's carry-forward surface is the `docs` kind (a non-docs delta never
|
|
62
|
+
// re-runs a clean deslop verdict).
|
|
63
|
+
docs: ["DOCS_ONLY", "PROSE_PRESENT"],
|
|
61
64
|
config: ["CONFIG_ONLY"],
|
|
62
65
|
test: ["TEST_ONLY"],
|
|
63
66
|
ci: ["CI_ONLY"],
|
|
@@ -108,6 +111,14 @@ const ANGLE_SURFACE_KINDS = (() => {
|
|
|
108
111
|
|
|
109
112
|
/**
|
|
110
113
|
* Resolve an angle's declared review surface (the pure angle -> surface mapping).
|
|
114
|
+
* `angle` is matched trim+lowercase against ALL THREE lookups below (the
|
|
115
|
+
* hardcoded ALWAYS_INCLUDE set, the caller-supplied `alwaysRerun` set, and the
|
|
116
|
+
* `kinds` map) — deliberately: this makes a case/whitespace-drifted name (e.g.
|
|
117
|
+
* "Correctness", "PR-Description") resolve the SAME surface as its canonical
|
|
118
|
+
* form, rather than falling through to `{ kind: "unknown" }`. That is a
|
|
119
|
+
* decision-affecting default: a case-drifted MAPPED angle now becomes
|
|
120
|
+
* carry-forward-eligible (via `resolveAngleCarryForward`) where an unnormalized
|
|
121
|
+
* lookup would have fail-closed it as unknown.
|
|
111
122
|
*
|
|
112
123
|
* - ALWAYS_INCLUDE angles (gate-evidence, renderer-security, pr-description) plus
|
|
113
124
|
* any explicit alwaysRerun angle -> `{ kind: "always" }`. These review a surface
|
|
@@ -122,15 +133,17 @@ const ANGLE_SURFACE_KINDS = (() => {
|
|
|
122
133
|
* @returns {AngleReviewSurface}
|
|
123
134
|
*/
|
|
124
135
|
export function angleReviewSurface(angle, { alwaysRerun } = {}) {
|
|
125
|
-
|
|
136
|
+
// Normalized ONCE here (trim+lowercase) so every caller — the hardcoded
|
|
137
|
+
// ALWAYS_INCLUDE check below, the configured alwaysRerun match, and the
|
|
138
|
+
// kinds lookup — agrees on one predicate regardless of whether the caller
|
|
139
|
+
// pre-lowercases (consolidate-fanin.mjs does; write-gate-context.mjs's
|
|
140
|
+
// mandatory-angle refusal does not, and must not have to).
|
|
141
|
+
const name = typeof angle === "string" ? angle.trim().toLowerCase() : "";
|
|
126
142
|
if (name.length === 0) return { kind: "unknown" };
|
|
127
143
|
if (ALWAYS_INCLUDE.has(name)) return { kind: "always" };
|
|
128
|
-
// Case-insensitive: callers key angles as base+lowercase while configs may
|
|
129
|
-
// carry case drift ("Correctness"); normalizing HERE keeps producer and
|
|
130
|
-
// consumer on one predicate instead of each caller pre-lowercasing.
|
|
131
144
|
if (alwaysRerun) {
|
|
132
145
|
const normalized = new Set([...alwaysRerun].map((entry) => String(entry).trim().toLowerCase()));
|
|
133
|
-
if (normalized.has(name
|
|
146
|
+
if (normalized.has(name)) return { kind: "always" };
|
|
134
147
|
}
|
|
135
148
|
const kinds = ANGLE_SURFACE_KINDS.get(name);
|
|
136
149
|
if (!kinds || kinds.size === 0) return { kind: "unknown" };
|