wicked-crew 0.7.28 → 0.7.30

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.
Files changed (146) hide show
  1. package/README.md +12 -1
  2. package/dist/api/chat-scope.d.ts +210 -0
  3. package/dist/api/chat-scope.d.ts.map +1 -0
  4. package/dist/api/chat-scope.js +728 -0
  5. package/dist/api/chat-scope.js.map +1 -0
  6. package/dist/api/diagnostics.d.ts +30 -2
  7. package/dist/api/diagnostics.d.ts.map +1 -1
  8. package/dist/api/diagnostics.js +73 -5
  9. package/dist/api/diagnostics.js.map +1 -1
  10. package/dist/api/governance-health.d.ts +171 -0
  11. package/dist/api/governance-health.d.ts.map +1 -0
  12. package/dist/api/governance-health.js +379 -0
  13. package/dist/api/governance-health.js.map +1 -0
  14. package/dist/api/open-path.d.ts.map +1 -1
  15. package/dist/api/open-path.js +2 -1
  16. package/dist/api/open-path.js.map +1 -1
  17. package/dist/api/post-hoc-deliver.d.ts +9 -5
  18. package/dist/api/post-hoc-deliver.d.ts.map +1 -1
  19. package/dist/api/post-hoc-deliver.js +21 -7
  20. package/dist/api/post-hoc-deliver.js.map +1 -1
  21. package/dist/api/routes.d.ts +70 -12
  22. package/dist/api/routes.d.ts.map +1 -1
  23. package/dist/api/routes.js +375 -60
  24. package/dist/api/routes.js.map +1 -1
  25. package/dist/api/seat-health.d.ts +38 -0
  26. package/dist/api/seat-health.d.ts.map +1 -1
  27. package/dist/api/seat-health.js +88 -5
  28. package/dist/api/seat-health.js.map +1 -1
  29. package/dist/api/seat-signin.d.ts.map +1 -1
  30. package/dist/api/seat-signin.js +49 -23
  31. package/dist/api/seat-signin.js.map +1 -1
  32. package/dist/api/seat-standing.d.ts +101 -0
  33. package/dist/api/seat-standing.d.ts.map +1 -0
  34. package/dist/api/seat-standing.js +138 -0
  35. package/dist/api/seat-standing.js.map +1 -0
  36. package/dist/api/server.d.ts.map +1 -1
  37. package/dist/api/server.js +37 -0
  38. package/dist/api/server.js.map +1 -1
  39. package/dist/campaigns/routes.d.ts +4 -4
  40. package/dist/cli/governance.d.ts +131 -0
  41. package/dist/cli/governance.d.ts.map +1 -0
  42. package/dist/cli/governance.js +354 -0
  43. package/dist/cli/governance.js.map +1 -0
  44. package/dist/cli/index.js +102 -3
  45. package/dist/cli/index.js.map +1 -1
  46. package/dist/core/adapter.d.ts +99 -1
  47. package/dist/core/adapter.d.ts.map +1 -1
  48. package/dist/core/adapter.js +149 -8
  49. package/dist/core/adapter.js.map +1 -1
  50. package/dist/core/bridge-path.d.ts +21 -0
  51. package/dist/core/bridge-path.d.ts.map +1 -1
  52. package/dist/core/bridge-path.js +37 -0
  53. package/dist/core/bridge-path.js.map +1 -1
  54. package/dist/core/bridge-reaper.d.ts.map +1 -1
  55. package/dist/core/bridge-reaper.js +11 -4
  56. package/dist/core/bridge-reaper.js.map +1 -1
  57. package/dist/core/deliver-text.d.ts +184 -0
  58. package/dist/core/deliver-text.d.ts.map +1 -0
  59. package/dist/core/deliver-text.js +434 -0
  60. package/dist/core/deliver-text.js.map +1 -0
  61. package/dist/core/deliver-triage.d.ts +90 -0
  62. package/dist/core/deliver-triage.d.ts.map +1 -0
  63. package/dist/core/deliver-triage.js +106 -0
  64. package/dist/core/deliver-triage.js.map +1 -0
  65. package/dist/core/deliver.d.ts +103 -9
  66. package/dist/core/deliver.d.ts.map +1 -1
  67. package/dist/core/deliver.js +244 -46
  68. package/dist/core/deliver.js.map +1 -1
  69. package/dist/core/estate-mcp-client.d.ts.map +1 -1
  70. package/dist/core/estate-mcp-client.js +7 -2
  71. package/dist/core/estate-mcp-client.js.map +1 -1
  72. package/dist/core/exec.d.ts.map +1 -1
  73. package/dist/core/exec.js +11 -1
  74. package/dist/core/exec.js.map +1 -1
  75. package/dist/core/governance-store.d.ts +219 -0
  76. package/dist/core/governance-store.d.ts.map +1 -0
  77. package/dist/core/governance-store.js +290 -0
  78. package/dist/core/governance-store.js.map +1 -0
  79. package/dist/core/repoPaths.d.ts +36 -5
  80. package/dist/core/repoPaths.d.ts.map +1 -1
  81. package/dist/core/repoPaths.js +46 -4
  82. package/dist/core/repoPaths.js.map +1 -1
  83. package/dist/interactive/bridge-pool.d.ts +29 -24
  84. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  85. package/dist/interactive/bridge-pool.js +74 -7
  86. package/dist/interactive/bridge-pool.js.map +1 -1
  87. package/dist/interactive/chat-events.d.ts.map +1 -1
  88. package/dist/interactive/chat-events.js +5 -2
  89. package/dist/interactive/chat-events.js.map +1 -1
  90. package/dist/interactive/council-outcome.d.ts +28 -0
  91. package/dist/interactive/council-outcome.d.ts.map +1 -0
  92. package/dist/interactive/council-outcome.js +57 -0
  93. package/dist/interactive/council-outcome.js.map +1 -0
  94. package/dist/interactive/demo-events.d.ts.map +1 -1
  95. package/dist/interactive/demo-events.js +5 -2
  96. package/dist/interactive/demo-events.js.map +1 -1
  97. package/dist/interactive/doc-delete-routes.d.ts.map +1 -1
  98. package/dist/interactive/doc-delete-routes.js +3 -2
  99. package/dist/interactive/doc-delete-routes.js.map +1 -1
  100. package/dist/interactive/draft-events.d.ts.map +1 -1
  101. package/dist/interactive/draft-events.js +5 -2
  102. package/dist/interactive/draft-events.js.map +1 -1
  103. package/dist/interactive/edit-events.d.ts.map +1 -1
  104. package/dist/interactive/edit-events.js +5 -2
  105. package/dist/interactive/edit-events.js.map +1 -1
  106. package/dist/projects/auto-refresh.d.ts +54 -0
  107. package/dist/projects/auto-refresh.d.ts.map +1 -0
  108. package/dist/projects/auto-refresh.js +76 -0
  109. package/dist/projects/auto-refresh.js.map +1 -0
  110. package/dist/projects/graph-paths.d.ts +4 -2
  111. package/dist/projects/graph-paths.d.ts.map +1 -1
  112. package/dist/projects/graph-paths.js +4 -2
  113. package/dist/projects/graph-paths.js.map +1 -1
  114. package/dist/projects/graph.d.ts +20 -6
  115. package/dist/projects/graph.d.ts.map +1 -1
  116. package/dist/projects/graph.js +40 -13
  117. package/dist/projects/graph.js.map +1 -1
  118. package/dist/projects/routes.d.ts.map +1 -1
  119. package/dist/projects/routes.js +30 -11
  120. package/dist/projects/routes.js.map +1 -1
  121. package/dist/projects/state-home.d.ts +6 -0
  122. package/dist/projects/state-home.d.ts.map +1 -1
  123. package/dist/projects/state-home.js +9 -1
  124. package/dist/projects/state-home.js.map +1 -1
  125. package/dist/skills/guards.d.ts +17 -6
  126. package/dist/skills/guards.d.ts.map +1 -1
  127. package/dist/skills/guards.js +42 -15
  128. package/dist/skills/guards.js.map +1 -1
  129. package/dist/skills/plugin-source.d.ts.map +1 -1
  130. package/dist/skills/plugin-source.js +3 -0
  131. package/dist/skills/plugin-source.js.map +1 -1
  132. package/dist/skills/refs.d.ts +181 -17
  133. package/dist/skills/refs.d.ts.map +1 -1
  134. package/dist/skills/refs.js +345 -26
  135. package/dist/skills/refs.js.map +1 -1
  136. package/dist/skills/store.d.ts +22 -1
  137. package/dist/skills/store.d.ts.map +1 -1
  138. package/dist/skills/store.js +161 -23
  139. package/dist/skills/store.js.map +1 -1
  140. package/dist/studio/assets/index-CWUS9Ump.js +542 -0
  141. package/dist/studio/assets/{index-CK1uJWs2.css → index-_ydd-jKd.css} +1 -1
  142. package/dist/studio/index.html +2 -2
  143. package/dist/studio/testid-inventory.json +567 -6
  144. package/endpoint-manifest.json +67 -14
  145. package/package.json +4 -4
  146. package/dist/studio/assets/index-CdmCMh2f.js +0 -539
