@event4u/agent-config 17.3.0 → 17.4.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 (137) 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 +164 -342
  5. package/CREDITS.md +2 -2
  6. package/README.md +1 -1
  7. package/dist/agent-src/skills/code-intelligence/SKILL.md +3 -0
  8. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  9. package/dist/cli/registry.js +2 -2
  10. package/dist/cli-delegate/{chunk-DHSSP7V3.js → chunk-5AYJGUA3.js} +0 -2
  11. package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
  12. package/dist/cli-delegate/{chunk-MT2WOOCY.js → chunk-WY5ZZPGL.js} +2 -2
  13. package/dist/cli-delegate/{chunk-ORJPBER2.js → chunk-X6LZ7W5A.js} +73 -10
  14. package/dist/cli-delegate/cmd_conformance.js +175 -183
  15. package/dist/cli-delegate/cmd_doctor.js +5 -5
  16. package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
  17. package/dist/cli-delegate/cmd_explain.js +1 -1
  18. package/dist/cli-delegate/cmd_handoff.js +2 -2
  19. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  21. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  22. package/dist/cli-delegate/cmd_update.js +24 -13
  23. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  24. package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
  25. package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
  26. package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
  27. package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +3 -3
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/code_graph_refresh.chunk.cjs +56 -27
  38. package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -1
  39. package/dist/hooks/dispatch.js +982 -929
  40. package/dist/hooks/dispatch.sha256 +1 -1
  41. package/dist/hooks/egress_engine.chunk.cjs +56 -6
  42. package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
  43. package/dist/hooks/update_notice.chunk.cjs +914 -0
  44. package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
  45. package/dist/mcp/registry-manifest.json +1 -1
  46. package/dist/mcp/server.json +2 -2
  47. package/dist/mcp/server.mjs +71259 -66437
  48. package/dist/scripts/condense.js +9 -6
  49. package/package.json +4 -1
  50. package/src/config/evaluator-budgets.json +9 -8
  51. package/src/config/gate-violation-baselines.json +2 -2
  52. package/src/config/hook-bundle-budget.json +31 -6
  53. package/src/config/packed-binary-manifest.json +9 -0
  54. package/src/config/sigstore/npm_keys.json +50 -0
  55. package/src/config/sigstore/trust-material.json +9 -0
  56. package/src/config/sigstore/trusted_root.json +126 -0
  57. package/src/config/skill-body-budget.json +7 -1
  58. package/src/scripts/_cli/cmd_update.ts +48 -20
  59. package/src/scripts/_cli/cmd_upgrade.ts +7 -1
  60. package/src/scripts/_cli/upgrade_unattended.ts +90 -76
  61. package/src/scripts/_dispatch.bash +3 -2
  62. package/src/scripts/_lib/confusables.ts +0 -19
  63. package/src/scripts/_lib/egress_normalize.ts +53 -23
  64. package/src/scripts/_lib/egress_text.ts +5 -3
  65. package/src/scripts/_lib/egress_token.ts +7 -5
  66. package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
  67. package/src/scripts/_lib/git_common_dir.ts +10 -0
  68. package/src/scripts/_lib/graph_feeder_record.ts +3 -2
  69. package/src/scripts/_lib/install_reach_checks.ts +32 -2
  70. package/src/scripts/_lib/lineage_backfill.ts +136 -0
  71. package/src/scripts/_lib/lineage_ledger.ts +211 -0
  72. package/src/scripts/_lib/lineage_map.ts +55 -0
  73. package/src/scripts/_lib/lineage_query.ts +140 -0
  74. package/src/scripts/_lib/lineage_seed.ts +116 -0
  75. package/src/scripts/_lib/lineage_watch.ts +249 -0
  76. package/src/scripts/_lib/link_crypto.ts +8 -4
  77. package/src/scripts/_lib/production_reach.ts +3 -3
  78. package/src/scripts/_lib/project_registry.ts +227 -0
  79. package/src/scripts/_lib/provenance_verify.ts +14 -242
  80. package/src/scripts/_lib/sigstore_provenance.ts +177 -0
  81. package/src/scripts/_lib/source_id.ts +48 -0
  82. package/src/scripts/_lib/source_shape.ts +4 -0
  83. package/src/scripts/_lib/unattended_gates.ts +6 -1
  84. package/src/scripts/advisories.ts +26 -10
  85. package/src/scripts/ai_council/council_egress.ts +5 -6
  86. package/src/scripts/bench_graph_feeder_latency.ts +10 -3
  87. package/src/scripts/check_dependency_floors.ts +7 -0
  88. package/src/scripts/check_egress_mutations.ts +1 -0
  89. package/src/scripts/code_graph/build.ts +67 -3
  90. package/src/scripts/code_graph/cli.ts +131 -28
  91. package/src/scripts/code_graph/conventions_default.ts +20 -1
  92. package/src/scripts/code_graph/detect.ts +5 -5
  93. package/src/scripts/code_graph/estate_cli.ts +135 -0
  94. package/src/scripts/code_graph/estate_index.ts +394 -0
  95. package/src/scripts/code_graph/estate_manifests.ts +223 -0
  96. package/src/scripts/code_graph/estate_map.ts +141 -0
  97. package/src/scripts/code_graph/extract.ts +4 -1
  98. package/src/scripts/code_graph/extract_more.ts +137 -2
  99. package/src/scripts/code_graph/graph_budget.ts +158 -0
  100. package/src/scripts/code_graph/graph_home.ts +80 -0
  101. package/src/scripts/code_graph/lineage_cli.ts +315 -0
  102. package/src/scripts/code_graph/map.ts +22 -13
  103. package/src/scripts/code_graph/map_model.ts +67 -12
  104. package/src/scripts/code_graph/map_verify.ts +68 -5
  105. package/src/scripts/code_graph/map_viewer/viewer.css +12 -0
  106. package/src/scripts/code_graph/map_viewer/viewer.js +45 -15
  107. package/src/scripts/code_graph/project_ref.ts +25 -0
  108. package/src/scripts/code_graph/projects_cli.ts +254 -0
  109. package/src/scripts/code_graph/providers/catalog.ts +14 -5
  110. package/src/scripts/code_graph/providers/django.ts +52 -9
  111. package/src/scripts/code_graph/providers/index.ts +2 -0
  112. package/src/scripts/code_graph/providers/rails.ts +204 -0
  113. package/src/scripts/code_graph/providers/spring.ts +38 -1
  114. package/src/scripts/code_graph/query.ts +9 -9
  115. package/src/scripts/code_graph/refresh_lock.ts +12 -8
  116. package/src/scripts/code_graph/repo_identity.ts +169 -0
  117. package/src/scripts/code_graph/sqlite_store.ts +22 -25
  118. package/src/scripts/code_graph/types.ts +6 -2
  119. package/src/scripts/condense.ts +6 -6
  120. package/src/scripts/hook_chunks.mjs +7 -0
  121. package/src/scripts/hook_manifest.json +1 -1
  122. package/src/scripts/hook_manifest.yaml +20 -17
  123. package/src/scripts/hooks/code_graph_refresh_hook.ts +10 -7
  124. package/src/scripts/hooks/dispatch_hook.ts +6 -7
  125. package/src/scripts/hooks/host_semantics.ts +72 -0
  126. package/src/scripts/hooks/lazy_concern.ts +4 -0
  127. package/src/scripts/hooks/user_notice.ts +105 -0
  128. package/src/scripts/lint_harvest_provenance.ts +137 -11
  129. package/src/scripts/mcp_server/consumer_tool_catalog.json +69 -15
  130. package/src/scripts/mcp_server/graph_tools.ts +161 -41
  131. package/src/scripts/network_sources.ts +24 -6
  132. package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
  133. package/src/scripts/update_notice_hook.ts +75 -7
  134. package/src/vendor/grammars/README.md +7 -6
  135. package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
  136. /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
  137. /package/dist/cli-delegate/{chunk-BMGUGSY6.js → chunk-TQG6UMPC.js} +0 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Seed the two lineage ledgers from the provenance stores that already exist.
