mandrel 2.32.0 → 2.33.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 (38) hide show
  1. package/.agents/docs/SDLC.md +8 -5
  2. package/.agents/docs/agentrc-reference.json +2 -1
  3. package/.agents/docs/configuration.md +1 -0
  4. package/.agents/runtime-deps.json +2 -1
  5. package/.agents/schemas/agentrc.schema.json +6 -0
  6. package/.agents/scripts/README.md +9 -0
  7. package/.agents/scripts/audit-to-stories.js +160 -41
  8. package/.agents/scripts/check-knip-entries.js +47 -24
  9. package/.agents/scripts/check-lifecycle-lint.js +72 -12
  10. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
  11. package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
  12. package/.agents/scripts/lib/config/runners.js +38 -16
  13. package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
  14. package/.agents/scripts/lib/dependency-parser.js +20 -7
  15. package/.agents/scripts/lib/findings/provenance-field.js +135 -0
  16. package/.agents/scripts/lib/findings/route-finding.js +57 -8
  17. package/.agents/scripts/lib/knip-config-resolver.js +181 -0
  18. package/.agents/scripts/lib/knip-entry-sync.js +78 -39
  19. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
  20. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
  21. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +69 -12
  22. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
  23. package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
  24. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
  25. package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
  26. package/.agents/scripts/lib/story-body/footer-block.js +97 -0
  27. package/.agents/scripts/lib/story-body/story-body.js +6 -22
  28. package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
  29. package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
  30. package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
  31. package/.agents/scripts/resolve-stories.js +21 -5
  32. package/.agents/scripts/stories-wave-tick.js +192 -9
  33. package/.agents/workflows/audit-to-stories.md +26 -0
  34. package/.agents/workflows/helpers/deliver-reference.md +28 -1
  35. package/.agents/workflows/helpers/deliver-story-reference.md +57 -0
  36. package/.agents/workflows/helpers/plan-reference.md +76 -0
  37. package/docs/CHANGELOG.md +16 -0
  38. package/package.json +3 -3
@@ -44,14 +44,17 @@ From zero to shipped:
44
44
  resolves their dependency graph from live state — body edges union native
45
45
  GitHub `blocked_by` edges, every blocker checked against its real issue
46
46
  state, so a Story whose blocker landed in an earlier plan run is simply
47
- ready. `/deliver` owns input resolution and `depends_on` sequencing only;
47
+ ready. `/deliver` owns input resolution and dispatch order — the declared
48
+ `depends_on` edges plus a delivery-time file-overlap guard that withholds
49
+ two Stories whose footprints would race the same path (see
50
+ [`architecture.md` § Scheduler safety mechanics](../../docs/architecture.md));
48
51
  every Story runs through the single v2 delivery engine
49
52
  [`helpers/deliver-story`](../workflows/helpers/deliver-story.md) —
50
53
  init → implement → acceptance self-eval → ceremony → close → CI watch →
51
54
  confirm-merge — which owns its own per-step detail. For a multi-Story run,
52
- `/deliver` sequences ready Stories by `depends_on` and runs the per-run
53
- epilogue (audit roster · follow-up roll-up · sibling coherence) once after
54
- the last Story lands.
55
+ `/deliver` sequences ready Stories by `depends_on` plus that footprint
56
+ guard — and runs the per-run epilogue (audit roster · follow-up roll-up ·
57
+ sibling coherence) once after the last Story lands.
55
58
 
56
59
  That is the whole happy path. Everything below is **detail** — branching
57
60
  conventions, HITL escalation, audit lenses — that you only need when the
@@ -239,7 +242,7 @@ self-eval, ceremony, close, CI watch, confirm-merge, cleanup) lives in the
239
242
  | Mode | Entry point | When to use |
240
243
  | --- | --- | --- |
241
244
  | **Single Story** | `/deliver <storyId>` | Deliver one Story end-to-end; ends with a PR open to `main`. |
242
- | **Story set** | `/deliver <storyId> [<storyId>…]` | Deliver multiple Stories in `depends_on` order (default concurrency **3**), resolved from live state so edges may point at Stories from earlier plan runs; each lands through its own PR, and the per-run epilogue runs after the set lands. |
245
+ | **Story set** | `/deliver <storyId> [<storyId>…]` | Deliver multiple Stories in `depends_on` order (default concurrency **3**), resolved from live state so edges may point at Stories from earlier plan runs; a delivery-time file-overlap guard additionally withholds two Stories whose footprints would race the same path (`delivery.deliverRunner.footprintGuard`). Each lands through its own PR, and the per-run epilogue runs after the set lands. |
243
246
  | **Story worker (internal)** | *helper* `helpers/deliver-story <storyId>` | Per-Story engine invoked internally by `/deliver`; not an operator slash command. |