@@ -131,7 +131,7 @@ import { derivedSkillName, parseFrontmatter, PLUGIN_NAME, SKILL_NAME_PREFIX, ski
131
131
  import { collisionGuard, coreDisableGuard, finding, frontmatterGuard, nameGuard, nestedSkillCreateGuard, noBaselineGuard, nonPortableGuard, SKILL_NAME_RE, supportFileGuard, verdictOf, } from './guards.js';
132
132
  import { LiveGenerations } from './live-generations.js';
133
133
  import { discoverLivePluginDetailed, gitStateOf, PluginSourceSymlinkError } from './plugin-source.js';
134
- import { extractPluginRootRefs, extractRelativeRefs, looksBinary, portabilityIssueOf, resolvePluginRootRef, resolveRelativeRef, } from './refs.js';
134
+ import { existsIn, extractPluginRootRefs, extractRelativeRefs, looksBinary, PORTABILITY_EVIDENCE_CAP, PORTABILITY_REASONS, portabilityEvidenceOf, portabilityIssuesOf, portabilityReasonsOf, resolvePluginRootRef, resolveRelativeRef, } from './refs.js';
135
135
  import { CURRENT_TMP_PREFIX, STAGING_PREFIX } from './root-names.js';
136
136
  import { assertNoSymlinkComponents, copyFiles, EntrySwappedError, hashFileSet, hashTree, impliedDirs, isCarriedFile, makeTreeReadOnly, pruneEmptyDirs, readFileNoFollow, removeTreeForce, sha256Hex, SKIP_DIR_NAMES, SymlinkComponentError, walkEntries, walkFiles, walkTree, writeFileAtomic, } from './tree.js';
137
137
  /** The root-level names the store creates live in ONE table (`root-names.ts`, design v3.5 §2) — re-exported for the tests that observe the flip. */
@@ -302,6 +302,46 @@ export class RevisionMismatchError extends Error {
302
302
  this.name = 'RevisionMismatchError';
303
303
  }
304
304
  }
