skillrepo 4.13.0 → 4.14.1

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.1",
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,
@@ -64,6 +65,8 @@ import {
64
65
  *
65
66
  * - 304 Not Modified → exit 0, NO output.
66
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.)
67
70
  * - Any failure → exit 0, ONE line: `[SkillRepo] Sync failed: <reason>.`
68
71
  * - Global-boundary disclosure (#2495): when the sync's summary
69
72
  * reports global skills that will also load in this session
@@ -171,6 +174,7 @@ export async function runUpdate(argv, io = {}) {
171
174
  const skipped = summary.skipped ?? 0;
172
175
  const total =
173
176
  summary.added + summary.updated + summary.removed + skipped;
177
+ const subject = syncSubject(summary);
174
178
  // Global-boundary disclosure (#2495), computed BEFORE the silent
175
179
  // branch below: the quiet 304/zero-delta/throttled session is
176
180
  // the COMMON session, and it must still disclose — the line
@@ -191,6 +195,19 @@ export async function runUpdate(argv, io = {}) {
191
195
  } catch {
192
196
  // Degrade to no disclosure line.
193
197
  }
198
+ // Repo compliance state (#2665, owner decision 2026-08-20): a
199
+ // skillset-declared repo that does not match its set says so in
200
+ // EVERY session, hook mode included — a state, never a
201
+ // warn-on-new event. Same failure domain as the disclosure: a
202
+ // formatter defect degrades to no line, never a failed sync.
203
+ let compliance = null;
204
+ try {
205
+ compliance = summary.compliance
206
+ ? formatRepoComplianceSummary({ ...summary.compliance, hookMode: true })
207
+ : null;
208
+ } catch {
209
+ // Degrade to no compliance line.
210
+ }
194
211
  const writeDisclosureLine = () => {
195
212
  if (!disclosure) return;
196
213
  try {
@@ -200,20 +217,30 @@ export async function runUpdate(argv, io = {}) {
200
217
  // the cosmetic line must not become a "Sync failed" report.
201
218
  }
202
219
  };
220
+ const writeComplianceLine = () => {
221
+ if (!compliance) return;
222
+ try {
223
+ stdout.write(`${compliance}\n`);
224
+ } catch {
225
+ // Same failure domain as the disclosure write above.
226
+ }
227
+ };
203
228
  if (summary.notModified || total === 0) {
204
229
  // 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.
230
+ // contract (the boundary disclosure and the compliance state
231
+ // are the sanctioned exceptions). Users should not see
232
+ // "Syncing..." on every session for no visible value.
208
233
  writeDisclosureLine();
234
+ writeComplianceLine();
209
235
  return;
210
236
  }
211
237
  stdout.write(
212
- `[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` +
213
239
  (skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
214
240
  `.\n`,
215
241
  );
216
242
  writeDisclosureLine();
243
+ writeComplianceLine();
217
244
  } catch (err) {
218
245
  // The one-line failure message is the user's primary signal
219
246
  // that something's wrong. Do not surface a stack trace — the
@@ -314,17 +341,28 @@ export async function runUpdate(argv, io = {}) {
314
341
  printSummary(summary, stdout);
315
342
  }
316
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
+
317
354
  function printSummary(s, out) {
318
355
  // `skipped` counts here: a run that dropped a skill is NOT "up to date",
319
356
  // and saying so was a false statement to the user (#2413 adversarial
320
357
  // review — the counter was incremented and never read).
321
358
  const skipped = s.skipped ?? 0;
322
359
  const total = s.added + s.updated + s.removed + skipped;
360
+ const subject = syncSubject(s);
323
361
  if (s.notModified || total === 0) {
324
- out.write("Library is up to date.\n");
362
+ out.write(`${subject} is up to date.\n`);
325
363
  return;
326
364
  }
327
- out.write("\n Library sync complete:\n");
365
+ out.write(`\n ${subject} sync complete:\n`);
328
366
  if (s.added > 0) out.write(` + ${s.added} added\n`);
329
367
  if (s.updated > 0) out.write(` ↻ ${s.updated} updated\n`);
330
368
  if (s.removed > 0) out.write(` − ${s.removed} removed\n`);
@@ -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,39 @@
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).
166
+ * @property {string} [skillsetRef] - The skillset this run was scoped to
167
+ * (`declaration.use`), stamped by the
168
+ * `runSync` dispatcher on EVERY return of
169
+ * `runSkillsetScopedSync` — 200, 304,
170
+ * grace/fail-closed, and that body's own
171
+ * per-repo throttle exit. Unlike
172
+ * `compliance.skillsetRef` (a narrower
173
+ * subset), presence means "this run
174
+ * dispatched to the skillset-scoped body".
175
+ * ABSENT on whole-library syncs AND on the
176
+ * machine-global throttled early-exit in
177
+ * `runSync`'s own preamble — that path
178
+ * returns BEFORE the declaration is
179
+ * resolved, so a declared repo never
180
+ * reaches dispatch there (same exclusion
181
+ * `compliance` carries). Sound discriminator
182
+ * for scope-aware copy on any path that
183
+ * actually prints it (#2679).
151
184
  * @property {string} syncedAt - ISO timestamp of the sync: the server
152
185
  * response `syncedAt` on a 200, or the
153
186
  * previously-cached sync timestamp on a
@@ -219,6 +252,7 @@ import {
219
252
  selectNewGlobalBoundaryFindings,
220
253
  normalizeStateKey,
221
254
  receiptViolationSummary,
255
+ formatRepoComplianceSummary,
222
256
  } from "./foreign-content.mjs";
223
257
  import {
224
258
  scanGlobalBoundary,
@@ -226,6 +260,9 @@ import {
226
260
  formatGlobalBoundaryWarnings,
227
261
  managedGlobalNamesFrom,
228
262
  resolveBoundaryMemberContext,
263
+ GLOBAL_SHADOWED_CATEGORY,
264
+ GLOBAL_LIBRARY_CATEGORY,
265
+ GLOBAL_FOREIGN_CATEGORY,
229
266
  } from "./global-boundary.mjs";
230
267
  import { resolveDeclaration } from "./skillset-declaration.mjs";
231
268
  import {
@@ -928,7 +965,7 @@ export async function runSync(options) {
928
965
  // extracted verbatim; the golden wire-identity tests arbitrate that
929
966
  // its behavior is character-for-character unchanged.
930
967
  if (declarationResolution?.status === "declared") {
931
- return runSkillsetScopedSync({
968
+ const scopedSummary = await runSkillsetScopedSync({
932
969
  serverUrl,
933
970
  apiKey,
934
971
  vendors,
@@ -937,6 +974,18 @@ export async function runSync(options) {
937
974
  minSyncIntervalMs: intervalMs,
938
975
  resolution: declarationResolution,
939
976
  });
977
+ // Reliable top-level scope marker (#2679). EVERY scoped return is a
978
+ // skillset sync — including the grace/fail-closed path
979
+ // (`serveLastVerified`) and the defensive 304-no-prior branch, where the
980
+ // #2665 `compliance` object is intentionally ABSENT. Presentation
981
+ // (`printSummary`, the session-hook line) keys the "Skillset" vs
982
+ // "Library" noun off THIS field, never off `compliance.skillsetRef`
983
+ // (whose presence rules exclude the non-fresh-scan paths, so keying off
984
+ // it mislabels a scoped grace sync as "Library").
985
+ return {
986
+ ...scopedSummary,
987
+ skillsetRef: declarationResolution.declaration.use,
988
+ };
940
989
  }
941
990
 
942
991
  return runWholeLibrarySync({
@@ -1659,6 +1708,7 @@ async function runSkillsetScopedSync({
1659
1708
  declarationDir: rootDir,
1660
1709
  baseDir: rootDir,
1661
1710
  scan: scan304,
1711
+ skillsetRef: declaration.use,
1662
1712
  });
1663
1713
  // Global-boundary scan on the scoped 304 (#2495) — the omission
1664
1714
  // contract runs it on EVERY scoped 200 AND 304, no skippable path.
@@ -1681,6 +1731,16 @@ async function runSkillsetScopedSync({
1681
1731
  ...(freshViolationSummary.count > 0 ? [freshViolationSummary] : []),
1682
1732
  ...receiptGlobalViolations(gbScan304),
1683
1733
  ];
1734
+ // Compliance state line (#2665) — always, from the FRESH scans + the
1735
+ // stored served set; hook mode carries it via the summary instead.
1736
+ const compliance304 = composeRepoCompliance({
1737
+ skillsetRef: declaration.use,
1738
+ foreignCount: scan304.foreignCount,
1739
+ gbScan: gbScan304,
1740
+ resolved: prior.resolved,
1741
+ stderr,
1742
+ hookMode: throttle === true,
1743
+ });
1684
1744
 
1685
1745
  // Stamp BOTH clocks — lastAttemptAt (throttle re-arm, same
1686
1746
  // load-bearing rationale as the bulk 304 path) and lastVerifiedAt
@@ -1734,6 +1794,7 @@ async function runSkillsetScopedSync({
1734
1794
  fullSync: false,
1735
1795
  unmanaged,
1736
1796
  globalBoundary: gbScan304.summary,
1797
+ compliance: compliance304,
1737
1798
  syncedAt: prior.lastSyncedAt ?? nowIso,
1738
1799
  };
1739
1800
  }
@@ -1844,7 +1905,11 @@ async function runSkillsetScopedSync({
1844
1905
  // written, local state does not advance (etag not persisted →
1845
1906
  // retried next session), and whatever is on disk stays.
1846
1907
  anyIncomplete = true;
1847
- resolvedEntries.push({ ...baseEntry, written: false });
1908
+ resolvedEntries.push({
1909
+ ...baseEntry,
1910
+ written: false,
1911
+ unwrittenReason: "incomplete",
1912
+ });
1848
1913
  continue;
1849
1914
  }
1850
1915
 
@@ -1860,12 +1925,23 @@ async function runSkillsetScopedSync({
1860
1925
  // Serve-refused: the guard protects local work over delivery.
1861
1926
  // Counted in `skipped` so interactive and hook summaries can't
1862
1927
  // go silent on withheld content; `written: false` carries the
1863
- // durable drift signal in state + receipt.
1928
+ // durable drift signal in state + receipt. The refusal REASON is
1929
+ // kept locally (#2665): "modified" (a genuine local edit) and
1930
+ // "unmanaged" (a hand-authored dir squatting the member's name)
1931
+ // are different compliance facts and the state line must not
1932
+ // call the latter an edit. A mixed multi-target refusal reads
1933
+ // "modified" — an edit anywhere is the stronger claim.
1864
1934
  for (const refusal of decision.refusals) {
1865
1935
  stderr.write(`${formatServeRefusalWarning(refusal, skill)}\n`);
1866
1936
  }
1867
1937
  summary.skipped++;
1868
- resolvedEntries.push({ ...baseEntry, written: false });
1938
+ resolvedEntries.push({
1939
+ ...baseEntry,
1940
+ written: false,
1941
+ unwrittenReason: decision.refusals.some((r) => r.reason === "modified")
1942
+ ? "modified"
1943
+ : "unmanaged",
1944
+ });
1869
1945
  continue;
1870
1946
  }
1871
1947
 
@@ -1889,7 +1965,11 @@ async function runSkillsetScopedSync({
1889
1965
  ` warning: skipped ${escapeControlChars(skill.owner)}/${escapeControlChars(skill.name)} (${err.message}). ` +
1890
1966
  `Other skills were still synced; this one will be retried next session.\n`,
1891
1967
  );
1892
- resolvedEntries.push({ ...baseEntry, written: false });
1968
+ resolvedEntries.push({
1969
+ ...baseEntry,
1970
+ written: false,
1971
+ unwrittenReason: "invalid",
1972
+ });
1893
1973
  continue;
1894
1974
  }
1895
1975
  if (wasAlreadyOnDisk) {
@@ -1936,6 +2016,7 @@ async function runSkillsetScopedSync({
1936
2016
  declarationDir: rootDir,
1937
2017
  baseDir: rootDir,
1938
2018
  scan,
2019
+ skillsetRef: declaration.use,
1939
2020
  });
1940
2021
 
1941
2022
  // Global-boundary scan (#2495): the disclosure counterpart to the
@@ -1959,6 +2040,16 @@ async function runSkillsetScopedSync({
1959
2040
  skillsetDeclared: true,
1960
2041
  });
1961
2042
  summary.globalBoundary = gbScan.summary;
2043
+ // Compliance state line (#2665) — always, from this sync's fresh scans
2044
+ // + served set; hook mode carries it via the summary instead.
2045
+ summary.compliance = composeRepoCompliance({
2046
+ skillsetRef: declaration.use,
2047
+ foreignCount: scan.foreignCount,
2048
+ gbScan,
2049
+ resolved: resolvedEntries,
2050
+ stderr,
2051
+ hookMode: throttle === true,
2052
+ });
1962
2053
  const violationSummary = receiptViolationSummary(scan);
1963
2054
  const violations = [
1964
2055
  ...(violationSummary.count > 0 ? [violationSummary] : []),
@@ -2584,7 +2675,7 @@ function emitGlobalBoundaryWarnings({ scan, stderr, hookMode, repoKey, skillsetD
2584
2675
  * @returns {number} Unmanaged-dir count across scanned roots (the
2585
2676
  * summary's `unmanaged` field — total, not just new).
2586
2677
  */
2587
- function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMode, declarationDir, baseDir, scan: precomputedScan }) {
2678
+ function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMode, declarationDir, baseDir, scan: precomputedScan, skillsetRef }) {
2588
2679
  let unmanagedCount = 0;
2589
2680
  try {
2590
2681
  const scan =
@@ -2595,7 +2686,9 @@ function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMo
2595
2686
 
2596
2687
  const seen = readGovernanceSeen();
2597
2688
  const newScan = selectNewFindings(scan, seen);
2598
- for (const line of formatForeignWarnings(newScan)) {
2689
+ // Declared repos get the compliance-framed per-dir copy (#2665);
2690
+ // undeclared repos keep the library-framed push-or-remove copy.
2691
+ for (const line of formatForeignWarnings(newScan, { skillsetRef })) {
2599
2692
  stderr.write(`${line}\n`);
2600
2693
  }
2601
2694
 
@@ -2622,6 +2715,70 @@ function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMo
2622
2715
  return unmanagedCount;
2623
2716
  }
2624
2717
 
2718
+ /**
2719
+ * Compose the repo's compliance state (#2665) from the two governance
2720
+ * scans + the served set, emit the ONE state line on interactive scoped
2721
+ * syncs, and return the counts for the sync summary. STATE, not event:
2722
+ * unlike the per-dir warnings this is never gated on `.governance-seen`
2723
+ * — it prints on EVERY scoped sync while any locally-observable cause
2724
+ * persists (owner decision 2026-08-20, "always report"). Hook mode
2725
+ * emits nothing here (both streams are black-holed by the hook runner);
2726
+ * `update --session-hook` formats the same counts off the returned
2727
+ * summary onto the session's stdout, the #2495 disclosure pattern.
2728
+ * Formatting/writing is its own failure domain — a defect degrades to
2729
+ * "no line", never a failed sync.
2730
+ *
2731
+ * @param {object} args
2732
+ * @param {string} args.skillsetRef - The declared `owner/name`.
2733
+ * @param {number} args.foreignCount - Project-scope foreign dirs.
2734
+ * @param {import("./global-boundary.mjs").GlobalBoundaryScan} args.gbScan
2735
+ * @param {import("./repo-sync-state.mjs").RepoResolvedEntry[]} args.resolved
2736
+ * @param {NodeJS.WritableStream} args.stderr
2737
+ * @param {boolean} args.hookMode
2738
+ * @returns {{skillsetRef: string, unmanagedCount: number, shadowedCount: number, globalBeyondCount: number, editRefusedCount: number, memberReplacedCount: number, notDeliveredCount: number}}
2739
+ */
2740
+ function composeRepoCompliance({ skillsetRef, foreignCount, gbScan, resolved, stderr, hookMode }) {
2741
+ // Bucket the served-but-not-written entries by their recorded reason
2742
+ // (#2665 review): "modified" is the only bucket allowed to claim a
2743
+ // local edit. "unmanaged" (a hand-authored dir squatting a member's
2744
+ // name — invisible to the foreign scan because the name IS managed)
2745
+ // gets its own bucket rather than folding into `unmanagedCount`, so
2746
+ // the state line's extras count stays consistent with the receipt's
2747
+ // scan-derived `unmanaged` violation count. Everything else —
2748
+ // "incomplete", "invalid", and entries from older CLIs/state files
2749
+ // that carry no reason — reads as the neutral "not delivered as
2750
+ // approved", never as an edit accusation.
2751
+ let editRefusedCount = 0;
2752
+ let memberReplacedCount = 0;
2753
+ let notDeliveredCount = 0;
2754
+ for (const e of resolved ?? []) {
2755
+ if (!e || e.written === true) continue;
2756
+ if (e.unwrittenReason === "modified") editRefusedCount += 1;
2757
+ else if (e.unwrittenReason === "unmanaged") memberReplacedCount += 1;
2758
+ else notDeliveredCount += 1;
2759
+ }
2760
+ const compliance = {
2761
+ skillsetRef,
2762
+ unmanagedCount: foreignCount ?? 0,
2763
+ shadowedCount: gbScan?.counts?.[GLOBAL_SHADOWED_CATEGORY] ?? 0,
2764
+ globalBeyondCount:
2765
+ (gbScan?.counts?.[GLOBAL_LIBRARY_CATEGORY] ?? 0) +
2766
+ (gbScan?.counts?.[GLOBAL_FOREIGN_CATEGORY] ?? 0),
2767
+ editRefusedCount,
2768
+ memberReplacedCount,
2769
+ notDeliveredCount,
2770
+ };
2771
+ if (!hookMode) {
2772
+ try {
2773
+ const line = formatRepoComplianceSummary(compliance);
2774
+ if (line) stderr.write(`${line}\n`);
2775
+ } catch {
2776
+ // Best-effort by design — the state line must never fail a sync.
2777
+ }
2778
+ }
2779
+ return compliance;
2780
+ }
2781
+
2625
2782
  /**
2626
2783
  * Build sync-receipt entries from a `.last-sync` skills map (#1832).
2627
2784
  *
@@ -988,6 +988,113 @@ describe("runUpdate — skillset-declaration gate (#2362)", () => {
988
988
  });
989
989
  });
990
990
 
991
+ // ── Scoped skillset sync says "Skillset", not "Library" (#2679) ────────
992
+ //
993
+ // In a declared repo, `update` runs a scoped skillset sync (sync.mjs
994
+ // dispatch) and the summary carries a top-level `skillsetRef` (stamped
995
+ // at the dispatcher on every scoped return), so the printer names that
996
+ // scope. An undeclared repo (whole library) stays byte-identical
997
+ // ("Library"). Sibling to #2672 (`list` vocabulary).
998
+
999
+ describe("runUpdate — scoped skillset sync vocabulary (#2679)", () => {
1000
+ beforeEach(setup);
1001
+ afterEach(teardown);
1002
+
1003
+ function declareRepo() {
1004
+ // .git bounds the declaration walk inside the sandbox project dir.
1005
+ mkdirSync(join(sandbox, "project", ".git"), { recursive: true });
1006
+ writeFileSync(
1007
+ join(sandbox, "project", "skillrepo.json"),
1008
+ JSON.stringify({
1009
+ skillset: { version: 1, name: "checkout", use: "acme/backend-core" },
1010
+ }),
1011
+ );
1012
+ }
1013
+
1014
+ function scopedResponse(skills) {
1015
+ return {
1016
+ skills,
1017
+ removals: [],
1018
+ syncedAt: "2026-08-20T00:00:00.000Z",
1019
+ skillset: {
1020
+ name: "backend-core",
1021
+ updatedAt: "2026-08-20T00:00:00.000Z",
1022
+ skippedExtras: [],
1023
+ },
1024
+ };
1025
+ }
1026
+
1027
+ it("no-op scoped sync prints 'Skillset is up to date', never 'Library'", async () => {
1028
+ declareRepo();
1029
+ server.setSkillsetResponse(scopedResponse([]));
1030
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1031
+ const out = stdout.text();
1032
+ assert.match(out, /Skillset is up to date/);
1033
+ assert.doesNotMatch(out, /Library is up to date/);
1034
+ });
1035
+
1036
+ it("scoped sync that writes a skill prints 'Skillset sync complete'", async () => {
1037
+ declareRepo();
1038
+ server.setSkillsetResponse(scopedResponse([makeSkill("checkout-flow")]));
1039
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1040
+ const out = stdout.text();
1041
+ assert.match(out, /Skillset sync complete/);
1042
+ assert.match(out, /added/);
1043
+ assert.doesNotMatch(out, /Library sync complete/);
1044
+ });
1045
+
1046
+ it("--session-hook scoped sync reports 'Skillset synced', not 'Library synced'", async () => {
1047
+ declareRepo();
1048
+ server.setSkillsetResponse(scopedResponse([makeSkill("checkout-flow")]));
1049
+ await runUpdate(
1050
+ ["--session-hook", "--key", VALID_KEY, "--url", serverUrl],
1051
+ { stdout },
1052
+ );
1053
+ const out = stdout.text();
1054
+ assert.match(out, /\[SkillRepo\] Skillset synced: \d+ added/);
1055
+ assert.doesNotMatch(out, /Library synced/);
1056
+ });
1057
+
1058
+ it("an UNDECLARED repo keeps the 'Library' vocabulary (whole-library scope)", async () => {
1059
+ // Guards against over-broadening: no declaration → no skillsetRef →
1060
+ // the subject must remain "Library".
1061
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1062
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1063
+ const out = stdout.text();
1064
+ assert.match(out, /Library is up to date/);
1065
+ assert.doesNotMatch(out, /Skillset/);
1066
+ });
1067
+
1068
+ it("declared repo during a registry outage (grace path) still says 'Skillset'", async () => {
1069
+ // Regression for the review finding: the scoped grace path
1070
+ // (serveLastVerified) returns NO `compliance` object, so a discriminator
1071
+ // keyed off compliance.skillsetRef mislabeled it "Library". The reliable
1072
+ // top-level skillsetRef (sync.mjs dispatcher) fixes it. First a
1073
+ // successful scoped sync writes the per-repo verified state (which
1074
+ // requires a server ETag — see the `result.etag` gate in sync.mjs)...
1075
+ declareRepo();
1076
+ server.setSkillsetEtag('W/"ss-' + "a".repeat(64) + '"');
1077
+ server.setSkillsetResponse(scopedResponse([makeSkill("checkout-flow")]));
1078
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1079
+
1080
+ // ...then the registry goes unreachable; the interactive sync (never
1081
+ // throttled) serves last-verified and must keep the "Skillset" noun.
1082
+ server.setSkillsetError({
1083
+ status: 503,
1084
+ body: { error: "upstream flake" },
1085
+ headers: { "Retry-After": "30" },
1086
+ });
1087
+ const graceStdout = createCaptureStream();
1088
+ await runUpdate(
1089
+ ["--key", VALID_KEY, "--url", serverUrl],
1090
+ { stdout: graceStdout },
1091
+ );
1092
+ const graceOut = graceStdout.text();
1093
+ assert.match(graceOut, /Skillset is up to date/);
1094
+ assert.doesNotMatch(graceOut, /Library is up to date/);
1095
+ });
1096
+ });
1097
+
991
1098
  // ── Throttle window + declaration interplay through the hook flags ─────
992
1099
  // (#2362 prod-readiness QA): a throttled hook sync returns BEFORE the
993
1100
  // gate by documented design (#2174 zero-work contract). A repo that
@@ -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,187 @@ 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
+ // Declared repo → scoped sync → the noun is "Skillset", not "Library"
1553
+ // (#2679; a top-level `skillsetRef` is stamped on the scoped path).
1554
+ assert.match(out, /\[SkillRepo\] Skillset synced: 1 added/);
1555
+ assert.match(
1556
+ out,
1557
+ /\[SkillRepo\] This repo does not match its skillset acme\/backend-core \(1 extra skill in the project\) — it is reported as not compliant\./,
1558
+ );
1559
+ assert.ok(
1560
+ !out.includes("handmade"),
1561
+ "hook line is counts-only — no dir names reach the session output",
1562
+ );
1563
+ });
1564
+
1565
+ it("a hand-authored dir squatting a MEMBER name reads 'replaced by unmanaged content', never 'edited locally' (#2665 review)", async () => {
1566
+ declareRepo();
1567
+ // The squat exists BEFORE the first sync: the CLI never wrote alpha,
1568
+ // so decideScopedWrite refuses with reason "unmanaged".
1569
+ const squat = join(repoRoot(), ".claude", "skills", "alpha");
1570
+ mkdirSync(squat, { recursive: true });
1571
+ writeFileSync(join(squat, "SKILL.md"), "---\nname: alpha\n---\nhand-authored\n");
1572
+
1573
+ server.setSkillsetEtag(SS_ETAG_A);
1574
+ server.setSkillsetResponse(scopedResponse([makeSkill("alpha")]));
1575
+ const stderr = createCaptureStream();
1576
+ const result = await scopedSync({ io: { stderr } });
1577
+ const t = stderr.text();
1578
+
1579
+ assert.match(t, /1 member replaced by unmanaged content/);
1580
+ assert.ok(!t.includes("edited locally"), "a squat is not an edit");
1581
+ // Not folded into the extras count either — that stays scan-derived so
1582
+ // it matches the receipt's `unmanaged` violation category (the squat's
1583
+ // name IS a managed member name, so the foreign scan never counts it).
1584
+ assert.ok(!t.includes("extra skill in the project"));
1585
+ assert.equal(result.compliance.memberReplacedCount, 1);
1586
+ assert.equal(result.compliance.editRefusedCount, 0);
1587
+ assert.equal(result.compliance.unmanagedCount, 0);
1588
+
1589
+ // Wire whitelist proof: the receipt entry carries exactly the four
1590
+ // fields — the local-only unwrittenReason never leaves the machine.
1591
+ const receipt = server.getLastReceipt();
1592
+ assert.deepEqual(receipt.repo.resolved, [
1593
+ { owner: "acme", name: "alpha", version: "1.0.0", written: false },
1594
+ ]);
1595
+ });
1596
+
1597
+ it("a filesIncomplete member reads 'not delivered as approved', never 'edited locally' (#2665 review)", async () => {
1598
+ declareRepo();
1599
+ server.setSkillsetEtag(SS_ETAG_A);
1600
+ server.setSkillsetResponse(
1601
+ scopedResponse([{ ...makeSkill("alpha"), filesIncomplete: true }]),
1602
+ );
1603
+ const stderr = createCaptureStream();
1604
+ const result = await scopedSync({ io: { stderr } });
1605
+ const t = stderr.text();
1606
+ assert.match(t, /1 member not delivered as approved/);
1607
+ assert.ok(!t.includes("edited locally"), "a server-side gap is not an edit");
1608
+ assert.equal(result.compliance.notDeliveredCount, 1);
1609
+ assert.equal(result.compliance.editRefusedCount, 0);
1610
+ });
1611
+
1612
+ it("a legacy state entry without unwrittenReason buckets as not-delivered on the 304 path (#2665 review)", async () => {
1613
+ declareRepo();
1614
+ server.setSkillsetEtag(SS_ETAG_A);
1615
+ server.setSkillsetResponse(scopedResponse([makeSkill("alpha")]));
1616
+ await scopedSync();
1617
+
1618
+ // Simulate state written by an older CLI: written:false, no reason.
1619
+ const prior = readRepoSyncState(repoRoot());
1620
+ writeRepoSyncState(repoRoot(), {
1621
+ ...prior,
1622
+ resolved: prior.resolved.map((e) => ({
1623
+ owner: e.owner,
1624
+ name: e.name,
1625
+ version: e.version,
1626
+ written: false,
1627
+ })),
1628
+ });
1629
+
1630
+ const stderr = createCaptureStream();
1631
+ const result = await scopedSync({ io: { stderr } });
1632
+ assert.equal(result.notModified, true);
1633
+ const t = stderr.text();
1634
+ assert.match(t, /1 member not delivered as approved/);
1635
+ assert.ok(!t.includes("edited locally"), "no false edit claim for unknown legacy reasons");
1636
+ assert.equal(result.compliance.notDeliveredCount, 1);
1637
+ });
1638
+
1639
+ it("a global copy shadowing a member flows through the real boundary scan into the state line (#2665 review)", async () => {
1640
+ declareRepo();
1641
+ // Global scope lives under the sandbox HOME set by setupServer.
1642
+ const globalDir = join(sandbox, "home", ".claude", "skills", "alpha");
1643
+ mkdirSync(globalDir, { recursive: true });
1644
+ writeFileSync(join(globalDir, "SKILL.md"), "---\nname: alpha\n---\nglobal copy\n");
1645
+
1646
+ server.setSkillsetEtag(SS_ETAG_A);
1647
+ server.setSkillsetResponse(scopedResponse([makeSkill("alpha")]));
1648
+ const stderr = createCaptureStream();
1649
+ const result = await scopedSync({ io: { stderr } });
1650
+ assert.equal(result.compliance.shadowedCount, 1);
1651
+ assert.match(stderr.text(), /1 member shadowed by a global copy/);
1652
+ });
1653
+ });