@dev-loops/core 1.0.0-rc.6 → 1.0.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 +8 -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/commit-msg-guard.mjs +168 -0
- 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/issue-refinement-artifact.mjs +186 -42
- 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 +11 -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/retrospective-checkpoint.mjs +59 -1
- 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 +2 -28
- 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";
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Enforces the commit-message contract AT COMMIT TIME (issue #1869): the
|
|
6
|
+
* attribution trailers, the no-bare-#N rule, and the conventional-commit
|
|
7
|
+
* subject form were previously prose-only — nothing checked them, so a
|
|
8
|
+
* non-compliant commit landed silently. Installed alongside the
|
|
9
|
+
* default-branch guard (see default-branch-guard.mjs), through the same
|
|
10
|
+
* ensure-worktree provisioning path, so it rides into every worktree too.
|
|
11
|
+
*
|
|
12
|
+
* The rendered hook is a single self-contained Node script (ESM: this repo's
|
|
13
|
+
* root package.json is `"type": "module"`, and Node resolves an
|
|
14
|
+
* extensionless direct-run script's module type by walking up for the
|
|
15
|
+
* nearest package.json — verified empirically, not merely assumed). Keeping
|
|
16
|
+
* the ENTIRE check inline in the rendered script (rather than requiring a
|
|
17
|
+
* sibling file back into the checkout) means the installed hook keeps
|
|
18
|
+
* working even if the checkout that installed it is later removed or moved
|
|
19
|
+
* — the same self-containment default-branch-guard's hooks rely on.
|
|
20
|
+
*/
|
|
21
|
+
export const COMMIT_MSG_GUARD_MARKER = "dev-loops:commit-msg-guard";
|
|
22
|
+
export const COMMIT_MSG_WAIVER_MARKER = `${COMMIT_MSG_GUARD_MARKER}:allow`;
|
|
23
|
+
|
|
24
|
+
// Ownership check mirrors default-branch-guard's: the marker must be its own
|
|
25
|
+
// line (a `//` comment, since the rendered hook is JS), not merely mentioned,
|
|
26
|
+
// so a foreign hook that references us in prose is still left untouched.
|
|
27
|
+
const GUARD_MARKER_LINE = new RegExp(`^// ${COMMIT_MSG_GUARD_MARKER}$`, "mu");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders the commit-msg hook as a standalone, runnable Node script. The
|
|
31
|
+
* validation logic below is the ONLY copy of it — there is no separate JS
|
|
32
|
+
* implementation this must stay in sync with, exactly like renderGuardHook's
|
|
33
|
+
* shell body has none either. Tests exercise it by actually running it (see
|
|
34
|
+
* commit-msg-guard.test.mjs), the same way default-branch-guard.test.mjs
|
|
35
|
+
* drives real git rather than asserting on rendered text.
|
|
36
|
+
*
|
|
37
|
+
* String.raw, not a plain template literal: the generated script is full of
|
|
38
|
+
* regex backslash escapes (\s, \d, \b, \.) that a normal template literal
|
|
39
|
+
* would silently strip (an unrecognized string escape drops its backslash),
|
|
40
|
+
* corrupting every regex in the installed hook. String.raw keeps every
|
|
41
|
+
* backslash literal while still substituting the ${...} marker constants.
|
|
42
|
+
* The generated script deliberately uses NO template literals of its own
|
|
43
|
+
* (string concatenation instead) — a literal backtick would otherwise close
|
|
44
|
+
* THIS OUTER template early.
|
|
45
|
+
*/
|
|
46
|
+
export function renderCommitMsgGuardHook() {
|
|
47
|
+
return String.raw`#!/usr/bin/env node
|
|
48
|
+
// ${COMMIT_MSG_GUARD_MARKER}
|
|
49
|
+
// Enforces the commit-message contract (issue #1869): attribution trailers,
|
|
50
|
+
// no bare non-issue #<digits>, and a conventional-commit subject. A
|
|
51
|
+
// per-commit waiver line (${COMMIT_MSG_WAIVER_MARKER}) skips every check
|
|
52
|
+
// below for a deliberate exception.
|
|
53
|
+
import { readFileSync } from "node:fs";
|
|
54
|
+
|
|
55
|
+
// git invokes commit-msg with ONLY the message-file path (unlike
|
|
56
|
+
// prepare-commit-msg, which also gets a source/sha) — no signal distinguishes
|
|
57
|
+
// an ordinary commit from a merge/squash at this hook. A default, unedited
|
|
58
|
+
// merge message ("Merge branch '...'", "Merge pull request #...", "Merge tag
|
|
59
|
+
// '...'"), a default git-revert message (Revert "..."), or a
|
|
60
|
+
// git commit --fixup/--squash autosquash subject (fixup! ... / squash! ...)
|
|
61
|
+
// is git/tooling-generated, not operator-authored prose, so each is exempt by
|
|
62
|
+
// its own recognizable shape rather than forced through a conventional-commit
|
|
63
|
+
// subject and trailers it was never meant to carry.
|
|
64
|
+
const [, , msgPath] = process.argv;
|
|
65
|
+
const message = readFileSync(msgPath, "utf8");
|
|
66
|
+
const subjectLine = message.split("\n", 1)[0] || "";
|
|
67
|
+
if (
|
|
68
|
+
/^Merge (branch|tag|remote-tracking branch|pull request) /u.test(subjectLine) ||
|
|
69
|
+
/^Revert "/u.test(subjectLine) ||
|
|
70
|
+
/^(fixup|squash)! /u.test(subjectLine)
|
|
71
|
+
) process.exit(0);
|
|
72
|
+
|
|
73
|
+
if (/^${COMMIT_MSG_GUARD_MARKER}:allow\b/mu.test(message)) process.exit(0);
|
|
74
|
+
|
|
75
|
+
const errors = [];
|
|
76
|
+
|
|
77
|
+
// Trailers are required only for an AGENT-authored commit: Claude Code sets
|
|
78
|
+
// CLAUDECODE=1 in every shell it spawns (the same harness-detection signal
|
|
79
|
+
// packages/core/src/loop/run-context.mjs's isClaudeHarness checks) — a plain
|
|
80
|
+
// human commit (CLAUDECODE unset) is never "Claude", so requiring a Claude
|
|
81
|
+
// co-author trailer on it would misattribute the commit, not enforce honesty.
|
|
82
|
+
if (process.env.CLAUDECODE === "1") {
|
|
83
|
+
if (!/^Co-Authored-By:\s*Claude\s+.+\s+<noreply@anthropic\.com>\s*$/imu.test(message)) {
|
|
84
|
+
errors.push("missing required trailer: Co-Authored-By: Claude <model> <noreply@anthropic.com>");
|
|
85
|
+
}
|
|
86
|
+
if (!/^Claude-Session:\s*\S+/imu.test(message)) {
|
|
87
|
+
errors.push("missing required trailer: Claude-Session: <url>");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A genuine "Closes #N" / "Fixes #N" / "Refs #N" reference (optionally a
|
|
92
|
+
// comma/and-joined list, and optionally the trailer colon form "Closes: #N")
|
|
93
|
+
// is allowed and stripped first; any #<digits> left over is a bare non-issue
|
|
94
|
+
// enumeration, which GitHub auto-links to an unrelated issue/PR when
|
|
95
|
+
// rendered.
|
|
96
|
+
const withoutAllowedRefs = message.replace(
|
|
97
|
+
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?|references?):?\s+#\d+(?:\s*(?:,|and)\s*#\d+)*/giu,
|
|
98
|
+
"",
|
|
99
|
+
);
|
|
100
|
+
if (/#\d+/u.test(withoutAllowedRefs)) {
|
|
101
|
+
errors.push('bare #<digits> reference found; use "Closes #N" / "Fixes #N" / "Refs #N" for a genuine issue reference, or reword a non-issue enumeration (e.g. "defect N")');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!/^(feat|fix|chore|docs|test|refactor|revert|perf|style|ci|build)\([^()\n]+\): .+\S/u.test(subjectLine)) {
|
|
105
|
+
errors.push("subject must be conventional-commit form \"type(scope): summary\" (type one of feat/fix/chore/docs/test/refactor/revert/perf/style/ci/build)");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (errors.length > 0) {
|
|
109
|
+
console.error("dev-loops: WORKTREE-COMMIT-MSG-GUARD refuses this commit — contract violation(s):");
|
|
110
|
+
for (const error of errors) console.error(" - " + error);
|
|
111
|
+
console.error(" Waiver: add a \"${COMMIT_MSG_WAIVER_MARKER}\" line to the commit message for a deliberate exception.");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
process.exit(0);
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Install the commit-msg guard into a repository's hook directory. Mirrors
|
|
120
|
+
* default-branch-guard's install-refusal checks (a caller with an unsafe
|
|
121
|
+
* `core.hooksPath`, a non-absolute/non-git `gitDir`, or a linked worktree's
|
|
122
|
+
* OWN gitdir must never report success for a hook that can never fire) and
|
|
123
|
+
* its atomic write + foreign-hook preservation — duplicated rather than
|
|
124
|
+
* shared, since it is one hook, not a family; see default-branch-guard.mjs
|
|
125
|
+
* for the family version if a third hook installer ever needs the same
|
|
126
|
+
* shape factored out.
|
|
127
|
+
*
|
|
128
|
+
* @param {{ gitDir: string, hooksPathOverride?: string|null }} target
|
|
129
|
+
*/
|
|
130
|
+
export function installCommitMsgGuard({ gitDir, hooksPathOverride = null }) {
|
|
131
|
+
const refuse = (reason) => ({ ok: false, installed: false, refreshed: false, skipped: true, reason });
|
|
132
|
+
|
|
133
|
+
if (typeof hooksPathOverride === "string") {
|
|
134
|
+
const configured = hooksPathOverride.trim();
|
|
135
|
+
return configured.length > 0
|
|
136
|
+
? refuse(`core.hooksPath is set to ${JSON.stringify(configured)} — install the guard there, or unset it`)
|
|
137
|
+
: refuse("core.hooksPath is set to an empty string — git runs no hooks at all");
|
|
138
|
+
}
|
|
139
|
+
if (typeof gitDir !== "string" || !path.isAbsolute(gitDir)) {
|
|
140
|
+
return refuse(`gitDir must be an absolute path; got ${JSON.stringify(gitDir)}`);
|
|
141
|
+
}
|
|
142
|
+
if (!fs.existsSync(path.join(gitDir, "HEAD"))) {
|
|
143
|
+
return refuse(`gitDir ${JSON.stringify(gitDir)} does not look like a git directory (no HEAD file)`);
|
|
144
|
+
}
|
|
145
|
+
if (fs.existsSync(path.join(gitDir, "commondir"))) {
|
|
146
|
+
return refuse(`gitDir ${JSON.stringify(gitDir)} is a linked worktree's own git directory, not the common one — hooks installed there never run`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
150
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
151
|
+
const hookPath = path.join(hooksDir, "commit-msg");
|
|
152
|
+
|
|
153
|
+
const existing = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, "utf8") : null;
|
|
154
|
+
const ours = existing === null || GUARD_MARKER_LINE.test(existing);
|
|
155
|
+
if (!ours) {
|
|
156
|
+
return { ok: true, installed: false, refreshed: false, skipped: true, reason: "a pre-existing hook is present and was left untouched" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Same atomic tmp-write + rename as default-branch-guard: the hooks dir is
|
|
160
|
+
// shared across worktrees, so a direct writeFileSync would be visible
|
|
161
|
+
// mid-write to a concurrent install or a real commit racing this one.
|
|
162
|
+
const tmpPath = path.join(hooksDir, `.commit-msg.tmp-${process.pid}-${Date.now()}`);
|
|
163
|
+
fs.writeFileSync(tmpPath, renderCommitMsgGuardHook(), { mode: 0o755 });
|
|
164
|
+
fs.chmodSync(tmpPath, 0o755);
|
|
165
|
+
fs.renameSync(tmpPath, hookPath);
|
|
166
|
+
|
|
167
|
+
return { ok: true, installed: existing === null, refreshed: existing !== null, skipped: false };
|
|
168
|
+
}
|
|
@@ -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" };
|