3
+ *
4
+ * Tracked side: one `mechanisms.jsonl` row per `harvests.jsonl` row — id, verdict, the
5
+ * asserting artefact and the date carried over, nothing else. `harvests.jsonl` stays, so
6
+ * every citation keeps resolving.
7
+ *
8
+ * Local side: every harvest `source_ref` and every `skills-provenance.yml` entry becomes a
9
+ * sealed source plus an edge to its mechanism. Nothing is invented on the way: an opaque
10
+ * harvest ref has no revision, so its edge records `pin: unknown`; a skills entry keeps the
11
+ * sealed pin it already carries. The tracked yml is read, never written.
12
+ */
13
+
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+
17
+ import { parse as parseYaml } from 'yaml';
18
+
19
+ import { canonicalize } from '../harvest_reference_tokens.js';
20
+ import type { HarvestRecord, MechanismRecord } from '../lint_harvest_provenance.js';
21
+ import { PIN_UNKNOWN, type EdgeRow, type Ledger, type SourceKind, type SourceRow } from './lineage_ledger.js';
22
+ import { decrypt, encrypt, is_token } from './link_crypto.js';
23
+ import { sourceId } from './source_id.js';
24
+
25
+ export const HARVESTS_REL = 'provenance/harvests.jsonl';
26
+ export const SKILLS_PROVENANCE_REL = 'agents/settings/contexts/skills-provenance.yml';
27
+
28
+ export function seedMechanismsFromHarvests(harvests: readonly HarvestRecord[]): MechanismRecord[] {
29
+ return harvests.map((h) => ({
30
+ mechanism_id: h.harvest_id,
31
+ verdict: h.verdict,
32
+ realised_in: [h.stated_in],
33
+ decided_at: h.harvested_at,
34
+ }));
35
+ }
36
+
37
+ const PINNED_REF_RE = /^(.+)@([0-9a-fA-F]{7,64})$/;
38
+
39
+ function kindOf(identity: string): SourceKind {
40
+ if (identity.startsWith('opaque:')) return 'unknown';
41
+ return canonicalize(identity) === null ? 'url' : 'repo';
42
+ }
43
+
44
+ export interface ImportResult {
45
+ readonly ledger: Ledger;
46
+ /** One line per entry that could not be imported, naming why. Never a guess in its place. */
47
+ readonly refused: string[];
48
+ }
49
+
50
+ function readHarvests(root: string): HarvestRecord[] {
51
+ const file = path.join(root, HARVESTS_REL);
52
+ if (!fs.existsSync(file)) return [];
53
+ return fs
54
+ .readFileSync(file, 'utf-8')
55
+ .split('\n')
56
+ .filter((l) => l.trim() !== '')
57
+ .map((l) => JSON.parse(l) as HarvestRecord);
58
+ }
59
+
60
+ /** Build the local rows for `root` without writing anything. `keys[0]` seals; all keys decrypt. */
61
+ export function buildLocalImport(root: string, keys: readonly string[]): ImportResult {
62
+ const key = keys[0] as string;
63
+ const sources = new Map<string, SourceRow>();
64
+ const edges: EdgeRow[] = [];
65
+ const refused: string[] = [];
66
+ const addSource = (identity: string, sealed: string, license: string): string => {
67
+ const id = sourceId(identity, key);
68
+ if (!sources.has(id)) sources.set(id, { source_id: id, kind: kindOf(identity), identity: sealed, license, watch: false });
69
+ return id;
70
+ };
71
+
72
+ for (const h of readHarvests(root)) {
73
+ let identity = h.source_ref;
74
+ let pin: string = PIN_UNKNOWN;
75
+ if (is_token(h.source_ref)) {
76
+ try {
77
+ identity = decrypt(h.source_ref, [...keys]);
78
+ } catch (e) {
79
+ refused.push(`${h.harvest_id}: source_ref does not decrypt with the configured key (${(e as Error).message})`);
80
+ continue;
81
+ }
82
+ } else if (!identity.startsWith('opaque:')) {
83
+ const m = PINNED_REF_RE.exec(identity);
84
+ if (m) {
85
+ identity = m[1] as string;
86
+ pin = encrypt(m[2] as string, key);
87
+ }
88
+ }
89
+ const id = addSource(identity, encrypt(identity, key), 'unknown');
90
+ edges.push({ source_id: id, mechanism_id: h.harvest_id, pin, locator: encrypt(h.evidence_locator, key) });
91
+ }
92
+
93
+ const ymlFile = path.join(root, SKILLS_PROVENANCE_REL);
94
+ if (fs.existsSync(ymlFile)) {
95
+ const doc = (parseYaml(fs.readFileSync(ymlFile, 'utf-8')) ?? {}) as Record<string, Record<string, unknown> | null>;
96
+ for (const [skill, entry] of Object.entries(doc)) {
97
+ const sealedSource = entry?.source_enc;
98
+ if (typeof sealedSource !== 'string' || !is_token(sealedSource)) {
99
+ refused.push(`${skill}: no sealed source_enc`);
100
+ continue;
101
+ }
102
+ let identity: string;
103
+ try {
104
+ identity = decrypt(sealedSource, [...keys]);
105
+ } catch (e) {
106
+ refused.push(`${skill}: source_enc does not decrypt with the configured key (${(e as Error).message})`);
107
+ continue;
108
+ }
109
+ const pinned = entry?.pinned_to_enc;
110
+ const license = typeof entry?.license === 'string' && entry.license !== '' ? entry.license : 'unknown';
111
+ const id = addSource(identity, sealedSource, license);
112
+ edges.push({ source_id: id, mechanism_id: skill, pin: typeof pinned === 'string' && is_token(pinned) ? pinned : PIN_UNKNOWN, locator: null });
113
+ }
114
+ }
115
+ return { ledger: { sources: [...sources.values()], edges }, refused };
116
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The upstream delta watch — one local run that turns an upstream move into findings.
3
+ *
4
+ * Per source the owner switched on (`watch: true`, off by default — council O7): one
5
+ * `git ls-remote`; if HEAD is the newest recorded pin (a full or short SHA, or a tag
6
+ * resolved through `ls-remote`), stop there. Otherwise a depth-1
7
+ * fetch of both commits into a private cache under the user-global home, then two diffs —
8
+ * the files under the edges' locators, and the top-level listing — classified as:
9
+ *
10
+ * reverify a locator's file changed under an adopted or adapted mechanism
11
+ * reopen a locator's file changed under a rejected or deferred mechanism
12
+ * drift a locator's file was deleted or renamed away
13
+ * candidate a new top-level entry, which no mechanism has looked at yet
14
+ * license-alert a top-level license file changed
15
+ *
16
+ * It never runs under CI (the key and the identities are local, CI logs are not), never
17
+ * fetches without consent, and never writes code: the output is a report and a stub.
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process';
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
23
+
24
+ import type { MechanismRecord } from '../lint_harvest_provenance.js';
25
+ import { gitEnv } from './git_env.js';
26
+ import { assertOutsideWorkTree, LineageError, PIN_UNKNOWN, readLedger, type SourceRow } from './lineage_ledger.js';
27
+ import { latestDecisions } from './lineage_query.js';
28
+ import { decrypt, encrypt } from './link_crypto.js';
29
+ import type { EnvMap } from './user_global_paths.js';
30
+
31
+ export type GitRunner = (args: string[], cwd?: string) => string;
32
+
33
+ const GIT_TIMEOUT_MS = 120_000;
34
+
35
+ export const systemGit: GitRunner = (args, cwd) =>
36
+ execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'protocol.ext.allow=never', '-c', 'http.followRedirects=false', ...args], {
37
+ cwd,
38
+ encoding: 'utf-8',
39
+ stdio: ['ignore', 'pipe', 'pipe'],
40
+ timeout: GIT_TIMEOUT_MS,
41
+ env: { ...gitEnv(), GIT_TERMINAL_PROMPT: '0' },
42
+ });
43
+
44
+ export type FindingClass = 'reverify' | 'reopen' | 'drift' | 'candidate' | 'license-alert';
45
+
46
+ export interface WatchFinding {
47
+ readonly class: FindingClass;
48
+ /** null for a candidate or a license alert — no mechanism has looked at it. */
49
+ readonly mechanism_id: string | null;
50
+ /** Plaintext, local only: the locator or top-level entry. */
51
+ readonly locator: string;
52
+ }
53
+
54
+ export interface SourceReport {
55
+ readonly source_id: string;
56
+ readonly identity: string;
57
+ readonly status: 'unchanged' | 'changed' | 'unpinned' | 'not-watchable' | 'error';
58
+ readonly pin: string | null;
59
+ readonly head: string | null;
60
+ readonly findings: WatchFinding[];
61
+ /** Mechanisms the source fed, for a license alert that concerns all of them. */
62
+ readonly mechanisms: string[];
63
+ readonly note?: string;
64
+ }
65
+
66
+ export interface WatchOptions {
67
+ readonly dir: string;
68
+ readonly keys: readonly string[];
69
+ readonly rows: readonly MechanismRecord[];
70
+ readonly git: GitRunner;
71
+ readonly env: EnvMap;
72
+ }
73
+
74
+ export function refuseUnderCi(env: EnvMap): void {
75
+ if (env.CI !== undefined && env.CI !== '' && env.CI !== 'false' && env.CI !== '0') {
76
+ throw new LineageError('the lineage watch never runs under CI — the key and the source identities are local, CI logs are not');
77
+ }
78
+ }
79
+
80
+ const LICENSE_RE = /^(licen[cs]e|copying)(\.[a-z0-9]+)?$/i;
81
+
82
+ /** A locator names a path, optionally suffixed `:<line>` or `#<anchor>`. */
83
+ function locatorPath(locator: string): string {
84
+ return locator.replace(/#.*$/, '').replace(/:\d+(-\d+)?$/, '').replace(/^\.\//, '').replace(/\/+$/, '');
85
+ }
86
+
87
+ const under = (file: string, loc: string): boolean => file === loc || file.startsWith(`${loc}/`);
88
+
89
+ /** The commit a tag names on the remote, peeled through an annotated tag; null when absent. */
90
+ function tagCommit(git: GitRunner, identity: string, tag: string): string | null {
91
+ if (tag.startsWith('-')) return null;
92
+ const refs = new Map<string, string>();
93
+ // The trailing glob is what makes ls-remote print the peeled `^{}` line; the exact match is below.
94
+ for (const line of git(['ls-remote', '--tags', identity, `refs/tags/${tag}*`]).split('\n')) {
95
+ const [sha, ref] = line.trim().split(/\s+/);
96
+ if (sha && ref) refs.set(ref, sha);
97
+ }
98
+ const sha = refs.get(`refs/tags/${tag}^{}`) ?? refs.get(`refs/tags/${tag}`) ?? null;
99
+ return sha !== null && /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
100
+ }
101
+
102
+ function watchOne(source: SourceRow, opts: WatchOptions): SourceReport {
103
+ const { keys, git } = opts;
104
+ const ledger = readLedger(opts.dir);
105
+ // The ledger is append-only, so only a mechanism's newest edge speaks for it.
106
+ const edges = [...new Map(ledger.edges.filter((e) => e.source_id === source.source_id).map((e) => [e.mechanism_id, e])).values()];
107
+ const mechanisms = [...new Set(edges.map((e) => e.mechanism_id))];
108
+ const identity = decrypt(source.identity, [...keys]);
109
+ const base = { source_id: source.source_id, identity, mechanisms };
110
+ if (source.kind !== 'repo') {
111
+ return { ...base, status: 'not-watchable', pin: null, head: null, findings: [], note: `kind ${source.kind} has no revision to compare` };
112
+ }
113
+ const pinned = edges.filter((e) => e.pin !== PIN_UNKNOWN);
114
+ if (pinned.length === 0) {
115
+ return { ...base, status: 'unpinned', pin: null, head: null, findings: [], note: 'no edge records a pin, so there is nothing to compare against' };
116
+ }
117
+ const pin = decrypt((pinned[pinned.length - 1] as { pin: string }).pin, [...keys]);
118
+
119
+ if (identity.startsWith('-')) return { ...base, status: 'error', pin, head: null, findings: [], note: 'identity is not a repository location' };
120
+ const head = (git(['ls-remote', identity, 'HEAD']).split(/\s+/)[0] ?? '').trim();
121
+ if (!/^[0-9a-f]{7,64}$/.test(head)) return { ...base, status: 'error', pin, head: null, findings: [], note: 'ls-remote returned no HEAD' };
122
+ const hexPin = /^[0-9a-f]{7,64}$/i.test(pin);
123
+ let pinRev = pin.toLowerCase();
124
+ if (!hexPin) {
125
+ const peeled = tagCommit(git, identity, pin);
126
+ if (peeled === null) return { ...base, status: 'error', pin, head, findings: [], note: `pin ${pin} is neither a commit nor a tag the remote has` };
127
+ pinRev = peeled;
128
+ }
129
+ if (head.startsWith(pinRev)) return { ...base, status: 'unchanged', pin, head, findings: [] };
130
+
131
+ const cache = path.join(opts.dir, 'cache', source.source_id);
132
+ // The cache is a repository of its own; what must sit outside every work tree is its container.
133
+ assertOutsideWorkTree(path.dirname(cache));
134
+ if (!fs.existsSync(path.join(cache, '.git'))) {
135
+ fs.mkdirSync(cache, { recursive: true, mode: 0o700 });
136
+ git(['init', '-q'], cache);
137
+ }
138
+ git(['fetch', '-q', '--depth', '1', '--no-tags', identity, head], cache);
139
+ if (pinRev.length >= 40) git(['fetch', '-q', '--depth', '1', '--no-tags', identity, pinRev], cache);
140
+ else {
141
+ // A remote serves no object by an abbreviated name, so a short pin is found in HEAD's history.
142
+ const shallow = git(['rev-parse', '--is-shallow-repository'], cache).trim() === 'true';
143
+ git(['fetch', '-q', '--no-tags', ...(shallow ? ['--unshallow'] : []), identity, head], cache);
144
+ pinRev = git(['rev-parse', '--verify', '-q', `${pinRev}^{commit}`], cache).trim();
145
+ }
146
+
147
+ const changed = new Map<string, string>();
148
+ for (const line of git(['diff', '--name-status', '-M', pinRev, head], cache).split('\n')) {
149
+ const [status, from] = line.split('\t');
150
+ if (status && from) changed.set(from, status.charAt(0));
151
+ }
152
+ const listing = (rev: string): Set<string> => new Set(git(['ls-tree', '--name-only', rev], cache).split('\n').filter((x) => x !== ''));
153
+ const before = listing(pinRev);
154
+ const after = listing(head);
155
+
156
+ const latest = latestDecisions(opts.rows);
157
+ const findings: WatchFinding[] = [];
158
+ for (const e of edges) {
159
+ if (e.locator === null) continue;
160
+ const locator = decrypt(e.locator, [...keys]);
161
+ const loc = locatorPath(locator);
162
+ const hits = [...changed.entries()].filter(([f]) => under(f, loc));
163
+ if (hits.length === 0) continue;
164
+ const gone = hits.every(([, s]) => s === 'D' || s === 'R');
165
+ const verdict = latest.get(e.mechanism_id)?.verdict;
166
+ const cls: FindingClass = gone ? 'drift' : verdict === 'reject' || verdict === 'deferred' ? 'reopen' : 'reverify';
167
+ findings.push({ class: cls, mechanism_id: e.mechanism_id, locator });
168
+ }
169
+ for (const entry of [...after].sort()) if (!before.has(entry)) findings.push({ class: 'candidate', mechanism_id: null, locator: entry });
170
+ for (const [f] of changed) if (!f.includes('/') && LICENSE_RE.test(f)) findings.push({ class: 'license-alert', mechanism_id: null, locator: f });
171
+ return { ...base, status: 'changed', pin, head, findings };
172
+ }
173
+
174
+ /** One run over every watched source. Throws under CI before any git call. */
175
+ export function runWatch(opts: WatchOptions): SourceReport[] {
176
+ refuseUnderCi(opts.env);
177
+ return readLedger(opts.dir)
178
+ .sources.filter((s) => s.watch === true)
179
+ .map((s) => {
180
+ try {
181
+ return watchOne(s, opts);
182
+ } catch (e) {
183
+ if (e instanceof LineageError) throw e;
184
+ return { source_id: s.source_id, identity: '', status: 'error' as const, pin: null, head: null, findings: [], mechanisms: [], note: (e as Error).message.split('\n')[0] ?? 'watch failed' };
185
+ }
186
+ });
187
+ }
188
+
189
+ const CLASS_ORDER: readonly FindingClass[] = ['reopen', 'drift', 'reverify', 'license-alert', 'candidate'];
190
+
191
+ /** The local report: identities in plaintext, so it is written only under the user-global home. */
192
+ export function renderWatchReport(reports: readonly SourceReport[], date: string): string {
193
+ const out = [`# Lineage watch — ${date} (local, never shared)`, ''];
194
+ for (const r of reports) {
195
+ out.push(`## ${r.source_id} — ${r.identity}`, '', `status ${r.status}${r.pin ? ` · pin ${r.pin}` : ''}${r.head ? ` · head ${r.head}` : ''}${r.note ? ` · ${r.note}` : ''}`, '');
196
+ for (const f of [...r.findings].sort((a, b) => CLASS_ORDER.indexOf(a.class) - CLASS_ORDER.indexOf(b.class))) {
197
+ out.push(`- ${f.class} ${f.mechanism_id ?? '(no mechanism)'} — ${f.locator}`);
198
+ }
199
+ out.push('');
200
+ }
201
+ return out.join('\n');
202
+ }
203
+
204
+ /**
205
+ * The tracked stub: mechanism ids and freshly sealed locators, nothing else — no source id,
206
+ * no identity, no pin. One per run, grouped by mechanism rather than by commit, so a busy
207
+ * upstream produces one decision to make per mechanism. Null when there is nothing to plan.
208
+ */
209
+ export function renderWatchStub(reports: readonly SourceReport[], date: string, key: string): string | null {
210
+ const byMechanism = new Map<string, WatchFinding[]>();
211
+ const unowned: WatchFinding[] = [];
212
+ for (const r of reports) {
213
+ for (const f of r.findings) {
214
+ if (f.class === 'license-alert') {
215
+ for (const m of r.mechanisms) byMechanism.set(m, [...(byMechanism.get(m) ?? []), f]);
216
+ } else if (f.mechanism_id === null) unowned.push(f);
217
+ else byMechanism.set(f.mechanism_id, [...(byMechanism.get(f.mechanism_id) ?? []), f]);
218
+ }
219
+ }
220
+ if (byMechanism.size === 0 && unowned.length === 0) return null;
221
+ const review = new Date(`${date}T00:00:00Z`);
222
+ review.setUTCDate(review.getUTCDate() + 90);
223
+ const line = (f: WatchFinding): string => `- ${f.class} — \`${encrypt(f.locator, key)}\``;
224
+ const out = [
225
+ '---',
226
+ 'complexity: lightweight',
227
+ `review_by: ${review.toISOString().slice(0, 10)}`,
228
+ '---',
229
+ '',
230
+ `# Stub: lineage delta of ${date}`,
231
+ '',
232
+ '> **Stub — not active work.** Written by `code-graph lineage watch`. An upstream that a',
233
+ '> recorded mechanism was read from has moved. Nothing was implemented or merged: each',
234
+ '> entry below is a decision for the owner. Locators are sealed (`link_crypto decrypt`);',
235
+ '> the local report under the user-global `runtime/lineage/watch/` has the rest.',
236
+ '',
237
+ ];
238
+ for (const m of [...byMechanism.keys()].sort()) {
239
+ out.push(`## Mechanism \`${m}\``, '', ...(byMechanism.get(m) as WatchFinding[]).map(line), '');
240
+ }
241
+ if (unowned.length > 0) out.push('## New upstream entries no mechanism covers', '', ...unowned.map(line), '');
242
+ out.push(
243
+ '## Kill criteria',
244
+ '',
245
+ '- Every entry above is re-verified, reopened or dismissed in `provenance/mechanisms.jsonl` — then delete this stub.',
246
+ '',
247
+ );
248
+ return out.join('\n');
249
+ }
@@ -58,6 +58,8 @@ import * as path from 'node:path';
58
58
  import process from 'node:process';
59
59
  import { fileURLToPath, pathToFileURL } from 'node:url';
60
60
 
61
+ import { sourceId } from './source_id.js';
62
+
61
63
  export const MAGIC = 'ENC1';
62
64
  const _SALT_LEN = 16;
63
65
  const _NONCE_LEN = 16;
@@ -232,19 +234,19 @@ export class ValueError extends Error {}
232
234
  export function _cli(argv: readonly string[]): number {
233
235
  const prog = 'link_crypto';
234
236
  const cmd = argv[0];
235
- const validCmds = new Set(['encrypt', 'decrypt', 'keygen', 'keystatus']);
237
+ const validCmds = new Set(['encrypt', 'decrypt', 'keygen', 'keystatus', 'source-id']);
236
238
 
237
239
  // argparse: subparser required → error when missing/invalid (exit 2).
238
240
  if (cmd === undefined || !validCmds.has(cmd)) {
239
- const choices = "'encrypt', 'decrypt', 'keygen', 'keystatus'";
241
+ const choices = "'encrypt', 'decrypt', 'keygen', 'keystatus', 'source-id'";
240
242
  if (cmd === undefined) {
241
243
  process.stderr.write(
242
- `usage: ${prog} [-h] {encrypt,decrypt,keygen,keystatus} ...\n` +
244
+ `usage: ${prog} [-h] {encrypt,decrypt,keygen,keystatus,source-id} ...\n` +
243
245
  `${prog}: error: the following arguments are required: cmd\n`,
244
246
  );
245
247
  } else {
246
248
  process.stderr.write(
247
- `usage: ${prog} [-h] {encrypt,decrypt,keygen,keystatus} ...\n` +
249
+ `usage: ${prog} [-h] {encrypt,decrypt,keygen,keystatus,source-id} ...\n` +
248
250
  `${prog}: error: argument cmd: invalid choice: '${cmd}' (choose from ${choices})\n`,
249
251
  );
250
252
  }
@@ -292,6 +294,8 @@ export function _cli(argv: readonly string[]): number {
292
294
  }
293
295
  if (cmd === 'encrypt') {
294
296
  process.stdout.write(encrypt(v, keys[0] as string) + '\n');
297
+ } else if (cmd === 'source-id') {
298
+ process.stdout.write(sourceId(v, keys[0] as string) + '\n');
295
299
  } else {
296
300
  process.stdout.write(decrypt(v, keys) + '\n');
297
301
  }
@@ -5,9 +5,9 @@
5
5
  * dynamic `import()` not followed. A symbol counts as reached when some module
6
6
  * in the closure imports it BY NAME from its defining module (or imports that
7
7
  * module whole), or when the defining module is itself an entry. Reaching the
8
- * module is not enough — `egress_normalize.ts` is in the closure for one regex
9
- * while `detectNormalized` has no production importer, and that is the case
10
- * this exists to see.
8
+ * module is not enough — `egress_normalize.ts` was in the closure for one regex
9
+ * while `detectNormalized` had no production importer (until one-detector 2.5
10
+ * wired it), and that is the case this exists to see.
11
11
  */
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import * as fs from 'node:fs';
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The one user-global list of projects this machine has worked in (ADR-299).
3
+ *
4
+ * `runtime/projects/registry.json`, keyed by `<repo id>-<checkout>`
5
+ * (`code_graph/repo_identity`): two clones of one origin are one logical
6
+ * project but keep their own root, graph home and commit, so neither build
7
+ * overwrites the other's. A registry written before that, keyed by the bare
8
+ * repo id, still loads; the entry is re-keyed when its checkout next records.
9
+ * A project enters by use — a graph build or refresh, an advisories refresh, or
10
+ * an explicit `code-graph projects add` — never by a crawl. The file names
11
+ * client projects by absolute path, so nothing that leaves the machine may read
12
+ * it (step 6.3 enforces that).
13
+ *
14
+ * Writes are atomic (`write_atomic`), and read-modify-write goes through a
15
+ * short mkdir lock so two processes recording at once do not drop an entry.
16
+ */
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+
20
+ import { canonicalOrigin, projectsDir, remoteUrl, repoIdentity } from '../code_graph/repo_identity.js';
21
+ import { git_common_dir } from './git_common_dir.js';
22
+ import { write_atomic } from './fs_atomic.js';
23
+ import type { EnvMap } from './user_global_paths.js';
24
+
25
+ export const DEFAULT_BUDGET_MB = 2048;
26
+
27
+ export interface ProjectEntry {
28
+ root: string;
29
+ /** The credential-free canonical origin, or null without a remote. */
30
+ remote: string | null;
31
+ fingerprint: string | null;
32
+ last_seen: string;
33
+ /** The graph home directory last used for this project. */
34
+ graph_home: string | null;
35
+ graph_commit: string | null;
36
+ languages: string[];
37
+ providers: string[];
38
+ pinned: boolean;
39
+ /** User-global graph homes of this project (main checkout and worktrees) → last query. */
40
+ homes: Record<string, string>;
41
+ }
42
+
43
+ export interface Registry {
44
+ schema: 1;
45
+ /** The estate index switch (council O1): off unless the owner turns it on. */
46
+ index: boolean;
47
+ budget_mb: number;
48
+ projects: Record<string, ProjectEntry>;
49
+ }
50
+
51
+ export interface RegistryOptions {
52
+ env?: EnvMap | null;
53
+ /** Overrides the projects directory (tests, advisories' injected machine dir). */
54
+ dir?: string;
55
+ now?: Date;
56
+ }
57
+
58
+ export function emptyRegistry(): Registry {
59
+ return { schema: 1, index: false, budget_mb: DEFAULT_BUDGET_MB, projects: {} };
60
+ }
61
+
62
+ export function registryDir(opts: RegistryOptions = {}): string {
63
+ return opts.dir ?? projectsDir(opts.env);
64
+ }
65
+
66
+ export function registryPath(env?: EnvMap | null, dir?: string): string {
67
+ return path.join(dir ?? projectsDir(env), 'registry.json');
68
+ }
69
+
70
+ function isEntry(v: unknown): v is ProjectEntry {
71
+ const e = v as Partial<ProjectEntry> | null;
72
+ return !!e && typeof e.root === 'string' && typeof e.last_seen === 'string';
73
+ }
74
+
75
+ /** A missing, corrupt or foreign file reads as empty, never as an error. */
76
+ export function readRegistry(opts: RegistryOptions = {}): Registry {
77
+ let raw: unknown;
78
+ try {
79
+ raw = JSON.parse(fs.readFileSync(registryPath(opts.env, opts.dir), 'utf-8'));
80
+ } catch {
81
+ return emptyRegistry();
82
+ }
83
+ const r = raw as Partial<Registry> | null;
84
+ if (!r || typeof r !== 'object' || Array.isArray(r) || r.schema !== 1 || typeof r.projects !== 'object' || r.projects === null) {
85
+ return emptyRegistry();
86
+ }
87
+ const projects: Record<string, ProjectEntry> = {};
88
+ for (const [id, e] of Object.entries(r.projects)) {
89
+ if (!isEntry(e)) continue;
90
+ projects[id] = {
91
+ root: e.root,
92
+ remote: e.remote ?? null,
93
+ fingerprint: e.fingerprint ?? null,
94
+ last_seen: e.last_seen,
95
+ graph_home: e.graph_home ?? null,
96
+ graph_commit: e.graph_commit ?? null,
97
+ languages: Array.isArray(e.languages) ? e.languages : [],
98
+ providers: Array.isArray(e.providers) ? e.providers : [],
99
+ pinned: e.pinned === true,
100
+ homes: e.homes && typeof e.homes === 'object' ? e.homes : {},
101
+ };
102
+ }
103
+ return {
104
+ schema: 1,
105
+ index: r.index === true,
106
+ budget_mb: typeof r.budget_mb === 'number' && r.budget_mb > 0 ? r.budget_mb : DEFAULT_BUDGET_MB,
107
+ projects,
108
+ };
109
+ }
110
+
111
+ export function writeRegistry(reg: Registry, opts: RegistryOptions = {}): void {
112
+ write_atomic(registryPath(opts.env, opts.dir), `${JSON.stringify(reg, null, 2)}\n`);
113
+ }
114
+
115
+ const LOCK_WAIT_MS = 2_000;
116
+ const LOCK_STALE_MS = 30_000;
117
+
118
+ function sleep(ms: number): void {
119
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
120
+ }
121
+
122
+ /** Read, change and write under the registry lock; the change sees the latest file. */
123
+ export function updateRegistry<T>(fn: (reg: Registry) => T, opts: RegistryOptions = {}): T {
124
+ const lock = path.join(registryDir(opts), 'registry.lock');
125
+ fs.mkdirSync(path.dirname(lock), { recursive: true });
126
+ const deadline = Date.now() + LOCK_WAIT_MS;
127
+ for (;;) {
128
+ try {
129
+ fs.mkdirSync(lock);
130
+ break;
131
+ } catch (e) {
132
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
133
+ try {
134
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) fs.rmSync(lock, { recursive: true, force: true });
135
+ } catch {
136
+ /* released meanwhile */
137
+ }
138
+ if (Date.now() > deadline) throw new Error(`project registry is locked: ${lock}`);
139
+ sleep(25);
140
+ }
141
+ }
142
+ try {
143
+ const reg = readRegistry(opts);
144
+ const out = fn(reg);
145
+ writeRegistry(reg, opts);
146
+ return out;
147
+ } finally {
148
+ fs.rmSync(lock, { recursive: true, force: true });
149
+ }
150
+ }
151
+
152
+ /** The main checkout of a worktree, else the root itself. */
153
+ export function mainRoot(root: string): string {
154
+ let real: string;
155
+ try {
156
+ real = fs.realpathSync(root);
157
+ } catch {
158
+ real = path.resolve(root);
159
+ }
160
+ const common = git_common_dir(real);
161
+ return common !== null && path.basename(common) === '.git' ? path.dirname(common) : real;
162
+ }
163
+
164
+ export type ProjectFields = Partial<Pick<ProjectEntry, 'fingerprint' | 'graph_home' | 'graph_commit' | 'languages' | 'providers' | 'pinned'>> & {
165
+ /** A user-global graph home that just served or rebuilt this project. */
166
+ usedHome?: string;
167
+ };
168
+
169
+ /** The logical project an entry key belongs to: the repo id, for a new or a bare legacy key. */
170
+ export function repoIdOf(key: string): string {
171
+ return key.split('-')[0] as string;
172
+ }
173
+
174
+ /** Record `root` (by use, never by a crawl) and return its entry key, `<repo id>-<checkout>`. */
175
+ export function recordProject(root: string, fields: ProjectFields = {}, opts: RegistryOptions = {}): string {
176
+ const dir = registryDir(opts);
177
+ const ident = repoIdentity(root, opts.env, dir);
178
+ const id = `${ident.repoId}-${ident.checkout}`;
179
+ const main = mainRoot(root);
180
+ const origin = remoteUrl(main);
181
+ const now = (opts.now ?? new Date()).toISOString();
182
+ updateRegistry((reg) => {
183
+ const legacy = reg.projects[ident.repoId];
184
+ const adopt = reg.projects[id] === undefined && legacy !== undefined && legacy.root === main;
185
+ const prev = reg.projects[id] ?? (adopt ? legacy : undefined);
186
+ if (adopt) delete reg.projects[ident.repoId];
187
+ const { usedHome, ...rest } = fields;
188
+ const next: ProjectEntry = {
189
+ root: main,
190
+ remote: origin === null ? null : canonicalOrigin(origin, main),
191
+ fingerprint: prev?.fingerprint ?? null,
192
+ graph_home: prev?.graph_home ?? null,
193
+ graph_commit: prev?.graph_commit ?? null,
194
+ languages: prev?.languages ?? [],
195
+ providers: prev?.providers ?? [],
196
+ pinned: prev?.pinned ?? false,
197
+ homes: { ...(prev?.homes ?? {}) },
198
+ ...Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)),
199
+ last_seen: now,
200
+ };
201
+ if (usedHome !== undefined) next.homes[usedHome] = now;
202
+ reg.projects[id] = next;
203
+ }, { ...opts, dir });
204
+ return id;
205
+ }
206
+
207
+ /**
208
+ * The entries a reference names: an entry key, a repo id (every checkout of
209
+ * it), a registered name (the root's basename) or a path. A name two projects
210
+ * share returns both, so the caller can refuse to guess.
211
+ */
212
+ export function findProjects(reg: Registry, ref: string): Array<[string, ProjectEntry]> {
213
+ const all = Object.entries(reg.projects);
214
+ const byKey = all.filter(([id]) => id === ref);
215
+ if (byKey.length) return byKey;
216
+ const byId = all.filter(([id]) => repoIdOf(id) === ref);
217
+ if (byId.length) return byId;
218
+ let real: string | null = null;
219
+ try {
220
+ real = fs.realpathSync(path.resolve(ref));
221
+ } catch {
222
+ real = path.resolve(ref);
223
+ }
224
+ const byPath = all.filter(([, e]) => e.root === real || e.root === ref);
225
+ if (byPath.length) return byPath;
226
+ return all.filter(([, e]) => path.basename(e.root) === ref);
227
+ }