305
+ /**
306
+ * Why `value` is not a well-formed `SkillPortability` for a row whose `portable` is `portable` —
307
+ * or `null` when it is: an object whose `portable` repeats the row's, whose `reasons` is a
308
+ * strictly sorted, unique list of known tokens (empty exactly when portable), and whose optional
309
+ * `evidence` is at most five strings. Shared by the manifest parse and the snapshot-row check.
310
+ */
311
+ function portabilityProblem(value, portable) {
312
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
313
+ return 'is not an object';
314
+ const p = value;
315
+ for (const k of Object.keys(p))
316
+ if (!['portable', 'reasons', 'evidence'].includes(k))
317
+ return `carries an unknown key ${JSON.stringify(k)}`;
318
+ if (p['portable'] !== portable)
319
+ return `.portable is ${JSON.stringify(p['portable'])}, not the row's ${String(portable)}`;
320
+ const reasons = p['reasons'];
321
+ if (!Array.isArray(reasons))
322
+ return '.reasons is not an array';
323
+ for (let i = 0; i < reasons.length; i += 1) {
324
+ const r = reasons[i];
325
+ if (typeof r !== 'string' || !PORTABILITY_REASONS.has(r))
326
+ return `.reasons[${i}] is ${JSON.stringify(r)}, not a known reason (${[...PORTABILITY_REASONS].join('|')})`;
327
+ if (i > 0 && !(reasons[i - 1] < r))
328
+ return '.reasons is not sorted and unique';
329
+ }
330
+ if ((reasons.length === 0) !== portable)
331
+ return `.reasons is ${reasons.length === 0 ? 'empty' : 'non-empty'} but the row is ${portable ? 'portable' : 'not portable'}`;
332
+ if (Object.hasOwn(p, 'evidence')) {
333
+ const ev = p['evidence'];
334
+ if (!Array.isArray(ev) || ev.some((e) => typeof e !== 'string'))
335
+ return '.evidence is not an array of strings';
336
+ if (ev.length > PORTABILITY_EVIDENCE_CAP)
337
+ return `.evidence carries ${ev.length} anchors (cap ${PORTABILITY_EVIDENCE_CAP})`;
338
+ }
339
+ return null;
340
+ }
341
+ /** Two string lists, equal element for element. */
342
+ function sameStrings(a, b) {
343
+ return a.length === b.length && a.every((x, i) => x === b[i]);
344
+ }
305
345
  /**
306
346
  * A skill row is `{name, dir}` + a boolean `portable` (the seat-compatibility fact core requires),
307
347
  * and — codex round 2: metadata is never trusted to name paths — `name` is a legal skill name,
@@ -317,6 +357,10 @@ function isSnapshotSkillRow(row) {
317
357
  return false;
318
358
  if (typeof r.dir !== 'string' || typeof r.portable !== 'boolean')
319
359
  return false;
360
+ // `portability` (F-079) is optional — older generations lack it — but when present it must be
361
+ // well-formed and agree with `portable`; its reasons are re-derived at verify (`snapshotRowsProblem`).
362
+ if (Object.hasOwn(r, 'portability') && portabilityProblem(r.portability, r.portable) !== null)
363
+ return false;
320
364
  // EVERY field is typed (codex round 7): `kind` an enum, `core` / `nested` real booleans — and
321
365
  // `nested` IS the fact the dir spells, a row cannot claim otherwise. `kind` and `portable` are
322
366
  // re-derived from the generation's own files at verify (`snapshotRowsProblem`); `core` is
@@ -616,12 +660,12 @@ export class SkillsStore {
616
660
  throw new SkillsManifestCorruptError(path, `manifest-invalid: ${detail}`);
617
661
  };
618
662
  const record = (value, where) => typeof value === 'object' && value !== null && !Array.isArray(value) ? value : fail(`${where} is not an object`);
619
- const exactKeys = (obj, keys, where) => {
663
+ const exactKeys = (obj, keys, where, optional = []) => {
620
664
  for (const k of keys)
621
665
  if (!(k in obj))
622
666
  fail(`${where} lacks ${JSON.stringify(k)}`);
623
667
  for (const k of Object.keys(obj))
624
- if (!keys.includes(k))
668
+ if (!keys.includes(k) && !optional.includes(k))
625
669
  fail(`${where} carries an unknown key ${JSON.stringify(k)}`);
626
670
  };
627
671
  const bool = (value, where) => (typeof value === 'boolean' ? value : fail(`${where} is ${JSON.stringify(value)}, not a boolean`));
@@ -662,7 +706,9 @@ export class SkillsStore {
662
706
  }
663
707
  const where = `skills[${name}]`;
664
708
  const e = record(value, where);
665
- exactKeys(e, ['dir', 'kind', 'core', 'portable', 'enabled', 'provenance', 'editedAt', 'upgradeAvailable', 'conflict', 'upstreamDir'], where);
709
+ // `portability` (F-079) is OPTIONAL: a manifest written before 0.7.30 carries `portable` alone
710
+ // and still loads — the next recompute (any mutation, analyze, publish) fills the field in.
711
+ exactKeys(e, ['dir', 'kind', 'core', 'portable', 'enabled', 'provenance', 'editedAt', 'upgradeAvailable', 'conflict', 'upstreamDir'], where, ['portability']);
666
712
  const dir = str(e['dir'], `${where}.dir`);
667
713
  const bad = unsafeSkillDir(dir);
668
714
  if (bad !== null)
@@ -670,11 +716,12 @@ export class SkillsStore {
670
716
  const derived = derivedSkillName(dir.slice(`${SKILLS_SUBDIR}/`.length));
671
717
  if (derived !== name)
672
718
  fail(`${where} sits at ${dir}, which derives ${JSON.stringify(derived)} — the key must be the path-derived name of its dir`);
719
+ const portable = bool(e['portable'], `${where}.portable`);
673
720
  skills[name] = {
674
721
  dir,
675
722
  kind: oneOf(e['kind'], SKILL_KINDS, `${where}.kind`),
676
723
  core: bool(e['core'], `${where}.core`),
677
- portable: bool(e['portable'], `${where}.portable`),
724
+ portable,
678
725
  enabled: bool(e['enabled'], `${where}.enabled`),
679
726
  provenance: oneOf(e['provenance'], PROVENANCES, `${where}.provenance`),
680
727
  editedAt: strOrNull(e['editedAt'], `${where}.editedAt`),
@@ -682,6 +729,12 @@ export class SkillsStore {
682
729
  conflict: bool(e['conflict'], `${where}.conflict`),
683
730
  upstreamDir: strOrNull(e['upstreamDir'], `${where}.upstreamDir`),
684
731
  };
732
+ if (Object.hasOwn(e, 'portability')) {
733
+ const problem = portabilityProblem(e['portability'], portable);
734
+ if (problem !== null)
735
+ fail(`${where}.portability ${problem}`);
736
+ skills[name].portability = e['portability'];
737
+ }
685
738
  const upstreamDir = skills[name]?.upstreamDir ?? null;
686
739
  if (upstreamDir !== null) {
687
740
  const badUpstream = unsafeSkillDir(upstreamDir);
@@ -876,6 +929,11 @@ export class SkillsStore {
876
929
  const files = tree.files;
877
930
  const byRel = new Map(files.map((f) => [f.rel, f]));
878
931
  const dirs = new Set(parsed.skills.map((r) => r.dir));
932
+ // The validator's universe at verify is the generation's OWN bundle files — the views and the
933
+ // metadata file are crew's, not the plugin's — the same universe recompute judged against
934
+ // (closure support files + enabled skills' own files), so the reasons re-derive identically.
935
+ const bundle = new Set(files.map((f) => f.rel).filter((rel) => rel !== SNAPSHOT_MANIFEST_FILENAME && !rel.startsWith(`${VIEWS_DIRNAME}/`)));
936
+ const exists = (p) => existsIn(bundle, p);
879
937
  for (const row of parsed.skills) {
880
938
  const skillMd = byRel.get(`${row.dir}/SKILL.md`);
881
939
  if (skillMd === undefined)
@@ -886,7 +944,7 @@ export class SkillsStore {
886
944
  const kind = skillKindOf(fm.fields);
887
945
  if (kind !== row.kind)
888
946
  return `skill row ${row.name} claims kind ${row.kind}, but its SKILL.md derives ${kind}`;
889
- let portable = true;
947
+ const hits = [];
890
948
  const prefix = `${row.dir}/`;
891
949
  for (const f of files) {
892
950
  if (!f.rel.startsWith(prefix) || owningSkillDir(f.rel, dirs) !== row.dir)
@@ -894,13 +952,16 @@ export class SkillsStore {
894
952
  const buf = readFileNoFollow(f.abs);
895
953
  if (looksBinary(buf))
896
954
  continue;
897
- if (portabilityIssueOf(buf.toString('utf8')) !== null) {
898
- portable = false;
899
- break;
900
- }
955
+ hits.push(...portabilityIssuesOf(buf.toString('utf8'), { fileRel: f.rel, skillDir: row.dir, skillDirs: dirs, exists }));
901
956
  }
957
+ const reasons = portabilityReasonsOf(hits);
958
+ const portable = reasons.length === 0;
902
959
  if (portable !== row.portable)
903
960
  return `skill row ${row.name} claims portable: ${String(row.portable)}, but its files derive ${String(portable)}`;
961
+ // The per-reason claim (F-079) is re-derived too — a row cannot name reasons its files do not carry.
962
+ if (row.portability !== undefined && !sameStrings(row.portability.reasons, reasons)) {
963
+ return `skill row ${row.name} claims portability reasons [${row.portability.reasons.join(', ')}], but its files derive [${reasons.join(', ')}]`;
964
+ }
904
965
  }
905
966
  // The copilot view as a WHOLE tree (codex round 9): EXACTLY `views/copilot/.github/skills/<name>/…`
906
967
  // for the sorted portable rows — the files each row owns, the directories they imply — and nothing
@@ -1053,7 +1114,7 @@ export class SkillsStore {
1053
1114
  if (!Array.isArray(s.skills))
1054
1115
  return `${SNAPSHOT_MANIFEST_FILENAME} has no skills array`;
1055
1116
  if (!s.skills.every(isSnapshotSkillRow)) {
1056
- return `${SNAPSHOT_MANIFEST_FILENAME} has a skill row that is not {name: a wicked-garden-* skill name, dir: a safe relative skills/… path deriving that name, kind: ${[...SKILL_KINDS].join('|')}, core: boolean, portable: boolean, nested: what the dir spells} — metadata is never trusted to name a path, and core cannot judge seat compatibility from it`;
1117
+ return `${SNAPSHOT_MANIFEST_FILENAME} has a skill row that is not {name: a wicked-garden-* skill name, dir: a safe relative skills/… path deriving that name, kind: ${[...SKILL_KINDS].join('|')}, core: boolean, portable: boolean, portability?: {portable: the same boolean, reasons: sorted unique tokens (empty iff portable), evidence?: ≤ ${PORTABILITY_EVIDENCE_CAP} anchors}, nested: what the dir spells} — metadata is never trusted to name a path, and core cannot judge seat compatibility from it`;
1057
1118
  }
1058
1119
  if (!isSortedUniqueRows(s.skills))
1059
1120
  return `${SNAPSHOT_MANIFEST_FILENAME} skill rows are not sorted by unique name — not what publish writes`;
@@ -1729,6 +1790,8 @@ export class SkillsStore {
1729
1790
  const refused = [];
1730
1791
  const catalogMd = new Map();
1731
1792
  const byOwner = this.recordsByOwner(m);
1793
+ const skillDirs = this.ownershipDirs(m);
1794
+ const bundleView = this.publishedBundleView(m, skillDirs);
1732
1795
  for (const [name, entry] of Object.entries(m.skills)) {
1733
1796
  const records = byOwner.get(entry.dir) ?? [];
1734
1797
  const userAdded = records.length > 0 && records.every(([, r]) => r.baselineHash === null);
@@ -1767,7 +1830,10 @@ export class SkillsStore {
1767
1830
  // Judged from what is on disk as a REGULAR file reached without crossing a link: a recorded
1768
1831
  // file that vanished is drift (publish reports it; the mutation in progress must not die on
1769
1832
  // it); one that became a link, or sits behind one, is refused and reported — never read.
1770
- let portable = true;
1833
+ // EVERY reason is collected (F-079), against the bundle the next publish would carry — so
1834
+ // the snapshot row re-derives to the same reasons at verify.
1835
+ const ctx = bundleView.contextFor(entry.dir);
1836
+ const hits = [];
1771
1837
  for (const [rel, r] of records) {
1772
1838
  if (r.effectiveHash === null)
1773
1839
  continue;
@@ -1781,10 +1847,12 @@ export class SkillsStore {
1781
1847
  }
1782
1848
  if (buf === null || looksBinary(buf))
1783
1849
  continue;
1784
- if (portabilityIssueOf(buf.toString('utf8')) !== null)
1785
- portable = false;
1850
+ for (const hit of portabilityIssuesOf(buf.toString('utf8'), ctx(rel)))
1851
+ hits.push({ ...hit, fileRel: rel });
1786
1852
  }
1787
- entry.portable = portable;
1853
+ const reasons = portabilityReasonsOf(hits);
1854
+ entry.portable = reasons.length === 0;
1855
+ entry.portability = { portable: entry.portable, reasons, evidence: portabilityEvidenceOf(hits) };
1788
1856
  }
1789
1857
  // A DIRECTLY registered reference is core regardless of its readability (design v3.5 §5; codex
1790
1858
  // round 8): a skill a workflow names by `skill_ref` keeps `core: true` when its `SKILL.md` is
@@ -1797,6 +1865,51 @@ export class SkillsStore {
1797
1865
  entry.core = closure.core.has(name) || registered.has(name);
1798
1866
  return refused;
1799
1867
  }
1868
+ /**
1869
+ * The portability validator's view of the bundle (F-079): what the NEXT publish would carry —
1870
+ * support files inside the closure and ENABLED skills' own files. A disabled skill's files, a
1871
+ * file outside the closure, a vanished record: not there. Judging against this universe (rather
1872
+ * than everything under `effective/`) is what lets `snapshotRowsProblem` re-derive the SAME
1873
+ * reasons from the generation alone — a `../` link into a disabled skill is a broken link
1874
+ * (`unresolved-ref` at publish), not a portability fact about a file the snapshot omits. The
1875
+ * judged skill's own files are always visible to it (own-directory links stay portable anyway),
1876
+ * and `extra` lets a write guard see the files it is about to land.
1877
+ */
1878
+ publishedBundleView(m, skillDirs, extra = {}) {
1879
+ const enabledDirs = new Set(Object.values(m.skills).filter((e) => e.enabled).map((e) => e.dir));
1880
+ const present = Object.entries(m.files).filter(([, r]) => r.effectiveHash !== null).map(([rel]) => rel);
1881
+ const visible = new Set();
1882
+ const own = new Map();
1883
+ for (const rel of present) {
1884
+ const owner = owningSkillDir(rel, skillDirs);
1885
+ if (owner === null) {
1886
+ // EXACTLY validate's selection of support files (review of #532, F-1): an owner-less file
1887
+ // under `skills/` (a `skills/README.md`, a dir whose SKILL.md never registered) is never
1888
+ // shipped, so it must not count as existing here either — or recompute would derive a
1889
+ // reason verify cannot re-derive from the generation, and `current` would be refused.
1890
+ if (!rel.startsWith(`${SKILLS_SUBDIR}/`) && inBundleClosure(rel))
1891
+ visible.add(rel);
1892
+ continue;
1893
+ }
1894
+ if (enabledDirs.has(owner))
1895
+ visible.add(rel);
1896
+ const list = own.get(owner);
1897
+ if (list === undefined)
1898
+ own.set(owner, new Set([rel]));
1899
+ else
1900
+ list.add(rel);
1901
+ }
1902
+ for (const rel of Object.keys(extra))
1903
+ visible.add(rel);
1904
+ return {
1905
+ contextFor: (skillDir) => {
1906
+ const dirs = skillDirs.has(skillDir) ? skillDirs : new Set([...skillDirs, skillDir]);
1907
+ const mine = own.get(skillDir) ?? new Set();
1908
+ const exists = (p) => existsIn(visible, p) || existsIn(mine, p);
1909
+ return (fileRel) => ({ fileRel, skillDir, skillDirs: dirs, exists });
1910
+ },
1911
+ };
1912
+ }
1800
1913
  /** The recompute's refusals as WARNINGS — for a mutation that landed on another skill (the blocking form is publish's). */
