@dev-loops/core 1.0.1 → 1.0.2-pre.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 +4 -2
- package/src/claude/hook-decisions.mjs +144 -20
- package/src/config/extension-defaults.yaml +14 -9
- package/src/github/comment-id-guard.mjs +38 -0
- package/src/github/gh.mjs +49 -0
- package/src/loop/commit-msg-guard.mjs +1 -1
- package/src/loop/gate-carry-forward.mjs +45 -21
- package/src/loop/gate-evidence-reconcile.mjs +75 -0
- package/src/loop/gate-fanin.mjs +34 -13
- package/src/loop/handoff-envelope.mjs +2 -2
- package/src/loop/issue-refinement-artifact.mjs +358 -99
- package/src/loop/pr-gate-coordination.mjs +13 -13
- package/src/loop/queue-board-sync.mjs +1 -28
- package/src/loop/review-dispatch-plan.mjs +1 -0
- package/src/loop/spec-authority.mjs +759 -0
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/worktree-guard.mjs +55 -0
- package/src/projects/list-queue-items.mjs +1 -30
- package/src/projects/move-queue-item.mjs +1 -30
- package/src/security/secret-scan.mjs +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2-pre.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
|
|
36
36
|
"./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
|
|
37
37
|
"./loop/gate-carry-forward": "./src/loop/gate-carry-forward.mjs",
|
|
38
|
+
"./loop/gate-evidence-reconcile": "./src/loop/gate-evidence-reconcile.mjs",
|
|
38
39
|
"./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
|
|
39
40
|
"./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
|
|
40
41
|
"./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
"./loop/run-context": "./src/loop/run-context.mjs",
|
|
68
69
|
"./loop/run-inspection": "./src/loop/run-inspection.mjs",
|
|
69
70
|
"./loop/run-post-merge-actions": "./src/loop/run-post-merge-actions.mjs",
|
|
71
|
+
"./loop/spec-authority": "./src/loop/spec-authority.mjs",
|
|
70
72
|
"./loop/spike-exit-contract": "./src/loop/spike-exit-contract.mjs",
|
|
71
73
|
"./loop/spike-intake-contract": "./src/loop/spike-intake-contract.mjs",
|
|
72
74
|
"./loop/steering": "./src/loop/steering.mjs",
|
|
@@ -100,7 +102,7 @@
|
|
|
100
102
|
"bin/**/*.mjs"
|
|
101
103
|
],
|
|
102
104
|
"scripts": {
|
|
103
|
-
"test": "
|
|
105
|
+
"test": "bun ../../scripts/run-bun-test.mjs ./test/*.test.mjs"
|
|
104
106
|
},
|
|
105
107
|
"publishConfig": {
|
|
106
108
|
"access": "public",
|
|
@@ -369,6 +369,99 @@ export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, en
|
|
|
369
369
|
};
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Env var that authorizes a deliberate main-checkout mutation while a worktree
|
|
374
|
+
* cycle is active. Reuses the existing default-branch-guard override
|
|
375
|
+
* (`DEVLOOPS_ALLOW_MAIN`, GUARD_OVERRIDE_ENV) — both mean "I intend to operate on
|
|
376
|
+
* the primary checkout on purpose" — so the operator surface stays one flag.
|
|
377
|
+
*/
|
|
378
|
+
export const WORKTREE_CHECKOUT_GUARD_OVERRIDE_ENV = "DEVLOOPS_ALLOW_MAIN";
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Decide whether a PreToolUse Write/Edit is a WRONG-CHECKOUT mutation: the call
|
|
382
|
+
* context is operating inside a linked worktree (the active cycle worktree) but
|
|
383
|
+
* the target resolves to a TRACKED file in the MAIN checkout instead of that
|
|
384
|
+
* worktree.
|
|
385
|
+
*
|
|
386
|
+
* After `ensure-worktree.mjs` establishes an isolated worktree for a cycle, an
|
|
387
|
+
* absolute-path `Edit`/`Write` that names the main-checkout copy of a source
|
|
388
|
+
* file lands the change on the wrong checkout — silently, until a `git status`
|
|
389
|
+
* in the worktree turns up empty (observed in practice: six edits hit main
|
|
390
|
+
* before a self-caught restore). This guard catches that before it reaches a commit.
|
|
391
|
+
*
|
|
392
|
+
* The "active worktree" is anchored to the CALL CONTEXT's cwd, not to a durable
|
|
393
|
+
* marker: this repo accumulates many stale `tmp/worktrees/` worktrees, so "a
|
|
394
|
+
* worktree exists" cannot mean "a cycle is active". The worktree that CONTAINS
|
|
395
|
+
* cwd is the one this context is driving; a write escaping it into main is the
|
|
396
|
+
* wrong-checkout mistake.
|
|
397
|
+
*
|
|
398
|
+
* The hook resolves the facts (via the shared `worktree-guard.mjs` primitives)
|
|
399
|
+
* and passes booleans so this decider stays pure and unit-testable:
|
|
400
|
+
* - `activeWorktreeRoot`: the listed worktree root containing cwd, or null when
|
|
401
|
+
* cwd is not inside any worktree (no active cycle context — AC3).
|
|
402
|
+
* - `isTargetUnderActiveWorktree`: the target resolves inside that worktree (a
|
|
403
|
+
* legitimate in-worktree edit — AC2).
|
|
404
|
+
* - `isMainCheckoutTracked`: the target resolves under the main checkout AND is
|
|
405
|
+
* a tracked (non-gitignored) file. The hook sets this true when the status is
|
|
406
|
+
* UNRESOLVABLE (e.g. `git check-ignore` errored) so an ambiguous context fails
|
|
407
|
+
* safe rather than silently allowing a wrong-checkout write (AC4).
|
|
408
|
+
* - `allowMainCheckout`: the deliberate-override signal (`DEVLOOPS_ALLOW_MAIN=1`)
|
|
409
|
+
* for an intended main-checkout edit (AC3).
|
|
410
|
+
*
|
|
411
|
+
* @param {Object} params
|
|
412
|
+
* @param {string} params.filePath - Target file path (as supplied to the tool).
|
|
413
|
+
* @param {string|null} [params.activeWorktreeRoot] - Listed worktree root containing cwd, or null.
|
|
414
|
+
* @param {boolean} [params.isTargetUnderActiveWorktree] - Target is inside the active worktree.
|
|
415
|
+
* @param {boolean} [params.isMainCheckoutTracked] - Target is a tracked main-checkout file (or unresolvable).
|
|
416
|
+
* @param {boolean} [params.allowMainCheckout] - Deliberate override (DEVLOOPS_ALLOW_MAIN=1).
|
|
417
|
+
* @param {string|null} [params.suggestedWorktreePath] - The worktree-local path the write should target.
|
|
418
|
+
* @returns {HookDecision}
|
|
419
|
+
*/
|
|
420
|
+
export function decideWorktreeCheckoutGuard({
|
|
421
|
+
filePath,
|
|
422
|
+
activeWorktreeRoot = null,
|
|
423
|
+
isTargetUnderActiveWorktree = false,
|
|
424
|
+
isMainCheckoutTracked = false,
|
|
425
|
+
allowMainCheckout = false,
|
|
426
|
+
suggestedWorktreePath = null,
|
|
427
|
+
}) {
|
|
428
|
+
// No active worktree context — this is the main checkout / orchestrator / a
|
|
429
|
+
// consumer repo's own interactive dev. A main-checkout edit is intended here
|
|
430
|
+
// (AC3). Also the only path that runs for repos never using dev-loop worktrees.
|
|
431
|
+
if (!activeWorktreeRoot) {
|
|
432
|
+
return ALLOW;
|
|
433
|
+
}
|
|
434
|
+
// Deliberate, operator-authorized main-checkout write during an active cycle (AC3).
|
|
435
|
+
if (allowMainCheckout) {
|
|
436
|
+
return ALLOW;
|
|
437
|
+
}
|
|
438
|
+
// Legitimate in-worktree edit — no false positive (AC2).
|
|
439
|
+
if (isTargetUnderActiveWorktree) {
|
|
440
|
+
return ALLOW;
|
|
441
|
+
}
|
|
442
|
+
// Outside the active worktree AND not a tracked main-checkout file: a scratch
|
|
443
|
+
// path (/tmp), a gitignored path, another worktree's file, or outside the repo
|
|
444
|
+
// entirely — none is the wrong-checkout mistake this guard exists to catch.
|
|
445
|
+
if (!isMainCheckoutTracked) {
|
|
446
|
+
return ALLOW;
|
|
447
|
+
}
|
|
448
|
+
// A worktree cycle is active and the target is a tracked main-checkout file
|
|
449
|
+
// (or an unresolvable/ambiguous context that fails safe — AC4). This is a
|
|
450
|
+
// wrong-checkout mutation (AC1).
|
|
451
|
+
const fix = suggestedWorktreePath
|
|
452
|
+
? ` Edit the worktree copy instead: "${suggestedWorktreePath}".`
|
|
453
|
+
: "";
|
|
454
|
+
return {
|
|
455
|
+
decision: "deny",
|
|
456
|
+
reason:
|
|
457
|
+
`WORKTREE-WRONG-CHECKOUT-GUARD: wrong-checkout mutation blocked — a worktree cycle is active ` +
|
|
458
|
+
`("${activeWorktreeRoot}") but this Write/Edit targets the MAIN checkout's non-gitignored file ` +
|
|
459
|
+
`"${filePath}".${fix} A file mutation that lands on the main checkout while a worktree is ` +
|
|
460
|
+
"active is silently lost from the branch. Set DEVLOOPS_ALLOW_MAIN=1 only for a deliberate " +
|
|
461
|
+
"main-checkout edit. See skills/docs/worktree-guidance.md.",
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
372
465
|
/**
|
|
373
466
|
* Env var that exempts an interactive session awaiting commit authorization from the
|
|
374
467
|
* SubagentStop uncommitted-work guard (#1619).
|
|
@@ -384,21 +477,26 @@ export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, en
|
|
|
384
477
|
export const DEVLOOPS_COMMIT_AUTH_PENDING_VAR = "DEVLOOPS_COMMIT_AUTH_PENDING";
|
|
385
478
|
|
|
386
479
|
/**
|
|
387
|
-
*
|
|
388
|
-
* uncommitted-work guard (#1786).
|
|
480
|
+
* Subagent roles whose contract forbids committing to the repository (#1925).
|
|
389
481
|
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* commit
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
482
|
+
* The `judge` and `review` agents are read-only over the repository: the judge writes only its
|
|
483
|
+
* own verdict artifact (under `tmp/`, gitignored) and the gate reviewer writes only its findings
|
|
484
|
+
* artifact (also under `tmp/`) — neither ever authors a tracked-file edit. So any uncommitted
|
|
485
|
+
* tracked change present in such a subagent's worktree is FOREIGN: it belongs to the orchestrator
|
|
486
|
+
* that dispatched it (a pending orchestrator edit that was in the shared worktree when the
|
|
487
|
+
* read-only pass ran), not to the read-only subagent. `LOCAL-COMMIT-BEFORE-EXIT` must not force
|
|
488
|
+
* one of these roles to author a commit of that foreign work — doing so violates the verdict-only
|
|
489
|
+
* contract (`agents/judge.agent.md`: "The only thing you write is your own verdict artifact").
|
|
490
|
+
* The data-loss protection is enforced against the OWNER of the edit (the orchestrator) on its own
|
|
491
|
+
* stop instead. This is intentionally scoped to read-only roles: editing roles (`developer`,
|
|
492
|
+
* `fixer`, `docs`, `quality`) and the orchestrator stay enforced (#1925 non-goal).
|
|
400
493
|
*/
|
|
401
|
-
export const
|
|
494
|
+
export const READONLY_SUBAGENT_ROLES = Object.freeze(["judge", "review"]);
|
|
495
|
+
|
|
496
|
+
/** Whether `agentType` (Claude `agent_type` from the SubagentStop payload) is a read-only role. */
|
|
497
|
+
export function isReadOnlySubagentRole(agentType) {
|
|
498
|
+
return typeof agentType === "string" && READONLY_SUBAGENT_ROLES.includes(agentType);
|
|
499
|
+
}
|
|
402
500
|
|
|
403
501
|
/**
|
|
404
502
|
* Decide whether a SubagentStop must be blocked because the subagent's worktree has
|
|
@@ -408,10 +506,19 @@ export const DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT_VAR = "DEVLOOPS_ORCHESTRATOR_OWNS
|
|
|
408
506
|
* uncommitted changes in a worktree are destroyed with no warning. `LOCAL-COMMIT-BEFORE-EXIT`
|
|
409
507
|
* existed only as prose. This decider makes it mechanical: refuse the subagent stop when the
|
|
410
508
|
* cwd is under `tmp/worktrees/` and `git status --porcelain` is non-empty, unless the session
|
|
411
|
-
* is an interactive one awaiting commit authorization
|
|
412
|
-
* orchestrator-owned-commit exemption (#1786) (either exempt). A clean worktree, a cwd
|
|
509
|
+
* is an interactive one awaiting commit authorization (exempt). A clean worktree, a cwd
|
|
413
510
|
* outside `tmp/worktrees/`, and a git-error/empty-porcelain case all allow the stop.
|
|
414
511
|
*
|
|
512
|
+
* Editing roles (`developer`/`fixer`/`docs`/`quality`) stay fully enforced: an editing
|
|
513
|
+
* sub-delegate commits its own work before exit (`LOCAL-COMMIT-BEFORE-EXIT`), so a dirty exit is
|
|
514
|
+
* always a real defect, never a sanctioned "orchestrator owns the commit" split. The removed
|
|
515
|
+
* `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT` env-var exemption (#1786) deadlocked such a role under a
|
|
516
|
+
* task-scoped no-commit instruction whenever the orchestrator could not set a per-dispatch env
|
|
517
|
+
* var (the Claude harness): the hook demanded a commit the session then denied, then re-blocked
|
|
518
|
+
* the exit (#1936). Disallowing the edit-here/commit-there split at the contract level makes the
|
|
519
|
+
* guard the enforcer and the deadlock structurally impossible while preserving data-loss
|
|
520
|
+
* protection. An orchestrator that wants one consolidated commit performs the edits itself.
|
|
521
|
+
*
|
|
415
522
|
* Pure and side-effect free. The hook script gathers `cwd` and the `git status --porcelain`
|
|
416
523
|
* output and calls this; the block decision is surfaced via exit code 2 + stderr JSON by the
|
|
417
524
|
* hook (the SubagentStop contract differs from PreToolUse's `permissionDecision` form).
|
|
@@ -424,21 +531,38 @@ export const DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT_VAR = "DEVLOOPS_ORCHESTRATOR_OWNS
|
|
|
424
531
|
* @param {boolean} [params.pendingCommitAuthorization] - True when the interactive session is
|
|
425
532
|
* awaiting commit authorization (exempt) — derived by the hook script from the
|
|
426
533
|
* `DEVLOOPS_COMMIT_AUTH_PENDING=1` opt-in env signal.
|
|
427
|
-
* @param {
|
|
428
|
-
*
|
|
429
|
-
*
|
|
534
|
+
* @param {string|null} [params.agentType] - Claude `agent_type` from the SubagentStop payload;
|
|
535
|
+
* a read-only role (`judge`/`review`, per `READONLY_SUBAGENT_ROLES`) is exempt (#1925) — its
|
|
536
|
+
* contract forbids commits, so any dirty tracked edit in its worktree is foreign
|
|
537
|
+
* (orchestrator-owned) and must not be pinned on it.
|
|
430
538
|
* @returns {HookDecision}
|
|
431
539
|
*/
|
|
432
|
-
export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false,
|
|
540
|
+
export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false, agentType = null }) {
|
|
433
541
|
if (typeof cwd !== "string" || !isUnderWorktreePath(cwd)) {
|
|
434
542
|
return ALLOW;
|
|
435
543
|
}
|
|
436
|
-
if (pendingCommitAuthorization
|
|
544
|
+
if (pendingCommitAuthorization) {
|
|
437
545
|
return ALLOW;
|
|
438
546
|
}
|
|
439
547
|
if (typeof porcelain !== "string" || porcelain.trim() === "") {
|
|
440
548
|
return ALLOW;
|
|
441
549
|
}
|
|
550
|
+
// Read-only role exemption (#1925): the worktree is dirty, but a `judge`/`review` subagent's
|
|
551
|
+
// contract forbids commits, so this pending tracked edit is foreign — it belongs to the
|
|
552
|
+
// orchestrator that dispatched this pass. Do not force a verdict-only role to commit it; allow
|
|
553
|
+
// the stop with an advisory naming the orchestrator as the actor responsible for the edit. The
|
|
554
|
+
// data-loss guard still fires against the orchestrator on its own (editing) stop.
|
|
555
|
+
if (isReadOnlySubagentRole(agentType)) {
|
|
556
|
+
return {
|
|
557
|
+
decision: "allow",
|
|
558
|
+
advisory: true,
|
|
559
|
+
reason:
|
|
560
|
+
`LOCAL-COMMIT-BEFORE-EXIT exempt for read-only role "${agentType}": the worktree has ` +
|
|
561
|
+
"uncommitted changes, but a verdict-only role must not author a commit of work it did not " +
|
|
562
|
+
"create. This pending edit is foreign — the ORCHESTRATOR that dispatched this pass owns it " +
|
|
563
|
+
"and is responsible for committing it before its own stop.",
|
|
564
|
+
};
|
|
565
|
+
}
|
|
442
566
|
const dirty = porcelain
|
|
443
567
|
.split("\n")
|
|
444
568
|
.map((l) => l.trim())
|
|
@@ -199,7 +199,7 @@ gates:
|
|
|
199
199
|
- name: docs-surface
|
|
200
200
|
angles: [docs, link-check, config-drift, contract-surface]
|
|
201
201
|
- name: process
|
|
202
|
-
angles: [scope, pr-description, gate-evidence, pr-checklist
|
|
202
|
+
angles: [scope, pr-description, gate-evidence, pr-checklist]
|
|
203
203
|
- name: correctness-input
|
|
204
204
|
angles: [correctness, input-validation]
|
|
205
205
|
- name: determinism-state
|
|
@@ -259,19 +259,24 @@ gates:
|
|
|
259
259
|
- name: docs
|
|
260
260
|
persona: docs
|
|
261
261
|
prompt: "Review documentation correctness for the current change. Check that relative markdown links resolve, symlink-backed doc pointers resolve, navigable doc references are actual markdown links rather than bare backtick path mentions, command/script references still exist and use current names, and index/surface references match the current file tree. Also flag stale command references: removed or renamed npm scripts, CLI commands, or tool invocations that no longer match the current codebase. When the repo provides `scripts/docs/validate-links.mjs`, use it for the mechanical link pass; otherwise keep the review scoped to the touched doc surface and current change only."
|
|
262
|
-
- name: pr-checklist
|
|
262
|
+
- name: pr-checklist
|
|
263
263
|
mandatory: true
|
|
264
264
|
persona: review
|
|
265
265
|
prompt: |-
|
|
266
|
+
Verify the PR carries self-contained list-form Acceptance criteria and Definition of done
|
|
267
|
+
CHECKLISTS (never a matrix/table on the PR, never checkboxes inside table cells) that are
|
|
268
|
+
faithfully DERIVED from the linked issue's authoritative AC→DoD mapping matrix (#1951).
|
|
266
269
|
Completeness is enforced deterministically (#1877): the pre_approval_gate fails closed on any
|
|
267
270
|
unchecked `- [ ]` in the PR body's AC/DoD checklist, so this angle's completeness duty is
|
|
268
|
-
machine-backed. Your remaining duty is TRUTHFULNESS, which the machine
|
|
269
|
-
that every checked `- [x]` box in the PR body's Acceptance criteria /
|
|
270
|
-
checklists is actually satisfied by the implementation — cite concrete
|
|
271
|
-
evidence; flag a dishonestly-ticked box as a blocking finding.
|
|
272
|
-
|
|
273
|
-
respected (no scope creep).
|
|
274
|
-
|
|
271
|
+
machine-backed. Your remaining duty is TRUTHFULNESS + DERIVATION FIDELITY, which the machine
|
|
272
|
+
cannot check: verify that every checked `- [x]` box in the PR body's Acceptance criteria /
|
|
273
|
+
Definition of done checklists is actually satisfied by the implementation — cite concrete
|
|
274
|
+
code/test/behavior evidence; flag a dishonestly-ticked box as a blocking finding. Verify the
|
|
275
|
+
PR checklists faithfully reflect the issue matrix (every matrix row is represented, nothing
|
|
276
|
+
invented or dropped) and that declared non-goals are respected (no scope creep). Do NOT
|
|
277
|
+
require a matrix on the PR — the matrix lives on the issue; the PR carries the derived
|
|
278
|
+
checklists. The boundary is explicit: the deterministic block enforces completeness (nothing
|
|
279
|
+
left unchecked/forgotten); you verify each [x] is real and faithfully derived.
|
|
275
280
|
- contradiction-lens
|
|
276
281
|
- correctness-final
|
|
277
282
|
- ui-validation
|
|
@@ -112,6 +112,44 @@ export function extractIssuePrIds(body) {
|
|
|
112
112
|
return [...found];
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// A consecutive run of number-sign(s) immediately before a digit. Strip EVERY
|
|
116
|
+
// number-sign in the run (`#123` and `##123` alike leave `123`), or a leftover
|
|
117
|
+
// still auto-links AND trips the decode-aware guard. A lookahead consumes only
|
|
118
|
+
// the number-sign(s), keeping the digits.
|
|
119
|
+
const BARE_ISSUE_PR_ID_RE = /#+(?=\d)/g;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The sanctioned pre-guard transform for GENERATED comment bodies (#1922):
|
|
123
|
+
* neutralize a bare `#<digits>` auto-link token to a guard-safe, non-auto-linking
|
|
124
|
+
* form by stripping the leading `#` (`#123` -> `123`). Auto-link syntax requires
|
|
125
|
+
* the leading `#`, so the result neither auto-links on GitHub nor trips
|
|
126
|
+
* `guardCommentBodyNoIssuePrIds` — and because no decode path can reassemble a
|
|
127
|
+
* `#` from bare digits, the guard's entity-decode surface stays satisfied too.
|
|
128
|
+
*
|
|
129
|
+
* This is deliberately SEPARATE from `guardCommentBodyNoIssuePrIds`: the guard
|
|
130
|
+
* stays fail-closed for human-authored bodies (it must still REFUSE a bare id a
|
|
131
|
+
* person typed), while gate generators opt into this transform on their OWN
|
|
132
|
+
* generated finding text before handing it to the guard. It does NOT weaken the
|
|
133
|
+
* guard.
|
|
134
|
+
*
|
|
135
|
+
* Run on RAW text BEFORE any markdown sanitizer (sanitizeInline/sanitizeCodeSpan)
|
|
136
|
+
* emits its own numeric character references (e.g. `[` for `[`): this strips
|
|
137
|
+
* `#` before digits without distinguishing an entity's digits from a bare id's,
|
|
138
|
+
* so applying it post-sanitize would corrupt an already-emitted entity
|
|
139
|
+
* (`[` -> `&91;`).
|
|
140
|
+
*
|
|
141
|
+
* ponytail: strips only a LITERAL `#` before digits — every form a review agent
|
|
142
|
+
* actually authors (`#123`). It does NOT mirror the guard's full entity-decode
|
|
143
|
+
* surface; text already carrying an entity-encoded number-sign adjacent to
|
|
144
|
+
* digits (`#123`) is unreachable from reviewer prose, and the guard remains
|
|
145
|
+
* the fail-closed backstop for it. Returns non-string input unchanged (stringly
|
|
146
|
+
* callers coerce first).
|
|
147
|
+
*/
|
|
148
|
+
export function neutralizeBareIssuePrIds(value) {
|
|
149
|
+
if (typeof value !== "string") return value;
|
|
150
|
+
return value.replace(BARE_ISSUE_PR_ID_RE, "");
|
|
151
|
+
}
|
|
152
|
+
|
|
115
153
|
// A caller-supplied allowlist is normally already an array (or other
|
|
116
154
|
// iterable) of ids. Guard the one mis-shaped input that would otherwise
|
|
117
155
|
// silently produce the wrong set: a plain CSV string. `Array.from` over a
|
package/src/github/gh.mjs
CHANGED
|
@@ -92,3 +92,52 @@ export async function ghGraphql(query, vars, env, runChild = defaultRunChild, {
|
|
|
92
92
|
}
|
|
93
93
|
return payload;
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
const GET_USER_ID = [
|
|
97
|
+
"query($login:String!) {",
|
|
98
|
+
" user(login:$login) { id }",
|
|
99
|
+
"}",
|
|
100
|
+
].join("\n");
|
|
101
|
+
|
|
102
|
+
const GET_ORG_ID = [
|
|
103
|
+
"query($login:String!) {",
|
|
104
|
+
" organization(login:$login) { id }",
|
|
105
|
+
"}",
|
|
106
|
+
].join("\n");
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a GitHub owner login to its node id and kind. Probes the user
|
|
110
|
+
* namespace first; a not-a-user probe failure (org logins make `gh api graphql`
|
|
111
|
+
* exit non-zero) falls through to the org namespace instead of throwing (#1949).
|
|
112
|
+
* A login that is neither a user nor an org fails closed with NO_USER_ID.
|
|
113
|
+
*
|
|
114
|
+
* The issue's "Proposed fix" wrapped only the user probe, but AC #3 requires
|
|
115
|
+
* NO_USER_ID for a genuinely non-existent owner; in production the org probe
|
|
116
|
+
* on a missing org also throws (non-zero exit), so both probes must be caught
|
|
117
|
+
* for the AC to hold. `cause` preserves the underlying org error for
|
|
118
|
+
* diagnostics. This does not change `ghGraphql`'s throw-on-non-zero-exit
|
|
119
|
+
* contract.
|
|
120
|
+
*/
|
|
121
|
+
export async function resolveOwner(login, env, runChild) {
|
|
122
|
+
try {
|
|
123
|
+
const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
|
|
124
|
+
if (userPayload?.data?.user?.id) {
|
|
125
|
+
return { id: userPayload.data.user.id, kind: "user" };
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// not a user login → fall through to the org probe
|
|
129
|
+
}
|
|
130
|
+
let orgError;
|
|
131
|
+
try {
|
|
132
|
+
const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
|
|
133
|
+
if (orgPayload?.data?.organization?.id) {
|
|
134
|
+
return { id: orgPayload.data.organization.id, kind: "org" };
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
orgError = err;
|
|
138
|
+
}
|
|
139
|
+
throw Object.assign(
|
|
140
|
+
new Error(`Could not resolve owner ID for "${login}"`),
|
|
141
|
+
{ code: "NO_USER_ID", cause: orgError },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -84,7 +84,7 @@ if (process.env.CLAUDECODE === "1") {
|
|
|
84
84
|
errors.push("missing required trailer: Co-Authored-By: Claude <model> <noreply@anthropic.com>");
|
|
85
85
|
}
|
|
86
86
|
if (!/^Claude-Session:\s*\S+/imu.test(message)) {
|
|
87
|
-
errors.push("missing required trailer: Claude-Session: <url>");
|
|
87
|
+
errors.push("missing required trailer: Claude-Session: <url> (e.g. Claude-Session: https://claude.ai/code/session_abc123)");
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Gate carry-forward: a pure, fail-closed seam that decides whether
|
|
3
|
-
*
|
|
4
|
-
* re-running that angle's reviewer.
|
|
2
|
+
* Gate carry-forward: a pure, fail-closed seam that decides whether an angle's
|
|
3
|
+
* verdict recorded at head A (clean OR findings-present) may be CARRIED FORWARD
|
|
4
|
+
* to head B without re-running that angle's reviewer.
|
|
5
5
|
*
|
|
6
6
|
* Motivation: fresh-context-per-head re-fans ALL gate angles on every head bump,
|
|
7
7
|
* even when the delta between the two heads provably cannot affect most angles
|
|
8
8
|
* (e.g. a doc-only follow-up commit cannot change what a code-correctness angle
|
|
9
|
-
* would find). Carry-forward lets the gate reuse the prior
|
|
10
|
-
* angles — but ONLY when it is provably safe.
|
|
9
|
+
* would find). Carry-forward lets the gate reuse the prior verdict for such
|
|
10
|
+
* angles — but ONLY when it is provably safe. This holds for a findings-present
|
|
11
|
+
* prior verdict too (issue #2017): a fixer push that never touches an angle's
|
|
12
|
+
* surface must not force that angle's OPEN findings to be re-litigated from
|
|
13
|
+
* scratch — the caller carries the prior findings forward unchanged, still
|
|
14
|
+
* open, still blocking. Carry-forward NEVER converts a finding into an
|
|
15
|
+
* approval; it only ever skips re-running a reviewer whose surface the delta
|
|
16
|
+
* provably did not touch.
|
|
11
17
|
*
|
|
12
|
-
* FAIL-CLOSED is paramount. An angle carries forward ONLY when
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* FAIL-CLOSED is paramount. An angle carries forward ONLY when its prior verdict
|
|
19
|
+
* is carry-forward-eligible (clean or findings_present) AND EVERY changed file in
|
|
20
|
+
* the delta A..B is provably OUTSIDE that angle's declared review surface. The
|
|
21
|
+
* default in every uncertain case (an ineligible prior verdict — e.g. "blocked"
|
|
22
|
+
* or missing, empty/unavailable delta, an unclassifiable file, an angle with no
|
|
23
|
+
* declared surface, a mandatory / always-run angle) is MUST-RE-RUN. Carry-forward
|
|
24
|
+
* never fabricates a verdict: the caller records the carried verdict (and, for a
|
|
25
|
+
* findings-present carry, the carried findings) with provenance pointing at the
|
|
26
|
+
* PRIOR head's reviewer (that reviewer genuinely reviewed this angle's surface,
|
|
27
|
+
* which the delta did not touch), clearly marked as carried — see
|
|
20
28
|
* skills/docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
|
|
21
29
|
* `carriedFromHead` provenance field.
|
|
22
30
|
*
|
|
@@ -153,10 +161,16 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
|
|
|
153
161
|
/**
|
|
154
162
|
* Pure, deterministic, FAIL-CLOSED carry-forward decision for a single angle.
|
|
155
163
|
*
|
|
156
|
-
* Given a prior
|
|
157
|
-
* A..B, and the angle's
|
|
158
|
-
*
|
|
159
|
-
*
|
|
164
|
+
* Given a prior carry-forward-eligible verdict recorded at head A (clean OR
|
|
165
|
+
* findings_present), the changed files of the delta A..B, and the angle's
|
|
166
|
+
* declared review surface, decide whether that verdict (and, for a
|
|
167
|
+
* findings-present angle, its open findings) may be carried forward to head B
|
|
168
|
+
* (carryForward: true) or the angle MUST re-run (carryForward: false).
|
|
169
|
+
* Defaults to must-re-run in every uncertain case. This function never
|
|
170
|
+
* inspects or mutates findings content — it only decides whether the delta
|
|
171
|
+
* proves the angle's surface untouched; the caller is responsible for
|
|
172
|
+
* carrying the actual prior findings through unchanged (never converting an
|
|
173
|
+
* open finding into an approval) when it honors `carryForward: true`.
|
|
160
174
|
*
|
|
161
175
|
* @param {object} input
|
|
162
176
|
* @param {string} input.angle
|
|
@@ -164,11 +178,18 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
|
|
|
164
178
|
* derived from {@link angleReviewSurface} when omitted.
|
|
165
179
|
* @param {string[]} input.changedFiles — repo-relative paths changed between head
|
|
166
180
|
* A and head B (the delta, NOT the full PR diff against base).
|
|
167
|
-
* @param {string} input.prevVerdict — the angle's verdict at head A.
|
|
168
|
-
*
|
|
181
|
+
* @param {string} input.prevVerdict — the angle's verdict at head A. "clean" and
|
|
182
|
+
* "findings_present" are carry-forward-eligible; anything else (e.g.
|
|
183
|
+
* "blocked", missing) is not.
|
|
169
184
|
* @returns {{ carryForward: boolean, reason: string }}
|
|
170
185
|
*/
|
|
171
186
|
|
|
187
|
+
// The only per-angle prior verdicts eligible to carry forward — matches the
|
|
188
|
+
// two verdict values gate-fanin's VALID_VERDICTS actually produces per angle
|
|
189
|
+
// (packages/core/src/loop/gate-fanin.mjs). Any other value (e.g. "blocked",
|
|
190
|
+
// undefined, a typo) fails closed to must-re-run.
|
|
191
|
+
const CARRY_FORWARD_ELIGIBLE_VERDICTS = new Set(["clean", "findings_present"]);
|
|
192
|
+
|
|
172
193
|
// A path whose change rewrites the dev-loop review system itself — the angle
|
|
173
194
|
// pool, mandatory floor, and reviewer personas/prompts — rather than a
|
|
174
195
|
// reviewed surface. A clean verdict produced under the OLD config cannot
|
|
@@ -187,8 +208,11 @@ export function isDevLoopConfigSourcePath(filePath) {
|
|
|
187
208
|
}
|
|
188
209
|
|
|
189
210
|
export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
|
|
190
|
-
if (prevVerdict
|
|
191
|
-
return {
|
|
211
|
+
if (!CARRY_FORWARD_ELIGIBLE_VERDICTS.has(prevVerdict)) {
|
|
212
|
+
return {
|
|
213
|
+
carryForward: false,
|
|
214
|
+
reason: `prior verdict is ${JSON.stringify(prevVerdict ?? null)}, not carry-forward-eligible (clean or findings_present)`,
|
|
215
|
+
};
|
|
192
216
|
}
|
|
193
217
|
const surface = angleSurface ?? angleReviewSurface(angle);
|
|
194
218
|
if (surface.kind === "always") {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate-evidence-reconcile.mjs — deterministic self-heal for a stuck
|
|
3
|
+
* `gate-evidence` required status (issue #1935).
|
|
4
|
+
*
|
|
5
|
+
* The server-side `gate-evidence` check (`.github/workflows/gate-evidence.yml`)
|
|
6
|
+
* re-fires when a gate verdict is posted (ADR 0043). That native re-fire is
|
|
7
|
+
* racy: a verdict-post run can be CANCELLED by `cancel-in-progress` when a
|
|
8
|
+
* superseding event lands, or evaluate before the just-posted verdict is
|
|
9
|
+
* API-visible, leaving the required status stuck at `failure` even though a
|
|
10
|
+
* clean current-head `pre_approval_gate` verdict now exists. Nothing re-fires
|
|
11
|
+
* afterward, so the merge stays `UNSTABLE` until a manual `gh run rerun`
|
|
12
|
+
* (observed on PR #1934; ADR 0057).
|
|
13
|
+
*
|
|
14
|
+
* This pure decision separates the two cases the reconcile must never confuse:
|
|
15
|
+
* - evidence genuinely satisfied but the status is stuck non-green → re-fire
|
|
16
|
+
* the concrete run that posted the stale status (automating the manual
|
|
17
|
+
* rerun; the rerun re-evaluates LIVE evidence, which is now satisfied).
|
|
18
|
+
* - evidence genuinely NOT satisfied → do nothing. A head that truly lacks a
|
|
19
|
+
* clean current-head verdict MUST keep failing the check (fail-closed).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Required commit-status context posted by the gate-evidence workflow. */
|
|
23
|
+
export const GATE_EVIDENCE_STATUS_CONTEXT = "gate-evidence";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract the Actions run id from a gate-evidence commit-status `target_url`.
|
|
27
|
+
* The workflow points every posted status at its own run:
|
|
28
|
+
* https://github.com/<owner>/<repo>/actions/runs/<run_id>
|
|
29
|
+
* Returns the numeric run id as a string, or null when the URL is absent or
|
|
30
|
+
* not an Actions-run URL (an unexpected target_url must not be coerced).
|
|
31
|
+
*
|
|
32
|
+
* @param {string} [targetUrl]
|
|
33
|
+
* @returns {string|null}
|
|
34
|
+
*/
|
|
35
|
+
export function parseRunIdFromTargetUrl(targetUrl) {
|
|
36
|
+
if (typeof targetUrl !== "string") return null;
|
|
37
|
+
const match = targetUrl.match(/\/actions\/runs\/(\d+)(?:[/?#]|$)/);
|
|
38
|
+
return match ? match[1] : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide whether a stuck `gate-evidence` status should be re-fired.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} input
|
|
45
|
+
* @param {boolean} input.evidenceSatisfied detect-checkpoint-evidence reports
|
|
46
|
+
* `evidenceState === "satisfied"` for the current head (clean draft_gate +
|
|
47
|
+
* current-head pre_approval_gate verdicts present).
|
|
48
|
+
* @param {string} [input.statusState] the `gate-evidence` commit-status state
|
|
49
|
+
* on the current head: `success` | `failure` | `error` | `pending` | `none`
|
|
50
|
+
* (`none` = no gate-evidence status posted for this head yet).
|
|
51
|
+
* @param {string|null} [input.runId] the Actions run id that posted the stale
|
|
52
|
+
* status (from `parseRunIdFromTargetUrl`), or null when unknown.
|
|
53
|
+
* @returns {{ action: "refire"|"none", runId?: string, reason: string }}
|
|
54
|
+
*/
|
|
55
|
+
export function resolveGateEvidenceStatusReconcile({ evidenceSatisfied, statusState, runId } = {}) {
|
|
56
|
+
// Fail-closed: never re-fire when the verdict evidence is genuinely not
|
|
57
|
+
// satisfied. This preserves the "verdict genuinely missing" case — the head
|
|
58
|
+
// keeps failing closed exactly as before (issue #1935 AC #3).
|
|
59
|
+
if (evidenceSatisfied !== true) {
|
|
60
|
+
return { action: "none", reason: "evidence-not-satisfied-fail-closed" };
|
|
61
|
+
}
|
|
62
|
+
// Already green — nothing to reconcile.
|
|
63
|
+
if (statusState === "success") {
|
|
64
|
+
return { action: "none", reason: "already-success" };
|
|
65
|
+
}
|
|
66
|
+
// Evidence IS satisfied for the current head, but the required status is not
|
|
67
|
+
// success (the push-before-verdict race: a cancelled/stale re-fire). Re-fire
|
|
68
|
+
// the concrete run that posted the stale status. Without a run id there is
|
|
69
|
+
// nothing to re-fire deterministically; leave it to the native path rather
|
|
70
|
+
// than forging a status.
|
|
71
|
+
if (!runId) {
|
|
72
|
+
return { action: "none", reason: "no-run-to-refire" };
|
|
73
|
+
}
|
|
74
|
+
return { action: "refire", runId, reason: "evidence-satisfied-status-stale" };
|
|
75
|
+
}
|