skillrepo 4.12.0 → 4.14.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/README.md CHANGED
@@ -119,11 +119,23 @@ and just write the config + gitignore.
119
119
  "removed": 0,
120
120
  "notModified": false,
121
121
  "fullSync": true,
122
- "syncedAt": "2026-05-01T00:00:00.000Z"
122
+ "syncedAt": "2026-05-01T00:00:00.000Z",
123
+ "globalBoundary": {
124
+ "total": 0,
125
+ "counts": { "global_library": 0, "global_foreign": 0, "global_shadowed": 0 },
126
+ "names": [],
127
+ "skillsetDeclared": false
128
+ }
123
129
  }
124
130
  }
125
131
  ```
126
132
 
133
+ `sync.globalBoundary` (4.13.0) appears on every successful
134
+ project-scope sync and describes the personal-scope skills that also
135
+ load in sessions here; it is absent when the scan did not run
136
+ (`--global`, or a scan failure). `names` stays local to your machine —
137
+ sync reporting to your organization carries counts only.
138
+
127
139
  Field notes:
128
140
  - `vendors` is the resolved canonical-key list, NOT the raw `--agent` input. `--agent agents` produces every cohort vendor (cursor, windsurf, gemini, codex, cline, copilot); `--agent none` produces an empty array.
129
141
  - `sessionSync.cohortHooks[]` reports per-vendor outcomes for the auto-refresh hooks installed alongside the Claude Code SessionStart hook (one entry per cohort vendor with a non-null `agentHook` registry spec — Cursor, Gemini CLI, Codex CLI, VS Code + Copilot). `reason` is present only when `action: "failed"`. Empty array when `--no-session-sync` was passed or no cohort vendor was selected.
@@ -183,6 +195,21 @@ Sync also warns — once per repo, same rules — when a `skillrepo.json`
183
195
  at the repo root is gitignored: the skillset declaration only works as
184
196
  a committed file, so remove it from `.gitignore` and commit it.
185
197
 
198
+ Project syncs also disclose the personal scope (the global boundary):
199
+ skills under the global folders (`~/.claude/skills/`,
200
+ `~/.agents/skills/`, `~/.codeium/windsurf/skills/`) load in every
201
+ session run inside a project, whether or not they came through a
202
+ skillset. When any exist, a one-line disclosure says how many global
203
+ skills will also load — in a declared repo it also counts how many sit
204
+ outside the repo's skillset and how many collide with a skillset
205
+ member's name. Today the session auto-sync prints that line in Claude
206
+ Code only (the other agents' background sync hooks run silently by
207
+ their hook contracts — see `docs/vendor-paths.md` for each vendor's
208
+ channel); `skillrepo list` shows the same line everywhere. Interactive
209
+ syncs add a one-time warning per finding with the remediation, and
210
+ your organization's sync reporting carries counts only
211
+ (`global_library`, `global_foreign`, `global_shadowed`), never names.
212
+
186
213
  A repository can declare a skillset in a root `skillrepo.json`:
187
214
  `{"skillset": {"version": 1, "name": "<repo-identity>", "use":
188
215
  "owner/skillset-name"}}` (optional `extra: ["owner/skill"]`). The CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.12.0",
3
+ "version": "4.14.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,12 @@ import { detectAgents } from "../lib/detect-agents.mjs";
43
43
  import { walkDetectedPlacements } from "../lib/placement-walk.mjs";
44
44
  import { getAgentByKey } from "../lib/agent-registry.mjs";
45
45
  import { computeSkillState, rollupState, SKILL_STATE } from "../lib/drift.mjs";
46
+ import {
47
+ scanGlobalBoundary,
48
+ formatGlobalBoundaryDisclosure,
49
+ managedGlobalNamesFrom,
50
+ resolveBoundaryMemberContext,
51
+ } from "../lib/global-boundary.mjs";
46
52
 
47
53
  /**
48
54
  * Run `list`. Throws CliError on any failure.
@@ -56,7 +62,10 @@ export async function runList(argv, io = {}) {
56
62
  const stdout = io.stdout ?? process.stdout;
57
63
  const flags = resolveFlags(argv);
58
64
 
59
- // `list` is a read-only drift check — it must NEVER set sync state.
65
+ // `list` is a read-only drift check — it must NEVER set sync state,
66
+ // and (#2495) it never writes the governance-seen file either: the
67
+ // boundary disclosure below scans without committing warn-state, so
68
+ // running `list` can't consume a warning `update` owes the user.
60
69
  // Use the manifest read (#1832): metadata only, no file bodies, and the
61
70
  // server records no delivery for it. Per-skill drift is computed from
62
71
  // on-disk SHAs + `.last-sync` below, never from the response body, so
@@ -122,6 +131,42 @@ export async function runList(argv, io = {}) {
122
131
 
123
132
  printTable(augmented, detected, stdout);
124
133
  printFooter(augmented, libraryResponse.etag, lastSync, stdout, canUseGlyphs(stdout));
134
+ printGlobalBoundaryDisclosure(detectedKeys, lastSync, stdout);
135
+ }
136
+
137
+ /**
138
+ * Global-boundary disclosure for the table surface (#2495): one line,
139
+ * same format as the session-hook disclosure, printed only when the
140
+ * detected vendors' GLOBAL roots hold skill dirs. Read-only to the
141
+ * letter of list's contract above — no seen-state commit, no per-dir
142
+ * warning lines (those are `update`'s interactive job). Best-effort:
143
+ * the drift table must never fail because a disclosure probe did.
144
+ *
145
+ * The declaration is resolved TOLERANTLY, like the throttled sync
146
+ * exit: `list` is a reporting surface, so an invalid `skillrepo.json`
147
+ * stays `update`'s error to raise — here it just means the scan runs
148
+ * without a member set.
149
+ *
150
+ * @param {string[]} vendors - Detected vendor keys.
151
+ * @param {import("../lib/sync.mjs").SyncStateFile | null} lastSync
152
+ * @param {NodeJS.WritableStream} stdout
153
+ */
154
+ function printGlobalBoundaryDisclosure(vendors, lastSync, stdout) {
155
+ try {
156
+ const { memberNames, baseDir } = resolveBoundaryMemberContext();
157
+ const scan = scanGlobalBoundary({
158
+ vendors,
159
+ memberNames,
160
+ managedGlobalNames: managedGlobalNamesFrom(lastSync?.skills),
161
+ baseDir,
162
+ });
163
+ const disclosure = formatGlobalBoundaryDisclosure(scan);
164
+ // Two-space indent matches every other list line; the line itself
165
+ // is byte-identical to the session-hook disclosure.
166
+ if (disclosure) stdout.write(` ${disclosure}\n`);
167
+ } catch {
168
+ // Disclosure is best-effort on every surface.
169
+ }
125
170
  }
126
171
 
127
172
  // ── Per-skill augmentation ─────────────────────────────────────────────
@@ -46,6 +46,8 @@
46
46
  */
47
47
 
48
48
  import { runSync } from "../lib/sync.mjs";
49
+ import { formatGlobalBoundaryDisclosure } from "../lib/global-boundary.mjs";
50
+ import { formatRepoComplianceSummary } from "../lib/foreign-content.mjs";
49
51
  import {
50
52
  resolveFlags,
51
53
  effectiveVendors,
@@ -64,6 +66,14 @@ import {
64
66
  * - 304 Not Modified → exit 0, NO output.
65
67
  * - 200 with changes → exit 0, ONE line: `[SkillRepo] Library synced: N added, N updated, N removed.`
66
68
  * - Any failure → exit 0, ONE line: `[SkillRepo] Sync failed: <reason>.`
69
+ * - Global-boundary disclosure (#2495): when the sync's summary
70
+ * reports global skills that will also load in this session
71
+ * (`globalBoundary.total > 0`), ONE additional line prints on
72
+ * EVERY success path — after the sync line, or alone on the
73
+ * otherwise-silent 304/zero-delta/throttled paths. Plain stdout
74
+ * enters the session's model context, which is the point: the
75
+ * agent itself learns what extra skills are in play. Failure
76
+ * paths never disclose (the failure line stays the single line).
67
77
  *
68
78
  * The "exit 0 on all errors" contract is non-negotiable: a sync
69
79
  * failure must NEVER block a Claude Code session start. Users on a
@@ -162,10 +172,63 @@ export async function runUpdate(argv, io = {}) {
162
172
  const skipped = summary.skipped ?? 0;
163
173
  const total =
164
174
  summary.added + summary.updated + summary.removed + skipped;
175
+ // Global-boundary disclosure (#2495), computed BEFORE the silent
176
+ // branch below: the quiet 304/zero-delta/throttled session is
177
+ // the COMMON session, and it must still disclose — the line
178
+ // exists so the session's model context knows about the global
179
+ // skills loading alongside the synced set, not to report sync
180
+ // work. Success paths only; the catch below never reaches here.
181
+ // Formatting is its OWN failure domain (architect review r1): a
182
+ // formatter defect must degrade to "no line", never fall into
183
+ // the outer catch and report a successful sync as failed —
184
+ // "disclosure must never break a sync" applies to the printer
185
+ // exactly as it does to the scanner.
186
+ let disclosure = null;
187
+ try {
188
+ disclosure =
189
+ summary.globalBoundary && summary.globalBoundary.total > 0
190
+ ? formatGlobalBoundaryDisclosure(summary.globalBoundary)
191
+ : null;
192
+ } catch {
193
+ // Degrade to no disclosure line.
194
+ }
195
+ // Repo compliance state (#2665, owner decision 2026-08-20): a
196
+ // skillset-declared repo that does not match its set says so in
197
+ // EVERY session, hook mode included — a state, never a
198
+ // warn-on-new event. Same failure domain as the disclosure: a
199
+ // formatter defect degrades to no line, never a failed sync.
200
+ let compliance = null;
201
+ try {
202
+ compliance = summary.compliance
203
+ ? formatRepoComplianceSummary({ ...summary.compliance, hookMode: true })
204
+ : null;
205
+ } catch {
206
+ // Degrade to no compliance line.
207
+ }
208
+ const writeDisclosureLine = () => {
209
+ if (!disclosure) return;
210
+ try {
211
+ stdout.write(`${disclosure}\n`);
212
+ } catch {
213
+ // Same failure domain as the formatter: a write failure on
214
+ // the cosmetic line must not become a "Sync failed" report.
215
+ }
216
+ };
217
+ const writeComplianceLine = () => {
218
+ if (!compliance) return;
219
+ try {
220
+ stdout.write(`${compliance}\n`);
221
+ } catch {
222
+ // Same failure domain as the disclosure write above.
223
+ }
224
+ };
165
225
  if (summary.notModified || total === 0) {
166
226
  // 304 Not Modified OR 200 with zero deltas — silent by
167
- // contract. Users should not see "Syncing..." on every
168
- // session for no visible value.
227
+ // contract (the boundary disclosure and the compliance state
228
+ // are the sanctioned exceptions). Users should not see
229
+ // "Syncing..." on every session for no visible value.
230
+ writeDisclosureLine();
231
+ writeComplianceLine();
169
232
  return;
170
233
  }
171
234
  stdout.write(
@@ -173,6 +236,8 @@ export async function runUpdate(argv, io = {}) {
173
236
  (skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
174
237
  `.\n`,
175
238
  );
239
+ writeDisclosureLine();
240
+ writeComplianceLine();
176
241
  } catch (err) {
177
242
  // The one-line failure message is the user's primary signal
178
243
  // that something's wrong. Do not surface a stack trace — the
@@ -186,11 +186,25 @@ export function scanForeignContent({ vendors, global, managedSkills, baseDir })
186
186
  * rules. The names stay on this machine — see the module docstring's
187
187
  * privacy contract.
188
188
  *
189
+ * Two copy branches (#2665): in a skillset-declared repo the finding is
190
+ * a COMPLIANCE statement — the consequence is named ("reported as not
191
+ * compliant") and the remediation is the governed path (remove, or have
192
+ * a skillset manager add the skill). `skillrepo push` is deliberately
193
+ * NOT offered there: pushing publishes the dir to the library but does
194
+ * not put it in the skillset, so it cannot restore compliance — and the
195
+ * developer may not hold publish rights at all. Undeclared repos keep
196
+ * the original library-framed push-or-remove copy.
197
+ *
189
198
  * @param {ForeignScanResult} scan
199
+ * @param {object} [options]
200
+ * @param {string | null} [options.skillsetRef] - The declared skillset
201
+ * (`owner/name`, schema-validated upstream) when the repo is
202
+ * skillset-scoped; absent/null → library-framed copy.
190
203
  * @returns {string[]}
191
204
  */
192
- export function formatForeignWarnings(scan) {
205
+ export function formatForeignWarnings(scan, { skillsetRef } = {}) {
193
206
  const lines = [];
207
+ const declared = typeof skillsetRef === "string" && skillsetRef.length > 0;
194
208
  for (const rootResult of scan.roots) {
195
209
  for (const dir of rootResult.foreignDirs) {
196
210
  // Directory names come from readdirSync — filesystem-sourced and
@@ -200,6 +214,15 @@ export function formatForeignWarnings(scan) {
200
214
  // server-path treatment in file-write.mjs (#2402 class).
201
215
  const safeDir = escapeControlChars(dir);
202
216
  const displayPath = `${rootResult.displayRoot}${safeDir}`;
217
+ if (declared) {
218
+ lines.push(
219
+ ` warning: ${displayPath}/ is not part of this repo's skillset ` +
220
+ `(${skillsetRef}), but agents still load it from disk. Remove the ` +
221
+ `directory, or ask a skillset manager to add the skill. Until ` +
222
+ `then this repo is reported as not compliant.`,
223
+ );
224
+ continue;
225
+ }
203
226
  // The remediation must be a RUNNABLE command. A tilde-shortened
