skillrepo 4.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.13.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": {
@@ -47,6 +47,7 @@
47
47
 
48
48
  import { runSync } from "../lib/sync.mjs";
49
49
  import { formatGlobalBoundaryDisclosure } from "../lib/global-boundary.mjs";
50
+ import { formatRepoComplianceSummary } from "../lib/foreign-content.mjs";
50
51
  import {
51
52
  resolveFlags,
52
53
  effectiveVendors,
@@ -191,6 +192,19 @@ export async function runUpdate(argv, io = {}) {
191
192
  } catch {
192
193
  // Degrade to no disclosure line.
193
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
+ }
194
208
  const writeDisclosureLine = () => {
195
209
  if (!disclosure) return;
196
210
  try {
@@ -200,12 +214,21 @@ export async function runUpdate(argv, io = {}) {
200
214
  // the cosmetic line must not become a "Sync failed" report.
201
215
  }
202
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
+ };
203
225
  if (summary.notModified || total === 0) {
204
226
  // 304 Not Modified OR 200 with zero deltas — silent by
205
- // contract (the boundary disclosure is the one sanctioned
206
- // exception). Users should not see "Syncing..." on every
207
- // 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.
208
230
  writeDisclosureLine();
231
+ writeComplianceLine();
209
232
  return;
210
233
  }
211
234
  stdout.write(
@@ -214,6 +237,7 @@ export async function runUpdate(argv, io = {}) {
214
237
  `.\n`,
215
238
  );
216
239
  writeDisclosureLine();
240
+ writeComplianceLine();
217
241
  } catch (err) {
218
242
  // The one-line failure message is the user's primary signal
219
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
@@ -421,10 +421,13 @@ export function formatGlobalBoundaryWarnings(
421
421
  // expands — is always the absolute (normalized) root + name.
422
422
  const globalPath = join(rootResult.root, safeName);
423
423
  if (entry.bucket === GLOBAL_SHADOWED_CATEGORY) {
424
+ // Shadowing only exists against a declared skillset's members, so
425
+ // this bucket is always a compliance statement (#2665).
424
426
  lines.push(
425
427
  ` warning: ${displayPath}/ has the same name as a skillset member. ` +
426
428
  `Depending on the agent, the global copy can load instead of the ` +
427
- `approved version. Remove the global copy, or rename your local skill.`,
429
+ `approved version. Remove the global copy, or rename your local ` +
430
+ `skill. Until then this repo is reported as not compliant.`,
428
431
  );
429
432
  } else if (entry.bucket === GLOBAL_LIBRARY_CATEGORY) {
430
433
  if (skillsetDeclared !== true) continue;
@@ -440,7 +443,20 @@ export function formatGlobalBoundaryWarnings(
440
443
  lines.push(
441
444
  ` warning: ${displayPath}/ is from your library but outside this ` +
442
445
  `repo's skillset, and agents load it in sessions here. Add it to ` +
443
- `the skillset, or remove the global copy at ${globalPath}.`,
446
+ `the skillset, or remove the global copy at ${globalPath}. Until ` +
447
+ `then this repo is reported as not compliant.`,
448
+ );
449
+ } else if (skillsetDeclared === true) {
450
+ // Foreign content in a DECLARED repo is a compliance statement
451
+ // (#2665): `skillrepo push` alone cannot restore compliance (it
452
+ // publishes to the library without touching the skillset, and the
453
+ // developer may not hold publish rights), so the governed path is
454
+ // named instead.
455
+ lines.push(
456
+ ` warning: ${displayPath}/ is not from your library, but agents ` +
457
+ `still load it from disk in sessions here. Remove the directory, ` +
458
+ `or publish it to your library and have a skillset manager add ` +
459
+ `it. Until then this repo is reported as not compliant.`,
444
460
  );
445
461
  } else {
446
462
  lines.push(
@@ -110,6 +110,15 @@ export const REPO_SYNC_GRACE_WINDOW_MS = 72 * 60 * 60 * 1000;
110
110
  * @property {string} version
111
111
  * @property {boolean} written - True = written/kept-current by the CLI
112
112
  * this sync; false = served-but-not-written (guard-refused).
113
+ * @property {string} [unwrittenReason] - WHY a `written: false` entry
114
+ * was not written (#2665): "modified" (local edit refused),
115
+ * "unmanaged" (hand-authored dir squatting the member name),
116
+ * "incomplete" (partial server payload), "invalid"
117
+ * (validation quarantine). LOCAL-ONLY — `buildRepoReceiptBlock`
118
+ * whitelists wire fields, so this never leaves the machine.
119
+ * Absent on entries written by older CLIs; readers must
120
+ * tolerate that (the compliance line buckets unknowns as
121
+ * "not delivered as approved", never as an edit claim).
113
122
  * @property {string} [skillMdSha256] - Payload SHA baseline; written
114
123
  * entries only.
115
124
  * @property {string} [filesSha256] - Payload SHA baseline; written
package/src/lib/sync.mjs CHANGED
@@ -148,6 +148,21 @@
148
148
  * `names` is LOCAL-ONLY (the disclosure
149
149
  * line and `--json`); receipts carry
150
150
  * counts-only categories, never names.
151
+ * @property {{skillsetRef: string, unmanagedCount: number, shadowedCount: number, globalBeyondCount: number, editRefusedCount: number, memberReplacedCount: number, notDeliveredCount: number}} [compliance] -
152
+ * Repo compliance state (#2665),
153
+ * counts-only — the input
154
+ * `formatRepoComplianceSummary`
155
+ * renders in hook mode. Presence
156
+ * semantics: PRESENT (possibly
157
+ * all-zero — the formatter returns
158
+ * null then) only on the scoped
159
+ * 200 and scoped 304 paths, where a
160
+ * declaration names the skillset to
161
+ * compare against; ABSENT on
162
+ * undeclared/whole-library syncs,
163
+ * the throttled early-exit, and the
164
+ * grace/fail-closed paths (no fresh
165
+ * scans there to state a fact from).
151
166
  * @property {string} syncedAt - ISO timestamp of the sync: the server
152
167
  * response `syncedAt` on a 200, or the
153
168
  * previously-cached sync timestamp on a
@@ -219,6 +234,7 @@ import {
219
234
  selectNewGlobalBoundaryFindings,
220
235
  normalizeStateKey,
221
236
  receiptViolationSummary,
237
+ formatRepoComplianceSummary,
222
238
  } from "./foreign-content.mjs";
223
239
  import {
224
240
  scanGlobalBoundary,
@@ -226,6 +242,9 @@ import {
226
242
  formatGlobalBoundaryWarnings,
227
243
  managedGlobalNamesFrom,
228
244
  resolveBoundaryMemberContext,
245
+ GLOBAL_SHADOWED_CATEGORY,
246
+ GLOBAL_LIBRARY_CATEGORY,
247
+ GLOBAL_FOREIGN_CATEGORY,
229
248
  } from "./global-boundary.mjs";
230
249
  import { resolveDeclaration } from "./skillset-declaration.mjs";
231
250
  import {
@@ -1659,6 +1678,7 @@ async function runSkillsetScopedSync({
1659
1678
  declarationDir: rootDir,
1660
1679
  baseDir: rootDir,
1661
1680
  scan: scan304,
1681
+ skillsetRef: declaration.use,
1662
1682
  });
1663
1683
  // Global-boundary scan on the scoped 304 (#2495) — the omission
1664
1684
  // contract runs it on EVERY scoped 200 AND 304, no skippable path.
@@ -1681,6 +1701,16 @@ async function runSkillsetScopedSync({
1681
1701
  ...(freshViolationSummary.count > 0 ? [freshViolationSummary] : []),
1682
1702
  ...receiptGlobalViolations(gbScan304),
1683
1703
  ];
1704
+ // Compliance state line (#2665) — always, from the FRESH scans + the
1705
+ // stored served set; hook mode carries it via the summary instead.
1706
+ const compliance304 = composeRepoCompliance({
1707
+ skillsetRef: declaration.use,
1708
+ foreignCount: scan304.foreignCount,
1709
+ gbScan: gbScan304,
1710
+ resolved: prior.resolved,
1711
+ stderr,
1712
+ hookMode: throttle === true,
1713
+ });
1684
1714
 
1685
1715
  // Stamp BOTH clocks — lastAttemptAt (throttle re-arm, same
1686
1716
  // load-bearing rationale as the bulk 304 path) and lastVerifiedAt
@@ -1734,6 +1764,7 @@ async function runSkillsetScopedSync({
1734
1764
  fullSync: false,
1735
1765
  unmanaged,
1736
1766
  globalBoundary: gbScan304.summary,
1767
+ compliance: compliance304,
1737
1768
  syncedAt: prior.lastSyncedAt ?? nowIso,
1738
1769
  };
1739
1770
  }
@@ -1844,7 +1875,11 @@ async function runSkillsetScopedSync({
1844
1875
  // written, local state does not advance (etag not persisted →
1845
1876
  // retried next session), and whatever is on disk stays.
1846
1877
  anyIncomplete = true;
1847
- resolvedEntries.push({ ...baseEntry, written: false });
1878
+ resolvedEntries.push({
1879
+ ...baseEntry,
1880
+ written: false,
1881
+ unwrittenReason: "incomplete",
1882
+ });
1848
1883
  continue;
1849
1884
  }
1850
1885
 
@@ -1860,12 +1895,23 @@ async function runSkillsetScopedSync({
1860
1895
  // Serve-refused: the guard protects local work over delivery.
1861
1896
  // Counted in `skipped` so interactive and hook summaries can't
1862
1897
  // go silent on withheld content; `written: false` carries the
1863
- // durable drift signal in state + receipt.
1898
+ // durable drift signal in state + receipt. The refusal REASON is
1899
+ // kept locally (#2665): "modified" (a genuine local edit) and
1900
+ // "unmanaged" (a hand-authored dir squatting the member's name)
1901
+ // are different compliance facts and the state line must not
1902
+ // call the latter an edit. A mixed multi-target refusal reads
1903
+ // "modified" — an edit anywhere is the stronger claim.
1864
1904
  for (const refusal of decision.refusals) {
1865
1905
  stderr.write(`${formatServeRefusalWarning(refusal, skill)}\n`);
1866
1906
  }
1867
1907
  summary.skipped++;
1868
- resolvedEntries.push({ ...baseEntry, written: false });
1908
+ resolvedEntries.push({
1909
+ ...baseEntry,
1910
+ written: false,
1911
+ unwrittenReason: decision.refusals.some((r) => r.reason === "modified")
1912
+ ? "modified"
1913
+ : "unmanaged",
1914
+ });
1869
1915
  continue;
1870
1916
  }
1871
1917
 
@@ -1889,7 +1935,11 @@ async function runSkillsetScopedSync({
1889
1935
  ` warning: skipped ${escapeControlChars(skill.owner)}/${escapeControlChars(skill.name)} (${err.message}). ` +
1890
1936
  `Other skills were still synced; this one will be retried next session.\n`,
1891
1937
  );
1892
- resolvedEntries.push({ ...baseEntry, written: false });
1938
+ resolvedEntries.push({
1939
+ ...baseEntry,
1940
+ written: false,
1941
+ unwrittenReason: "invalid",
1942
+ });
1893
1943
  continue;
1894
1944
  }
1895
1945
  if (wasAlreadyOnDisk) {
@@ -1936,6 +1986,7 @@ async function runSkillsetScopedSync({
1936
1986
  declarationDir: rootDir,
1937
1987
  baseDir: rootDir,
1938
1988
  scan,
1989
+ skillsetRef: declaration.use,
1939
1990
  });
1940
1991
 
1941
1992
  // Global-boundary scan (#2495): the disclosure counterpart to the
@@ -1959,6 +2010,16 @@ async function runSkillsetScopedSync({
1959
2010
  skillsetDeclared: true,
1960
2011
  });
1961
2012
  summary.globalBoundary = gbScan.summary;
2013
+ // Compliance state line (#2665) — always, from this sync's fresh scans
2014
+ // + served set; hook mode carries it via the summary instead.
2015
+ summary.compliance = composeRepoCompliance({
2016
+ skillsetRef: declaration.use,
2017
+ foreignCount: scan.foreignCount,
2018
+ gbScan,
2019
+ resolved: resolvedEntries,
2020
+ stderr,
2021
+ hookMode: throttle === true,
2022
+ });
1962
2023
  const violationSummary = receiptViolationSummary(scan);
1963
2024
  const violations = [
1964
2025
  ...(violationSummary.count > 0 ? [violationSummary] : []),
@@ -2584,7 +2645,7 @@ function emitGlobalBoundaryWarnings({ scan, stderr, hookMode, repoKey, skillsetD
2584
2645
  * @returns {number} Unmanaged-dir count across scanned roots (the
2585
2646
  * summary's `unmanaged` field — total, not just new).
2586
2647
  */
2587
- function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMode, declarationDir, baseDir, scan: precomputedScan }) {
2648
+ function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMode, declarationDir, baseDir, scan: precomputedScan, skillsetRef }) {
2588
2649
  let unmanagedCount = 0;
2589
2650
  try {
2590
2651
  const scan =
@@ -2595,7 +2656,9 @@ function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMo
2595
2656
 
2596
2657
  const seen = readGovernanceSeen();
2597
2658
  const newScan = selectNewFindings(scan, seen);
2598
- for (const line of formatForeignWarnings(newScan)) {
2659
+ // Declared repos get the compliance-framed per-dir copy (#2665);
2660
+ // undeclared repos keep the library-framed push-or-remove copy.
2661
+ for (const line of formatForeignWarnings(newScan, { skillsetRef })) {
2599
2662
  stderr.write(`${line}\n`);
2600
2663
  }
2601
2664
 
@@ -2622,6 +2685,70 @@ function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMo
2622
2685
  return unmanagedCount;
2623
2686
  }
2624
2687
 
2688
+ /**
2689
+ * Compose the repo's compliance state (#2665) from the two governance
2690
+ * scans + the served set, emit the ONE state line on interactive scoped
2691
+ * syncs, and return the counts for the sync summary. STATE, not event:
2692
+ * unlike the per-dir warnings this is never gated on `.governance-seen`
2693
+ * — it prints on EVERY scoped sync while any locally-observable cause
2694
+ * persists (owner decision 2026-08-20, "always report"). Hook mode
2695
+ * emits nothing here (both streams are black-holed by the hook runner);
2696
+ * `update --session-hook` formats the same counts off the returned
2697
+ * summary onto the session's stdout, the #2495 disclosure pattern.
2698
+ * Formatting/writing is its own failure domain — a defect degrades to
2699
+ * "no line", never a failed sync.
2700
+ *
2701
+ * @param {object} args
2702
+ * @param {string} args.skillsetRef - The declared `owner/name`.
2703
+ * @param {number} args.foreignCount - Project-scope foreign dirs.
2704
+ * @param {import("./global-boundary.mjs").GlobalBoundaryScan} args.gbScan
2705
+ * @param {import("./repo-sync-state.mjs").RepoResolvedEntry[]} args.resolved
2706
+ * @param {NodeJS.WritableStream} args.stderr
2707
+ * @param {boolean} args.hookMode
2708
+ * @returns {{skillsetRef: string, unmanagedCount: number, shadowedCount: number, globalBeyondCount: number, editRefusedCount: number, memberReplacedCount: number, notDeliveredCount: number}}
2709
+ */
2710
+ function composeRepoCompliance({ skillsetRef, foreignCount, gbScan, resolved, stderr, hookMode }) {
2711
+ // Bucket the served-but-not-written entries by their recorded reason
2712
+ // (#2665 review): "modified" is the only bucket allowed to claim a
2713
+ // local edit. "unmanaged" (a hand-authored dir squatting a member's
2714
+ // name — invisible to the foreign scan because the name IS managed)
2715
+ // gets its own bucket rather than folding into `unmanagedCount`, so
2716
+ // the state line's extras count stays consistent with the receipt's
2717
+ // scan-derived `unmanaged` violation count. Everything else —
2718
+ // "incomplete", "invalid", and entries from older CLIs/state files
2719
+ // that carry no reason — reads as the neutral "not delivered as
2720
+ // approved", never as an edit accusation.
2721
+ let editRefusedCount = 0;
2722
+ let memberReplacedCount = 0;
2723
+ let notDeliveredCount = 0;
2724
+ for (const e of resolved ?? []) {
2725
+ if (!e || e.written === true) continue;
2726
+ if (e.unwrittenReason === "modified") editRefusedCount += 1;
2727
+ else if (e.unwrittenReason === "unmanaged") memberReplacedCount += 1;
2728
+ else notDeliveredCount += 1;
2729
+ }
2730
+ const compliance = {
2731
+ skillsetRef,
2732
+ unmanagedCount: foreignCount ?? 0,
2733
+ shadowedCount: gbScan?.counts?.[GLOBAL_SHADOWED_CATEGORY] ?? 0,
2734
+ globalBeyondCount:
2735
+ (gbScan?.counts?.[GLOBAL_LIBRARY_CATEGORY] ?? 0) +
2736
+ (gbScan?.counts?.[GLOBAL_FOREIGN_CATEGORY] ?? 0),
2737
+ editRefusedCount,
2738
+ memberReplacedCount,
2739
+ notDeliveredCount,
2740
+ };
2741
+ if (!hookMode) {
2742
+ try {
2743
+ const line = formatRepoComplianceSummary(compliance);
2744
+ if (line) stderr.write(`${line}\n`);
2745
+ } catch {
2746
+ // Best-effort by design — the state line must never fail a sync.
2747
+ }
2748
+ }
2749
+ return compliance;
2750
+ }
2751
+
2625
2752
  /**
2626
2753
  * Build sync-receipt entries from a `.last-sync` skills map (#1832).
2627
2754
  *
@@ -24,6 +24,7 @@ import { execFileSync } from "node:child_process";
24
24
  import {
25
25
  scanForeignContent,
26
26
  formatForeignWarnings,
27
+ formatRepoComplianceSummary,
27
28
  receiptViolationSummary,
28
29
  checkDeclarationGitignored,
29
30
  formatDeclarationIgnoredWarning,
@@ -199,6 +200,38 @@ describe("formatForeignWarnings", () => {
199
200
  }
200
201
  });
201
202
 
203
+ it("declared repos get compliance-framed copy: consequence stated, no 'skillrepo push' (#2665)", () => {
204
+ seedDir(".claude/skills", "hand-rolled");
205
+ const scan = scanForeignContent({
206
+ vendors: ["claudeCode"],
207
+ managedSkills: MANAGED,
208
+ });
209
+ const lines = formatForeignWarnings(scan, { skillsetRef: "acme/backend-core" });
210
+ assert.equal(lines.length, 1);
211
+ assert.match(lines[0], /^ {2}warning: \.claude\/skills\/hand-rolled\//);
212
+ assert.match(lines[0], /is not part of this repo's skillset \(acme\/backend-core\)/);
213
+ assert.match(lines[0], /Remove the directory, or ask a skillset manager to add the skill\./);
214
+ assert.match(lines[0], /Until then this repo is reported as not compliant\.$/);
215
+ assert.ok(
216
+ !lines[0].includes("skillrepo push"),
217
+ "push cannot restore compliance in a declared repo (#2665)",
218
+ );
219
+ assert.ok(!lines[0].includes("!"), "copy rules: no exclamation marks");
220
+ });
221
+
222
+ it("a null/absent skillsetRef keeps the library-framed push-or-remove copy", () => {
223
+ seedDir(".claude/skills", "hand-rolled");
224
+ const scan = scanForeignContent({
225
+ vendors: ["claudeCode"],
226
+ managedSkills: MANAGED,
227
+ });
228
+ for (const options of [undefined, {}, { skillsetRef: null }]) {
229
+ const [line] = formatForeignWarnings(scan, options);
230
+ assert.match(line, /is not from your library/);
231
+ assert.match(line, /skillrepo push/);
232
+ }
233
+ });
234
+
202
235
  it("emits nothing for a clean scan", () => {
203
236
  const scan = scanForeignContent({
204
237
  vendors: ["claudeCode"],
@@ -208,6 +241,100 @@ describe("formatForeignWarnings", () => {
208
241
  });
209
242
  });
210
243
 
244
+ describe("formatRepoComplianceSummary (#2665)", () => {
245
+ it("returns null when every count is zero (a compliant repo says nothing)", () => {
246
+ assert.equal(
247
+ formatRepoComplianceSummary({ skillsetRef: "acme/backend-core" }),
248
+ null,
249
+ );
250
+ assert.equal(
251
+ formatRepoComplianceSummary({
252
+ skillsetRef: "acme/backend-core",
253
+ unmanagedCount: 0,
254
+ shadowedCount: 0,
255
+ globalBeyondCount: 0,
256
+ editRefusedCount: 0,
257
+ hookMode: true,
258
+ }),
259
+ null,
260
+ );
261
+ });
262
+
263
+ it("interactive: one warning sentence naming the skillset, the causes, and the consequence", () => {
264
+ const line = formatRepoComplianceSummary({
265
+ skillsetRef: "acme/backend-core",
266
+ unmanagedCount: 2,
267
+ shadowedCount: 1,
268
+ globalBeyondCount: 3,
269
+ editRefusedCount: 1,
270
+ });
271
+ assert.equal(
272
+ line,
273
+ " warning: this repo does not match its skillset acme/backend-core: " +
274
+ "2 extra skills in the project, 1 member shadowed by a global copy, " +
275
+ "3 global skills beyond the set, 1 member edited locally. " +
276
+ "It is reported as not compliant to your organization.",
277
+ );
278
+ assert.ok(!line.includes("!"), "copy rules: no exclamation marks");
279
+ assert.ok(
280
+ !line.includes("account") && !line.includes("team"),
281
+ "copy rules: 'organization', never 'account'/'team'",
282
+ );
283
+ });
284
+
285
+ it("hook mode: one [SkillRepo]-prefixed line for the agent session", () => {
286
+ const line = formatRepoComplianceSummary({
287
+ skillsetRef: "acme/backend-core",
288
+ unmanagedCount: 1,
289
+ hookMode: true,
290
+ });
291
+ assert.equal(
292
+ line,
293
+ "[SkillRepo] This repo does not match its skillset acme/backend-core " +
294
+ "(1 extra skill in the project) — it is reported as not compliant.",
295
+ );
296
+ });
297
+
298
+ it("includes only nonzero causes and pluralizes each", () => {
299
+ const line = formatRepoComplianceSummary({
300
+ skillsetRef: "acme/x",
301
+ shadowedCount: 2,
302
+ });
303
+ assert.match(line, /2 members shadowed by a global copy/);
304
+ assert.ok(!line.includes("extra skill"));
305
+ assert.ok(!line.includes("beyond the set"));
306
+ assert.ok(!line.includes("edited locally"));
307
+ const singular = formatRepoComplianceSummary({
308
+ skillsetRef: "acme/x",
309
+ globalBeyondCount: 1,
310
+ editRefusedCount: 2,
311
+ });
312
+ assert.match(singular, /1 global skill beyond the set/);
313
+ assert.match(singular, /2 members edited locally/);
314
+ });
315
+
316
+ it("keeps replaced-by-unmanaged and not-delivered as their own causes — never phrased as edits (#2665 review)", () => {
317
+ const line = formatRepoComplianceSummary({
318
+ skillsetRef: "acme/x",
319
+ memberReplacedCount: 1,
320
+ notDeliveredCount: 2,
321
+ });
322
+ assert.match(line, /1 member replaced by unmanaged content/);
323
+ assert.match(line, /2 members not delivered as approved/);
324
+ assert.ok(
325
+ !line.includes("edited"),
326
+ "server-side and squatting causes must never claim a local edit",
327
+ );
328
+ const plural = formatRepoComplianceSummary({
329
+ skillsetRef: "acme/x",
330
+ memberReplacedCount: 2,
331
+ notDeliveredCount: 1,
332
+ });
333
+ assert.match(plural, /2 members replaced by unmanaged content/);
334
+ assert.match(plural, /1 member not delivered as approved/);
335
+ });
336
+ });
337
+
211
338
  describe("receiptViolationSummary — the H7/#2111 privacy seam", () => {
212
339
  it("carries category and count ONLY — never names", () => {
213
340
  seedDir(".claude/skills", "secret-internal-tool");
@@ -463,7 +463,7 @@ describe("formatGlobalBoundaryWarnings", () => {
463
463
  assert.match(lines[0], /is from your library but outside this repo's skillset/);
464
464
  // Separator-agnostic: join() emits "\linter" on Windows, "/linter"
465
465
  // elsewhere — the path is real either way.
466
- assert.match(lines[0], /Add it to the skillset, or remove the global copy at .*[/\\]linter\.$/);
466
+ assert.match(lines[0], /Add it to the skillset, or remove the global copy at .*[/\\]linter\./);
467
467
  // Must NOT suggest `skillrepo remove`: that deletes the item from the
468
468
  // whole library (DELETE /api/v1/library/{owner}/{name}), and a bare
469
469
  // name can't address it anyway — the CLI rejects it (#2495 audit).
@@ -498,6 +498,41 @@ describe("formatGlobalBoundaryWarnings", () => {
498
498
  );
499
499
  });
500
500
 
501
+ it("declared repos state the compliance consequence on every bucket (#2665)", () => {
502
+ seedGlobal(".claude/skills", "deploy"); // shadows a member
503
+ seedGlobal(".claude/skills", "linter"); // library, outside the set
504
+ seedGlobal(".claude/skills", "stranger"); // foreign
505
+ const scan = scanClaude({
506
+ memberNames: new Set(["deploy"]),
507
+ managedGlobalNames: new Set(["linter"]),
508
+ });
509
+ const lines = formatGlobalBoundaryWarnings(scan, { skillsetDeclared: true });
510
+ assert.equal(lines.length, 3);
511
+ for (const line of lines) {
512
+ assert.match(
513
+ line,
514
+ /Until then this repo is reported as not compliant\.$/,
515
+ `consequence missing on: ${line}`,
516
+ );
517
+ assert.ok(!line.includes("!"), "copy rules: no exclamation marks");
518
+ }
519
+ });
520
+
521
+ it("foreign (declared): governed remediation, never a bare 'skillrepo push' (#2665)", () => {
522
+ seedGlobal(".claude/skills", "stranger");
523
+ const scan = scanClaude();
524
+ const [line] = formatGlobalBoundaryWarnings(scan, { skillsetDeclared: true });
525
+ assert.match(line, /is not from your library/);
526
+ assert.match(
527
+ line,
528
+ /Remove the directory, or publish it to your library and have a skillset manager add it\./,
529
+ );
530
+ assert.ok(
531
+ !line.includes("skillrepo push"),
532
+ "push alone cannot restore compliance in a declared repo",
533
+ );
534
+ });
535
+
501
536
  it("escapes control characters in warning lines", { skip: platform() === "win32" }, () => {
502
537
  seedGlobal(".claude/skills", "evil\u001b]0;pwned\u0007");
503
538
  const [line] = formatGlobalBoundaryWarnings(scanClaude(), {
@@ -1467,3 +1467,185 @@ describe("skillset-scoped sync — session-hook mode exits 0 on every failure cl
1467
1467
  assert.match(stdout.text(), /^\[SkillRepo\] Sync failed: .+\n$/);
1468
1468
  });
1469
1469
  });
1470
+
1471
+ // ── Repo compliance state line (#2665) ─────────────────────────────────
1472
+
1473
+ describe("repo compliance state line (#2665) — always on while non-compliant", () => {
1474
+ beforeEach(setupServer);
1475
+ afterEach(teardownServer);
1476
+
1477
+ const STATE_LINE = /this repo does not match its skillset acme\/backend-core/;
1478
+
1479
+ it("prints on EVERY interactive scoped sync while per-dir details stay warn-on-new", async () => {
1480
+ declareRepo();
1481
+ server.setSkillsetEtag(SS_ETAG_A);
1482
+ server.setSkillsetResponse(scopedResponse([makeSkill("alpha")]));
1483
+ const stderr0 = createCaptureStream();
1484
+ await scopedSync({ io: { stderr: stderr0 } });
1485
+ assert.ok(!STATE_LINE.test(stderr0.text()), "a compliant repo says nothing");
1486
+
1487
+ const handmadeDir = join(repoRoot(), ".claude", "skills", "handmade");
1488
+ mkdirSync(handmadeDir, { recursive: true });
1489
+ writeFileSync(join(handmadeDir, "SKILL.md"), "---\nname: handmade\n---\n");
1490
+
1491
+ // Second sync (304): the per-dir warning wears the declared-repo copy
1492
+ // AND the one-line state summary prints.
1493
+ const stderr1 = createCaptureStream();
1494
+ await scopedSync({ io: { stderr: stderr1 } });
1495
+ const t1 = stderr1.text();
1496
+ assert.match(
1497
+ t1,
1498
+ /handmade\/ is not part of this repo's skillset \(acme\/backend-core\)/,
1499
+ "declared repos get the compliance-framed per-dir copy",
1500
+ );
1501
+ assert.match(t1, /Until then this repo is reported as not compliant\./);
1502
+ assert.ok(!t1.includes("skillrepo push"), "no push remediation in a declared repo");
1503
+ assert.match(t1, STATE_LINE);
1504
+ assert.match(
1505
+ t1,
1506
+ /1 extra skill in the project\. It is reported as not compliant to your organization\./,
1507
+ );
1508
+
1509
+ // Third sync: the per-dir detail is seen-state-gated away; the STATE
1510
+ // line still prints — a state, not an event (owner decision
1511
+ // 2026-08-20, "always report").
1512
+ const stderr2 = createCaptureStream();
1513
+ await scopedSync({ io: { stderr: stderr2 } });
1514
+ const t2 = stderr2.text();
1515
+ assert.ok(!t2.includes("handmade"), "per-dir detail stays warn-on-new");
1516
+ assert.match(t2, STATE_LINE, "state line prints on every sync while non-compliant");
1517
+ });
1518
+
1519
+ it("counts edit-refused members in the state line", async () => {
1520
+ declareRepo();
1521
+ server.setSkillsetEtag(SS_ETAG_A);
1522
+ server.setSkillsetResponse(scopedResponse([makeSkill("alpha"), makeSkill("beta")]));
1523
+ await scopedSync();
1524
+ appendFileSync(
1525
+ join(repoRoot(), ".claude", "skills", "beta", "SKILL.md"),
1526
+ "\nlocal edit\n",
1527
+ );
1528
+ // The skillset moves on, so the next sync is a 200 full-set reconcile
1529
+ // that serve-refuses the edited beta (written: false).
1530
+ server.setSkillsetEtag(SS_ETAG_B);
1531
+ server.setSkillsetResponse(scopedResponse([makeSkill("alpha"), makeSkill("beta")]));
1532
+ const stderr = createCaptureStream();
1533
+ await scopedSync({ io: { stderr } });
1534
+ assert.match(stderr.text(), STATE_LINE);
1535
+ assert.match(stderr.text(), /1 member edited locally/);
1536
+ });
1537
+
1538
+ it("session-hook mode surfaces the state line on stdout with the [SkillRepo] prefix, counts only", async () => {
1539
+ declareRepo();
1540
+ server.setSkillsetEtag(SS_ETAG_A);
1541
+ server.setSkillsetResponse(scopedResponse([makeSkill("alpha")]));
1542
+ const handmadeDir = join(repoRoot(), ".claude", "skills", "handmade");
1543
+ mkdirSync(handmadeDir, { recursive: true });
1544
+ writeFileSync(join(handmadeDir, "SKILL.md"), "---\nname: handmade\n---\n");
1545
+
1546
+ const stdout = createCaptureStream();
1547
+ await runUpdate(
1548
+ ["--session-hook", "--key", VALID_KEY, "--url", serverUrl],
1549
+ { stdout },
1550
+ );
1551
+ const out = stdout.text();
1552
+ assert.match(out, /\[SkillRepo\] Library synced: 1 added/);
1553
+ assert.match(
1554
+ out,
1555
+ /\[SkillRepo\] This repo does not match its skillset acme\/backend-core \(1 extra skill in the project\) — it is reported as not compliant\./,
1556
+ );
1557
+ assert.ok(
1558
+ !out.includes("handmade"),
1559
+ "hook line is counts-only — no dir names reach the session output",
1560
+ );
1561
+ });
1562
+
1563
+ it("a hand-authored dir squatting a MEMBER name reads 'replaced by unmanaged content', never 'edited locally' (#2665 review)", async () => {
1564
+ declareRepo();
1565
+ // The squat exists BEFORE the first sync: the CLI never wrote alpha,
1566
+ // so decideScopedWrite refuses with reason "unmanaged".
1567
+ const squat = join(repoRoot(), ".claude", "skills", "alpha");
1568
+ mkdirSync(squat, { recursive: true });
1569
+ writeFileSync(join(squat, "SKILL.md"), "---\nname: alpha\n---\nhand-authored\n");
1570
+
1571
+ server.setSkillsetEtag(SS_ETAG_A);
1572
+ server.setSkillsetResponse(scopedResponse([makeSkill("alpha")]));
1573
+ const stderr = createCaptureStream();
1574
+ const result = await scopedSync({ io: { stderr } });
1575
+ const t = stderr.text();
1576
+
1577
+ assert.match(t, /1 member replaced by unmanaged content/);
1578
+ assert.ok(!t.includes("edited locally"), "a squat is not an edit");
1579
+ // Not folded into the extras count either — that stays scan-derived so
1580
+ // it matches the receipt's `unmanaged` violation category (the squat's
1581
+ // name IS a managed member name, so the foreign scan never counts it).
1582
+ assert.ok(!t.includes("extra skill in the project"));
1583
+ assert.equal(result.compliance.memberReplacedCount, 1);
1584
+ assert.equal(result.compliance.editRefusedCount, 0);
1585
+ assert.equal(result.compliance.unmanagedCount, 0);
1586
+
1587
+ // Wire whitelist proof: the receipt entry carries exactly the four
1588
+ // fields — the local-only unwrittenReason never leaves the machine.
1589
+ const receipt = server.getLastReceipt();
1590
+ assert.deepEqual(receipt.repo.resolved, [
1591
+ { owner: "acme", name: "alpha", version: "1.0.0", written: false },
1592
+ ]);
1593
+ });
1594
+
1595
+ it("a filesIncomplete member reads 'not delivered as approved', never 'edited locally' (#2665 review)", async () => {
1596
+ declareRepo();
1597
+ server.setSkillsetEtag(SS_ETAG_A);
1598
+ server.setSkillsetResponse(
1599
+ scopedResponse([{ ...makeSkill("alpha"), filesIncomplete: true }]),
1600
+ );
1601
+ const stderr = createCaptureStream();
1602
+ const result = await scopedSync({ io: { stderr } });
1603
+ const t = stderr.text();
1604
+ assert.match(t, /1 member not delivered as approved/);
1605
+ assert.ok(!t.includes("edited locally"), "a server-side gap is not an edit");
1606
+ assert.equal(result.compliance.notDeliveredCount, 1);
1607
+ assert.equal(result.compliance.editRefusedCount, 0);
1608
+ });
1609
+
1610
+ it("a legacy state entry without unwrittenReason buckets as not-delivered on the 304 path (#2665 review)", async () => {
1611
+ declareRepo();
1612
+ server.setSkillsetEtag(SS_ETAG_A);
1613
+ server.setSkillsetResponse(scopedResponse([makeSkill("alpha")]));
1614
+ await scopedSync();
1615
+
1616
+ // Simulate state written by an older CLI: written:false, no reason.
1617
+ const prior = readRepoSyncState(repoRoot());
1618
+ writeRepoSyncState(repoRoot(), {
1619
+ ...prior,
1620
+ resolved: prior.resolved.map((e) => ({
1621
+ owner: e.owner,
1622
+ name: e.name,
1623
+ version: e.version,
1624
+ written: false,
1625
+ })),
1626
+ });
1627
+
1628
+ const stderr = createCaptureStream();
1629
+ const result = await scopedSync({ io: { stderr } });
1630
+ assert.equal(result.notModified, true);
1631
+ const t = stderr.text();
1632
+ assert.match(t, /1 member not delivered as approved/);
1633
+ assert.ok(!t.includes("edited locally"), "no false edit claim for unknown legacy reasons");
1634
+ assert.equal(result.compliance.notDeliveredCount, 1);
1635
+ });
1636
+
1637
+ it("a global copy shadowing a member flows through the real boundary scan into the state line (#2665 review)", async () => {
1638
+ declareRepo();
1639
+ // Global scope lives under the sandbox HOME set by setupServer.
1640
+ const globalDir = join(sandbox, "home", ".claude", "skills", "alpha");
1641
+ mkdirSync(globalDir, { recursive: true });
1642
+ writeFileSync(join(globalDir, "SKILL.md"), "---\nname: alpha\n---\nglobal copy\n");
1643
+
1644
+ server.setSkillsetEtag(SS_ETAG_A);
1645
+ server.setSkillsetResponse(scopedResponse([makeSkill("alpha")]));
1646
+ const stderr = createCaptureStream();
1647
+ const result = await scopedSync({ io: { stderr } });
1648
+ assert.equal(result.compliance.shadowedCount, 1);
1649
+ assert.match(stderr.text(), /1 member shadowed by a global copy/);
1650
+ });
1651
+ });