mandrel 2.59.0 → 2.60.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.
Files changed (97) hide show
  1. package/.agents/README.md +11 -9
  2. package/.agents/agents/acceptance-critic.md +24 -43
  3. package/.agents/agents/story-worker.md +18 -19
  4. package/.agents/docs/SDLC.md +6 -6
  5. package/.agents/docs/agentrc-reference.json +1 -2
  6. package/.agents/docs/configuration.md +29 -46
  7. package/.agents/docs/quality-gates.md +8 -4
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +4 -5
  10. package/.agents/rules/ci-remediation.md +41 -8
  11. package/.agents/rules/known-tooling-behavior.md +65 -15
  12. package/.agents/schemas/acceptance-eval-verdict.schema.json +1 -1
  13. package/.agents/schemas/agentrc.schema.json +6 -11
  14. package/.agents/schemas/story-deliver-terminal.schema.json +3 -3
  15. package/.agents/scripts/README.md +11 -1
  16. package/.agents/scripts/acceptance-eval.js +25 -27
  17. package/.agents/scripts/ceremony-derive.js +15 -10
  18. package/.agents/scripts/check-context-budget.js +148 -228
  19. package/.agents/scripts/check-schema-references.js +5 -3
  20. package/.agents/scripts/check-workflow-citations.js +33 -147
  21. package/.agents/scripts/coverage-capture.js +7 -4
  22. package/.agents/scripts/deliver-light.js +41 -100
  23. package/.agents/scripts/deliver-run.js +631 -0
  24. package/.agents/scripts/file-ci-gap.js +59 -11
  25. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +6 -2
  26. package/.agents/scripts/lib/changed-files.js +30 -0
  27. package/.agents/scripts/lib/config/delivery-routing.js +5 -4
  28. package/.agents/scripts/lib/config/explain.js +1 -3
  29. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +1 -1
  30. package/.agents/scripts/lib/config-resolver.js +1 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -21
  32. package/.agents/scripts/lib/coverage-capture-fullscope.js +10 -2
  33. package/.agents/scripts/lib/coverage-capture-incremental.js +3 -2
  34. package/.agents/scripts/lib/coverage-capture-usage.js +4 -1
  35. package/.agents/scripts/lib/doc-tiers.js +4 -2
  36. package/.agents/scripts/lib/feedback-loop/graduator-core.js +7 -6
  37. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +7 -5
  38. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  39. package/.agents/scripts/lib/gh-exec.js +160 -0
  40. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  41. package/.agents/scripts/lib/orchestration/ceremony-routing.js +74 -132
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +123 -12
  43. package/.agents/scripts/lib/orchestration/complexity-gate.js +180 -352
  44. package/.agents/scripts/lib/orchestration/light-suitability.js +71 -136
  45. package/.agents/scripts/lib/orchestration/plan-context.js +13 -25
  46. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +8 -6
  47. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +76 -95
  48. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +35 -18
  49. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +11 -11
  50. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -29
  51. package/.agents/scripts/lib/orchestration/review-depth.js +14 -11
  52. package/.agents/scripts/lib/orchestration/run-epilogue.js +260 -182
  53. package/.agents/scripts/lib/orchestration/run-scoped-config.js +63 -99
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +3 -3
  55. package/.agents/scripts/lib/orchestration/single-story-close/phases/graphql-preflight.js +137 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +105 -18
  57. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -3
  58. package/.agents/scripts/lib/orchestration/story-follow-ups.js +156 -39
  59. package/.agents/scripts/lib/orchestration/story-init-envelope.js +71 -0
  60. package/.agents/scripts/lib/orchestration/task-body-validator.js +8 -17
  61. package/.agents/scripts/lib/orchestration/ticket-validator.js +44 -183
  62. package/.agents/scripts/lib/orchestration/ticketing/reads.js +14 -25
  63. package/.agents/scripts/lib/story-body/body-format-lints.js +58 -12
  64. package/.agents/scripts/lib/story-body/story-body.js +83 -29
  65. package/.agents/scripts/lib/templates/decomposer-prompts.js +7 -15
  66. package/.agents/scripts/lib/wave-runner/live-probe.js +31 -5
  67. package/.agents/scripts/merge-baseline.js +4 -5
  68. package/.agents/scripts/plan-context.js +117 -28
  69. package/.agents/scripts/plan-persist.js +79 -28
  70. package/.agents/scripts/plan-run-epilogue.js +11 -8
  71. package/.agents/scripts/pr-watch-with-update.js +9 -2
  72. package/.agents/scripts/run-verify.js +13 -6
  73. package/.agents/scripts/single-story-init.js +7 -57
  74. package/.agents/scripts/stories-wave-tick.js +160 -26
  75. package/.agents/skills/core/gates-and-baselines/reference.md +0 -1
  76. package/.agents/skills/skills.index.json +2 -2
  77. package/.agents/skills/stack/qa/playwright/SKILL.md +26 -0
  78. package/.agents/workflows/helpers/acceptance-self-eval.md +84 -157
  79. package/.agents/workflows/helpers/code-review.md +4 -2
  80. package/.agents/workflows/helpers/deliver-digest.md +31 -24
  81. package/.agents/workflows/helpers/deliver-light.md +92 -101
  82. package/.agents/workflows/helpers/deliver-reference.md +116 -100
  83. package/.agents/workflows/helpers/deliver-story-reference.md +58 -124
  84. package/.agents/workflows/helpers/deliver-story.md +17 -18
  85. package/.agents/workflows/helpers/plan-reference.md +65 -54
  86. package/.agents/workflows/mandrel-deliver.md +47 -31
  87. package/.agents/workflows/mandrel-plan.md +22 -21
  88. package/.agents/workflows/mandrel-update.md +36 -21
  89. package/docs/CHANGELOG.md +35 -0
  90. package/lib/cli/update.js +376 -17
  91. package/lib/migrations/index.js +2 -0
  92. package/lib/migrations/steps/2.60.0-retire-audit-results-autofile.js +40 -0
  93. package/package.json +2 -1
  94. package/.agents/schemas/model-attribution.schema.json +0 -53
  95. package/.agents/scripts/lib/orchestration/model-attribution.js +0 -418
  96. package/.agents/scripts/lib/orchestration/story-plan-state.js +0 -33
  97. package/.agents/scripts/lib/orchestration/structured-comment-parser.js +0 -67
