@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
|
@@ -76,7 +76,7 @@ import { AuthCache, select_solo_member } from './ai_council/solo_dispatch.js';
|
|
|
76
76
|
import { InvalidModeError, resolve_global_mode } from './ai_council/modes.js';
|
|
77
77
|
import { resolveMemberTransport } from './ai_council/transport_resolver.js';
|
|
78
78
|
import { absentReasonFromCliFailure, classifyCliFailure, type AbsentReason } from './ai_council/transport_resolver.js';
|
|
79
|
-
import { evaluateQuorum, type QuorumResult } from './ai_council/quorum.js';
|
|
79
|
+
import { evaluateQuorum, SOLO_FLOOR_MIN_PRESENT, type QuorumResult } from './ai_council/quorum.js';
|
|
80
80
|
import {
|
|
81
81
|
appendEvent,
|
|
82
82
|
appendQuorumEvent,
|
|
@@ -525,6 +525,12 @@ function _synthesize_ai_council_block(cfg: CouncilConfig): Dict {
|
|
|
525
525
|
// configured `quorum: <k>`. Additive key — callers that never read
|
|
526
526
|
// it observe no change.
|
|
527
527
|
quorum: cfg.quorum,
|
|
528
|
+
// Forwarded for the same reason `quorum` above had to be: a key that
|
|
529
|
+
// `_build_config` validates and this block drops is a key the loader
|
|
530
|
+
// enforces and the runtime never sees. That defect shipped once here
|
|
531
|
+
// already; repeating it for the shadow floor would silently pin every
|
|
532
|
+
// `floor_would_hold` to the default no matter what the operator set.
|
|
533
|
+
quorum_min_present: cfg.quorum_min_present,
|
|
528
534
|
necessity_classifier: {
|
|
529
535
|
enabled: cfg.necessity_classifier.enabled,
|
|
530
536
|
mode: cfg.necessity_classifier.mode,
|
|
@@ -647,6 +653,25 @@ function _quorum_setting_from(ai: Dict): QuorumSetting {
|
|
|
647
653
|
return 'majority';
|
|
648
654
|
}
|
|
649
655
|
|
|
656
|
+
/**
|
|
657
|
+
* `ai['quorum_min_present']` with the same fail-soft posture as
|
|
658
|
+
* `_quorum_setting_from`: the loader has already validated it on the normal
|
|
659
|
+
* path, and a hand-built dict that bypasses the loader falls back to the
|
|
660
|
+
* ADR-224 default rather than throwing.
|
|
661
|
+
*
|
|
662
|
+
* Failing soft costs nothing on the safety side here, and less than it does
|
|
663
|
+
* for `quorum`: this value feeds a counterfactual boolean that no gate reads,
|
|
664
|
+
* so the worst outcome of a wrong fallback is one mis-recorded telemetry line,
|
|
665
|
+
* never a pass that concluded when it should not have.
|
|
666
|
+
*/
|
|
667
|
+
function _quorum_min_present_from(ai: Dict): number {
|
|
668
|
+
const raw = ai['quorum_min_present'];
|
|
669
|
+
if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 1) {
|
|
670
|
+
return raw;
|
|
671
|
+
}
|
|
672
|
+
return SOLO_FLOOR_MIN_PRESENT;
|
|
673
|
+
}
|
|
674
|
+
|
|
650
675
|
/**
|
|
651
676
|
* Emit one `quorum_result` line for a resolved quorum.
|
|
652
677
|
*
|
|
@@ -674,6 +699,13 @@ function _emitQuorumEvent(
|
|
|
674
699
|
configuredTotal?: number;
|
|
675
700
|
lens?: string;
|
|
676
701
|
invocation?: string;
|
|
702
|
+
/**
|
|
703
|
+
* The ADR-224 shadow floor for this pass. `gateClass` is deliberately
|
|
704
|
+
* NOT in this context object: no CLI path declares itself gate-class,
|
|
705
|
+
* so a parameter here would be one no caller ever sets, and its
|
|
706
|
+
* absence is what the `false` on every line honestly records.
|
|
707
|
+
*/
|
|
708
|
+
minPresent?: number;
|
|
677
709
|
},
|
|
678
710
|
): void {
|
|
679
711
|
appendQuorumEvent({
|
|
@@ -684,6 +716,7 @@ function _emitQuorumEvent(
|
|
|
684
716
|
dispatch: ctx.dispatch ?? 'full',
|
|
685
717
|
configuredTotal: ctx.configuredTotal ?? quorum.total,
|
|
686
718
|
result: quorum,
|
|
719
|
+
...(ctx.minPresent !== undefined ? { minPresent: ctx.minPresent } : {}),
|
|
687
720
|
absent: absent.map(
|
|
688
721
|
(a): QuorumAbsence => ({
|
|
689
722
|
member: String(a['member'] ?? ''),
|
|
@@ -1009,6 +1042,7 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
|
|
|
1009
1042
|
// reach this line and neither is a pass.
|
|
1010
1043
|
_emitQuorumEvent('pre_run', quorum_out.result, absent_entries, {
|
|
1011
1044
|
command: opts.command ?? 'run',
|
|
1045
|
+
minPresent: _quorum_min_present_from(ai),
|
|
1012
1046
|
});
|
|
1013
1047
|
}
|
|
1014
1048
|
if (members.length === 0) {
|
|
@@ -2646,6 +2680,7 @@ function cmd_run(
|
|
|
2646
2680
|
// and the solo-conclusion rate cannot make the one distinction it
|
|
2647
2681
|
// exists for.
|
|
2648
2682
|
dispatch: dispatch_shape,
|
|
2683
|
+
minPresent: _quorum_min_present_from(ai_cfg),
|
|
2649
2684
|
...(configured_total !== null ? { configuredTotal: configured_total } : {}),
|
|
2650
2685
|
lens: question.mode,
|
|
2651
2686
|
invocation: String(_getattr(args, 'invocation', 'agent')),
|
|
@@ -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`);
|
|
@@ -306,6 +306,84 @@ export function computeReviewScope(runGit: GitRunner, base: string): ReviewScope
|
|
|
306
306
|
return { diffText, hash: sha256(diffText), empty: isEmptyScope(diffText) };
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Staleness verdict for a findings artefact already sitting at the dispatch
|
|
311
|
+
* target, decided by the ONE review-scope hash both dispatcher and validator
|
|
312
|
+
* bind to — never by a second definition of "same review".
|
|
313
|
+
*
|
|
314
|
+
* - `current` — its manifest claims the scope being dispatched now. This is
|
|
315
|
+
* the live review; overwriting it would destroy the record of the review
|
|
316
|
+
* that is still in force, so the refusal stands.
|
|
317
|
+
* - `stale` — it claims a DIFFERENT scope. The fixes moved the scope past it,
|
|
318
|
+
* which is exactly the §2.7 re-bind case, and it must not be mistaken for
|
|
319
|
+
* the current review.
|
|
320
|
+
* - `unreadable` — no parseable manifest. Treated like `current`: refusing is
|
|
321
|
+
* the conservative branch when the artefact cannot be identified, since the
|
|
322
|
+
* alternative is renaming a file whose contents nobody established.
|
|
323
|
+
*/
|
|
324
|
+
export type ArtefactStaleness = 'current' | 'stale' | 'unreadable';
|
|
325
|
+
|
|
326
|
+
export function artefactStaleness(text: string, currentScopeHash: string): ArtefactStaleness {
|
|
327
|
+
const manifest = parseManifest(text);
|
|
328
|
+
if (manifest === null) {
|
|
329
|
+
return 'unreadable';
|
|
330
|
+
}
|
|
331
|
+
return manifest.scope_hash === currentScopeHash ? 'current' : 'stale';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The refusal text for a leftover artefact at the dispatch target, naming which
|
|
336
|
+
* of the contract's two paths applies instead of offering only `--force`.
|
|
337
|
+
*
|
|
338
|
+
* **Why this is a message and NOT an automatic rename.** Renaming a stale
|
|
339
|
+
* artefact aside looks like the obvious fix and contract §2.7 forbids it: a fix
|
|
340
|
+
* pass changes the review scope, so an artefact bound to the previous scope is
|
|
341
|
+
* the NORMAL in-place re-bind case, and "renaming instead of re-binding here
|
|
342
|
+
* would leave the shipping content with no review at all → `missing-artifact`".
|
|
343
|
+
* The archival rename is a separate, later step, it has a prescribed name
|
|
344
|
+
* (`<slug>.round<N>-review.md`), and it is gated on every finding already being
|
|
345
|
+
* terminal (`check_review_dispositions`, which recognises an archived record by
|
|
346
|
+
* `-review.md`). An invented quarantine name would have slipped past that gate
|
|
347
|
+
* too, creating an archive path with no terminal check on it.
|
|
348
|
+
*
|
|
349
|
+
* So the dispatcher classifies and explains; the operator performs whichever
|
|
350
|
+
* contract-conform step actually applies.
|
|
351
|
+
*/
|
|
352
|
+
export function leftoverArtefactRefusal(
|
|
353
|
+
findingsPathRel: string,
|
|
354
|
+
verdict: ArtefactStaleness,
|
|
355
|
+
staleScopeHash: string,
|
|
356
|
+
currentScopeHash: string,
|
|
357
|
+
): string {
|
|
358
|
+
const head = `❌ Refusing to overwrite existing findings artifact: ${findingsPathRel}\n`;
|
|
359
|
+
if (verdict === 'unreadable') {
|
|
360
|
+
return (
|
|
361
|
+
head +
|
|
362
|
+
' Its manifest is unreadable, so it cannot be identified as superseded.\n' +
|
|
363
|
+
' Inspect it before doing anything: --force OVERWRITES, and an unidentified\n' +
|
|
364
|
+
' artefact may be the only record of a review that happened.\n'
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (verdict === 'current') {
|
|
368
|
+
return (
|
|
369
|
+
head +
|
|
370
|
+
` It already binds the scope being dispatched now (${currentScopeHash.slice(0, 12)}),\n` +
|
|
371
|
+
' so it is the LIVE review of this content. Re-dispatching would discard it.\n'
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return (
|
|
375
|
+
head +
|
|
376
|
+
` It binds scope ${staleScopeHash.slice(0, 12)}; this dispatch binds ${currentScopeHash.slice(0, 12)},\n` +
|
|
377
|
+
' so the reviewed content moved under it. That is the normal re-bind case, and\n' +
|
|
378
|
+
' contract §2.7 gives it two paths — neither of which is a fresh skeleton:\n' +
|
|
379
|
+
' 1. RE-BIND IN PLACE (the usual one): edit this artefact — new `scope:`, rows\n' +
|
|
380
|
+
' flipped to terminal — and re-commit it. §2.5 expects exactly that.\n' +
|
|
381
|
+
' 2. ARCHIVE the closed round: rename it to `<slug>.round<N>-review.md` ONCE every\n' +
|
|
382
|
+
' finding is terminal, which `check_review_dispositions` enforces. Then dispatch.\n' +
|
|
383
|
+
' --force writes a fresh skeleton OVER this file and destroys the record.\n'
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
309
387
|
/** Manifest comment block — exactly the contract §5 shape. */
|
|
310
388
|
export function deriveManifest(inputs: ManifestInputs): string {
|
|
311
389
|
return [
|
|
@@ -895,8 +973,31 @@ function runDispatch(args: Args): number {
|
|
|
895
973
|
const outDirAbs = path.resolve(args.repo, args.outDir);
|
|
896
974
|
const inputDirAbs = path.join(outDirAbs, `${slug}.review-input`);
|
|
897
975
|
const findingsAbs = path.join(outDirAbs, `${slug}.findings.md`);
|
|
976
|
+
// A leftover artefact at the target is one of three very different things and
|
|
977
|
+
// the pre-existing refusal could not tell them apart: it said "use --force"
|
|
978
|
+
// for all of them, and `--force` overwrites — so the only escape it offered
|
|
979
|
+
// destroys the record of a review that happened. Classify by the ONE
|
|
980
|
+
// review-scope hash both dispatcher and validator bind to, and name the
|
|
981
|
+
// contract-conform step for what was actually found.
|
|
898
982
|
if (fs.existsSync(findingsAbs) && !args.force) {
|
|
899
|
-
|
|
983
|
+
let existingText: string | null = null;
|
|
984
|
+
try {
|
|
985
|
+
existingText = fs.readFileSync(findingsAbs, 'utf-8');
|
|
986
|
+
} catch {
|
|
987
|
+
existingText = null;
|
|
988
|
+
}
|
|
989
|
+
const verdict: ArtefactStaleness =
|
|
990
|
+
existingText === null ? 'unreadable' : artefactStaleness(existingText, hashes.scope_hash);
|
|
991
|
+
const staleHash =
|
|
992
|
+
existingText === null ? '' : (parseManifest(existingText)?.scope_hash ?? '');
|
|
993
|
+
process.stderr.write(
|
|
994
|
+
leftoverArtefactRefusal(
|
|
995
|
+
path.relative(args.repo, findingsAbs),
|
|
996
|
+
verdict,
|
|
997
|
+
staleHash,
|
|
998
|
+
hashes.scope_hash,
|
|
999
|
+
),
|
|
1000
|
+
);
|
|
900
1001
|
return 1;
|
|
901
1002
|
}
|
|
902
1003
|
|