1801
1914
  recomputeWarnings(m) {
1802
1915
  return this.recomputeDerived(m).map((f) => ({ ...f, severity: 'warning' }));
@@ -2024,6 +2137,10 @@ export class SkillsStore {
2024
2137
  if (entry.enabled)
2025
2138
  return this.result(m, name, findings);
2026
2139
  entry.enabled = true;
2140
+ // Enablement moves the bundle the validator judges against (F-079): another skill's `../`
2141
+ // link into this one now resolves. Re-derive so the manifest's `portability` stays honest
2142
+ // between publishes (refusals were reported by the recompute above).
2143
+ this.recomputeDerived(m);
2027
2144
  this.commit(m);
2028
2145
  return this.result(m, name, findings);
2029
2146
  }
@@ -2038,6 +2155,8 @@ export class SkillsStore {
2038
2155
  if (!entry.enabled)
2039
2156
  return this.result(m, name, findings);
2040
2157
  entry.enabled = false;
2158
+ // See `enable`: the disabled skill's files leave the validator's bundle view.
2159
+ this.recomputeDerived(m);
2041
2160
  this.commit(m);
2042
2161
  return this.result(m, name, findings);
2043
2162
  }
@@ -2163,7 +2282,7 @@ export class SkillsStore {
2163
2282
  catch (err) {
2164
2283
  return this.blocked(m, [this.pathFinding(err, name, rawRel)]);
2165
2284
  }
2166
- const findings = this.putGuards(name, entry, target.rel, content);
2285
+ const findings = this.putGuards(m, name, entry, target.rel, content);
2167
2286
  if (verdictOf(findings) === 'blocked')
2168
2287
  return this.blocked(m, findings);
2169
2288
  // One file, the same transaction as every multi-file swap (codex round 7): staged, the existing
@@ -2509,16 +2628,32 @@ export class SkillsStore {
2509
2628
  });
2510
2629
  return this.result(m, name, findings);
2511
2630
  }
