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.
- package/.agents/docs/SDLC.md +8 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +1 -0
- package/.agents/runtime-deps.json +2 -1
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/scripts/README.md +9 -0
- package/.agents/scripts/audit-to-stories.js +160 -41
- package/.agents/scripts/check-knip-entries.js +47 -24
- package/.agents/scripts/check-lifecycle-lint.js +72 -12
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
- package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
- package/.agents/scripts/lib/config/runners.js +38 -16
- package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
- package/.agents/scripts/lib/dependency-parser.js +20 -7
- package/.agents/scripts/lib/findings/provenance-field.js +135 -0
- package/.agents/scripts/lib/findings/route-finding.js +57 -8
- package/.agents/scripts/lib/knip-config-resolver.js +181 -0
- package/.agents/scripts/lib/knip-entry-sync.js +78 -39
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +69 -12
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
- package/.agents/scripts/lib/story-body/footer-block.js +97 -0
- package/.agents/scripts/lib/story-body/story-body.js +6 -22
- package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
- package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
- package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
- package/.agents/scripts/resolve-stories.js +21 -5
- package/.agents/scripts/stories-wave-tick.js +192 -9
- package/.agents/workflows/audit-to-stories.md +26 -0
- package/.agents/workflows/helpers/deliver-reference.md +28 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +57 -0
- package/.agents/workflows/helpers/plan-reference.md +76 -0
- package/docs/CHANGELOG.md +16 -0
- package/package.json +3 -3
package/.agents/docs/SDLC.md
CHANGED
|
@@ -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
|
|
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`
|
|
53
|
-
epilogue (audit roster · follow-up roll-up ·
|
|
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;
|
|
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
|
|
@@ -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.
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
764
|
+
const seedMarkdown = () => {
|
|
688
765
|
const plan = loadPlanImpl(values.plan);
|
|
689
|
-
|
|
766
|
+
return buildPlanSeedMarkdownImpl({
|
|
690
767
|
groups: plan.groups ?? [],
|
|
691
768
|
findings: plan.findings ?? [],
|
|
692
769
|
sourceReports: plan.sourceReports ?? [],
|
|
693
770
|
});
|
|
694
|
-
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
771
|
+
};
|
|
697
772
|
|
|
698
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
718
|
-
|
|
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
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
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
|
|
34
|
-
// 2 the check could not run (
|
|
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
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
});
|