204
227
  // display root ("~/.claude/skills/") breaks copy-paste — quotes
205
228
  // suppress shell tilde expansion and Windows cmd never expands
@@ -217,6 +240,100 @@ export function formatForeignWarnings(scan) {
217
240
  return lines;
218
241
  }
219
242
 
243
+ /**
244
+ * The repo compliance state line (#2665, owner decisions 2026-08-20):
245
+ * ONE sentence stating that the repo does not currently match its
246
+ * declared skillset and is reported as not compliant, listing the
247
+ * locally-observable causes as counts. Unlike the per-dir warnings
248
+ * above this is a STATE, not an event — callers print it on EVERY
249
+ * scoped sync while any cause persists (never gated on
250
+ * `.governance-seen`), including session-hook syncs, where it takes
251
+ * the `[SkillRepo]` prefix so the agent session sees it too (the
252
+ * #2495 disclosure precedent). Counts only — names stay in the
253
+ * per-dir warnings, which remain local-only and warn-on-new.
254
+ *
255
+ * Server-side causes a fresh sync cannot observe (behind-version or
256
+ * missing members on a repo that has not re-synced) surface on the
257
+ * dashboard instead — this line covers what THIS machine can see now.
258
+ *
259
+ * @param {object} input
260
+ * @param {string} input.skillsetRef - Declared skillset (`owner/name`).
261
+ * @param {number} [input.unmanagedCount] - Project-scope foreign dirs
262
+ * (the scan-derived count — matches the receipt's `unmanaged`
263
+ * violation category, deliberately NOT mixed with the
264
+ * member-name collisions below).
265
+ * @param {number} [input.shadowedCount] - Global copies shadowing members.
266
+ * @param {number} [input.globalBeyondCount] - Global-scope skills beyond
267
+ * the set (`global_library` + `global_foreign`).
268
+ * @param {number} [input.editRefusedCount] - Members whose LOCAL EDITS
269
+ * the sync refused to overwrite (`unwrittenReason: "modified"`
270
+ * only — the one bucket allowed to claim an edit).
271
+ * @param {number} [input.memberReplacedCount] - Members whose slot is
272
+ * occupied by hand-authored content the CLI never wrote
273
+ * (`unwrittenReason: "unmanaged"` — invisible to the foreign
274
+ * scan because the dir name IS a managed member name).
275
+ * @param {number} [input.notDeliveredCount] - Members not delivered as
276
+ * approved for server/payload reasons ("incomplete", "invalid")
277
+ * or unknown legacy reasons — never phrased as an edit.
278
+ * @param {boolean} [input.hookMode] - Session-hook formatting
279
+ * (`[SkillRepo]` prefix, single line for the hook UI).
280
+ * @returns {string | null} The line, or null when every count is zero.
281
+ */
282
+ export function formatRepoComplianceSummary({
283
+ skillsetRef,
284
+ unmanagedCount = 0,
285
+ shadowedCount = 0,
286
+ globalBeyondCount = 0,
287
+ editRefusedCount = 0,
288
+ memberReplacedCount = 0,
289
+ notDeliveredCount = 0,
290
+ hookMode = false,
291
+ }) {
292
+ const parts = [];
293
+ if (unmanagedCount > 0) {
294
+ parts.push(
295
+ `${unmanagedCount} extra skill${unmanagedCount === 1 ? "" : "s"} in the project`,
296
+ );
297
+ }
298
+ if (shadowedCount > 0) {
299
+ parts.push(
300
+ `${shadowedCount} member${shadowedCount === 1 ? "" : "s"} shadowed by a global copy`,
301
+ );
302
+ }
303
+ if (globalBeyondCount > 0) {
304
+ parts.push(
305
+ `${globalBeyondCount} global skill${globalBeyondCount === 1 ? "" : "s"} beyond the set`,
306
+ );
307
+ }
308
+ if (memberReplacedCount > 0) {
309
+ parts.push(
310
+ `${memberReplacedCount} member${memberReplacedCount === 1 ? "" : "s"} replaced by unmanaged content`,
311
+ );
312
+ }
313
+ if (editRefusedCount > 0) {
314
+ parts.push(
315
+ `${editRefusedCount} member${editRefusedCount === 1 ? "" : "s"} edited locally`,
316
+ );
317
+ }
318
+ if (notDeliveredCount > 0) {
319
+ parts.push(
320
+ `${notDeliveredCount} member${notDeliveredCount === 1 ? "" : "s"} not delivered as approved`,
321
+ );
322
+ }
323
+ if (parts.length === 0) return null;
324
+ const causes = parts.join(", ");
325
+ if (hookMode) {
326
+ return (
327
+ `[SkillRepo] This repo does not match its skillset ${skillsetRef} ` +
328
+ `(${causes}) — it is reported as not compliant.`
329
+ );
330
+ }
331
+ return (
332
+ ` warning: this repo does not match its skillset ${skillsetRef}: ` +
333
+ `${causes}. It is reported as not compliant to your organization.`
334
+ );
335
+ }
336
+
220
337
  // ── Warn-on-new state (#2361 owner directive: no repeat warnings) ──────
