skillrepo 4.12.0 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/package.json +1 -1
- package/src/commands/list.mjs +46 -1
- package/src/commands/update.mjs +42 -1
- package/src/lib/foreign-content.mjs +141 -2
- package/src/lib/global-boundary.mjs +495 -0
- package/src/lib/sync.mjs +260 -20
- package/src/test/commands/list.test.mjs +130 -0
- package/src/test/commands/update.test.mjs +106 -0
- package/src/test/lib/foreign-content.test.mjs +45 -0
- package/src/test/lib/global-boundary.test.mjs +607 -0
- package/src/test/lib/sync-skillset.test.mjs +240 -2
- package/src/test/lib/sync.test.mjs +238 -0
package/README.md
CHANGED
|
@@ -119,11 +119,23 @@ and just write the config + gitignore.
|
|
|
119
119
|
"removed": 0,
|
|
120
120
|
"notModified": false,
|
|
121
121
|
"fullSync": true,
|
|
122
|
-
"syncedAt": "2026-05-01T00:00:00.000Z"
|
|
122
|
+
"syncedAt": "2026-05-01T00:00:00.000Z",
|
|
123
|
+
"globalBoundary": {
|
|
124
|
+
"total": 0,
|
|
125
|
+
"counts": { "global_library": 0, "global_foreign": 0, "global_shadowed": 0 },
|
|
126
|
+
"names": [],
|
|
127
|
+
"skillsetDeclared": false
|
|
128
|
+
}
|
|
123
129
|
}
|
|
124
130
|
}
|
|
125
131
|
```
|
|
126
132
|
|
|
133
|
+
`sync.globalBoundary` (4.13.0) appears on every successful
|
|
134
|
+
project-scope sync and describes the personal-scope skills that also
|
|
135
|
+
load in sessions here; it is absent when the scan did not run
|
|
136
|
+
(`--global`, or a scan failure). `names` stays local to your machine —
|
|
137
|
+
sync reporting to your organization carries counts only.
|
|
138
|
+
|
|
127
139
|
Field notes:
|
|
128
140
|
- `vendors` is the resolved canonical-key list, NOT the raw `--agent` input. `--agent agents` produces every cohort vendor (cursor, windsurf, gemini, codex, cline, copilot); `--agent none` produces an empty array.
|
|
129
141
|
- `sessionSync.cohortHooks[]` reports per-vendor outcomes for the auto-refresh hooks installed alongside the Claude Code SessionStart hook (one entry per cohort vendor with a non-null `agentHook` registry spec — Cursor, Gemini CLI, Codex CLI, VS Code + Copilot). `reason` is present only when `action: "failed"`. Empty array when `--no-session-sync` was passed or no cohort vendor was selected.
|
|
@@ -183,6 +195,21 @@ Sync also warns — once per repo, same rules — when a `skillrepo.json`
|
|
|
183
195
|
at the repo root is gitignored: the skillset declaration only works as
|
|
184
196
|
a committed file, so remove it from `.gitignore` and commit it.
|
|
185
197
|
|
|
198
|
+
Project syncs also disclose the personal scope (the global boundary):
|
|
199
|
+
skills under the global folders (`~/.claude/skills/`,
|
|
200
|
+
`~/.agents/skills/`, `~/.codeium/windsurf/skills/`) load in every
|
|
201
|
+
session run inside a project, whether or not they came through a
|
|
202
|
+
skillset. When any exist, a one-line disclosure says how many global
|
|
203
|
+
skills will also load — in a declared repo it also counts how many sit
|
|
204
|
+
outside the repo's skillset and how many collide with a skillset
|
|
205
|
+
member's name. Today the session auto-sync prints that line in Claude
|
|
206
|
+
Code only (the other agents' background sync hooks run silently by
|
|
207
|
+
their hook contracts — see `docs/vendor-paths.md` for each vendor's
|
|
208
|
+
channel); `skillrepo list` shows the same line everywhere. Interactive
|
|
209
|
+
syncs add a one-time warning per finding with the remediation, and
|
|
210
|
+
your organization's sync reporting carries counts only
|
|
211
|
+
(`global_library`, `global_foreign`, `global_shadowed`), never names.
|
|
212
|
+
|
|
186
213
|
A repository can declare a skillset in a root `skillrepo.json`:
|
|
187
214
|
`{"skillset": {"version": 1, "name": "<repo-identity>", "use":
|
|
188
215
|
"owner/skillset-name"}}` (optional `extra: ["owner/skill"]`). The CLI
|
package/package.json
CHANGED
package/src/commands/list.mjs
CHANGED
|
@@ -43,6 +43,12 @@ import { detectAgents } from "../lib/detect-agents.mjs";
|
|
|
43
43
|
import { walkDetectedPlacements } from "../lib/placement-walk.mjs";
|
|
44
44
|
import { getAgentByKey } from "../lib/agent-registry.mjs";
|
|
45
45
|
import { computeSkillState, rollupState, SKILL_STATE } from "../lib/drift.mjs";
|
|
46
|
+
import {
|
|
47
|
+
scanGlobalBoundary,
|
|
48
|
+
formatGlobalBoundaryDisclosure,
|
|
49
|
+
managedGlobalNamesFrom,
|
|
50
|
+
resolveBoundaryMemberContext,
|
|
51
|
+
} from "../lib/global-boundary.mjs";
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
54
|
* Run `list`. Throws CliError on any failure.
|
|
@@ -56,7 +62,10 @@ export async function runList(argv, io = {}) {
|
|
|
56
62
|
const stdout = io.stdout ?? process.stdout;
|
|
57
63
|
const flags = resolveFlags(argv);
|
|
58
64
|
|
|
59
|
-
// `list` is a read-only drift check — it must NEVER set sync state
|
|
65
|
+
// `list` is a read-only drift check — it must NEVER set sync state,
|
|
66
|
+
// and (#2495) it never writes the governance-seen file either: the
|
|
67
|
+
// boundary disclosure below scans without committing warn-state, so
|
|
68
|
+
// running `list` can't consume a warning `update` owes the user.
|
|
60
69
|
// Use the manifest read (#1832): metadata only, no file bodies, and the
|
|
61
70
|
// server records no delivery for it. Per-skill drift is computed from
|
|
62
71
|
// on-disk SHAs + `.last-sync` below, never from the response body, so
|
|
@@ -122,6 +131,42 @@ export async function runList(argv, io = {}) {
|
|
|
122
131
|
|
|
123
132
|
printTable(augmented, detected, stdout);
|
|
124
133
|
printFooter(augmented, libraryResponse.etag, lastSync, stdout, canUseGlyphs(stdout));
|
|
134
|
+
printGlobalBoundaryDisclosure(detectedKeys, lastSync, stdout);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Global-boundary disclosure for the table surface (#2495): one line,
|
|
139
|
+
* same format as the session-hook disclosure, printed only when the
|
|
140
|
+
* detected vendors' GLOBAL roots hold skill dirs. Read-only to the
|
|
141
|
+
* letter of list's contract above — no seen-state commit, no per-dir
|
|
142
|
+
* warning lines (those are `update`'s interactive job). Best-effort:
|
|
143
|
+
* the drift table must never fail because a disclosure probe did.
|
|
144
|
+
*
|
|
145
|
+
* The declaration is resolved TOLERANTLY, like the throttled sync
|
|
146
|
+
* exit: `list` is a reporting surface, so an invalid `skillrepo.json`
|
|
147
|
+
* stays `update`'s error to raise — here it just means the scan runs
|
|
148
|
+
* without a member set.
|
|
149
|
+
*
|
|
150
|
+
* @param {string[]} vendors - Detected vendor keys.
|
|
151
|
+
* @param {import("../lib/sync.mjs").SyncStateFile | null} lastSync
|
|
152
|
+
* @param {NodeJS.WritableStream} stdout
|
|
153
|
+
*/
|
|
154
|
+
function printGlobalBoundaryDisclosure(vendors, lastSync, stdout) {
|
|
155
|
+
try {
|
|
156
|
+
const { memberNames, baseDir } = resolveBoundaryMemberContext();
|
|
157
|
+
const scan = scanGlobalBoundary({
|
|
158
|
+
vendors,
|
|
159
|
+
memberNames,
|
|
160
|
+
managedGlobalNames: managedGlobalNamesFrom(lastSync?.skills),
|
|
161
|
+
baseDir,
|
|
162
|
+
});
|
|
163
|
+
const disclosure = formatGlobalBoundaryDisclosure(scan);
|
|
164
|
+
// Two-space indent matches every other list line; the line itself
|
|
165
|
+
// is byte-identical to the session-hook disclosure.
|
|
166
|
+
if (disclosure) stdout.write(` ${disclosure}\n`);
|
|
167
|
+
} catch {
|
|
168
|
+
// Disclosure is best-effort on every surface.
|
|
169
|
+
}
|
|
125
170
|
}
|
|
126
171
|
|
|
127
172
|
// ── Per-skill augmentation ─────────────────────────────────────────────
|
package/src/commands/update.mjs
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
48
|
import { runSync } from "../lib/sync.mjs";
|
|
49
|
+
import { formatGlobalBoundaryDisclosure } from "../lib/global-boundary.mjs";
|
|
49
50
|
import {
|
|
50
51
|
resolveFlags,
|
|
51
52
|
effectiveVendors,
|
|
@@ -64,6 +65,14 @@ import {
|
|
|
64
65
|
* - 304 Not Modified → exit 0, NO output.
|
|
65
66
|
* - 200 with changes → exit 0, ONE line: `[SkillRepo] Library synced: N added, N updated, N removed.`
|
|
66
67
|
* - Any failure → exit 0, ONE line: `[SkillRepo] Sync failed: <reason>.`
|
|
68
|
+
* - Global-boundary disclosure (#2495): when the sync's summary
|
|
69
|
+
* reports global skills that will also load in this session
|
|
70
|
+
* (`globalBoundary.total > 0`), ONE additional line prints on
|
|
71
|
+
* EVERY success path — after the sync line, or alone on the
|
|
72
|
+
* otherwise-silent 304/zero-delta/throttled paths. Plain stdout
|
|
73
|
+
* enters the session's model context, which is the point: the
|
|
74
|
+
* agent itself learns what extra skills are in play. Failure
|
|
75
|
+
* paths never disclose (the failure line stays the single line).
|
|
67
76
|
*
|
|
68
77
|
* The "exit 0 on all errors" contract is non-negotiable: a sync
|
|
69
78
|
* failure must NEVER block a Claude Code session start. Users on a
|
|
@@ -162,10 +171,41 @@ export async function runUpdate(argv, io = {}) {
|
|
|
162
171
|
const skipped = summary.skipped ?? 0;
|
|
163
172
|
const total =
|
|
164
173
|
summary.added + summary.updated + summary.removed + skipped;
|
|
174
|
+
// Global-boundary disclosure (#2495), computed BEFORE the silent
|
|
175
|
+
// branch below: the quiet 304/zero-delta/throttled session is
|
|
176
|
+
// the COMMON session, and it must still disclose — the line
|
|
177
|
+
// exists so the session's model context knows about the global
|
|
178
|
+
// skills loading alongside the synced set, not to report sync
|
|
179
|
+
// work. Success paths only; the catch below never reaches here.
|
|
180
|
+
// Formatting is its OWN failure domain (architect review r1): a
|
|
181
|
+
// formatter defect must degrade to "no line", never fall into
|
|
182
|
+
// the outer catch and report a successful sync as failed —
|
|
183
|
+
// "disclosure must never break a sync" applies to the printer
|
|
184
|
+
// exactly as it does to the scanner.
|
|
185
|
+
let disclosure = null;
|
|
186
|
+
try {
|
|
187
|
+
disclosure =
|
|
188
|
+
summary.globalBoundary && summary.globalBoundary.total > 0
|
|
189
|
+
? formatGlobalBoundaryDisclosure(summary.globalBoundary)
|
|
190
|
+
: null;
|
|
191
|
+
} catch {
|
|
192
|
+
// Degrade to no disclosure line.
|
|
193
|
+
}
|
|
194
|
+
const writeDisclosureLine = () => {
|
|
195
|
+
if (!disclosure) return;
|
|
196
|
+
try {
|
|
197
|
+
stdout.write(`${disclosure}\n`);
|
|
198
|
+
} catch {
|
|
199
|
+
// Same failure domain as the formatter: a write failure on
|
|
200
|
+
// the cosmetic line must not become a "Sync failed" report.
|
|
201
|
+
}
|
|
202
|
+
};
|
|
165
203
|
if (summary.notModified || total === 0) {
|
|
166
204
|
// 304 Not Modified OR 200 with zero deltas — silent by
|
|
167
|
-
// contract
|
|
205
|
+
// contract (the boundary disclosure is the one sanctioned
|
|
206
|
+
// exception). Users should not see "Syncing..." on every
|
|
168
207
|
// session for no visible value.
|
|
208
|
+
writeDisclosureLine();
|
|
169
209
|
return;
|
|
170
210
|
}
|
|
171
211
|
stdout.write(
|
|
@@ -173,6 +213,7 @@ export async function runUpdate(argv, io = {}) {
|
|
|
173
213
|
(skipped > 0 ? `, ${skipped} SKIPPED (could not be written)` : "") +
|
|
174
214
|
`.\n`,
|
|
175
215
|
);
|
|
216
|
+
writeDisclosureLine();
|
|
176
217
|
} catch (err) {
|
|
177
218
|
// The one-line failure message is the user's primary signal
|
|
178
219
|
// that something's wrong. Do not surface a stack trace — the
|
|
@@ -245,11 +245,41 @@ export function formatForeignWarnings(scan) {
|
|
|
245
245
|
* @property {Record<string, boolean>} declarations - abs repo path →
|
|
246
246
|
* true when the gitignored-declaration warning was already
|
|
247
247
|
* shown and the declaration is still gitignored.
|
|
248
|
+
* @property {Record<string, Record<string, string[]>>} globalBoundary -
|
|
249
|
+
* abs repo path → (abs global root → dir names already warned
|
|
250
|
+
* about, sorted). The #2495 global-boundary counterpart to
|
|
251
|
+
* `roots`, keyed per REPO because the same global dir means
|
|
252
|
+
* different things in different repos (shadowing one repo's
|
|
253
|
+
* skillset member, plain foreign content elsewhere) — one
|
|
254
|
+
* repo consuming the warning must not silence it for another.
|
|
248
255
|
*/
|
|
249
256
|
|
|
250
257
|
/** @returns {GovernanceSeenState} */
|
|
251
258
|
function emptySeenState() {
|
|
252
|
-
return { schemaVersion: 1, roots: {}, declarations: {} };
|
|
259
|
+
return { schemaVersion: 1, roots: {}, declarations: {}, globalBoundary: {} };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Shape-check the `globalBoundary` field on read: an object of objects
|
|
264
|
+
* of arrays, or `{}` when anything about it is malformed — the same
|
|
265
|
+
* warn-everything degradation the other fields use, applied to the
|
|
266
|
+
* whole field (a partially-trusted structure isn't worth salvaging
|
|
267
|
+
* when the cost of a reset is one repeated warning).
|
|
268
|
+
*
|
|
269
|
+
* @param {unknown} value
|
|
270
|
+
* @returns {Record<string, Record<string, string[]>>}
|
|
271
|
+
*/
|
|
272
|
+
function coerceGlobalBoundary(value) {
|
|
273
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
274
|
+
for (const repoMap of Object.values(value)) {
|
|
275
|
+
if (!repoMap || typeof repoMap !== "object" || Array.isArray(repoMap)) {
|
|
276
|
+
return {};
|
|
277
|
+
}
|
|
278
|
+
for (const names of Object.values(repoMap)) {
|
|
279
|
+
if (!Array.isArray(names)) return {};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return /** @type {Record<string, Record<string, string[]>>} */ (value);
|
|
253
283
|
}
|
|
254
284
|
|
|
255
285
|
/** @returns {GovernanceSeenState} */
|
|
@@ -262,6 +292,11 @@ export function readGovernanceSeen() {
|
|
|
262
292
|
// at the current schema. A future v2 that must PRESERVE v1 data
|
|
263
293
|
// needs an explicit accept-in-place branch here, the way
|
|
264
294
|
// sync.mjs's readLastSync accepts its immediately-prior schema.
|
|
295
|
+
// `globalBoundary` (#2495) is an ADDITIVE key at schemaVersion 1,
|
|
296
|
+
// not a bump: a pre-#2495 CLI sharing this file simply drops the
|
|
297
|
+
// key on its next write, and the cost is one repeated boundary
|
|
298
|
+
// warning on the next post-#2495 interactive sync — documented,
|
|
299
|
+
// acceptable, and self-healing.
|
|
265
300
|
if (!parsed || typeof parsed !== "object" || parsed.schemaVersion !== 1) {
|
|
266
301
|
return emptySeenState();
|
|
267
302
|
}
|
|
@@ -277,6 +312,7 @@ export function readGovernanceSeen() {
|
|
|
277
312
|
!Array.isArray(parsed.declarations)
|
|
278
313
|
? parsed.declarations
|
|
279
314
|
: {},
|
|
315
|
+
globalBoundary: coerceGlobalBoundary(parsed.globalBoundary),
|
|
280
316
|
};
|
|
281
317
|
} catch {
|
|
282
318
|
return emptySeenState();
|
|
@@ -396,7 +432,110 @@ export function commitGovernanceSeen({ scan, repoKey, declarationIgnored }) {
|
|
|
396
432
|
} else {
|
|
397
433
|
delete declarations[repoKey];
|
|
398
434
|
}
|
|
399
|
-
writeGovernanceSeen({
|
|
435
|
+
writeGovernanceSeen({
|
|
436
|
+
schemaVersion: 1,
|
|
437
|
+
roots,
|
|
438
|
+
declarations,
|
|
439
|
+
// Pass-through from the FRESH read (#2495): this commit owns only
|
|
440
|
+
// `roots` + this repo's declaration flag; the boundary scan's keys
|
|
441
|
+
// belong to `commitGlobalBoundarySeen`, and dropping them here
|
|
442
|
+
// would re-warn every boundary finding after every project sync.
|
|
443
|
+
globalBoundary: fresh.globalBoundary,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Commit a global-boundary scan (#2495) into the seen-state file with
|
|
449
|
+
* the same NARROW merge discipline as `commitGovernanceSeen`: fresh
|
|
450
|
+
* read, rewrite ONLY `globalBoundary[repoKey]` (this repo's view), and
|
|
451
|
+
* pass `roots`/`declarations` — plus every OTHER repo's boundary key —
|
|
452
|
+
* through from the fresh read, so a concurrent sync elsewhere keeps
|
|
453
|
+
* its update even if this process writes last.
|
|
454
|
+
*
|
|
455
|
+
* Replace-not-merge per scanned root within the repo key: dirs that
|
|
456
|
+
* disappeared are pruned so a removed-then-reappearing dir warns
|
|
457
|
+
* again; a clean root deletes its key, and a repo whose every scanned
|
|
458
|
+
* root ended clean deletes its whole entry (state files should not
|
|
459
|
+
* accumulate empty husks).
|
|
460
|
+
*
|
|
461
|
+
* The RESIDUAL same-key race documented on `writeGovernanceSeen`
|
|
462
|
+
* applies here identically: two concurrent interactive syncs of ONE
|
|
463
|
+
* repo last-write-win on that repoKey, which can revive a pruned
|
|
464
|
+
* entry and suppress a re-warn — a repeated-or-suppressed warning,
|
|
465
|
+
* never a wrong receipt count (counts come from the live scan).
|
|
466
|
+
*
|
|
467
|
+
* @param {object} options
|
|
468
|
+
* @param {{ roots: { root: string, entries: { name: string }[] }[] }} options.scan
|
|
469
|
+
* The FULL boundary scan (everything currently on disk, not
|
|
470
|
+
* just the newly-warned entries).
|
|
471
|
+
* @param {string} options.repoKey - Normalized repo path.
|
|
472
|
+
*/
|
|
473
|
+
export function commitGlobalBoundarySeen({ scan, repoKey }) {
|
|
474
|
+
const fresh = readGovernanceSeen();
|
|
475
|
+
const globalBoundary = { ...fresh.globalBoundary };
|
|
476
|
+
const repoMap = { ...(globalBoundary[repoKey] ?? {}) };
|
|
477
|
+
for (const rootResult of scan?.roots ?? []) {
|
|
478
|
+
const names = (rootResult.entries ?? []).map((entry) => entry.name).sort();
|
|
479
|
+
if (names.length > 0) {
|
|
480
|
+
repoMap[rootResult.root] = names;
|
|
481
|
+
} else {
|
|
482
|
+
delete repoMap[rootResult.root];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (Object.keys(repoMap).length === 0) {
|
|
486
|
+
delete globalBoundary[repoKey];
|
|
487
|
+
} else {
|
|
488
|
+
globalBoundary[repoKey] = repoMap;
|
|
489
|
+
}
|
|
490
|
+
writeGovernanceSeen({
|
|
491
|
+
schemaVersion: 1,
|
|
492
|
+
roots: fresh.roots,
|
|
493
|
+
declarations: fresh.declarations,
|
|
494
|
+
globalBoundary,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Select the boundary findings this repo has NOT been warned about
|
|
500
|
+
* yet. Pure — no IO; the `commitGlobalBoundarySeen` above does the
|
|
501
|
+
* bookkeeping. Mirrors `selectNewFindings`, keyed one level deeper
|
|
502
|
+
* (per repo, then per root).
|
|
503
|
+
*
|
|
504
|
+
* Gates ONLY the interactive warning lines: the disclosure line and
|
|
505
|
+
* the receipt categories always use the FULL scan — seen-state
|
|
506
|
+
* suppresses repetition for a human, never facts for the record.
|
|
507
|
+
*
|
|
508
|
+
* @param {import("./global-boundary.mjs").GlobalBoundaryScan} scan
|
|
509
|
+
* @param {GovernanceSeenState} seen
|
|
510
|
+
* @param {string} repoKey - Normalized repo path.
|
|
511
|
+
* @returns {{ roots: object[], counts: Record<string, number>, total: number }}
|
|
512
|
+
* Scan-shaped (roots/counts/total recomputed over the unseen
|
|
513
|
+
* entries). No `summary` — the disclosure surfaces never
|
|
514
|
+
* consume a filtered scan, so embedding one here would invite
|
|
515
|
+
* exactly the partial-disclosure bug the gating rule forbids.
|
|
516
|
+
*/
|
|
517
|
+
export function selectNewGlobalBoundaryFindings(scan, seen, repoKey) {
|
|
518
|
+
const repoMap =
|
|
519
|
+
seen?.globalBoundary && typeof seen.globalBoundary === "object"
|
|
520
|
+
? seen.globalBoundary[repoKey] ?? {}
|
|
521
|
+
: {};
|
|
522
|
+
const counts = {};
|
|
523
|
+
for (const key of Object.keys(scan?.counts ?? {})) counts[key] = 0;
|
|
524
|
+
const result = { roots: [], counts, total: 0 };
|
|
525
|
+
for (const rootResult of scan?.roots ?? []) {
|
|
526
|
+
const prior = new Set(
|
|
527
|
+
Array.isArray(repoMap[rootResult.root]) ? repoMap[rootResult.root] : [],
|
|
528
|
+
);
|
|
529
|
+
const freshEntries = (rootResult.entries ?? []).filter(
|
|
530
|
+
(entry) => !prior.has(entry.name),
|
|
531
|
+
);
|
|
532
|
+
result.roots.push({ ...rootResult, entries: freshEntries });
|
|
533
|
+
for (const entry of freshEntries) {
|
|
534
|
+
counts[entry.bucket] = (counts[entry.bucket] ?? 0) + 1;
|
|
535
|
+
result.total += 1;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return result;
|
|
400
539
|
}
|
|
401
540
|
|
|
402
541
|
/**
|