244
247
 
245
248
  The single operator-facing entry point is `/deliver`. It performs no
@@ -105,7 +105,8 @@
105
105
  }
106
106
  },
107
107
  "deliverRunner": {
108
- "concurrencyCap": 3
108
+ "concurrencyCap": 3,
109
+ "footprintGuard": "enforce"
109
110
  },
110
111
  "worktreeIsolation": {
111
112
  "enabled": true,
@@ -156,6 +156,7 @@ Everything `/deliver` and `single-story-close` consume: execution timeouts, work
156
156
  | `tempRetention.classes.planDirs` | No | `boolean` | `true` | <tempRoot>/plan-<slug>/ — abandoned plan authoring dirs. Age-floored only; the current run is always excluded. |
157
157
  | `deliverRunner` | No | `object` | — | Bounded-concurrency knob for the /deliver fan-out. |
158
158
  | `deliverRunner.concurrencyCap` | No | `integer` | `3` | Maximum ready Stories dispatched by /deliver at once. Default 3. Moderate by design — keeps host-quota consumption predictable while allowing a small ready-set fan-out. Set 1 for strictly sequential delivery; raise further on hosts with adequate parallel-agent quota. See deliver.md for the sequencing model and throughput tradeoff. |
159
+ | `deliverRunner.footprintGuard` | No | `"enforce"` \| `"advisory"` | `"enforce"` | How a file-footprint collision affects dispatch. 'enforce' (default, and the behaviour to keep unless you have a reason) withholds a Story whose footprint races a peer admitted this beat or one still in flight — the guard encodes delivery-time-only knowledge (open implementation windows, foreign leases, ground that moved since planning) that no depends_on edge can carry. 'advisory' still DETECTS every collision and reports each would-be withhold in the tick envelope, but lets dispatch follow the declared depends_on edges alone — a deliberate throughput trade for a run whose ordering is fully declared. See stories-wave-tick.js and helpers/deliver-reference.md. |
159
160
  | `worktreeIsolation` | No | `object` | — | Per-Story git worktree provisioning. Each Story is implemented in its own checkout so concurrent siblings never share a working tree. |
160
161
  | `worktreeIsolation.enabled` | No | `boolean` | `true` | When true, `single-story-init.js` materializes a worktree per Story. False implements every Story in the main checkout, which is only safe for strictly serial delivery. |
161
162
  | `worktreeIsolation.root` | No | `string` | `".worktrees"` | Repo-relative directory the per-Story worktrees are created under. Required whenever `enabled` is explicitly true. |
@@ -3,7 +3,7 @@
3
3
  "dependencies": {
4
4
  "ajv": "^8.20.0",
5
5
  "ajv-formats": "^3.0.1",
6
- "js-yaml": "^4.1.1",
6
+ "js-yaml": "^4.3.1",
7
7
  "minimatch": "^10.0.0",
8
8
  "picomatch": "^4.0.4",
9
9
  "typhonjs-escomplex": "^0.1.0"
@@ -12,6 +12,7 @@
12
12
  "@commitlint/load": "^21.0.0",
13
13
  "chokidar": "^5.0.0",
14
14
  "jscpd": "^4.0.0",
15
+ "knip": "^6.17.1",
15
16
  "typescript": ">=5.0.0"
16
17
  }
17
18
  }
@@ -528,6 +528,12 @@
528
528
  "minimum": 1,
529
529
  "description": "Maximum ready Stories dispatched by /deliver at once. Default 3. Moderate by design — keeps host-quota consumption predictable while allowing a small ready-set fan-out. Set 1 for strictly sequential delivery; raise further on hosts with adequate parallel-agent quota. See deliver.md for the sequencing model and throughput tradeoff.",
530
530
  "default": 3
