@event4u/agent-config 14.19.0 → 14.21.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.
Files changed (161) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +351 -156
  5. package/README.md +1 -1
  6. package/agents/templates/.ai-council.yml.example +10 -0
  7. package/dist/agent-src/commands/agent-handoff.md +42 -41
  8. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  9. package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
  10. package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
  11. package/dist/agent-src/commands/analyze.md +12 -7
  12. package/dist/agent-src/commands/feature/plan.md +55 -16
  13. package/dist/agent-src/commands/feature/refactor.md +8 -3
  14. package/dist/agent-src/commands/humanize.md +48 -4
  15. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
  16. package/dist/agent-src/contexts/execution/host-capability-manifest.md +11 -2
  17. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
  18. package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
  19. package/dist/agent-src/rules/evaluator-independence.md +4 -4
  20. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
  21. package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
  22. package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
  23. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
  24. package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
  25. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
  26. package/dist/agent-src/skills/markitdown/SKILL.md +2 -2
  27. package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
  28. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
  29. package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
  30. package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
  31. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  32. package/dist/agent-src/templates/features.md +5 -0
  33. package/dist/agent-src/templates/roadmaps.md +29 -1
  34. package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  35. package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  36. package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
  37. package/dist/cli/mcp/dispatch.js +10 -0
  38. package/dist/cli/mcp/stdio.js +33 -2
  39. package/dist/cli/mcp/telemetry.js +227 -0
  40. package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
  41. package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-2TAQ2K4V.js} +166 -1
  42. package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-643Z7FIC.js} +79 -12
  43. package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
  44. package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-J3RFNKG4.js} +41 -5
  45. package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
  46. package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
  47. package/dist/cli-delegate/cmd_conformance.js +88 -110
  48. package/dist/cli-delegate/cmd_doctor.js +3 -3
  49. package/dist/cli-delegate/cmd_export.js +2 -2
  50. package/dist/cli-delegate/cmd_handoff.js +42 -12
  51. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  52. package/dist/cli-delegate/cmd_prune.js +2 -2
  53. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  54. package/dist/cli-delegate/cmd_session_recycle.js +59 -902
  55. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  56. package/dist/cli-delegate/cmd_sync.js +2 -2
  57. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  58. package/dist/cli-delegate/cmd_validate.js +2 -2
  59. package/dist/discovery/deprecation-report.md +1 -1
  60. package/dist/discovery/discovery-manifest.json +62 -31
  61. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  62. package/dist/discovery/discovery-manifest.summary.md +3 -3
  63. package/dist/discovery/dormancy-report.md +1 -1
  64. package/dist/discovery/orphan-report.md +1 -1
  65. package/dist/discovery/packs.json +7 -6
  66. package/dist/discovery/trust-report.md +2 -2
  67. package/dist/discovery/workspaces.json +6 -5
  68. package/dist/hooks/dispatch.js +3801 -2803
  69. package/dist/install/install.mjs +396 -168
  70. package/dist/mcp/registry-manifest.json +3 -3
  71. package/dist/mcp/server.json +2 -2
  72. package/dist/mcp/server.mjs +809 -180
  73. package/dist/scripts/ai_council/content_ceiling.js +63 -0
  74. package/dist/scripts/ai_council/seat_policy.js +106 -0
  75. package/dist/scripts/ai_council/transport_resolver.js +21 -0
  76. package/dist/scripts/mcp_server/telemetry.js +148 -0
  77. package/docs/guidelines/agent-infra/naming.md +1 -1
  78. package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
  79. package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
  80. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
  81. package/docs/guidelines/redundancy-taxonomy.md +28 -0
  82. package/llms.txt +14 -14
  83. package/package.json +1 -1
  84. package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  85. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  86. package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
  87. package/src/config/assurance-capability-registry.json +9 -0
  88. package/src/config/gate-coverage.yml +55 -0
  89. package/src/config/gate-violation-baselines.json +18 -6
  90. package/src/config/release-gate-locality.yml +4 -3
  91. package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
  92. package/src/scripts/_lib/audit_field_provenance.ts +44 -3
  93. package/src/scripts/_lib/blocked_by_marker.ts +189 -0
  94. package/src/scripts/_lib/host_capability.ts +76 -1
  95. package/src/scripts/_lib/install_reach_checks.ts +33 -1
  96. package/src/scripts/_lib/mcp_bridge.ts +222 -10
  97. package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
  98. package/src/scripts/_lib/orchestration_record.ts +23 -1
  99. package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
  100. package/src/scripts/_lib/release_highlights.ts +19 -211
  101. package/src/scripts/_lib/release_material.ts +27 -77
  102. package/src/scripts/_lib/release_position.ts +96 -0
  103. package/src/scripts/_lib/review_skipped_record.ts +17 -1
  104. package/src/scripts/_lib/security_lint.ts +179 -7
  105. package/src/scripts/_lib/self_repair.ts +141 -6
  106. package/src/scripts/_lib/self_repair_store.ts +273 -7
  107. package/src/scripts/_lib/structured_ask.ts +117 -0
  108. package/src/scripts/_lib/subagent_capsule.ts +155 -3
  109. package/src/scripts/_lib/tty_prompt.ts +76 -0
  110. package/src/scripts/ai_council/api_transport.ts +163 -0
  111. package/src/scripts/ai_council/clients.ts +58 -63
  112. package/src/scripts/ai_council/config.ts +13 -0
  113. package/src/scripts/ai_council/content_ceiling.ts +79 -0
  114. package/src/scripts/ai_council/jury_aggregate.ts +178 -0
  115. package/src/scripts/ai_council/seat_policy.ts +137 -0
  116. package/src/scripts/ai_council/status_surface.ts +79 -0
  117. package/src/scripts/ai_council/transport_resolver.ts +59 -1
  118. package/src/scripts/ai_tells_rules.ts +446 -5
  119. package/src/scripts/ask_block_census.ts +499 -0
  120. package/src/scripts/bench_humanizer_eval.ts +264 -40
  121. package/src/scripts/bench_prose_tells_fp.ts +198 -0
  122. package/src/scripts/build_rule_trigger_matrix.ts +5 -0
  123. package/src/scripts/check_branch_work_committed.ts +234 -0
  124. package/src/scripts/check_mcp_doc_drift.ts +217 -0
  125. package/src/scripts/check_pack_size.ts +321 -3
  126. package/src/scripts/check_release_highlights.ts +0 -157
  127. package/src/scripts/council_cli.ts +18 -27
  128. package/src/scripts/detect_ai_tells.ts +142 -20
  129. package/src/scripts/handoff_context_hook.ts +54 -7
  130. package/src/scripts/harvest_reference_tokens.ts +698 -0
  131. package/src/scripts/hook_manifest.json +1 -1
  132. package/src/scripts/hook_manifest.yaml +31 -1
  133. package/src/scripts/hooks/concern_registry.ts +2 -0
  134. package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
  135. package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
  136. package/src/scripts/hooks/session_eol_hook.ts +18 -7
  137. package/src/scripts/install-hooks.sh +52 -0
  138. package/src/scripts/install.ts +4 -4
  139. package/src/scripts/lint_agent_security.ts +316 -25
  140. package/src/scripts/lint_confusables.ts +42 -6
  141. package/src/scripts/lint_handoffs.ts +0 -132
  142. package/src/scripts/lint_hidden_unicode.ts +49 -7
  143. package/src/scripts/lint_instruction_smuggling.ts +42 -6
  144. package/src/scripts/lint_mcp_config_security.ts +42 -6
  145. package/src/scripts/lint_roadmap_blockers.ts +51 -1
  146. package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
  147. package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
  148. package/src/scripts/mcp_server/resources.ts +41 -0
  149. package/src/scripts/mcp_server/telemetry.ts +19 -4
  150. package/src/scripts/migrate_command_suggestions.ts +1 -1
  151. package/src/scripts/orchestration_record.ts +12 -1
  152. package/src/scripts/probe_unblocked_ask.ts +135 -2
  153. package/src/scripts/release.ts +50 -95
  154. package/src/scripts/release_drill.ts +6 -10
  155. package/src/scripts/release_env.ts +1 -0
  156. package/src/scripts/release_publication.ts +40 -31
  157. package/src/scripts/self_repair_cli.ts +31 -1
  158. package/src/scripts/self_review_gate.ts +409 -15
  159. package/src/scripts/skill_scout.ts +69 -0
  160. package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
  161. package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