221
338
  //
222
339
  // A warning that repeats unchanged findings on every sync trains users
@@ -245,11 +362,41 @@ export function formatForeignWarnings(scan) {
245
362
  * @property {Record<string, boolean>} declarations - abs repo path →
246
363
  * true when the gitignored-declaration warning was already
247
364
  * shown and the declaration is still gitignored.
365
+ * @property {Record<string, Record<string, string[]>>} globalBoundary -
366
+ * abs repo path → (abs global root → dir names already warned
367
+ * about, sorted). The #2495 global-boundary counterpart to
368
+ * `roots`, keyed per REPO because the same global dir means
369
+ * different things in different repos (shadowing one repo's
370
+ * skillset member, plain foreign content elsewhere) — one
371
+ * repo consuming the warning must not silence it for another.
248
372
  */
249
373
 
250
374
  /** @returns {GovernanceSeenState} */
251
375
  function emptySeenState() {
252
- return { schemaVersion: 1, roots: {}, declarations: {} };
376
+ return { schemaVersion: 1, roots: {}, declarations: {}, globalBoundary: {} };
377
+ }
378
+
379
+ /**
380
+ * Shape-check the `globalBoundary` field on read: an object of objects
381
+ * of arrays, or `{}` when anything about it is malformed — the same
382
+ * warn-everything degradation the other fields use, applied to the
383
+ * whole field (a partially-trusted structure isn't worth salvaging
384
+ * when the cost of a reset is one repeated warning).
385
+ *
386
+ * @param {unknown} value
387
+ * @returns {Record<string, Record<string, string[]>>}
388
+ */
389
+ function coerceGlobalBoundary(value) {
390
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
391
+ for (const repoMap of Object.values(value)) {
392
+ if (!repoMap || typeof repoMap !== "object" || Array.isArray(repoMap)) {
393
+ return {};
394
+ }
395
+ for (const names of Object.values(repoMap)) {
396
+ if (!Array.isArray(names)) return {};
397
+ }
398
+ }
399
+ return /** @type {Record<string, Record<string, string[]>>} */ (value);
253
400
  }
254
401
 
255
402
  /** @returns {GovernanceSeenState} */
@@ -262,6 +409,11 @@ export function readGovernanceSeen() {
262
409
  // at the current schema. A future v2 that must PRESERVE v1 data
263
410
  // needs an explicit accept-in-place branch here, the way
264
411
  // sync.mjs's readLastSync accepts its immediately-prior schema.
412
+ // `globalBoundary` (#2495) is an ADDITIVE key at schemaVersion 1,
413
+ // not a bump: a pre-#2495 CLI sharing this file simply drops the
414
+ // key on its next write, and the cost is one repeated boundary
415
+ // warning on the next post-#2495 interactive sync — documented,
416
+ // acceptable, and self-healing.
265
417
  if (!parsed || typeof parsed !== "object" || parsed.schemaVersion !== 1) {
266
418
  return emptySeenState();
267
419
  }
@@ -277,6 +429,7 @@ export function readGovernanceSeen() {
277
429
  !Array.isArray(parsed.declarations)
278
430
  ? parsed.declarations
279
431
  : {},
432
+ globalBoundary: coerceGlobalBoundary(parsed.globalBoundary),
280
433
  };
281
434
  } catch {
282
435
  return emptySeenState();
@@ -396,7 +549,110 @@ export function commitGovernanceSeen({ scan, repoKey, declarationIgnored }) {
396
549
  } else {
397
550
  delete declarations[repoKey];
398
551
  }
399
- writeGovernanceSeen({ schemaVersion: 1, roots, declarations });
552
+ writeGovernanceSeen({
553
+ schemaVersion: 1,
554
+ roots,
555
+ declarations,
556
+ // Pass-through from the FRESH read (#2495): this commit owns only
557
+ // `roots` + this repo's declaration flag; the boundary scan's keys
558
+ // belong to `commitGlobalBoundarySeen`, and dropping them here
559
+ // would re-warn every boundary finding after every project sync.
560
+ globalBoundary: fresh.globalBoundary,
561
+ });
562
+ }
563
+
564
+ /**
565
+ * Commit a global-boundary scan (#2495) into the seen-state file with
566
+ * the same NARROW merge discipline as `commitGovernanceSeen`: fresh
567
+ * read, rewrite ONLY `globalBoundary[repoKey]` (this repo's view), and
568
+ * pass `roots`/`declarations` — plus every OTHER repo's boundary key —
569
+ * through from the fresh read, so a concurrent sync elsewhere keeps
570
+ * its update even if this process writes last.
571
+ *
572
+ * Replace-not-merge per scanned root within the repo key: dirs that
573
+ * disappeared are pruned so a removed-then-reappearing dir warns
574
+ * again; a clean root deletes its key, and a repo whose every scanned
575
+ * root ended clean deletes its whole entry (state files should not
576
+ * accumulate empty husks).
577
+ *
578
+ * The RESIDUAL same-key race documented on `writeGovernanceSeen`
579
+ * applies here identically: two concurrent interactive syncs of ONE
580
+ * repo last-write-win on that repoKey, which can revive a pruned
581
+ * entry and suppress a re-warn — a repeated-or-suppressed warning,
582
+ * never a wrong receipt count (counts come from the live scan).
583
+ *
584
+ * @param {object} options
585
+ * @param {{ roots: { root: string, entries: { name: string }[] }[] }} options.scan
586
+ * The FULL boundary scan (everything currently on disk, not
587
+ * just the newly-warned entries).
588
+ * @param {string} options.repoKey - Normalized repo path.
589
+ */
590
+ export function commitGlobalBoundarySeen({ scan, repoKey }) {
591
+ const fresh = readGovernanceSeen();
592
+ const globalBoundary = { ...fresh.globalBoundary };
593
+ const repoMap = { ...(globalBoundary[repoKey] ?? {}) };
594
+ for (const rootResult of scan?.roots ?? []) {
595
+ const names = (rootResult.entries ?? []).map((entry) => entry.name).sort();
596
+ if (names.length > 0) {
597
+ repoMap[rootResult.root] = names;
598
+ } else {
599
+ delete repoMap[rootResult.root];
600
+ }
601
+ }
602
+ if (Object.keys(repoMap).length === 0) {
603
+ delete globalBoundary[repoKey];
604
+ } else {
605
+ globalBoundary[repoKey] = repoMap;
606
+ }
607
+ writeGovernanceSeen({
608
+ schemaVersion: 1,
609
+ roots: fresh.roots,
610
+ declarations: fresh.declarations,
611
+ globalBoundary,
612
+ });
613
+ }
614
+
615
+ /**
616
+ * Select the boundary findings this repo has NOT been warned about
617
+ * yet. Pure — no IO; the `commitGlobalBoundarySeen` above does the
618
+ * bookkeeping. Mirrors `selectNewFindings`, keyed one level deeper
619
+ * (per repo, then per root).
620
+ *
621
+ * Gates ONLY the interactive warning lines: the disclosure line and
622
+ * the receipt categories always use the FULL scan — seen-state
623
+ * suppresses repetition for a human, never facts for the record.
624
+ *
625
+ * @param {import("./global-boundary.mjs").GlobalBoundaryScan} scan
626
+ * @param {GovernanceSeenState} seen
627
+ * @param {string} repoKey - Normalized repo path.
628
+ * @returns {{ roots: object[], counts: Record<string, number>, total: number }}
629
+ * Scan-shaped (roots/counts/total recomputed over the unseen
630
+ * entries). No `summary` — the disclosure surfaces never
631
+ * consume a filtered scan, so embedding one here would invite
632
+ * exactly the partial-disclosure bug the gating rule forbids.
633
+ */
634
+ export function selectNewGlobalBoundaryFindings(scan, seen, repoKey) {
635
+ const repoMap =
636
+ seen?.globalBoundary && typeof seen.globalBoundary === "object"
637
+ ? seen.globalBoundary[repoKey] ?? {}
638
+ : {};
639
+ const counts = {};
640
+ for (const key of Object.keys(scan?.counts ?? {})) counts[key] = 0;
641
+ const result = { roots: [], counts, total: 0 };
642
+ for (const rootResult of scan?.roots ?? []) {
643
+ const prior = new Set(
644
+ Array.isArray(repoMap[rootResult.root]) ? repoMap[rootResult.root] : [],
645
+ );
646
+ const freshEntries = (rootResult.entries ?? []).filter(
647
+ (entry) => !prior.has(entry.name),
648
+ );
649
+ result.roots.push({ ...rootResult, entries: freshEntries });
650
+ for (const entry of freshEntries) {
651
+ counts[entry.bucket] = (counts[entry.bucket] ?? 0) + 1;
652
+ result.total += 1;
653
+ }
654
+ }
655
+ return result;
400
656
  }
401
657
 
402
658
  /**