2512
- putGuards(name, entry, rel, content) {
2631
+ putGuards(m, name, entry, rel, content) {
2513
2632
  const out = [];
2514
2633
  if (rel === 'SKILL.md')
2515
2634
  out.push(...frontmatterGuard(content, name, { isCore: entry.core, file: `${entry.dir}/SKILL.md` }));
2516
2635
  out.push(...compact([nestedSkillCreateGuard(rel, name), supportFileGuard(rel, name)]));
2517
- if (entry.portable)
2518
- out.push(...compact([nonPortableGuard({ [rel]: content }, name)]));
2636
+ // Warn on every reason this write INTRODUCES — one the skill does not already carry (review
2637
+ // of #532, F-7): an author fixing a non-portable skill file by file still hears about a new
2638
+ // reason, while a reason the skill already has is not repeated on every edit. An entry with
2639
+ // no `portability` yet (an older manifest) knows no reasons, so everything is new.
2640
+ const known = new Set(entry.portability?.reasons ?? []);
2641
+ out.push(...nonPortableGuard({ [rel]: content }, name, this.writeContext(m, entry.dir, { [rel]: content })).filter((f) => f.portabilityReason === undefined || !known.has(f.portabilityReason)));
2519
2642
  return out;
2520
2643
  }
2521
- filesGuards(name, files) {
2644
+ /**
2645
+ * The portability validator's context for a write of `files` (skill-relative keys) into
2646
+ * `skillDir`: the would-be bundle plus the files about to land, keyed by the skill-relative
2647
+ * path the guard iterates.
2648
+ */
2649
+ writeContext(m, skillDir, files) {
2650
+ const extra = {};
2651
+ for (const [rel, content] of Object.entries(files))
2652
+ extra[`${skillDir}/${rel}`] = content;
2653
+ const ctx = this.publishedBundleView(m, this.ownershipDirs(m), extra).contextFor(skillDir);
2654
+ return (rel) => ctx(`${skillDir}/${rel}`);
2655
+ }
2656
+ filesGuards(m, name, dir, files) {
2522
2657
  const out = [];
2523
2658
  const keys = Object.keys(files).sort();
2524
2659
  for (const rel of keys) {
@@ -2553,7 +2688,7 @@ export class SkillsStore {
2553
2688
  continue;
2554
2689
  out.push(finding('path-invalid', 'blocking', 'a files map lays each key out as a regular file, so a key cannot also be a directory on the way to another key — the two cannot both exist on disk', `${JSON.stringify(rel)} is a file AND a directory prefix of ${JSON.stringify(under)}`, { skill: name, file: rel }));
2555
2690
  }
2556
- out.push(...compact([nonPortableGuard(files, name)]));
2691
+ out.push(...nonPortableGuard(files, name, this.writeContext(m, dir, files)));
2557
2692
  return out;
2558
2693
  }
2559
2694
  addGuards(m, name, files) {
@@ -2565,12 +2700,12 @@ export class SkillsStore {
2565
2700
  if (existsSync(this.pluginPath(dir)) || this.hasBaselineDir(m, dir)) {
2566
2701
  out.push(finding('name-collision', 'blocking', 'a directory already exists where this skill would land', `${dir} exists`, { skill: name }));
2567
2702
  }
2568
- out.push(...this.filesGuards(name, files));
2703
+ out.push(...this.filesGuards(m, name, dir, files));
2569
2704
  out.push(...frontmatterGuard(files['SKILL.md'], name, { isCore: this.registeredRefs().has(name), file: `${dir}/SKILL.md` }));
2570
2705
  return out;
2571
2706
  }
2572
2707
  replaceGuards(m, name, entry, files) {
2573
- const out = this.filesGuards(name, files);
2708
+ const out = this.filesGuards(m, name, entry.dir, files);
2574
2709
  const nestedUnder = [...this.ownershipDirs(m)].filter((d) => d !== entry.dir && d.startsWith(`${entry.dir}/`));
2575
2710
  for (const rel of Object.keys(files)) {
2576
2711
  const owner = owningSkillDir(`${entry.dir}/${rel}`, new Set([entry.dir, ...nestedUnder]));
@@ -3047,6 +3182,9 @@ export class SkillsStore {
3047
3182
  kind: entry.kind,
3048
3183
  core: entry.core,
3049
3184
  portable: entry.portable,
3185
+ // `validate` recomputed every entry (F-079), so the per-reason claim is fresh here; the
3186
+ // fallback only guards the type — a row is never written without `portability`.
3187
+ portability: entry.portability ?? { portable: entry.portable, reasons: [], evidence: [] },
3050
3188
  nested: isNestedSkillDir(entry.dir),
3051
3189
  }))
3052
3190
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),