@dev-loops/core 1.0.0 → 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/config.mjs +11 -97
- package/src/config/extension-defaults.yaml +18 -7
- 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 +47 -20
- package/src/loop/handoff-envelope.mjs +2 -2
- package/src/loop/issue-refinement-artifact.mjs +608 -75
- package/src/loop/pr-gate-coordination.mjs +27 -2
- package/src/loop/queue-board-sync.mjs +7 -38
- 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/projects/resolve-project.mjs +6 -6
- 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.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())
|
package/src/config/config.mjs
CHANGED
|
@@ -563,17 +563,11 @@ function boardRefConfig(ownerKey) {
|
|
|
563
563
|
});
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
-
const QueueBoardConfig = boardRefConfig("queue.board");
|
|
567
|
-
|
|
568
566
|
/** Queue mode config */
|
|
569
567
|
const QueueConfig = z.strictObject({
|
|
570
568
|
maxParallel: z.number().int().min(1).max(10).default(3).describe("Maximum queue items worked in parallel."),
|
|
571
569
|
maxAutoFiledIssues: z.number().int().min(0).max(100).default(10).describe("Cap on auto-filed issues per run."),
|
|
572
570
|
reDispatchMaxRetries: z.number().int().min(0).max(10).default(1).describe("Retries when re-dispatching a failed queue item."),
|
|
573
|
-
// Deprecated: superseded by `tracker.board` (issue #1408, the tracker-agnostic
|
|
574
|
-
// seam). Kept accepted for back-compat — see resolveTrackerBoard, which reads
|
|
575
|
-
// `tracker.board` first and falls back to this field with a load-time warning.
|
|
576
|
-
board: QueueBoardConfig.describe("Deprecated: use tracker.board instead. GitHub Projects board identifier.").optional(),
|
|
577
571
|
archiveOlderThanDays: z.number().int().positive().describe("Archive done board items older than this many days.").optional(),
|
|
578
572
|
});
|
|
579
573
|
|
|
@@ -583,7 +577,7 @@ const QueueConfig = z.strictObject({
|
|
|
583
577
|
* at `resolveTrackerAdapter` call time, not at config-parse time — the
|
|
584
578
|
* seam/resolver must not preclude a consumer registering an external
|
|
585
579
|
* provider post-1.0 (`plugin`, reserved, not implemented in this pass).
|
|
586
|
-
* `board`
|
|
580
|
+
* `board` is the canonical GitHub Projects board identifier (see resolveTrackerBoard).
|
|
587
581
|
*
|
|
588
582
|
* No generic `fieldMappings` (logical-column -> provider-status) key here:
|
|
589
583
|
* the github provider's logical-column -> Status mapping IS the existing,
|
|
@@ -598,7 +592,7 @@ const QueueConfig = z.strictObject({
|
|
|
598
592
|
const TrackerConfig = z.strictObject({
|
|
599
593
|
provider: z.string().trim().min(1).describe("Tracker provider registry key. Built-in: \"github\" (default).").optional(),
|
|
600
594
|
plugin: z.string().trim().min(1).describe("Reserved: module specifier for an external tracker provider plugin (post-1.0, not implemented in this pass).").optional(),
|
|
601
|
-
board: boardRefConfig("tracker.board").describe("Tracker board identifier
|
|
595
|
+
board: boardRefConfig("tracker.board").describe("Tracker board identifier.").optional(),
|
|
602
596
|
});
|
|
603
597
|
|
|
604
598
|
/**
|
|
@@ -940,13 +934,11 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
940
934
|
maxParallel: 3,
|
|
941
935
|
maxAutoFiledIssues: 10,
|
|
942
936
|
reDispatchMaxRetries: 1,
|
|
943
|
-
// queue.board is intentionally absent from defaults — setting it is an
|
|
944
|
-
// explicit operator opt-in for Projects-based queue ordering.
|
|
945
937
|
}),
|
|
946
938
|
tracker: Object.freeze({
|
|
947
939
|
provider: "github",
|
|
948
940
|
// tracker.board is intentionally absent from defaults — setting it is an
|
|
949
|
-
// explicit operator opt-in
|
|
941
|
+
// explicit operator opt-in for Projects-based queue ordering. The logical-column ->
|
|
950
942
|
// Status mapping is queue.statusColumns (see TrackerConfig above), not a
|
|
951
943
|
// tracker-owned default.
|
|
952
944
|
}),
|
|
@@ -1285,7 +1277,7 @@ export function resolveRoleModel(config, { role, harness, kind } = {}) {
|
|
|
1285
1277
|
* @typedef {object} ConfigLoadError
|
|
1286
1278
|
* @property {string} path - Human-readable file path or layer name
|
|
1287
1279
|
* @property {string} message - Error description
|
|
1288
|
-
* @property {"
|
|
1280
|
+
* @property {"extensionDefaults"|"defaults"|"devloops"|"merged"} layer - Which config layer failed
|
|
1289
1281
|
*/
|
|
1290
1282
|
|
|
1291
1283
|
// ============================================================================
|
|
@@ -1499,10 +1491,11 @@ function configError(message, code, filePath) {
|
|
|
1499
1491
|
}
|
|
1500
1492
|
|
|
1501
1493
|
/**
|
|
1502
|
-
* Try to load and merge one config layer (defaults or
|
|
1494
|
+
* Try to load and merge one config layer (extensionDefaults, defaults, or
|
|
1495
|
+
* devloops).
|
|
1503
1496
|
* @param {Record<string, unknown>} merged - Current merged config
|
|
1504
1497
|
* @param {string|string[]} basePaths - Config file base path(s) without extension
|
|
1505
|
-
* @param {"defaults"|"
|
|
1498
|
+
* @param {"extensionDefaults"|"defaults"|"devloops"} layer - Layer name
|
|
1506
1499
|
* @param {string[]} warnings
|
|
1507
1500
|
* @param {ConfigLoadError[]} errors
|
|
1508
1501
|
* @param {{ warnOnMissing?: boolean }} [options]
|
|
@@ -1628,7 +1621,7 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
|
|
|
1628
1621
|
|
|
1629
1622
|
/**
|
|
1630
1623
|
* Load the dev-loop configuration with full precedence:
|
|
1631
|
-
*
|
|
1624
|
+
* repo .devloops > repo .pi/dev-loop/defaults.(yaml|yml|json) > extension defaults > built-in defaults
|
|
1632
1625
|
*
|
|
1633
1626
|
* Never throws for config-related problems.
|
|
1634
1627
|
* Returns extension defaults (with built-in defaults as the final fallback) even when all repo-local config files are missing or broken.
|
|
@@ -1641,7 +1634,6 @@ export async function loadDevLoopConfig(options = {}) {
|
|
|
1641
1634
|
const configDir = path.join(repoRoot, ".pi", "dev-loop");
|
|
1642
1635
|
const defaultsPath = path.join(configDir, "defaults");
|
|
1643
1636
|
const devloopsPath = path.join(repoRoot, ".devloops");
|
|
1644
|
-
const settingsPaths = [path.join(configDir, "settings"), path.join(configDir, "overrides")];
|
|
1645
1637
|
|
|
1646
1638
|
/** @type {string[]} */
|
|
1647
1639
|
const warnings = [];
|
|
@@ -1678,79 +1670,7 @@ export async function loadDevLoopConfig(options = {}) {
|
|
|
1678
1670
|
|
|
1679
1671
|
if (primaryExists) {
|
|
1680
1672
|
// .devloops is the primary override — apply it
|
|
1681
|
-
merged = await applyLayer(merged, devloopsPath, "
|
|
1682
|
-
|
|
1683
|
-
// Warn if legacy files still exist alongside .devloops (but don't load them —
|
|
1684
|
-
// .devloops is authoritative; legacy must not override it)
|
|
1685
|
-
let legacyAlongside = false;
|
|
1686
|
-
for (const legacyPath of settingsPaths) {
|
|
1687
|
-
for (const ext of [".yaml", ".yml", ".json"]) {
|
|
1688
|
-
try {
|
|
1689
|
-
await readFile(legacyPath + ext, "utf8");
|
|
1690
|
-
legacyAlongside = true;
|
|
1691
|
-
break;
|
|
1692
|
-
} catch (err) {
|
|
1693
|
-
if (err?.code !== "ENOENT") {
|
|
1694
|
-
// File exists but is unreadable — treat as "found" so the
|
|
1695
|
-
// deprecation warning fires (applyLayer is not called for legacy
|
|
1696
|
-
// paths when .devloops is present, so the flag only controls the warning).
|
|
1697
|
-
legacyAlongside = true;
|
|
1698
|
-
break;
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
if (legacyAlongside) break;
|
|
1703
|
-
}
|
|
1704
|
-
if (legacyAlongside) {
|
|
1705
|
-
warnings.push(
|
|
1706
|
-
`Deprecated config path(s) found under .pi/dev-loop/settings.* or .pi/dev-loop/overrides.*. ` +
|
|
1707
|
-
`Migrate to .devloops (or .devloops.yaml/.devloops.yml/.devloops.json) at repo root. ` +
|
|
1708
|
-
`Legacy paths will be removed in a future version.`
|
|
1709
|
-
);
|
|
1710
|
-
}
|
|
1711
|
-
} else {
|
|
1712
|
-
// No .devloops — fall back to legacy .pi/dev-loop/settings.* or overrides.* (deprecated)
|
|
1713
|
-
let legacyFound = false;
|
|
1714
|
-
for (const legacyPath of settingsPaths) {
|
|
1715
|
-
for (const ext of [".yaml", ".yml", ".json"]) {
|
|
1716
|
-
try {
|
|
1717
|
-
await readFile(legacyPath + ext, "utf8");
|
|
1718
|
-
legacyFound = true;
|
|
1719
|
-
break;
|
|
1720
|
-
} catch (err) {
|
|
1721
|
-
if (err?.code !== "ENOENT") {
|
|
1722
|
-
// File exists but is unreadable — treat as "found" so the
|
|
1723
|
-
// deprecation warning fires and applyLayer can surface the error
|
|
1724
|
-
// (legacy applyLayer runs in this branch).
|
|
1725
|
-
legacyFound = true;
|
|
1726
|
-
break;
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1730
|
-
if (legacyFound) break;
|
|
1731
|
-
}
|
|
1732
|
-
if (legacyFound) {
|
|
1733
|
-
warnings.push(
|
|
1734
|
-
`Deprecated config path(s) found under .pi/dev-loop/settings.* or .pi/dev-loop/overrides.*. ` +
|
|
1735
|
-
`Migrate to .devloops (or .devloops.yaml/.devloops.yml/.devloops.json) at repo root. ` +
|
|
1736
|
-
`Legacy paths will be removed in a future version.`
|
|
1737
|
-
);
|
|
1738
|
-
merged = await applyLayer(merged, settingsPaths, "settings", warnings, errors);
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
// Deprecated `queue.board` -> `tracker.board` alias (issue #1408, the
|
|
1743
|
-
// tracker-agnostic seam). Runs on the fully-merged object (unlike the
|
|
1744
|
-
// `strategy: "github-first"` alias above, this only affects cross-layer
|
|
1745
|
-
// MERGE PRECEDENCE, not per-layer schema validity — queue.board is still a
|
|
1746
|
-
// valid FileConfigSchema shape on its own — so normalizing once here, after
|
|
1747
|
-
// every layer has merged, is sufficient).
|
|
1748
|
-
if (isPlainObject(merged.queue?.board) && !isPlainObject(merged.tracker?.board)) {
|
|
1749
|
-
warnings.push(
|
|
1750
|
-
`queue.board is a deprecated alias for tracker.board (issue #1408). ` +
|
|
1751
|
-
`Update .devloops to set tracker.board instead; the alias will be removed in a future version.`
|
|
1752
|
-
);
|
|
1753
|
-
merged = { ...merged, tracker: { ...(merged.tracker ?? {}), board: merged.queue.board } };
|
|
1673
|
+
merged = await applyLayer(merged, devloopsPath, "devloops", warnings, errors);
|
|
1754
1674
|
}
|
|
1755
1675
|
|
|
1756
1676
|
// Validate final merged config
|
|
@@ -3085,19 +3005,13 @@ export function resolveTrackerProvider(config) {
|
|
|
3085
3005
|
}
|
|
3086
3006
|
|
|
3087
3007
|
/**
|
|
3088
|
-
* Resolve the effective tracker board identifier. `tracker.board` is
|
|
3089
|
-
* canonical
|
|
3090
|
-
* `tracker.board` by `loadDevLoopConfig` (with a load-time warning) for any
|
|
3091
|
-
* config that went through the loader. This resolver also accepts a
|
|
3092
|
-
* hand-built config object that sets `queue.board` directly (bypassing the
|
|
3093
|
-
* loader, e.g. in a test) and falls back to it — with no warning, since only
|
|
3094
|
-
* the loader surfaces warnings.
|
|
3008
|
+
* Resolve the effective tracker board identifier. `tracker.board` is the
|
|
3009
|
+
* canonical (and only) board config key.
|
|
3095
3010
|
*
|
|
3096
3011
|
* @param {DevLoopConfig} config
|
|
3097
3012
|
* @returns {{ number?: number, title?: string } | null}
|
|
3098
3013
|
*/
|
|
3099
3014
|
export function resolveTrackerBoard(config) {
|
|
3100
3015
|
if (isPlainObject(config?.tracker?.board)) return config.tracker.board;
|
|
3101
|
-
if (isPlainObject(config?.queue?.board)) return config.queue.board;
|
|
3102
3016
|
return null;
|
|
3103
3017
|
}
|
|
@@ -65,7 +65,7 @@ gates:
|
|
|
65
65
|
- name: config-drift
|
|
66
66
|
persona: review
|
|
67
67
|
prompt: |-
|
|
68
|
-
Cross-check config, schema, and documentation for contract drift: - Verify that configuration files (.
|
|
68
|
+
Cross-check config, schema, and documentation for contract drift: - Verify that configuration files (.devloops,
|
|
69
69
|
package.json, CI workflows, skill manifests) agree on canonical
|
|
70
70
|
status tokens, support floors, and required flags.
|
|
71
71
|
- Flag any instance where two sources of truth disagree about the
|
|
@@ -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,13 +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
|
|
267
|
-
|
|
268
|
-
|
|
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).
|
|
269
|
+
Completeness is enforced deterministically (#1877): the pre_approval_gate fails closed on any
|
|
270
|
+
unchecked `- [ ]` in the PR body's AC/DoD checklist, so this angle's completeness duty is
|
|
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.
|
|
269
280
|
- contradiction-lens
|
|
270
281
|
- correctness-final
|
|
271
282
|
- ui-validation
|
|
@@ -324,7 +335,7 @@ localImplementation:
|
|
|
324
335
|
maxFiles: 2
|
|
325
336
|
maxLines: 100
|
|
326
337
|
|
|
327
|
-
# Queue defaults (repo-specific
|
|
338
|
+
# Queue defaults (repo-specific tracker.board omitted by design).
|
|
328
339
|
queue:
|
|
329
340
|
maxParallel: 3
|
|
330
341
|
# Local-first is PR-first (issues are skipped, #952), so auto-filing issues is
|
|
@@ -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
|
|