@event4u/agent-config 9.36.0 → 10.1.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 +117 -2
- package/agents/templates/.ai-council.yml.example +31 -51
- package/dist/agent-src/commands/analyze/conformance.md +15 -4
- package/dist/agent-src/rules/session-canary.md +28 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +17 -0
- package/dist/agent-src/skills/adr-create/SKILL.md +5 -0
- package/dist/agent-src/skills/check-refs/SKILL.md +5 -0
- package/dist/agent-src/skills/lint-skills/SKILL.md +5 -0
- package/dist/agent-src/skills/md-language-check/SKILL.md +5 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-SFGARMAB.js → chunk-2H5SE7DU.js} +24 -4
- package/dist/cli-delegate/{chunk-U7CMAIOC.js → chunk-3JUTT26I.js} +1 -1
- package/dist/cli-delegate/{chunk-JP53LNVT.js → chunk-HJSKTMIF.js} +6 -14
- package/dist/cli-delegate/{chunk-QEEB4LHO.js → chunk-LRXJK6GN.js} +1 -1
- package/dist/cli-delegate/{chunk-NR7HSN3P.js → chunk-NX5AXSLD.js} +3 -3
- package/dist/cli-delegate/{chunk-AG47LAGQ.js → chunk-TVGXH7VA.js} +1 -1
- package/dist/cli-delegate/{chunk-55RI5UXK.js → chunk-XT2YTJCZ.js} +22 -18
- package/dist/cli-delegate/{chunk-JEJABGKB.js → chunk-XTC5ARB6.js} +16 -12
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +1037 -196
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +1 -1
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +125 -89
- package/dist/install/install.mjs +22 -26
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1579 -616
- package/dist/scripts/_lib/agent_settings.js +39 -25
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/condense.js +15 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/wizard.js +23 -3
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/server/schemas/settings.js +6 -6
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +14 -9
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +17 -0
- package/src/config/agent-settings.template.yml +6 -36
- package/src/config/ci-local-parity.yml +44 -0
- package/src/scripts/_lib/agent_settings.ts +39 -25
- package/src/scripts/_lib/environment_detector.ts +52 -1
- package/src/scripts/_lib/untrusted_content.test.ts +175 -0
- package/src/scripts/_lib/untrusted_content.ts +184 -0
- package/src/scripts/ai_council/config.ts +62 -24
- package/src/scripts/before_complete_hook.ts +39 -0
- package/src/scripts/check_bridge_derivation.ts +89 -3
- package/src/scripts/check_ci_local_parity.ts +74 -0
- package/src/scripts/check_references.ts +15 -1
- package/src/scripts/condense.ts +16 -2
- package/src/scripts/conformance_scan.ts +200 -9
- package/src/scripts/council_cli.ts +95 -3
- package/src/scripts/hook_manifest.yaml +12 -6
- package/src/scripts/hooks/block_no_verify.ts +82 -9
- package/src/scripts/hooks/turn_end_gate_hook.ts +138 -105
- package/src/scripts/probe_promissory_closing.ts +180 -0
- package/src/scripts/probe_session_canary.ts +180 -0
- package/src/scripts/schemas/skill.schema.json +42 -0
- package/src/scripts/skill_linter.ts +57 -2
- package/src/server/schemas/settings.ts +6 -14
|
@@ -639,6 +639,20 @@ export interface CouncilConfig {
|
|
|
639
639
|
readonly low_impact: LowImpactConfig;
|
|
640
640
|
readonly lens_overrides: LensOverridesConfig;
|
|
641
641
|
readonly source_path: PathLike | null;
|
|
642
|
+
/**
|
|
643
|
+
* Transport keys the config file still carries and the loader deliberately
|
|
644
|
+
* IGNORED — dotted paths, e.g. `defaults.mode`, `members.anthropic.mode`.
|
|
645
|
+
*
|
|
646
|
+
* Transport is resolved per machine per invocation, never configured: the
|
|
647
|
+
* chain is `cli → api → unavailable`, and an airgapped host forces the
|
|
648
|
+
* `api` rung. Deleting the keys from the schema outright would have made
|
|
649
|
+
* every pre-existing installation fail to load, so a stale key is
|
|
650
|
+
* accepted, ignored, and reported once — the migration IS the ignore.
|
|
651
|
+
*
|
|
652
|
+
* Empty on a config that never carried one, which is the shape the current
|
|
653
|
+
* template ships.
|
|
654
|
+
*/
|
|
655
|
+
readonly ignored_transport_keys: readonly string[];
|
|
642
656
|
}
|
|
643
657
|
|
|
644
658
|
// ── default factories (dataclass defaults) ─────────────────────────
|
|
@@ -796,7 +810,11 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
|
|
|
796
810
|
throw new CouncilConfigError('`enabled` must be a bool.');
|
|
797
811
|
}
|
|
798
812
|
|
|
799
|
-
const
|
|
813
|
+
const ignored_transport_keys: string[] = [];
|
|
814
|
+
const defaults = _build_defaults(
|
|
815
|
+
_asDict(_getOr(raw, 'defaults', {})),
|
|
816
|
+
ignored_transport_keys,
|
|
817
|
+
);
|
|
800
818
|
const cost_budget = _build_cost_budget(
|
|
801
819
|
_asDict(_getOr(raw, 'cost_budget', {})),
|
|
802
820
|
);
|
|
@@ -809,7 +827,12 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
|
|
|
809
827
|
for (const [name, cfg] of Object.entries(members_raw)) {
|
|
810
828
|
members.set(
|
|
811
829
|
name,
|
|
812
|
-
_build_member(
|
|
830
|
+
_build_member(
|
|
831
|
+
name,
|
|
832
|
+
_asDict(_orEmpty(cfg)),
|
|
833
|
+
defaults.mode,
|
|
834
|
+
ignored_transport_keys,
|
|
835
|
+
),
|
|
813
836
|
);
|
|
814
837
|
}
|
|
815
838
|
|
|
@@ -920,6 +943,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
|
|
|
920
943
|
low_impact,
|
|
921
944
|
lens_overrides,
|
|
922
945
|
source_path,
|
|
946
|
+
ignored_transport_keys,
|
|
923
947
|
};
|
|
924
948
|
}
|
|
925
949
|
|
|
@@ -1508,23 +1532,36 @@ function _build_consensus_scoring(d: Dict): ConsensusScoringConfig {
|
|
|
1508
1532
|
|
|
1509
1533
|
const _VALID_MEMBER_MODES: ReadonlySet<string> = new Set(['cli', 'api']);
|
|
1510
1534
|
|
|
1511
|
-
function _build_defaults(d: Dict): DefaultsConfig {
|
|
1535
|
+
function _build_defaults(d: Dict, ignored: string[] = []): DefaultsConfig {
|
|
1512
1536
|
if (!_isDict(d)) {
|
|
1513
1537
|
throw new CouncilConfigError('`defaults` must be a mapping.');
|
|
1514
1538
|
}
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1539
|
+
// Transport is RESOLVED, never configured.
|
|
1540
|
+
//
|
|
1541
|
+
// The CLI-first flip (road-to-always-on-orchestration Phase 3.1) changed
|
|
1542
|
+
// only what a config that OMITS `mode` receives. Every installation that
|
|
1543
|
+
// spelled the key out — which the wizard did, and which every pre-flip
|
|
1544
|
+
// template shipped — kept its old `api` value forever, silently, and went
|
|
1545
|
+
// on paying per token while a subscription CLI sat unused on the same
|
|
1546
|
+
// machine. A default that only fires on an absent key does not migrate
|
|
1547
|
+
// anybody; it just makes the docs read better than the behaviour.
|
|
1548
|
+
//
|
|
1549
|
+
// So the key is no longer read. `auto` is the only value this loader
|
|
1550
|
+
// produces, and `transport_resolver` picks the concrete rung per machine
|
|
1551
|
+
// per invocation (`cli → api → unavailable`, airgap forcing `api`). A file
|
|
1552
|
+
// still carrying `mode:` loads fine and is reported via
|
|
1553
|
+
// `ignored_transport_keys` — see `CouncilConfig`.
|
|
1554
|
+
// `null` as the sentinel, not `undefined`: `_get`'s default parameter is
|
|
1555
|
+
// typed `Json`, which excludes `undefined`. A config spelling `mode: null`
|
|
1556
|
+
// is indistinguishable from an absent key here, and that is correct — a
|
|
1557
|
+
// null transport is not a pin worth reporting.
|
|
1558
|
+
if (_get(d, 'mode', null) !== null) {
|
|
1559
|
+
ignored.push('defaults.mode');
|
|
1560
|
+
}
|
|
1561
|
+
const mode = 'auto';
|
|
1525
1562
|
// `member_mode` (step-9 P8 · U1) — global preference for solo /
|
|
1526
|
-
// CLI-mode invocations. Narrower set than `defaults.mode`
|
|
1527
|
-
// `manual` makes no sense as a per-member dispatch default.
|
|
1563
|
+
// CLI-mode invocations. Narrower set than the retired `defaults.mode`
|
|
1564
|
+
// because `manual` makes no sense as a per-member dispatch default.
|
|
1528
1565
|
const member_mode = _get(d, 'member_mode', 'cli');
|
|
1529
1566
|
if (!(_isStr(member_mode) && _VALID_MEMBER_MODES.has(member_mode))) {
|
|
1530
1567
|
throw new CouncilConfigError(
|
|
@@ -1726,6 +1763,7 @@ function _build_member(
|
|
|
1726
1763
|
// a future caller that omits the third argument observes the same
|
|
1727
1764
|
// doctrine rather than silently reverting to the retired `api` default.
|
|
1728
1765
|
default_mode = 'auto',
|
|
1766
|
+
ignored: string[] = [],
|
|
1729
1767
|
): MemberConfig {
|
|
1730
1768
|
if (!_VALID_PROVIDERS.has(name)) {
|
|
1731
1769
|
throw new CouncilConfigError(
|
|
@@ -1738,15 +1776,15 @@ function _build_member(
|
|
|
1738
1776
|
const model = _pyTruthy(modelVal) ? (modelVal as string) : '';
|
|
1739
1777
|
const api_key_ref_raw = _get(cfg, 'api_key_ref', null);
|
|
1740
1778
|
const api_key_ref = api_key_ref_raw === undefined ? null : api_key_ref_raw;
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
const effective_mode =
|
|
1779
|
+
// Per-member transport is no longer configurable either — same reasoning
|
|
1780
|
+
// as `_build_defaults`. A member that pinned `mode: api` would reintroduce
|
|
1781
|
+
// exactly the silent-spend path the global key was removed for, one
|
|
1782
|
+
// provider at a time. Recorded as ignored, never read.
|
|
1783
|
+
if (_get(cfg, 'mode', null) !== null) {
|
|
1784
|
+
ignored.push(`members.${name}.mode`);
|
|
1785
|
+
}
|
|
1786
|
+
const member_mode: string | null = null;
|
|
1787
|
+
const effective_mode = default_mode;
|
|
1750
1788
|
const binary_raw = _get(cfg, 'binary', null);
|
|
1751
1789
|
const binary = binary_raw === undefined ? null : binary_raw;
|
|
1752
1790
|
if (binary !== null) {
|
|
@@ -113,6 +113,14 @@ function _empty_state(): StateDict {
|
|
|
113
113
|
verified_this_turn: false,
|
|
114
114
|
ci_saw_pending: false,
|
|
115
115
|
nonevidence_this_turn: 0,
|
|
116
|
+
// Round 7 § Phase 1 — the last CI read, SESSION-scoped on purpose. Every
|
|
117
|
+
// other counter here is turn-scoped because a stale POSITIVE would wrongly
|
|
118
|
+
// vouch ("I verified"). This one is a NEGATIVE ("CI was not settled"), and a
|
|
119
|
+
// stale negative only ever refuses more often — so surviving the turn
|
|
120
|
+
// boundary preserves the freshness invariant rather than bypassing it. It
|
|
121
|
+
// has to survive it: the measured failure is a completion claim in a LATER
|
|
122
|
+
// turn than the poll it rests on.
|
|
123
|
+
ci_last: null,
|
|
116
124
|
checked_at: _now(),
|
|
117
125
|
};
|
|
118
126
|
}
|
|
@@ -303,6 +311,16 @@ function _update(state: StateDict, event: string, envelope: StateDict): StateDic
|
|
|
303
311
|
// Session boundary — reset session-scoped counters.
|
|
304
312
|
state["session_id"] = session_id;
|
|
305
313
|
state["verifications_this_session"] = 0;
|
|
314
|
+
// R2 finding 1 (high). `ci_last` is SESSION-scoped, and until this line
|
|
315
|
+
// nothing cleared it: `_reset_turn` clears the turn-scoped witness,
|
|
316
|
+
// `_empty_state` sets it null only for a state file that does not exist yet,
|
|
317
|
+
// and `_load_state` merges the persisted value over that default. So session
|
|
318
|
+
// A polled CI, saw pending, and ended; session B — a doc-only session that
|
|
319
|
+
// never touched CI — said "Fertig" and was REFUSED, contradicting both the
|
|
320
|
+
// consumer's own comment ("a session that never polled CI must never be
|
|
321
|
+
// refused for it") and the negative case the roadmap pinned. Session-scoped
|
|
322
|
+
// has to mean cleared at the boundary, or it means never cleared.
|
|
323
|
+
state["ci_last"] = null;
|
|
306
324
|
state = _reset_turn(state, session_id);
|
|
307
325
|
}
|
|
308
326
|
|
|
@@ -338,6 +356,27 @@ function _update(state: StateDict, event: string, envelope: StateDict): StateDic
|
|
|
338
356
|
} else {
|
|
339
357
|
counts = false; // unrecognised or unreadable — never read as settled
|
|
340
358
|
}
|
|
359
|
+
// Round 7 § 1.1 — the session-scoped negative the turn-end consumer reads.
|
|
360
|
+
//
|
|
361
|
+
// R2 finding 3 (medium), and it was the sharpest one: this said `settled:
|
|
362
|
+
// pending === 0` and CLAIMED "the same discrimination `counts` makes
|
|
363
|
+
// above". It was not. `counts` requires `!vacuous && ci_saw_pending`;
|
|
364
|
+
// `pending === 0` alone drops both. A post-push poll that reads a stale
|
|
365
|
+
// all-pass table returns pending 0 — verbatim the FC-3b failure documented
|
|
366
|
+
// twenty lines up — and recorded `settled: true`, so the completion
|
|
367
|
+
// detector went SILENT in exactly the premature-claim case it exists for.
|
|
368
|
+
// A detector defeated by the false settle it was built to catch is worse
|
|
369
|
+
// than no detector, because it reports coverage.
|
|
370
|
+
//
|
|
371
|
+
// Now it reuses `counts` itself, which is that discrimination rather than
|
|
372
|
+
// a paraphrase of it. `pending === null` (unreadable, and "no checks
|
|
373
|
+
// reported") stays not-a-settle by construction.
|
|
374
|
+
state["ci_last"] = {
|
|
375
|
+
at: _now(),
|
|
376
|
+
command: cmd.slice(0, 512),
|
|
377
|
+
pending,
|
|
378
|
+
settled: pending === 0 && counts === true,
|
|
379
|
+
};
|
|
341
380
|
}
|
|
342
381
|
|
|
343
382
|
state["last_verification"] = {
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
* changed entry means the tree on disk did not derive from the current
|
|
17
17
|
* source (hand-edit, stale generation, or a nondeterministic generator).
|
|
18
18
|
*
|
|
19
|
+
* Both legs are scoped to the roots this checkout's generator actually writes:
|
|
20
|
+
* a root whose owning tool is deactivated in `agents/.agent-tools.yml` has no
|
|
21
|
+
* regeneration path, so nothing found there is drift it could repair. Skipped
|
|
22
|
+
* roots are named on stderr, never dropped silently.
|
|
23
|
+
*
|
|
19
24
|
* Run AFTER `task generate-tools` (CI: the sync-consistency job). Exit 1 on
|
|
20
25
|
* the first drifted artefact, naming it.
|
|
21
26
|
*/
|
|
@@ -26,6 +31,7 @@ import * as path from 'node:path';
|
|
|
26
31
|
import { fileURLToPath } from 'node:url';
|
|
27
32
|
|
|
28
33
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
34
|
+
import { active_tools_at } from './condense.js';
|
|
29
35
|
|
|
30
36
|
const _HERE = fileURLToPath(import.meta.url);
|
|
31
37
|
const REPO_ROOT = path.join(path.dirname(_HERE), '..', '..');
|
|
@@ -50,6 +56,59 @@ export const BRIDGE_ROOTS: readonly string[] = [
|
|
|
50
56
|
/** Repo-relative prefixes a bridge symlink may resolve into. */
|
|
51
57
|
const DERIVATION_SOURCES = ['dist/agent-src', 'src', 'AGENTS.md'] as const;
|
|
52
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Bridge root → the tool id whose activation makes the generator maintain it.
|
|
61
|
+
*
|
|
62
|
+
* A root ABSENT from this map is maintained unconditionally, and `.augment` is
|
|
63
|
+
* the one that matters: `project_to_augment()` carries no tool gate, so mapping
|
|
64
|
+
* it to `augment` would silence the audit on a tree that is regenerated on
|
|
65
|
+
* every run — a strict weakening. Absent-means-unconditional keeps that the
|
|
66
|
+
* default, so a future bridge root has to opt IN to being skippable.
|
|
67
|
+
*/
|
|
68
|
+
const ROOT_TOOL_ID: Readonly<Record<string, string>> = {
|
|
69
|
+
'.claude/rules': 'claude-code',
|
|
70
|
+
'.claude/skills': 'claude-code',
|
|
71
|
+
'.claude/agents': 'claude-code',
|
|
72
|
+
'.claude/personas': 'claude-code',
|
|
73
|
+
'.claude/user-types': 'claude-code',
|
|
74
|
+
'.cursor/rules': 'cursor',
|
|
75
|
+
'.cursor/commands': 'cursor',
|
|
76
|
+
'.windsurf/rules': 'windsurf',
|
|
77
|
+
'.windsurf/workflows': 'windsurf',
|
|
78
|
+
'.windsurfrules': 'windsurf',
|
|
79
|
+
'.clinerules': 'cline',
|
|
80
|
+
'GEMINI.md': 'gemini',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Split `roots` into the ones this checkout's generator maintains and the ones
|
|
85
|
+
* it does not, given the active tool set (`null` = all tools active).
|
|
86
|
+
*
|
|
87
|
+
* Auditing a root whose owning tool is inactive is not a drift check: the
|
|
88
|
+
* generator never writes it, so whatever sits there is an unmaintained fossil
|
|
89
|
+
* with no regeneration path. Reporting it leaves hand-deletion as the only
|
|
90
|
+
* compliant action, which is exactly what `check_rule_projection_integrity`
|
|
91
|
+
* rejected as "not a gate" after the same masked-config shape broke the 9.27.0
|
|
92
|
+
* release. This one broke 9.36.0 the same way: `agents/.agent-tools.yml` is
|
|
93
|
+
* committed with all eight tools but carries `skip-worktree` locally and reads
|
|
94
|
+
* `tools: []`, so `.claude/skills/` had not been regenerated since 2026-07-05
|
|
95
|
+
* and still held symlinks to a directory renamed since.
|
|
96
|
+
*/
|
|
97
|
+
export function partition_roots(
|
|
98
|
+
roots: readonly string[],
|
|
99
|
+
active: ReadonlySet<string> | null,
|
|
100
|
+
): { audited: string[]; skipped: string[] } {
|
|
101
|
+
if (active === null) return { audited: [...roots], skipped: [] };
|
|
102
|
+
const audited: string[] = [];
|
|
103
|
+
const skipped: string[] = [];
|
|
104
|
+
for (const r of roots) {
|
|
105
|
+
const tool = ROOT_TOOL_ID[r];
|
|
106
|
+
if (tool === undefined || active.has(tool)) audited.push(r);
|
|
107
|
+
else skipped.push(r);
|
|
108
|
+
}
|
|
109
|
+
return { audited, skipped };
|
|
110
|
+
}
|
|
111
|
+
|
|
53
112
|
function _walk(p: string, out: string[]): void {
|
|
54
113
|
const st = fs.lstatSync(p);
|
|
55
114
|
if (st.isSymbolicLink() || st.isFile()) {
|
|
@@ -132,7 +191,22 @@ export function validate_symlinks(root: string, repoRoot: string): string[] {
|
|
|
132
191
|
|
|
133
192
|
export function run(repoRoot: string): string[] {
|
|
134
193
|
const errors: string[] = [];
|
|
135
|
-
const
|
|
194
|
+
const { audited, skipped } = partition_roots(BRIDGE_ROOTS, active_tools_at(repoRoot));
|
|
195
|
+
|
|
196
|
+
// LOUD, never a silent narrowing: the audited surface just shrank, and a
|
|
197
|
+
// reader who cannot see which roots dropped out cannot distinguish this run
|
|
198
|
+
// from a full one — that indistinguishability is the false green
|
|
199
|
+
// `_lib/scan_scope.ts` exists to prevent.
|
|
200
|
+
if (skipped.length > 0) {
|
|
201
|
+
process.stderr.write(
|
|
202
|
+
`⚠️ check_bridge_derivation: ${skipped.length} bridge root(s) NOT audited — ` +
|
|
203
|
+
`agents/.agent-tools.yml deactivates the owning tool, so the generator does ` +
|
|
204
|
+
`not write them in this checkout and their contents cannot be attributed as ` +
|
|
205
|
+
`drift: ${skipped.join(', ')}. CI activates all eight tools.\n`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const roots = audited.map((r) => path.join(repoRoot, r));
|
|
136
210
|
|
|
137
211
|
const before: Record<string, Record<string, string>> = {};
|
|
138
212
|
for (const r of roots) before[r] = snapshot_tree(r);
|
|
@@ -141,11 +215,17 @@ export function run(repoRoot: string): string[] {
|
|
|
141
215
|
// there is nothing to diff and nothing to validate, and the check reports a
|
|
142
216
|
// fixpoint it never tested. Thrown, not returned — `errors` is the drift
|
|
143
217
|
// channel, and a dead scope is not drift.
|
|
218
|
+
//
|
|
219
|
+
// No `allowEmpty` twin of the sibling gate's is needed here, and that is a
|
|
220
|
+
// property of the map rather than an omission: `.augment` is maintained
|
|
221
|
+
// unconditionally, so `audited` is never empty and zero entries always means
|
|
222
|
+
// a genuinely dead surface (no `dist/agent-src/` at all), not a deactivated
|
|
223
|
+
// tool. Map `.augment` to a tool and this reasoning stops holding.
|
|
144
224
|
assertScanned({
|
|
145
225
|
gate: 'check_bridge_derivation',
|
|
146
226
|
scanned: Object.values(before).reduce((n, snap) => n + Object.keys(snap).length, 0),
|
|
147
227
|
units: 'bridge entries',
|
|
148
|
-
roots:
|
|
228
|
+
roots: audited,
|
|
149
229
|
});
|
|
150
230
|
|
|
151
231
|
// Regenerate in a child process so MODULE_STATE resolves exactly as the
|
|
@@ -188,6 +268,12 @@ if (isMain) {
|
|
|
188
268
|
for (const e of errors) process.stderr.write(`❌ ${e}\n`);
|
|
189
269
|
process.exit(1);
|
|
190
270
|
}
|
|
191
|
-
|
|
271
|
+
// Name the count rather than "bridge trees": with a tool deactivated the
|
|
272
|
+
// green covers fewer roots than the reader would assume from a bare tick.
|
|
273
|
+
const n = partition_roots(BRIDGE_ROOTS, active_tools_at(root)).audited.length;
|
|
274
|
+
process.stdout.write(
|
|
275
|
+
`✅ ${n}/${BRIDGE_ROOTS.length} bridge roots derive from src/ ` +
|
|
276
|
+
'(symlink targets valid, regeneration is a fixpoint)\n',
|
|
277
|
+
);
|
|
192
278
|
process.exit(0);
|
|
193
279
|
}
|
|
@@ -38,6 +38,27 @@ const HOOK = path.join(REPO, 'src', 'scripts', 'install-hooks.sh');
|
|
|
38
38
|
/** Roots of the local closure. `ci` is the local mirror; the hook is what a push runs. */
|
|
39
39
|
const LOCAL_ROOTS = ['ci', 'consistency'] as const;
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Round 7 § Phase 3 — the third dimension, and why two were not enough.
|
|
43
|
+
*
|
|
44
|
+
* `task ci` INCLUDES `preflight` (`Taskfile.yml:87`), so the two roots above
|
|
45
|
+
* answer "does the local MIRROR match CI". The pass an agent is actually told to
|
|
46
|
+
* run before pushing is `task preflight`, a deliberate subset of `ci` — its own
|
|
47
|
+
* docstring excludes `check_enforcement_coverage` at a measured 30.7 s because
|
|
48
|
+
* "a pre-push gate that doubles the hook teaches people to skip it".
|
|
49
|
+
*
|
|
50
|
+
* That makes `CI ∩ ci \ preflight` invisible to this gate by construction, and it
|
|
51
|
+
* is exactly where round 7's measured "green locally, red remotely" cycles came
|
|
52
|
+
* from: the Thin-Root char cap, the ratchet living in the Node-Tests shard, and
|
|
53
|
+
* the derived-page freshness check.
|
|
54
|
+
*
|
|
55
|
+
* Reported, not gated. The subset is intentional, so failing on it would demand
|
|
56
|
+
* that `preflight` grow into `ci` — the opposite of what its author decided. What
|
|
57
|
+
* this dimension removes is the IMPLICATION that a green preflight means CI will
|
|
58
|
+
* be green.
|
|
59
|
+
*/
|
|
60
|
+
const PREFLIGHT_ROOTS = ['preflight'] as const;
|
|
61
|
+
|
|
41
62
|
export interface Declared {
|
|
42
63
|
id: string;
|
|
43
64
|
class: string;
|
|
@@ -197,6 +218,19 @@ export interface ParityReport {
|
|
|
197
218
|
undeclared_ci_only: string[];
|
|
198
219
|
undeclared_local_only: string[];
|
|
199
220
|
stale_declarations: string[];
|
|
221
|
+
/** Round 7 § Phase 3 — `task preflight`'s own closure. */
|
|
222
|
+
preflight_gates: string[];
|
|
223
|
+
/**
|
|
224
|
+
* `CI ∩ local \ (preflight ∪ pre-push hook)` — gates a push's remote run
|
|
225
|
+
* enforces, the local mirror covers, and NOTHING a push runs locally reaches.
|
|
226
|
+
* Report-only: the measured size of "green locally, red remotely", not a
|
|
227
|
+
* failure.
|
|
228
|
+
*
|
|
229
|
+
* The hook's own closure is in the subtrahend per R2 finding 4: subtracting
|
|
230
|
+
* preflight alone counted gates the pre-push hook already runs, which cannot
|
|
231
|
+
* fail after a push. That read 221; the honest figure is 209.
|
|
232
|
+
*/
|
|
233
|
+
ci_not_in_preflight: string[];
|
|
200
234
|
}
|
|
201
235
|
|
|
202
236
|
export function analyse(repo = REPO, manifest?: Manifest): ParityReport {
|
|
@@ -231,12 +265,38 @@ export function analyse(repo = REPO, manifest?: Manifest): ParityReport {
|
|
|
231
265
|
...[...declaredLocal].filter((g) => localGates.has(g) && ciGates.has(g)),
|
|
232
266
|
].sort();
|
|
233
267
|
|
|
268
|
+
// Round 7 § 3.1 — the third dimension. Derived the same way as the other two,
|
|
269
|
+
// so it cannot drift from them by construction.
|
|
270
|
+
//
|
|
271
|
+
// R2 finding 4 (medium): the first version subtracted only the `preflight`
|
|
272
|
+
// closure from `localGates`, and `localGates` includes every gate reachable
|
|
273
|
+
// from `install-hooks.sh` — which the PRE-PUSH hook runs. A gate that runs
|
|
274
|
+
// before the push lands cannot produce a "green locally, red remotely" cycle,
|
|
275
|
+
// so counting it inflated the number and mislabelled it, in a step whose own
|
|
276
|
+
// text calls the number "the deliverable, not an adjective". The subtrahend is
|
|
277
|
+
// therefore everything a push already runs: preflight PLUS the hook's own
|
|
278
|
+
// closure.
|
|
279
|
+
const preflightGates = local_closure(PREFLIGHT_ROOTS, tasks);
|
|
280
|
+
const prePush = new Set(preflightGates);
|
|
281
|
+
if (fs.existsSync(HOOK)) {
|
|
282
|
+
const hook = fs.readFileSync(HOOK, 'utf-8');
|
|
283
|
+
for (const g of extract_gates(hook)) prePush.add(g);
|
|
284
|
+
for (const t of extract_tasks(hook)) {
|
|
285
|
+
for (const g of local_closure([t], tasks)) prePush.add(g);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const ci_not_in_preflight = [...ciGates]
|
|
289
|
+
.filter((g) => localGates.has(g) && !prePush.has(g))
|
|
290
|
+
.sort();
|
|
291
|
+
|
|
234
292
|
return {
|
|
235
293
|
ci_gates: [...ciGates].sort(),
|
|
236
294
|
local_gates: [...localGates].sort(),
|
|
237
295
|
undeclared_ci_only,
|
|
238
296
|
undeclared_local_only,
|
|
239
297
|
stale_declarations,
|
|
298
|
+
preflight_gates: [...preflightGates].sort(),
|
|
299
|
+
ci_not_in_preflight,
|
|
240
300
|
};
|
|
241
301
|
}
|
|
242
302
|
|
|
@@ -292,6 +352,20 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
|
292
352
|
for (const g of r.stale_declarations) process.stdout.write(` - ${g}\n`);
|
|
293
353
|
}
|
|
294
354
|
|
|
355
|
+
// Round 7 § 3.2 — printed on EVERY path, pass or fail, because the number is
|
|
356
|
+
// the deliverable. A green parity verdict above says the local MIRROR matches
|
|
357
|
+
// CI; it says nothing about the pass a contributor is told to run, and reading
|
|
358
|
+
// the green line as if it did is the implication this block removes.
|
|
359
|
+
process.stdout.write(
|
|
360
|
+
`ℹ️ preflight: ${String(r.preflight_gates.length)} gate(s) of the ${String(r.local_gates.length)} local ones; ` +
|
|
361
|
+
`${String(r.ci_not_in_preflight.length)} CI-enforced gate(s) run NEITHER in \`task preflight\` NOR\n` +
|
|
362
|
+
' anywhere else the pre-push hook reaches — i.e. the set that can only fail after\n' +
|
|
363
|
+
' a push. `task ci` INCLUDES preflight, so the parity verdict above cannot see it.\n' +
|
|
364
|
+
' Report-only: the subset is deliberate (see the preflight docstring). A green\n' +
|
|
365
|
+
' preflight is therefore not a prediction that CI will be green.\n',
|
|
366
|
+
);
|
|
367
|
+
for (const g of r.ci_not_in_preflight) process.stdout.write(` · ${g}\n`);
|
|
368
|
+
|
|
295
369
|
if (!failed) {
|
|
296
370
|
process.stdout.write(
|
|
297
371
|
`✅ CI ↔ local parity: ${String(r.ci_gates.length)} CI gate(s), ${String(r.local_gates.length)} local, ` +
|
|
@@ -57,7 +57,21 @@ function makeBrokenRef(
|
|
|
57
57
|
|
|
58
58
|
const SCAN_DIRS = ['dist/agent-src', 'agents'];
|
|
59
59
|
const SKIP_DIRS = [
|
|
60
|
-
|
|
60
|
+
// Archived roadmaps have historical refs — a link to a file that existed when
|
|
61
|
+
// the plan was written is not drift, so the exclusion is right on the merits.
|
|
62
|
+
//
|
|
63
|
+
// ROUND 7, DOWNGRADE (2026-08-12): it also means this checker is the ONLY thing
|
|
64
|
+
// that would see a class of real rot, and cannot. Measured: **530 dead relative
|
|
65
|
+
// links across 147 of 466 archived roadmaps**. The cause is not history — it is
|
|
66
|
+
// that `archive_completed_roadmaps` moves a file one directory deeper and never
|
|
67
|
+
// re-depths its outgoing relative links, so a link that resolved before the move
|
|
68
|
+
// is broken BY the move. Two tools, one silence: the writer does not rewrite, and
|
|
69
|
+
// the only checker that would notice is scoped away from where it lands.
|
|
70
|
+
//
|
|
71
|
+
// Not repaired here: the fix is a bulk edit across 147 files plus a re-depth pass
|
|
72
|
+
// in the sweep, which is its own change. Recorded so the exclusion is read as a
|
|
73
|
+
// known gap with a number rather than as coverage.
|
|
74
|
+
'agents/roadmaps/archive',
|
|
61
75
|
'agents/runtime/council/sessions', // per-user audit trail (gitignored), captured provider output
|
|
62
76
|
'agents/runtime/council/responses', // paired council output (gitignored), captured provider output
|
|
63
77
|
'agents/runtime/council/questions', // design Q&A trail — forward-refs to planned artifacts
|
package/src/scripts/condense.ts
CHANGED
|
@@ -359,8 +359,18 @@ const _ALL_TOOLS: ReadonlySet<string> = new Set([
|
|
|
359
359
|
]);
|
|
360
360
|
void _ALL_TOOLS;
|
|
361
361
|
|
|
362
|
-
|
|
363
|
-
|
|
362
|
+
/**
|
|
363
|
+
* Tool ids selected by `agents/.agent-tools.yml` under `root`, or `null` when
|
|
364
|
+
* the file is absent / malformed / carries no `tools:` list — `null` means
|
|
365
|
+
* "all tools active", never "none".
|
|
366
|
+
*
|
|
367
|
+
* Root-parameterised and exported so a consumer auditing a checkout other than
|
|
368
|
+
* `MODULE_STATE.PROJECT_ROOT` (a gate run with `--root`) reads the selection
|
|
369
|
+
* this generator actually honours, instead of re-implementing these four
|
|
370
|
+
* fallbacks and drifting from them.
|
|
371
|
+
*/
|
|
372
|
+
export function active_tools_at(root: string): ReadonlySet<string> | null {
|
|
373
|
+
const tools_file = path.join(root, 'agents', '.agent-tools.yml');
|
|
364
374
|
if (!_exists(tools_file)) {
|
|
365
375
|
return null;
|
|
366
376
|
}
|
|
@@ -380,6 +390,10 @@ function _active_tools(): ReadonlySet<string> | null {
|
|
|
380
390
|
return new Set(tools.filter((t): t is string => typeof t === 'string'));
|
|
381
391
|
}
|
|
382
392
|
|
|
393
|
+
function _active_tools(): ReadonlySet<string> | null {
|
|
394
|
+
return active_tools_at(MODULE_STATE.PROJECT_ROOT);
|
|
395
|
+
}
|
|
396
|
+
|
|
383
397
|
function _tool_active(tool_id: string): boolean {
|
|
384
398
|
const active = _active_tools();
|
|
385
399
|
return active === null ? true : active.has(tool_id);
|