@event4u/agent-config 9.33.0 → 9.34.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 +85 -167
- package/dist/agent-src/commands/agent-handoff.md +15 -3
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- 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/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
- package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-REMEZSVP.js} +9 -2
- package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +7 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +11 -11
- 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 +96 -7
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +118 -10
- 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/config/quorum-attendance-budget.json +1 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
- 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/subagent_capsule.ts +78 -1
- package/src/scripts/ai_council/events_log.ts +2 -2
- 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 +10 -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/discovery_graph.ts +201 -27
- package/src/scripts/dispatch_r2_reviewer.ts +102 -1
- 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/self_repair_cli.ts +26 -1
- package/src/scripts/skill_linter.ts +58 -2
|
@@ -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
|
|
@@ -23,6 +23,18 @@
|
|
|
23
23
|
* build [--manifest P] [--out P] [--force] (re)build the graph cache
|
|
24
24
|
* affected <artefact> [--depth N] [--manifest P] relation-filtered BFS
|
|
25
25
|
* explain <concept> [--budget N] [--manifest P] seed + 2-hop + budget-cut
|
|
26
|
+
* orphans [--manifest P] zero-inbound artefact REPORT
|
|
27
|
+
*
|
|
28
|
+
* `orphans` is a **report, never a gate** (road-to-inbox-harvest-2026-08-b
|
|
29
|
+
* estate-lifecycle 3.2): the relation set above is five typed edges and sees a
|
|
30
|
+
* strictly narrower graph than `check_references.ts`, so a prose-only
|
|
31
|
+
* cross-reference produces no edge and a reachable artefact reads as
|
|
32
|
+
* zero-inbound. The false-positive class is provably non-empty, which is why
|
|
33
|
+
* the command exits 0 on hits and points at review rather than removal.
|
|
34
|
+
*
|
|
35
|
+
* Per-pass `stats` accompany the graph: each edge-extraction pass is wrapped in
|
|
36
|
+
* its own `try`, so one malformed field records `"error"` for that pass instead
|
|
37
|
+
* of failing the whole build silently or loudly.
|
|
26
38
|
*
|
|
27
39
|
* Exit codes: 0 ok, 1 not-found, 2 usage error, 3 internal error.
|
|
28
40
|
*/
|
|
@@ -33,14 +45,35 @@ import * as path from 'node:path';
|
|
|
33
45
|
import process from 'node:process';
|
|
34
46
|
import { fileURLToPath } from 'node:url';
|
|
35
47
|
|
|
48
|
+
import { reportScanned } from './_lib/scan_scope.js';
|
|
36
49
|
import { scanCached } from './_lib/stat_index.js';
|
|
37
50
|
|
|
38
51
|
const PROG = 'discovery_graph.ts';
|
|
39
52
|
const _HERE = fileURLToPath(import.meta.url);
|
|
40
53
|
export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
41
54
|
// discovery-graph-v<N>.json → version namespace in the path (versioned-cache B5b).
|
|
55
|
+
// The FILE name stays v1 on purpose: `complexity_report.ts:234` reads this exact
|
|
56
|
+
// path. `schema_version` inside the payload carries the shape version instead,
|
|
57
|
+
// and `getGraph` forces a rebuild when a cached payload predates it.
|
|
42
58
|
export const GRAPH_CACHE = path.join(REPO_ROOT, 'agents', 'runtime', 'state', 'discovery-graph-v1.json');
|
|
43
59
|
|
|
60
|
+
/** Payload shape version. Bumped when `Graph` gains or changes a field. */
|
|
61
|
+
export const GRAPH_SCHEMA_VERSION = 2;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Prefixes that mark a node as SYNTHETIC — a pack or workspace container the
|
|
65
|
+
* graph invents, not an artefact on disk. Artefact ids are bare repo-relative
|
|
66
|
+
* paths (`idOf`), so the two live in one id space; anything reasoning over
|
|
67
|
+
* "artefacts" must exclude these explicitly rather than assume no path ever
|
|
68
|
+
* starts with `pack:`.
|
|
69
|
+
*/
|
|
70
|
+
export const SYNTHETIC_NODE_PREFIXES = ['pack:', 'workspace:'] as const;
|
|
71
|
+
|
|
72
|
+
/** True when `id` names a pack/workspace container rather than an artefact. */
|
|
73
|
+
export function isSyntheticNode(id: string): boolean {
|
|
74
|
+
return SYNTHETIC_NODE_PREFIXES.some((p) => id.startsWith(p));
|
|
75
|
+
}
|
|
76
|
+
|
|
44
77
|
export type EdgeConfidence = 'EXTRACTED' | 'INFERRED' | 'AMBIGUOUS';
|
|
45
78
|
export interface Edge {
|
|
46
79
|
from: string;
|
|
@@ -48,11 +81,14 @@ export interface Edge {
|
|
|
48
81
|
rel: string;
|
|
49
82
|
confidence: EdgeConfidence;
|
|
50
83
|
}
|
|
84
|
+
/** Per-pass edge counts; `"error"` marks a pass that threw and produced nothing. */
|
|
85
|
+
export type GraphStats = Record<string, number | 'error'>;
|
|
51
86
|
export interface Graph {
|
|
52
87
|
schema_version: number;
|
|
53
88
|
source_checksum: string;
|
|
54
89
|
nodes: string[];
|
|
55
90
|
edges: Edge[];
|
|
91
|
+
stats: GraphStats;
|
|
56
92
|
}
|
|
57
93
|
|
|
58
94
|
interface Artefact {
|
|
@@ -94,43 +130,92 @@ export function loadManifest(manifestPath: string | null): Manifest {
|
|
|
94
130
|
return parsed;
|
|
95
131
|
}
|
|
96
132
|
|
|
133
|
+
/** The five independent edge-extraction passes, in stats-key order. */
|
|
134
|
+
const _PASSES = ['supersedes', 'routes_to', 'references_adr', 'member_of_pack', 'member_of_workspace'] as const;
|
|
135
|
+
type PassName = (typeof _PASSES)[number];
|
|
136
|
+
|
|
97
137
|
/** Deterministic edge extraction from the manifest's structured fields. */
|
|
98
138
|
export function buildGraph(manifest: Manifest): Graph {
|
|
99
139
|
const nodeSet = new Set<string>();
|
|
100
140
|
const edges: Edge[] = [];
|
|
101
141
|
const idOf = (a: Artefact): string => a.path;
|
|
102
142
|
|
|
143
|
+
// Per-pass counters + error containment. One malformed field must not cost
|
|
144
|
+
// the other four passes: a pass that throws is recorded as `"error"` and
|
|
145
|
+
// contributes no edges, instead of aborting the build or — worse — being
|
|
146
|
+
// swallowed into a silently smaller graph nobody can distinguish from a
|
|
147
|
+
// correct one.
|
|
148
|
+
const counts = new Map<PassName, number>(_PASSES.map((p) => [p, 0]));
|
|
149
|
+
const failed = new Set<PassName>();
|
|
150
|
+
const run = (pass: PassName, fn: () => number): void => {
|
|
151
|
+
if (failed.has(pass)) return;
|
|
152
|
+
try {
|
|
153
|
+
counts.set(pass, (counts.get(pass) ?? 0) + fn());
|
|
154
|
+
} catch {
|
|
155
|
+
failed.add(pass);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
103
159
|
for (const a of manifest.artefacts) {
|
|
104
160
|
const id = idOf(a);
|
|
105
161
|
nodeSet.add(id);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
162
|
+
run('supersedes', () => {
|
|
163
|
+
let n = 0;
|
|
164
|
+
for (const r of _asStrings(a.replaces)) {
|
|
165
|
+
edges.push({ from: id, to: r, rel: 'supersedes', confidence: 'EXTRACTED' });
|
|
166
|
+
edges.push({ from: r, to: id, rel: 'superseded_by', confidence: 'EXTRACTED' });
|
|
167
|
+
nodeSet.add(r);
|
|
168
|
+
n += 2;
|
|
169
|
+
}
|
|
170
|
+
return n;
|
|
171
|
+
});
|
|
172
|
+
run('routes_to', () => {
|
|
173
|
+
let n = 0;
|
|
174
|
+
for (const t of _asStrings(a.routes_to)) {
|
|
175
|
+
edges.push({ from: id, to: t, rel: 'routes_to', confidence: 'EXTRACTED' });
|
|
119
176
|
nodeSet.add(t);
|
|
177
|
+
n += 1;
|
|
120
178
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
179
|
+
return n;
|
|
180
|
+
});
|
|
181
|
+
// ADR references surfaced through any structured target field.
|
|
182
|
+
run('references_adr', () => {
|
|
183
|
+
let n = 0;
|
|
184
|
+
for (const t of [..._asStrings(a.replaces), ..._asStrings(a.routes_to)]) {
|
|
185
|
+
if (/(^|\/)docs\/(decisions|adr)\/ADR-/i.test(t) || /\bADR-\d+/.test(t)) {
|
|
186
|
+
edges.push({ from: id, to: t, rel: 'references_adr', confidence: 'EXTRACTED' });
|
|
187
|
+
nodeSet.add(t);
|
|
188
|
+
n += 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return n;
|
|
192
|
+
});
|
|
193
|
+
run('member_of_pack', () => {
|
|
194
|
+
let n = 0;
|
|
195
|
+
for (const p of _asStrings(a.packs)) {
|
|
196
|
+
const node = `pack:${p}`;
|
|
197
|
+
edges.push({ from: id, to: node, rel: 'member_of', confidence: 'INFERRED' });
|
|
198
|
+
nodeSet.add(node);
|
|
199
|
+
n += 1;
|
|
200
|
+
}
|
|
201
|
+
return n;
|
|
202
|
+
});
|
|
203
|
+
run('member_of_workspace', () => {
|
|
204
|
+
let n = 0;
|
|
205
|
+
for (const w of _asStrings(a.workspaces)) {
|
|
206
|
+
const node = `workspace:${w}`;
|
|
207
|
+
edges.push({ from: id, to: node, rel: 'member_of', confidence: 'INFERRED' });
|
|
208
|
+
nodeSet.add(node);
|
|
209
|
+
n += 1;
|
|
210
|
+
}
|
|
211
|
+
return n;
|
|
212
|
+
});
|
|
132
213
|
}
|
|
133
214
|
|
|
215
|
+
const stats: GraphStats = {};
|
|
216
|
+
for (const p of _PASSES) stats[p] = failed.has(p) ? 'error' : (counts.get(p) ?? 0);
|
|
217
|
+
const artefactIds = new Set(manifest.artefacts.map(idOf));
|
|
218
|
+
|
|
134
219
|
// Stable, de-duplicated ordering → byte-stable output.
|
|
135
220
|
const seen = new Set<string>();
|
|
136
221
|
const uniq = edges.filter((e) => {
|
|
@@ -140,15 +225,28 @@ export function buildGraph(manifest: Manifest): Graph {
|
|
|
140
225
|
return true;
|
|
141
226
|
});
|
|
142
227
|
uniq.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.rel.localeCompare(b.rel));
|
|
228
|
+
// Distinct EXTRACTED targets that name no artefact in the manifest. The
|
|
229
|
+
// structured fields carry LOGICAL names (`commit:in-chunks`) while node ids
|
|
230
|
+
// are repo-relative paths, so a non-zero count here means inbound degree is
|
|
231
|
+
// systematically under-counted — the fact any inverse traversal has to know
|
|
232
|
+
// before it presents a list. Measured, not assumed: see
|
|
233
|
+
// `agents/evidence/analysis/discovery-graph-inbound-degree.md`.
|
|
234
|
+
const danglingTargets = new Set(
|
|
235
|
+
uniq.filter((e) => e.confidence === 'EXTRACTED' && !artefactIds.has(e.to)).map((e) => e.to),
|
|
236
|
+
);
|
|
237
|
+
stats['dangling_targets'] = danglingTargets.size;
|
|
143
238
|
const checksum =
|
|
144
239
|
typeof manifest.checksum === 'string' && manifest.checksum
|
|
145
240
|
? manifest.checksum
|
|
146
241
|
: 'sha256:' + createHash('sha256').update(JSON.stringify(manifest.artefacts)).digest('hex');
|
|
147
242
|
return {
|
|
148
|
-
schema_version:
|
|
243
|
+
schema_version: GRAPH_SCHEMA_VERSION,
|
|
149
244
|
source_checksum: checksum,
|
|
150
245
|
nodes: [...nodeSet].sort(),
|
|
151
246
|
edges: uniq,
|
|
247
|
+
// Counts are edges EXTRACTED per pass, before the de-duplication above —
|
|
248
|
+
// so they sum to ≥ `edges.length` and stay attributable to one pass.
|
|
249
|
+
stats,
|
|
152
250
|
};
|
|
153
251
|
}
|
|
154
252
|
|
|
@@ -188,7 +286,39 @@ export function getGraph(manifestPath: string | null, force: boolean): Graph {
|
|
|
188
286
|
if (manifestPath !== null) {
|
|
189
287
|
return buildGraph(loadManifest(manifestPath));
|
|
190
288
|
}
|
|
191
|
-
|
|
289
|
+
const files = _sourceFiles();
|
|
290
|
+
const graph = scanCached<Graph>(GRAPH_CACHE, files, () => buildGraph(loadManifest(null)), force);
|
|
291
|
+
// A cache written before the current payload shape is a stale shape, not a
|
|
292
|
+
// stale signature — `scanCached` compares signatures only, so the guard is
|
|
293
|
+
// here. One forced rebuild costs a manifest pass; serving a payload missing
|
|
294
|
+
// fields the caller's type promises costs a wrong answer.
|
|
295
|
+
if (graph.schema_version !== GRAPH_SCHEMA_VERSION) {
|
|
296
|
+
return scanCached<Graph>(GRAPH_CACHE, files, () => buildGraph(loadManifest(null)), true);
|
|
297
|
+
}
|
|
298
|
+
return graph;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Artefact nodes with zero inbound EXTRACTED edges — the estate's "nothing
|
|
303
|
+
* points at this" question, as an inverse traversal of the existing edge set.
|
|
304
|
+
*
|
|
305
|
+
* Two exclusions, both load-bearing:
|
|
306
|
+
*
|
|
307
|
+
* - `member_of` is INFERRED (`packs`/`workspaces`), and every packed artefact
|
|
308
|
+
* has one. Counting it would make almost everything look reachable via its
|
|
309
|
+
* own pack node and reduce the report to noise.
|
|
310
|
+
* - Synthetic pack/workspace nodes are not artefacts and are never the subject
|
|
311
|
+
* of the question.
|
|
312
|
+
*
|
|
313
|
+
* The result is a REVIEW list, never a removal list — see the module header.
|
|
314
|
+
*/
|
|
315
|
+
export function inboundZero(graph: Graph): string[] {
|
|
316
|
+
const withInbound = new Set<string>();
|
|
317
|
+
for (const e of graph.edges) {
|
|
318
|
+
if (e.confidence !== 'EXTRACTED') continue;
|
|
319
|
+
withInbound.add(e.to);
|
|
320
|
+
}
|
|
321
|
+
return graph.nodes.filter((n) => !isSyntheticNode(n) && !withInbound.has(n)).sort();
|
|
192
322
|
}
|
|
193
323
|
|
|
194
324
|
/** Relation-filtered BFS from a start node. Returns reachable nodes + hop depth. */
|
|
@@ -247,6 +377,50 @@ export function main(argv: string[]): number {
|
|
|
247
377
|
if (sub === 'build') {
|
|
248
378
|
const graph = getGraph(manifestPath, rest.includes('--force'));
|
|
249
379
|
process.stdout.write(`${PROG}: graph — ${graph.nodes.length} nodes, ${graph.edges.length} edges\n`);
|
|
380
|
+
for (const [pass, n] of Object.entries(graph.stats)) {
|
|
381
|
+
process.stdout.write(` ${pass}: ${String(n)}\n`);
|
|
382
|
+
}
|
|
383
|
+
reportScanned({
|
|
384
|
+
gate: PROG,
|
|
385
|
+
scanned: graph.nodes.length,
|
|
386
|
+
units: 'node(s)',
|
|
387
|
+
roots: ['src', 'docs/decisions', 'docs/adr'],
|
|
388
|
+
});
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
if (sub === 'orphans') {
|
|
392
|
+
const graph = getGraph(manifestPath, false);
|
|
393
|
+
const hits = inboundZero(graph);
|
|
394
|
+
const dangling = graph.stats['dangling_targets'];
|
|
395
|
+
// Degraded path, NAMED rather than papered over: while EXTRACTED
|
|
396
|
+
// targets do not resolve to artefact paths, inbound degree is
|
|
397
|
+
// under-counted for every artefact and the list below is the whole
|
|
398
|
+
// estate, not a finding. Printing it anyway would be a report that
|
|
399
|
+
// looks like evidence and is not.
|
|
400
|
+
if (typeof dangling === 'number' && dangling > 0) {
|
|
401
|
+
process.stdout.write(
|
|
402
|
+
`${PROG}: inbound degree is DEGRADED — ${String(dangling)} EXTRACTED edge target(s)\n` +
|
|
403
|
+
'name no artefact path in the manifest (the structured fields carry logical\n' +
|
|
404
|
+
'names, node ids are repo-relative paths). Every artefact therefore reads as\n' +
|
|
405
|
+
`zero-inbound (${String(hits.length)} of ${String(graph.nodes.length)} nodes), which is a\n` +
|
|
406
|
+
'property of the edge set, not of the estate. No list is printed.\n' +
|
|
407
|
+
'See `agents/evidence/analysis/discovery-graph-inbound-degree.md`.\n',
|
|
408
|
+
);
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
process.stdout.write(`zero-inbound artefacts (${hits.length} of ${graph.nodes.length} nodes):\n`);
|
|
412
|
+
for (const h of hits) process.stdout.write(` ${h}\n`);
|
|
413
|
+
// 3.3 — the disposition line travels WITH the list, so a reader who
|
|
414
|
+
// sees only the output still sees what it licenses.
|
|
415
|
+
process.stdout.write(
|
|
416
|
+
'\nThis is a review prompt, not a removal list. The graph sees five typed\n' +
|
|
417
|
+
'edge kinds and no prose cross-references, so a referenced artefact can\n' +
|
|
418
|
+
'appear here; `check_references.ts` is the cross-reference gate, not this.\n' +
|
|
419
|
+
'Sunset stays explicit and recorded in the removing commit, with no\n' +
|
|
420
|
+
'tombstone files (`docs/governance.md` § Skill lifecycle policy), and the\n' +
|
|
421
|
+
'shipped precedent for stale artefacts is archive-not-delete\n' +
|
|
422
|
+
'(`src/scripts/janitor.ts:10` — never auto-sweeps).\n',
|
|
423
|
+
);
|
|
250
424
|
return 0;
|
|
251
425
|
}
|
|
252
426
|
if (sub === 'affected') {
|
|
@@ -279,7 +453,7 @@ export function main(argv: string[]): number {
|
|
|
279
453
|
for (const n of nodes) process.stdout.write(` [${n.depth}] ${n.node} (via ${n.via})\n`);
|
|
280
454
|
return 0;
|
|
281
455
|
}
|
|
282
|
-
process.stderr.write(`usage: ${PROG} <build|affected|explain> [args]\n`);
|
|
456
|
+
process.stderr.write(`usage: ${PROG} <build|affected|explain|orphans> [args]\n`);
|
|
283
457
|
return 2;
|
|
284
458
|
} catch (exc) {
|
|
285
459
|
process.stderr.write(`${PROG}: error: ${String(exc)}\n`);
|