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