@@ -0,0 +1,698 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * harvest_reference_tokens — the deterministic reference-token discovery pass
4
+ * (`road-to-bounded-reference-harvest-loop` Phase 4).
5
+ *
6
+ * Six jobs, one script, in one order: **discover** the `ENC1:` tokens across
7
+ * the roadmap estate, **decrypt** them, **classify** each resolved URL,
8
+ * **canonicalize** the ones that are repositories, **deduplicate** on identity
9
+ * plus pinned revision, and **write** a resumable manifest into the gitignored
10
+ * local area. It is deliberately not a module family: every stage's output is
11
+ * the next stage's only input, so a seam between them would buy nothing and
12
+ * cost a contract.
13
+ *
14
+ * Why the census is printable and the findings are not:
15
+ * The token census — occurrences, unique tokens, files, per-level breakdown —
16
+ * names nothing, so it prints. The **resolved URLs are the source names this
17
+ * repository's confidentiality programme exists to hide**, so they never reach
18
+ * stdout except behind `--json` at a terminal, and they are written only under
19
+ * `agents/.harvest-local/`, which `.gitignore` covers as a whole directory
20
+ * rather than as a pattern one rename defeats.
21
+ *
22
+ * Refuse, never skip.
23
+ * Extraction needs no key, so the census works without one. Everything past
24
+ * extraction does, and with no key resolvable this **refuses with exit 2**
25
+ * rather than reporting a quietly short classification. A zero that means
26
+ * "nobody could look" is the failure this repository's scan-scope discipline
27
+ * exists to prevent, and it is exactly the failure mode Risk 7 of the roadmap
28
+ * names.
29
+ *
30
+ * Usage:
31
+ * harvest_reference_tokens [--dry-run] [--manifest <path>] [--limit <n>]
32
+ * harvest_reference_tokens --json # plaintext, terminal only
33
+ * harvest_reference_tokens --resume [--refresh]
34
+ *
35
+ * Exit codes: 0 = the pass ran, 2 = usage error or no key resolvable.
36
+ */
37
+
38
+ import * as crypto from 'node:crypto';
39
+ import * as fs from 'node:fs';
40
+ import * as path from 'node:path';
41
+ import process from 'node:process';
42
+ import { fileURLToPath, pathToFileURL } from 'node:url';
43
+
44
+ import { decrypt, resolve_keys } from './_lib/link_crypto.js';
45
+
46
+ const _HERE = fileURLToPath(import.meta.url);
47
+ const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
48
+
49
+ /** The gitignored local area. `.gitignore` covers the whole directory. */
50
+ export const LOCAL_AREA = 'agents/.harvest-local';
51
+ /** One line per identity. The only place an opaque id resolves to a name. */
52
+ export const MANIFEST = `${LOCAL_AREA}/manifest.jsonl`;
53
+
54
+ /**
55
+ * Bumped when the loop contract changes shape. Part of the freshness key, so a
56
+ * contract change invalidates prior analyses; this package's own revision is
57
+ * deliberately NOT part of it (§ freshnessKey).
58
+ */
59
+ export const CONTRACT_VERSION = 1;
60
+
61
+ /**
62
+ * The five levels of the roadmap estate. Enumerated here rather than read from
63
+ * a shared library because no canonical estate reader exists in
64
+ * `src/scripts/_lib/` — verified, not assumed.
65
+ */
66
+ export const ESTATE_LEVELS = ['active', 'archive', 'later', 'stubs', 'skipped'] as const;
67
+ export type EstateLevel = (typeof ESTATE_LEVELS)[number];
68
+
69
+ /**
70
+ * Mirrors the token scan in `sweep_source_surfaces.ts:587`, with one addition:
71
+ * a minimum length. The shortest real token in the estate is 93 characters
72
+ * (measured), so requiring 60 base64 characters excludes a short prose
73
+ * placeholder such as `ENC1:abcd` while matching every genuine token.
74
+ *
75
+ * The character class and the minimum are two bounds against two different
76
+ * placeholders, and only the first is load-bearing on today's tree: a plain
77
+ * substring scan for `ENC1:` reports 77 estate files, the character class
78
+ * brings that to the 63 that carry a token (the other 14 mention the bare
79
+ * prefix in prose), and the minimum currently rejects nothing further. It is
80
+ * kept because the defect it guards is the same one — documentation showing
81
+ * the token *shape* counted as a token, which is what Risk 7 names.
82
+ */
83
+ export const TOKEN_RE = /ENC1:[A-Za-z0-9+/=]{60,}/g;
84
+
85
+ /** Hosts whose `/owner/repo` path shape denotes a repository. */
86
+ const REPO_HOSTS = new Set([
87
+ 'github.com',
88
+ 'gitlab.com',
89
+ 'bitbucket.org',
90
+ 'codeberg.org',
91
+ 'gitea.com',
92
+ 'git.sr.ht',
93
+ 'sr.ht',
94
+ ]);
95
+
96
+ /**
97
+ * First path segments on a repository host that are the host's own product
98
+ * surface, never an owner. A URL under one of these is not-a-repository even
99
+ * though its host is a repository host.
100
+ */
101
+ const NON_OWNER_SEGMENTS = new Set([
102
+ 'about', 'blog', 'collections', 'customer-stories', 'enterprise', 'explore',
103
+ 'features', 'marketplace', 'notifications', 'orgs', 'pricing', 'pulls',
104
+ 'search', 'security', 'settings', 'sponsors', 'topics', 'trending',
105
+ ]);
106
+
107
+ /** Path segments that follow `<owner>/<repo>` and are collapsed away. */
108
+ const SUBPATH_SEGMENTS = new Set([
109
+ 'blob', 'tree', 'commit', 'commits', 'issues', 'pull', 'pulls',
110
+ 'discussions', 'releases', 'tags', 'wiki', 'actions', 'blame', 'raw',
111
+ ]);
112
+
113
+ export type Classification = 'repository' | 'not-a-repository' | 'unresolvable';
114
+
115
+ /** One token occurrence, before decryption. */
116
+ export interface Occurrence {
117
+ token: string;
118
+ file: string;
119
+ level: EstateLevel;
120
+ }
121
+
122
+ /** The countable half of the pass — names nothing, so it prints. */
123
+ export interface Census {
124
+ occurrences: number;
125
+ uniqueTokens: number;
126
+ files: number;
127
+ byLevel: Record<EstateLevel, number>;
128
+ }
129
+
130
+ /** One resolved token. `url` and `identity` are plaintext — never printed. */
131
+ export interface Resolved {
132
+ token: string;
133
+ url: string | null;
134
+ identity: string | null;
135
+ revision: string | null;
136
+ classification: Classification;
137
+ citedBy: string[];
138
+ }
139
+
140
+ export type Status =
141
+ | 'pending'
142
+ | 'running'
143
+ | 'done'
144
+ | 'skipped-as-not-a-repository'
145
+ | 'skipped-as-duplicate'
146
+ | 'skipped-as-fresh'
147
+ | 'blocked-on-missing-key'
148
+ | 'blocked-as-unresolvable'
149
+ | 'failed';
150
+
151
+ /** Every status the manifest may carry. A value outside this set is a defect. */
152
+ export const STATUSES: readonly Status[] = [
153
+ 'pending', 'running', 'done',
154
+ 'skipped-as-not-a-repository', 'skipped-as-duplicate', 'skipped-as-fresh',
155
+ 'blocked-on-missing-key', 'blocked-as-unresolvable', 'failed',
156
+ ];
157
+
158
+ /** One manifest line. One per identity-plus-revision, never one per token. */
159
+ export interface Entry {
160
+ opaque_id: string;
161
+ identity: string | null;
162
+ revision: string | null;
163
+ status: Status;
164
+ cited_by: string[];
165
+ tokens: number;
166
+ contract_version: number;
167
+ loops: number;
168
+ focus: string | null;
169
+ observed_at: string;
170
+ }
171
+
172
+ /**
173
+ * The opaque id: `sha256(identity + '@' + revision)`, first 16 hex characters.
174
+ *
175
+ * The revision is inside the hash on purpose. It makes the id **stable** across
176
+ * two runs at one revision — so a resumed run lands in the directory it already
177
+ * populated — and **different** across revisions, so one repository at two
178
+ * historical pins is two pieces of evidence instead of one silently
179
+ * overwriting the other.
180
+ */
181
+ export function opaqueId(identity: string, revision: string): string {
182
+ return crypto.createHash('sha256').update(`${identity}@${revision}`).digest('hex').slice(0, 16);
183
+ }
184
+
185
+ /**
186
+ * Collapse every spelling of one repository onto `<host>/<owner>/<repo>`.
187
+ *
188
+ * Handles the four spellings that occur in practice: the plain HTTPS URL, the
189
+ * `.git` suffix, the `git@host:owner/repo.git` SCP form, and a deep link such
190
+ * as `/tree/main/x` or `/blob/main/y`. Returns `null` when the URL is not a
191
+ * repository at all — classification reads this return value rather than the
192
+ * prose the token sat next to.
193
+ */
194
+ export function canonicalize(raw: string): string | null {
195
+ let s = raw.trim();
196
+ if (s === '') {
197
+ return null;
198
+ }
199
+ const scp = /^(?:ssh:\/\/)?git@([^:/]+)[:/](.+)$/.exec(s);
200
+ if (scp) {
201
+ s = `https://${scp[1]}/${scp[2] as string}`;
202
+ }
203
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
204
+ s = `https://${s}`;
205
+ }
206
+ let u: URL;
207
+ try {
208
+ u = new URL(s);
209
+ } catch {
210
+ return null;
211
+ }
212
+ const host = u.hostname.toLowerCase().replace(/^www\./, '');
213
+ const segs = u.pathname.split('/').filter((x) => x !== '');
214
+ const dotGit = segs.length > 0 && (segs[segs.length - 1] as string).endsWith('.git');
215
+ if (!REPO_HOSTS.has(host) && !dotGit) {
216
+ return null;
217
+ }
218
+ if (segs.length < 2) {
219
+ return null;
220
+ }
221
+ const owner = (segs[0] as string).toLowerCase();
222
+ if (NON_OWNER_SEGMENTS.has(owner)) {
223
+ return null;
224
+ }
225
+ let repo = (segs[1] as string).toLowerCase().replace(/\.git$/, '');
226
+ if (repo === '') {
227
+ return null;
228
+ }
229
+ if (segs.length > 2 && !SUBPATH_SEGMENTS.has((segs[2] as string).toLowerCase())) {
230
+ // A third segment that is not a known sub-path is a different surface
231
+ // (a host's nested group, a docs route) — do not guess it is a repo.
232
+ if (host !== 'gitlab.com') {
233
+ return null;
234
+ }
235
+ repo = segs.slice(1).join('/').toLowerCase().replace(/\.git$/, '');
236
+ }
237
+ return `${host}/${owner}/${repo}`;
238
+ }
239
+
240
+ /** `repository` iff `canonicalize` yields an identity. Reads the URL, never the prose. */
241
+ export function classifyUrl(raw: string): Classification {
242
+ return canonicalize(raw) === null ? 'not-a-repository' : 'repository';
243
+ }
244
+
245
+ /** Which estate level a roadmap path sits at. */
246
+ export function levelOf(rel: string): EstateLevel {
247
+ const m = /^agents\/roadmaps\/([^/]+)\//.exec(rel);
248
+ const dir = m ? (m[1] as string) : '';
249
+ return (ESTATE_LEVELS as readonly string[]).includes(dir) ? (dir as EstateLevel) : 'active';
250
+ }
251
+
252
+ /** Every `.md` under the estate, sorted so two runs enumerate identically. */
253
+ export function estateFiles(root: string = ROOT): string[] {
254
+ const base = path.join(root, 'agents', 'roadmaps');
255
+ const out: string[] = [];
256
+ const walk = (dir: string): void => {
257
+ let ents: fs.Dirent[];
258
+ try {
259
+ ents = fs.readdirSync(dir, { withFileTypes: true });
260
+ } catch {
261
+ return;
262
+ }
263
+ for (const e of ents.sort((a, b) => (a.name < b.name ? -1 : 1))) {
264
+ const p = path.join(dir, e.name);
265
+ if (e.isDirectory()) {
266
+ walk(p);
267
+ } else if (e.name.endsWith('.md')) {
268
+ out.push(path.relative(root, p));
269
+ }
270
+ }
271
+ };
272
+ walk(base);
273
+ return out.sort();
274
+ }
275
+
276
+ /** Extract every token occurrence. Needs no key. */
277
+ export function discover(root: string = ROOT): Occurrence[] {
278
+ const out: Occurrence[] = [];
279
+ for (const rel of estateFiles(root)) {
280
+ let text: string;
281
+ try {
282
+ text = fs.readFileSync(path.join(root, rel), 'utf-8');
283
+ } catch {
284
+ continue;
285
+ }
286
+ for (const m of text.matchAll(TOKEN_RE)) {
287
+ out.push({ token: m[0], file: rel, level: levelOf(rel) });
288
+ }
289
+ }
290
+ return out;
291
+ }
292
+
293
+ /** Counts only. Prints safely because it names nothing. */
294
+ export function census(occ: readonly Occurrence[]): Census {
295
+ const byLevel: Record<EstateLevel, number> = {
296
+ active: 0, archive: 0, later: 0, stubs: 0, skipped: 0,
297
+ };
298
+ const filesPerLevel: Record<EstateLevel, Set<string>> = {
299
+ active: new Set(), archive: new Set(), later: new Set(),
300
+ stubs: new Set(), skipped: new Set(),
301
+ };
302
+ for (const o of occ) {
303
+ filesPerLevel[o.level].add(o.file);
304
+ }
305
+ for (const l of ESTATE_LEVELS) {
306
+ byLevel[l] = filesPerLevel[l].size;
307
+ }
308
+ return {
309
+ occurrences: occ.length,
310
+ uniqueTokens: new Set(occ.map((o) => o.token)).size,
311
+ files: new Set(occ.map((o) => o.file)).size,
312
+ byLevel,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Decrypt, classify, canonicalize. One `Resolved` per **unique token**, with
318
+ * the citing files fanned in — a token repeated in three roadmaps is one
319
+ * resolution, not three.
320
+ */
321
+ export function resolveAll(occ: readonly Occurrence[], keys: readonly string[]): Resolved[] {
322
+ const cited = new Map<string, string[]>();
323
+ for (const o of occ) {
324
+ const l = cited.get(o.token) ?? [];
325
+ if (!l.includes(o.file)) {
326
+ l.push(o.file);
327
+ }
328
+ cited.set(o.token, l);
329
+ }
330
+ const out: Resolved[] = [];
331
+ for (const token of [...cited.keys()].sort()) {
332
+ const citedBy = (cited.get(token) as string[]).slice().sort();
333
+ let url: string | null = null;
334
+ try {
335
+ url = decrypt(token, keys as string[]);
336
+ } catch {
337
+ out.push({ token, url: null, identity: null, revision: null, classification: 'unresolvable', citedBy });
338
+ continue;
339
+ }
340
+ const identity = canonicalize(url);
341
+ out.push({
342
+ token,
343
+ url,
344
+ identity,
345
+ revision: revisionFrom(url),
346
+ classification: identity === null ? 'not-a-repository' : 'repository',
347
+ citedBy,
348
+ });
349
+ }
350
+ return out;
351
+ }
352
+
353
+ /**
354
+ * The pinned revision, when the stored link carries one (`…@<sha>`, or a
355
+ * `/tree/<sha>` deep link). `null` means unpinned, which is a distinct
356
+ * identity from any pinned one — see `dedupe`.
357
+ */
358
+ export function revisionFrom(url: string): string | null {
359
+ const at = /@([0-9a-f]{7,40})\s*$/i.exec(url);
360
+ if (at) {
361
+ return (at[1] as string).toLowerCase();
362
+ }
363
+ const tree = /\/(?:tree|commit|blob)\/([0-9a-f]{7,40})(?:\/|$)/i.exec(url);
364
+ return tree ? (tree[1] as string).toLowerCase() : null;
365
+ }
366
+
367
+ /**
368
+ * One entry per identity **plus** pinned revision.
369
+ *
370
+ * Never per token: encryption is randomised, so two tokens for one repository
371
+ * never compare equal and a token-keyed dedupe would report one repository
372
+ * twice. And never per identity alone: one repository at two historical pins is
373
+ * two pieces of evidence, so collapsing them would silently drop one.
374
+ */
375
+ export function dedupe(resolved: readonly Resolved[], now: Date = new Date()): Entry[] {
376
+ const stamp = now.toISOString().slice(0, 10);
377
+ const byKey = new Map<string, Entry>();
378
+ const out: Entry[] = [];
379
+ for (const r of resolved) {
380
+ if (r.classification === 'unresolvable') {
381
+ out.push(mkEntry(r, 'blocked-as-unresolvable', stamp));
382
+ continue;
383
+ }
384
+ if (r.classification === 'not-a-repository') {
385
+ out.push(mkEntry(r, 'skipped-as-not-a-repository', stamp));
386
+ continue;
387
+ }
388
+ const key = `${r.identity as string}@${r.revision ?? ''}`;
389
+ const seen = byKey.get(key);
390
+ if (seen) {
391
+ seen.tokens += 1;
392
+ for (const f of r.citedBy) {
393
+ if (!seen.cited_by.includes(f)) {
394
+ seen.cited_by.push(f);
395
+ }
396
+ }
397
+ seen.cited_by.sort();
398
+ continue;
399
+ }
400
+ const e = mkEntry(r, 'pending', stamp);
401
+ byKey.set(key, e);
402
+ out.push(e);
403
+ }
404
+ return out;
405
+ }
406
+
407
+ function mkEntry(r: Resolved, status: Status, stamp: string): Entry {
408
+ return {
409
+ opaque_id: r.identity === null ? '' : opaqueId(r.identity, r.revision ?? 'HEAD'),
410
+ identity: r.identity,
411
+ revision: r.revision,
412
+ status,
413
+ cited_by: r.citedBy.slice().sort(),
414
+ tokens: 1,
415
+ contract_version: CONTRACT_VERSION,
416
+ loops: 3,
417
+ focus: null,
418
+ observed_at: stamp,
419
+ };
420
+ }
421
+
422
+ /**
423
+ * The freshness key: identity, upstream revision, contract version, loop count
424
+ * and focus.
425
+ *
426
+ * **This package's own revision is deliberately absent.** Including it would
427
+ * change the key on every commit here, so every analysis would be permanently
428
+ * stale and `skipped-as-fresh` would be unreachable — a freshness rule that
429
+ * never fires is not a freshness rule.
430
+ */
431
+ export function freshnessKey(e: Entry): string {
432
+ return [e.identity ?? '', e.revision ?? '', String(e.contract_version), String(e.loops), e.focus ?? ''].join('|');
433
+ }
434
+
435
+ /** Read a manifest. A missing file is an empty manifest, never an error. */
436
+ export function readManifest(file: string): Entry[] {
437
+ let text: string;
438
+ try {
439
+ text = fs.readFileSync(file, 'utf-8');
440
+ } catch {
441
+ return [];
442
+ }
443
+ const out: Entry[] = [];
444
+ for (const line of text.split('\n')) {
445
+ if (line.trim() === '') {
446
+ continue;
447
+ }
448
+ try {
449
+ out.push(JSON.parse(line) as Entry);
450
+ } catch {
451
+ continue;
452
+ }
453
+ }
454
+ return out;
455
+ }
456
+
457
+ /** Serialise one entry per line, ordered so two runs write identical bytes. */
458
+ export function renderManifest(entries: readonly Entry[]): string {
459
+ const keyed = entries.slice().sort((a, b) => (freshnessKey(a) < freshnessKey(b) ? -1 : 1));
460
+ return keyed.map((e) => JSON.stringify(e)).join('\n') + '\n';
461
+ }
462
+
463
+ /** A resume decision for one entry, with the reason the run must print. */
464
+ export interface ResumeDecision {
465
+ entry: Entry;
466
+ run: boolean;
467
+ reason: string;
468
+ }
469
+
470
+ /**
471
+ * Merge a fresh discovery against a prior manifest.
472
+ *
473
+ * An entry the prior manifest already carries as `done` is `skipped-as-fresh`
474
+ * when its freshness key is unchanged — that is what makes an interrupted batch
475
+ * resumable without re-running a completed repository. `--refresh` overrides
476
+ * the skip and nothing else.
477
+ */
478
+ export function planResume(
479
+ fresh: readonly Entry[],
480
+ prior: readonly Entry[],
481
+ refresh = false,
482
+ ): ResumeDecision[] {
483
+ const done = new Map<string, Entry>();
484
+ for (const p of prior) {
485
+ if (p.status === 'done') {
486
+ done.set(freshnessKey(p), p);
487
+ }
488
+ }
489
+ const out: ResumeDecision[] = [];
490
+ for (const e of fresh) {
491
+ if (e.status === 'skipped-as-not-a-repository') {
492
+ out.push({ entry: e, run: false, reason: 'not a repository — classification read the resolved URL' });
493
+ continue;
494
+ }
495
+ if (e.status === 'blocked-as-unresolvable') {
496
+ out.push({ entry: e, run: false, reason: 'token did not decrypt with any configured key' });
497
+ continue;
498
+ }
499
+ const hit = done.get(freshnessKey(e));
500
+ if (hit && !refresh) {
501
+ out.push({ entry: { ...e, status: 'skipped-as-fresh' }, run: false, reason: `already done at ${hit.observed_at} — freshness key unchanged` });
502
+ continue;
503
+ }
504
+ out.push({ entry: e, run: true, reason: refresh && hit ? 're-run requested by --refresh' : 'pending' });
505
+ }
506
+ return out;
507
+ }
508
+
509
+ /** What the fold pass decided for one capability gap. */
510
+ export type Disposition =
511
+ | 'no-action'
512
+ | 'already-planned'
513
+ | 'extend-existing'
514
+ | 'create-follow-up'
515
+ | 'create-new'
516
+ | 'contested';
517
+
518
+ /** One repository's contribution to one capability gap. */
519
+ export interface Candidate {
520
+ opaque_id: string;
521
+ /** Kebab-case slug naming what THIS package lacks — never the reference. */
522
+ gap: string;
523
+ }
524
+
525
+ /** One gap after the fold. `sources` is why three repositories are not three roadmaps. */
526
+ export interface Fold {
527
+ gap: string;
528
+ roadmap: string | null;
529
+ sources: string[];
530
+ disposition: Disposition;
531
+ }
532
+
533
+ /**
534
+ * Is a proposed roadmap filename free of every source's identity?
535
+ *
536
+ * The check is mechanical because the failure is mechanical: a tracked filename
537
+ * is a published string, and `adopt-<owner>-<repo>.md` publishes the one thing
538
+ * the confidentiality programme exists to withhold, in the surface no
539
+ * `.gitignore` can retract. Compares against each identity's host, owner and
540
+ * repository segments separately — `owner-repo` in a filename is caught by the
541
+ * segment test even though the joined string never appears in the identity.
542
+ */
543
+ export function roadmapNameIsSourceFree(name: string, identities: readonly string[]): boolean {
544
+ const hay = path.basename(name).toLowerCase();
545
+ for (const id of identities) {
546
+ for (const seg of id.toLowerCase().split(/[/.]/)) {
547
+ if (seg.length >= 4 && hay.includes(seg)) {
548
+ return false;
549
+ }
550
+ }
551
+ }
552
+ return true;
553
+ }
554
+
555
+ /**
556
+ * Fold candidates onto one roadmap per capability gap.
557
+ *
558
+ * **Three repositories showing one gap strengthen the evidence on one owning
559
+ * roadmap; they never create three.** That inversion is the whole reason a
560
+ * harvester exists rather than a loop that lands whatever each analysis
561
+ * returns: the estate's growth budget is spent on gaps, and a gap is a property
562
+ * of this package, so the count of references that happened to reveal it is
563
+ * evidence about the gap and not a multiplier on it.
564
+ *
565
+ * A gap an active roadmap already owns folds onto that owner rather than
566
+ * opening a second entry, and a proposed name carrying a source identity is
567
+ * returned `contested` rather than silently renamed — a rename would hide the
568
+ * fact that the finding was understood only as "what they have".
569
+ */
570
+ export function foldCandidates(
571
+ candidates: readonly Candidate[],
572
+ ownedGaps: ReadonlyMap<string, string> = new Map(),
573
+ identitiesById: ReadonlyMap<string, string> = new Map(),
574
+ ): Fold[] {
575
+ const byGap = new Map<string, string[]>();
576
+ for (const c of candidates) {
577
+ const l = byGap.get(c.gap) ?? [];
578
+ if (!l.includes(c.opaque_id)) {
579
+ l.push(c.opaque_id);
580
+ }
581
+ byGap.set(c.gap, l);
582
+ }
583
+ const out: Fold[] = [];
584
+ for (const gap of [...byGap.keys()].sort()) {
585
+ const sources = (byGap.get(gap) as string[]).slice().sort();
586
+ const owner = ownedGaps.get(gap);
587
+ if (owner !== undefined) {
588
+ out.push({ gap, roadmap: owner, sources, disposition: 'already-planned' });
589
+ continue;
590
+ }
591
+ const name = `agents/roadmaps/road-to-${gap}.md`;
592
+ const ids = sources.map((id) => identitiesById.get(id)).filter((x): x is string => x !== undefined);
593
+ if (!roadmapNameIsSourceFree(name, ids)) {
594
+ out.push({ gap, roadmap: null, sources, disposition: 'contested' });
595
+ continue;
596
+ }
597
+ out.push({ gap, roadmap: name, sources, disposition: 'create-new' });
598
+ }
599
+ return out;
600
+ }
601
+
602
+ function renderCensus(c: Census): string {
603
+ const lv = ESTATE_LEVELS.map((l) => `${l}=${c.byLevel[l]}`).join(' ');
604
+ return [
605
+ `token occurrences: ${c.occurrences}`,
606
+ `unique tokens: ${c.uniqueTokens}`,
607
+ `files: ${c.files}`,
608
+ `files by level: ${lv}`,
609
+ ].join('\n');
610
+ }
611
+
612
+ export function main(argv: readonly string[]): number {
613
+ const has = (f: string): boolean => argv.includes(f);
614
+ const val = (f: string): string | null => {
615
+ const i = argv.indexOf(f);
616
+ return i >= 0 && argv[i + 1] !== undefined ? (argv[i + 1] as string) : null;
617
+ };
618
+ const occ = discover(ROOT);
619
+ const c = census(occ);
620
+
621
+ if (has('--dry-run') || argv.length === 0) {
622
+ process.stdout.write(`${renderCensus(c)}\nscanned: ${estateFiles(ROOT).length} roadmap file(s)\n`);
623
+ return 0;
624
+ }
625
+
626
+ const keys = resolve_keys(ROOT);
627
+ if (keys.length === 0) {
628
+ process.stderr.write(
629
+ '❌ no link-encryption key resolvable — refusing to classify.\n' +
630
+ ' Without a key every token is unresolvable, and a classification of\n' +
631
+ ' zero repositories would be indistinguishable from a real absence.\n' +
632
+ ' Set secrets.link_encryption_key in .agent-settings.yml (project or\n' +
633
+ ' user-global) or EVENT4U_LINK_KEY, then re-run.\n',
634
+ );
635
+ return 2;
636
+ }
637
+
638
+ const resolved = resolveAll(occ, keys);
639
+ const entries = dedupe(resolved);
640
+
641
+ if (has('--json')) {
642
+ if (!process.stdout.isTTY && process.env.HARVEST_ALLOW_NONTTY !== '1') {
643
+ process.stderr.write(
644
+ '❌ --json prints resolved source URLs and stdout is not a terminal.\n' +
645
+ ' Refusing: a redirect would write plaintext source attribution to a\n' +
646
+ ' file. Run it at a terminal, or set HARVEST_ALLOW_NONTTY=1 knowing\n' +
647
+ ' the destination is inside the gitignored local area.\n',
648
+ );
649
+ return 2;
650
+ }
651
+ process.stdout.write(`${JSON.stringify({ census: c, entries }, null, 2)}\n`);
652
+ return 0;
653
+ }
654
+
655
+ const manifestPath = path.resolve(ROOT, val('--manifest') ?? MANIFEST);
656
+ const prior = has('--resume') ? readManifest(manifestPath) : [];
657
+ const plan = planResume(entries, prior, has('--refresh'));
658
+ const limit = Number(val('--limit') ?? '0');
659
+ const runnable = plan.filter((d) => d.run);
660
+ const capped = limit > 0 ? runnable.slice(0, limit) : runnable;
661
+
662
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
663
+ fs.writeFileSync(manifestPath, renderManifest(plan.map((d) => d.entry)), 'utf-8');
664
+
665
+ const counts = new Map<string, number>();
666
+ for (const d of plan) {
667
+ counts.set(d.entry.status, (counts.get(d.entry.status) ?? 0) + 1);
668
+ }
669
+ process.stdout.write(`${renderCensus(c)}\n`);
670
+ process.stdout.write(`manifest: ${path.relative(ROOT, manifestPath)}\n`);
671
+ for (const s of STATUSES) {
672
+ const n = counts.get(s) ?? 0;
673
+ if (n > 0) {
674
+ process.stdout.write(` ${s}: ${n}\n`);
675
+ }
676
+ }
677
+ process.stdout.write(`to run: ${capped.length}${limit > 0 && runnable.length > capped.length ? ` (of ${runnable.length}, --limit ${limit})` : ''}\n`);
678
+ return 0;
679
+ }
680
+
681
+ /** Fixture self-test — the shapes the roadmap's Phase 4 verifies name. */
682
+ export function selfTest(): number {
683
+ const id1 = opaqueId('github.com/o/r', 'abc1234');
684
+ if (id1 !== opaqueId('github.com/o/r', 'abc1234') || id1 === opaqueId('github.com/o/r', 'def5678')) {
685
+ process.stderr.write('selfTest: opaque id is not stable-and-revision-sensitive\n');
686
+ return 1;
687
+ }
688
+ if (canonicalize('https://example.org/guide/intro') !== null) {
689
+ process.stderr.write('selfTest: a documentation URL classified as a repository\n');
690
+ return 1;
691
+ }
692
+ process.stdout.write('selfTest: ok\n');
693
+ return 0;
694
+ }
695
+
696
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
697
+ process.exit(process.argv.includes('--self-test') ? selfTest() : main(process.argv.slice(2)));
698
+ }