agent-skills-standard 2.7.0 → 2.7.2

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/dist/index.js CHANGED
@@ -38,8 +38,11 @@ cross_fetch = __toESM(cross_fetch);
38
38
  let js_yaml = require("js-yaml");
39
39
  js_yaml = __toESM(js_yaml);
40
40
  let zod = require("zod");
41
+ let node_path = require("node:path");
42
+ node_path = __toESM(node_path);
41
43
  let os = require("os");
42
44
  os = __toESM(os);
45
+ let node_stream = require("node:stream");
43
46
  let node_child_process = require("node:child_process");
44
47
  let node_os = require("node:os");
45
48
  node_os = __toESM(node_os);
@@ -50,7 +53,7 @@ let crypto = require("crypto");
50
53
  crypto = __toESM(crypto);
51
54
  var package_default = {
52
55
  name: "agent-skills-standard",
53
- version: "2.7.0",
56
+ version: "2.7.2",
54
57
  description: "A CLI to manage and sync AI agent skills standard for Cursor, Claude, Copilot, Windsurf, and more.",
55
58
  main: "dist/index.js",
56
59
  bin: {
@@ -1455,10 +1458,62 @@ var SkillConfigSchema = zod.z.object({
1455
1458
  mcp: McpConfigSchema.optional(),
1456
1459
  telemetry: zod.z.boolean().optional()
1457
1460
  });
1461
+ /**
1462
+ * Known top-level runtime fields a reviewed publication may replace. Unlisted
1463
+ * (unknown/extension) root fields are always preserved verbatim.
1464
+ */
1465
+ var REVIEWED_ROOT_FIELDS = [
1466
+ "registry",
1467
+ "agents",
1468
+ "custom_overrides",
1469
+ "workflows",
1470
+ "prune",
1471
+ "workflows_ref",
1472
+ "specialists_ref",
1473
+ "telemetry",
1474
+ "skills_mode"
1475
+ ];
1476
+ /** Known MCP fields; unknown MCP extension keys survive an MCP consent update. */
1477
+ var MCP_FIELD_KEYS = [
1478
+ "enabled",
1479
+ "scope",
1480
+ "prompted",
1481
+ "version",
1482
+ "snippets"
1483
+ ];
1458
1484
  var DEFAULT_SDLC_SUPPORT_CATEGORIES = ["quality-engineering"];
1459
1485
  var PRIMARY_CONFIG_FILE = ".skillsrc";
1460
1486
  var LEGACY_YAML_CONFIG_FILE = ".skillsrc.yaml";
1461
1487
  var CONFIG_CONFLICT_MESSAGE = "Configuration changed on disk since it was opened; reload before applying.";
1488
+ var RESERVED_SELECTION_KEYS = {
1489
+ ref: "set `ref` on the category itself, e.g. `skills.<category>.ref`",
1490
+ include: "move the allowlist under the category it applies to, e.g. `skills.<category>.include`",
1491
+ exclude: "move the exclusion list under the category it applies to, e.g. `skills.<category>.exclude`; a global `skills.exclude` is not supported"
1492
+ };
1493
+ function isReservedSelectionKey(key) {
1494
+ return Object.prototype.hasOwnProperty.call(RESERVED_SELECTION_KEYS, key);
1495
+ }
1496
+ function isLegacySectionKey(trimmed) {
1497
+ if (trimmed === "workflows:") return "workflows";
1498
+ if (trimmed === "mcp:") return "mcp";
1499
+ if (trimmed.startsWith("custom_overrides:")) return "custom_overrides";
1500
+ return null;
1501
+ }
1502
+ /**
1503
+ * Invalid configuration that names both the file and the logical key needing
1504
+ * repair. Callers surface the message verbatim rather than guessing a
1505
+ * migration or rewriting the user's document.
1506
+ */
1507
+ var ConfigValidationError = class extends Error {
1508
+ configPath;
1509
+ logicalPath;
1510
+ constructor(configPath, logicalPath, detail) {
1511
+ super(`Invalid configuration at ${configPath}: "${logicalPath}" ${detail}`);
1512
+ this.configPath = configPath;
1513
+ this.logicalPath = logicalPath;
1514
+ this.name = "ConfigValidationError";
1515
+ }
1516
+ };
1462
1517
  /**
1463
1518
  * Service for managing the `.skillsrc` configuration file.
1464
1519
  * Handles loading, saving, and initial construction of the configuration based on project metadata.
@@ -1498,12 +1553,32 @@ var ConfigService = class {
1498
1553
  if (trimmed.startsWith("- ")) return trimmed.replace(/^-\s+/, "").split(/\s+-\s+/).map((item) => item.trim()).filter(Boolean);
1499
1554
  return trimmed.split(",").map((item) => item.trim()).filter(Boolean);
1500
1555
  }
1556
+ /**
1557
+ * Parses one inline legacy value with the document YAML parser so quoted
1558
+ * scalars and flow collections keep their YAML meaning. Shapes the
1559
+ * line-oriented fallback cannot represent unambiguously are refused instead
1560
+ * of being published as raw token strings.
1561
+ */
1562
+ parseLegacyYamlValue(raw, configPath, logicalPath) {
1563
+ let parsed;
1564
+ try {
1565
+ parsed = js_yaml.load(raw);
1566
+ } catch (error) {
1567
+ throw new ConfigValidationError(configPath, logicalPath, `has an inline value ("${raw}") that is not valid YAML: ${error.message}`);
1568
+ }
1569
+ if (raw.startsWith("{") || raw.startsWith("[")) {
1570
+ if (!this.isRecord(parsed) && !Array.isArray(parsed)) throw new ConfigValidationError(configPath, logicalPath, `has an inline value ("${raw}") that does not parse as a YAML collection.`);
1571
+ return parsed;
1572
+ }
1573
+ if (typeof parsed === "object" && parsed !== null) throw new ConfigValidationError(configPath, logicalPath, `has an inline value ("${raw}") that parses as a YAML collection without flow braces; quote it or use {..}/[..].`);
1574
+ return parsed;
1575
+ }
1501
1576
  parseLegacyBoolean(value) {
1502
1577
  const normalized = value.trim().toLowerCase();
1503
1578
  if (normalized === "true") return true;
1504
1579
  if (normalized === "false") return false;
1505
1580
  }
1506
- migrateLegacyTextConfig(content) {
1581
+ migrateLegacyTextConfig(content, configPath) {
1507
1582
  const lines = content.split(/\r?\n/);
1508
1583
  const registryLine = lines.find((line) => line.trim().startsWith("registry:"));
1509
1584
  const agentsIndex = lines.findIndex((line) => line.trim() === "agents:");
@@ -1523,35 +1598,74 @@ var ConfigService = class {
1523
1598
  agents.push(match[1].trim());
1524
1599
  }
1525
1600
  config.agents = agents;
1526
- const sectionNames = /* @__PURE__ */ new Set([
1527
- "custom_overrides",
1528
- "workflows",
1529
- "mcp"
1530
- ]);
1601
+ const skills = {};
1602
+ config.skills = skills;
1531
1603
  let currentCategory = null;
1604
+ let currentCategoryIndent = 0;
1605
+ let pendingList = null;
1532
1606
  for (let index = skillsIndex + 1; index < lines.length; index += 1) {
1533
- const trimmed = lines[index].trim();
1534
- if (!trimmed) continue;
1535
- if (trimmed === "workflows:" || trimmed === "mcp:" || trimmed.startsWith("custom_overrides:")) break;
1536
- const categoryMatch = trimmed.match(/^([A-Za-z0-9-]+):$/);
1537
- if (categoryMatch && !sectionNames.has(categoryMatch[1])) {
1538
- currentCategory = categoryMatch[1];
1539
- config.skills[currentCategory] = {};
1607
+ const line = lines[index];
1608
+ const trimmed = line.trim();
1609
+ if (!trimmed || trimmed.startsWith("#")) continue;
1610
+ const indent = line.length - line.trimStart().length;
1611
+ const sectionKey = isLegacySectionKey(trimmed);
1612
+ if (sectionKey) {
1613
+ if (currentCategory && indent > currentCategoryIndent) throw new ConfigValidationError(configPath, `skills.${currentCategory}.${sectionKey}`, "is indented as a category property but names a root section; cannot infer which it is.");
1614
+ break;
1615
+ }
1616
+ if (trimmed.startsWith("- ")) {
1617
+ const item = trimmed.slice(2).trim();
1618
+ if (!pendingList || !currentCategory) continue;
1619
+ if (indent < pendingList.indent) throw new ConfigValidationError(configPath, `skills.${currentCategory}.${pendingList.key}`, `has a list item ("${item}") indented less than the key that opened it; cannot infer membership.`);
1620
+ if (item) {
1621
+ const entry = skills[currentCategory];
1622
+ const value = this.parseLegacyYamlValue(item, configPath, `skills.${currentCategory}.${pendingList.key}`);
1623
+ if (isReservedSelectionKey(pendingList.key) && typeof value !== "string") throw new ConfigValidationError(configPath, `skills.${currentCategory}.${pendingList.key}`, `has a non-string list item ("${item}"); selection ids must be strings.`);
1624
+ const current = entry[pendingList.key];
1625
+ entry[pendingList.key] = Array.isArray(current) ? [...current, value] : [value];
1626
+ }
1540
1627
  continue;
1541
1628
  }
1542
- if (!currentCategory) continue;
1543
- const refMatch = trimmed.match(/^ref:\s*(.+)$/);
1544
- if (refMatch) {
1545
- config.skills[currentCategory].ref = refMatch[1].trim();
1629
+ const keyMatch = trimmed.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
1630
+ if (!keyMatch) continue;
1631
+ const key = keyMatch[1];
1632
+ const inlineValue = (keyMatch[2] ?? "").trim();
1633
+ const isNestedProperty = currentCategory !== null && indent > currentCategoryIndent;
1634
+ if (pendingList && indent > pendingList.indent) throw new ConfigValidationError(configPath, `skills.${currentCategory}.${pendingList.key}`, "opened a block list, but a nested key appears instead; cannot infer membership.");
1635
+ if (isReservedSelectionKey(key)) {
1636
+ if (!currentCategory || indent < currentCategoryIndent) throw new ConfigValidationError(configPath, key, `cannot infer which category this ${key} applies to. Correction: ${RESERVED_SELECTION_KEYS[key]}.`);
1637
+ pendingList = null;
1638
+ const entry = skills[currentCategory];
1639
+ if (key === "ref") {
1640
+ if (!inlineValue) throw new ConfigValidationError(configPath, `skills.${currentCategory}.ref`, "must name a git ref.");
1641
+ entry.ref = inlineValue;
1642
+ continue;
1643
+ }
1644
+ entry[key] = inlineValue ? this.parseLegacyList(inlineValue) : [];
1645
+ if (!inlineValue) pendingList = {
1646
+ key,
1647
+ indent
1648
+ };
1546
1649
  continue;
1547
1650
  }
1548
- const excludeMatch = trimmed.match(/^exclude:\s*(.+)$/);
1549
- if (excludeMatch) {
1550
- config.skills[currentCategory].exclude = this.parseLegacyList(excludeMatch[1]);
1651
+ if (isNestedProperty && currentCategory) {
1652
+ pendingList = null;
1653
+ const entry = skills[currentCategory];
1654
+ if (inlineValue) entry[key] = this.parseLegacyYamlValue(inlineValue, configPath, `skills.${currentCategory}.${key}`);
1655
+ else {
1656
+ entry[key] = null;
1657
+ pendingList = {
1658
+ key,
1659
+ indent
1660
+ };
1661
+ }
1551
1662
  continue;
1552
1663
  }
1553
- const includeMatch = trimmed.match(/^include:\s*(.+)$/);
1554
- if (includeMatch) config.skills[currentCategory].include = this.parseLegacyList(includeMatch[1]);
1664
+ if (inlineValue) throw new ConfigValidationError(configPath, `skills.${key}`, `has an inline value ("${inlineValue}") where a category entry is required. Correction: write selection keys as nested mapping entries under "skills.${key}".`);
1665
+ currentCategory = key;
1666
+ currentCategoryIndent = indent;
1667
+ pendingList = null;
1668
+ skills[currentCategory] = {};
1555
1669
  }
1556
1670
  const overridesLine = lines.find((line) => line.trim().startsWith("custom_overrides:"));
1557
1671
  if (overridesLine) {
@@ -1586,7 +1700,27 @@ var ConfigService = class {
1586
1700
  }
1587
1701
  return config;
1588
1702
  }
1589
- normalizeRawConfig(rawConfig) {
1703
+ describeValue(value) {
1704
+ if (Array.isArray(value)) return "a list";
1705
+ if (value === null) return "null";
1706
+ return `a ${typeof value}`;
1707
+ }
1708
+ /**
1709
+ * Rejects reserved selection keys and non-mapping category values before any
1710
+ * registry request, naming the exact logical key. Category names themselves
1711
+ * are never checked against a local allowlist: a project may use any category
1712
+ * the remote registry actually publishes.
1713
+ */
1714
+ assertValidSkillCategories(skills, configPath) {
1715
+ if (skills === void 0) return;
1716
+ if (!this.isRecord(skills)) throw new ConfigValidationError(configPath, "skills", `must be a mapping of category name to its selection, not ${this.describeValue(skills)}.`);
1717
+ for (const [category, value] of Object.entries(skills)) {
1718
+ if (isReservedSelectionKey(category)) throw new ConfigValidationError(configPath, `skills.${category}`, `is a reserved selection key, not a category. Correction: ${RESERVED_SELECTION_KEYS[category]}.`);
1719
+ if (value === null || value === void 0) continue;
1720
+ if (!this.isRecord(value)) throw new ConfigValidationError(configPath, `skills.${category}`, `must be a mapping of optional ref/include/exclude (plus extension keys), not ${this.describeValue(value)}.`);
1721
+ }
1722
+ }
1723
+ normalizeRawConfig(rawConfig, configPath) {
1590
1724
  if (!this.isRecord(rawConfig)) return {
1591
1725
  config: {},
1592
1726
  migrated: false
@@ -1607,10 +1741,11 @@ var ConfigService = class {
1607
1741
  }
1608
1742
  return agent;
1609
1743
  });
1744
+ this.assertValidSkillCategories(normalized.skills, configPath);
1610
1745
  if (this.isRecord(normalized.skills)) {
1611
1746
  const normalizedSkills = {};
1612
1747
  for (const [category, value] of Object.entries(normalized.skills)) {
1613
- if (!this.isRecord(value)) {
1748
+ if (value === null || value === void 0) {
1614
1749
  normalizedSkills[category] = {};
1615
1750
  migrated = true;
1616
1751
  continue;
@@ -1648,20 +1783,29 @@ var ConfigService = class {
1648
1783
  const configPath = await this.resolveConfigPath(cwd);
1649
1784
  if (!configPath) return null;
1650
1785
  const content = await fs_extra.default.readFile(configPath, "utf8");
1786
+ return this.parseConfigSource(content, configPath, cwd);
1787
+ }
1788
+ /**
1789
+ * Parses already-read bytes with the same parser as `readConfigSource`. Used
1790
+ * by reviewed publication so its baseline comes from the verified file
1791
+ * content rather than caller-held snapshot objects.
1792
+ */
1793
+ parseConfigSource(content, configPath, cwd) {
1651
1794
  let rawConfig;
1652
1795
  let migratedFromText = false;
1653
1796
  try {
1654
1797
  rawConfig = js_yaml.load(content);
1655
1798
  } catch (parseError) {
1656
- rawConfig = this.migrateLegacyTextConfig(content);
1799
+ rawConfig = this.migrateLegacyTextConfig(content, configPath);
1657
1800
  migratedFromText = rawConfig !== null;
1658
1801
  if (!migratedFromText) throw parseError;
1659
1802
  }
1660
- const { config: normalizedConfig, migrated } = this.normalizeRawConfig(rawConfig);
1803
+ const { config: normalizedConfig, migrated } = this.normalizeRawConfig(rawConfig, configPath);
1661
1804
  const parsed = SkillConfigSchema.safeParse(normalizedConfig);
1662
1805
  if (!parsed.success) throw new Error(`Invalid .skillsrc format: ${parsed.error.message}`);
1663
1806
  return {
1664
1807
  config: parsed.data,
1808
+ document: normalizedConfig,
1665
1809
  raw: this.isRecord(rawConfig) ? { ...rawConfig } : {},
1666
1810
  sourcePath: configPath,
1667
1811
  fingerprint: (0, node_crypto.createHash)("sha256").update(content, "utf8").digest("hex"),
@@ -1680,10 +1824,11 @@ var ConfigService = class {
1680
1824
  try {
1681
1825
  const source = await this.readConfigSource(cwd);
1682
1826
  if (!source) return null;
1683
- if (source.migratedFromText || source.migrated || source.shouldRenameLegacyYamlFile) await fs_extra.default.outputFile(this.getPrimaryConfigPath(cwd), this.dumpConfig(source.config));
1827
+ if (source.migratedFromText || source.migrated || source.shouldRenameLegacyYamlFile) await fs_extra.default.outputFile(this.getPrimaryConfigPath(cwd), this.dumpConfig(source.document));
1684
1828
  if (source.shouldRenameLegacyYamlFile) await fs_extra.default.remove(source.sourcePath);
1685
1829
  return source.config;
1686
1830
  } catch (error) {
1831
+ if (error instanceof ConfigValidationError) throw error;
1687
1832
  throw new Error(`Failed to load config: ${error}`, { cause: error });
1688
1833
  }
1689
1834
  }
@@ -1707,28 +1852,92 @@ var ConfigService = class {
1707
1852
  * unrelated root values and category extension fields survive.
1708
1853
  */
1709
1854
  async saveSelection(snapshot, next, cwd = process.cwd()) {
1710
- const parsed = SkillConfigSchema.safeParse(next);
1711
- if (!parsed.success) throw new Error(`Invalid .skillsrc format: ${parsed.error.message}`);
1712
- const validated = parsed.data;
1855
+ return this.commitReviewedSnapshot(snapshot, next, cwd, (baseline, validated) => {
1856
+ const raw = baseline.raw;
1857
+ const merged = { ...raw };
1858
+ merged.skills = this.mergeSkillEntries(raw.skills, validated.skills);
1859
+ if (validated.skills_mode) merged.skills_mode = validated.skills_mode;
1860
+ else delete merged.skills_mode;
1861
+ return merged;
1862
+ });
1863
+ }
1864
+ /**
1865
+ * Publishes a complete reviewed configuration (accepted category/workflow/
1866
+ * specialist pins and later MCP consent updates) without dropping unknown
1867
+ * data. Changed known runtime fields are applied over the source document,
1868
+ * skill entries merge while preserving category extensions, and unknown root
1869
+ * fields plus unknown MCP extension fields survive.
1870
+ */
1871
+ async saveReviewedConfig(snapshot, next, cwd = process.cwd()) {
1872
+ return this.commitReviewedSnapshot(snapshot, next, cwd, (baseline, validated, configPath) => this.buildReviewedDocument(baseline.config, baseline.raw, validated, configPath));
1873
+ }
1874
+ /**
1875
+ * Shared publication: validate, verify the snapshot still matches the file
1876
+ * bytes, re-parse those bytes as the only baseline, build and validate the
1877
+ * normalized document, then write. Any failure refuses before the filesystem
1878
+ * is touched. `snapshot.config`/`snapshot.raw` are never used as a baseline
1879
+ * because callers can mutate them in place.
1880
+ */
1881
+ async commitReviewedSnapshot(snapshot, next, cwd, buildDocument) {
1882
+ this.assertValidSkillCategories(next.skills, snapshot.sourcePath);
1883
+ const parsedNext = SkillConfigSchema.safeParse(next);
1884
+ if (!parsedNext.success) throw new Error(`Invalid .skillsrc format: ${parsedNext.error.message}`);
1885
+ const validated = parsedNext.data;
1713
1886
  if (await this.resolveConfigPath(cwd) !== snapshot.sourcePath) throw new Error(CONFIG_CONFLICT_MESSAGE);
1714
1887
  const currentContent = await fs_extra.default.readFile(snapshot.sourcePath, "utf8");
1715
1888
  if ((0, node_crypto.createHash)("sha256").update(currentContent, "utf8").digest("hex") !== snapshot.fingerprint) throw new Error(CONFIG_CONFLICT_MESSAGE);
1716
- const merged = { ...snapshot.raw };
1717
- merged.skills = this.mergeSkillEntries(snapshot.raw.skills, validated.skills);
1718
- if (validated.skills_mode) merged.skills_mode = validated.skills_mode;
1719
- else delete merged.skills_mode;
1889
+ const baseline = this.parseConfigSource(currentContent, snapshot.sourcePath, cwd);
1720
1890
  const primaryConfigPath = this.getPrimaryConfigPath(cwd);
1891
+ const merged = buildDocument(baseline, validated, primaryConfigPath);
1892
+ const { config: normalizedDocument } = this.normalizeRawConfig(merged, primaryConfigPath);
1893
+ const persisted = SkillConfigSchema.safeParse(normalizedDocument);
1894
+ if (!persisted.success) throw new Error(`Invalid .skillsrc format: ${persisted.error.message}`);
1721
1895
  const content = this.dumpConfig(merged);
1722
1896
  await fs_extra.default.outputFile(primaryConfigPath, content);
1723
1897
  const legacyYamlPath = this.getLegacyYamlConfigPath(cwd);
1724
1898
  if (snapshot.sourcePath === legacyYamlPath && await fs_extra.default.pathExists(legacyYamlPath)) await fs_extra.default.remove(legacyYamlPath);
1725
1899
  return {
1726
- config: validated,
1900
+ config: persisted.data,
1727
1901
  raw: merged,
1728
1902
  sourcePath: primaryConfigPath,
1729
1903
  fingerprint: (0, node_crypto.createHash)("sha256").update(content, "utf8").digest("hex")
1730
1904
  };
1731
1905
  }
1906
+ buildReviewedDocument(previous, raw, next, configPath) {
1907
+ const merged = { ...raw };
1908
+ merged.skills = this.mergeSkillEntries(raw.skills, next.skills);
1909
+ const previousFields = previous;
1910
+ const nextFields = next;
1911
+ for (const field of REVIEWED_ROOT_FIELDS) {
1912
+ if (this.valuesEqual(nextFields[field], previousFields[field])) continue;
1913
+ if (nextFields[field] === void 0) delete merged[field];
1914
+ else merged[field] = nextFields[field];
1915
+ }
1916
+ if (!this.valuesEqual(next.mcp, previous.mcp)) {
1917
+ if (next.mcp === void 0) {
1918
+ const unknownKeys = this.isRecord(raw.mcp) ? Object.keys(raw.mcp).filter((key) => !MCP_FIELD_KEYS.includes(key)) : [];
1919
+ if (unknownKeys.length > 0) throw new ConfigValidationError(configPath, "mcp", `cannot be removed because it contains unrecognized extension keys (${unknownKeys.join(", ")}); remove them explicitly first.`);
1920
+ delete merged.mcp;
1921
+ } else {
1922
+ const mcp = { ...this.isRecord(raw.mcp) ? raw.mcp : {} };
1923
+ const nextMcp = next.mcp;
1924
+ for (const key of MCP_FIELD_KEYS) if (Object.prototype.hasOwnProperty.call(nextMcp, key)) mcp[key] = nextMcp[key];
1925
+ else delete mcp[key];
1926
+ merged.mcp = mcp;
1927
+ }
1928
+ }
1929
+ return merged;
1930
+ }
1931
+ valuesEqual(left, right) {
1932
+ if (left === right) return true;
1933
+ if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => this.valuesEqual(value, right[index]));
1934
+ if (this.isRecord(left) && this.isRecord(right)) {
1935
+ const leftKeys = Object.keys(left);
1936
+ const rightKeys = Object.keys(right);
1937
+ return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && this.valuesEqual(left[key], right[key]));
1938
+ }
1939
+ return false;
1940
+ }
1732
1941
  mergeSkillEntries(rawSkills, nextSkills) {
1733
1942
  const existing = this.isRecord(rawSkills) ? rawSkills : {};
1734
1943
  const merged = {};
@@ -1752,6 +1961,7 @@ var ConfigService = class {
1752
1961
  async saveConfig(config, cwd = process.cwd()) {
1753
1962
  const primaryConfigPath = this.getPrimaryConfigPath(cwd);
1754
1963
  const legacyYamlPath = this.getLegacyYamlConfigPath(cwd);
1964
+ this.assertValidSkillCategories(config.skills, primaryConfigPath);
1755
1965
  await fs_extra.default.outputFile(primaryConfigPath, this.dumpConfig(config));
1756
1966
  if (legacyYamlPath !== primaryConfigPath && await fs_extra.default.pathExists(legacyYamlPath)) await fs_extra.default.remove(legacyYamlPath);
1757
1967
  }
@@ -2606,8 +2816,485 @@ var HookService = class {
2606
2816
  }
2607
2817
  };
2608
2818
  //#endregion
2819
+ //#region src/services/install/safePath.ts
2820
+ /**
2821
+ * Asserts that a target file path resides inside rootDir and that NO intermediate
2822
+ * directory or terminal file under rootDir is a symbolic link.
2823
+ *
2824
+ * Requirements:
2825
+ * - rootDir may itself be a symlink (or inside one); components under rootDir must NOT be.
2826
+ * - Both existing files and dangling symlinks must be detected via lstat.
2827
+ * - Fails closed if lexical containment is breached or if any component under rootDir is a symlink.
2828
+ *
2829
+ * @param rootDir The trusted root directory
2830
+ * @param targetPath The destination file/directory path to check
2831
+ */
2832
+ async function assertNoSymlinksUnderRoot(rootDir, targetPath) {
2833
+ const resolvedRoot = path.default.resolve(rootDir);
2834
+ const resolvedTarget = path.default.resolve(targetPath);
2835
+ const relative = path.default.relative(resolvedRoot, resolvedTarget);
2836
+ if (relative === ".." || relative.startsWith(`..${path.default.sep}`) || path.default.isAbsolute(relative)) throw new Error(`Path traversal or outside target detected: "${targetPath}" is outside "${rootDir}"`);
2837
+ const segments = relative.split(path.default.sep).filter(Boolean);
2838
+ let current = resolvedRoot;
2839
+ for (const segment of segments) {
2840
+ current = path.default.join(current, segment);
2841
+ try {
2842
+ if ((await fs_extra.default.lstat(current)).isSymbolicLink()) throw new Error(`Symlink path component not permitted: "${current}" is a symbolic link under "${rootDir}"`);
2843
+ } catch (err) {
2844
+ if (err instanceof Error && err.message.includes("Symlink path component")) throw err;
2845
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") break;
2846
+ throw err;
2847
+ }
2848
+ }
2849
+ }
2850
+ var DIGEST_RE$1 = /^[0-9a-f]{64}$/;
2851
+ var GIT_BLOB_SHA1_RE$1 = /^[0-9a-f]{40}$/;
2852
+ var COMMIT_RE = /^[0-9a-f]{40}$/;
2853
+ var PACKAGE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
2854
+ var CATEGORY_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
2855
+ var ATTRIBUTION_FILE_NAME_RE$1 = /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i;
2856
+ function isBoundedRelativePath$1(value) {
2857
+ if (typeof value !== "string" || value.length === 0) return false;
2858
+ if (value.includes("\0") || value.includes("\\")) return false;
2859
+ if (node_path.default.posix.isAbsolute(value) || node_path.default.isAbsolute(value)) return false;
2860
+ if (node_path.default.posix.normalize(value) !== value) return false;
2861
+ return !value.split("/").includes("..");
2862
+ }
2863
+ function isBoundedAttributionFileName(value) {
2864
+ return typeof value === "string" && ATTRIBUTION_FILE_NAME_RE$1.test(value);
2865
+ }
2866
+ function fail$1(where, message) {
2867
+ throw new Error(`Invalid .skills-lock.json attributionCatalog: ${where} ${message}`);
2868
+ }
2869
+ function assertExactKeys(where, value, keys) {
2870
+ const allowed = new Set(keys);
2871
+ for (const key of Object.keys(value)) if (!allowed.has(key)) fail$1(where, `has unexpected field '${key}'`);
2872
+ }
2873
+ function assertString(where, field, value) {
2874
+ if (typeof value !== "string" || value.length === 0) fail$1(where, `has an invalid ${field}`);
2875
+ return value;
2876
+ }
2877
+ function assertRecord(where, value) {
2878
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail$1(where, "must be an object");
2879
+ return value;
2880
+ }
2881
+ function assertDigest(where, field, value) {
2882
+ if (typeof value !== "string" || !DIGEST_RE$1.test(value)) fail$1(where, `has an invalid ${field}`);
2883
+ return value;
2884
+ }
2885
+ function assertCommit(where, field, value) {
2886
+ if (typeof value !== "string" || !COMMIT_RE.test(value)) fail$1(where, `has an invalid ${field} (expected a resolved commit)`);
2887
+ return value;
2888
+ }
2889
+ function assertBlobSha1(where, value) {
2890
+ if (typeof value !== "string" || !GIT_BLOB_SHA1_RE$1.test(value)) fail$1(where, "has an invalid blobSha1 (expected a Git blob SHA-1, not a truncated SHA-256)");
2891
+ return value;
2892
+ }
2893
+ function parseDestination(where, value) {
2894
+ const raw = assertRecord(where, value);
2895
+ assertExactKeys(where, raw, [
2896
+ "agent",
2897
+ "path",
2898
+ "sha256"
2899
+ ]);
2900
+ const agent = assertString(where, "destination agent", raw.agent);
2901
+ const destinationPath = assertString(where, "destination path", raw.path);
2902
+ if (!isBoundedRelativePath$1(destinationPath)) fail$1(where, "has an unsafe destination path");
2903
+ return {
2904
+ agent,
2905
+ path: destinationPath,
2906
+ sha256: assertDigest(where, "destination sha256", raw.sha256)
2907
+ };
2908
+ }
2909
+ function parseFile(where, value) {
2910
+ const raw = assertRecord(where, value);
2911
+ assertExactKeys(where, raw, [
2912
+ "name",
2913
+ "sourcePath",
2914
+ "sourceKind",
2915
+ "requestedRef",
2916
+ "resolvedCommit",
2917
+ "blobSha1",
2918
+ "sha256",
2919
+ "basis",
2920
+ "destinations"
2921
+ ]);
2922
+ const name = raw.name;
2923
+ if (!isBoundedAttributionFileName(name)) fail$1(where, "has an invalid installed filename");
2924
+ const sourcePath = assertString(where, "sourcePath", raw.sourcePath);
2925
+ if (!isBoundedRelativePath$1(sourcePath)) fail$1(where, "has an unsafe sourcePath");
2926
+ if (raw.sourceKind !== "package" && raw.sourceKind !== "root") fail$1(where, "has an invalid sourceKind");
2927
+ if (raw.basis !== "release-manifest" && raw.basis !== "commit-tree-blob") fail$1(where, "has an invalid verification basis");
2928
+ if (!Array.isArray(raw.destinations) || raw.destinations.length === 0) fail$1(where, "has no published destinations");
2929
+ const destinations = raw.destinations.map((binding, index) => parseDestination(`${where} destination[${index}]`, binding));
2930
+ return {
2931
+ name,
2932
+ sourcePath,
2933
+ sourceKind: raw.sourceKind,
2934
+ requestedRef: assertString(where, "requestedRef", raw.requestedRef),
2935
+ resolvedCommit: assertCommit(where, "resolvedCommit", raw.resolvedCommit),
2936
+ blobSha1: assertBlobSha1(where, raw.blobSha1),
2937
+ sha256: assertDigest(where, "sha256", raw.sha256),
2938
+ basis: raw.basis,
2939
+ destinations
2940
+ };
2941
+ }
2942
+ function parsePackage(where, value) {
2943
+ const raw = assertRecord(where, value);
2944
+ assertExactKeys(where, raw, [
2945
+ "registry",
2946
+ "declaringCategory",
2947
+ "requestedRef",
2948
+ "resolvedCommit",
2949
+ "files"
2950
+ ]);
2951
+ if (typeof raw.declaringCategory !== "string" || !CATEGORY_TOKEN_RE.test(raw.declaringCategory)) fail$1(where, "has an invalid declaringCategory");
2952
+ if (!Array.isArray(raw.files) || raw.files.length === 0) fail$1(where, "has no attribution files");
2953
+ const fileNames = /* @__PURE__ */ new Set();
2954
+ const files = raw.files.map((file, index) => {
2955
+ const parsed = parseFile(`${where} files[${index}]`, file);
2956
+ if (fileNameKey(parsed.name, fileNames)) fail$1(where, `has a duplicate installed filename '${parsed.name}'`);
2957
+ return parsed;
2958
+ });
2959
+ return {
2960
+ registry: assertString(where, "registry", raw.registry),
2961
+ declaringCategory: raw.declaringCategory,
2962
+ requestedRef: assertString(where, "requestedRef", raw.requestedRef),
2963
+ resolvedCommit: assertCommit(where, "resolvedCommit", raw.resolvedCommit),
2964
+ files
2965
+ };
2966
+ }
2967
+ function fileNameKey(name, seen) {
2968
+ const key = name.toLowerCase();
2969
+ if (seen.has(key)) return true;
2970
+ seen.add(key);
2971
+ return false;
2972
+ }
2973
+ /**
2974
+ * Validates and narrows the optional attribution catalog. Unknown versions and
2975
+ * malformed qualified records fail closed so no caller can republish them.
2976
+ */
2977
+ function validateAttributionCatalog(catalog) {
2978
+ const raw = assertRecord("root", catalog);
2979
+ assertExactKeys("root", raw, ["version", "packages"]);
2980
+ if (raw.version !== 1) fail$1("root", `has unsupported version ${String(raw.version)} (this CLI only reads version 1)`);
2981
+ const rawPackages = assertRecord("packages", raw.packages);
2982
+ const packages = {};
2983
+ for (const [packageId, record] of Object.entries(rawPackages)) {
2984
+ if (!PACKAGE_ID_RE.test(packageId)) fail$1(`package '${packageId}'`, "is not a bounded category/skill key");
2985
+ packages[packageId] = parsePackage(`package '${packageId}'`, record);
2986
+ }
2987
+ return {
2988
+ version: 1,
2989
+ packages
2990
+ };
2991
+ }
2992
+ function sameOrigin(left, right) {
2993
+ return left.sourcePath === right.sourcePath && left.sourceKind === right.sourceKind && left.requestedRef === right.requestedRef && left.resolvedCommit === right.resolvedCommit && left.blobSha1 === right.blobSha1 && left.sha256 === right.sha256 && left.basis === right.basis;
2994
+ }
2995
+ /**
2996
+ * A previous record is trustworthy only when every recorded destination
2997
+ * exists and its own bytes still match its own recorded digest. A pooled
2998
+ * digest anywhere is not evidence: one intact copy never qualifies an edited
2999
+ * or missing sibling, and digests swapped between agents never match.
3000
+ */
3001
+ function qualifiesAgainstInstalled(previous, installed) {
3002
+ if (!installed || installed.length === 0) return false;
3003
+ return previous.destinations.every((recorded) => {
3004
+ const actual = installed.find((candidate) => candidate.agent === recorded.agent && candidate.path === recorded.path);
3005
+ return actual !== void 0 && actual.sha256 !== null && actual.sha256 === recorded.sha256;
3006
+ });
3007
+ }
3008
+ function compareFiles(previous, planned, unqualifiedNames, originMoved) {
3009
+ const previousByName = new Map(previous.files.map((file) => [file.name, file]));
3010
+ const plannedByName = new Map(planned.files.map((file) => [file.name, file]));
3011
+ const names = [.../* @__PURE__ */ new Set([...previousByName.keys(), ...plannedByName.keys()])].sort();
3012
+ const files = [];
3013
+ let sawAdded = false;
3014
+ let sawRemoved = false;
3015
+ let sawBytesChanged = false;
3016
+ let sawOriginChanged = false;
3017
+ for (const name of names) {
3018
+ const oldFile = previousByName.get(name) ?? null;
3019
+ const newFile = plannedByName.get(name) ?? null;
3020
+ const trustworthyOld = (oldFile ? unqualifiedNames.has(name) : false) ? null : oldFile;
3021
+ if (!oldFile && newFile) {
3022
+ sawAdded = true;
3023
+ files.push({
3024
+ name,
3025
+ status: "added",
3026
+ previousSha256: null,
3027
+ plannedSha256: newFile.sha256,
3028
+ previous: null,
3029
+ planned: newFile
3030
+ });
3031
+ continue;
3032
+ }
3033
+ if (oldFile && !newFile) {
3034
+ sawRemoved = true;
3035
+ files.push({
3036
+ name,
3037
+ status: "removed",
3038
+ previousSha256: oldFile.sha256,
3039
+ plannedSha256: null,
3040
+ previous: trustworthyOld,
3041
+ planned: null
3042
+ });
3043
+ continue;
3044
+ }
3045
+ if (oldFile && newFile) {
3046
+ if (oldFile.sha256 !== newFile.sha256) {
3047
+ sawBytesChanged = true;
3048
+ files.push({
3049
+ name,
3050
+ status: "changed",
3051
+ previousSha256: oldFile.sha256,
3052
+ plannedSha256: newFile.sha256,
3053
+ previous: trustworthyOld,
3054
+ planned: newFile
3055
+ });
3056
+ continue;
3057
+ }
3058
+ if (!sameOrigin(oldFile, newFile) || originMoved) {
3059
+ sawOriginChanged = true;
3060
+ files.push({
3061
+ name,
3062
+ status: "origin-changed",
3063
+ previousSha256: oldFile.sha256,
3064
+ plannedSha256: newFile.sha256,
3065
+ previous: trustworthyOld,
3066
+ planned: newFile
3067
+ });
3068
+ continue;
3069
+ }
3070
+ files.push({
3071
+ name,
3072
+ status: "unchanged",
3073
+ previousSha256: oldFile.sha256,
3074
+ plannedSha256: newFile.sha256,
3075
+ previous: trustworthyOld,
3076
+ planned: newFile
3077
+ });
3078
+ }
3079
+ }
3080
+ return {
3081
+ files,
3082
+ sawAdded,
3083
+ sawRemoved,
3084
+ sawBytesChanged,
3085
+ sawOriginChanged
3086
+ };
3087
+ }
3088
+ /**
3089
+ * Pure comparison of qualified attribution metadata. No writes, no prompts:
3090
+ * the command layer owns consent, publication and exit behavior.
3091
+ */
3092
+ function compareAttribution(input) {
3093
+ const previousPackages = input.previousCatalog?.packages ?? {};
3094
+ const packageIds = [.../* @__PURE__ */ new Set([...Object.keys(previousPackages), ...Object.keys(input.planned)])].sort();
3095
+ const packages = [];
3096
+ const legacyUnqualified = [];
3097
+ const disclosures = [];
3098
+ let requiresConsent = false;
3099
+ for (const packageId of packageIds) {
3100
+ const previous = previousPackages[packageId] ?? null;
3101
+ const planned = input.planned[packageId] ?? null;
3102
+ if (!input.hasExistingInstallation) {
3103
+ if (!planned) continue;
3104
+ packages.push({
3105
+ packageId,
3106
+ status: "added",
3107
+ reason: "first-install",
3108
+ previous: null,
3109
+ planned,
3110
+ files: planned.files.map((file) => ({
3111
+ name: file.name,
3112
+ status: "added",
3113
+ previousSha256: null,
3114
+ plannedSha256: file.sha256,
3115
+ previous: null,
3116
+ planned: file
3117
+ })),
3118
+ requiresConsent: false
3119
+ });
3120
+ disclosures.push(planned);
3121
+ continue;
3122
+ }
3123
+ if (!previous) {
3124
+ if (!planned) continue;
3125
+ const installed = input.installed?.[packageId];
3126
+ if (Object.values(installed ?? {}).some((bindings) => bindings.some((binding) => binding.sha256 !== null))) {
3127
+ legacyUnqualified.push(packageId);
3128
+ requiresConsent = true;
3129
+ packages.push({
3130
+ packageId,
3131
+ status: "legacy-unqualified",
3132
+ reason: "unqualified-origin",
3133
+ previous: null,
3134
+ planned,
3135
+ files: planned.files.map((file) => ({
3136
+ name: file.name,
3137
+ status: "added",
3138
+ previousSha256: null,
3139
+ plannedSha256: file.sha256,
3140
+ previous: null,
3141
+ planned: file
3142
+ })),
3143
+ requiresConsent: true
3144
+ });
3145
+ continue;
3146
+ }
3147
+ requiresConsent = true;
3148
+ packages.push({
3149
+ packageId,
3150
+ status: "added",
3151
+ reason: "added",
3152
+ previous: null,
3153
+ planned,
3154
+ files: planned.files.map((file) => ({
3155
+ name: file.name,
3156
+ status: "added",
3157
+ previousSha256: null,
3158
+ plannedSha256: file.sha256,
3159
+ previous: null,
3160
+ planned: file
3161
+ })),
3162
+ requiresConsent: true
3163
+ });
3164
+ continue;
3165
+ }
3166
+ const installed = input.installed?.[packageId];
3167
+ const unqualifiedNames = new Set(previous.files.filter((file) => !qualifiesAgainstInstalled(file, installed?.[file.name])).map((file) => file.name));
3168
+ if (!planned) {
3169
+ requiresConsent = true;
3170
+ packages.push({
3171
+ packageId,
3172
+ status: "removed",
3173
+ reason: "removed",
3174
+ previous: unqualifiedNames.size > 0 ? null : previous,
3175
+ planned: null,
3176
+ files: previous.files.map((file) => ({
3177
+ name: file.name,
3178
+ status: "removed",
3179
+ previousSha256: file.sha256,
3180
+ plannedSha256: null,
3181
+ previous: unqualifiedNames.has(file.name) ? null : file,
3182
+ planned: null
3183
+ })),
3184
+ requiresConsent: true
3185
+ });
3186
+ continue;
3187
+ }
3188
+ const compared = compareFiles(previous, planned, unqualifiedNames, previous.registry !== planned.registry || previous.declaringCategory !== planned.declaringCategory || previous.requestedRef !== planned.requestedRef || previous.resolvedCommit !== planned.resolvedCommit);
3189
+ if (unqualifiedNames.size > 0) {
3190
+ legacyUnqualified.push(packageId);
3191
+ requiresConsent = true;
3192
+ packages.push({
3193
+ packageId,
3194
+ status: "legacy-unqualified",
3195
+ reason: installed && Object.keys(installed).length > 0 ? "installed-bytes-mismatch" : "unqualified-origin",
3196
+ previous: null,
3197
+ planned,
3198
+ files: compared.files,
3199
+ requiresConsent: true
3200
+ });
3201
+ continue;
3202
+ }
3203
+ let reason = "unchanged";
3204
+ let status = "unchanged";
3205
+ if (compared.sawRemoved) {
3206
+ reason = "removed";
3207
+ status = "changed";
3208
+ } else if (compared.sawAdded) {
3209
+ reason = "added";
3210
+ status = "changed";
3211
+ } else if (compared.sawBytesChanged) {
3212
+ reason = "changed";
3213
+ status = "changed";
3214
+ } else if (compared.sawOriginChanged) {
3215
+ reason = "origin-changed";
3216
+ status = "origin-changed";
3217
+ }
3218
+ if (status !== "unchanged") requiresConsent = true;
3219
+ packages.push({
3220
+ packageId,
3221
+ status,
3222
+ reason,
3223
+ previous,
3224
+ planned,
3225
+ files: compared.files,
3226
+ requiresConsent: status !== "unchanged"
3227
+ });
3228
+ }
3229
+ return {
3230
+ firstInstall: !input.hasExistingInstallation,
3231
+ requiresConsent,
3232
+ packages,
3233
+ legacyUnqualified,
3234
+ disclosures
3235
+ };
3236
+ }
3237
+ /**
3238
+ * Digest of an installed attribution file, or null when it cannot be read
3239
+ * inside the project root. The shared install path boundary rejects traversal
3240
+ * and any symlink component (terminal, parent, or dangling) before the bytes
3241
+ * are read, so external bytes can never qualify a record.
3242
+ */
3243
+ async function readInstalledAttributionDigest(rootDir, relativePath) {
3244
+ try {
3245
+ const absPath = node_path.default.join(rootDir, relativePath);
3246
+ await assertNoSymlinksUnderRoot(rootDir, absPath);
3247
+ const bytes = await fs_extra.default.readFile(absPath);
3248
+ return (0, node_crypto.createHash)("sha256").update(bytes).digest("hex");
3249
+ } catch {
3250
+ return null;
3251
+ }
3252
+ }
3253
+ /**
3254
+ * Reads the bytes actually installed at every recorded destination, preserving
3255
+ * agent/path identity. A missing or unsafe destination is reported with
3256
+ * `sha256: null`, which the comparison treats as unqualified rather than as
3257
+ * agreement.
3258
+ */
3259
+ async function collectInstalledAttributionDigests(rootDir, packages) {
3260
+ const state = {};
3261
+ for (const [packageId, record] of Object.entries(packages)) {
3262
+ const perFile = {};
3263
+ for (const file of record.files) {
3264
+ perFile[file.name] = [];
3265
+ for (const binding of file.destinations) perFile[file.name].push({
3266
+ agent: binding.agent,
3267
+ path: binding.path,
3268
+ sha256: await readInstalledAttributionDigest(rootDir, binding.path)
3269
+ });
3270
+ }
3271
+ state[packageId] = perFile;
3272
+ }
3273
+ return state;
3274
+ }
3275
+ function buildAttributionCatalog(packages) {
3276
+ const sorted = {};
3277
+ for (const packageId of Object.keys(packages).sort()) sorted[packageId] = packages[packageId];
3278
+ return {
3279
+ version: 1,
3280
+ packages: sorted
3281
+ };
3282
+ }
3283
+ //#endregion
2609
3284
  //#region src/services/LockfileService.ts
2610
3285
  var LOCKFILE_NAME = ".skills-lock.json";
3286
+ /** Top-level names this writer owns; anything else is preserved verbatim. */
3287
+ var KNOWN_LOCK_KEYS = {
3288
+ version: true,
3289
+ registry: true,
3290
+ generatedAt: true,
3291
+ sources: true,
3292
+ entries: true,
3293
+ generation: true,
3294
+ disclosed: true,
3295
+ workflowCatalog: true,
3296
+ attributionCatalog: true
3297
+ };
2611
3298
  /**
2612
3299
  * Reserved ownership agent for the shared canonical workflow body installed at
2613
3300
  * `.agents/workflows/*.md`. It is not a provider and must never be added to the
@@ -2647,7 +3334,7 @@ function parseGenerationLedger(value) {
2647
3334
  entries
2648
3335
  };
2649
3336
  }
2650
- function sha256(content) {
3337
+ function sha256$1(content) {
2651
3338
  return (0, node_crypto.createHash)("sha256").update(content, Buffer.isBuffer(content) ? void 0 : "utf8").digest("hex");
2652
3339
  }
2653
3340
  /**
@@ -2687,7 +3374,8 @@ var lockV2Schema = zod.z.object({
2687
3374
  sha256: zod.z.string()
2688
3375
  })),
2689
3376
  disclosed: zod.z.record(zod.z.string(), zod.z.array(zod.z.string())).optional(),
2690
- workflowCatalog: zod.z.unknown().optional()
3377
+ workflowCatalog: zod.z.unknown().optional(),
3378
+ attributionCatalog: zod.z.unknown().optional()
2691
3379
  }).passthrough();
2692
3380
  var lockVersionProbe = zod.z.object({ version: zod.z.unknown() });
2693
3381
  /**
@@ -2701,12 +3389,13 @@ function parseLockV2(raw) {
2701
3389
  const where = issue?.path.length ? issue.path.join(".") : "root";
2702
3390
  throw new Error(`Invalid .skills-lock.json: ${where} ${issue?.message ?? "is invalid"}`);
2703
3391
  }
2704
- const { workflowCatalog: rawCatalog, ...rest } = parsed.data;
3392
+ const { workflowCatalog: rawCatalog, attributionCatalog: rawAttribution, ...rest } = parsed.data;
2705
3393
  const generation = parseGenerationLedger(raw.generation);
2706
3394
  return {
2707
3395
  ...rest,
2708
3396
  ...generation ? { generation } : {},
2709
- ...rawCatalog === void 0 ? {} : { workflowCatalog: validateWorkflowCatalog(rawCatalog) }
3397
+ ...rawCatalog === void 0 ? {} : { workflowCatalog: validateWorkflowCatalog(rawCatalog) },
3398
+ ...rawAttribution === void 0 ? {} : { attributionCatalog: validateAttributionCatalog(rawAttribution) }
2710
3399
  };
2711
3400
  }
2712
3401
  var DIGEST_RE = /^[a-f0-9]{64}$/;
@@ -2835,21 +3524,27 @@ var LockfileService = class {
2835
3524
  async write(rootDir, lock) {
2836
3525
  const file = this.lockfilePath(rootDir);
2837
3526
  if (lock.workflowCatalog !== void 0) validateWorkflowCatalog(lock.workflowCatalog);
3527
+ if (lock.attributionCatalog !== void 0) validateAttributionCatalog(lock.attributionCatalog);
3528
+ let existingRaw;
2838
3529
  let existingDisclosed;
2839
3530
  let existingGeneration;
3531
+ let existingAttribution;
2840
3532
  if (await fs_extra.default.pathExists(file)) {
2841
- let raw;
2842
3533
  try {
2843
- raw = await readJsonStrict(file);
3534
+ existingRaw = await readJsonStrict(file);
2844
3535
  } catch {}
3536
+ const raw = existingRaw;
2845
3537
  if (raw) {
2846
3538
  existingDisclosed = raw.disclosed;
2847
- if (raw.version === 2) existingGeneration = parseGenerationLedger(raw.generation);
2848
- else if (raw.generation !== void 0) throw new Error("Unsupported repository-generation lock namespace");
3539
+ if (raw.version === 2) {
3540
+ existingGeneration = parseGenerationLedger(raw.generation);
3541
+ if (raw.attributionCatalog !== void 0) existingAttribution = validateAttributionCatalog(raw.attributionCatalog);
3542
+ } else if (raw.generation !== void 0) throw new Error("Unsupported repository-generation lock namespace");
2849
3543
  }
2850
3544
  }
2851
3545
  const generation = lock.generation === void 0 ? existingGeneration : parseGenerationLedger(lock.generation);
2852
3546
  const disclosed = lock.disclosed ?? existingDisclosed;
3547
+ const attribution = lock.attributionCatalog === void 0 ? existingAttribution : lock.attributionCatalog;
2853
3548
  const sortedEntries = {};
2854
3549
  for (const k of Object.keys(lock.entries).sort()) sortedEntries[k] = lock.entries[k];
2855
3550
  const sortedGenerationEntries = {};
@@ -2861,10 +3556,16 @@ var LockfileService = class {
2861
3556
  version: 1,
2862
3557
  entries: sortedGenerationEntries
2863
3558
  } } : {},
2864
- ...disclosed ? { disclosed } : {}
3559
+ ...disclosed ? { disclosed } : {},
3560
+ ...attribution ? { attributionCatalog: attribution } : {}
2865
3561
  };
2866
3562
  if (!generation) delete out.generation;
2867
3563
  if (!disclosed) delete out.disclosed;
3564
+ if (!attribution) delete out.attributionCatalog;
3565
+ for (const [key, value] of Object.entries(existingRaw ?? {})) {
3566
+ if (KNOWN_LOCK_KEYS[key] || key in out) continue;
3567
+ out[key] = value;
3568
+ }
2868
3569
  await this.publishAtomic(file, out);
2869
3570
  }
2870
3571
  /**
@@ -2894,6 +3595,7 @@ var LockfileService = class {
2894
3595
  if (raw?.version === 2) parseGenerationLedger(raw.generation);
2895
3596
  else if (raw?.generation !== void 0) throw new Error("Unsupported repository-generation lock namespace");
2896
3597
  if (raw.workflowCatalog !== void 0) validateWorkflowCatalog(raw.workflowCatalog);
3598
+ if (raw.attributionCatalog !== void 0) validateAttributionCatalog(raw.attributionCatalog);
2897
3599
  await this.publishAtomic(file, {
2898
3600
  ...raw,
2899
3601
  disclosed
@@ -2909,7 +3611,7 @@ var LockfileService = class {
2909
3611
  missing.push(rel);
2910
3612
  continue;
2911
3613
  }
2912
- if (sha256(await fs_extra.default.readFile(absPath)) !== entry.sha256) mismatches.push(rel);
3614
+ if (sha256$1(await fs_extra.default.readFile(absPath)) !== entry.sha256) mismatches.push(rel);
2913
3615
  }
2914
3616
  return {
2915
3617
  ok: mismatches.length === 0 && missing.length === 0,
@@ -4009,6 +4711,302 @@ var InitService = class {
4009
4711
  await fs_extra.default.outputFile(configPath, commentHeader + js_yaml.dump(config));
4010
4712
  }
4011
4713
  };
4714
+ var AMBIGUOUS_ERROR_BODY_TIMEOUT_MS = 1e3;
4715
+ var RATE_LIMIT_EVIDENCE = /rate[-_ ]?limit|abuse[-_ ]?detection|too many requests/i;
4716
+ /**
4717
+ * Drops credentials, query strings, and fragments. GitHub tokens can appear in
4718
+ * query strings on some endpoints, so only origin and path are retained.
4719
+ */
4720
+ function sanitizeEndpoint(url) {
4721
+ try {
4722
+ const parsed = new URL(url);
4723
+ return `${parsed.origin}${parsed.pathname}`;
4724
+ } catch {
4725
+ return url.split(/[?#]/)[0] ?? url;
4726
+ }
4727
+ }
4728
+ function readHeader(res, name) {
4729
+ const headers = res.headers;
4730
+ if (!headers || typeof headers.get !== "function") return void 0;
4731
+ const value = headers.get(name);
4732
+ return typeof value === "string" ? value : void 0;
4733
+ }
4734
+ /** `Retry-After` in seconds. Malformed, negative, or non-integer values are ignored. */
4735
+ function parseRetryAfterSeconds(value) {
4736
+ if (value === void 0) return void 0;
4737
+ const trimmed = value.trim();
4738
+ if (!/^\d+$/.test(trimmed)) return void 0;
4739
+ const seconds = Number(trimmed);
4740
+ return Number.isSafeInteger(seconds) ? seconds : void 0;
4741
+ }
4742
+ /** `x-ratelimit-reset` is epoch seconds. Malformed or implausible values are ignored. */
4743
+ function parseResetAt(value) {
4744
+ if (value === void 0) return void 0;
4745
+ const trimmed = value.trim();
4746
+ if (!/^\d+$/.test(trimmed)) return void 0;
4747
+ const seconds = Number(trimmed);
4748
+ if (!Number.isSafeInteger(seconds) || seconds <= 0) return void 0;
4749
+ const date = /* @__PURE__ */ new Date(seconds * 1e3);
4750
+ const time = date.getTime();
4751
+ if (Number.isNaN(time)) return void 0;
4752
+ const year = date.getUTCFullYear();
4753
+ if (year < 1970 || year > 2100) return void 0;
4754
+ return date.toISOString();
4755
+ }
4756
+ function parseRemaining(value) {
4757
+ if (value === void 0) return void 0;
4758
+ const trimmed = value.trim();
4759
+ if (!/^\d+$/.test(trimmed)) return void 0;
4760
+ const remaining = Number(trimmed);
4761
+ return Number.isSafeInteger(remaining) ? remaining : void 0;
4762
+ }
4763
+ function readNodeBody(stream) {
4764
+ return new Promise((resolve) => {
4765
+ const chunks = [];
4766
+ let total = 0;
4767
+ let settled = false;
4768
+ const finish = (text) => {
4769
+ if (settled) return;
4770
+ settled = true;
4771
+ clearTimeout(timer);
4772
+ stream.removeListener("data", onData);
4773
+ stream.removeListener("end", onEnd);
4774
+ stream.removeListener("error", onError);
4775
+ if (text === null) stream.destroy();
4776
+ else stream.pause();
4777
+ resolve(text);
4778
+ };
4779
+ const onData = (chunk) => {
4780
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4781
+ total += bytes.byteLength;
4782
+ if (total > 8192) {
4783
+ finish(null);
4784
+ return;
4785
+ }
4786
+ chunks.push(bytes);
4787
+ };
4788
+ const onEnd = () => finish(Buffer.concat(chunks).toString("utf8"));
4789
+ const onError = () => finish(null);
4790
+ const timer = setTimeout(() => finish(null), AMBIGUOUS_ERROR_BODY_TIMEOUT_MS);
4791
+ stream.on("data", onData);
4792
+ stream.on("end", onEnd);
4793
+ stream.on("error", onError);
4794
+ });
4795
+ }
4796
+ function readWebChunk(reader, timeoutMs) {
4797
+ return new Promise((resolve) => {
4798
+ let settled = false;
4799
+ const timer = setTimeout(() => {
4800
+ if (settled) return;
4801
+ settled = true;
4802
+ resolve("timeout");
4803
+ }, timeoutMs);
4804
+ reader.read().then((result) => {
4805
+ if (settled) return;
4806
+ settled = true;
4807
+ clearTimeout(timer);
4808
+ resolve(result);
4809
+ }, () => {
4810
+ if (settled) return;
4811
+ settled = true;
4812
+ clearTimeout(timer);
4813
+ resolve("timeout");
4814
+ });
4815
+ });
4816
+ }
4817
+ /**
4818
+ * Abandons a WHATWG reader without waiting on `cancel()`. A cancellation
4819
+ * promise may never settle; awaiting it would extend the classification
4820
+ * deadline. The lock is released when possible, otherwise the runtime owns the
4821
+ * reader after the pending read settles.
4822
+ */
4823
+ function abandonWebReader(reader) {
4824
+ try {
4825
+ reader.cancel().catch(() => void 0);
4826
+ } catch {}
4827
+ try {
4828
+ reader.releaseLock();
4829
+ } catch {}
4830
+ }
4831
+ async function readWebBody(stream) {
4832
+ const reader = stream.getReader();
4833
+ const decoder = new TextDecoder();
4834
+ let text = "";
4835
+ let total = 0;
4836
+ const deadline = Date.now() + AMBIGUOUS_ERROR_BODY_TIMEOUT_MS;
4837
+ try {
4838
+ for (;;) {
4839
+ const remaining = deadline - Date.now();
4840
+ if (remaining <= 0) {
4841
+ abandonWebReader(reader);
4842
+ return null;
4843
+ }
4844
+ const chunk = await readWebChunk(reader, remaining);
4845
+ if (chunk === "timeout") {
4846
+ abandonWebReader(reader);
4847
+ return null;
4848
+ }
4849
+ if (chunk.done) return text + decoder.decode();
4850
+ total += chunk.value.byteLength;
4851
+ if (total > 8192) {
4852
+ abandonWebReader(reader);
4853
+ return null;
4854
+ }
4855
+ text += decoder.decode(chunk.value, { stream: true });
4856
+ }
4857
+ } catch {
4858
+ return null;
4859
+ }
4860
+ }
4861
+ /**
4862
+ * Releases a response body that will never be read. Node streams are destroyed
4863
+ * synchronously; WHATWG cancellation is started with rejection handling and
4864
+ * never awaited, so a stalled cancel cannot hold up classification or latch.
4865
+ */
4866
+ function abandonResponseBody(res) {
4867
+ const body = res.body;
4868
+ if (body instanceof node_stream.Readable) {
4869
+ body.destroy();
4870
+ return;
4871
+ }
4872
+ if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) try {
4873
+ body.cancel().catch(() => void 0);
4874
+ } catch {}
4875
+ }
4876
+ /**
4877
+ * Reads only the ambiguous 403 body, bounded in bytes and time, for both the
4878
+ * Node stream that `cross-fetch` returns and a WHATWG stream. Any timeout,
4879
+ * oversize read, or stream error releases the body and returns null.
4880
+ */
4881
+ function readAmbiguousErrorBody(res) {
4882
+ const body = res.body;
4883
+ if (body instanceof node_stream.Readable) return readNodeBody(body);
4884
+ if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) return readWebBody(body);
4885
+ return Promise.resolve(null);
4886
+ }
4887
+ var GithubRequestError = class GithubRequestError extends Error {
4888
+ kind;
4889
+ endpoint;
4890
+ authenticated;
4891
+ status;
4892
+ statusText;
4893
+ retryAfterSeconds;
4894
+ resetAt;
4895
+ rateLimitKind;
4896
+ constructor(init) {
4897
+ super(init.message, { cause: init.cause });
4898
+ this.name = "GithubRequestError";
4899
+ this.kind = init.kind;
4900
+ this.endpoint = sanitizeEndpoint(init.endpoint);
4901
+ this.authenticated = init.authenticated;
4902
+ this.status = init.status;
4903
+ this.statusText = init.statusText;
4904
+ this.retryAfterSeconds = init.retryAfterSeconds;
4905
+ this.resetAt = init.resetAt;
4906
+ this.rateLimitKind = init.rateLimitKind;
4907
+ }
4908
+ /** Serialization allowlist: no token, no headers, no cause chain. */
4909
+ toJSON() {
4910
+ return {
4911
+ name: "GithubRequestError",
4912
+ kind: this.kind,
4913
+ status: this.status ?? null,
4914
+ endpoint: this.endpoint,
4915
+ authenticated: this.authenticated,
4916
+ retryAfterSeconds: this.retryAfterSeconds ?? null,
4917
+ resetAt: this.resetAt ?? null,
4918
+ rateLimitKind: this.rateLimitKind ?? null,
4919
+ message: this.message
4920
+ };
4921
+ }
4922
+ static isRateLimit(error) {
4923
+ return error instanceof GithubRequestError && error.kind === "rate-limited";
4924
+ }
4925
+ static isAccessDenial(error) {
4926
+ return error instanceof GithubRequestError && error.kind === "access-denied";
4927
+ }
4928
+ };
4929
+ function buildFailure(res) {
4930
+ const status = typeof res.status === "number" && Number.isInteger(res.status) ? res.status : 0;
4931
+ const statusText = typeof res.statusText === "string" ? res.statusText : void 0;
4932
+ return {
4933
+ status,
4934
+ statusText,
4935
+ statusLabel: `${status || "unknown"}${statusText ? ` ${statusText}` : ""}`,
4936
+ retryAfterSeconds: parseRetryAfterSeconds(readHeader(res, "retry-after")),
4937
+ resetAt: parseResetAt(readHeader(res, "x-ratelimit-reset"))
4938
+ };
4939
+ }
4940
+ function classifyGithubStatus(res, endpoint, authenticated) {
4941
+ const { status, statusText, statusLabel, retryAfterSeconds, resetAt } = buildFailure(res);
4942
+ const primary = parseRemaining(readHeader(res, "x-ratelimit-remaining")) === 0;
4943
+ if (status === 404) return new GithubRequestError({
4944
+ kind: "not-found",
4945
+ message: `GitHub resource not found: ${statusLabel}`,
4946
+ endpoint,
4947
+ authenticated,
4948
+ status,
4949
+ statusText
4950
+ });
4951
+ if (status === 401) return new GithubRequestError({
4952
+ kind: "access-denied",
4953
+ message: `GitHub access denied: ${statusLabel}`,
4954
+ endpoint,
4955
+ authenticated,
4956
+ status,
4957
+ statusText,
4958
+ resetAt
4959
+ });
4960
+ if (status === 403 || status === 429) {
4961
+ if (primary || status === 429 || retryAfterSeconds !== void 0) {
4962
+ const rateLimitKind = primary ? "primary" : retryAfterSeconds !== void 0 || status === 429 ? "secondary" : void 0;
4963
+ return new GithubRequestError({
4964
+ kind: "rate-limited",
4965
+ message: `GitHub rate limit exceeded (${rateLimitKind ?? "unclassified"}): ${statusLabel}`,
4966
+ endpoint,
4967
+ authenticated,
4968
+ status,
4969
+ statusText,
4970
+ retryAfterSeconds,
4971
+ resetAt,
4972
+ rateLimitKind
4973
+ });
4974
+ }
4975
+ return "ambiguous";
4976
+ }
4977
+ return new GithubRequestError({
4978
+ kind: "transport",
4979
+ message: `GitHub API Error: ${statusLabel}`,
4980
+ endpoint,
4981
+ authenticated,
4982
+ status,
4983
+ statusText
4984
+ });
4985
+ }
4986
+ /** Resolves an ambiguous 403 by reading a bounded body for throttle evidence. */
4987
+ async function classifyAmbiguous403(res, endpoint, authenticated) {
4988
+ const { status, statusText, statusLabel, resetAt } = buildFailure(res);
4989
+ const body = await readAmbiguousErrorBody(res);
4990
+ if (body !== null && RATE_LIMIT_EVIDENCE.test(body)) return new GithubRequestError({
4991
+ kind: "rate-limited",
4992
+ message: `GitHub rate limit exceeded (secondary): ${statusLabel}`,
4993
+ endpoint,
4994
+ authenticated,
4995
+ status,
4996
+ statusText,
4997
+ resetAt,
4998
+ rateLimitKind: "secondary"
4999
+ });
5000
+ return new GithubRequestError({
5001
+ kind: "access-denied",
5002
+ message: `GitHub access denied: ${statusLabel}`,
5003
+ endpoint,
5004
+ authenticated,
5005
+ status,
5006
+ statusText,
5007
+ resetAt
5008
+ });
5009
+ }
4012
5010
  //#endregion
4013
5011
  //#region src/services/GithubService.ts
4014
5012
  /** A downloaded file failed its integrity or size check. */
@@ -4024,13 +5022,32 @@ function gitBlobSha1(content) {
4024
5022
  return (0, node_crypto.createHash)("sha1").update(Buffer.concat([header, content])).digest("hex");
4025
5023
  }
4026
5024
  /**
5025
+ * Detaches a cached value from the caller that receives it. Buffers are
5026
+ * copied; plain objects are structured-cloned. The session cache therefore
5027
+ * never shares mutable state with consumers.
5028
+ */
5029
+ function detachValue(value) {
5030
+ if (Buffer.isBuffer(value)) return Buffer.from(value);
5031
+ if (value !== null && typeof value === "object") return structuredClone(value);
5032
+ return value;
5033
+ }
5034
+ /**
4027
5035
  * Service for interacting with the GitHub API and fetching raw file content.
4028
5036
  * Handles repository tree discovery, file downloads, and URL parsing.
5037
+ *
5038
+ * One instance is operation-scoped: identical requests are coalesced and
5039
+ * reused for the lifetime of the operation, the first confirmed throttling
5040
+ * response latches the whole operation, and `resetOperation()` clears both
5041
+ * when a client is reused for another command.
4029
5042
  */
4030
- var GithubService = class {
5043
+ var GithubService = class GithubService {
4031
5044
  token;
4032
5045
  baseUrl = "https://api.github.com";
4033
5046
  rawBaseUrl = "https://raw.githubusercontent.com";
5047
+ sessionCache = /* @__PURE__ */ new Map();
5048
+ inFlight = /* @__PURE__ */ new Map();
5049
+ rateLimitError = null;
5050
+ operationEpoch = 0;
4034
5051
  constructor(token) {
4035
5052
  this.token = token;
4036
5053
  }
@@ -4039,49 +5056,155 @@ var GithubService = class {
4039
5056
  if (this.token) h.Authorization = `token ${this.token}`;
4040
5057
  return h;
4041
5058
  }
5059
+ /** True once this operation has observed confirmed throttling. */
5060
+ get rateLimited() {
5061
+ return this.rateLimitError !== null;
5062
+ }
5063
+ /** The first throttling failure that latched this operation, if any. */
5064
+ get rateLimitFailure() {
5065
+ return this.rateLimitError;
5066
+ }
5067
+ /**
5068
+ * Clears session caches and the throttle latch. Call only between commands
5069
+ * that reuse the same client. Requests already in flight settle normally,
5070
+ * but their results are not retained.
5071
+ */
5072
+ resetOperation() {
5073
+ this.sessionCache.clear();
5074
+ this.inFlight.clear();
5075
+ this.rateLimitError = null;
5076
+ this.operationEpoch += 1;
5077
+ }
5078
+ /**
5079
+ * Single-flight per request identity: concurrent callers share one pending
5080
+ * request, completed values are reused within the operation, and failures
5081
+ * are never cached (so a recovered request can be retried).
5082
+ */
5083
+ async sessionRequest(key, run) {
5084
+ const cached = this.sessionCache.get(key);
5085
+ if (cached !== void 0) return detachValue(cached);
5086
+ const pending = this.inFlight.get(key);
5087
+ if (pending) return detachValue(await pending);
5088
+ const epoch = this.operationEpoch;
5089
+ const promise = this.runSessionRequest(key, epoch, run);
5090
+ this.inFlight.set(key, promise);
5091
+ return promise;
5092
+ }
5093
+ async runSessionRequest(key, epoch, run) {
5094
+ try {
5095
+ const value = await run();
5096
+ if (epoch === this.operationEpoch && value !== null && value !== void 0) this.sessionCache.set(key, detachValue(value));
5097
+ return value;
5098
+ } finally {
5099
+ if (epoch === this.operationEpoch) this.inFlight.delete(key);
5100
+ }
5101
+ }
5102
+ /**
5103
+ * Performs one HTTP request, rejecting with a typed failure for any non-2xx
5104
+ * response or transport error.
5105
+ *
5106
+ * - `authenticated` reflects the Authorization header actually attached to
5107
+ * this request, never a constructor assumption.
5108
+ * - A status/header-decidable throttle latches synchronously; only an
5109
+ * ambiguous 403 awaits its bounded body, and that latch is guarded by the
5110
+ * epoch captured before the request started.
5111
+ * - Transport messages are fixed text; the raw error (which may contain
5112
+ * credentials in a URL) stays internal to `cause`.
5113
+ */
5114
+ async request(url, init) {
5115
+ if (this.rateLimitError) throw this.rateLimitError;
5116
+ const epoch = this.operationEpoch;
5117
+ const authenticated = GithubService.hasAttachedAuthorization(init);
5118
+ let res;
5119
+ try {
5120
+ res = init === void 0 ? await (0, cross_fetch.default)(url) : await (0, cross_fetch.default)(url, init);
5121
+ } catch (error) {
5122
+ throw new GithubRequestError({
5123
+ kind: "transport",
5124
+ message: "GitHub request failed before a response was received",
5125
+ endpoint: url,
5126
+ authenticated,
5127
+ cause: error
5128
+ });
5129
+ }
5130
+ if (!res.ok) {
5131
+ const verdict = classifyGithubStatus(res, url, authenticated);
5132
+ if (verdict !== "ambiguous") {
5133
+ if (verdict.kind === "rate-limited" && epoch === this.operationEpoch && !this.rateLimitError) this.rateLimitError = verdict;
5134
+ abandonResponseBody(res);
5135
+ throw verdict;
5136
+ }
5137
+ const failure = await classifyAmbiguous403(res, url, authenticated);
5138
+ if (failure.kind === "rate-limited" && epoch === this.operationEpoch && !this.rateLimitError) this.rateLimitError = failure;
5139
+ throw failure;
5140
+ }
5141
+ return res;
5142
+ }
5143
+ /** True only when this request's headers actually carry an Authorization value. */
5144
+ static hasAttachedAuthorization(init) {
5145
+ const headers = init?.headers;
5146
+ if (!headers) return false;
5147
+ if (Array.isArray(headers)) return headers.some((entry) => entry[0]?.toLowerCase() === "authorization" && Boolean(entry[1]));
5148
+ if (typeof Headers !== "undefined" && headers instanceof Headers) return Boolean(headers.get("authorization"));
5149
+ return Object.entries(headers).some(([name, value]) => name.toLowerCase() === "authorization" && typeof value === "string" && value.length > 0);
5150
+ }
5151
+ /** Rate-limit and access-denial failures are actionable; never flatten them to null. */
5152
+ static isActionable(error) {
5153
+ return GithubRequestError.isRateLimit(error) || GithubRequestError.isAccessDenial(error);
5154
+ }
5155
+ static isAbsence(error) {
5156
+ return error instanceof GithubRequestError && error.kind === "not-found";
5157
+ }
4042
5158
  /**
4043
5159
  * Fetches the recursive Git tree for a repository.
4044
5160
  * @param owner Repository owner
4045
5161
  * @param repo Repository name
4046
5162
  * @param ref Git reference (branch, tag, or commit SHA)
4047
- * @returns The tree data or null if not found
5163
+ * @param options.commit Resolved commit; keys the shared tree by immutable identity
5164
+ * @returns The tree data, or null when the object or reference does not exist
4048
5165
  */
4049
- async getRepoTree(owner, repo, ref) {
4050
- const url = `${this.baseUrl}/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`;
4051
- try {
4052
- const res = await (0, cross_fetch.default)(url, { headers: this.headers });
4053
- if (!res.ok) {
4054
- if (res.status === 404) return null;
4055
- throw new Error(`GitHub API Error: ${res.status} ${res.statusText}`);
5166
+ async getRepoTree(owner, repo, ref, options) {
5167
+ const identity = options?.commit ?? ref;
5168
+ const key = options?.commit ? `tree-commit:${owner}/${repo}@${options.commit}` : `tree-ref:${owner}/${repo}@${ref}`;
5169
+ const url = `${this.baseUrl}/repos/${owner}/${repo}/git/trees/${identity}?recursive=1`;
5170
+ return this.sessionRequest(key, async () => {
5171
+ try {
5172
+ return await (await this.request(url, { headers: this.headers })).json();
5173
+ } catch (error) {
5174
+ if (GithubService.isActionable(error)) throw error;
5175
+ if (GithubService.isAbsence(error)) return null;
5176
+ console.error(picocolors.default.red(`Failed to fetch repo tree: ${error}`));
5177
+ return null;
4056
5178
  }
4057
- return await res.json();
4058
- } catch (error) {
4059
- console.error(picocolors.default.red(`Failed to fetch repo tree: ${error}`));
4060
- return null;
4061
- }
5179
+ });
4062
5180
  }
4063
5181
  /**
4064
5182
  * Fetches raw file bytes from GitHub and validates their Git blob identity.
4065
5183
  * Text callers should use getRawFile; package callers use this byte-preserving path.
4066
5184
  */
4067
5185
  async getRawFileBytes(owner, repo, ref, path, options) {
4068
- const url = `${this.rawBaseUrl}/${owner}/${repo}/${ref}/${path}`;
4069
- try {
4070
- const res = await (0, cross_fetch.default)(url);
4071
- if (!res.ok) return null;
4072
- const content = Buffer.from(await res.arrayBuffer());
4073
- const maxBytes = options?.maxBytes ?? 1048576;
4074
- if (content.byteLength > maxBytes) throw new IntegrityError(`${path}: ${content.byteLength} bytes exceeds the ${maxBytes}-byte limit`);
4075
- if (options?.expectedSha) {
4076
- const actualSha = gitBlobSha1(content);
4077
- if (actualSha !== options.expectedSha) throw new IntegrityError(`${path}: blob sha mismatch (expected ${options.expectedSha}, got ${actualSha}) — download may be corrupted or tampered with`);
5186
+ const identity = options?.commit ?? ref;
5187
+ const maxBytes = options?.maxBytes ?? 1048576;
5188
+ const key = `raw:${owner}/${repo}@${identity}:${path}#${options?.expectedSha ?? ""}#${maxBytes}`;
5189
+ const url = `${this.rawBaseUrl}/${owner}/${repo}/${identity}/${path}`;
5190
+ return this.sessionRequest(key, async () => {
5191
+ try {
5192
+ const res = await this.request(url);
5193
+ const content = Buffer.from(await res.arrayBuffer());
5194
+ if (content.byteLength > maxBytes) throw new IntegrityError(`${path}: ${content.byteLength} bytes exceeds the ${maxBytes}-byte limit`);
5195
+ if (options?.expectedSha) {
5196
+ const actualSha = gitBlobSha1(content);
5197
+ if (actualSha !== options.expectedSha) throw new IntegrityError(`${path}: blob sha mismatch (expected ${options.expectedSha}, got ${actualSha}) — download may be corrupted or tampered with`);
5198
+ }
5199
+ return content;
5200
+ } catch (error) {
5201
+ if (error instanceof IntegrityError) throw error;
5202
+ if (GithubService.isActionable(error)) throw error;
5203
+ if (GithubService.isAbsence(error)) return null;
5204
+ console.error(picocolors.default.red(`Failed to fetch file ${path}: ${error}`));
5205
+ return null;
4078
5206
  }
4079
- return content;
4080
- } catch (error) {
4081
- if (error instanceof IntegrityError) throw error;
4082
- console.error(picocolors.default.red(`Failed to fetch file ${path}: ${error}`));
4083
- return null;
4084
- }
5207
+ });
4085
5208
  }
4086
5209
  /**
4087
5210
  * Fetches raw file content as UTF-8 for established text-only callers.
@@ -4095,14 +5218,17 @@ var GithubService = class {
4095
5218
  * @param repo Repository name
4096
5219
  */
4097
5220
  async getLatestReleaseTag(owner, repo) {
4098
- try {
4099
- const res = await (0, cross_fetch.default)(`${this.baseUrl}/repos/${owner}/${repo}/releases/latest`, { headers: this.headers });
4100
- if (!res.ok) return null;
4101
- return (await res.json()).tag_name;
4102
- } catch (error) {
4103
- if (process.env.DEBUG) console.warn(`[GithubService] Failed to fetch latest release: ${error}`);
4104
- return null;
4105
- }
5221
+ const key = `latest-release:${owner}/${repo}`;
5222
+ const url = `${this.baseUrl}/repos/${owner}/${repo}/releases/latest`;
5223
+ return this.sessionRequest(key, async () => {
5224
+ try {
5225
+ return (await (await this.request(url, { headers: this.headers })).json()).tag_name;
5226
+ } catch (error) {
5227
+ if (GithubService.isActionable(error)) throw error;
5228
+ if (process.env.DEBUG) console.warn(`[GithubService] Failed to fetch latest release: ${error}`);
5229
+ return null;
5230
+ }
5231
+ });
4106
5232
  }
4107
5233
  /**
4108
5234
  * Fetches basic repository information, such as the default branch.
@@ -4110,34 +5236,59 @@ var GithubService = class {
4110
5236
  * @param repo Repository name
4111
5237
  */
4112
5238
  async getRepoInfo(owner, repo) {
5239
+ const key = `repo-info:${owner}/${repo}`;
4113
5240
  const url = `${this.baseUrl}/repos/${owner}/${repo}`;
4114
- try {
4115
- const res = await (0, cross_fetch.default)(url, { headers: this.headers });
4116
- if (!res.ok) return null;
4117
- return await res.json();
4118
- } catch (error) {
4119
- console.error(picocolors.default.red(`Failed to fetch repo info: ${error}`));
4120
- return null;
4121
- }
5241
+ return this.sessionRequest(key, async () => {
5242
+ try {
5243
+ return await (await this.request(url, { headers: this.headers })).json();
5244
+ } catch (error) {
5245
+ if (GithubService.isActionable(error)) throw error;
5246
+ console.error(picocolors.default.red(`Failed to fetch repo info: ${error}`));
5247
+ return null;
5248
+ }
5249
+ });
4122
5250
  }
4123
5251
  /**
4124
5252
  * Downloads multiple text files concurrently with a limit. Existing text
4125
5253
  * consumers retain UTF-8 strings; package sync uses the byte variant below.
5254
+ *
5255
+ * Rejects with the first known `IntegrityError` or access-denied
5256
+ * `GithubRequestError` in input-task order after every started worker
5257
+ * settles, unless a throttle latch owns the terminal `rateLimited` result.
4126
5258
  */
4127
5259
  async downloadFilesConcurrent(tasks, concurrency = 10) {
4128
- return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFile(task.owner, task.repo, task.ref, task.path, { expectedSha: task.sha }));
5260
+ return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFile(task.owner, task.repo, task.ref, task.path, {
5261
+ expectedSha: task.sha,
5262
+ commit: task.commit
5263
+ }));
4129
5264
  }
4130
5265
  /**
4131
5266
  * Downloads multiple files without decoding their response bytes.
5267
+ *
5268
+ * Same terminal-failure contract as `downloadFilesConcurrent`, so package
5269
+ * callers cannot treat verification or authorization failure as absence.
4132
5270
  */
4133
5271
  async downloadFilesConcurrentBytes(tasks, concurrency = 10) {
4134
- return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFileBytes(task.owner, task.repo, task.ref, task.path, { expectedSha: task.sha }));
5272
+ return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFileBytes(task.owner, task.repo, task.ref, task.path, {
5273
+ expectedSha: task.sha,
5274
+ commit: task.commit
5275
+ }));
4135
5276
  }
5277
+ /**
5278
+ * Runs the batch, settles every started worker, then decides the terminal
5279
+ * outcome:
5280
+ * - a throttle latch keeps priority (`rateLimited` result, as before);
5281
+ * - otherwise the first `IntegrityError` or access-denied failure in
5282
+ * input-task order is rethrown, so callers never mistake verification or
5283
+ * authorization failure for missing data;
5284
+ * - a genuine missing file (404) stays a `failed` result.
5285
+ */
4136
5286
  async downloadFilesConcurrentWith(tasks, concurrency, download) {
4137
5287
  const outcomes = Array(tasks.length);
4138
5288
  let nextIndex = 0;
4139
5289
  const worker = async () => {
4140
5290
  while (nextIndex < tasks.length) {
5291
+ if (this.rateLimitError) return;
4141
5292
  const index = nextIndex++;
4142
5293
  const task = tasks[index];
4143
5294
  if (!task) continue;
@@ -4156,7 +5307,8 @@ var GithubService = class {
4156
5307
  outcomes[index] = {
4157
5308
  kind: "failed",
4158
5309
  path: task.path,
4159
- reason: error instanceof Error ? error.message : String(error)
5310
+ reason: error instanceof Error ? error.message : String(error),
5311
+ error
4160
5312
  };
4161
5313
  }
4162
5314
  }
@@ -4164,16 +5316,32 @@ var GithubService = class {
4164
5316
  await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
4165
5317
  const ok = [];
4166
5318
  const failed = [];
4167
- for (const outcome of outcomes) {
4168
- if (!outcome) continue;
4169
- if (outcome.kind === "ok") ok.push({
5319
+ for (let index = 0; index < tasks.length; index += 1) {
5320
+ const outcome = outcomes[index];
5321
+ const task = tasks[index];
5322
+ if (outcome?.kind === "ok") ok.push({
4170
5323
  path: outcome.path,
4171
5324
  content: outcome.content
4172
5325
  });
4173
- else failed.push({
5326
+ else if (outcome?.kind === "failed") failed.push({
4174
5327
  path: outcome.path,
4175
5328
  reason: outcome.reason
4176
5329
  });
5330
+ else if (task && this.rateLimitError) failed.push({
5331
+ path: task.path,
5332
+ reason: `not attempted: ${this.rateLimitError.message}`
5333
+ });
5334
+ }
5335
+ if (this.rateLimitError) return {
5336
+ ok,
5337
+ failed,
5338
+ rateLimited: this.rateLimitError
5339
+ };
5340
+ for (let index = 0; index < outcomes.length; index += 1) {
5341
+ const outcome = outcomes[index];
5342
+ if (outcome?.kind !== "failed" || outcome.error === void 0) continue;
5343
+ const error = outcome.error;
5344
+ if (error instanceof IntegrityError || GithubRequestError.isAccessDenial(error)) throw error;
4177
5345
  }
4178
5346
  return {
4179
5347
  ok,
@@ -4202,18 +5370,20 @@ var GithubService = class {
4202
5370
  * Returns null on non-200, invalid response, or network failure.
4203
5371
  */
4204
5372
  async resolveCommit(owner, repo, ref) {
5373
+ const key = `commit:${owner}/${repo}@${ref}`;
4205
5374
  const url = `${this.baseUrl}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`;
4206
- try {
4207
- const res = await (0, cross_fetch.default)(url, { headers: {
4208
- ...this.headers,
4209
- Accept: "application/vnd.github.sha"
4210
- } });
4211
- if (!res.ok) return null;
4212
- const text = (await res.text()).trim();
4213
- return /^[0-9a-f]{40}$/i.test(text) ? text : null;
4214
- } catch {
4215
- return null;
4216
- }
5375
+ return this.sessionRequest(key, async () => {
5376
+ try {
5377
+ const text = (await (await this.request(url, { headers: {
5378
+ ...this.headers,
5379
+ Accept: "application/vnd.github.sha"
5380
+ } })).text()).trim();
5381
+ return /^[0-9a-f]{40}$/i.test(text) ? text : null;
5382
+ } catch (error) {
5383
+ if (GithubService.isActionable(error)) throw error;
5384
+ return null;
5385
+ }
5386
+ });
4217
5387
  }
4218
5388
  /**
4219
5389
  * Fetches and validates a release MANIFEST.json for a release tag.
@@ -4225,34 +5395,35 @@ var GithubService = class {
4225
5395
  * failed verification must never look like "nothing to verify".
4226
5396
  */
4227
5397
  async getReleaseManifest(owner, repo, tag) {
5398
+ const key = `release-manifest:${owner}/${repo}@${tag}`;
4228
5399
  const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/MANIFEST.json`;
4229
- let res;
4230
- try {
4231
- res = await (0, cross_fetch.default)(url);
4232
- } catch (error) {
4233
- const detail = error instanceof Error ? error.message : String(error);
4234
- throw new Error(`Failed to fetch MANIFEST.json for ${tag}: ${detail}`, { cause: error });
4235
- }
4236
- if (res.status === 404) return null;
4237
- if (!res.ok) throw new Error(`Failed to fetch MANIFEST.json for ${tag}: HTTP ${res.status}`);
4238
- let data;
4239
- try {
4240
- data = await res.json();
4241
- } catch (error) {
4242
- throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: ${error instanceof Error ? error.message : String(error)}`);
4243
- }
4244
- if (typeof data !== "object" || data === null || Array.isArray(data)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: expected an object`);
4245
- const record = data;
4246
- if (record.schema_version !== 1) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: unsupported schema_version`);
4247
- if (record.tag !== tag) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: tag does not match`);
4248
- if (typeof record.commit !== "string" || !/^[0-9a-f]{40}$/i.test(record.commit)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: invalid commit`);
4249
- if (typeof record.files !== "object" || record.files === null || Array.isArray(record.files)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: missing files map`);
4250
- const files = record.files;
4251
- for (const [key, value] of Object.entries(files)) {
4252
- if (typeof key !== "string" || typeof value !== "string") throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: non-string entry ${key}`);
4253
- if (!/^[0-9a-f]{64}$/i.test(value)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: invalid digest for ${key}`);
4254
- }
4255
- return record;
5400
+ return this.sessionRequest(key, async () => {
5401
+ let res;
5402
+ try {
5403
+ res = await this.request(url);
5404
+ } catch (error) {
5405
+ if (GithubService.isAbsence(error)) return null;
5406
+ throw error;
5407
+ }
5408
+ let data;
5409
+ try {
5410
+ data = await res.json();
5411
+ } catch (error) {
5412
+ throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: ${error instanceof Error ? error.message : String(error)}`);
5413
+ }
5414
+ if (typeof data !== "object" || data === null || Array.isArray(data)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: expected an object`);
5415
+ const record = data;
5416
+ if (record.schema_version !== 1) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: unsupported schema_version`);
5417
+ if (record.tag !== tag) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: tag does not match`);
5418
+ if (typeof record.commit !== "string" || !/^[0-9a-f]{40}$/i.test(record.commit)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: invalid commit`);
5419
+ if (typeof record.files !== "object" || record.files === null || Array.isArray(record.files)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: missing files map`);
5420
+ const files = record.files;
5421
+ for (const [entry, value] of Object.entries(files)) {
5422
+ if (typeof entry !== "string" || typeof value !== "string") throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: non-string entry ${entry}`);
5423
+ if (!/^[0-9a-f]{64}$/i.test(value)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: invalid digest for ${entry}`);
5424
+ }
5425
+ return record;
5426
+ });
4256
5427
  }
4257
5428
  };
4258
5429
  //#endregion
@@ -5020,38 +6191,6 @@ async function removeEmptyParents(rootDir, dir) {
5020
6191
  }
5021
6192
  }
5022
6193
  //#endregion
5023
- //#region src/services/install/safePath.ts
5024
- /**
5025
- * Asserts that a target file path resides inside rootDir and that NO intermediate
5026
- * directory or terminal file under rootDir is a symbolic link.
5027
- *
5028
- * Requirements:
5029
- * - rootDir may itself be a symlink (or inside one); components under rootDir must NOT be.
5030
- * - Both existing files and dangling symlinks must be detected via lstat.
5031
- * - Fails closed if lexical containment is breached or if any component under rootDir is a symlink.
5032
- *
5033
- * @param rootDir The trusted root directory
5034
- * @param targetPath The destination file/directory path to check
5035
- */
5036
- async function assertNoSymlinksUnderRoot(rootDir, targetPath) {
5037
- const resolvedRoot = path.default.resolve(rootDir);
5038
- const resolvedTarget = path.default.resolve(targetPath);
5039
- const relative = path.default.relative(resolvedRoot, resolvedTarget);
5040
- if (relative === ".." || relative.startsWith(`..${path.default.sep}`) || path.default.isAbsolute(relative)) throw new Error(`Path traversal or outside target detected: "${targetPath}" is outside "${rootDir}"`);
5041
- const segments = relative.split(path.default.sep).filter(Boolean);
5042
- let current = resolvedRoot;
5043
- for (const segment of segments) {
5044
- current = path.default.join(current, segment);
5045
- try {
5046
- if ((await fs_extra.default.lstat(current)).isSymbolicLink()) throw new Error(`Symlink path component not permitted: "${current}" is a symbolic link under "${rootDir}"`);
5047
- } catch (err) {
5048
- if (err instanceof Error && err.message.includes("Symlink path component")) throw err;
5049
- if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") break;
5050
- throw err;
5051
- }
5052
- }
5053
- }
5054
- //#endregion
5055
6194
  //#region src/services/install/OwnershipWriter.ts
5056
6195
  var PassthroughWriter = class {
5057
6196
  rootDir;
@@ -5085,7 +6224,7 @@ var OwnershipWriter = class {
5085
6224
  await assertNoSymlinksUnderRoot(this.opts.rootDir, absPath);
5086
6225
  const rel = toPosixRel(this.opts.rootDir, absPath);
5087
6226
  this.touched.add(rel);
5088
- const incoming = sha256(content);
6227
+ const incoming = sha256$1(content);
5089
6228
  const entry = {
5090
6229
  ...meta,
5091
6230
  sha256: incoming
@@ -5097,7 +6236,7 @@ var OwnershipWriter = class {
5097
6236
  this.next[rel] = entry;
5098
6237
  return;
5099
6238
  }
5100
- const current = sha256(await fs_extra.default.readFile(absPath));
6239
+ const current = sha256$1(await fs_extra.default.readFile(absPath));
5101
6240
  if (current === incoming) {
5102
6241
  this.plan.unchanged.push(rel);
5103
6242
  this.next[rel] = entry;
@@ -5140,7 +6279,7 @@ var OwnershipWriter = class {
5140
6279
  const abs = path.default.join(this.opts.rootDir, rel);
5141
6280
  await assertNoSymlinksUnderRoot(this.opts.rootDir, abs);
5142
6281
  if (!await fs_extra.default.pathExists(abs)) continue;
5143
- if (sha256(await fs_extra.default.readFile(abs)) !== prev.sha256) {
6282
+ if (sha256$1(await fs_extra.default.readFile(abs)) !== prev.sha256) {
5144
6283
  this.plan.keptOrphans.push(rel);
5145
6284
  continue;
5146
6285
  }
@@ -5170,6 +6309,8 @@ var OwnershipWriter = class {
5170
6309
  };
5171
6310
  //#endregion
5172
6311
  //#region src/services/SkillSyncService.ts
6312
+ var GIT_BLOB_SHA1_RE = /^[0-9a-f]{40}$/;
6313
+ var ATTRIBUTION_FAMILY_RE = /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i;
5173
6314
  /**
5174
6315
  * A selected registry category or package could not be assembled completely.
5175
6316
  * Callers must not write installations or lockfiles from this partial result.
@@ -5189,6 +6330,7 @@ var SkillSyncService = class {
5189
6330
  githubService;
5190
6331
  failedPackagesByCategory = /* @__PURE__ */ new Map();
5191
6332
  failedCategories = /* @__PURE__ */ new Set();
6333
+ failureDetails = /* @__PURE__ */ new Map();
5192
6334
  constructor(githubService) {
5193
6335
  this.githubService = githubService;
5194
6336
  }
@@ -5198,6 +6340,7 @@ var SkillSyncService = class {
5198
6340
  async assembleSkills(categories, config, verifiers = {}, trees = {}, origins = {}, requireSkillDefinition = false) {
5199
6341
  this.failedPackagesByCategory.clear();
5200
6342
  this.failedCategories.clear();
6343
+ this.failureDetails.clear();
5201
6344
  const collected = [];
5202
6345
  const collectedOrigins = /* @__PURE__ */ new Map();
5203
6346
  const conflicts = [];
@@ -5211,21 +6354,31 @@ var SkillSyncService = class {
5211
6354
  const catConfig = config.skills[category];
5212
6355
  const ref = catConfig.ref || "main";
5213
6356
  console.log(picocolors.default.gray(` - Discovering ${category} (${ref})...`));
5214
- const treeData = trees[category] ?? await this.githubService.getRepoTree(owner, repo, ref);
6357
+ const resolvedSource = origins[category];
6358
+ const commit = resolvedSource?.commit && GIT_BLOB_SHA1_RE.test(resolvedSource.commit) ? resolvedSource.commit : void 0;
6359
+ const verifier = verifiers[category];
6360
+ if (verifier && commit) {
6361
+ const rejection = verifier.checkSourceCommit(commit);
6362
+ if (rejection) throw new IntegrityError(`${category}@${ref}: ${rejection}`);
6363
+ }
6364
+ const treeData = trees[category] ?? (commit ? await this.githubService.getRepoTree(owner, repo, ref, { commit }) : await this.githubService.getRepoTree(owner, repo, ref));
5215
6365
  if (!treeData) {
5216
6366
  this.failedCategories.add(category);
5217
6367
  console.log(picocolors.default.red(` ❌ Failed to fetch ${category}@${ref}.`));
5218
6368
  continue;
5219
6369
  }
5220
- const resolvedSource = origins[category];
5221
6370
  const origin = resolvedSource ? {
5222
6371
  category,
5223
6372
  ref: resolvedSource.ref,
5224
6373
  commit: resolvedSource.commit
5225
6374
  } : void 0;
6375
+ const source = commit && resolvedSource ? {
6376
+ requestedRef: resolvedSource.ref,
6377
+ resolvedCommit: commit
6378
+ } : void 0;
5226
6379
  const foldersToSync = resolveSkillFolders(category, catConfig, treeData.tree, requireSkillDefinition);
5227
6380
  for (const absOrRelSkill of foldersToSync) {
5228
- const skill = await this.fetchSkill(owner, repo, ref, category, absOrRelSkill, treeData.tree, verifiers);
6381
+ const skill = await this.fetchSkill(owner, repo, ref, category, absOrRelSkill, treeData.tree, verifiers, source);
5229
6382
  if (skill) {
5230
6383
  const collectedSkill = origin ? {
5231
6384
  ...skill,
@@ -5250,7 +6403,11 @@ var SkillSyncService = class {
5250
6403
  const failures = [
5251
6404
  ...this.failedCategories,
5252
6405
  ...conflicts,
5253
- ...Array.from(this.failedPackagesByCategory).flatMap(([category, skills]) => Array.from(skills, (skill) => `${category}/${skill}`))
6406
+ ...Array.from(this.failedPackagesByCategory).flatMap(([category, skills]) => Array.from(skills, (skill) => {
6407
+ const key = `${category}/${skill}`;
6408
+ const detail = this.failureDetails.get(key);
6409
+ return detail ? `${key} (${detail})` : key;
6410
+ }))
5254
6411
  ];
5255
6412
  if (failures.length > 0) throw new SkillAssemblyError(failures);
5256
6413
  return collected;
@@ -5260,6 +6417,7 @@ var SkillSyncService = class {
5260
6417
  */
5261
6418
  async writeSkills(skills, config, agents, writer = new PassthroughWriter(process.cwd()), rootDir = process.cwd()) {
5262
6419
  const overrides = config.custom_overrides || [];
6420
+ const written = /* @__PURE__ */ new Map();
5263
6421
  for (const agentId of agents) {
5264
6422
  const basePath = SUPPORTED_AGENTS.find((agent) => agent.id === agentId)?.path;
5265
6423
  if (!basePath) continue;
@@ -5269,9 +6427,43 @@ var SkillSyncService = class {
5269
6427
  const agentDef = SUPPORTED_AGENTS.find((agent) => agent.id === agentId);
5270
6428
  if (!agentDef?.path) continue;
5271
6429
  const basePath = agentDef.path;
5272
- for (const skill of skills) await this.writeSkillForAgent(agentId, skill, overrides, basePath, writer, config, false, rootDir);
6430
+ for (const skill of skills) {
6431
+ const bindings = await this.writeSkillForAgent(agentId, skill, overrides, basePath, writer, config, false, rootDir);
6432
+ for (const { name, binding } of bindings) {
6433
+ const key = `${skill.category}/${skill.skill}\0${name}`;
6434
+ const existing = written.get(key) ?? [];
6435
+ if (!existing.some((current) => current.agent === binding.agent && current.path === binding.path)) existing.push(binding);
6436
+ written.set(key, existing);
6437
+ }
6438
+ }
5273
6439
  console.log(picocolors.default.gray(` - Updated ${basePath}/ (${agentDef.name})`));
5274
6440
  }
6441
+ const registry = config.registry ?? "";
6442
+ const packages = {};
6443
+ for (const skill of skills) {
6444
+ const origin = skill.origin;
6445
+ if (!origin?.commit || !GIT_BLOB_SHA1_RE.test(origin.commit)) continue;
6446
+ const files = [];
6447
+ for (const file of skill.files) {
6448
+ if (!file.attribution) continue;
6449
+ const destinations = written.get(`${skill.category}/${skill.skill}\0${file.name}`);
6450
+ if (!destinations || destinations.length === 0) continue;
6451
+ files.push({
6452
+ ...file.attribution,
6453
+ name: file.name,
6454
+ destinations
6455
+ });
6456
+ }
6457
+ if (files.length === 0) continue;
6458
+ packages[`${skill.category}/${skill.skill}`] = {
6459
+ registry,
6460
+ declaringCategory: origin.category,
6461
+ requestedRef: origin.ref,
6462
+ resolvedCommit: origin.commit,
6463
+ files
6464
+ };
6465
+ }
6466
+ return packages;
5275
6467
  }
5276
6468
  async writeSkillForAgent(agentId, skill, overrides, basePath, writer, config, preflightOnly = false, rootDir = basePath) {
5277
6469
  const isKiro = agentId === Agent.Kiro;
@@ -5297,50 +6489,136 @@ var SkillSyncService = class {
5297
6489
  const content = isKiro && fileItem.name === "SKILL.md" ? this.transformSkillForKiro(fileItem.content, skill.category) : fileItem.bytes ?? fileItem.content;
5298
6490
  pending.push({
5299
6491
  target: targetFilePath,
5300
- content
6492
+ content,
6493
+ name: fileItem.name,
6494
+ attributed: fileItem.attribution !== void 0
5301
6495
  });
5302
6496
  }
5303
- if (pending === null) return;
5304
- for (const { target, content } of pending) await writer.write(target, content, {
5305
- owner: "skill",
5306
- source,
5307
- agent: agentId
5308
- });
6497
+ if (pending === null) return [];
6498
+ const bindings = [];
6499
+ for (const { target, content, name, attributed } of pending) {
6500
+ await writer.write(target, content, {
6501
+ owner: "skill",
6502
+ source,
6503
+ agent: agentId
6504
+ });
6505
+ if (!attributed) continue;
6506
+ const installedDigest = await this.readInstalledDigest(target);
6507
+ const writtenDigest = sha256$1(content);
6508
+ if (installedDigest !== writtenDigest) continue;
6509
+ bindings.push({
6510
+ name,
6511
+ binding: {
6512
+ agent: agentId,
6513
+ path: toPosixRel(rootDir, target),
6514
+ sha256: writtenDigest
6515
+ }
6516
+ });
6517
+ }
6518
+ return bindings;
5309
6519
  }
5310
- async fetchSkill(owner, repo, ref, category, absOrRelSkill, tree, verifiers = {}) {
6520
+ /** Digest of the file as it exists on disk, or null when unreadable. */
6521
+ async readInstalledDigest(absPath) {
6522
+ try {
6523
+ return sha256$1(await fs_extra.default.readFile(absPath));
6524
+ } catch {
6525
+ return null;
6526
+ }
6527
+ }
6528
+ async fetchSkill(owner, repo, ref, category, absOrRelSkill, tree, verifiers = {}, source) {
5311
6529
  const [sourceCat, skillName] = absOrRelSkill.includes("/") ? absOrRelSkill.split("/") : [category, absOrRelSkill];
5312
6530
  if (!sourceCat || !skillName) return null;
5313
6531
  const prefix = `skills/${sourceCat}/${skillName}/`;
5314
6532
  const packageFiles = tree.filter((file) => file.type === "blob" && file.path.startsWith(prefix) && this.isPackageResource(file.path.slice(prefix.length)));
5315
- const rootAttributionFiles = tree.filter((file) => file.type === "blob" && this.isRootAttributionFile(file.path) && !packageFiles.some((packageFile) => packageFile.path.slice(prefix.length).toLowerCase() === file.path.toLowerCase()));
5316
- const downloadTasks = [...packageFiles, ...rootAttributionFiles].sort((left, right) => left.path.localeCompare(right.path)).map((file) => ({
6533
+ const localFamilies = new Set(packageFiles.map((file) => this.attributionFamily(file.path.slice(prefix.length))).filter((family) => family !== null));
6534
+ const rootAttributionFiles = tree.filter((file) => {
6535
+ if (file.type !== "blob" || !this.isRootAttributionFile(file.path)) return false;
6536
+ const family = this.attributionFamily(file.path);
6537
+ if (!family || localFamilies.has(family)) return false;
6538
+ return !packageFiles.some((packageFile) => packageFile.path.slice(prefix.length).toLowerCase() === file.path.toLowerCase());
6539
+ });
6540
+ const selectedFiles = [...packageFiles, ...rootAttributionFiles].sort((left, right) => left.path.localeCompare(right.path));
6541
+ const downloadTasks = selectedFiles.map((file) => ({
5317
6542
  owner,
5318
6543
  repo,
5319
6544
  ref,
5320
6545
  path: file.path,
5321
- sha: file.sha
6546
+ sha: file.sha,
6547
+ ...source ? { commit: source.resolvedCommit } : {}
5322
6548
  }));
5323
6549
  const { ok: files, failed } = await this.githubService.downloadFilesConcurrentBytes(downloadTasks);
5324
6550
  const verifier = verifiers[category];
6551
+ let integrityFailure;
5325
6552
  if (verifier) for (const f of files) {
5326
- if (this.isRootAttributionFile(f.path)) continue;
6553
+ if (this.isRootAttributionFile(f.path)) {
6554
+ const rejection = verifier.checkRootAttribution(f.path, f.content);
6555
+ if (rejection) {
6556
+ failed.push({
6557
+ path: f.path,
6558
+ reason: rejection
6559
+ });
6560
+ integrityFailure ??= {
6561
+ path: f.path,
6562
+ reason: rejection
6563
+ };
6564
+ }
6565
+ continue;
6566
+ }
5327
6567
  const rejection = verifier.check(f.path, f.content);
5328
- if (rejection) failed.push({
5329
- path: f.path,
5330
- reason: rejection
5331
- });
6568
+ if (rejection) {
6569
+ failed.push({
6570
+ path: f.path,
6571
+ reason: rejection
6572
+ });
6573
+ integrityFailure ??= {
6574
+ path: f.path,
6575
+ reason: rejection
6576
+ };
6577
+ }
5332
6578
  }
5333
6579
  for (const failure of failed) console.log(picocolors.default.red(` ❌ ${sourceCat}/${skillName}: ${failure.path} — ${failure.reason}`));
5334
- if (!packageFiles.some((file) => file.path === `${prefix}SKILL.md`) || failed.length > 0 || files.length !== downloadTasks.length) return null;
6580
+ if (integrityFailure) throw new IntegrityError(`Release manifest verification failed for ${sourceCat}/${skillName}: ${integrityFailure.path} — ${integrityFailure.reason}`);
6581
+ const hasSkillDefinition = packageFiles.some((file) => file.path === `${prefix}SKILL.md`);
6582
+ if (!hasSkillDefinition || failed.length > 0 || files.length !== downloadTasks.length) {
6583
+ this.markPackageFailed(sourceCat, skillName, failed[0]?.reason ?? (hasSkillDefinition ? "downloaded file count mismatch" : "missing SKILL.md"));
6584
+ return null;
6585
+ }
6586
+ const blobShaByPath = new Map(selectedFiles.map((file) => [file.path, file.sha]));
6587
+ const records = /* @__PURE__ */ new Map();
6588
+ if (source) for (const file of files) {
6589
+ const isPackageFile = file.path.startsWith(prefix);
6590
+ const name = isPackageFile ? file.path.slice(prefix.length) : file.path;
6591
+ if (this.attributionFamily(name) === null) continue;
6592
+ const blobSha1 = blobShaByPath.get(file.path);
6593
+ if (!blobSha1 || !GIT_BLOB_SHA1_RE.test(blobSha1)) {
6594
+ this.markPackageFailed(sourceCat, skillName, `missing Git blob identity for ${file.path}`);
6595
+ return null;
6596
+ }
6597
+ const basis = verifier?.hasManifestEntry(file.path) ? "release-manifest" : "commit-tree-blob";
6598
+ records.set(name, {
6599
+ sourcePath: file.path,
6600
+ sourceKind: isPackageFile ? "package" : "root",
6601
+ requestedRef: source.requestedRef,
6602
+ resolvedCommit: source.resolvedCommit,
6603
+ blobSha1,
6604
+ sha256: sha256$1(file.content),
6605
+ basis
6606
+ });
6607
+ }
5335
6608
  console.log(picocolors.default.gray(` + Fetched ${sourceCat}/${skillName} (${files.length} files)`));
5336
6609
  return {
5337
6610
  category: sourceCat,
5338
6611
  skill: skillName,
5339
- files: files.map((file) => ({
5340
- name: file.path.startsWith(prefix) ? file.path.slice(prefix.length) : file.path,
5341
- content: file.content.toString("utf8"),
5342
- bytes: file.content
5343
- }))
6612
+ files: files.map((file) => {
6613
+ const name = file.path.startsWith(prefix) ? file.path.slice(prefix.length) : file.path;
6614
+ const attribution = records.get(name);
6615
+ return {
6616
+ name,
6617
+ content: file.content.toString("utf8"),
6618
+ bytes: file.content,
6619
+ ...attribution ? { attribution } : {}
6620
+ };
6621
+ })
5344
6622
  };
5345
6623
  }
5346
6624
  /**
@@ -5386,16 +6664,24 @@ var SkillSyncService = class {
5386
6664
  isPackageResource(relativePath) {
5387
6665
  return relativePath === "SKILL.md" || /^(references|scripts|assets)\//.test(relativePath) || this.isRootAttributionFile(relativePath);
5388
6666
  }
6667
+ attributionFamily(filePath) {
6668
+ if (filePath.includes("/")) return null;
6669
+ const match = ATTRIBUTION_FAMILY_RE.exec(filePath);
6670
+ if (!match) return null;
6671
+ return match[1].toLowerCase() === "license" ? "license" : "notice";
6672
+ }
5389
6673
  isRootAttributionFile(filePath) {
5390
- return !filePath.includes("/") && /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i.test(filePath);
6674
+ return this.attributionFamily(filePath) !== null;
5391
6675
  }
5392
- markPackageFailed(category, skill) {
6676
+ markPackageFailed(category, skill, detail) {
5393
6677
  let failedSkills = this.failedPackagesByCategory.get(category);
5394
6678
  if (!failedSkills) {
5395
6679
  failedSkills = /* @__PURE__ */ new Set();
5396
6680
  this.failedPackagesByCategory.set(category, failedSkills);
5397
6681
  }
5398
6682
  failedSkills.add(skill);
6683
+ const key = `${category}/${skill}`;
6684
+ if (detail && !this.failureDetails.has(key)) this.failureDetails.set(key, detail);
5399
6685
  }
5400
6686
  isRecord(value) {
5401
6687
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -6260,15 +7546,17 @@ var WorkflowSyncService = class {
6260
7546
  * need the collected files; new callers must use `acquireWorkflows` so an
6261
7547
  * incomplete fetch is distinguishable from a deliberate empty selection.
6262
7548
  */
6263
- async assembleWorkflows(config, ref = "main", verifier) {
6264
- return (await this.acquireWorkflows(config, ref, verifier)).workflows;
7549
+ async assembleWorkflows(config, ref = "main", verifier, commit) {
7550
+ return (await this.acquireWorkflows(config, ref, verifier, commit)).workflows;
6265
7551
  }
6266
7552
  /**
6267
7553
  * Acquires the selected canonical workflows and reports whether the desired
6268
7554
  * set is complete. Partial discovery/download/integrity failures are
6269
- * `incomplete` and never authorize absence-based pruning.
7555
+ * `incomplete` and never authorize absence-based pruning. When `commit` is
7556
+ * supplied, discovery and downloads use that immutable identity instead of
7557
+ * the mutable ref string.
6270
7558
  */
6271
- async acquireWorkflows(config, ref = "main", verifier) {
7559
+ async acquireWorkflows(config, ref = "main", verifier, commit) {
6272
7560
  if (config.workflows === false || config.workflows === void 0) return {
6273
7561
  status: "disabled",
6274
7562
  workflows: [],
@@ -6289,8 +7577,12 @@ var WorkflowSyncService = class {
6289
7577
  }]
6290
7578
  };
6291
7579
  const { owner, repo } = githubMatch;
7580
+ if (verifier && commit) {
7581
+ const rejection = verifier.checkSourceCommit(commit);
7582
+ if (rejection) throw new IntegrityError(`workflows@${ref}: ${rejection}`);
7583
+ }
6292
7584
  console.log(picocolors.default.gray(` - Discovering workflows (${ref})...`));
6293
- const treeData = await this.githubService.getRepoTree(owner, repo, ref);
7585
+ const treeData = await this.githubService.getRepoTree(owner, repo, ref, commit ? { commit } : void 0);
6294
7586
  if (!treeData) {
6295
7587
  console.log(picocolors.default.red(` ❌ Failed to fetch workflows@${ref}.`));
6296
7588
  return {
@@ -6317,21 +7609,21 @@ var WorkflowSyncService = class {
6317
7609
  failures: []
6318
7610
  };
6319
7611
  }
6320
- const { ok: files, failed } = await this.githubService.downloadFilesConcurrent(workflowFiles.map((f) => ({
7612
+ const downloadResult = await this.githubService.downloadFilesConcurrent(workflowFiles.map((f) => ({
6321
7613
  owner,
6322
7614
  repo,
6323
7615
  ref,
6324
7616
  path: f.path,
6325
- sha: f.sha
7617
+ sha: f.sha,
7618
+ ...commit ? { commit } : {}
6326
7619
  })));
7620
+ if (downloadResult.rateLimited) throw downloadResult.rateLimited;
7621
+ const { ok: files, failed } = downloadResult;
6327
7622
  const okFiles = [];
6328
7623
  for (const f of files) {
6329
7624
  const rejection = verifier?.check(f.path, f.content);
6330
- if (rejection) failed.push({
6331
- path: f.path,
6332
- reason: rejection
6333
- });
6334
- else okFiles.push(f);
7625
+ if (rejection) throw new IntegrityError(`${f.path}: ${rejection}`);
7626
+ okFiles.push(f);
6335
7627
  }
6336
7628
  for (const failure of failed) console.log(picocolors.default.red(` ❌ ${failure.path} — ${failure.reason}`));
6337
7629
  const status = failed.length > 0 ? "incomplete" : "complete";
@@ -6401,7 +7693,7 @@ var WorkflowSyncService = class {
6401
7693
  source: `workflow:${workflowName}`,
6402
7694
  agent: SHARED_WORKFLOW_AGENT
6403
7695
  });
6404
- const canonicalDigest = sha256(fileItem.content);
7696
+ const canonicalDigest = sha256$1(fileItem.content);
6405
7697
  writtenDigests.set(canonicalPath, canonicalDigest);
6406
7698
  result.canonical.push({
6407
7699
  name: workflowName,
@@ -6433,7 +7725,7 @@ var WorkflowSyncService = class {
6433
7725
  continue;
6434
7726
  }
6435
7727
  if (this.isOverridden(targetFilePath, overrides)) continue;
6436
- const outputDigest = sha256(transformed.content);
7728
+ const outputDigest = sha256$1(transformed.content);
6437
7729
  const existingDigest = writtenDigests.get(targetFilePath);
6438
7730
  if (existingDigest === void 0) {
6439
7731
  await writer.write(targetFilePath, transformed.content, {
@@ -6452,7 +7744,7 @@ var WorkflowSyncService = class {
6452
7744
  agent: agentId,
6453
7745
  path: this.normalizePath(targetFilePath),
6454
7746
  outputDigest,
6455
- canonicalDigest: sha256(fileItem.content)
7747
+ canonicalDigest: sha256$1(fileItem.content)
6456
7748
  });
6457
7749
  }
6458
7750
  }
@@ -6760,33 +8052,66 @@ var SpecialistTransformer = class {
6760
8052
  };
6761
8053
  //#endregion
6762
8054
  //#region src/services/SpecialistSyncService.ts
6763
- /**
6764
- * Service responsible for syncing specialist skills from the internal registry
6765
- * to native agent configuration directories.
6766
- */
6767
8055
  var SpecialistSyncService = class {
6768
8056
  githubService;
6769
8057
  constructor(githubService = new GithubService(process.env.GITHUB_TOKEN)) {
6770
8058
  this.githubService = githubService;
6771
8059
  }
6772
- async assembleSpecialists(config, ref = "main", verifier) {
8060
+ /**
8061
+ * Acquires every remote specialist, reporting whether the selected set is
8062
+ * complete. Fetches are bound to `commit` when the caller resolved one, so a
8063
+ * tag that moved after resolution cannot supply different bytes.
8064
+ */
8065
+ async acquireSpecialists(config, ref = "main", verifier, commit) {
6773
8066
  const githubMatch = GithubService.parseGitHubUrl(config.registry);
6774
- if (!githubMatch) return [];
8067
+ if (!githubMatch) return {
8068
+ status: "incomplete",
8069
+ specialists: [],
8070
+ failures: [{
8071
+ path: "registry",
8072
+ reason: `Unsupported registry URL '${config.registry}'.`
8073
+ }]
8074
+ };
6775
8075
  const { owner, repo } = githubMatch;
6776
- const treeData = await this.githubService.getRepoTree(owner, repo, ref);
6777
- if (!treeData) return [];
8076
+ const treeOptions = commit ? { commit } : void 0;
8077
+ if (verifier && commit) {
8078
+ const rejection = verifier.checkSourceCommit(commit);
8079
+ if (rejection) throw new IntegrityError(`specialists@${ref}: ${rejection}`);
8080
+ }
8081
+ const treeData = await this.githubService.getRepoTree(owner, repo, ref, treeOptions);
8082
+ if (!treeData) return {
8083
+ status: "incomplete",
8084
+ specialists: [],
8085
+ failures: [{
8086
+ path: "specialists",
8087
+ reason: `Failed to fetch specialists@${ref}.`
8088
+ }]
8089
+ };
6778
8090
  const skillFiles = treeData.tree.filter((item) => item.type === "blob" && item.path.startsWith("skills/specialists/") && item.path.endsWith("/SKILL.md"));
6779
8091
  const specialists = [];
8092
+ const failures = [];
6780
8093
  for (const file of skillFiles) {
6781
8094
  const specialistName = file.path.split("/")[2];
6782
- if (!specialistName) continue;
6783
- const content = await this.githubService.getRawFile(owner, repo, ref, file.path);
6784
- if (!content) continue;
6785
- const rejection = verifier?.check(file.path, content);
6786
- if (rejection) {
6787
- console.log(picocolors.default.red(` ❌ ${file.path} — ${rejection}`));
8095
+ if (!specialistName) {
8096
+ failures.push({
8097
+ path: file.path,
8098
+ reason: "specialist directory name is missing"
8099
+ });
8100
+ continue;
8101
+ }
8102
+ const content = await this.githubService.getRawFile(owner, repo, ref, file.path, {
8103
+ expectedSha: file.sha,
8104
+ ...commit ? { commit } : {}
8105
+ });
8106
+ if (!content) {
8107
+ failures.push({
8108
+ path: file.path,
8109
+ reason: "download failed"
8110
+ });
6788
8111
  continue;
6789
8112
  }
8113
+ const rejection = verifier?.check(file.path, content);
8114
+ if (rejection) throw new IntegrityError(`${file.path}: ${rejection}`);
6790
8115
  specialists.push({
6791
8116
  category: "specialists",
6792
8117
  skill: specialistName,
@@ -6796,9 +8121,13 @@ var SpecialistSyncService = class {
6796
8121
  }]
6797
8122
  });
6798
8123
  }
6799
- return specialists;
8124
+ return {
8125
+ status: failures.length > 0 ? "incomplete" : "complete",
8126
+ specialists,
8127
+ failures
8128
+ };
6800
8129
  }
6801
- async syncCollectedSpecialists(rootDir, agents, specialists, writer = new PassthroughWriter(rootDir)) {
8130
+ async writeSpecialists(rootDir, agents, specialists, writer = new PassthroughWriter(rootDir)) {
6802
8131
  if (specialists.length === 0) return 0;
6803
8132
  for (const agentId of agents) {
6804
8133
  const agentDef = SUPPORTED_AGENTS.find((a) => a.id === agentId);
@@ -6825,20 +8154,18 @@ var SpecialistSyncService = class {
6825
8154
  return specialists.length;
6826
8155
  }
6827
8156
  /**
6828
- * Syncs all specialists from a source directory to active agents.
6829
- * @param rootDir Project root directory
6830
- * @param agents List of agents to sync for
6831
- * @param sourceDir Optional custom source directory for specialists
8157
+ * Reads every specialist from a project-local source directory. This is a
8158
+ * local acquisition with no network I/O; the caller publishes the result
8159
+ * through `writeSpecialists` after the shared preflight gates.
6832
8160
  */
6833
- async syncSpecialists(rootDir, agents, sourceDir, writer = new PassthroughWriter(rootDir)) {
6834
- const specialistsDir = sourceDir || path.default.join(rootDir, "skills/specialists");
6835
- if (!await fs_extra.default.pathExists(specialistsDir)) return 0;
6836
- const specialistFolders = (await fs_extra.default.readdir(specialistsDir)).filter((f) => {
6837
- return fs_extra.default.statSync(path.default.join(specialistsDir, f)).isDirectory();
8161
+ async readLocalSpecialists(sourceDir) {
8162
+ if (!await fs_extra.default.pathExists(sourceDir)) return [];
8163
+ const specialistFolders = (await fs_extra.default.readdir(sourceDir)).filter((f) => {
8164
+ return fs_extra.default.statSync(path.default.join(sourceDir, f)).isDirectory();
6838
8165
  });
6839
8166
  const collected = [];
6840
8167
  for (const folder of specialistFolders) {
6841
- const skillPath = path.default.join(specialistsDir, folder, "SKILL.md");
8168
+ const skillPath = path.default.join(sourceDir, folder, "SKILL.md");
6842
8169
  if (!await fs_extra.default.pathExists(skillPath)) continue;
6843
8170
  const content = await fs_extra.default.readFile(skillPath, "utf8");
6844
8171
  collected.push({
@@ -6850,7 +8177,7 @@ var SpecialistSyncService = class {
6850
8177
  }]
6851
8178
  });
6852
8179
  }
6853
- return this.syncCollectedSpecialists(rootDir, agents, collected, writer);
8180
+ return collected;
6854
8181
  }
6855
8182
  };
6856
8183
  //#endregion
@@ -7123,22 +8450,120 @@ var BackupService = class {
7123
8450
  };
7124
8451
  //#endregion
7125
8452
  //#region src/services/install/ManifestVerifier.ts
8453
+ function normalizeRepoPath(repoPath) {
8454
+ return repoPath.replace(/^(\.\/|\/)+/, "");
8455
+ }
8456
+ function sha256(content) {
8457
+ return (0, node_crypto.createHash)("sha256").update(content, Buffer.isBuffer(content) ? void 0 : "utf8").digest("hex");
8458
+ }
8459
+ function isListed(manifest, repoPath) {
8460
+ return Object.prototype.hasOwnProperty.call(manifest.files, repoPath);
8461
+ }
7126
8462
  var ManifestVerifier = class {
7127
8463
  manifest;
7128
8464
  constructor(manifest) {
7129
8465
  this.manifest = manifest;
7130
8466
  }
8467
+ /** Resolved commit this manifest claims, or null when no manifest exists. */
8468
+ get commit() {
8469
+ return this.manifest?.commit ?? null;
8470
+ }
8471
+ /**
8472
+ * A manifest used for selected-source verification must describe the exact
8473
+ * resolved commit. A syntactically valid but different commit is an
8474
+ * integrity failure, never a silently skipped verification.
8475
+ */
8476
+ checkSourceCommit(expected) {
8477
+ if (!this.manifest) return null;
8478
+ if (this.manifest.commit === expected) return null;
8479
+ return `release MANIFEST.json commit '${this.manifest.commit}' does not match the resolved source commit '${expected}'`;
8480
+ }
8481
+ /**
8482
+ * Whether the schema-1 files map binds this exact repo path. Root LICENSE
8483
+ * and NOTICE entries are ordinary files-map entries, not a separate
8484
+ * namespace.
8485
+ */
8486
+ hasManifestEntry(repoPath) {
8487
+ if (!this.manifest) return false;
8488
+ return isListed(this.manifest, normalizeRepoPath(repoPath));
8489
+ }
7131
8490
  /** Returns null when OK (or no manifest), else a rejection reason. */
7132
8491
  check(repoPath, content) {
7133
8492
  if (!this.manifest) return null;
7134
- const normalized = repoPath.replace(/^(\.\/|\/)+/, "");
7135
- if (!Object.prototype.hasOwnProperty.call(this.manifest.files, normalized)) return "not listed in release MANIFEST.json";
8493
+ const normalized = normalizeRepoPath(repoPath);
8494
+ if (!isListed(this.manifest, normalized)) return "not listed in release MANIFEST.json";
7136
8495
  const expectedSha = this.manifest.files[normalized];
7137
- if ((0, node_crypto.createHash)("sha256").update(content, Buffer.isBuffer(content) ? void 0 : "utf8").digest("hex") !== expectedSha) return "sha256 does not match release MANIFEST.json";
8496
+ if (sha256(content) !== expectedSha) return "sha256 does not match release MANIFEST.json";
8497
+ return null;
8498
+ }
8499
+ /**
8500
+ * Root LICENSE/NOTICE live outside every category release scope, so legacy
8501
+ * manifests do not list them. A listed root entry is always enforced; an
8502
+ * unlisted one leaves the caller's verified commit-tree/blob basis intact
8503
+ * instead of inventing a manifest verification that never happened.
8504
+ */
8505
+ checkRootAttribution(repoPath, content) {
8506
+ if (!this.manifest) return null;
8507
+ const normalized = normalizeRepoPath(repoPath);
8508
+ if (!isListed(this.manifest, normalized)) return null;
8509
+ if (sha256(content) !== this.manifest.files[normalized]) return "sha256 does not match release MANIFEST.json root attribution";
7138
8510
  return null;
7139
8511
  }
7140
8512
  };
7141
8513
  //#endregion
8514
+ //#region src/services/install/UpdatePolicy.ts
8515
+ /**
8516
+ * Automatic version-update policy for release-pinned categories, workflows,
8517
+ * and specialists.
8518
+ *
8519
+ * A newer advertised release may only replace a configured pin automatically
8520
+ * when both refs are canonical stable semantic-version refs for the same tag
8521
+ * prefix and the candidate is strictly newer. Branches, commits, custom refs,
8522
+ * prereleases, downgrades, and equal versions stay explicit: accepting them
8523
+ * would silently move a user to incomparable source.
8524
+ */
8525
+ var STABLE_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
8526
+ /**
8527
+ * @param currentRef currently configured ref, if the project already pinned one
8528
+ * @param candidateRef ref advertised by the registry for the same category
8529
+ * @param tagPrefix tag prefix published for that category (may be empty)
8530
+ * @returns true only for a strictly newer comparable stable release ref
8531
+ */
8532
+ function isAutomaticUpgrade(currentRef, candidateRef, tagPrefix) {
8533
+ if (typeof currentRef !== "string" || typeof candidateRef !== "string") return false;
8534
+ const current = parseStableVersion(currentRef, tagPrefix);
8535
+ const candidate = parseStableVersion(candidateRef, tagPrefix);
8536
+ if (!current || !candidate) return false;
8537
+ for (let index = 0; index < 3; index += 1) {
8538
+ const order = compareNumericStrings(current[index], candidate[index]);
8539
+ if (order !== 0) return order < 0;
8540
+ }
8541
+ return false;
8542
+ }
8543
+ /**
8544
+ * Strips the category tag prefix and requires a canonical `MAJOR.MINOR.PATCH`
8545
+ * core. Numeric components are returned as digit strings so comparison stays
8546
+ * exact beyond `Number.MAX_SAFE_INTEGER`.
8547
+ */
8548
+ function parseStableVersion(ref, tagPrefix) {
8549
+ if (!ref.startsWith(tagPrefix)) return null;
8550
+ const version = ref.slice(tagPrefix.length);
8551
+ const match = STABLE_VERSION.exec(version);
8552
+ if (!match || match[0] !== version) return null;
8553
+ const components = [
8554
+ match[1],
8555
+ match[2],
8556
+ match[3]
8557
+ ];
8558
+ for (const component of components) if (component.length > 1 && component.startsWith("0")) return null;
8559
+ return components;
8560
+ }
8561
+ function compareNumericStrings(left, right) {
8562
+ if (left.length !== right.length) return left.length < right.length ? -1 : 1;
8563
+ if (left === right) return 0;
8564
+ return left < right ? -1 : 1;
8565
+ }
8566
+ //#endregion
7142
8567
  //#region src/services/install/RefResolver.ts
7143
8568
  var RefResolver = class {
7144
8569
  github;
@@ -7225,21 +8650,21 @@ var RefResolver = class {
7225
8650
  //#endregion
7226
8651
  //#region src/services/SyncService.ts
7227
8652
  var defaultExec = (cmd, args) => {
7228
- const { promise, resolve, reject } = Promise.withResolvers();
7229
- (0, node_child_process.execFile)(cmd, args, (error, _stdout, stderr) => {
7230
- if (error) {
7231
- if (error.code === "ENOENT") return reject(error);
7232
- return resolve({
7233
- code: typeof error.code === "number" ? error.code : 1,
7234
- stderr: stderr || error.message
8653
+ return new Promise((resolve, reject) => {
8654
+ (0, node_child_process.execFile)(cmd, args, (error, _stdout, stderr) => {
8655
+ if (error) {
8656
+ if (error.code === "ENOENT") return reject(error);
8657
+ return resolve({
8658
+ code: typeof error.code === "number" ? error.code : 1,
8659
+ stderr: stderr || error.message
8660
+ });
8661
+ }
8662
+ resolve({
8663
+ code: 0,
8664
+ stderr: stderr || ""
7235
8665
  });
7236
- }
7237
- resolve({
7238
- code: 0,
7239
- stderr: stderr || ""
7240
8666
  });
7241
8667
  });
7242
- return promise;
7243
8668
  };
7244
8669
  /**
7245
8670
  * Rejects an incomplete or malformed repository tree. A truncated or shaped
@@ -7278,6 +8703,8 @@ function assertIncludeTargetExists(category, expression, targetCategory, targetS
7278
8703
  const skillPath = `${categoryPath}/${targetSkill}/SKILL.md`;
7279
8704
  if (!tree.tree.some((item) => item.type === "blob" && item.path === skillPath)) throw new SkillAssemblyError([`${category} include ${expression} (requested skill missing)`]);
7280
8705
  }
8706
+ /** Bounded attribution-family filename used for installed variant discovery. */
8707
+ var ATTRIBUTION_FILE_NAME_RE = /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i;
7281
8708
  /**
7282
8709
  * Service responsible for coordinating the synchronization of agent skills and workflows.
7283
8710
  * It acts as a facade, delegating specialized synchronization tasks to SkillSyncService
@@ -7289,7 +8716,7 @@ var SyncService = class {
7289
8716
  githubService;
7290
8717
  skillSyncService;
7291
8718
  workflowSyncService;
7292
- specialistSyncService = new SpecialistSyncService();
8719
+ specialistSyncService;
7293
8720
  gitService = new GitService();
7294
8721
  lockfileService = new LockfileService();
7295
8722
  writer = new PassthroughWriter(process.cwd());
@@ -7306,10 +8733,13 @@ var SyncService = class {
7306
8733
  };
7307
8734
  warnedMovedTags = /* @__PURE__ */ new Set();
7308
8735
  installScope = "all";
8736
+ /** Attribution records for the packages actually published by this run. */
8737
+ skillAttributionPackages = null;
7309
8738
  constructor(githubService = new GithubService(process.env.GITHUB_TOKEN)) {
7310
8739
  this.githubService = githubService;
7311
8740
  this.skillSyncService = new SkillSyncService(githubService);
7312
8741
  this.workflowSyncService = new WorkflowSyncService(githubService);
8742
+ this.specialistSyncService = new SpecialistSyncService(githubService);
7313
8743
  }
7314
8744
  async reconcileConfig(config, projectDeps) {
7315
8745
  const reenabled = this.configService.reconcileDependencies(config, projectDeps);
@@ -7327,7 +8757,14 @@ var SyncService = class {
7327
8757
  async assembleSkills(categories, config, trees, origins, requireSkillDefinition = false) {
7328
8758
  await this.warnIfSyncingFromSameRepo(config);
7329
8759
  const skillCategories = categories.filter((category) => category !== "specialists");
7330
- return this.skillSyncService.assembleSkills(skillCategories, config, this.manifestVerifiers.skills, trees, origins, requireSkillDefinition);
8760
+ const sourceOrigins = origins ?? Object.fromEntries(skillCategories.filter((category) => this.resolvedRefs?.skills[category]).map((category) => {
8761
+ const source = this.resolvedRefs.skills[category];
8762
+ return [category, {
8763
+ ref: source.ref,
8764
+ commit: source.commit
8765
+ }];
8766
+ }));
8767
+ return this.skillSyncService.assembleSkills(skillCategories, config, this.manifestVerifiers.skills, trees, sourceOrigins, requireSkillDefinition);
7331
8768
  }
7332
8769
  /**
7333
8770
  * Prefetches only the skill sources for a scoped apply. Resolves every
@@ -7437,6 +8874,7 @@ var SyncService = class {
7437
8874
  const { lock, migratedFromV1 } = await this.lockfileService.load(process.cwd(), agents);
7438
8875
  this.dryRun = opts.dryRun;
7439
8876
  this.previousLock = lock;
8877
+ this.skillAttributionPackages = null;
7440
8878
  this.pendingWorkflowWrites = {
7441
8879
  canonical: [],
7442
8880
  bindings: []
@@ -7499,11 +8937,61 @@ var SyncService = class {
7499
8937
  entries
7500
8938
  };
7501
8939
  if (catalog) lock.workflowCatalog = catalog;
8940
+ const attribution = await this.buildAttributionCatalog(configured, entries);
8941
+ if (attribution) lock.attributionCatalog = attribution;
7502
8942
  await this.lockfileService.write(process.cwd(), lock);
7503
8943
  }
7504
8944
  return plan;
7505
8945
  }
7506
8946
  /**
8947
+ * Merges this run's published attribution over the previous catalog at file
8948
+ * granularity. A previous file survives only when the declaring category is
8949
+ * still configured, this run did not publish replacement bytes for that file,
8950
+ * and every recorded destination still survives ownership finalization with
8951
+ * the exact recorded bytes. Preserved overrides/untouched files therefore
8952
+ * keep their provenance instead of reading as removals; pruned destinations,
8953
+ * removed agents, unconfigured categories and edited copies still drop.
8954
+ */
8955
+ async buildAttributionCatalog(configured, entries) {
8956
+ const previous = this.previousLock?.attributionCatalog;
8957
+ const written = this.skillAttributionPackages;
8958
+ if (!written && !previous) return void 0;
8959
+ const mergedPackages = {};
8960
+ for (const [packageId, record] of Object.entries(previous?.packages ?? {})) {
8961
+ if (!configured.has(record.declaringCategory)) continue;
8962
+ const replacement = written?.[packageId];
8963
+ const retainedFiles = [];
8964
+ for (const file of record.files) {
8965
+ if (replacement?.files.some((candidate) => candidate.name === file.name)) continue;
8966
+ if (await this.attributionFileDestinationsSurvive(file, entries)) retainedFiles.push(file);
8967
+ }
8968
+ if (retainedFiles.length === 0) continue;
8969
+ mergedPackages[packageId] = {
8970
+ ...replacement ?? record,
8971
+ files: [...replacement?.files ?? [], ...retainedFiles]
8972
+ };
8973
+ }
8974
+ const merged = buildAttributionCatalog({
8975
+ ...mergedPackages,
8976
+ ...written ?? {}
8977
+ });
8978
+ if (previous) return merged;
8979
+ return Object.keys(merged.packages).length > 0 ? merged : void 0;
8980
+ }
8981
+ /**
8982
+ * A retained file must describe only bytes that are still installed: every
8983
+ * destination has to survive ownership finalization for the recorded agent
8984
+ * and still hash to its recorded digest.
8985
+ */
8986
+ async attributionFileDestinationsSurvive(file, entries) {
8987
+ for (const binding of file.destinations) {
8988
+ const entry = entries[binding.path];
8989
+ if (!entry || entry.agent !== binding.agent) return false;
8990
+ if (await readInstalledAttributionDigest(process.cwd(), binding.path) !== binding.sha256) return false;
8991
+ }
8992
+ return true;
8993
+ }
8994
+ /**
7507
8995
  * Builds the catalog from files that survived ownership reconciliation.
7508
8996
  * Canonical bodies that were kept edited/overridden and native exports whose
7509
8997
  * on-disk bytes differ from the deterministic output are omitted rather than
@@ -7538,7 +9026,7 @@ var SyncService = class {
7538
9026
  } catch {
7539
9027
  continue;
7540
9028
  }
7541
- const digest = sha256(canonicalBytes);
9029
+ const digest = sha256$1(canonicalBytes);
7542
9030
  if (digest !== planned.digest) continue;
7543
9031
  const nativeBindings = [];
7544
9032
  const nativeConflicts = [];
@@ -7549,7 +9037,7 @@ var SyncService = class {
7549
9037
  try {
7550
9038
  outputBytes = await fs_extra.default.readFile(outputPath);
7551
9039
  } catch {}
7552
- if (outputBytes === null || sha256(outputBytes) !== binding.outputDigest) {
9040
+ if (outputBytes === null || sha256$1(outputBytes) !== binding.outputDigest) {
7553
9041
  nativeConflicts.push({
7554
9042
  agent: binding.agent,
7555
9043
  path: binding.path
@@ -7699,28 +9187,244 @@ var SyncService = class {
7699
9187
  async writeSkills(skills, config) {
7700
9188
  if (!this.dryRun && this.installScope !== "skills") await this.notifyLegacyAgentFolder();
7701
9189
  const agents = await this.resolveTargetAgents(config);
7702
- await this.skillSyncService.writeSkills(skills, config, agents, this.writer);
9190
+ this.skillAttributionPackages = await this.skillSyncService.writeSkills(skills, config, agents, this.writer);
7703
9191
  }
7704
9192
  async assembleWorkflows(config) {
7705
9193
  const ref = this.resolvedRefs?.workflows.ref || config.workflows_ref || "main";
7706
- return this.workflowSyncService.assembleWorkflows(config, ref, this.manifestVerifiers.workflows);
9194
+ return this.workflowSyncService.assembleWorkflows(config, ref, this.manifestVerifiers.workflows, this.resolvedRefs?.workflows.commit ?? void 0);
7707
9195
  }
7708
9196
  async acquireWorkflows(config) {
7709
9197
  const ref = this.resolvedRefs?.workflows.ref || config.workflows_ref || "main";
7710
- return this.workflowSyncService.acquireWorkflows(config, ref, this.manifestVerifiers.workflows);
9198
+ return this.workflowSyncService.acquireWorkflows(config, ref, this.manifestVerifiers.workflows, this.resolvedRefs?.workflows.commit ?? void 0);
7711
9199
  }
7712
9200
  async writeWorkflows(workflows, config) {
7713
9201
  const agents = await this.resolveTargetAgents(config);
7714
9202
  this.pendingWorkflowWrites = await this.workflowSyncService.writeWorkflows(workflows, config, agents, this.writer);
7715
9203
  }
7716
- async syncSpecialists(config) {
7717
- const agents = await this.resolveTargetAgents(config);
7718
- if (agents.length === 0) return true;
9204
+ /**
9205
+ * Acquires every desired specialist before any managed publication. A local
9206
+ * `skills/specialists` directory is the project's own source and is complete
9207
+ * by construction; otherwise the pinned remote source is acquired and its
9208
+ * completeness is reported explicitly.
9209
+ */
9210
+ async acquireSpecialists(config) {
7719
9211
  const localRegistrySource = path.default.join(process.cwd(), "skills/specialists");
7720
- if (await fs_extra.default.pathExists(localRegistrySource)) return await this.specialistSyncService.syncSpecialists(process.cwd(), agents, localRegistrySource, this.writer) > 0;
9212
+ if (await fs_extra.default.pathExists(localRegistrySource)) return {
9213
+ status: "complete",
9214
+ specialists: await this.specialistSyncService.readLocalSpecialists(localRegistrySource),
9215
+ failures: []
9216
+ };
7721
9217
  const ref = this.resolvedRefs?.specialists.ref || config.specialists_ref || "main";
7722
- const specialists = await this.specialistSyncService.assembleSpecialists(config, ref, this.manifestVerifiers.specialists);
7723
- return await this.specialistSyncService.syncCollectedSpecialists(process.cwd(), agents, specialists, this.writer) > 0;
9218
+ return this.specialistSyncService.acquireSpecialists(config, ref, this.manifestVerifiers.specialists, this.resolvedRefs?.specialists.commit ?? void 0);
9219
+ }
9220
+ /**
9221
+ * Publishes the specialists acquired during preflight. Returns whether any
9222
+ * specialist is desired, which is the only evidence that authorizes pruning
9223
+ * previously installed specialist exports.
9224
+ */
9225
+ async writeSpecialists(acquisition, config) {
9226
+ const agents = await this.resolveTargetAgents(config);
9227
+ if (agents.length === 0) return true;
9228
+ await this.specialistSyncService.writeSpecialists(process.cwd(), agents, acquisition.specialists, this.writer);
9229
+ return acquisition.specialists.length > 0;
9230
+ }
9231
+ /** The typed throttle failure, if this operation latched one. */
9232
+ get rateLimitFailure() {
9233
+ return this.githubService.rateLimitFailure;
9234
+ }
9235
+ /**
9236
+ * Builds the attribution plan for the acquired (unpublished) skills and
9237
+ * compares it with the qualified state of the current installation. Nothing
9238
+ * is written: the command layer owns consent and publication.
9239
+ *
9240
+ * Destination bindings are derived from the same agent layout the ownership
9241
+ * writer will use, so the collector reads the bytes a previous run would
9242
+ * have installed at exactly those paths. The candidate digest recorded here
9243
+ * is the source digest; publication re-derives the installed digest from the
9244
+ * actual written bytes.
9245
+ */
9246
+ async planAttribution(skills, config) {
9247
+ const agents = await this.resolveTargetAgents(config);
9248
+ const overrides = config.custom_overrides ?? [];
9249
+ const registry = config.registry ?? "";
9250
+ const previousCatalog = this.previousLock?.attributionCatalog;
9251
+ const planned = {};
9252
+ /** `<packageId>\0<fileName>` -> effective destination paths. */
9253
+ const publishedPaths = /* @__PURE__ */ new Set();
9254
+ const agentDir = (agentId, skill) => {
9255
+ const basePath = SUPPORTED_AGENTS.find((agent) => agent.id === agentId)?.path;
9256
+ if (!basePath) return null;
9257
+ return agentId === Agent.Kiro ? path.default.join(basePath, `${skill.category}-${skill.skill}`) : path.default.join(basePath, skill.category, skill.skill);
9258
+ };
9259
+ for (const skill of skills) {
9260
+ const origin = skill.origin;
9261
+ const packageId = `${skill.category}/${skill.skill}`;
9262
+ const files = [];
9263
+ for (const file of skill.files) {
9264
+ if (!file.attribution) continue;
9265
+ const destinations = [];
9266
+ for (const agentId of agents) {
9267
+ const dir = agentDir(agentId, skill);
9268
+ if (!dir) continue;
9269
+ const relative = toPosixRel(process.cwd(), path.default.join(dir, file.name));
9270
+ if (isOverriddenRel(relative, overrides)) continue;
9271
+ publishedPaths.add(`${packageId}\0${file.name}\0${relative}`);
9272
+ destinations.push({
9273
+ agent: agentId,
9274
+ path: relative,
9275
+ sha256: file.attribution.sha256
9276
+ });
9277
+ }
9278
+ if (destinations.length === 0) continue;
9279
+ files.push({
9280
+ ...file.attribution,
9281
+ name: file.name,
9282
+ destinations
9283
+ });
9284
+ }
9285
+ const previous = previousCatalog?.packages[packageId];
9286
+ const preserved = this.preservedAttributionFiles(packageId, previous, publishedPaths, overrides, config.prune !== false);
9287
+ if (files.length === 0) {
9288
+ if (previous && preserved.length === previous.files.length) planned[packageId] = {
9289
+ ...previous,
9290
+ files: preserved
9291
+ };
9292
+ continue;
9293
+ }
9294
+ if (!origin?.commit || !/^[0-9a-f]{40}$/.test(origin.commit)) continue;
9295
+ for (const file of preserved) if (!files.some((candidate) => candidate.name === file.name)) files.push(file);
9296
+ planned[packageId] = {
9297
+ registry,
9298
+ declaringCategory: origin.category,
9299
+ requestedRef: origin.ref,
9300
+ resolvedCommit: origin.commit,
9301
+ files
9302
+ };
9303
+ }
9304
+ for (const [packageId, record] of Object.entries(previousCatalog?.packages ?? {})) {
9305
+ if (planned[packageId]) continue;
9306
+ const preserved = this.preservedAttributionFiles(packageId, record, publishedPaths, overrides, config.prune !== false);
9307
+ if (preserved.length !== record.files.length) continue;
9308
+ planned[packageId] = {
9309
+ ...record,
9310
+ files: preserved
9311
+ };
9312
+ }
9313
+ const installedOnly = await this.discoverInstalledAttributionVariants(skills, planned, previousCatalog?.packages ?? {}, agents, overrides, publishedPaths);
9314
+ const installed = await collectInstalledAttributionDigests(process.cwd(), this.mergeAttributionDestinations(planned, previousCatalog?.packages ?? {}));
9315
+ const hasInstalledBytes = installedOnly.some((variant) => variant.sha256 !== null) || Object.values(installed).some((perFile) => Object.values(perFile).some((bindings) => bindings.some((binding) => binding.sha256 !== null)));
9316
+ return {
9317
+ planned,
9318
+ installed,
9319
+ installedOnly,
9320
+ comparison: compareAttribution({
9321
+ planned,
9322
+ previousCatalog,
9323
+ hasExistingInstallation: Object.keys(this.previousLock?.entries ?? {}).length > 0 || hasInstalledBytes,
9324
+ installed
9325
+ })
9326
+ };
9327
+ }
9328
+ /**
9329
+ * Previous records whose bytes this run will not touch: every recorded
9330
+ * destination is either a configured override or (when pruning is disabled)
9331
+ * a destination the ownership finalizer keeps. The record is returned
9332
+ * verbatim so the comparison sees preserved bytes, not a removal.
9333
+ */
9334
+ preservedAttributionFiles(packageId, previous, publishedPaths, overrides, prune) {
9335
+ if (!previous) return [];
9336
+ const preserved = [];
9337
+ for (const file of previous.files) if (file.destinations.every((destination) => {
9338
+ if (publishedPaths.has(`${packageId}\0${file.name}\0${destination.path}`)) return false;
9339
+ return isOverriddenRel(destination.path, overrides) || !prune;
9340
+ })) preserved.push({ ...file });
9341
+ return preserved;
9342
+ }
9343
+ /**
9344
+ * Bounded discovery of attribution-family files that exist in installed
9345
+ * package directories but appear in neither the candidate nor the catalog.
9346
+ * Only effective package directories are read — candidate packages plus
9347
+ * directories referenced by previous destinations — only top-level
9348
+ * LICENSE/NOTICE variants are considered, and the actual installed digest is
9349
+ * reported with no invented origin.
9350
+ */
9351
+ async discoverInstalledAttributionVariants(skills, planned, previous, agents, overrides, publishedPaths) {
9352
+ const knownPaths = /* @__PURE__ */ new Set();
9353
+ for (const record of [...Object.values(planned), ...Object.values(previous)]) for (const file of record.files) for (const destination of file.destinations) knownPaths.add(destination.path);
9354
+ const scan = /* @__PURE__ */ new Map();
9355
+ for (const skill of skills) {
9356
+ const packageId = `${skill.category}/${skill.skill}`;
9357
+ for (const agentId of agents) {
9358
+ const basePath = SUPPORTED_AGENTS.find((agent) => agent.id === agentId)?.path;
9359
+ if (!basePath) continue;
9360
+ const dir = agentId === Agent.Kiro ? path.default.join(basePath, `${skill.category}-${skill.skill}`) : path.default.join(basePath, skill.category, skill.skill);
9361
+ scan.set(dir, {
9362
+ packageId,
9363
+ agent: agentId,
9364
+ dir
9365
+ });
9366
+ }
9367
+ }
9368
+ for (const [packageId, record] of Object.entries(previous)) for (const file of record.files) for (const destination of file.destinations) {
9369
+ const dir = path.default.dirname(path.default.join(process.cwd(), destination.path));
9370
+ scan.set(dir, {
9371
+ packageId,
9372
+ agent: destination.agent,
9373
+ dir
9374
+ });
9375
+ }
9376
+ const variants = /* @__PURE__ */ new Map();
9377
+ for (const { packageId, agent, dir } of scan.values()) {
9378
+ let entries;
9379
+ try {
9380
+ entries = await fs_extra.default.readdir(dir);
9381
+ } catch {
9382
+ continue;
9383
+ }
9384
+ for (const entry of entries) {
9385
+ if (!ATTRIBUTION_FILE_NAME_RE.test(entry)) continue;
9386
+ const relative = toPosixRel(process.cwd(), path.default.join(dir, entry));
9387
+ if (knownPaths.has(relative) || publishedPaths.has(`${packageId}\0${entry}\0${relative}`) || isOverriddenRel(relative, overrides)) continue;
9388
+ variants.set(relative, {
9389
+ packageId,
9390
+ name: entry,
9391
+ agent,
9392
+ path: relative,
9393
+ sha256: await readInstalledAttributionDigest(process.cwd(), relative)
9394
+ });
9395
+ }
9396
+ }
9397
+ return [...variants.values()].sort((left, right) => left.path.localeCompare(right.path));
9398
+ }
9399
+ /**
9400
+ * Unions the planned and previously recorded destination sets per package
9401
+ * and filename so the installed-state read covers both sides of the
9402
+ * comparison. Extra bindings never qualify a record; they only let the
9403
+ * comparison see the real bytes at every path it may judge.
9404
+ */
9405
+ mergeAttributionDestinations(planned, previous) {
9406
+ const merged = { ...planned };
9407
+ for (const [packageId, record] of Object.entries(previous)) {
9408
+ const base = merged[packageId];
9409
+ if (!base) {
9410
+ merged[packageId] = record;
9411
+ continue;
9412
+ }
9413
+ const files = base.files.map((file) => ({ ...file }));
9414
+ for (const priorFile of record.files) {
9415
+ const existing = files.find((file) => file.name === priorFile.name);
9416
+ if (!existing) {
9417
+ files.push({ ...priorFile });
9418
+ continue;
9419
+ }
9420
+ for (const destination of priorFile.destinations) if (!existing.destinations.some((current) => current.agent === destination.agent && current.path === destination.path)) existing.destinations.push(destination);
9421
+ }
9422
+ merged[packageId] = {
9423
+ ...base,
9424
+ files
9425
+ };
9426
+ }
9427
+ return merged;
7724
9428
  }
7725
9429
  async applyIndices(config, targetAgents, localMetadata, desiredSkillIds, options) {
7726
9430
  const agents = targetAgents || await this.resolveTargetAgents(config);
@@ -7795,22 +9499,25 @@ var SyncService = class {
7795
9499
  for (const [cat, catConfig] of Object.entries(config.skills)) {
7796
9500
  const remoteMetaCat = remoteMeta.categories?.[cat];
7797
9501
  if (!remoteMetaCat?.version) continue;
7798
- const latestRef = `${remoteMetaCat.tag_prefix || ""}${remoteMetaCat.version}`;
7799
- if (catConfig.ref !== latestRef) candidates.push({
9502
+ const prefix = remoteMetaCat.tag_prefix || "";
9503
+ const latestRef = `${prefix}${remoteMetaCat.version}`;
9504
+ if (catConfig.ref === void 0 || isAutomaticUpgrade(catConfig.ref, latestRef, prefix)) candidates.push({
7800
9505
  key: cat,
7801
9506
  ref: latestRef
7802
9507
  });
7803
9508
  }
7804
9509
  if (remoteMeta.releases?.workflows?.version) {
7805
- const latestWorkflowsRef = `${remoteMeta.releases.workflows.tag_prefix || ""}${remoteMeta.releases.workflows.version}`;
7806
- if (config.workflows_ref && config.workflows_ref !== latestWorkflowsRef) candidates.push({
9510
+ const prefix = remoteMeta.releases.workflows.tag_prefix || "";
9511
+ const latestWorkflowsRef = `${prefix}${remoteMeta.releases.workflows.version}`;
9512
+ if (config.workflows_ref && isAutomaticUpgrade(config.workflows_ref, latestWorkflowsRef, prefix)) candidates.push({
7807
9513
  key: "workflows",
7808
9514
  ref: latestWorkflowsRef
7809
9515
  });
7810
9516
  }
7811
9517
  if (remoteMeta.categories?.specialists?.version) {
7812
- const latestSpecialistsRef = `${remoteMeta.categories.specialists.tag_prefix || ""}${remoteMeta.categories.specialists.version}`;
7813
- if (config.specialists_ref && config.specialists_ref !== latestSpecialistsRef) candidates.push({
9518
+ const prefix = remoteMeta.categories.specialists.tag_prefix || "";
9519
+ const latestSpecialistsRef = `${prefix}${remoteMeta.categories.specialists.version}`;
9520
+ if (config.specialists_ref && isAutomaticUpgrade(config.specialists_ref, latestSpecialistsRef, prefix)) candidates.push({
7814
9521
  key: "specialists",
7815
9522
  ref: latestSpecialistsRef
7816
9523
  });
@@ -8982,9 +10689,25 @@ var RetrospectiveCommand = class {
8982
10689
  };
8983
10690
  //#endregion
8984
10691
  //#region src/commands/sync.ts
10692
+ /** A classified command failure; `cancelled` marks a declined required consent. */
10693
+ var SyncFailureError = class extends Error {
10694
+ failure;
10695
+ cancelled;
10696
+ constructor(failure, cancelled = false) {
10697
+ super(failure.message);
10698
+ this.failure = failure;
10699
+ this.cancelled = cancelled;
10700
+ this.name = "SyncFailureError";
10701
+ }
10702
+ };
8985
10703
  /**
8986
10704
  * Command for synchronizing skills and workflows from the remote registry to the local workspace.
8987
- * It handles configuration re-detection, fetching files, writing to disk, and updating indices.
10705
+ *
10706
+ * Publication order is preflight-first: configuration and pins are reconciled
10707
+ * in memory, every selected source is resolved and acquired, and the
10708
+ * attribution plan is computed before any managed file, lock entry, or saved
10709
+ * ref is written. A preflight failure therefore preserves the installed bytes,
10710
+ * the lock, and the saved refs.
8988
10711
  */
8989
10712
  var SyncCommand = class {
8990
10713
  configService;
@@ -9000,109 +10723,385 @@ var SyncCommand = class {
9000
10723
  this.hookService = hookService || new HookService();
9001
10724
  }
9002
10725
  /**
9003
- * Executes the synchronization flow.
9004
- * Reconciles dependencies, fetches skills and workflows from the registry, and updates AGENTS.md.
10726
+ * Executes the synchronization flow and returns a discriminated outcome.
10727
+ * The CLI boundary owns `process.exitCode`; services never exit the process.
9005
10728
  */
9006
10729
  async run(options = {}) {
10730
+ const restoreOutput = options.json ? this.redirectProgressToStderr() : () => {};
9007
10731
  try {
9008
- const config = await this.configService.loadConfig();
9009
- if (!config) {
9010
- console.log(picocolors.default.red("❌ Error: .skillsrc not found. Run `init` first."));
9011
- return;
9012
- }
9013
- const projectDeps = await this.detectionService.getProjectDeps();
9014
- const skillsChanged = await this.syncService.reconcileConfig(config, projectDeps);
9015
- const workflowsChanged = await this.syncService.reconcileWorkflows(config);
9016
- if (!options.dryRun && (skillsChanged || workflowsChanged)) await this.configService.saveConfig(config);
9017
- const resolvedRefs = await this.syncService.resolvePins(config);
9018
- if (Object.entries(resolvedRefs.newPins).length > 0) {
9019
- const pinnedList = [resolvedRefs.newPins.workflows_ref, resolvedRefs.newPins.specialists_ref].filter(Boolean).join(", ");
9020
- if (!options.dryRun) {
9021
- if (resolvedRefs.newPins.workflows_ref) config.workflows_ref = resolvedRefs.newPins.workflows_ref;
9022
- if (resolvedRefs.newPins.specialists_ref) config.specialists_ref = resolvedRefs.newPins.specialists_ref;
9023
- await this.configService.saveConfig(config);
9024
- console.log(picocolors.default.green(`📌 Pinned ${pinnedList} in .skillsrc`));
9025
- } else console.log(picocolors.default.gray(` (dry-run) would pin ${pinnedList} in .skillsrc`));
9026
- }
9027
- for (const warning of resolvedRefs.warnings) console.log(picocolors.default.yellow(warning));
9028
- const updates = await this.syncService.checkForUpdates(config);
9029
- if (updates && Object.keys(updates).length > 0) {
9030
- if (options.dryRun) {
9031
- if (!options.json) for (const [cat, ref] of Object.entries(updates)) console.log(picocolors.default.gray(` (dry-run) would offer: ${cat} -> ${ref}`));
9032
- } else {
9033
- console.log(picocolors.default.yellow("\n🚀 New skill versions detected:"));
9034
- for (const [cat, ref] of Object.entries(updates)) {
9035
- const currentRef = cat === "workflows" ? config.workflows_ref : cat === "specialists" ? config.specialists_ref : config.skills[cat]?.ref;
9036
- console.log(picocolors.default.gray(` - ${cat}: ${currentRef} -> ${ref}`));
9037
- }
9038
- let update = options.yes;
9039
- if (update === void 0) {
9040
- if (!process.stdin.isTTY) {
9041
- console.log(picocolors.default.cyan("ℹ️ Non-interactive environment detected. Skipping version updates. Use --yes to auto-confirm."));
9042
- update = false;
9043
- } else update = (await inquirer.default.prompt([{
9044
- type: "confirm",
9045
- name: "update",
9046
- message: "Do you want to update .skillsrc with these versions?",
9047
- default: true
9048
- }])).update;
9049
- }
9050
- if (update) {
9051
- for (const [cat, ref] of Object.entries(updates)) if (cat === "workflows") config.workflows_ref = ref;
9052
- else if (cat === "specialists") config.specialists_ref = ref;
9053
- else if (config.skills[cat]) config.skills[cat].ref = ref;
9054
- await this.configService.saveConfig(config);
9055
- console.log(picocolors.default.green("✅ .skillsrc updated."));
9056
- } else console.log(picocolors.default.cyan("ℹ️ Skipping version updates, staying on pinned refs."));
10732
+ return await this.execute(options);
10733
+ } catch (error) {
10734
+ const failure = this.classify(error);
10735
+ const cancelled = error instanceof SyncFailureError && error.cancelled;
10736
+ this.reportFailure(failure, options, cancelled);
10737
+ return cancelled ? {
10738
+ status: "cancelled",
10739
+ failure
10740
+ } : {
10741
+ status: "failure",
10742
+ failure
10743
+ };
10744
+ } finally {
10745
+ restoreOutput();
10746
+ }
10747
+ }
10748
+ async execute(options) {
10749
+ const snapshot = await this.configService.readSnapshot();
10750
+ if (!snapshot) throw new SyncFailureError({
10751
+ code: "config-invalid",
10752
+ message: "❌ Error: .skillsrc not found. Run `init` first."
10753
+ });
10754
+ const config = structuredClone(snapshot.config);
10755
+ const projectDeps = await this.detectionService.getProjectDeps();
10756
+ const skillsChanged = await this.syncService.reconcileConfig(config, projectDeps);
10757
+ const workflowsChanged = await this.syncService.reconcileWorkflows(config);
10758
+ let configChanged = skillsChanged || workflowsChanged;
10759
+ const resolvedRefs = await this.syncService.resolvePins(config);
10760
+ if (Object.entries(resolvedRefs.newPins).length > 0) {
10761
+ const pinnedList = [resolvedRefs.newPins.workflows_ref, resolvedRefs.newPins.specialists_ref].filter(Boolean).join(", ");
10762
+ if (!options.dryRun) {
10763
+ if (resolvedRefs.newPins.workflows_ref) config.workflows_ref = resolvedRefs.newPins.workflows_ref;
10764
+ if (resolvedRefs.newPins.specialists_ref) config.specialists_ref = resolvedRefs.newPins.specialists_ref;
10765
+ configChanged = true;
10766
+ console.log(picocolors.default.green(`📌 Pinned ${pinnedList} in .skillsrc`));
10767
+ } else console.log(picocolors.default.gray(` (dry-run) would pin ${pinnedList} in .skillsrc`));
10768
+ }
10769
+ for (const warning of resolvedRefs.warnings) console.log(picocolors.default.yellow(warning));
10770
+ const updates = await this.syncService.checkForUpdates(config);
10771
+ let refsChanged = false;
10772
+ if (updates && Object.keys(updates).length > 0) {
10773
+ if (options.dryRun) {
10774
+ if (!options.json) for (const [cat, ref] of Object.entries(updates)) console.log(picocolors.default.gray(` (dry-run) would offer: ${cat} -> ${ref}`));
10775
+ } else {
10776
+ console.log(picocolors.default.yellow("\n🚀 New skill versions detected:"));
10777
+ for (const [cat, ref] of Object.entries(updates)) {
10778
+ const currentRef = cat === "workflows" ? config.workflows_ref : cat === "specialists" ? config.specialists_ref : config.skills[cat]?.ref;
10779
+ console.log(picocolors.default.gray(` - ${cat}: ${currentRef} -> ${ref}`));
9057
10780
  }
10781
+ let update = options.yes;
10782
+ if (update === void 0) {
10783
+ if (options.json || !process.stdin.isTTY) {
10784
+ console.log(picocolors.default.cyan("ℹ️ Non-interactive environment detected. Skipping version updates. Use --yes to auto-confirm."));
10785
+ update = false;
10786
+ } else update = (await inquirer.default.prompt([{
10787
+ type: "confirm",
10788
+ name: "update",
10789
+ message: "Do you want to update .skillsrc with these versions?",
10790
+ default: true
10791
+ }])).update;
10792
+ }
10793
+ if (update) {
10794
+ for (const [cat, ref] of Object.entries(updates)) if (cat === "workflows") {
10795
+ config.workflows_ref = ref;
10796
+ refsChanged = true;
10797
+ } else if (cat === "specialists") {
10798
+ config.specialists_ref = ref;
10799
+ refsChanged = true;
10800
+ } else if (config.skills[cat]) {
10801
+ config.skills[cat].ref = ref;
10802
+ refsChanged = true;
10803
+ }
10804
+ configChanged = true;
10805
+ console.log(picocolors.default.green("✅ .skillsrc updated."));
10806
+ } else console.log(picocolors.default.cyan("ℹ️ Skipping version updates, staying on pinned refs."));
9058
10807
  }
9059
- if (!options.json) console.log(picocolors.default.cyan(`🚀 Syncing skills from ${config.registry}...`));
9060
- await this.syncService.beginInstall(config, {
9061
- dryRun: Boolean(options.dryRun),
9062
- force: options.force ?? []
9063
- });
9064
- const skillCategories = Object.keys(config.skills).filter((category) => category !== "specialists");
9065
- const skills = await this.syncService.assembleSkills(skillCategories, config);
9066
- const workflowAcquisition = await this.syncService.acquireWorkflows(config);
9067
- const workflows = workflowAcquisition.workflows;
9068
- if (workflowAcquisition.status === "incomplete") {
9069
- console.error(picocolors.default.red("❌ Workflow acquisition incomplete; existing workflows were preserved and no success is claimed."));
9070
- for (const failure of workflowAcquisition.failures) console.error(picocolors.default.red(` ❌ ${failure.path} — ${failure.reason}`));
9071
- }
9072
- await this.syncService.writeSkills(skills, config);
9073
- await this.syncService.writeWorkflows(workflows, config);
9074
- const specialistsOk = await this.syncService.syncSpecialists(config);
9075
- const plan = await this.syncService.completeInstall(config, {
9076
- skills,
9077
- workflows,
9078
- specialistsOk,
9079
- refreshIndices: true,
9080
- completedSkillCategories: skillCategories,
9081
- workflowAcquisition
10808
+ }
10809
+ if (refsChanged) await this.syncService.resolvePins(config);
10810
+ if (!options.json) console.log(picocolors.default.cyan(`🚀 Syncing skills from ${config.registry}...`));
10811
+ await this.syncService.beginInstall(config, {
10812
+ dryRun: Boolean(options.dryRun),
10813
+ force: options.force ?? []
10814
+ });
10815
+ const skillCategories = Object.keys(config.skills).filter((category) => category !== "specialists");
10816
+ const skills = await this.syncService.assembleSkills(skillCategories, config);
10817
+ const workflowAcquisition = await this.syncService.acquireWorkflows(config);
10818
+ if (workflowAcquisition.status === "incomplete") throw new SyncFailureError({
10819
+ code: "sync-failed",
10820
+ message: "Workflow acquisition incomplete; existing files, lock and saved refs were preserved.",
10821
+ details: { workflowFailures: workflowAcquisition.failures }
10822
+ });
10823
+ const workflows = workflowAcquisition.workflows;
10824
+ const specialistAcquisition = await this.syncService.acquireSpecialists(config);
10825
+ if (specialistAcquisition.status === "incomplete") throw new SyncFailureError({
10826
+ code: "sync-failed",
10827
+ message: "Specialist acquisition incomplete; existing files, lock and saved refs were preserved.",
10828
+ details: { specialistFailures: specialistAcquisition.failures }
10829
+ });
10830
+ const latched = this.syncService.rateLimitFailure;
10831
+ if (latched) throw latched;
10832
+ let attribution = null;
10833
+ let attributionRequiresConsent = false;
10834
+ if (!options.dryRun) {
10835
+ const plan = await this.syncService.planAttribution(skills, config);
10836
+ attribution = plan.comparison;
10837
+ attributionRequiresConsent = plan.comparison.requiresConsent || plan.installedOnly.length > 0;
10838
+ this.printAttributionPreview(plan);
10839
+ await this.ensureAttributionConsent(plan, options);
10840
+ }
10841
+ let publishedSnapshot = snapshot;
10842
+ if (!options.dryRun) publishedSnapshot = configChanged ? await this.configService.saveReviewedConfig(snapshot, config) : await this.assertSnapshotUnchanged(snapshot);
10843
+ await this.syncService.writeSkills(skills, config);
10844
+ await this.syncService.writeWorkflows(workflows, config);
10845
+ const specialistsOk = await this.syncService.writeSpecialists(specialistAcquisition, config);
10846
+ const plan = await this.syncService.completeInstall(config, {
10847
+ skills,
10848
+ workflows,
10849
+ specialistsOk,
10850
+ refreshIndices: true,
10851
+ completedSkillCategories: skillCategories,
10852
+ workflowAcquisition
10853
+ });
10854
+ if (options.dryRun) {
10855
+ if (options.json) this.writeJson({
10856
+ schema_version: 1,
10857
+ kind: "sync.plan",
10858
+ data: plan
9082
10859
  });
9083
- if (options.dryRun) {
9084
- if (options.json) console.log(JSON.stringify({
9085
- schema_version: 1,
9086
- kind: "sync.plan",
9087
- data: plan
9088
- }));
9089
- else {
9090
- console.log(picocolors.default.cyan("🔍 Dry run — no files written"));
9091
- this.printPlanCounts(plan);
9092
- if (options.verbose) this.printVerbosePlan(plan);
9093
- console.log(picocolors.default.gray(" (dry-run) skipped: MCP registration, hooks, capability notes"));
10860
+ else {
10861
+ console.log(picocolors.default.cyan("🔍 Dry run — no files written"));
10862
+ this.printPlanCounts(plan);
10863
+ if (options.verbose) this.printVerbosePlan(plan);
10864
+ console.log(picocolors.default.gray(" (dry-run) skipped: MCP registration, hooks, capability notes"));
10865
+ }
10866
+ return {
10867
+ status: "dry-run",
10868
+ plan
10869
+ };
10870
+ }
10871
+ this.printSummary(plan);
10872
+ await this.syncService.discloseCapabilities(config);
10873
+ console.log(picocolors.default.green("✅ All skills synced successfully!"));
10874
+ await this.runMcpPhase(config, options, publishedSnapshot);
10875
+ await this.runHookPhase(config, options);
10876
+ if (options.json) this.writeJson({
10877
+ schema_version: 1,
10878
+ kind: "sync.result",
10879
+ data: {
10880
+ status: "success",
10881
+ added: plan.added.length,
10882
+ updated: plan.updated.length,
10883
+ unchanged: plan.unchanged.length,
10884
+ kept: plan.kept.length,
10885
+ unknown: plan.unknown.length,
10886
+ pruned: plan.pruned.length,
10887
+ keptOrphans: plan.keptOrphans.length,
10888
+ attribution: attribution ? {
10889
+ required: attributionRequiresConsent,
10890
+ changes: attribution.packages.filter((change) => change.requiresConsent).length
10891
+ } : null
10892
+ }
10893
+ });
10894
+ return {
10895
+ status: "success",
10896
+ plan
10897
+ };
10898
+ }
10899
+ /**
10900
+ * Confirms the configuration on disk is still the snapshot read at preflight
10901
+ * without writing anything. Runs immediately before the first managed write.
10902
+ */
10903
+ async assertSnapshotUnchanged(snapshot) {
10904
+ const current = await this.configService.readSnapshot();
10905
+ if (!current || current.sourcePath !== snapshot.sourcePath || current.fingerprint !== snapshot.fingerprint) throw new SyncFailureError({
10906
+ code: "config-invalid",
10907
+ message: "❌ .skillsrc changed after preflight; nothing was published. Re-run `ags sync`."
10908
+ });
10909
+ return snapshot;
10910
+ }
10911
+ /**
10912
+ * In JSON mode stdout carries exactly one envelope. Every progress line,
10913
+ * including service-level logging, is redirected to stderr for the run.
10914
+ */
10915
+ redirectProgressToStderr() {
10916
+ const originalLog = console.log;
10917
+ const originalInfo = console.info;
10918
+ console.log = (...args) => console.error(...args);
10919
+ console.info = (...args) => console.error(...args);
10920
+ return () => {
10921
+ console.log = originalLog;
10922
+ console.info = originalInfo;
10923
+ };
10924
+ }
10925
+ writeJson(value) {
10926
+ process.stdout.write(`${JSON.stringify(value)}\n`);
10927
+ }
10928
+ /** Maps a thrown value to a stable, caller-visible failure contract. */
10929
+ classify(error) {
10930
+ if (error instanceof SyncFailureError) return error.failure;
10931
+ const latched = this.syncService.rateLimitFailure;
10932
+ if (latched) return this.githubError(latched);
10933
+ if (error instanceof GithubRequestError) return this.githubError(error);
10934
+ if (error instanceof ConfigValidationError) return {
10935
+ code: "config-invalid",
10936
+ message: `❌ Invalid .skillsrc: ${error.message}`,
10937
+ details: {
10938
+ configPath: error.configPath,
10939
+ logicalPath: error.logicalPath
10940
+ }
10941
+ };
10942
+ if (error instanceof IntegrityError) return {
10943
+ code: "integrity",
10944
+ message: error.message
10945
+ };
10946
+ if (error instanceof Error) return {
10947
+ code: "sync-failed",
10948
+ message: error.message
10949
+ };
10950
+ return {
10951
+ code: "sync-failed",
10952
+ message: String(error)
10953
+ };
10954
+ }
10955
+ githubError(error) {
10956
+ const auth = error.authenticated ? "a GitHub token was sent" : "no GitHub token was sent";
10957
+ const code = error.kind === "rate-limited" || error.kind === "access-denied" ? error.kind : error.kind === "integrity" ? "integrity" : "sync-failed";
10958
+ let hint;
10959
+ if (error.kind === "rate-limited") {
10960
+ const timing = error.resetAt ? ` It resets at ${error.resetAt}.` : error.retryAfterSeconds !== void 0 ? ` Retry after ${error.retryAfterSeconds}s.` : "";
10961
+ hint = `GitHub ${error.rateLimitKind ?? "primary"} rate limit reached; no further requests were scheduled.${timing} ${auth}; set GITHUB_TOKEN for a higher limit.`;
10962
+ } else if (error.kind === "access-denied") hint = `GitHub denied access to ${error.endpoint}. ${auth}; verify repository access or set GITHUB_TOKEN.`;
10963
+ else if (error.kind === "integrity") hint = "A downloaded source failed integrity verification; nothing was published.";
10964
+ else hint = `GitHub request failed at ${error.endpoint}. ${auth}; retry when the network or the reference is available.`;
10965
+ return {
10966
+ code,
10967
+ message: error.message,
10968
+ hint,
10969
+ github: error.toJSON()
10970
+ };
10971
+ }
10972
+ reportFailure(failure, options, cancelled) {
10973
+ if (options.json) {
10974
+ this.writeJson({
10975
+ schema_version: 1,
10976
+ kind: "sync.error",
10977
+ data: {
10978
+ code: failure.code,
10979
+ message: failure.message,
10980
+ ...failure.hint ? { hint: failure.hint } : {},
10981
+ ...failure.github ? { github: failure.github } : {},
10982
+ ...failure.details ? { details: failure.details } : {},
10983
+ ...cancelled ? { cancelled: true } : {}
9094
10984
  }
9095
- return;
10985
+ });
10986
+ return;
10987
+ }
10988
+ console.error(picocolors.default.red("❌ Sync failed:"), failure.message);
10989
+ if (failure.hint) console.error(picocolors.default.yellow(` ${failure.hint}`));
10990
+ const acquisitionFailures = failure.details?.workflowFailures ?? failure.details?.specialistFailures;
10991
+ if (Array.isArray(acquisitionFailures)) for (const item of acquisitionFailures) console.error(picocolors.default.red(` ❌ ${String(item.path)} — ${String(item.reason)}`));
10992
+ }
10993
+ /**
10994
+ * Discloses a first installation and previews every transition with both
10995
+ * origins, the installed digest, explicitly unknown old origins, and
10996
+ * installed-only attribution files that no candidate/catalog record covers.
10997
+ */
10998
+ printAttributionPreview(plan) {
10999
+ const { comparison, installed, installedOnly } = plan;
11000
+ if (comparison.firstInstall && installedOnly.length === 0) {
11001
+ for (const record of comparison.disclosures) {
11002
+ const files = record.files.map((file) => `${file.name} (${file.basis}, ${file.resolvedCommit.slice(0, 12)})`).join(", ");
11003
+ console.error(picocolors.default.gray(` ℹ️ First install — attribution disclosed from ${record.registry}@${record.requestedRef}: ${files}`));
9096
11004
  }
9097
- this.printSummary(plan);
9098
- await this.syncService.discloseCapabilities(config);
9099
- if (workflowAcquisition.status !== "incomplete") console.log(picocolors.default.green("✅ All skills synced successfully!"));
9100
- await this.runMcpPhase(config, options);
9101
- await this.runHookPhase(config, options);
9102
- } catch (error) {
9103
- if (error instanceof Error) console.error(picocolors.default.red("❌ Sync failed:"), error.message);
9104
- else console.error(picocolors.default.red("❌ Sync failed:"), String(error));
11005
+ return;
9105
11006
  }
11007
+ const relevant = comparison.packages.filter((change) => change.status !== "unchanged" || change.requiresConsent || change.files.some((file) => file.previous === null));
11008
+ if (relevant.length === 0 && installedOnly.length === 0) return;
11009
+ console.error(picocolors.default.yellow("\n⚠️ Attribution changes require consent:"));
11010
+ for (const change of relevant) {
11011
+ console.error(picocolors.default.yellow(` ${change.status} ${change.packageId} (${change.reason})`));
11012
+ for (const file of change.files.slice(0, 12)) {
11013
+ const installedNow = installed[change.packageId]?.[file.name] ?? [];
11014
+ const installedDigest = installedNow.find((binding) => binding.sha256 !== null)?.sha256 ?? null;
11015
+ console.error(picocolors.default.gray(` ${file.status} ${file.name}`));
11016
+ console.error(picocolors.default.gray(` old: ${this.describeAttributionOrigin(file.previous, file.previousSha256 ?? installedDigest, "old")}`));
11017
+ console.error(picocolors.default.gray(` new: ${this.describeAttributionOrigin(file.planned, file.plannedSha256, "new")}`));
11018
+ console.error(picocolors.default.gray(` installed: ${installedNow.length === 0 ? "not recorded" : installedNow.map((binding) => `${binding.agent}:${binding.path} sha256=${binding.sha256 ? binding.sha256.slice(0, 12) : "unreadable"}`).join("; ")}`));
11019
+ }
11020
+ if (change.files.length > 12) console.error(picocolors.default.gray(` … +${change.files.length - 12} more files`));
11021
+ }
11022
+ for (const variant of installedOnly.slice(0, 12)) console.error(picocolors.default.yellow(` installed-only ${variant.name} @ ${variant.path} (${variant.agent}): origin unknown, installed sha256 ${variant.sha256 ? variant.sha256.slice(0, 12) : "unreadable"}`));
11023
+ if (installedOnly.length > 12) console.error(picocolors.default.gray(` … +${installedOnly.length - 12} more installed-only files`));
11024
+ }
11025
+ describeAttributionOrigin(record, digest, kind) {
11026
+ if (record) return `${record.sourcePath}@${record.requestedRef}/${record.resolvedCommit.slice(0, 12)} basis=${record.basis} sha256=${record.sha256.slice(0, 12)}`;
11027
+ if (digest) return `unknown origin (qualified record unavailable) sha256=${digest.slice(0, 12)}`;
11028
+ return kind === "new" ? "not shipped by the selected source" : "none";
11029
+ }
11030
+ /**
11031
+ * Requires separate attribution consent for any computed transition or any
11032
+ * installed attribution file that cannot be qualified. Ordinary `--yes` is
11033
+ * not consent; a non-interactive run without the flag fails preflight, and an
11034
+ * interactive decline cancels the run, both before any publication.
11035
+ */
11036
+ async ensureAttributionConsent(plan, options) {
11037
+ const { comparison, installed, installedOnly } = plan;
11038
+ if (!(comparison.requiresConsent || installedOnly.length > 0)) return;
11039
+ const details = {
11040
+ legacyUnqualified: comparison.legacyUnqualified,
11041
+ installedOnly: installedOnly.map((variant) => ({
11042
+ packageId: variant.packageId,
11043
+ name: variant.name,
11044
+ agent: variant.agent,
11045
+ path: variant.path,
11046
+ sha256: variant.sha256
11047
+ })),
11048
+ packages: comparison.packages.filter((change) => change.requiresConsent || installedOnly.some((variant) => variant.packageId === change.packageId)).map((change) => ({
11049
+ packageId: change.packageId,
11050
+ status: change.status,
11051
+ reason: change.reason,
11052
+ files: change.files.map((file) => ({
11053
+ name: file.name,
11054
+ status: file.status,
11055
+ previousSha256: file.previousSha256,
11056
+ plannedSha256: file.plannedSha256,
11057
+ previous: file.previous ? {
11058
+ sourcePath: file.previous.sourcePath,
11059
+ sourceKind: file.previous.sourceKind,
11060
+ requestedRef: file.previous.requestedRef,
11061
+ resolvedCommit: file.previous.resolvedCommit,
11062
+ blobSha1: file.previous.blobSha1,
11063
+ basis: file.previous.basis,
11064
+ sha256: file.previous.sha256
11065
+ } : null,
11066
+ planned: file.planned ? {
11067
+ sourcePath: file.planned.sourcePath,
11068
+ sourceKind: file.planned.sourceKind,
11069
+ requestedRef: file.planned.requestedRef,
11070
+ resolvedCommit: file.planned.resolvedCommit,
11071
+ blobSha1: file.planned.blobSha1,
11072
+ basis: file.planned.basis,
11073
+ sha256: file.planned.sha256
11074
+ } : null,
11075
+ installed: (installed[change.packageId]?.[file.name] ?? []).map((binding) => ({
11076
+ agent: binding.agent,
11077
+ path: binding.path,
11078
+ sha256: binding.sha256
11079
+ }))
11080
+ }))
11081
+ }))
11082
+ };
11083
+ if (options.acceptAttributionChanges) return;
11084
+ const message = "Attribution changes require explicit consent; re-run with --accept-attribution-changes (ordinary --yes does not authorize attribution changes).";
11085
+ if (options.json || !process.stdin.isTTY) throw new SyncFailureError({
11086
+ code: "attribution-consent-required",
11087
+ message,
11088
+ hint: "Review the preview above, then re-run with --accept-attribution-changes.",
11089
+ details
11090
+ });
11091
+ await this.promptAttributionConsent(details);
11092
+ }
11093
+ async promptAttributionConsent(details) {
11094
+ if ((await inquirer.default.prompt([{
11095
+ type: "confirm",
11096
+ name: "accept",
11097
+ message: "Apply these attribution changes to the installed files?",
11098
+ default: false
11099
+ }])).accept) return;
11100
+ throw new SyncFailureError({
11101
+ code: "attribution-consent-required",
11102
+ message: "Attribution changes were declined; nothing was published.",
11103
+ details
11104
+ }, true);
9106
11105
  }
9107
11106
  /**
9108
11107
  * Phase 7 — MCP integration. Three paths:
@@ -9113,7 +11112,7 @@ var SyncCommand = class {
9113
11112
  * In non-interactive mode (no TTY) and never-prompted: skip with a hint, do
9114
11113
  * not assume consent. Users can opt in later via `ags mcp enable`.
9115
11114
  */
9116
- async runMcpPhase(config, options) {
11115
+ async runMcpPhase(config, options, snapshot) {
9117
11116
  const mcp = config.mcp ? { ...config.mcp } : defaultMcpConfig();
9118
11117
  const agents = config.agents ?? [];
9119
11118
  const snippetOnlyOverride = options.snippets === true && (!mcp.enabled || mcp.scope === "disabled");
@@ -9123,17 +11122,19 @@ var SyncCommand = class {
9123
11122
  mcp.snippets = true;
9124
11123
  } else if (options.snippets !== void 0) mcp.snippets = options.snippets;
9125
11124
  if (!snippetOnlyOverride && (!mcp.prompted || !mcp.enabled && mcp.prompted)) {
9126
- if (!process.stdin.isTTY && !options.yes) {
11125
+ if ((options.json || !process.stdin.isTTY) && !options.yes) {
9127
11126
  if (!mcp.prompted) console.log(picocolors.default.gray("\nℹ️ MCP integration not yet configured. Run `ags mcp enable` (or `ags mcp scope project`) when ready."));
9128
11127
  return;
9129
11128
  }
9130
11129
  const decided = await this.promptMcpConsent(options, mcp.prompted && !mcp.enabled);
9131
- config.mcp = {
11130
+ const reviewed = structuredClone(config);
11131
+ reviewed.mcp = {
9132
11132
  enabled: decided.enabled,
9133
11133
  scope: decided.enabled ? decided.scope : "disabled",
9134
11134
  prompted: true
9135
11135
  };
9136
- await this.configService.saveConfig(config);
11136
+ config.mcp = reviewed.mcp;
11137
+ await this.configService.saveReviewedConfig(snapshot, reviewed);
9137
11138
  }
9138
11139
  const finalMcp = config.mcp ? { ...config.mcp } : defaultMcpConfig();
9139
11140
  if (snippetOnlyOverride) {
@@ -9143,6 +11144,10 @@ var SyncCommand = class {
9143
11144
  } else if (options.snippets !== void 0) finalMcp.snippets = options.snippets;
9144
11145
  if (!finalMcp.enabled || finalMcp.scope === "disabled") return;
9145
11146
  const userScopePrompt = options.yes ? async () => true : async (agent, file) => {
11147
+ if (options.json || !process.stdin.isTTY) {
11148
+ console.log(picocolors.default.gray(` ℹ️ Skipping user-scope MCP config for ${agent} (${file}); re-run interactively or with --yes to authorize.`));
11149
+ return false;
11150
+ }
9146
11151
  const { ok } = await inquirer.default.prompt([{
9147
11152
  type: "confirm",
9148
11153
  name: "ok",
@@ -9223,7 +11228,7 @@ var SyncCommand = class {
9223
11228
  return;
9224
11229
  }
9225
11230
  const promptPermission = options.yes ? async () => true : async (agent) => {
9226
- if (!process.stdin.isTTY) {
11231
+ if (options.json || !process.stdin.isTTY) {
9227
11232
  console.log(picocolors.default.gray(`\nℹ️ Skipping MCP permission grant for ${agent} (non-interactive; use --yes to auto-confirm).`));
9228
11233
  return false;
9229
11234
  }
@@ -10241,7 +12246,7 @@ var UninstallService = class {
10241
12246
  missing.push(rel);
10242
12247
  continue;
10243
12248
  }
10244
- if (sha256(await fs_extra.default.readFile(absPath)) === entry.sha256) remove.push(rel);
12249
+ if (sha256$1(await fs_extra.default.readFile(absPath)) === entry.sha256) remove.push(rel);
10245
12250
  else keepEdited.push(rel);
10246
12251
  }
10247
12252
  const removedAgents = hasAgents ? [...selection.agents] : [];
@@ -10256,7 +12261,7 @@ var UninstallService = class {
10256
12261
  }
10257
12262
  const entry = lock.entries[rel];
10258
12263
  const content = await fs_extra.default.readFile(absPath);
10259
- if (entry && sha256(content) === entry.sha256) remove.push(rel);
12264
+ if (entry && sha256$1(content) === entry.sha256) remove.push(rel);
10260
12265
  else keepEdited.push(rel);
10261
12266
  }
10262
12267
  return {
@@ -11366,7 +13371,7 @@ var PolicyCommand = class {
11366
13371
  };
11367
13372
  //#endregion
11368
13373
  //#region src/index.ts
11369
- dotenv.default.config();
13374
+ dotenv.default.config({ quiet: true });
11370
13375
  var _agsExecPath = process.argv[1] ?? "";
11371
13376
  if (_agsExecPath.includes("/pnpm/") && _agsExecPath.includes("/node_modules/agent-skills-standard/") && !_agsExecPath.includes(`agent-skills-standard@${package_default.version}`)) process.stderr.write(`\n${picocolors.default.yellow("⚠️ ags is running from a stale global shim — reported version will be wrong.")}\n ${picocolors.default.cyan("Quickfix:")} ${picocolors.default.bold("npx agent-skills-standard@latest upgrade")} ` + picocolors.default.gray("(auto-detects & removes the stale shim)") + "\n\n");
11372
13377
  var program = new commander.Command();
@@ -11374,8 +13379,9 @@ program.name("agent-skills-standard").description("A CLI to manage and sync AI a
11374
13379
  program.command("init").description("Initialize a .skillsrc configuration file interactively").action(async () => {
11375
13380
  await new InitCommand().run();
11376
13381
  });
11377
- program.command("sync").description("Sync skills to AI Agent skill directories").option("-y, --yes", "Automatically confirm interactive prompts (e.g. update versions)").option("--snippets", "Generate JSON config snippets in ./mcp-config-snippets/; if MCP is disabled, run snippet-only mode").option("--dry-run", "Show what sync would change without writing anything").option("--force <paths...>", "Overwrite these files even if you edited them (backed up first)").option("--verbose", "With --dry-run: list every path").option("--json", "With --dry-run: print the plan as JSON (sync.plan)").action(async (options) => {
11378
- await new SyncCommand().run(options);
13382
+ program.command("sync").description("Sync skills to AI Agent skill directories").option("-y, --yes", "Automatically confirm interactive prompts (e.g. update versions)").option("--snippets", "Generate JSON config snippets in ./mcp-config-snippets/; if MCP is disabled, run snippet-only mode").option("--dry-run", "Show what sync would change without writing anything").option("--force <paths...>", "Overwrite these files even if you edited them (backed up first)").option("--verbose", "With --dry-run: list every path").option("--json", "Emit one versioned JSON envelope on stdout (sync.plan for --dry-run, sync.result/sync.error otherwise)").option("--accept-attribution-changes", "Consent to the previewed attribution changes for this run only (ordinary --yes is not consent)").action(async (options) => {
13383
+ const outcome = await new SyncCommand().run(options);
13384
+ process.exitCode = outcome.status === "success" || outcome.status === "dry-run" ? 0 : 1;
11379
13385
  });
11380
13386
  program.command("verify").description("Check installed files against .skills-lock.json (catches tampering, partial writes, or manual drift)").option("--agent <agent>", "Which agent skill directory to verify (default: first configured agent)").option("--strict", "Fail if any locked ref has moved (resolves to a different commit)").option("--attestation", "Verify build-provenance attestations using the GitHub CLI (gh)").action(async (options) => {
11381
13387
  await new VerifyCommand().run(options);