package/lib/cli/update.js CHANGED
@@ -16,7 +16,9 @@
16
16
  * materialized `.agents/` state (Story #4065). When already on newest AND
17
17
  * no drift: nothing to do (true no-op). When already on newest BUT drift
18
18
  * detected: skip npm-update/migrations, run sync + sync-commands to heal.
19
- * 3. install — bump the dependency (lockfile bump left STAGED).
19
+ * 3. install — bump the dependency (manifest + lockfile rewritten on
20
+ * disk; whether the package manager also stages them is reported, never
21
+ * assumed — see § No git mutation).
20
22
  * The package manager is auto-detected from the lockfile in the project
21
23
  * root: `pnpm-lock.yaml` ⇒ `pnpm add -D …` (with `-w` at a
22
24
  * `pnpm-workspace.yaml` root), `yarn.lock` ⇒ `yarn add -D …`, otherwise
@@ -56,10 +58,29 @@
56
58
  *
57
59
  * ## No git mutation
58
60
  *
59
- * The npm dependency bump rewrites `package.json` / `package-lock.json` in the
60
- * working tree but the orchestrator performs **no** `git add` / `git commit`:
61
- * the lockfile bump is left staged-on-disk for the operator to review and
62
- * commit. This module never shells out to git.
61
+ * The dependency bump rewrites `package.json` / the lockfile in the working
62
+ * tree but the orchestrator performs **no** `git add` / `git commit`. This
63
+ * module **reads git state to report it; never mutates it** (Story #5339).
64
+ *
65
+ * The final success line used to assert "the lockfile bump is staged for
66
+ * review" unconditionally. That is only ever true where `npm install` happens
67
+ * to stage; the `pnpm add` / `yarn add` seams stage nothing, so on a pnpm
68
+ * consumer the operator was told to review an empty index while the manifest,
69
+ * the lockfile and every re-materialized `.agents/` path sat unstaged — an
70
+ * invitation to commit a half-upgrade. The orchestrator now probes the real
71
+ * index through the injectable `gitStatus` seam — two read-only calls, a
72
+ * pathspec-anchored `git status --porcelain` plus `git ls-files .agents` — and
73
+ * prints one of three truthful lines: staged, not staged (with the exact
74
+ * `git add` command), or a neutral "review the working tree" line when git is
75
+ * unavailable or the probe fails. A failed probe never fails the update: the
76
+ * run still exits 0.
77
+ *
78
+ * **Staged means both halves** (Story #5364): the index differs from HEAD
79
+ * *and* the worktree agrees with the index. Reading only the index reported a
80
+ * manifest pair the operator staged *before* running the command as staged
81
+ * afterwards, even though the install had since rewritten both files on disk —
82
+ * the exact false claim this report exists to remove. The drift-heal path
83
+ * reports the re-materialized payload the same way.
63
84
  *
64
85
  * ## `--dry-run`
65
86
  *
@@ -99,6 +120,16 @@
99
120
  * sole post-install execution path. See § Re-exec of
100
121
  * post-install phases.
101
122
  * - `surfaceChangelog` — emits the target changelog section
123
+ * - `gitStatus` — read-only git probe backing the staging report;
124
+ * receives `({ cwd, lockfile })` and returns
125
+ * `{ ok, stagedManifest, stagedLockfile,
126
+ * stagedPayload, tracksAgents }` (Story #5339,
127
+ * #5364). Never mutates the repository.
128
+ * - `detectLockfile` — resolves the lockfile name the staging report
129
+ * names — and the probe's pathspec uses — from the
130
+ * uncoerced package-manager probe, so a bun
131
+ * consumer is named its real lockfile; receives
132
+ * `(cwd)`
102
133
  * - `write` / `writeErr` — stdout / stderr sinks
103
134
  * - `exit` — process.exit replacement
104
135
  * - `cwd` — process.cwd() replacement (used to resolve the
@@ -374,6 +405,26 @@ function repairInstallCommand(packageManager) {
374
405
  * @returns {{ packageManager: 'pnpm' | 'yarn' | 'npm', workspaceRoot: boolean }}
375
406
  */
376
407
  export function detectPackageManager(cwd = process.cwd(), fs = nodeFs) {
408
+ const result = probePackageManager(cwd, fs);
409
+ // Coerce `bun` → `npm` because this orchestrator's install-command builder
410
+ // only handles pnpm / yarn / npm today.
411
+ const packageManager =
412
+ result.packageManager === 'bun' ? 'npm' : result.packageManager;
413
+ return { packageManager, workspaceRoot: result.workspaceRoot };
414
+ }
415
+
416
+ /**
417
+ * The raw, **uncoerced** package-manager probe. `detectPackageManager` above
418
+ * flattens `bun` to `npm` for the install-command builder's benefit; the
419
+ * staging report must not inherit that flattening, or a bun consumer is told
420
+ * to stage a `package-lock.json` its tree does not have (Story #5364). Reading
421
+ * the probe before the coercion is the whole fix.
422
+ *
423
+ * @param {string} cwd - Project root to probe.
424
+ * @param {typeof nodeFs} [fs]
425
+ * @returns {{ packageManager: 'pnpm'|'yarn'|'bun'|'npm', workspaceRoot: boolean }}
426
+ */
427
+ function probePackageManager(cwd, fs = nodeFs) {
377
428
  const exists = (p) => {
378
429
  try {
379
430
  return fs.existsSync(p);
@@ -381,12 +432,7 @@ export function detectPackageManager(cwd = process.cwd(), fs = nodeFs) {
381
432
  return false;
382
433
  }
383
434
  };
384
- const result = detectPackageManagerWithWorkspace(cwd, exists);
385
- // Coerce `bun` → `npm` because this orchestrator's install-command builder
386
- // only handles pnpm / yarn / npm today.
387
- const packageManager =
388
- result.packageManager === 'bun' ? 'npm' : result.packageManager;
389
- return { packageManager, workspaceRoot: result.workspaceRoot };
435
+ return detectPackageManagerWithWorkspace(cwd, exists);
390
436
  }
391
437
 
392
438
  /**
@@ -434,10 +480,292 @@ export function resolveInstallCmd(
434
480
  return `npm install ${PACKAGE_NAME}@${target}`;
435
481
  }
436
482
 
483
+ /**
484
+ * The lockfile each supported package manager writes. The staging report names
485
+ * the detected one so the `git add` hint is copy-pasteable in the consumer's
486
+ * real tree rather than always saying `package-lock.json` (Story #5339).
487
+ */
488
+ const LOCKFILE_BY_PACKAGE_MANAGER = {
489
+ pnpm: 'pnpm-lock.yaml',
490
+ yarn: 'yarn.lock',
491
+ bun: 'bun.lockb',
492
+ npm: 'package-lock.json',
493
+ };
494
+
495
+ /** The manifest the dependency bump rewrites alongside the lockfile. */
496
+ const MANIFEST_FILENAME = 'package.json';
497
+
498
+ /** The materialized payload directory the sync phase rewrites. */
499
+ const AGENTS_DIR = '.agents';
500
+
501
+ /**
502
+ * Default `detectLockfile` seam: resolve the lockfile name for the staging
503
+ * report from the **uncoerced** package-manager probe (`pnpm-lock.yaml` /
504
+ * `yarn.lock` / `bun.lockb` / `package-lock.json`). Reading
505
+ * `probePackageManager` rather than `detectPackageManager` is deliberate: the
506
+ * latter flattens `bun` to `npm` so the install-command builder has a command
507
+ * to emit, and inheriting that here would name a lockfile the consumer's tree
508
+ * does not carry (Story #5364). A directory with no recognizable toolchain
509
+ * still resolves to `npm`, so the report always names a concrete file.
510
+ *
511
+ * @param {string} cwd - Consumer project root.
512
+ * @param {typeof nodeFs} [fs]
513
+ * @returns {string}
514
+ */
515
+ export function defaultDetectLockfile(cwd, fs = nodeFs) {
516
+ const { packageManager } = probePackageManager(cwd, fs);
517
+ return LOCKFILE_BY_PACKAGE_MANAGER[packageManager] ?? 'package-lock.json';
518
+ }
519
+
520
+ /** The line printed when the probe cannot report the index at all. */
521
+ const NEUTRAL_STAGING_LINE =
522
+ 'Review the working tree and commit the bump (git not available to report staging state).';
523
+
524
+ /** What the probe reports when it cannot read the index at all. */
525
+ const DEGRADED_GIT_STATE = Object.freeze({
526
+ ok: false,
527
+ stagedManifest: false,
528
+ stagedLockfile: false,
529
+ stagedPayload: false,
530
+ tracksAgents: false,
531
+ });
532
+
533
+ /** True when a `spawnSync` result is a clean, non-throwing exit. */
534
+ const spawnOk = (result) =>
535
+ Boolean(result) && !result.error && result.status === 0;
536
+
537
+ /**
538
+ * The path one `git status --porcelain` v1 record reports as **staged**, or
539
+ * `null` when that record is not staged (or carries no path).
540
+ *
541
+ * Staged means both halves of the contract (Story #5364): the index differs
542
+ * from HEAD *and* the worktree agrees with the index. Porcelain v1 states that
543
+ * as `XY<space>path` — `X` the index column, `Y` the worktree column — so a
544
+ * path the operator staged before the install and the install then rewrote on
545
+ * disk reports `MM` and is correctly NOT staged. `?` in the index column is an
546
+ * untracked file, which is never staged.
547
+ *
548
+ * A rename carries `orig -> new`; the post-rename path is the one the operator
549
+ * stages. Porcelain quotes paths containing unusual bytes, so the quoting is
550
+ * stripped.
551
+ *
552
+ * @param {string} record
553
+ * @returns {string | null}
554
+ */
555
+ function stagedPathFromPorcelain(record) {
556
+ if (record.length < 4 || record[1] !== ' ') return null;
557
+ if (record[0] === ' ' || record[0] === '?') return null;
558
+ const raw = record.slice(3).trim();
559
+ const arrow = raw.lastIndexOf(' -> ');
560
+ const pathPart = arrow === -1 ? raw : raw.slice(arrow + 4);
561
+ const path =
562
+ pathPart.startsWith('"') && pathPart.endsWith('"')
563
+ ? pathPart.slice(1, -1)
564
+ : pathPart;
565
+ return path.length > 0 ? path : null;
566
+ }
567
+
568
+ /**
569
+ * Which slot of the probe state one staged path fills, or `null` for a path
570
+ * that fills none.
571
+ *
572
+ * The payload test is anchored on a path separator, so a sibling like
573
+ * `my.agents/` never matches, and it runs first: a `package.json` *inside*
574
+ * `.agents/` is payload, never the consumer's root manifest. The manifest and
575
+ * lockfile tests tolerate one leading prefix because git reports porcelain
576
+ * paths relative to the repository root, which need not be the probe root —
577
+ * safe only because the caller's pathspec already anchored the query there, so
578
+ * a staged `packages/app/package.json` never reaches this function at all
579
+ * (Story #5364).
580
+ *
581
+ * @param {string} path
582
+ * @param {string} lockfile
583
+ * @returns {'stagedPayload' | 'stagedManifest' | 'stagedLockfile' | null}
584
+ */
585
+ function stagedSlotFor(path, lockfile) {
586
+ if (path === AGENTS_DIR || /(^|\/)\.agents\//.test(path))
587
+ return 'stagedPayload';
588
+ const isRootFile = (name) => path === name || path.endsWith(`/${name}`);
589
+ if (isRootFile(MANIFEST_FILENAME)) return 'stagedManifest';
590
+ return isRootFile(lockfile) ? 'stagedLockfile' : null;
591
+ }
592
+
593
+ /**
594
+ * Default `gitStatus` seam: probe the consumer repository's real index state.
595
+ *
596
+ * **Two** read-only plumbing calls, no mutation of any kind (§ No git
597
+ * mutation):
598
+ * - `git status --porcelain -- package.json <lockfile> .agents` — one read
599
+ * that answers every branch the report chooses between. The pathspecs are
600
+ * resolved relative to `cwd`, which anchors the query at the probe root:
601
+ * a nested workspace manifest is simply not in the output. Both status
602
+ * columns come back, so "staged" can mean index-differs-**and**-worktree-
603
+ * clean rather than the index alone.
604
+ * - `git ls-files .agents` — whether the consumer *tracks* the materialized
605
+ * payload, which decides whether the `git add` hint names it.
606
+ *
607
+ * Any failure — git absent, not a repository, a non-zero exit, a throw from the
608
+ * spawn boundary — degrades to `{ ok: false }` so the caller prints the neutral
609
+ * line and the update still exits 0. The probe never throws.
610
+ *
611
+ * Security (security-baseline § Output & Rendering): every argv segment here is
612
+ * a fixed vector or a lockfile name drawn from `LOCKFILE_BY_PACKAGE_MANAGER`,
613
+ * never operator input, and no shell is used.
614
+ *
615
+ * @param {{
616
+ * cwd?: string,
617
+ * lockfile?: string,
618
+ * spawnSync?: typeof spawnSync,
619
+ * }} [opts]
620
+ * @returns {{
621
+ * ok: boolean,
622
+ * stagedManifest: boolean,
623
+ * stagedLockfile: boolean,
624
+ * stagedPayload: boolean,
625
+ * tracksAgents: boolean,
626
+ * }}
627
+ */
628
+ export function defaultGitStatus({
629
+ cwd = process.cwd(),
630
+ lockfile = LOCKFILE_BY_PACKAGE_MANAGER.npm,
631
+ spawnSync: spawn = spawnSync,
632
+ } = {}) {
633
+ const run = (args) => spawn('git', args, { cwd, encoding: 'utf8' });
634
+ try {
635
+ const status = run([
636
+ 'status',
637
+ '--porcelain',
638
+ '--',
639
+ MANIFEST_FILENAME,
640
+ lockfile,
641
+ AGENTS_DIR,
642
+ ]);
643
+ if (!spawnOk(status)) return DEGRADED_GIT_STATE;
644
+ const agents = run(['ls-files', AGENTS_DIR]);
645
+ const state = {
646
+ ...DEGRADED_GIT_STATE,
647
+ ok: true,
648
+ tracksAgents: spawnOk(agents) && String(agents.stdout).trim().length > 0,
649
+ };
650
+ for (const record of String(status.stdout).split('\n')) {
651
+ const path = stagedPathFromPorcelain(record);
652
+ const slot = path && stagedSlotFor(path, lockfile);
653
+ if (slot) state[slot] = true;
654
+ }
655
+ return state;
656
+ } catch {
657
+ return DEGRADED_GIT_STATE;
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Render the one truthful staging line a successful run closes with
663
+ * (Story #5339, corrected by Story #5364).
664
+ *
665
+ * `scope: 'bump'` (the full upgrade) reports the manifest + lockfile pair:
666
+ *
667
+ * - **staged** — both are staged, so "staged for review" is a fact.
668
+ * - **not staged** — anything else: the line says so and prints the exact
669
+ * `git add` command, naming `.agents/` too when the consumer tracks that
670
+ * tree (the sync re-materialized it, and an operator who stages only the
671
+ * manifest pair silently drops that diff).
672
+ *
673
+ * `scope: 'payload'` (the drift heal, which bumps no dependency) reports the
674
+ * re-materialized `.agents/` tree instead, and returns `''` when the consumer
675
+ * does not track it — there is then nothing to stage and nothing to say.
676
+ *
677
+ * Either scope degrades to a line that claims nothing about the index when the
678
+ * probe could not report.
679
+ *
680
+ * Pure: takes the probe result and the resolved lockfile name, touches no fs.
681
+ *
682
+ * @param {{
683
+ * ok?: boolean,
684
+ * stagedManifest?: boolean,
685
+ * stagedLockfile?: boolean,
686
+ * stagedPayload?: boolean,
687
+ * tracksAgents?: boolean,
688
+ * }} gitState - The `gitStatus` seam's return value.
689
+ * @param {string} lockfile - The detected lockfile name.
690
+ * @param {{ scope?: 'bump' | 'payload' }} [opts]
691
+ * @returns {string} A single sentence-run with no trailing newline, or `''`
692
+ * when there is nothing to report.
693
+ */
694
+ export function formatStagingReport(
695
+ gitState,
696
+ lockfile,
697
+ { scope = 'bump' } = {},
698
+ ) {
699
+ const {
700
+ ok = false,
701
+ stagedManifest = false,
702
+ stagedLockfile = false,
703
+ stagedPayload = false,
704
+ tracksAgents = false,
705
+ } = gitState ?? {};
706
+
707
+ if (!ok) return NEUTRAL_STAGING_LINE;
708
+
709
+ if (scope === 'payload') {
710
+ if (!tracksAgents) return '';
711
+ return stagedPayload
712
+ ? `The re-materialized ${AGENTS_DIR}/ payload is staged for review.`
713
+ : `The re-materialized ${AGENTS_DIR}/ payload is NOT staged. Review and stage it: git add ${AGENTS_DIR}/`;
714
+ }
715
+
716
+ if (stagedManifest && stagedLockfile) {
717
+ return `The dependency bump is staged for review (${MANIFEST_FILENAME}, ${lockfile}).`;
718
+ }
719
+
720
+ const targets = [MANIFEST_FILENAME, lockfile];
721
+ let note = '';
722
+ if (tracksAgents) {
723
+ targets.push(`${AGENTS_DIR}/`);
724
+ note = ` ${AGENTS_DIR}/ is tracked here, so stage the re-materialized payload too.`;
725
+ }
726
+ return `The dependency bump is NOT staged.${note} Review and stage it: git add ${targets.join(' ')}`;
727
+ }
728
+
729
+ /**
730
+ * Resolve the staging line for a success surface, absorbing any failure in
731
+ * either seam. Reporting the index is a courtesy on top of completed work: a
732
+ * probe that throws (git missing, a seam raising) must degrade to the neutral
733
+ * line, never turn a successful run into a crash.
734
+ *
735
+ * The lockfile is resolved first because the probe's pathspec names it — that
736
+ * is what anchors the query at the probe root.
737
+ *
738
+ * @param {{
739
+ * gitStatus: (opts: { cwd: string, lockfile: string }) => object,
740
+ * detectLockfile: (cwd: string) => string,
741
+ * projectRoot: string,
742
+ * scope?: 'bump' | 'payload',
743
+ * }} deps
744
+ * @returns {string}
745
+ */
746
+ function resolveStagingReport({
747
+ gitStatus,
748
+ detectLockfile,
749
+ projectRoot,
750
+ scope = 'bump',
751
+ }) {
752
+ try {
753
+ const lockfile = detectLockfile(projectRoot);
754
+ return formatStagingReport(
755
+ gitStatus({ cwd: projectRoot, lockfile }),
756
+ lockfile,
757
+ { scope },
758
+ );
759
+ } catch {
760
+ return NEUTRAL_STAGING_LINE;
761
+ }
762
+ }
763
+
437
764
  /**
438
765
  * Default `npmUpdate` seam: install the resolved target version. The install
439
- * rewrites `package.json` / the lockfile on disk (left staged for the
440
- * operator); this performs no git mutation.
766
+ * rewrites `package.json` / the lockfile on disk for the operator to review;
767
+ * this performs no git mutation. Whether the package manager also staged them
768
+ * is reported by the staging probe, never assumed (Story #5339).
441
769
  *
442
770
  * The package manager is auto-detected from `cwd`'s lockfile (Story #3575) so
443
771
  * the bump lands in the operator's real lockfile rather than running
@@ -1119,6 +1447,8 @@ async function executePlan({
1119
1447
  * checkDrift?: () => (boolean | Promise<boolean>),
1120
1448
  * spawnPhase?: (phase: string, args: string[], opts: { binPath: string, cwd: string, write: (s: string) => void, writeErr: (s: string) => void }) => Promise<{ ok: boolean, stdout: string, stderr: string }> | { ok: boolean, stdout: string, stderr: string },
1121
1449
  * surfaceChangelog?: (version: string) => unknown | Promise<unknown>,
1450
+ * gitStatus?: (opts: { cwd: string }) => { ok: boolean, staged?: string[], unstaged?: string[], tracksAgents?: boolean },
1451
+ * detectLockfile?: (cwd: string) => string,
1122
1452
  * write?: (s: string) => void,
1123
1453
  * writeErr?: (s: string) => void,
1124
1454
  * exit?: (code: number) => void,
@@ -1142,6 +1472,8 @@ export async function runUpdate({
1142
1472
  checkDrift,
1143
1473
  spawnPhase,
1144
1474
  surfaceChangelog,
1475
+ gitStatus = defaultGitStatus,
1476
+ detectLockfile = defaultDetectLockfile,
1145
1477
  write = (s) => process.stdout.write(s),
1146
1478
  writeErr = (s) => process.stderr.write(s),
1147
1479
  exit = (code) => process.exit(code),
@@ -1245,8 +1577,19 @@ export async function runUpdate({
1245
1577
  });
1246
1578
 
1247
1579
  if (plan.action === 'resynced') {
1580
+ // The heal re-materializes `.agents/` and stops — no dependency is bumped,
1581
+ // so the payload IS the diff. A consumer that tracks that tree used to be
1582
+ // told nothing at all about it (Story #5364), the same silent-diff hazard
1583
+ // the staging report exists to close, one branch earlier.
1584
+ const payloadLine = resolveStagingReport({
1585
+ gitStatus,
1586
+ detectLockfile,
1587
+ projectRoot,
1588
+ scope: 'payload',
1589
+ });
1248
1590
  write(
1249
- `✅ Healed .agents/ drift (v${current}). The materialized payload is now current.\n`,
1591
+ `✅ Healed .agents/ drift (v${current}). The materialized payload is now current.` +
1592
+ `${payloadLine ? ` ${payloadLine}` : ''}\n`,
1250
1593
  );
1251
1594
  return {
1252
1595
  ok: true,
@@ -1270,7 +1613,12 @@ export async function runUpdate({
1270
1613
  };
1271
1614
  }
1272
1615
 
1273
- write(`✅ Updated to v${target}. The lockfile bump is staged for review.\n`);
1616
+ // Report the real index state rather than asserting one (Story #5339). Both
1617
+ // seams are read-only and failure-tolerant: a probe that cannot answer yields
1618
+ // the neutral line, and the update still reports success with a zero exit.
1619
+ write(
1620
+ `✅ Updated to v${target}. ${resolveStagingReport({ gitStatus, detectLockfile, projectRoot })}\n`,
1621
+ );
1274
1622
  return {
1275
1623
  ok: true,
1276
1624
  action: 'updated',
@@ -1292,8 +1640,8 @@ export async function runUpdate({
1292
1640
  * a current baseline after the upgrade.
1293
1641
  * - `npmUpdate` runs the install command — auto-detected from the project
1294
1642
  * lockfile (`pnpm`/`yarn`/`npm`), or the `--install-cmd` override —
1295
- * through the shared `runInstallCommand` helper — no git mutation;
1296
- * lockfile left staged.
1643
+ * through the shared `runInstallCommand` helper — no git mutation; the
1644
+ * resulting index state is reported by the staging probe, not asserted.
1297
1645
  * - `spawnPhase` is wired to `defaultSpawnPhase`, which spawns each
1298
1646
  * post-install phase (sync, sync-commands, migrate, doctor) as
1299
1647
  * `node <packageRoot>/bin/mandrel.js …` (Story #4613 — the resolved bin
@@ -1336,6 +1684,8 @@ export async function runUpdate({
1336
1684
  * cwd?: () => string,
1337
1685
  * resolveBinScript?: (projectRoot: string) => string,
1338
1686
  * checkDrift?: () => (boolean | Promise<boolean>),
1687
+ * gitStatus?: (opts: { cwd: string }) => object,
1688
+ * detectLockfile?: (cwd: string) => string,
1339
1689
  * write?: (s: string) => void,
1340
1690
  * writeErr?: (s: string) => void,
1341
1691
  * exit?: (code: number) => void,
@@ -1361,6 +1711,8 @@ export default async function run(argv = [], deps = {}) {
1361
1711
  cwd,
1362
1712
  resolveBinScript,
1363
1713
  checkDrift,
1714
+ gitStatus,
1715
+ detectLockfile,
1364
1716
  } = deps;
1365
1717
 
1366
1718
  const cwdFn = typeof cwd === 'function' ? cwd : () => process.cwd();
@@ -1405,6 +1757,13 @@ export default async function run(argv = [], deps = {}) {
1405
1757
  fs,
1406
1758
  }),
1407
1759
  ...(checkDrift ? { checkDrift } : {}),
1760
+ // Story #5339 — the staging probe. Production leaves both undefined so
1761
+ // runUpdate applies the real read-only git seam and the lockfile detection
1762
+ // that mirrors the install seam's; tests stub them for a fake tree.
1763
+ ...(gitStatus ? { gitStatus } : {}),
1764
+ ...(detectLockfile
1765
+ ? { detectLockfile }
1766
+ : { detectLockfile: (dir) => defaultDetectLockfile(dir, fs) }),
1408
1767
  spawnPhase: productionSpawnPhase,
1409
1768
  surfaceChangelog: (target) =>
1410
1769
  defaultSurfaceChangelog(target, {
@@ -61,6 +61,7 @@ import { retireCodebaseSnapshot } from './steps/2.20.0-retire-codebase-snapshot.
61
61
  import { retireLintBaselineCommand } from './steps/2.32.0-retire-lint-baseline-command.js';
62
62
  import { retireDeliveryLimitKnobs } from './steps/2.57.0-retire-delivery-limit-knobs.js';
63
63
  import { retirePlanningLimitKnobs } from './steps/2.57.0-retire-planning-limit-knobs.js';
64
+ import { retireAuditResultsAutoFile } from './steps/2.60.0-retire-audit-results-autofile.js';
64
65
 
65
66
  /**
66
67
  * Ordered registry of migration steps. MUST stay sorted ascending by
@@ -82,6 +83,7 @@ export const migrations = [
82
83
  retireLintBaselineCommand,
83
84
  retirePlanningLimitKnobs,
84
85
  retireDeliveryLimitKnobs,
86
+ retireAuditResultsAutoFile,
85
87
  ];
86
88
 
87
89
  /**
@@ -0,0 +1,40 @@
1
+ // lib/migrations/steps/2.60.0-retire-audit-results-autofile.js
2
+ /**
3
+ * Story #5366 — strip the retired `delivery.feedbackLoop.auditResultsAutoFile`
4
+ * key from a consumer's config.
5
+ *
6
+ * The audit-results graduator this toggle switched was deleted two releases
7
+ * ago, so the key had no runtime reader left: Story #5341's flip of its
8
+ * default from `true` to `false` changed nothing at all, because nothing read
9
+ * either value. A toggle with no reader is worse than no toggle — it reads as
10
+ * a live control, and a consumer that set it was configuring nothing.
11
+ *
12
+ * `delivery.feedbackLoop` carries `additionalProperties: false`, so a config
13
+ * that still sets the key fails AJV validation outright on upgrade rather than
14
+ * warning. That is what makes this a migration rather than a docs change.
15
+ *
16
+ * Both config surfaces are swept: `config-resolver.js` deep-merges
17
+ * `.agentrc.local.json` over `.agentrc.json` **before** the AJV gate runs, so
18
+ * a key surviving in the gitignored overlay fails exactly as a base one would.
19
+ *
20
+ * Pruning: `pruneDepth: 2` prunes an emptied `feedbackLoop` and then an
21
+ * emptied `delivery`, both of which are optional. The sibling `retroProposals`
22
+ * toggle is deliberately untouched — it has a live reader — so a consumer who
23
+ * set both keeps its block.
24
+ */
25
+
26
+ import { createRetireAgentrcKeyStep } from '../helpers/retire-agentrc-key.js';
27
+
28
+ export const retireAuditResultsAutoFile = createRetireAgentrcKeyStep({
29
+ version: '2.60.0',
30
+ description:
31
+ 'strip the retired delivery.feedbackLoop.auditResultsAutoFile key from ' +
32
+ '.agentrc.json — its graduator was deleted, so the toggle had no ' +
33
+ 'runtime reader (Story #5366)',
34
+ keys: [
35
+ {
36
+ path: ['delivery', 'feedbackLoop', 'auditResultsAutoFile'],
37
+ pruneDepth: 2,
38
+ },
39
+ ],
40
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.59.0",
3
+ "version": "2.60.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",
@@ -45,6 +45,7 @@
45
45
  "maintainability:update": "node .agents/scripts/update-maintainability-baseline.js",
46
46
  "maintainability:reanchor": "node .agents/scripts/update-maintainability-baseline.js --full-scope",
47
47
  "check:arch": "node .agents/scripts/check-arch-cycles.js",
48
+ "//resident-context-reports": "check:context-budget and check:workflow-citations are REPORTS, not ratchets (Story #5340, ADR 20260917-5340). check:context-budget still fails when the alwaysLoaded tier exceeds its recorded total plus the committed tolerance; its workflow tier, and check:workflow-citations in full, print and always exit 0. A green run is not evidence the numbers held — read the output.",
48
49
  "check:context-budget": "node .agents/scripts/check-context-budget.js",
49
50
  "check:cyclomatic": "node .agents/scripts/check-cyclomatic.js",
50
51
  "cyclomatic:update": "node .agents/scripts/check-cyclomatic.js --update",
@@ -1,53 +0,0 @@
1
- {
2
- "$schema": "http://json-schema.org/draft-07/schema#",
3
- "$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/model-attribution.schema.json",
4
- "title": "ModelAttribution",
5
- "description": "Payload of the <!-- structured:model-attribution --> comment upserted onto a Task ticket at the moment it transitions to agent::executing (Story #2813). One entry per Task. Story- and Epic-level breakdowns are derived at query time from the child Tasks' attribution comments — there is no Story/Epic-scope emission.",
6
- "x-mandrel-uncompiled": {
7
- "reason": "Deliberate: this document is the SSOT for the shape, but no AJV instance compiles it. The framework does not pull AJV into the structured-comment path — that path uses hand-rolled shape guards — so the runtime gate below mirrors this document by hand. Keep the two in step: an edit here is only real once the validator enforces it. Declared in-file per Story #4938 so a reader is never left inferring authority from the file's mere existence.",
8
- "runtimeGate": ".agents/scripts/lib/orchestration/model-attribution.js#validateModelAttributionPayload"
9
- },
10
- "type": "object",
11
- "additionalProperties": false,
12
- "required": ["kind", "ticketId", "model", "source", "recordedAt"],
13
- "properties": {
14
- "kind": { "type": "string", "const": "model-attribution" },
15
- "ticketId": {
16
- "type": "integer",
17
- "minimum": 1,
18
- "description": "GitHub Issue number of the Task this attribution is recorded on."
19
- },
20
- "model": {
21
- "type": "object",
22
- "additionalProperties": false,
23
- "required": ["id"],
24
- "properties": {
25
- "id": {
26
- "type": "string",
27
- "minLength": 1,
28
- "description": "Canonical model identifier (e.g. 'claude-opus-4-7', 'claude-sonnet-4-6'), or the sentinel 'unknown' when neither SDK metadata nor a runtime env var supplied an identity."
29
- },
30
- "family": {
31
- "type": "string",
32
- "minLength": 1,
33
- "description": "Coarse model family label for rollup grouping (e.g. 'Opus', 'Sonnet', 'Haiku'). Optional; derived from the id when present."
34
- }
35
- }
36
- },
37
- "source": {
38
- "type": "string",
39
- "enum": ["sdk-metadata", "env", "unknown"],
40
- "description": "Provenance of the model identification, in fallback order: 'sdk-metadata' (resolved from the SDK response), 'env' (resolved from a runtime env var), 'unknown' (neither was available)."
41
- },
42
- "recordedAt": {
43
- "type": "string",
44
- "format": "date-time",
45
- "description": "ISO-8601 timestamp at which the attribution was recorded."
46
- },
47
- "sdkMetadata": {
48
- "type": "object",
49
- "description": "Opaque pass-through fields lifted from the SDK response metadata when available (e.g. response id, usage hints). Never required; never relied upon by the rollup helper.",
50
- "additionalProperties": true
51
- }
52
- }
53
- }