@event4u/agent-config 9.33.0 → 9.35.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +154 -145
- package/CONTRIBUTING.md +23 -14
- package/dist/agent-src/commands/agent-handoff.md +15 -3
- package/dist/agent-src/commands/roadmap/next.md +37 -5
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
- package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
- package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
- package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/dist/cli/registry.js +2 -2
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-2TGMGLBL.js} +11 -4
- package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
- package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-H6F2CQFI.js} +1 -1
- package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
- package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
- package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-ZAXH3ZWU.js} +3 -0
- package/dist/cli-delegate/cmd_conformance.js +2 -2
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +7 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +13 -13
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +2 -2
- package/dist/discovery/dormancy-report.md +17 -0
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +3 -3
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +3 -3
- package/dist/hooks/dispatch.js +355 -85
- package/dist/install/install.mjs +3 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +192 -12
- package/dist/server/schemas/settings.js +1 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +3 -2
- package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
- package/package.json +2 -2
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/src/agent-src/scripts/roadmap_gates.ts +119 -4
- package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/src/config/agent-settings.template.yml +11 -2
- package/src/config/quorum-attendance-budget.json +11 -3
- package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
- package/src/scripts/_dispatch.bash +6 -2
- package/src/scripts/_lib/env_kill_switch.ts +37 -0
- package/src/scripts/_lib/security_lint.ts +18 -4
- package/src/scripts/_lib/self_repair.ts +60 -0
- package/src/scripts/_lib/self_repair_store.ts +65 -5
- package/src/scripts/_lib/session_register.ts +67 -1
- package/src/scripts/_lib/subagent_capsule.ts +78 -1
- package/src/scripts/ai_council/config.ts +60 -0
- package/src/scripts/ai_council/events_log.ts +105 -6
- package/src/scripts/ai_council/orchestrator.ts +38 -0
- package/src/scripts/ai_council/prompts.ts +165 -3
- package/src/scripts/ai_council/quorum.ts +87 -0
- package/src/scripts/ai_team/review_gate.ts +2 -2
- package/src/scripts/build_discovery_manifest.ts +165 -0
- package/src/scripts/check_claims.ts +51 -2
- package/src/scripts/ci_time_ratio.ts +3 -1
- package/src/scripts/council_cli.ts +36 -1
- package/src/scripts/discovery_graph.ts +201 -27
- package/src/scripts/dispatch_r2_reviewer.ts +102 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
- package/src/scripts/lint_framework_leakage.ts +195 -28
- package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
- package/src/scripts/lint_handoffs.ts +92 -2
- package/src/scripts/lint_hedge_words.ts +317 -0
- package/src/scripts/lint_mcp_config_security.ts +1 -1
- package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
- package/src/scripts/memory_lookup.ts +1 -2
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +4 -0
- package/src/scripts/self_repair_cli.ts +26 -1
- package/src/scripts/session_register_hook.ts +238 -18
- package/src/scripts/sessions_cli.ts +377 -12
- package/src/scripts/skill_linter.ts +58 -2
- package/src/server/schemas/settings.ts +3 -0
|
@@ -460,9 +460,19 @@ export const REQUIRED_SYNTHESIS_SECTIONS = ['Kill criteria', 'Concrete next step
|
|
|
460
460
|
* fills the Convergence / Divergence slot) — not the template prompt. Every
|
|
461
461
|
* lens must close with a non-empty `### Kill criteria` and `### Concrete next
|
|
462
462
|
* step`; a missing or placeholder-empty section throws `SynthesisRenderError`
|
|
463
|
-
* naming the first offender.
|
|
464
|
-
*
|
|
465
|
-
*
|
|
463
|
+
* naming the first offender. Safe to call on decision and creative lenses
|
|
464
|
+
* alike, since both now require the two sections.
|
|
465
|
+
*
|
|
466
|
+
* **Not wired onto the emit path, deliberately.** This docstring used to claim
|
|
467
|
+
* it was "called on the synthesis-emit path"; measured, the function had zero
|
|
468
|
+
* production call sites and only the test imported it. Wiring it
|
|
469
|
+
* unconditionally into `render()` is not available either: with no chairman the
|
|
470
|
+
* rendered body is the literal `*to be summarised by the host agent*`, which
|
|
471
|
+
* carries neither section, so every templated render would throw (verified —
|
|
472
|
+
* the probe raises `missing the required "### Kill criteria" section`). A
|
|
473
|
+
* caller that HAS a completed synthesis calls this; the templated default path
|
|
474
|
+
* has nothing to validate. The tally check below is the half that could be
|
|
475
|
+
* wired, because it is conditional on a verdict line the template never emits.
|
|
466
476
|
*/
|
|
467
477
|
export function assert_synthesis_sections(text: string): void {
|
|
468
478
|
const lines = text.split('\n');
|
|
@@ -488,6 +498,158 @@ export function assert_synthesis_sections(text: string): void {
|
|
|
488
498
|
}
|
|
489
499
|
}
|
|
490
500
|
|
|
501
|
+
/** The reserved verdict label a synthesis uses when the tally did not converge. */
|
|
502
|
+
export const SPLIT_VERDICT_LABEL = 'split';
|
|
503
|
+
|
|
504
|
+
// Mirrors `_STANCE_RE`'s shape discipline: an explicit, single-field closing
|
|
505
|
+
// line, never read out of surrounding prose.
|
|
506
|
+
//
|
|
507
|
+
// **Case-SENSITIVE, deliberately.** The `i` flag was here and was wrong: the
|
|
508
|
+
// pattern is line-anchored, so `Verdict: option A is the stronger choice` —
|
|
509
|
+
// ordinary chairman prose opening a line — parsed as a machine-readable verdict
|
|
510
|
+
// carrying the whole sentence as its label. That is precisely the prose
|
|
511
|
+
// inference the paragraph above forbids, arriving through the regex instead of
|
|
512
|
+
// through a fallback. `VERDICT_LINE_CONTRACT` asks for the literal uppercase
|
|
513
|
+
// token, so requiring it costs a compliant author nothing and closes the class.
|
|
514
|
+
const _VERDICT_RE = /^\s*VERDICT:\s*(.+?)\s*$/gm;
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Mandatory closing-line contract for a synthesis rendered alongside a stance
|
|
518
|
+
* tally. The synthesis states its verdict as a machine-readable label rather
|
|
519
|
+
* than leaving it to be mined out of the recommendation prose — the same
|
|
520
|
+
* discipline `STANCE_LINE_CONTRACT` imposes on members.
|
|
521
|
+
*/
|
|
522
|
+
export const VERDICT_LINE_CONTRACT = `Close the synthesis with EXACTLY this line, and nothing after it:
|
|
523
|
+
VERDICT: <option-label>
|
|
524
|
+
Use the SAME <option-label> the Vote Tally cleared. Use \`${SPLIT_VERDICT_LABEL}\` when no option cleared the threshold — an honest split is a verdict, and claiming a winner the tally did not record is a render error.`;
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Parse the synthesis verdict line. Returns the LAST match (the final,
|
|
528
|
+
* most-authoritative one) or `null` when no well-formed line exists.
|
|
529
|
+
*
|
|
530
|
+
* `null` is a repair marker, never a guess: this function does not fall back to
|
|
531
|
+
* reading agreement out of the prose. That is the invariant `stance_tally`
|
|
532
|
+
* states for members ("the tally never infers a stance from the surrounding
|
|
533
|
+
* prose") and the one `check_finding_dispositions` states for the release
|
|
534
|
+
* ledger ("a comment is mutable and unaudited; it is transport, not a record").
|
|
535
|
+
*/
|
|
536
|
+
export function parse_verdict_line(text: string): { label: string; display: string } | null {
|
|
537
|
+
_VERDICT_RE.lastIndex = 0;
|
|
538
|
+
let last: RegExpExecArray | null = null;
|
|
539
|
+
let m: RegExpExecArray | null;
|
|
540
|
+
while ((m = _VERDICT_RE.exec(text)) !== null) {
|
|
541
|
+
last = m;
|
|
542
|
+
}
|
|
543
|
+
if (last === null) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
const raw = (last[1] ?? '').trim();
|
|
547
|
+
if (raw.length === 0) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
// An angle-bracketed label is the CONTRACT's own placeholder, not a verdict.
|
|
551
|
+
// This matters on the templated path: with no chairman the rendered body IS
|
|
552
|
+
// `VERDICT_LINE_CONTRACT`'s template text, so a parser without this guard
|
|
553
|
+
// would read `<option-label>` as a claimed winner and throw on every
|
|
554
|
+
// un-summarised render. Same treatment `assert_synthesis_sections` gives a
|
|
555
|
+
// placeholder-empty section.
|
|
556
|
+
if (/^<.*>$/.test(raw)) {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
return { label: raw.toLowerCase(), display: raw };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Validate a completed synthesis against the stance tally it was rendered
|
|
564
|
+
* beside: a verdict naming a winner the tally did not clear throws
|
|
565
|
+
* `SynthesisRenderError`, the same way a missing section does.
|
|
566
|
+
*
|
|
567
|
+
* The defect this closes: `assert_synthesis_sections` is a *shape* check, so a
|
|
568
|
+
* synthesis could report agreement over a tally that recorded dissent and
|
|
569
|
+
* nothing downstream disagreed. That is the class behind the recorded 9.14.0
|
|
570
|
+
* release failure, where the head read "Security and correctness: none" over a
|
|
571
|
+
* critical finding — `check_finding_dispositions.ts` is the same check on the
|
|
572
|
+
* release surface.
|
|
573
|
+
*
|
|
574
|
+
* **An absent verdict line does not throw.** It is a repair marker under the
|
|
575
|
+
* same rule as an unparseable stance: the fail-safe direction is to record that
|
|
576
|
+
* the claim was not made, never to infer one from prose. That also keeps every
|
|
577
|
+
* synthesis rendered before `VERDICT_LINE_CONTRACT` shipped green.
|
|
578
|
+
*/
|
|
579
|
+
export function assert_synthesis_matches_tally(text: string, tally: TallyView): void {
|
|
580
|
+
const mismatch = describe_verdict_mismatch(text, tally);
|
|
581
|
+
if (mismatch !== null) {
|
|
582
|
+
throw new SynthesisRenderError(mismatch);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The slice of a `StanceTallyResult` the verdict check reads. */
|
|
587
|
+
export interface TallyView {
|
|
588
|
+
consensus: { label: string } | null;
|
|
589
|
+
split: boolean;
|
|
590
|
+
options: ReadonlyArray<{ label: string }>;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The same comparison as `assert_synthesis_matches_tally`, returning the
|
|
595
|
+
* mismatch as a string instead of throwing — `null` means "no contradiction".
|
|
596
|
+
*
|
|
597
|
+
* **Why both shapes exist.** Throwing is right for a caller holding a finished
|
|
598
|
+
* synthesis it can refuse. It is wrong on the render path: `render()` throws
|
|
599
|
+
* before the tally and the Convergence blocks are pushed, so a mismatch would
|
|
600
|
+
* destroy the ENTIRE artifact — every member response, the peer review, the
|
|
601
|
+
* quorum bookkeeping — *after* every provider call has already been paid for.
|
|
602
|
+
* The module's own answer for an unverifiable claim is the `needs_repair`
|
|
603
|
+
* marker `render_vote_tally` prints, not a discarded pass, and the roadmap had
|
|
604
|
+
* already recorded that unconditionally wiring the sibling shape check was
|
|
605
|
+
* unsafe for this same reason. So the render path surfaces the contradiction
|
|
606
|
+
* where a reader will see it and keeps the artifact.
|
|
607
|
+
*/
|
|
608
|
+
export function describe_verdict_mismatch(text: string, tally: TallyView): string | null {
|
|
609
|
+
const verdict = parse_verdict_line(text);
|
|
610
|
+
if (verdict === null) {
|
|
611
|
+
return null; // repair marker, not a guess
|
|
612
|
+
}
|
|
613
|
+
// The reserved sense of `split` yields to a real option that carries the
|
|
614
|
+
// same label (R2 finding 8): the label vocabulary is free text mined from
|
|
615
|
+
// member `STANCE:` lines, so nothing stops an option genuinely called
|
|
616
|
+
// `split`, and when the tally clears it the CORRECT verdict would otherwise
|
|
617
|
+
// read as a contradiction. Deciding by whether the tally actually holds
|
|
618
|
+
// that option keeps one namespace without an escape syntax authors would
|
|
619
|
+
// have to learn.
|
|
620
|
+
const optionNamedSplit = tally.options.some(
|
|
621
|
+
(o) => o.label.toLowerCase() === SPLIT_VERDICT_LABEL,
|
|
622
|
+
);
|
|
623
|
+
const claimsSplit = verdict.label === SPLIT_VERDICT_LABEL && !optionNamedSplit;
|
|
624
|
+
|
|
625
|
+
if (tally.consensus === null) {
|
|
626
|
+
if (claimsSplit) {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
// `split` is the tally's own word for this state; reading it here rather
|
|
630
|
+
// than re-deriving "no consensus ⇒ split" keeps the message honest when
|
|
631
|
+
// the tally is empty (zero options is not a split across zero options).
|
|
632
|
+
const how = tally.split
|
|
633
|
+
? `split across ${tally.options.length} option(s)`
|
|
634
|
+
: 'no option-level stances parsed';
|
|
635
|
+
return (
|
|
636
|
+
`synthesis claims "${verdict.display}" but the tally recorded no consensus ` +
|
|
637
|
+
`(${how}); use \`${SPLIT_VERDICT_LABEL}\` or re-run the round`
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (claimsSplit) {
|
|
642
|
+
return `synthesis claims a split but the tally cleared "${tally.consensus.label}"`;
|
|
643
|
+
}
|
|
644
|
+
if (verdict.label !== tally.consensus.label.toLowerCase()) {
|
|
645
|
+
return (
|
|
646
|
+
`synthesis claims "${verdict.display}" but the tally cleared ` +
|
|
647
|
+
`"${tally.consensus.label}"`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
|
|
491
653
|
/**
|
|
492
654
|
* Drop any *whole line* containing a host-agent identity substring.
|
|
493
655
|
*
|
|
@@ -93,6 +93,17 @@ export function evaluateQuorum(
|
|
|
93
93
|
* full-attendance one. Advisory render and telemetry only: no gate reads it,
|
|
94
94
|
* and nothing downstream may branch on it without its own decision record.
|
|
95
95
|
*
|
|
96
|
+
* ONE such record now exists, and it is deliberately narrow: ADR-224
|
|
97
|
+
* (`docs/decisions/ADR-224-gate-scoped-solo-attendance-floor.md`) authorizes a
|
|
98
|
+
* `min_present: 2` floor to branch on this predicate **for gate-class passes
|
|
99
|
+
* only**, decided against a measured 12.5 % solo-conclusion rate. What landed
|
|
100
|
+
* from that authorization is `wouldSoloFloorHold` below, which is a
|
|
101
|
+
* counterfactual and branches nothing — so the sentence above still describes
|
|
102
|
+
* the code: no caller holds a pass on solo status today. It is stated here so
|
|
103
|
+
* a reader finds the authorization instead of re-deriving the prohibition, and
|
|
104
|
+
* so the next branch is visibly a separate argument: the exception is
|
|
105
|
+
* gate-class passes, not solo status generally.
|
|
106
|
+
*
|
|
96
107
|
* `total` is deliberately NOT consulted. A council configured with a single
|
|
97
108
|
* member concludes solo by construction, and that is still a conclusion
|
|
98
109
|
* reached on one voice — collapsing it into "not solo" would hide exactly
|
|
@@ -102,3 +113,79 @@ export function evaluateQuorum(
|
|
|
102
113
|
export function isSoloConcluded(result: QuorumResult): boolean {
|
|
103
114
|
return result.status === 'concluded' && result.present === 1;
|
|
104
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The floor value ADR-224 named: a gate-class pass wants at least two voices.
|
|
119
|
+
*
|
|
120
|
+
* A constant rather than a bare `2` at the call site so the shadow telemetry
|
|
121
|
+
* below and any future enforcement read the same number, and so changing it
|
|
122
|
+
* is one edit with one place to argue about.
|
|
123
|
+
*/
|
|
124
|
+
export const SOLO_FLOOR_MIN_PRESENT = 2;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* WOULD a `min_present` floor have held this pass? Counterfactual only — this
|
|
128
|
+
* predicate changes nothing and no caller branches on it to hold a gate.
|
|
129
|
+
*
|
|
130
|
+
* ADR-224 chose a gate-scoped `min_present: 2` floor against a solo-conclusion
|
|
131
|
+
* rate of 1 of 8 passes, whose 95 % interval spans roughly 0.3 %–53 %. Two
|
|
132
|
+
* facts, both read off the tree rather than assumed, decided that what lands
|
|
133
|
+
* first is the measurement and not the enforcement:
|
|
134
|
+
*
|
|
135
|
+
* - **Nothing in the tree branches on `QuorumStatus` to hold anything.** The
|
|
136
|
+
* only non-`quorum.ts` reader of `'inconclusive'` is
|
|
137
|
+
* `council_cli.ts::_deserialise_quorum`, which validates a persisted string.
|
|
138
|
+
* An enforced floor would therefore change zero behaviour today — there is
|
|
139
|
+
* no gate for it to hold — while still being able to hang an advisory pass
|
|
140
|
+
* the moment a consumer appears.
|
|
141
|
+
* - **ADR-224's own review trigger (b) is "the gate-class floor lands and its
|
|
142
|
+
* own fire-rate telemetry accumulates"**, which presumes exactly this
|
|
143
|
+
* counterfactual is being recorded.
|
|
144
|
+
*
|
|
145
|
+
* So the floor is evaluated on EVERY pass and recorded, never applied. The
|
|
146
|
+
* fire-rate that accumulates from it is what makes the enforcement decision an
|
|
147
|
+
* evidence call later instead of a second point estimate.
|
|
148
|
+
*
|
|
149
|
+
* `minPresent` is clamped with the same discipline `resolveQuorumThreshold`
|
|
150
|
+
* applies, and the clamp carries meaning rather than only safety: where the
|
|
151
|
+
* ceiling resolves to 1 the floor never fires, so a council **configured**
|
|
152
|
+
* with one member is not counted as a degraded pass. That is the split
|
|
153
|
+
* `quorum-attendance-budget.json` already insists on for the solo-conclusion
|
|
154
|
+
* rate — a one-member roster and a two-member roster that lost one are
|
|
155
|
+
* different findings, and an unclamped floor would merge them.
|
|
156
|
+
*
|
|
157
|
+
* **The ceiling is the larger of `result.total` and `configuredTotal`, and it
|
|
158
|
+
* has to be.** Clamping against `result.total` alone was the first version of
|
|
159
|
+
* this function and it was structurally unable to fire on the exact case
|
|
160
|
+
* ADR-224 was decided on: `_postRunQuorum` computes `total` over the roster
|
|
161
|
+
* that CONSTRUCTED, so a two-member council where one member fails to
|
|
162
|
+
* construct reads `total = 1, present = 1`, the ceiling collapses to 1, and a
|
|
163
|
+
* conclusion reached on one of two configured voices records as "the floor
|
|
164
|
+
* would not have held". The series review trigger (b) accumulates would have
|
|
165
|
+
* been biased low by construction — a metric blind to its own target
|
|
166
|
+
* population. `configured_total` exists on the event line precisely to keep
|
|
167
|
+
* that pass distinguishable, and the fix is to consult it.
|
|
168
|
+
*
|
|
169
|
+
* `max`, not `configuredTotal` outright: `total > configuredTotal` is
|
|
170
|
+
* legitimate and not a shortfall, because `--siblings` fans one enabled config
|
|
171
|
+
* ENTRY into N clients. `configuredTotal` counts entries, `total` counts
|
|
172
|
+
* clients, and the honest ceiling is the largest roster the pass could have
|
|
173
|
+
* drawn on either way.
|
|
174
|
+
*
|
|
175
|
+
* A pass that never reached `concluded` is not held by the floor: it failed
|
|
176
|
+
* the threshold, which is a different outcome with a different cause. The two
|
|
177
|
+
* are mutually exclusive by construction, which is what makes them readable
|
|
178
|
+
* apart from the event log alone.
|
|
179
|
+
*/
|
|
180
|
+
export function wouldSoloFloorHold(
|
|
181
|
+
result: QuorumResult,
|
|
182
|
+
minPresent: number = SOLO_FLOOR_MIN_PRESENT,
|
|
183
|
+
configuredTotal?: number,
|
|
184
|
+
): boolean {
|
|
185
|
+
if (result.status !== 'concluded') {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const ceiling = Math.max(result.total, configuredTotal ?? result.total);
|
|
189
|
+
const floor = Math.min(Math.max(Math.trunc(minPresent), 1), ceiling);
|
|
190
|
+
return result.present < floor;
|
|
191
|
+
}
|
|
@@ -42,6 +42,7 @@ import * as os from 'node:os';
|
|
|
42
42
|
import * as path from 'node:path';
|
|
43
43
|
|
|
44
44
|
import { claude_config_dir, CODEX_MARKETPLACE_NAME, CODEX_PLUGIN_ID } from '../_lib/claude_plugin.js';
|
|
45
|
+
import { isEnvKillSwitchActive } from '../_lib/env_kill_switch.js';
|
|
45
46
|
import { atomic_write_json } from '../hooks/state_io.js';
|
|
46
47
|
import { AI_TEAM_DEFAULTS, load_ai_team_config, type AiTeamConfig } from './config.js';
|
|
47
48
|
|
|
@@ -170,8 +171,7 @@ export function circuit_breaker_notice(bound: number): string {
|
|
|
170
171
|
// ── ledger (events-log convention) ──────────────────────────────────
|
|
171
172
|
|
|
172
173
|
function _kill_switch_active(): boolean {
|
|
173
|
-
|
|
174
|
-
return !(value === '' || value === '0' || value === 'false' || value === 'False');
|
|
174
|
+
return isEnvKillSwitchActive(_KILL_SWITCH_ENV);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
function _iso_seconds_z(d: Date): string {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
*
|
|
39
39
|
* Schema: docs/contracts/discovery-manifest.schema.json
|
|
40
40
|
*/
|
|
41
|
+
import * as child_process from 'node:child_process';
|
|
41
42
|
import * as crypto from 'node:crypto';
|
|
42
43
|
import * as fs from 'node:fs';
|
|
43
44
|
import * as path from 'node:path';
|
|
@@ -83,6 +84,7 @@ interface ModuleConfig {
|
|
|
83
84
|
DEFAULT_DEPRECATION_REPORT: string;
|
|
84
85
|
DEFAULT_TRUST_REPORT: string;
|
|
85
86
|
DEFAULT_ORPHAN_REPORT: string;
|
|
87
|
+
DEFAULT_DORMANCY_REPORT: string;
|
|
86
88
|
DEFAULT_WORKSPACES_JSON: string;
|
|
87
89
|
DEFAULT_PACKS_JSON: string;
|
|
88
90
|
// Patchable seams (Python monkeypatches these module attributes).
|
|
@@ -101,6 +103,7 @@ function _deriveConfig(root: string): ModuleConfig {
|
|
|
101
103
|
DEFAULT_DEPRECATION_REPORT: path.join(disc, 'deprecation-report.md'),
|
|
102
104
|
DEFAULT_TRUST_REPORT: path.join(disc, 'trust-report.md'),
|
|
103
105
|
DEFAULT_ORPHAN_REPORT: path.join(disc, 'orphan-report.md'),
|
|
106
|
+
DEFAULT_DORMANCY_REPORT: path.join(disc, 'dormancy-report.md'),
|
|
104
107
|
DEFAULT_WORKSPACES_JSON: path.join(disc, 'workspaces.json'),
|
|
105
108
|
DEFAULT_PACKS_JSON: path.join(disc, 'packs.json'),
|
|
106
109
|
artefact_roots: _artefact_roots,
|
|
@@ -937,6 +940,156 @@ function _deprecation_report(manifest: JsonObject): string {
|
|
|
937
940
|
return lines.join('\n') + '\n';
|
|
938
941
|
}
|
|
939
942
|
|
|
943
|
+
/** Dormancy window, in months. `docs/governance.md` § Skill lifecycle policy. */
|
|
944
|
+
const _DORMANCY_MONTHS = 6;
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* What the dormancy report could actually establish from git in this checkout.
|
|
948
|
+
*
|
|
949
|
+
* `ok: false` is a first-class outcome, not an error: a shallow clone or a
|
|
950
|
+
* repository younger than the window cannot distinguish "untouched for six
|
|
951
|
+
* months" from "history truncated three months ago", and CI clones are shallow
|
|
952
|
+
* by default. The report NAMES that instead of emitting a list built on it.
|
|
953
|
+
*/
|
|
954
|
+
export interface DormancyProbe {
|
|
955
|
+
ok: boolean;
|
|
956
|
+
/** Why the signal is unavailable — set iff `ok` is false. */
|
|
957
|
+
reason?: string;
|
|
958
|
+
/** Repo-relative paths with at least one commit inside the window. */
|
|
959
|
+
touched: Set<string>;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function _git(root: string, args: readonly string[]): string | null {
|
|
963
|
+
try {
|
|
964
|
+
return child_process.execFileSync('git', [...args], {
|
|
965
|
+
cwd: root,
|
|
966
|
+
encoding: 'utf-8',
|
|
967
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
968
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
969
|
+
});
|
|
970
|
+
} catch {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Probe git once for the whole estate: one bounded `--since` walk, exactly the
|
|
977
|
+
* command `governance.md` names, rather than one `git log` per artefact.
|
|
978
|
+
*/
|
|
979
|
+
function _dormancy_probe(root: string, now: Date = new Date()): DormancyProbe {
|
|
980
|
+
const since = `${String(_DORMANCY_MONTHS)} months ago`;
|
|
981
|
+
if (_git(root, ['rev-parse', '--git-dir']) === null) {
|
|
982
|
+
return { ok: false, reason: 'not a git checkout — commit history is unavailable', touched: new Set() };
|
|
983
|
+
}
|
|
984
|
+
if ((_git(root, ['rev-parse', '--is-shallow-repository']) ?? '').trim() === 'true') {
|
|
985
|
+
return {
|
|
986
|
+
ok: false,
|
|
987
|
+
reason:
|
|
988
|
+
'shallow clone — a truncated history is indistinguishable from a dormant artefact. ' +
|
|
989
|
+
'Re-run after `git fetch --unshallow`.',
|
|
990
|
+
touched: new Set(),
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
const oldest = (_git(root, ['log', '--reverse', '--format=%cI', '--max-count=1']) ?? '').trim();
|
|
994
|
+
if (oldest !== '') {
|
|
995
|
+
const cutoff = new Date(now);
|
|
996
|
+
cutoff.setMonth(cutoff.getMonth() - _DORMANCY_MONTHS);
|
|
997
|
+
if (new Date(oldest) > cutoff) {
|
|
998
|
+
return {
|
|
999
|
+
ok: false,
|
|
1000
|
+
reason:
|
|
1001
|
+
`repository history starts ${oldest.slice(0, 10)}, inside the ` +
|
|
1002
|
+
`${String(_DORMANCY_MONTHS)}-month window — every artefact would read as dormant`,
|
|
1003
|
+
touched: new Set(),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const out = _git(root, ['log', `--since=${since}`, '--name-only', '--format=']);
|
|
1008
|
+
if (out === null) {
|
|
1009
|
+
return { ok: false, reason: 'git log failed', touched: new Set() };
|
|
1010
|
+
}
|
|
1011
|
+
const touched = new Set(out.split('\n').map((l) => l.trim()).filter((l) => l !== ''));
|
|
1012
|
+
return { ok: true, touched };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* The unit dormancy is measured over. A skill is a directory (governance names
|
|
1017
|
+
* `git log -- src/skills/<name>/`), so a commit touching its `reference.md`
|
|
1018
|
+
* keeps the skill alive; every other artefact is its own file.
|
|
1019
|
+
*/
|
|
1020
|
+
function _dormancy_unit(artefactPath: string): string {
|
|
1021
|
+
return path.basename(artefactPath) === 'SKILL.md' ? path.dirname(artefactPath) : artefactPath;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Artefacts with no commit inside the dormancy window.
|
|
1026
|
+
*
|
|
1027
|
+
* Report-only by construction: `governance.md` § Skill lifecycle policy says
|
|
1028
|
+
* dormancy triggers review, never auto-deprecation — and a finished artefact is
|
|
1029
|
+
* indistinguishable from an abandoned one from commit dates, so the
|
|
1030
|
+
* false-positive class is not empty and no argument makes it so.
|
|
1031
|
+
*/
|
|
1032
|
+
function _dormant_artefacts(manifest: JsonObject, probe: DormancyProbe): JsonObject[] {
|
|
1033
|
+
if (!probe.ok) {
|
|
1034
|
+
return [];
|
|
1035
|
+
}
|
|
1036
|
+
const alive = new Set<string>();
|
|
1037
|
+
for (const p of probe.touched) {
|
|
1038
|
+
alive.add(p);
|
|
1039
|
+
// A touched file keeps its containing directory-shaped artefact alive.
|
|
1040
|
+
for (let d = path.dirname(p); d !== '.' && d !== path.sep; d = path.dirname(d)) {
|
|
1041
|
+
alive.add(d);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
const out: JsonObject[] = [];
|
|
1045
|
+
for (const a of manifest['artefacts'] as JsonObject[]) {
|
|
1046
|
+
const unit = _dormancy_unit(a['path'] as string);
|
|
1047
|
+
if (!alive.has(unit)) {
|
|
1048
|
+
out.push({ path: a['path'], unit, category: a['category'] });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
out.sort((a, b) => _cmpStr(a['path'] as string, b['path'] as string));
|
|
1052
|
+
return out;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** The dormancy section of the discovery report family (governance.md § Skill lifecycle policy). */
|
|
1056
|
+
function _dormancy_report(manifest: JsonObject, probe: DormancyProbe): string {
|
|
1057
|
+
const lines = ['# Discovery — Dormancy Report', ''];
|
|
1058
|
+
lines.push(`- Generated: \`${String(manifest['generated_at'])}\``);
|
|
1059
|
+
lines.push(`- Window: **${String(_DORMANCY_MONTHS)} months** without a commit`);
|
|
1060
|
+
lines.push('');
|
|
1061
|
+
lines.push('> Dormancy triggers **review, never auto-deprecation**');
|
|
1062
|
+
lines.push('> (`docs/governance.md` § Skill lifecycle policy). A finished artefact and an');
|
|
1063
|
+
lines.push('> abandoned one look identical from commit dates, so this is a prompt to ask');
|
|
1064
|
+
lines.push('> "still earning its slot?" — never a removal list, and never a gate.');
|
|
1065
|
+
lines.push('');
|
|
1066
|
+
if (!probe.ok) {
|
|
1067
|
+
lines.push('## Signal unavailable');
|
|
1068
|
+
lines.push('');
|
|
1069
|
+
lines.push(`**No dormancy list is published**: ${String(probe.reason)}`);
|
|
1070
|
+
lines.push('');
|
|
1071
|
+
lines.push('An empty section here would read as "nothing is dormant", which is a');
|
|
1072
|
+
lines.push('different claim from "the signal could not be computed".');
|
|
1073
|
+
lines.push('');
|
|
1074
|
+
return lines.join('\n') + '\n';
|
|
1075
|
+
}
|
|
1076
|
+
const items = _dormant_artefacts(manifest, probe);
|
|
1077
|
+
lines.push(`- Dormant artefacts: **${items.length}**`);
|
|
1078
|
+
lines.push('');
|
|
1079
|
+
if (items.length === 0) {
|
|
1080
|
+
lines.push('_None. Every artefact was touched inside the window._');
|
|
1081
|
+
lines.push('');
|
|
1082
|
+
return lines.join('\n') + '\n';
|
|
1083
|
+
}
|
|
1084
|
+
lines.push('| Path | Unit | Category |');
|
|
1085
|
+
lines.push('|---|---|---|');
|
|
1086
|
+
for (const a of items) {
|
|
1087
|
+
lines.push(`| \`${String(a['path'])}\` | \`${String(a['unit'])}\` | ${String(a['category'])} |`);
|
|
1088
|
+
}
|
|
1089
|
+
lines.push('');
|
|
1090
|
+
return lines.join('\n') + '\n';
|
|
1091
|
+
}
|
|
1092
|
+
|
|
940
1093
|
/** Trust-level breakdown by workspace + human-review sanity flag. Mirrors `_trust_report`. */
|
|
941
1094
|
function _trust_report(manifest: JsonObject): string {
|
|
942
1095
|
const byWs = new Map<string, JsonObject>();
|
|
@@ -1195,6 +1348,7 @@ interface ParsedArgs {
|
|
|
1195
1348
|
deprecation_report: string;
|
|
1196
1349
|
trust_report: string;
|
|
1197
1350
|
orphan_report: string;
|
|
1351
|
+
dormancy_report: string;
|
|
1198
1352
|
workspaces_json: string;
|
|
1199
1353
|
packs_json: string;
|
|
1200
1354
|
strict: boolean;
|
|
@@ -1231,6 +1385,7 @@ function parse_args(argv: readonly string[]): ParsedArgs {
|
|
|
1231
1385
|
deprecation_report: _config.DEFAULT_DEPRECATION_REPORT,
|
|
1232
1386
|
trust_report: _config.DEFAULT_TRUST_REPORT,
|
|
1233
1387
|
orphan_report: _config.DEFAULT_ORPHAN_REPORT,
|
|
1388
|
+
dormancy_report: _config.DEFAULT_DORMANCY_REPORT,
|
|
1234
1389
|
workspaces_json: _config.DEFAULT_WORKSPACES_JSON,
|
|
1235
1390
|
packs_json: _config.DEFAULT_PACKS_JSON,
|
|
1236
1391
|
strict: false,
|
|
@@ -1242,6 +1397,7 @@ function parse_args(argv: readonly string[]): ParsedArgs {
|
|
|
1242
1397
|
' [--deprecation-report DEPRECATION_REPORT]\n' +
|
|
1243
1398
|
' [--trust-report TRUST_REPORT]\n' +
|
|
1244
1399
|
' [--orphan-report ORPHAN_REPORT]\n' +
|
|
1400
|
+
' [--dormancy-report DORMANCY_REPORT]\n' +
|
|
1245
1401
|
' [--workspaces-json WORKSPACES_JSON]\n' +
|
|
1246
1402
|
' [--packs-json PACKS_JSON] [--strict]\n' +
|
|
1247
1403
|
' [--quiet]\n';
|
|
@@ -1252,6 +1408,7 @@ function parse_args(argv: readonly string[]): ParsedArgs {
|
|
|
1252
1408
|
'--deprecation-report': 'deprecation_report',
|
|
1253
1409
|
'--trust-report': 'trust_report',
|
|
1254
1410
|
'--orphan-report': 'orphan_report',
|
|
1411
|
+
'--dormancy-report': 'dormancy_report',
|
|
1255
1412
|
'--workspaces-json': 'workspaces_json',
|
|
1256
1413
|
'--packs-json': 'packs_json',
|
|
1257
1414
|
};
|
|
@@ -1324,6 +1481,10 @@ function main(argv: readonly string[]): number {
|
|
|
1324
1481
|
_writeFileMkdir(args.deprecation_report, _deprecation_report(manifest));
|
|
1325
1482
|
_writeFileMkdir(args.trust_report, _trust_report(manifest));
|
|
1326
1483
|
_writeFileMkdir(args.orphan_report, _orphan_report(manifest));
|
|
1484
|
+
_writeFileMkdir(
|
|
1485
|
+
args.dormancy_report,
|
|
1486
|
+
_dormancy_report(manifest, _dormancy_probe(_config.ROOT)),
|
|
1487
|
+
);
|
|
1327
1488
|
// Phase 5 sub-views.
|
|
1328
1489
|
_writeFileMkdir(
|
|
1329
1490
|
args.workspaces_json,
|
|
@@ -1425,6 +1586,10 @@ export {
|
|
|
1425
1586
|
_trust_report,
|
|
1426
1587
|
_orphan_artefacts,
|
|
1427
1588
|
_orphan_report,
|
|
1589
|
+
_dormancy_probe,
|
|
1590
|
+
_dormancy_unit,
|
|
1591
|
+
_dormant_artefacts,
|
|
1592
|
+
_dormancy_report,
|
|
1428
1593
|
_workspaces_view,
|
|
1429
1594
|
_packs_view,
|
|
1430
1595
|
_summary,
|
|
@@ -55,7 +55,14 @@ const LEDGER_REL = 'docs/CLAIMS.md';
|
|
|
55
55
|
const SURFACE_ROOTS = ['README.md', 'docs'];
|
|
56
56
|
|
|
57
57
|
const CLAIM_MARKER = /<!--\s*claim:([A-Za-z0-9._-]+)\s*-->/g;
|
|
58
|
-
|
|
58
|
+
// The documented grammar is `https://… (YYYY-MM-DD)` — with a space, in both
|
|
59
|
+
// docs/CLAIMS.md § Evidence-pointer grammar and this file's own header. The
|
|
60
|
+
// original pattern required the stamp to abut the URL (`\S+\(`), so every
|
|
61
|
+
// pointer written the documented way was rejected. That went unnoticed because
|
|
62
|
+
// the form had zero usage until the external research citations were bound;
|
|
63
|
+
// the space is now optional, which accepts both spellings and keeps the
|
|
64
|
+
// enforcement matching the spec rather than the other way round.
|
|
65
|
+
const URL_DATED = /^https?:\/\/\S+\s*\(\d{4}-\d{2}-\d{2}\)\s*$/;
|
|
59
66
|
|
|
60
67
|
/** Witness sweep (P1.2) — marketing-class surfaces where a bare number must
|
|
61
68
|
* not appear as a capability claim. docs/proof.md and docs/comparison.yaml
|
|
@@ -118,6 +125,17 @@ export interface LedgerEntry {
|
|
|
118
125
|
evidence: string;
|
|
119
126
|
status: string;
|
|
120
127
|
last_verified: string;
|
|
128
|
+
/**
|
|
129
|
+
* Optional forward link on a `resolved-null` entry: the id of the claim that
|
|
130
|
+
* reopened the same question by a different mechanism. The retire-never-delete
|
|
131
|
+
* lifecycle keeps the null readable forever, but a reader arriving at a closed
|
|
132
|
+
* question has no way to learn it was later reopened — this is that link.
|
|
133
|
+
*
|
|
134
|
+
* Parsed and gated rather than merely documented: an unparsed field would be a
|
|
135
|
+
* documented claim the code does not honour, which is the defect class this
|
|
136
|
+
* ledger exists to make impossible.
|
|
137
|
+
*/
|
|
138
|
+
superseded_by: string;
|
|
121
139
|
}
|
|
122
140
|
|
|
123
141
|
interface Args {
|
|
@@ -158,6 +176,7 @@ function load_ledger(): Map<string, LedgerEntry> {
|
|
|
158
176
|
evidence: cur.evidence ?? '',
|
|
159
177
|
status: cur.status ?? '',
|
|
160
178
|
last_verified: cur.last_verified ?? '',
|
|
179
|
+
superseded_by: cur.superseded_by ?? '',
|
|
161
180
|
});
|
|
162
181
|
}
|
|
163
182
|
cur = {};
|
|
@@ -170,7 +189,7 @@ function load_ledger(): Map<string, LedgerEntry> {
|
|
|
170
189
|
continue;
|
|
171
190
|
}
|
|
172
191
|
if (!cur.id) continue;
|
|
173
|
-
const field = line.match(/^-\s+(claim|kind|evidence|status|last_verified):\s*(.*)$/);
|
|
192
|
+
const field = line.match(/^-\s+(claim|kind|evidence|status|last_verified|superseded_by):\s*(.*)$/);
|
|
174
193
|
if (field) {
|
|
175
194
|
const key = field[1] as keyof LedgerEntry;
|
|
176
195
|
(cur as Record<string, string>)[key] = (field[2] ?? '').trim();
|
|
@@ -320,6 +339,36 @@ function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
320
339
|
if (un) findings.push({ id: entry.id, file: LEDGER_REL, reason: `backed entry has dangling evidence — ${un}` });
|
|
321
340
|
}
|
|
322
341
|
|
|
342
|
+
// 2b. Successor pointers: a `superseded_by` must name a real ledger entry,
|
|
343
|
+
// and only a closed question can have a successor. The field exists so a
|
|
344
|
+
// reader arriving at a `resolved-null` learns the question was reopened
|
|
345
|
+
// by a different mechanism — a dangling id would send them nowhere, and
|
|
346
|
+
// the field on a live entry would claim a closure that never happened.
|
|
347
|
+
for (const entry of ledger.values()) {
|
|
348
|
+
if (!entry.superseded_by) continue;
|
|
349
|
+
if (entry.status !== 'resolved-null') {
|
|
350
|
+
findings.push({
|
|
351
|
+
id: entry.id,
|
|
352
|
+
file: LEDGER_REL,
|
|
353
|
+
reason: `superseded_by is only meaningful on a resolved-null entry (status: ${entry.status || 'missing'})`,
|
|
354
|
+
});
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (!ledger.has(entry.superseded_by)) {
|
|
358
|
+
findings.push({
|
|
359
|
+
id: entry.id,
|
|
360
|
+
file: LEDGER_REL,
|
|
361
|
+
reason: `superseded_by names claim:${entry.superseded_by}, which is not in the ledger`,
|
|
362
|
+
});
|
|
363
|
+
} else if (entry.superseded_by === entry.id) {
|
|
364
|
+
findings.push({
|
|
365
|
+
id: entry.id,
|
|
366
|
+
file: LEDGER_REL,
|
|
367
|
+
reason: 'superseded_by points at its own entry',
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
323
372
|
// 3. Witness sweep (road-to-opt-subagent-harvest P1.2): a QUANTIFIED
|
|
324
373
|
// capability claim on a marketing-class surface must be witnessed — a
|
|
325
374
|
// `<!-- claim:ID -->` marker on the same line, or an explicit
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
* - Median ratio < 3× across all classes → structural overhead acceptable
|
|
21
21
|
*
|
|
22
22
|
* Output: human-readable table on stdout + JSON to
|
|
23
|
-
* `agents/
|
|
23
|
+
* `agents/reports/ci-time-ratio.json` — the tracked report directory, and
|
|
24
|
+
* the path `DEFAULT_OUT` has always written. This docstring named a
|
|
25
|
+
* different, untracked directory until 2026-08-11; the code never did.
|
|
24
26
|
*
|
|
25
27
|
* Usage:
|
|
26
28
|
* node ci_time_ratio.js --limit 30
|