531
+ },
532
+ "footprintGuard": {
533
+ "type": "string",
534
+ "enum": ["enforce", "advisory"],
535
+ "description": "How a file-footprint collision affects dispatch. 'enforce' (default, and the behaviour to keep unless you have a reason) withholds a Story whose footprint races a peer admitted this beat or one still in flight — the guard encodes delivery-time-only knowledge (open implementation windows, foreign leases, ground that moved since planning) that no depends_on edge can carry. 'advisory' still DETECTS every collision and reports each would-be withhold in the tick envelope, but lets dispatch follow the declared depends_on edges alone — a deliberate throughput trade for a run whose ordering is fully declared. See stories-wave-tick.js and helpers/deliver-reference.md.",
536
+ "default": "enforce"
531
537
  }
532
538
  },
533
539
  "additionalProperties": false
@@ -13,6 +13,15 @@ GitHub Actions surfaces first. `check-knip-entries.js` derives that
13
13
  caller set mechanically, so a CLI no invoker names is dead, not
14
14
  operator-only.
15
15
 
16
+ It reads the entry list from whatever configuration knip itself would
17
+ load — `knip.json`, `knip.jsonc`, `.knip.json(c)`, `knip.ts`, `knip.js`,
18
+ `knip.config.ts`, `knip.config.js`, or `package.json#knip` — evaluating
19
+ TS/JS modules rather than parsing them, and counting entries declared
20
+ per-workspace as well as at the top level. A project with no knip
21
+ configuration at all exits 0 with a skip line, so the gate is safe to
22
+ wire everywhere; a configuration that exists but cannot be resolved
23
+ still exits 2.
24
+
16
25
  ## See Also
17
26
 
18
27
  - [`/.agents/README.md`](../README.md) — consumer user guide.
@@ -45,6 +45,7 @@ import {
45
45
  } from './lib/audit-to-stories/ledger.js';
46
46
  import { parseAuditReports } from './lib/audit-to-stories/parse-audit-md.js';
47
47
  import { buildPlanSeedMarkdown } from './lib/audit-to-stories/seed-from-findings.js';
48
+ import { wireAuditStoryEdges } from './lib/audit-to-stories/wire-dependencies.js';
48
49
  import { runAsCli } from './lib/cli-utils.js';
49
50
  import { searchSemanticCandidates } from './lib/findings/semantic-issue-search.js';
50
51
  import { SEVERITIES, SEVERITY_RANK } from './lib/findings/severity.js';
@@ -583,6 +584,81 @@ function buildAndGateStories(eligible, edges) {
583
584
  return built;
584
585
  }
585
586
 
