@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 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": "node --test ./test/*.test.mjs"
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
- * Env var that exempts an orchestrator-owned-commit dispatch from the SubagentStop
388
- * uncommitted-work guard (#1786).
480
+ * Subagent roles whose contract forbids committing to the repository (#1925).
389
481
  *
390
- * A "LOCAL EDITS ONLY: no commit" dispatch (e.g. the `developer`/`quality`/`docs` delegation
391
- * pattern in `skills/local-implementation/SKILL.md` "Delegation contract") tells the subagent to
392
- * make local edits and report changed files, leaving commit + push to the dispatching
393
- * orchestrator once it consolidates results. Without an exemption, that subagent's own
394
- * SubagentStop event still sees the dirty worktree it was told not to commit and deadlocks. The
395
- * dispatcher sets `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` for that dispatch to declare it owns the
396
- * commit — same opt-in `DEVLOOPS_*` signal shape as `DEVLOOPS_COMMIT_AUTH_PENDING`, but distinct:
397
- * this one exempts a non-interactive delegated dispatch whose commit responsibility sits with its
398
- * caller, not an interactive session awaiting operator authorization. Left unset, an ordinary
399
- * dispatch's commit-before-exit obligation stays enforced (fail closed by default).
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 DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT_VAR = "DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT";
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, or the dispatch is an explicit
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 {boolean} [params.orchestratorOwnsCommit] - True when this dispatch is an explicit
428
- * orchestrator-owned-commit exemption (exempt) — derived by the hook script from the
429
- * `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` opt-in env signal.
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, orchestratorOwnsCommit = 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 || orchestratorOwnsCommit) {
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())
@@ -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` supersedes the deprecated `queue.board` (see resolveTrackerBoard).
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; supersedes the deprecated queue.board.").optional(),
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 (mirrors queue.board). The logical-column ->
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 {"defaults"|"settings"|"extensionDefaults"|"merged"} layer - Which config layer failed
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 settings).
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"|"settings"} layer - Layer name
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
- * settings.(yaml|yml|json) > legacy overrides.(yaml|yml|json) > repo .pi/dev-loop/defaults.(yaml|yml|json) > extension defaults > built-in defaults
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, "settings", warnings, errors);
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; `queue.board` is a DEPRECATED alias, already normalized onto
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 (.pi/dev-loop/settings.yaml,
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-matrix]
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-matrix
262
+ - name: pr-checklist
263
263
  mandatory: true
264
264
  persona: review
265
265
  prompt: |-
266
- Verify before approval that the PR checklist and AC/DoD/non-goals matrix are complete. - Every PR checkbox (`- [ ]`) must be checked. If any box is unchecked, flag it as a blocking finding. - The PR body must contain an AC/DoD/non-goals matrix that maps each acceptance criterion
267
- to its definition-of-done item(s) and lists explicit non-goals.
268
- - The matrix must have a markdown table with at least a header row and one content row. - Flag the matrix as incomplete if any acceptance criterion, definition-of-done item, or non-goal is missing.
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 queue.board omitted by design).
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. `&#91;` 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;` -> `&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 (`&num;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