skillrepo 4.14.0 → 4.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.14.0",
3
+ "version": "4.15.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": {
@@ -8,7 +8,23 @@
8
8
  * reports the library-level ETag state ("library in sync" vs
9
9
  * "library has changed since last sync").
10
10
  *
11
- * Pipeline:
11
+ * Skillset-declared repos (#2672, epic #2357). When cwd walks up to a
12
+ * `skillrepo.json` that DECLARES a skillset, `list` shows that repo's
13
+ * DELIVERED view instead of the whole library: rows are the per-repo
14
+ * state file's `resolved` manifest (the same set the sync receipts
15
+ * attest — NOT a live re-resolution of the declaration), drift is
16
+ * computed against the per-repo SHA baselines and the DETECTED-ROOT
17
+ * placements (never `.last-sync`/cwd, which scoped syncs bypass by
18
+ * design — D12), and the #2665 compliance line is appended. This path
19
+ * is fully offline (no library GET) and read-only. An `invalid`
20
+ * declaration fails closed exactly as `sync` does — a mis-declared
21
+ * repo is never shown the whole library (D9). Undeclared and
22
+ * config-only repos keep the whole-library view below, byte-identical
23
+ * to pre-#2672 (D2). Recall status is deliberately NOT surfaced here
24
+ * (the CLI has no recall signal — recall is absence in delivery);
25
+ * tracked separately in #2793.
26
+ *
27
+ * Pipeline (whole-library view):
12
28
  * 1. `getLibrary` returns the current registry skills + ETag.
13
29
  * 2. `readLastSync` reads the v2 `.last-sync` map (per-skill SHAs +
14
30
  * versions) for the on-disk-vs-synced comparison.
@@ -47,8 +63,18 @@ import {
47
63
  scanGlobalBoundary,
48
64
  formatGlobalBoundaryDisclosure,
49
65
  managedGlobalNamesFrom,
50
- resolveBoundaryMemberContext,
66
+ GLOBAL_SHADOWED_CATEGORY,
67
+ GLOBAL_LIBRARY_CATEGORY,
68
+ GLOBAL_FOREIGN_CATEGORY,
51
69
  } from "../lib/global-boundary.mjs";
70
+ import { resolveDeclaration } from "../lib/skillset-declaration.mjs";
71
+ import { readRepoSyncState } from "../lib/repo-sync-state.mjs";
72
+ import {
73
+ scanForeignContent,
74
+ buildRepoCompliance,
75
+ formatRepoComplianceSummary,
76
+ } from "../lib/foreign-content.mjs";
77
+ import { escapeControlChars } from "../lib/file-write.mjs";
52
78
 
53
79
  /**
54
80
  * Run `list`. Throws CliError on any failure.
@@ -60,8 +86,43 @@ import {
60
86
  */
61
87
  export async function runList(argv, io = {}) {
62
88
  const stdout = io.stdout ?? process.stdout;
89
+ const stderr = io.stderr ?? process.stderr;
63
90
  const flags = resolveFlags(argv);
64
91
 
92
+ // #2672: dispatch on the repo's skillset declaration BEFORE any
93
+ // network call, so the skillset path stays fully offline. Resolution
94
+ // is local-only and never throws for the undeclared cases; `invalid`
95
+ // carries a typed fail-closed error.
96
+ const declaration = resolveDeclaration();
97
+
98
+ if (declaration.status === "invalid") {
99
+ // Fail closed, exactly as `sync` does (D9): a mis-declared repo must
100
+ // never be shown the whole library — that fail-OPEN is the bug this
101
+ // issue removes. The dispatcher prints this typed error cleanly.
102
+ throw declaration.error;
103
+ }
104
+
105
+ if (declaration.status === "declared") {
106
+ return runSkillsetListView({ declaration, flags, stdout, stderr });
107
+ }
108
+
109
+ // `absent` | `config-only` → the whole-library view, byte-identical
110
+ // to pre-#2672 (D2).
111
+ return runLibraryListView({ flags, stdout });
112
+ }
113
+
114
+ /**
115
+ * The whole-library `list` view (#679 / #1555): fetch the manifest,
116
+ * classify each library skill's on-disk drift against `.last-sync`, and
117
+ * render the table + ETag footer + global-boundary disclosure. Reached
118
+ * for undeclared and config-only repos; behavior is unchanged from
119
+ * pre-#2672.
120
+ *
121
+ * @param {object} args
122
+ * @param {ReturnType<typeof resolveFlags>} args.flags
123
+ * @param {NodeJS.WritableStream} args.stdout
124
+ */
125
+ async function runLibraryListView({ flags, stdout }) {
65
126
  // `list` is a read-only drift check — it must NEVER set sync state,
66
127
  // and (#2495) it never writes the governance-seen file either: the
67
128
  // boundary disclosure below scans without committing warn-state, so
@@ -153,7 +214,15 @@ export async function runList(argv, io = {}) {
153
214
  */
154
215
  function printGlobalBoundaryDisclosure(vendors, lastSync, stdout) {
155
216
  try {
156
- const { memberNames, baseDir } = resolveBoundaryMemberContext();
217
+ // Reached ONLY for undeclared / config-only repos — the dispatcher
218
+ // routes declared repos to the skillset view and throws on invalid —
219
+ // so there is never a skillset member set here. `resolveBoundaryMemberContext()`
220
+ // would just re-walk the declaration the dispatcher already resolved
221
+ // and return exactly `{ memberNames: null, baseDir: undefined }`;
222
+ // inlining it avoids that redundant second walk-up per plain `list`
223
+ // (#2672 review).
224
+ const memberNames = null;
225
+ const baseDir = undefined;
157
226
  const scan = scanGlobalBoundary({
158
227
  vendors,
159
228
  memberNames,
@@ -255,6 +324,391 @@ function validateBaselineShape(entry) {
255
324
  return entry;
256
325
  }
257
326
 
327
+ // ── Skillset-declared view (#2672) ─────────────────────────────────────
328
+
329
+ /**
330
+ * Sentinel status for a resolved member that was SERVED but not written
331
+ * to disk by the CLI (`written: false`). Distinct from the four
332
+ * `SKILL_STATE` drift values, which only apply to delivered members.
333
+ */
334
+ const SKILLSET_NOT_DELIVERED = "not-delivered";
335
+
336
+ /**
337
+ * Render `list` for a skillset-declared repo: the repo's DELIVERED set,
338
+ * sourced from the per-repo state file (`repo-sync-state.mjs`) — the
339
+ * same manifest the sync receipts attest, NOT a live re-resolution of
340
+ * the declaration. Fully offline and read-only: no library GET, no state
341
+ * writes, no `.governance-seen` writes.
342
+ *
343
+ * Sub-cases:
344
+ * - No per-repo state (declared but never scoped-synced here) → say so
345
+ * and point at `skillrepo update`; NEVER fall back to the library
346
+ * view (that silent fallback is the D9 shape this issue removes).
347
+ * - Have state → the delivered manifest as rows (per-row drift for
348
+ * written members against the per-repo SHA baseline at the detected
349
+ * root; a distinct "not delivered" row for served-but-refused
350
+ * members) plus the #2665 compliance line, composed the way `sync`'s
351
+ * 304 path does — REPLAY the delivery-reason counts from `resolved`,
352
+ * RE-SCAN foreign + global content fresh from disk (read-only).
353
+ *
354
+ * @param {object} args
355
+ * @param {import("../lib/skillset-declaration.mjs").DeclarationResolution} args.declaration
356
+ * @param {ReturnType<typeof resolveFlags>} args.flags
357
+ * @param {NodeJS.WritableStream} args.stdout
358
+ * @param {NodeJS.WritableStream} args.stderr
359
+ */
360
+ function runSkillsetListView({ declaration, flags, stdout, stderr }) {
361
+ const rootDir = declaration.rootDir;
362
+ const skillsetRef = declaration.declaration.use;
363
+ const repoName = declaration.declaration.name;
364
+
365
+ // The state reader warns ONCE on a corrupt file — that diagnostic
366
+ // belongs on stderr, never stdout (which `--json` consumers parse).
367
+ const state = readRepoSyncState(rootDir, { stderr });
368
+
369
+ if (!state) {
370
+ // Declared but no readable per-repo state → never scoped-synced here
371
+ // (or the state file was corrupt, which the reader already flagged on
372
+ // stderr). Either way the remediation is a sync — NOT a library-view
373
+ // fallback.
374
+ if (flags.json) {
375
+ stdout.write(
376
+ JSON.stringify(
377
+ { skillset: { ref: skillsetRef, repo: repoName, synced: false }, skills: [] },
378
+ null,
379
+ 2,
380
+ ) + "\n",
381
+ );
382
+ return;
383
+ }
384
+ stdout.write(
385
+ `\n This repo declares skillset ${skillsetRef}, but there is no local sync state for it yet.\n` +
386
+ " Run `skillrepo update` to fetch it.\n\n",
387
+ );
388
+ return;
389
+ }
390
+
391
+ const detected = detectAgents().filter((d) => d.detected);
392
+ const detectedVendorEntries = detected
393
+ .map((d) => getAgentByKey(d.key))
394
+ .filter((entry) => entry !== null && entry.projectTarget !== null);
395
+ const detectedKeys = detectedVendorEntries.map((e) => e.key);
396
+
397
+ // Placements anchored at the DETECTED ROOT (#2672), not cwd.
398
+ const placementsMap = walkDetectedPlacements(detectedKeys, rootDir);
399
+
400
+ const resolved = Array.isArray(state.resolved) ? state.resolved : [];
401
+ const members = resolved.map((entry) =>
402
+ augmentMember(entry, detectedVendorEntries, placementsMap),
403
+ );
404
+
405
+ const { compliance, gbScan } = evaluateSkillsetGovernance({
406
+ skillsetRef,
407
+ resolved,
408
+ detectedKeys,
409
+ rootDir,
410
+ });
411
+
412
+ if (flags.json) {
413
+ stdout.write(
414
+ JSON.stringify(
415
+ formatSkillsetJson({ skillsetRef, repoName, members, compliance }),
416
+ null,
417
+ 2,
418
+ ) + "\n",
419
+ );
420
+ return;
421
+ }
422
+
423
+ printSkillsetTable({ skillsetRef, repoName, detected, members, compliance, gbScan, out: stdout });
424
+ }
425
+
426
+ /**
427
+ * Per-member drift for the skillset view. `written: true` members are
428
+ * classified against their per-repo SHA baseline exactly like the
429
+ * library view (reusing `computeSkillState`), but with the SERVED
430
+ * version as both the library version and the baseline version — so this
431
+ * view has no STALE axis (the delivered version IS the approved one; an
432
+ * upstream bump is invisible offline). `written: false` members were
433
+ * served but the CLI refused to write them; they carry no baseline and
434
+ * become a distinct "not delivered" row keyed by `unwrittenReason`.
435
+ *
436
+ * @param {import("../lib/repo-sync-state.mjs").RepoResolvedEntry} entry
437
+ * @param {import("../lib/agent-registry.mjs").AgentEntry[]} detectedVendorEntries
438
+ * @param {Map<string, import("../lib/placement-walk.mjs").LocalPlacement>} placementsMap
439
+ */
440
+ function augmentMember(entry, detectedVendorEntries, placementsMap) {
441
+ const owner = entry.owner;
442
+ const name = entry.name;
443
+ const version = entry.version ?? null;
444
+
445
+ if (entry.written !== true) {
446
+ return {
447
+ owner,
448
+ name,
449
+ version,
450
+ delivered: false,
451
+ reason: typeof entry.unwrittenReason === "string" ? entry.unwrittenReason : null,
452
+ status: SKILLSET_NOT_DELIVERED,
453
+ placements: [],
454
+ };
455
+ }
456
+
457
+ const baseline = validateBaselineShape({
458
+ version: entry.version,
459
+ skillMdSha256: entry.skillMdSha256,
460
+ filesSha256: entry.filesSha256,
461
+ });
462
+ const placements = [];
463
+ for (const vendorEntry of detectedVendorEntries) {
464
+ const onDisk = placementsMap.get(`${vendorEntry.key}::project::${name}`) ?? null;
465
+ const localPlacement = onDisk
466
+ ? { present: true, skillMdSha256: onDisk.skillMdSha256, filesSha256: onDisk.filesSha256 }
467
+ : { present: false, skillMdSha256: null, filesSha256: null };
468
+ const state = computeSkillState({
469
+ libraryVersion: version,
470
+ lastSyncEntry: baseline,
471
+ localPlacement,
472
+ });
473
+ placements.push({ vendor: vendorEntry.key, scope: "project", state });
474
+ }
475
+ const status = rollupState(placements.map((p) => p.state));
476
+ return { owner, name, version, delivered: true, reason: null, status, placements };
477
+ }
478
+
479
+ /**
480
+ * Run the read-only governance scans for the skillset view ONCE and
481
+ * return both the #2665 compliance object and the raw global-boundary
482
+ * scan (the latter feeds the #2495 disclosure line, so both surfaces
483
+ * share one scan). Compliance is composed the way `sync`'s 304 path
484
+ * does: REPLAY the delivery-reason counts from `resolved` (via
485
+ * `buildRepoCompliance`) and RE-SCAN foreign + global content fresh from
486
+ * disk. Uses the raw `scanForeignContent` / `scanGlobalBoundary` — never
487
+ * `sync`'s `runGlobalBoundaryScan`, which also emits warnings and would
488
+ * commit seen-state (forbidden here). Best-effort: a scan failure leaves
489
+ * the scan-derived counts at zero (and `gbScan` null), so the
490
+ * delivery-reason half of compliance still stands.
491
+ *
492
+ * @param {object} args
493
+ * @param {string} args.skillsetRef
494
+ * @param {import("../lib/repo-sync-state.mjs").RepoResolvedEntry[]} args.resolved
495
+ * @param {string[]} args.detectedKeys
496
+ * @param {string} args.rootDir
497
+ * @returns {{ compliance: ReturnType<typeof buildRepoCompliance>, gbScan: (import("../lib/global-boundary.mjs").GlobalBoundaryScan | null) }}
498
+ */
499
+ function evaluateSkillsetGovernance({ skillsetRef, resolved, detectedKeys, rootDir }) {
500
+ const managedSkills = {};
501
+ for (const e of resolved) {
502
+ if (e && e.owner && e.name) {
503
+ managedSkills[`${e.owner}/${e.name}`] = { version: e.version };
504
+ }
505
+ }
506
+
507
+ let foreignCount = 0;
508
+ let gbScan = null;
509
+ try {
510
+ foreignCount = scanForeignContent({
511
+ vendors: detectedKeys,
512
+ global: false,
513
+ managedSkills,
514
+ baseDir: rootDir,
515
+ }).foreignCount;
516
+ gbScan = scanGlobalBoundary({
517
+ vendors: detectedKeys,
518
+ memberNames: new Set(resolved.map((e) => e.name)),
519
+ managedGlobalNames: managedGlobalNamesFrom(readLastSync()?.skills),
520
+ baseDir: rootDir,
521
+ });
522
+ } catch {
523
+ // Best-effort: a scan failure must never crash the delivered-set view.
524
+ }
525
+
526
+ const compliance = buildRepoCompliance({
527
+ skillsetRef,
528
+ foreignCount,
529
+ shadowedCount: gbScan?.counts?.[GLOBAL_SHADOWED_CATEGORY] ?? 0,
530
+ globalBeyondCount:
531
+ (gbScan?.counts?.[GLOBAL_LIBRARY_CATEGORY] ?? 0) +
532
+ (gbScan?.counts?.[GLOBAL_FOREIGN_CATEGORY] ?? 0),
533
+ resolved,
534
+ });
535
+ return { compliance, gbScan };
536
+ }
537
+
538
+ /**
539
+ * Skillset `--json` shape (#2672). Undeclared repos keep the #679 bare
540
+ * array (see `formatJson`); a declared repo grows a top-level object so
541
+ * the skillset ref and machine-readable compliance have a home — no
542
+ * existing script consumes `list --json` inside a declared repo, so this
543
+ * new shape breaks no contract. Recall is absent by design (#2793).
544
+ */
545
+ function formatSkillsetJson({ skillsetRef, repoName, members, compliance }) {
546
+ return {
547
+ skillset: {
548
+ ref: skillsetRef,
549
+ repo: repoName,
550
+ synced: true,
551
+ // Single source of truth for "compliant": the formatter returns
552
+ // null iff there are zero causes, so a future 7th cause updates both
553
+ // the warning line and this flag in lockstep (#2672 review).
554
+ compliant: formatRepoComplianceSummary(compliance) === null,
555
+ compliance: {
556
+ unmanagedCount: compliance.unmanagedCount,
557
+ shadowedCount: compliance.shadowedCount,
558
+ globalBeyondCount: compliance.globalBeyondCount,
559
+ editRefusedCount: compliance.editRefusedCount,
560
+ memberReplacedCount: compliance.memberReplacedCount,
561
+ notDeliveredCount: compliance.notDeliveredCount,
562
+ },
563
+ },
564
+ skills: members
565
+ .slice()
566
+ .sort(sortByOwnerAndName)
567
+ .map((m) => ({
568
+ owner: m.owner,
569
+ name: m.name,
570
+ version: m.version,
571
+ delivered: m.delivered,
572
+ state: m.delivered ? m.status : SKILLSET_NOT_DELIVERED,
573
+ ...(m.delivered ? {} : { reason: m.reason }),
574
+ placements: m.placements,
575
+ })),
576
+ };
577
+ }
578
+
579
+ /**
580
+ * Human table for the skillset view. Columns are Skill / Version /
581
+ * Status — no Updated/Description (the per-repo state carries neither;
582
+ * fetching them would mean a library GET this offline view deliberately
583
+ * avoids). The #2665 compliance line is appended via the SAME formatter
584
+ * `sync` uses; when compliant, a positive one-liner replaces it.
585
+ */
586
+ function printSkillsetTable({ skillsetRef, repoName, detected, members, compliance, gbScan, out }) {
587
+ const useGlyphs = canUseGlyphs(out);
588
+
589
+ out.write(`\n Skillset: ${skillsetRef}${repoName ? ` (declared as ${repoName})` : ""}\n`);
590
+ if (detected.length > 0) {
591
+ const detectedLabel = detected.map((d) => `${d.displayName} (project)`).join(", ");
592
+ out.write(` Detected: ${detectedLabel}\n`);
593
+ } else {
594
+ out.write(" No agents detected in this project.\n");
595
+ }
596
+
597
+ const sorted = members.slice().sort(sortByOwnerAndName);
598
+
599
+ if (sorted.length === 0) {
600
+ out.write(`\n Skillset ${skillsetRef} resolves to no members.\n\n`);
601
+ } else {
602
+ const table = new Table({
603
+ head: ["Skill", "Version", "Status"],
604
+ colWidths: computeSkillsetColWidths(streamColumns(out)),
605
+ wordWrap: true,
606
+ style: { head: ["bold"] },
607
+ });
608
+ for (const m of sorted) {
609
+ // Escape control chars in the state-file-sourced identifier and
610
+ // version before printing (#2402 class). The per-repo state file is
611
+ // filesystem-sourced and could be corrupt or tampered; a raw ANSI
612
+ // sequence in a member `name` would otherwise clear the screen or
613
+ // spoof the compliance verdict this view exists to report truthfully.
614
+ // `cli-table3` emits cell content verbatim (it only ignores escapes
615
+ // for width). `--json` needs no escaping — JSON.stringify handles it.
616
+ table.push([
617
+ escapeControlChars(formatIdentifier(m)),
618
+ escapeControlChars(String(m.version ?? "")) || "—",
619
+ renderSkillsetStatusCell(m, useGlyphs),
620
+ ]);
621
+ }
622
+ out.write("\n" + table.toString() + "\n\n");
623
+
624
+ // "needs attention" = any delivered member that isn't CURRENT, plus
625
+ // every not-delivered member.
626
+ const attention = sorted.filter(
627
+ (m) => !m.delivered || m.status !== SKILL_STATE.CURRENT,
628
+ ).length;
629
+ const total = sorted.length;
630
+ if (attention === 0) {
631
+ out.write(` ${total} member${total === 1 ? "" : "s"} delivered and current.\n`);
632
+ } else {
633
+ out.write(
634
+ ` ${total} member${total === 1 ? "" : "s"} in skillset ${skillsetRef}. ` +
635
+ `${attention} need${attention === 1 ? "s" : ""} attention.\n`,
636
+ );
637
+ }
638
+ }
639
+
640
+ // #2665 compliance state line — same formatter as `sync`, appended when
641
+ // any cause is present (the formatter returns null when compliant).
642
+ const line = formatRepoComplianceSummary(compliance);
643
+ if (line) {
644
+ out.write(`${line}\n`);
645
+ } else {
646
+ out.write(` ${useGlyphs ? "✓" : "[ok]"} compliant with skillset ${skillsetRef}.\n`);
647
+ }
648
+
649
+ // #2495 global-boundary disclosure — the same read-only one-liner the
650
+ // library view and session hook print, naming the global skills that
651
+ // ALSO load in this session. Complementary to the compliance verdict
652
+ // above (operational awareness vs governance verdict); best-effort, so
653
+ // it never fails the view.
654
+ try {
655
+ const disclosure = gbScan ? formatGlobalBoundaryDisclosure(gbScan) : null;
656
+ if (disclosure) out.write(` ${disclosure}\n`);
657
+ } catch {
658
+ // Disclosure is best-effort on every surface.
659
+ }
660
+ out.write("\n");
661
+ }
662
+
663
+ /**
664
+ * Status cell for a skillset member. Delivered members use the drift
665
+ * vocabulary; not-delivered members key their copy off the refusal
666
+ * reason, matching the compliance line ("edited locally" / "replaced").
667
+ */
668
+ function renderSkillsetStatusCell(m, useGlyphs) {
669
+ if (!m.delivered) {
670
+ const detail =
671
+ m.reason === "modified"
672
+ ? "edited locally"
673
+ : m.reason === "unmanaged"
674
+ ? "replaced"
675
+ : m.reason === "incomplete"
676
+ ? "incomplete"
677
+ : m.reason === "invalid"
678
+ ? "invalid"
679
+ : null;
680
+ const base = useGlyphs ? "⊘ not delivered" : "NOT DELIVERED";
681
+ return detail ? `${base} · ${detail}` : base;
682
+ }
683
+ switch (m.status) {
684
+ case SKILL_STATE.CURRENT:
685
+ return useGlyphs ? "✓ current" : "OK";
686
+ case SKILL_STATE.EDITED:
687
+ return useGlyphs ? "✎ edited" : "EDIT";
688
+ case SKILL_STATE.MISSING:
689
+ return useGlyphs ? "✗ missing" : "MISS";
690
+ default:
691
+ // Only current/edited/missing are reachable for a delivered member —
692
+ // its served version IS its baseline version, so `computeSkillState`
693
+ // never returns `stale` here. `?` is the defensive catch-all.
694
+ return useGlyphs ? "?" : "?";
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Three-column widths for the skillset table (Skill / Version /
700
+ * Status), same cap/floor policy as the library table's
701
+ * `computeColWidths`.
702
+ */
703
+ function computeSkillsetColWidths(terminalColumns) {
704
+ const total = terminalColumns > 60 ? Math.min(terminalColumns, 120) : 100;
705
+ const skillCol = 34;
706
+ const versionCol = 12;
707
+ // -4 accounts for the 2 between-column borders + edge padding.
708
+ const statusCol = Math.max(20, total - skillCol - versionCol - 4);
709
+ return [skillCol, versionCol, statusCol];
710
+ }
711
+
258
712
  // ── JSON formatter ─────────────────────────────────────────────────────
259
713
 
260
714
  /**
@@ -65,6 +65,8 @@ import {
65
65
  *
66
66
  * - 304 Not Modified → exit 0, NO output.
67
67
  * - 200 with changes → exit 0, ONE line: `[SkillRepo] Library synced: N added, N updated, N removed.`
68
+ * ("Skillset" replaces "Library" when a declared repo ran a scoped
69
+ * skillset sync — #2679.)
68
70
  * - Any failure → exit 0, ONE line: `[SkillRepo] Sync failed: <reason>.`
69
71
  * - Global-boundary disclosure (#2495): when the sync's summary
70
72
  * reports global skills that will also load in this session
@@ -172,6 +174,7 @@ export async function runUpdate(argv, io = {}) {
172
174
  const skipped = summary.skipped ?? 0;
173
175
  const total =
174
176
  summary.added + summary.updated + summary.removed + skipped;
177
+ const subject = syncSubject(summary);
175
178
  // Global-boundary disclosure (#2495), computed BEFORE the silent
176
179
  // branch below: the quiet 304/zero-delta/throttled session is
177
180
  // the COMMON session, and it must still disclose — the line
@@ -232,7 +235,7 @@ export async function runUpdate(argv, io = {}) {
232
235
  return;
233
236
  }
234
237
  stdout.write(
235
- `[SkillRepo] Library synced: ${summary.added} added, ${summary.updated} updated, ${summary.removed} removed` +
238
+ `[SkillRepo] ${subject} synced: ${summary.added} added, ${summary.updated} updated, ${summary.removed} removed` +
236
239
  (skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
237
240
  `.\n`,
238
241
  );
@@ -338,17 +341,28 @@ export async function runUpdate(argv, io = {}) {
338
341
  printSummary(summary, stdout);
339
342
  }
340
343
 
344
+ // The noun for the scope a sync operated on (#2679). A declared repo's
345
+ // scoped sync carries a top-level `skillsetRef` (stamped by the runSync
346
+ // dispatcher on EVERY scoped return, including the grace/fail-closed path);
347
+ // a whole-library sync does not. Keying off THIS — not the #2665
348
+ // `compliance` object, whose presence excludes the non-fresh-scan paths —
349
+ // is what keeps a scoped grace sync from mislabeling itself "Library".
350
+ function syncSubject(summary) {
351
+ return summary.skillsetRef ? "Skillset" : "Library";
352
+ }
353
+
341
354
  function printSummary(s, out) {
342
355
  // `skipped` counts here: a run that dropped a skill is NOT "up to date",
343
356
  // and saying so was a false statement to the user (#2413 adversarial
344
357
  // review — the counter was incremented and never read).
345
358
  const skipped = s.skipped ?? 0;
346
359
  const total = s.added + s.updated + s.removed + skipped;
360
+ const subject = syncSubject(s);
347
361
  if (s.notModified || total === 0) {
348
- out.write("Library is up to date.\n");
362
+ out.write(`${subject} is up to date.\n`);
349
363
  return;
350
364
  }
351
- out.write("\n Library sync complete:\n");
365
+ out.write(`\n ${subject} sync complete:\n`);
352
366
  if (s.added > 0) out.write(` + ${s.added} added\n`);
353
367
  if (s.updated > 0) out.write(` ↻ ${s.updated} updated\n`);
354
368
  if (s.removed > 0) out.write(` − ${s.removed} removed\n`);
@@ -334,6 +334,64 @@ export function formatRepoComplianceSummary({
334
334
  );
335
335
  }
336
336
 
337
+ /**
338
+ * Bucket a per-repo `resolved` manifest into the #2665 compliance
339
+ * object. Pure — no I/O, no printing — so both `sync` (which prints the
340
+ * line to stderr, `composeRepoCompliance`) and `list` (#2672, which
341
+ * renders it under the skillset table) share ONE definition of how
342
+ * served-but-not-written entries map to the three delivery-reason
343
+ * buckets.
344
+ *
345
+ * The scan-derived counts (`foreignCount`, `shadowedCount`,
346
+ * `globalBeyondCount`) are computed by the caller from the fresh
347
+ * foreign + global-boundary scans and passed in — keeping the
348
+ * global-boundary category constants out of this module (there is a
349
+ * one-directional import global-boundary → foreign-content already, so
350
+ * importing them back would cycle). This function owns only the
351
+ * `resolved`-derived half, the part identical wherever a per-repo state
352
+ * is read: `written: true` entries never contribute (they were
353
+ * delivered); `written: false` entries bucket by `unwrittenReason` —
354
+ * "modified" → editRefused (the one bucket allowed to claim a local
355
+ * edit), "unmanaged" → memberReplaced, everything else
356
+ * (incomplete/invalid, or a legacy entry with no reason) →
357
+ * notDelivered, never phrased as an edit.
358
+ *
359
+ * @param {object} input
360
+ * @param {string} input.skillsetRef
361
+ * @param {number} [input.foreignCount] - Project-scope foreign dirs
362
+ * (the scan's `foreignCount`) → `unmanagedCount`.
363
+ * @param {number} [input.shadowedCount]
364
+ * @param {number} [input.globalBeyondCount]
365
+ * @param {Array<{written?: boolean, unwrittenReason?: string}>} [input.resolved]
366
+ * @returns {{skillsetRef: string, unmanagedCount: number, shadowedCount: number, globalBeyondCount: number, editRefusedCount: number, memberReplacedCount: number, notDeliveredCount: number}}
367
+ */
368
+ export function buildRepoCompliance({
369
+ skillsetRef,
370
+ foreignCount = 0,
371
+ shadowedCount = 0,
372
+ globalBeyondCount = 0,
373
+ resolved = [],
374
+ }) {
375
+ let editRefusedCount = 0;
376
+ let memberReplacedCount = 0;
377
+ let notDeliveredCount = 0;
378
+ for (const e of resolved ?? []) {
379
+ if (!e || e.written === true) continue;
380
+ if (e.unwrittenReason === "modified") editRefusedCount += 1;
381
+ else if (e.unwrittenReason === "unmanaged") memberReplacedCount += 1;
382
+ else notDeliveredCount += 1;
383
+ }
384
+ return {
385
+ skillsetRef,
386
+ unmanagedCount: foreignCount ?? 0,
387
+ shadowedCount: shadowedCount ?? 0,
388
+ globalBeyondCount: globalBeyondCount ?? 0,
389
+ editRefusedCount,
390
+ memberReplacedCount,
391
+ notDeliveredCount,
392
+ };
393
+ }
394
+
337
395
  // ── Warn-on-new state (#2361 owner directive: no repeat warnings) ──────
338
396
  //
339
397
  // A warning that repeats unchanged findings on every sync trains users
@@ -68,9 +68,13 @@ import { getAgentByKey } from "./agent-registry.mjs";
68
68
  * vendors that map to the same target share the walk's results.
69
69
  *
70
70
  * @param {string[]} detectedVendorKeys
71
+ * @param {string} [baseDir] - Project-scope anchor (#2672): the skillset
72
+ * view passes the declaration's DETECTED ROOT so placements are read
73
+ * from the repo root, not cwd. Omitted (undefined) → today's
74
+ * cwd-anchored behavior, byte-identical for the whole-library view.
71
75
  * @returns {Map<string, LocalPlacement>}
72
76
  */
73
- export function walkDetectedPlacements(detectedVendorKeys) {
77
+ export function walkDetectedPlacements(detectedVendorKeys, baseDir) {
74
78
  if (!Array.isArray(detectedVendorKeys) || detectedVendorKeys.length === 0) {
75
79
  return new Map();
76
80
  }
@@ -91,7 +95,7 @@ export function walkDetectedPlacements(detectedVendorKeys) {
91
95
  /** @type {Map<string, LocalPlacement>} */
92
96
  const result = new Map();
93
97
  for (const [target, vendorKeys] of targetToVendors) {
94
- const skillsAtTarget = walkPlacementTarget(target);
98
+ const skillsAtTarget = walkPlacementTarget(target, baseDir);
95
99
  for (const skill of skillsAtTarget) {
96
100
  for (const vendorKey of vendorKeys) {
97
101
  const key = `${vendorKey}::project::${skill.skillName}`;
@@ -119,12 +123,14 @@ export function walkDetectedPlacements(detectedVendorKeys) {
119
123
  * vendor/scope expansion happens in `walkDetectedPlacements`.
120
124
  *
121
125
  * @param {string} target - PlacementTarget enum value.
126
+ * @param {string} [baseDir] - Project-scope anchor (#2672), threaded to
127
+ * `resolvePlacementRoot`. Omitted → cwd-anchored (unchanged).
122
128
  * @returns {{ skillName: string, skillMdSha256: string | null, filesSha256: string | null }[]}
123
129
  */
124
- export function walkPlacementTarget(target) {
130
+ export function walkPlacementTarget(target, baseDir) {
125
131
  let parentDir;
126
132
  try {
127
- parentDir = resolvePlacementRoot(target);
133
+ parentDir = resolvePlacementRoot(target, baseDir);
128
134
  } catch {
129
135
  // Unknown target — surface as no placements rather than throwing.
130
136
  // Defensive: detect-agents + agent-registry should never produce