587
+ /**
588
+ * The `--wire-edges` pass: hand the opened issue numbers back so the cohort's
589
+ * detected group edges become declared ordering (Story #5044).
590
+ *
591
+ * This is the second half of the two-pass crossing `--emit-stories` starts. The
592
+ * host opens one Issue per group from the emitted drafts, then replays the
593
+ * `groupKey → issueNumber` map here; each Story whose blockers now exist is
594
+ * re-rendered with a canonical `blocked by #N` footer and the same edges are
595
+ * mirrored as native `blocked_by` relations.
596
+ *
597
+ * @param {object} params
598
+ * @param {object} params.plan A `--scan` plan envelope.
599
+ * @param {Record<string, number>} params.issueByGroupKey
600
+ * @param {object} [deps]
601
+ * @param {Function} [deps.loadProviderImpl]
602
+ * @param {Function} [deps.wireImpl]
603
+ * @returns {Promise<object>} the wiring summary.
604
+ */
605
+ async function wireEdges({ plan, issueByGroupKey }, deps = {}) {
606
+ const { loadProviderImpl = loadProvider, wireImpl = wireAuditStoryEdges } =
607
+ deps;
608
+ const groups = (plan.classifications ?? [])
609
+ .filter((c) => c.action === 'create')
610
+ .map((c) => c.group);
611
+ const provider = await loadProviderImpl();
612
+ if (typeof provider?.updateTicket !== 'function') {
613
+ throw new Error(
614
+ '--wire-edges needs a provider exposing updateTicket to rewrite the ' +
615
+ 'Story bodies with their `blocked by #N` footers. Configure ' +
616
+ 'github.owner/repo (and auth), or wire the edges by hand.',
617
+ );
618
+ }
619
+ return wireImpl({
620
+ groups,
621
+ edges: plan.edges ?? [],
622
+ issueByGroupKey,
623
+ provider,
624
+ updateBody: (issueNumber, body) =>
625
+ provider.updateTicket(issueNumber, { body }),
626
+ });
627
+ }
628
+
629
+ /**
630
+ * Parse the `--ids` argument: a JSON object mapping group key → issue number,
631
+ * or a path to a file containing one.
632
+ *
633
+ * @param {string|undefined} raw
634
+ * @returns {Record<string, number>}
635
+ */
636
+ function parseIssueMap(raw) {
637
+ if (!raw) {
638
+ throw new Error(
639
+ '--wire-edges requires --ids \'{"<groupKey>": <issueNumber>, ...}\' ' +
640
+ '(or a path to a JSON file with that shape) — the issue numbers the ' +
641
+ 'create pass opened. Without them there is nothing to resolve the ' +
642
+ 'group edges against.',
643
+ );
644
+ }
645
+ const text = raw.trimStart().startsWith('{')
646
+ ? raw
647
+ : fs.readFileSync(raw, 'utf8');
648
+ const parsed = JSON.parse(text);
649
+ const out = {};
650
+ for (const [key, value] of Object.entries(parsed)) {
651
+ const n = Number(value);
652
+ if (!Number.isInteger(n) || n <= 0) {
653
+ throw new Error(
654
+ `--ids: "${key}" maps to ${JSON.stringify(value)}, which is not a positive issue number.`,
655
+ );
656
+ }
657
+ out[key] = n;
658
+ }
659
+ return out;
660
+ }
661
+
586
662
  function persist(text, outPath) {
587
663
  if (!outPath) {
588
664
  process.stdout.write(text);
@@ -604,6 +680,8 @@ export const __testing = {
604
680
  resolveSeverityFloor,
605
681
  reconcileScanLedger,
606
682
  issueStatesFromClassifications,
683
+ wireEdges,
684
+ parseIssueMap,
607
685
  };
608
686
 
609
687
  /**
@@ -637,6 +715,8 @@ export async function runAuditToStories(
637
715
  loadPlanImpl = loadPlan,
638
716
  buildAndGateStoriesImpl = buildAndGateStories,
639
717
  buildPlanSeedMarkdownImpl = buildPlanSeedMarkdown,
718
+ wireEdgesImpl = wireEdges,
719
+ parseIssueMapImpl = parseIssueMap,
640
720
  persistImpl = persist,
641
721
  stdout = process.stdout,
642
722
  } = deps;
@@ -648,6 +728,8 @@ export async function runAuditToStories(
648
728
  'dry-run': { type: 'boolean' },
649
729
  'emit-plan-seed': { type: 'boolean' },
650
730
  'emit-stories': { type: 'boolean' },
731
+ 'wire-edges': { type: 'boolean' },
732
+ ids: { type: 'string' },
651
733
  glob: { type: 'string' },
652
734
  severity: { type: 'string' },
653
735
  ledger: { type: 'string' },
@@ -659,64 +741,93 @@ export async function runAuditToStories(
659
741
  strict: false,
660
742
  });
661
743
 
662
- if (values.auto) {
663
- const { summary } = await runAutoImpl({
664
- glob: values.glob,
665
- severity: values.severity,
666
- dryRun: values['dry-run'],
667
- useProvider: !values['no-provider'],
668
- ledgerPath: values.ledger,
669
- });
670
- persistImpl(JSON.stringify(summary, null, 2), values.out);
671
- if (!values.out) stdout.write('\n');
672
- return;
673
- }
674
-
675
- if (values.scan) {
676
- const plan = await buildPlanImpl({
744
+ const json = (value) => JSON.stringify(value, null, 2);
745
+
746
+ const runAutoSummary = async () =>
747
+ (
748
+ await runAutoImpl({
749
+ glob: values.glob,
750
+ severity: values.severity,
751
+ dryRun: values['dry-run'],
752
+ useProvider: !values['no-provider'],
753
+ ledgerPath: values.ledger,
754
+ })
755
+ ).summary;
756
+
757
+ const scanPlan = () =>
758
+ buildPlanImpl({
677
759
  glob: values.glob,
678
760
  severity: values.severity,
679
761
  useProvider: !values['no-provider'],
680
762
  });
681
- const out = JSON.stringify(plan, null, 2);
682
- persistImpl(out, values.out);
683
- if (!values.out) stdout.write('\n');
684
- return;
685
- }
686
763
 
687
- if (values['emit-plan-seed']) {
764
+ const seedMarkdown = () => {
688
765
  const plan = loadPlanImpl(values.plan);
689
- const md = buildPlanSeedMarkdownImpl({
766
+ return buildPlanSeedMarkdownImpl({
690
767
  groups: plan.groups ?? [],
691
768
  findings: plan.findings ?? [],
692
769
  sourceReports: plan.sourceReports ?? [],
693
770
  });
694
- persistImpl(md, values.out);
695
- return;
696
- }
771
+ };
697
772
 
698
- if (values['emit-stories']) {
773
+ const emittedStories = () => {
699
774
  const plan = loadPlanImpl(values.plan);
700
775
  const eligible = (plan.classifications ?? [])
701
776
  .filter((c) => c.action === 'create')
702
777
  .map((c) => c.group);
703
778
  const built = buildAndGateStoriesImpl(eligible, plan.edges ?? []);
704
- const out = values.json
705
- ? JSON.stringify(built, null, 2)
706
- : built
707
- .map(
708
- (s, i) =>
709
- `--- story ${i + 1} ---\nTitle: ${s.title}\nLabels: ${s.labels.join(', ')}\n\n${s.body}\n`,
710
- )
711
- .join('\n');
712
- persistImpl(out, values.out);
713
- if (!values.out) stdout.write('\n');
714
- return;
779
+ return values.json ? json(built) : renderStoryDrafts(built);
780
+ };
781
+
782
+ const wiredEdges = () =>
783
+ wireEdgesImpl({
784
+ plan: loadPlanImpl(values.plan),
785
+ issueByGroupKey: parseIssueMapImpl(values.ids),
786
+ });
787
+
788
+ // One table, not a chain of `if (values.X) { …; return; }`. Each entry
789
+ // renders its sub-command's output; persisting it — and the stdout newline a
790
+ // piped run needs — happens once, below. The chain restated that tail in
791
+ // every arm, so each new sub-command paid for it twice: once in the branch
792
+ // and once in the complexity budget.
793
+ const subcommands = [
794
+ ['auto', async () => json(await runAutoSummary()), true],
795
+ ['scan', async () => json(await scanPlan()), true],
796
+ ['emit-plan-seed', () => seedMarkdown(), false],
797
+ ['emit-stories', () => emittedStories(), true],
798
+ ['wire-edges', async () => json(await wiredEdges()), true],
799
+ ];
800
+
801
+ const entry = subcommands.find(([flag]) => values[flag]);
802
+ if (!entry) {
803
+ throw new Error(
804
+ 'Usage: node audit-to-stories.js (--scan | --emit-plan-seed | --emit-stories | --wire-edges) [options]',
805
+ );
715
806
  }
807
+ const [, render, newlineOnStdout] = entry;
808
+ persistImpl(await render(), values.out);
809
+ if (newlineOnStdout && !values.out) stdout.write('\n');
810
+ }
716
811
 
717
- throw new Error(
718
- 'Usage: node audit-to-stories.js (--scan | --emit-plan-seed | --emit-stories) [options]',
719
- );
812
+ /**
813
+ * Render the Story drafts as the human-readable `--emit-stories` transcript
814
+ * (the `--json` form is the machine one). `dependsOn` is surfaced because the
815
+ * group edges no longer ride the body at emit time — the blockers have no issue
816
+ * numbers yet — so this is where a human driving the create pass by hand sees
817
+ * the ordering they will replay through `--wire-edges` (Story #5044).
818
+ *
819
+ * @param {Array<{ title: string, labels: string[], body: string, groupKey?: string, dependsOn?: string[] }>} built
820
+ * @returns {string}
821
+ */
822
+ function renderStoryDrafts(built) {
823
+ return built
824
+ .map((s, i) => {
825
+ const deps = (s.dependsOn ?? []).length
826
+ ? `\nDepends on group(s): ${s.dependsOn.join(', ')}`
827
+ : '';
828
+ return `--- story ${i + 1} ---\nTitle: ${s.title}\nLabels: ${s.labels.join(', ')}\nGroup key: ${s.groupKey}${deps}\n\n${s.body}\n`;
829
+ })
830
+ .join('\n');
720
831
  }
721
832
 
722
833
  async function main() {
@@ -727,7 +838,7 @@ runAsCli(import.meta.url, main, {
727
838
  source: 'audit-to-stories',
728
839
  usage: {
729
840
  invocation:
730
- 'node .agents/scripts/audit-to-stories.js (--scan | --auto | --emit-plan-seed | --emit-stories) [options]',
841
+ 'node .agents/scripts/audit-to-stories.js (--scan | --auto | --emit-plan-seed | --emit-stories | --wire-edges) [options]',
731
842
  summary:
732
843
  'Turn audit-lens findings under temp/audits/ into a dedup-checked plan seed or standalone Stories.',
733
844
  flags: [
@@ -735,6 +846,14 @@ runAsCli(import.meta.url, main, {
735
846
  ['--auto', 'Run the full scan → file pipeline and print the summary.'],
736
847
  ['--emit-plan-seed', 'Emit a /plan --seed-file document.'],
737
848
  ['--emit-stories', 'Emit the Story drafts as JSON.'],
849
+ [
850
+ '--wire-edges',
851
+ 'Second pass: resolve the detected group edges to blocked by #N footers plus native blocked_by relations. Needs --plan and --ids.',
852
+ ],
853
+ [
854
+ '--ids <json|path>',
855
+ 'Group key → opened issue number, as JSON or a path to a JSON file. Required by --wire-edges.',
856
+ ],
738
857
  ['--glob <pattern>', 'Override the audit-results glob.'],
739
858
  ['--severity <level>', 'Lowest severity to include (high|medium|low).'],
740
859
  ['--ledger <path>', 'Path to the dedup ledger.'],
@@ -19,19 +19,29 @@
19
19
  // This gate removes the luck. It derives the invoked set from the executable
20
20
  // surfaces #5001's own acceptance criterion named — package.json scripts, husky
21
21
  // hooks, `.github/workflows`, `.agents` workflow/skill/agent/rule markdown, and
22
- // script-to-script spawns — and asserts it matches `knip.json`'s entry array in
23
- // both directions. See `lib/knip-entry-sync.js` for why liveness means
24
- // *invoked* rather than *present*, and why documentation prose does not count.
22
+ // script-to-script spawns — and asserts it matches the entry array of whatever
23
+ // configuration knip itself would load, in both directions. See
24
+ // `lib/knip-entry-sync.js` for why liveness means *invoked* rather than
25
+ // *present*, and why documentation prose does not count.
25
26
  //
26
- // Measurement-free by construction: no knip spawn, no scorer, no coverage
27
- // artifact. It is a directory read and a handful of regexes, which is what
28
- // makes it cheap enough to sit in the required-check set next to
29
- // `check-baseline-scope.js`.
27
+ // Analysis-free by construction: knip's config resolver is loaded (a
28
+ // `knip.config.ts` has to be evaluated, not parsed), but nothing is scanned
29
+ // no knip run, no scorer, no coverage artifact. Past that it is a directory
30
+ // read and a handful of regexes, which is what keeps it cheap enough to sit in
31
+ // the required-check set next to `check-baseline-scope.js`.
32
+ //
33
+ // Not-applicable is not failure (Story #5039). A repository with no knip
34
+ // configuration at all — and one where `knip` is not installed — exits 0 with a
35
+ // skip line, the same opt-in posture `qa.gherkinLint` uses. Without that, the
36
+ // gate could not be wired into a consumer that does not run knip, and #5001's
37
+ // guard stayed unbuilt everywhere it was most needed. A configuration that
38
+ // EXISTS but cannot be resolved still exits 2: absence and breakage are
39
+ // different answers.
30
40
  //
31
41
  // Exit codes:
32
- // 0 entry list matches the invoked set
33
- // 1 divergence — a missing, stale, or phantom entry
34
- // 2 the check could not run (unreadable knip.json, unusable repository)
42
+ // 0 entry list matches the invoked set, or there is no configuration to check
43
+ // 1 divergence — a missing, stale, phantom, or unsuffixed entry
44
+ // 2 the check could not run (unresolvable configuration, unusable repository)
35
45
 
36
46
  import process from 'node:process';
37
47
  import { runAsCli } from './lib/cli-utils.js';
@@ -49,17 +59,35 @@ const HELP = {
49
59
  invocation:
50
60
  'node .agents/scripts/check-knip-entries.js [--cwd <dir>] [--json]',
51
61
  summary:
52
- "Assert knip.json's explicit .agents/scripts entry list matches the set of CLIs something actually invokes.",
62
+ "Assert the explicit .agents/scripts entry list in knip's resolved configuration matches the set of CLIs something actually invokes.",
53
63
  flags: [
54
64
  ['--cwd <dir>', 'Repository root to check. Default: process.cwd().'],
55
65
  ['--json', 'Emit the report as JSON instead of text.'],
56
66
  ],
57
67
  notes: [
58
- 'Exit codes:\n 0 entry list matches\n 1 divergence\n 2 the check could not run',
68
+ 'Resolves the config through knip itself, so every location knip supports works\n(knip.json/.jsonc, .knip.json(c), knip.ts/.js, knip.config.ts/.js,\npackage.json#knip). A TS config is evaluated, so a computed entry array reads\ncorrectly, and per-workspace entries count alongside the top-level array.',
69
+ 'Exit codes:\n 0 entry list matches, or there is no config to check (skip)\n 1 divergence\n 2 the check could not run — a config that EXISTS but will not resolve',
59
70
  'A missing entry is the dangerous one: knip calls the CLI dead, and accepting\nthe dead-exports diff would record a live CLI as expected-dead (Story #5012).',
60
71
  ],
61
72
  };
62
73
 
74
+ /**
75
+ * The gate's single exit-code decision, shared by the text and `--json` paths.
76
+ *
77
+ * Kept in one place for the same reason `countDivergences` is: a direction
78
+ * added to the report must not be able to change one path's verdict and not
79
+ * the other's. Skip outranks error outranks divergence — a repository with no
80
+ * configuration has nothing to diverge from.
81
+ *
82
+ * @param {Awaited<ReturnType<typeof resolveEntrySync>>} report
83
+ * @returns {number}
84
+ */
85
+ function exitCodeFor(report) {
86
+ if (report.skipped) return EXIT_PASS;
87
+ if (report.error) return EXIT_CANNOT_RUN;
88
+ return countDivergences(report) > 0 ? EXIT_DIVERGED : EXIT_PASS;
89
+ }
90
+
63
91
  /**
64
92
  * Parse argv into an options bag. An unknown flag is a config error rather than
65
93
  * a silent no-op, matching `check-baseline-scope.js`.
@@ -107,25 +135,20 @@ export async function runCli({
107
135
  stderr.write(`[knip-entries] ❌ ${err?.message ?? String(err)}\n`);
108
136
  return EXIT_CANNOT_RUN;
109
137
  }
110
- const repoRoot = args.cwd ?? cwd;
111
- const report = resolveEntrySync({ repoRoot });
138
+ const report = await resolveEntrySync({ repoRoot: args.cwd ?? cwd });
112
139
 
113
140
  if (args.json) {
114
141
  stdout.write(
115
142
  `${JSON.stringify({ kind: 'knip-entry-sync', ...report }, null, 2)}\n`,
116
143
  );
117
- if (report.error) return EXIT_CANNOT_RUN;
118
- return countDivergences(report) > 0 ? EXIT_DIVERGED : EXIT_PASS;
119
- }
120
-
121
- if (report.error) {
144
+ } else if (report.skipped) {
145
+ stdout.write(`[knip-entries] ⏭️ not applicable: ${report.skipped}\n`);
146
+ } else if (report.error) {
122
147
  stderr.write(`[knip-entries] ❌ ${report.error}\n`);
123
- return EXIT_CANNOT_RUN;
148
+ } else {
149
+ stdout.write(`\n--- knip-entries ---\n${renderEntrySyncReport(report)}\n`);
124
150
  }
125
-
126
- stdout.write(`\n--- knip-entries ---\n`);
127
- stdout.write(`${renderEntrySyncReport(report)}\n`);
128
- return countDivergences(report) > 0 ? EXIT_DIVERGED : EXIT_PASS;
151
+ return exitCodeFor(report);
129
152
  }
130
153
 
131
154
  runAsCli(import.meta.url, async () => runCli(), {
@@ -46,6 +46,23 @@
46
46
  * The exempt path is matched by suffix so it bites even before the
47
47
  * armer file lands (Wave 7); pre-existence is not required.
48
48
  *
49
+ * Scan root:
50
+ * --root <dir> Scan <dir> instead of this checkout. Both surfaces are
51
+ * derived from it, so one flag moves the whole scan.
52
+ * Defaults to the repository this script ships in, which is
53
+ * what `npm run lint` gets by passing nothing.
54
+ *
55
+ * The seam exists for tests (Story #5052). The CLI test that proves a
56
+ * violation is *caught* has to plant one, and it used to plant into the
57
+ * live `.agents/` tree — which `tests/e2e/sync-prune.integration.test.js`
58
+ * copies with the real binary, so the two raced: the sync either lost the
59
+ * file between enumeration and `copyfile` (ENOENT) or copied it once and
60
+ * pruned it on the second pass, tripping an idempotence assertion. Pointing
61
+ * the planting test at a temp root keeps what that test was written for —
62
+ * discovery is still the CLI's own walk, not an injected file list — while
63
+ * leaving the shared tree untouched. Mirrors the `--root` seam
64
+ * `check-test-temp-hygiene.js` already ships for the same reason.
65
+ *
49
66
  * Exit codes:
50
67
  * 0 — clean.
51
68
  * 1 — at least one violation; offending file + line printed to stderr.
@@ -65,15 +82,22 @@ import { walkFilesByExtension } from './lib/fs-walk.js';
65
82
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
66
83
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
67
84
 
68
- const LIFECYCLE_DIR = path.join(
69
- REPO_ROOT,
70
- '.agents',
71
- 'scripts',
72
- 'lib',
73
- 'orchestration',
74
- 'lifecycle',
75
- );
76
- const SCRIPTS_DIR = path.join(REPO_ROOT, '.agents', 'scripts');
85
+ /** Rule 1's scan surface for a given repository root. */
86
+ function lifecycleDirFor(root) {
87
+ return path.join(
88
+ root,
89
+ '.agents',
90
+ 'scripts',
91
+ 'lib',
92
+ 'orchestration',
93
+ 'lifecycle',
94
+ );
95
+ }
96
+
97
+ /** Rule 3's scan surface for a given repository root. */
98
+ function scriptsDirFor(root) {
99
+ return path.join(root, '.agents', 'scripts');
100
+ }
77
101
 
78
102
  /**
79
103
  * Files exempt from the merge-lockout rule. The path is matched by
@@ -269,10 +293,31 @@ export function findMergeLockoutViolations(
269
293
  return violations;
270
294
  }
271
295
 
296
+ /**
297
+ * Parse the argument vector. The only option is the scan root; anything
298
+ * else is ignored so an extra flag can never silently narrow the scan.
299
+ *
300
+ * @param {string[]} argv Arguments without the node/script entries.
301
+ * @returns {{ root: string }}
302
+ */
303
+ function parseArgv(argv) {
304
+ let root = REPO_ROOT;
305
+ for (let i = 0; i < argv.length; i += 1) {
306
+ if (argv[i] === '--root') {
307
+ i += 1;
308
+ root = path.resolve(String(argv[i] ?? '.'));
309
+ }
310
+ }
311
+ return { root };
312
+ }
313
+
272
314
  async function main() {
273
- // Per-rule discovery.
274
- const v1 = findPromiseAllViolations(LIFECYCLE_DIR);
275
- const v3 = findMergeLockoutViolations(SCRIPTS_DIR);
315
+ const { root } = parseArgv(process.argv.slice(2));
316
+ // Per-rule discovery. Both rules' exemptions match by absolute-path
317
+ // SUFFIX, which is what lets an injected root keep the same allow-list
318
+ // semantics as the live tree — do not re-anchor them to `root`.
319
+ const v1 = findPromiseAllViolations(lifecycleDirFor(root));
320
+ const v3 = findMergeLockoutViolations(scriptsDirFor(root));
276
321
  const all = [
277
322
  ...v1.map((v) => ({ rule: 'no-promise-all-lifecycle', ...v })),
278
323
  ...v3.map((v) => ({ rule: 'merge-lockout', ...v })),
@@ -297,4 +342,19 @@ async function main() {
297
342
  await runAsCli(import.meta.url, main, {
298
343
  source: 'check-lifecycle-lint',
299
344
  propagateExitCode: true,
345
+ usage: {
346
+ invocation: 'node .agents/scripts/check-lifecycle-lint.js [--root <dir>]',
347
+ summary:
348
+ 'Enforce the two lifecycle lint rules biome cannot express: no Promise.all on the append-only lifecycle surface, and the auto-merge lockout on string literals under .agents/scripts/.',
349
+ flags: [
350
+ [
351
+ '--root <dir>',
352
+ 'Repository root to scan (default: the checkout this script ships in). Both rule surfaces derive from it; used by tests so a planted violation never touches the shared tree.',
353
+ ],
354
+ ],
355
+ notes: [
356
+ 'Ships as part of `npm run lint`, which invokes it with no arguments.',
357
+ 'Exit codes:\n 0 clean\n 1 at least one violation; offending file and line printed to stderr',
358
+ ],
359
+ },
300
360
  });