wicked-crew 0.8.2 → 0.8.4

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 (76) hide show
  1. package/dist/api/auth.d.ts +5 -1
  2. package/dist/api/auth.d.ts.map +1 -1
  3. package/dist/api/auth.js +23 -0
  4. package/dist/api/auth.js.map +1 -1
  5. package/dist/api/governance-health.d.ts.map +1 -1
  6. package/dist/api/governance-health.js +9 -6
  7. package/dist/api/governance-health.js.map +1 -1
  8. package/dist/api/mcp.d.ts.map +1 -1
  9. package/dist/api/mcp.js +6 -1
  10. package/dist/api/mcp.js.map +1 -1
  11. package/dist/api/routes.d.ts.map +1 -1
  12. package/dist/api/routes.js +5 -2
  13. package/dist/api/routes.js.map +1 -1
  14. package/dist/api/server.d.ts +29 -0
  15. package/dist/api/server.d.ts.map +1 -1
  16. package/dist/api/server.js +67 -9
  17. package/dist/api/server.js.map +1 -1
  18. package/dist/api/skills.d.ts.map +1 -1
  19. package/dist/api/skills.js +16 -8
  20. package/dist/api/skills.js.map +1 -1
  21. package/dist/api/stall-watchdog.d.ts +10 -2
  22. package/dist/api/stall-watchdog.d.ts.map +1 -1
  23. package/dist/api/stall-watchdog.js +32 -13
  24. package/dist/api/stall-watchdog.js.map +1 -1
  25. package/dist/cli/governance.d.ts +7 -0
  26. package/dist/cli/governance.d.ts.map +1 -1
  27. package/dist/cli/governance.js +14 -1
  28. package/dist/cli/governance.js.map +1 -1
  29. package/dist/cli/index.d.ts.map +1 -1
  30. package/dist/cli/index.js +27 -4
  31. package/dist/cli/index.js.map +1 -1
  32. package/dist/core/governance-store.d.ts +9 -0
  33. package/dist/core/governance-store.d.ts.map +1 -1
  34. package/dist/core/governance-store.js +13 -0
  35. package/dist/core/governance-store.js.map +1 -1
  36. package/dist/core/run-boot-resume.d.ts +55 -0
  37. package/dist/core/run-boot-resume.d.ts.map +1 -0
  38. package/dist/core/run-boot-resume.js +94 -0
  39. package/dist/core/run-boot-resume.js.map +1 -0
  40. package/dist/interactive/doc-grounding.d.ts +37 -0
  41. package/dist/interactive/doc-grounding.d.ts.map +1 -1
  42. package/dist/interactive/doc-grounding.js +63 -0
  43. package/dist/interactive/doc-grounding.js.map +1 -1
  44. package/dist/interactive/doc-list-routes.d.ts +5 -0
  45. package/dist/interactive/doc-list-routes.d.ts.map +1 -1
  46. package/dist/interactive/doc-list-routes.js +7 -1
  47. package/dist/interactive/doc-list-routes.js.map +1 -1
  48. package/dist/interactive/draft-events.d.ts +4 -1
  49. package/dist/interactive/draft-events.d.ts.map +1 -1
  50. package/dist/interactive/draft-events.js +15 -3
  51. package/dist/interactive/draft-events.js.map +1 -1
  52. package/dist/skills/live-generations.d.ts +16 -11
  53. package/dist/skills/live-generations.d.ts.map +1 -1
  54. package/dist/skills/live-generations.js +28 -14
  55. package/dist/skills/live-generations.js.map +1 -1
  56. package/dist/skills/runtime.d.ts +13 -2
  57. package/dist/skills/runtime.d.ts.map +1 -1
  58. package/dist/skills/runtime.js +32 -5
  59. package/dist/skills/runtime.js.map +1 -1
  60. package/dist/skills/store.d.ts +73 -5
  61. package/dist/skills/store.d.ts.map +1 -1
  62. package/dist/skills/store.js +179 -42
  63. package/dist/skills/store.js.map +1 -1
  64. package/dist/skills/tree.d.ts +39 -0
  65. package/dist/skills/tree.d.ts.map +1 -1
  66. package/dist/skills/tree.js +102 -13
  67. package/dist/skills/tree.js.map +1 -1
  68. package/dist/studio/assets/{index-DRybGZtf.js → index-B-ce66JW.js} +123 -122
  69. package/dist/studio/assets/index-TvS52qj5.css +32 -0
  70. package/dist/studio/editors/wicked-page/editor.json +1 -1
  71. package/dist/studio/editors/wicked-page/index.html +1 -1
  72. package/dist/studio/index.html +2 -2
  73. package/dist/studio/testid-inventory.json +259 -6
  74. package/endpoint-manifest.json +1 -1
  75. package/package.json +4 -4
  76. package/dist/studio/assets/index-CGjbTpLl.css +0 -32
@@ -144,7 +144,7 @@ import { LiveGenerations } from './live-generations.js';
144
144
  import { discoverLivePluginDetailed, GARDEN_INSTALL_COMMAND, gitStateOf, PluginSourceSymlinkError, REQUIRED_GARDEN_VERSION, } from './plugin-source.js';
145
145
  import { existsIn, extractPluginRootRefs, extractRelativeRefs, looksBinary, PORTABILITY_EVIDENCE_CAP, PORTABILITY_REASONS, PORTABILITY_RULES_IDENTITY, portabilityEvidenceOf, portabilityIssuesOf, portabilityReasonsOf, resolvePluginRootRef, resolveRelativeRef, } from './refs.js';
146
146
  import { CURRENT_TMP_PREFIX, STAGING_PREFIX } from './root-names.js';
147
- import { assertNoSymlinkComponents, copyFiles, EntrySwappedError, fingerprintTree, hashFileSet, hashTree, impliedDirs, isCarriedFile, makeTreeReadOnly, pruneEmptyDirs, readFileNoFollow, removeTreeForce, sha256Hex, SKIP_DIR_NAMES, SymlinkComponentError, walkEntries, walkFiles, walkTree, writeFileAtomic, } from './tree.js';
147
+ import { assertNoSymlinkComponents, budgetPacer, copyFiles, copyFilesSteps, drain, EntrySwappedError, fingerprintTreeSteps, hashFileSet, hashTreeSteps, impliedDirs, isCarriedFile, makeTreeReadOnly, makeTreeReadOnlySteps, paced, pruneEmptyDirs, readFileNoFollow, removeTreeForce, removeTreeForcePaced, sha256Hex, SKIP_DIR_NAMES, SymlinkComponentError, walkEntriesSteps, walkFiles, walkTree, walkTreeSteps, writeFileAtomic, } from './tree.js';
148
148
  /** 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. */
149
149
  export { CURRENT_TMP_PREFIX } from './root-names.js';
150
150
  import { baselineVenvDir, UV_CACHE_DIRNAME, VENV_READY_MARKER } from './venv.js';
@@ -512,8 +512,17 @@ export class SkillsStore {
512
512
  live = new LiveGenerations();
513
513
  /** The one publish that may run at a time (module header) — `null` when none is in flight. */
514
514
  publishInFlight = null;
515
+ makePacer;
515
516
  /** One provisioning per baseline hash: a concurrent caller awaits the in-flight one. */
516
517
  venvInFlight = new Map();
518
+ /**
519
+ * Where the environment seconds went since this store was built (crew#741): the ms the
520
+ * provisioner path ran (torn-env removal, `uv sync`, marker, lock, verify) and how many times it
521
+ * reached the provisioner. A boot over a baseline whose env already carries the verified ready
522
+ * marker reads `0 / 0` — `ensureVenv`'s fast path never gets here. Read by the daemon's boot
523
+ * stage line (`api/server.ts`), never persisted.
524
+ */
525
+ venvStageTotals = { ms: 0, provisions: 0 };
517
526
  /** The canonical (realpath) identity of the root the store BOUND — recorded the first time the
518
527
  * root is seen as a real directory (boot / seed), compared on every operation for the store's
519
528
  * whole lifetime (the root is never re-aimed: a new root is a new store). */
@@ -546,6 +555,7 @@ export class SkillsStore {
546
555
  });
547
556
  this.now = opts.now ?? (() => new Date().toISOString());
548
557
  this.warn = opts.warn ?? ((m) => console.warn(m));
558
+ this.makePacer = opts.pacer ?? (() => budgetPacer());
549
559
  }
550
560
  // ── Paths ─────────────────────────────────────────────────────────────────────────────────
551
561
  get root() {
@@ -555,6 +565,13 @@ export class SkillsStore {
555
565
  isPublishing() {
556
566
  return this.publishInFlight !== null;
557
567
  }
568
+ /**
569
+ * The `venv` boot stage (crew#741): provisioner time and count since construction. `{ ms: 0,
570
+ * provisions: 0 }` is the proof that a boot on an unchanged baseline re-ran no `uv sync`.
571
+ */
572
+ venvStage() {
573
+ return { ms: Math.round(this.venvStageTotals.ms), provisions: this.venvStageTotals.provisions };
574
+ }
558
575
  effectiveDir() {
559
576
  return join(this.rootDir, EFFECTIVE_DIRNAME);
560
577
  }
@@ -836,6 +853,18 @@ export class SkillsStore {
836
853
  if (expected !== m.revision)
837
854
  throw new RevisionMismatchError(expected, m.revision);
838
855
  }
856
+ /**
857
+ * A MUTATION while a publish runs is refused before it touches anything (crew#852): the publish
858
+ * now yields to the event loop between tree steps, so an API write could otherwise commit between
859
+ * its validation and its commit — the publish would then either fail its own CAS or stage content
860
+ * its manifest no longer describes. Same envelope as a second publish (`publish-in-flight`, 2xx,
861
+ * nothing written — re-read and retry); before the pacing a write in that window queued behind
862
+ * the blocked loop and failed its CAS after the publish landed. Reads are never refused.
863
+ */
864
+ refuseDuringPublish() {
865
+ if (this.publishInFlight !== null)
866
+ throw new SkillsPublishInFlightError(this.revision());
867
+ }
839
868
  // ── `current` ─────────────────────────────────────────────────────────────────────────────
840
869
  /**
841
870
  * The published snapshot `current` resolves to, VERIFIED (module header), or `null` when there
@@ -846,6 +875,18 @@ export class SkillsStore {
846
875
  * re-derived each time it is handed out.
847
876
  */
848
877
  currentSnapshot() {
878
+ return drain(this.currentSnapshotSteps());
879
+ }
880
+ /**
881
+ * `currentSnapshot()` on the event loop (crew#852): the same verification, step for step, with
882
+ * its walk, fingerprint and hash paced so a request landing mid-verification is answered within
883
+ * one slice instead of after the whole generation. The route layer's publish and `GET /skills`
884
+ * drive this one; boot and the synchronous readers keep `currentSnapshot()`.
885
+ */
886
+ async currentSnapshotPaced(pacer) {
887
+ return paced(this.currentSnapshotSteps(), pacer);
888
+ }
889
+ *currentSnapshotSteps() {
849
890
  this.assertRootIdentity(); // never answered through a root that changed identity (codex round 4)
850
891
  const link = this.currentLink();
851
892
  let target;
@@ -859,7 +900,7 @@ export class SkillsStore {
859
900
  throw new SkillsCurrentInvalidError(link, 'it is not a symbolic link');
860
901
  throw err;
861
902
  }
862
- return this.verifyCurrent(link, target);
903
+ return yield* this.verifyCurrentSteps(link, target);
863
904
  }
864
905
  /**
865
906
  * The skill rows of the VERIFIED current generation — what the engine's intake admission judges
@@ -872,12 +913,16 @@ export class SkillsStore {
872
913
  const current = this.currentSnapshot();
873
914
  if (current === null)
874
915
  return null;
916
+ return this.snapshotSkillsOf(current);
917
+ }
918
+ /** `currentSnapshotSkills()` for a generation the caller holds VERIFIED already (the ladder's own, just exported) — the metadata read alone, no re-walk (crew#852). */
919
+ snapshotSkillsOf(current) {
875
920
  const meta = this.readSnapshotMetadata(current.path);
876
921
  if (typeof meta === 'string')
877
922
  return null;
878
923
  return { gen: current.gen, skills: meta.parsed.skills.map((row) => row.name) };
879
924
  }
880
- verifyCurrent(link, target) {
925
+ *verifyCurrentSteps(link, target) {
881
926
  const invalid = (detail) => {
882
927
  throw new SkillsCurrentInvalidError(link, detail);
883
928
  };
@@ -958,7 +1003,7 @@ export class SkillsStore {
958
1003
  // moves during verification pairs the verified result with a fingerprint that no longer
959
1004
  // matches, so the next read re-verifies) and once more AFTER it; the result is memoised only
960
1005
  // when both agree.
961
- const fingerprint = fingerprintTree(real);
1006
+ const fingerprint = yield* fingerprintTreeSteps(real);
962
1007
  const memo = this.verifiedCurrent;
963
1008
  if (memo !== null &&
964
1009
  fingerprint !== null &&
@@ -974,7 +1019,7 @@ export class SkillsStore {
974
1019
  return invalid(linkProblem);
975
1020
  return memo.result;
976
1021
  }
977
- const tree = walkTree(real);
1022
+ const tree = yield* walkTreeSteps(real);
978
1023
  const special = tree.others[0];
979
1024
  if (special !== undefined)
980
1025
  return invalid(`${special.rel} is neither a file, a directory nor a symlink — a published generation carries no special nodes`);
@@ -986,7 +1031,7 @@ export class SkillsStore {
986
1031
  if (prunedDir !== undefined) {
987
1032
  return invalid(`unexpected directory ${prunedDir} — a published generation never carries a ${posix.basename(prunedDir)} directory (publish copies no pruned directory; the environment is the .venv link at the generation root): the immutable snapshot was modified`);
988
1033
  }
989
- const hash = this.snapshotHash(tree);
1034
+ const hash = yield* this.snapshotHashSteps(tree);
990
1035
  if (hash !== parsed.contentHash) {
991
1036
  return invalid(`content hash mismatch — snapshot.json records ${parsed.contentHash}, the tree hashes ${hash}: the immutable snapshot was modified`);
992
1037
  }
@@ -999,14 +1044,14 @@ export class SkillsStore {
999
1044
  const running = { ...PORTABILITY_RULES_IDENTITY };
1000
1045
  const recorded = parsed.rulesVersion === undefined || parsed.rulesSha256 === undefined ? null : { version: parsed.rulesVersion, sha256: parsed.rulesSha256 };
1001
1046
  const stale = recorded === null || recorded.version !== running.version || recorded.sha256 !== running.sha256;
1002
- const rows = this.snapshotRowsProblem(parsed, tree, stale);
1047
+ const rows = yield* this.snapshotRowsProblemSteps(parsed, tree, stale);
1003
1048
  if ('problem' in rows)
1004
1049
  return invalid(rows.problem);
1005
1050
  const linkProblem = this.snapshotLinkProblem(real, tree.links, parsed);
1006
1051
  if (linkProblem !== null)
1007
1052
  return invalid(linkProblem);
1008
1053
  const result = { gen: parsed.gen, path: real, rules: { recorded, running, stale }, drift: rows.drift };
1009
- const after = fingerprintTree(real);
1054
+ const after = yield* fingerprintTreeSteps(real);
1010
1055
  this.verifiedCurrent =
1011
1056
  fingerprint !== null && after === fingerprint
1012
1057
  ? { gen: parsed.gen, contentHash: parsed.contentHash, snapshotHash: meta.rawSha, fingerprint, links: [...tree.links], result }
@@ -1030,7 +1075,7 @@ export class SkillsStore {
1030
1075
  * else (a missing SKILL.md, a kind claim, the view shape against the RECORDED rows) is judged the
1031
1076
  * same under any rules: none of it depends on the rule table.
1032
1077
  */
1033
- snapshotRowsProblem(parsed, tree, staleRules) {
1078
+ *snapshotRowsProblemSteps(parsed, tree, staleRules) {
1034
1079
  const problem = (detail) => ({ problem: detail });
1035
1080
  const drift = [];
1036
1081
  const files = tree.files;
@@ -1046,6 +1091,7 @@ export class SkillsStore {
1046
1091
  if (skillMd === undefined)
1047
1092
  return problem(`skill row ${row.name} names ${row.dir}, but the generation carries no ${row.dir}/SKILL.md`);
1048
1093
  const fm = parseFrontmatter(readFileNoFollow(skillMd.abs).toString('utf8'));
1094
+ yield;
1049
1095
  if (!fm.ok)
1050
1096
  return problem(`${row.dir}/SKILL.md frontmatter does not parse (${fm.reason}) — its row cannot be re-derived`);
1051
1097
  const kind = skillKindOf(fm.fields);
@@ -1057,6 +1103,7 @@ export class SkillsStore {
1057
1103
  if (!f.rel.startsWith(prefix) || owningSkillDir(f.rel, dirs) !== row.dir)
1058
1104
  continue;
1059
1105
  const buf = readFileNoFollow(f.abs);
1106
+ yield;
1060
1107
  if (looksBinary(buf))
1061
1108
  continue;
1062
1109
  for (const hit of portabilityIssuesOf(buf.toString('utf8'), { fileRel: f.rel, skillDir: row.dir, skillDirs: dirs, exists }))
@@ -1117,7 +1164,10 @@ export class SkillsStore {
1117
1164
  }
1118
1165
  /** Hash over a snapshot tree — files (`snapshot.json` excluded), link entries (path + link text) AND directory entries (codex round 9: an extra empty directory changes it). */
1119
1166
  snapshotHash(tree) {
1120
- return hashTree(tree.files.filter((f) => f.rel !== SNAPSHOT_MANIFEST_FILENAME), tree.links, tree.dirs);
1167
+ return drain(this.snapshotHashSteps(tree));
1168
+ }
1169
+ *snapshotHashSteps(tree) {
1170
+ return yield* hashTreeSteps(tree.files.filter((f) => f.rel !== SNAPSHOT_MANIFEST_FILENAME), tree.links, tree.dirs);
1121
1171
  }
1122
1172
  /** The link text publish writes for a snapshot's `.venv`: relative on POSIX, the absolute target for a Windows junction. */
1123
1173
  venvLinkText(baseline) {
@@ -1428,6 +1478,10 @@ export class SkillsStore {
1428
1478
  * covers it, and no other pruned name is linked from anywhere.
1429
1479
  */
1430
1480
  baselineProblem(hash) {
1481
+ return drain(this.baselineProblemSteps(hash));
1482
+ }
1483
+ /** `baselineProblem` as steps (crew#852): it walks AND re-hashes the whole bundle — the walk's yields, then one per file hashed. */
1484
+ *baselineProblemSteps(hash) {
1431
1485
  const dir = this.baselineDir(hash);
1432
1486
  const st = lstatOrNull(dir);
1433
1487
  if (st === null)
@@ -1438,7 +1492,7 @@ export class SkillsStore {
1438
1492
  return `${dir} is not a directory`;
1439
1493
  let tree;
1440
1494
  try {
1441
- tree = walkTree(dir);
1495
+ tree = yield* walkTreeSteps(dir);
1442
1496
  }
1443
1497
  catch (err) {
1444
1498
  return `${dir} cannot be walked (${err instanceof Error ? err.message : String(err)})`;
@@ -1449,7 +1503,7 @@ export class SkillsStore {
1449
1503
  const special = tree.others[0];
1450
1504
  if (special !== undefined)
1451
1505
  return `${dir} carries ${special.rel}, which is neither a file nor a directory`;
1452
- const actual = hashFileSet(tree.files);
1506
+ const actual = yield* hashTreeSteps(tree.files, []);
1453
1507
  if (actual !== hash)
1454
1508
  return `${dir} hashes to ${actual}, not to its name — a bundle file was modified, added or removed`;
1455
1509
  return null;
@@ -1596,11 +1650,13 @@ export class SkillsStore {
1596
1650
  const inFlight = this.venvInFlight.get(hash);
1597
1651
  if (inFlight !== undefined)
1598
1652
  return inFlight;
1653
+ const tStage = performance.now();
1599
1654
  const run = (async () => {
1600
1655
  if (this.entryExists(venvDir)) {
1601
1656
  this.warn(`[skills] ${venvDir} exists without a verified ready marker (a torn earlier sync or an unlockable env) — removed and re-provisioned`);
1602
1657
  removeTreeForce(venvDir);
1603
1658
  }
1659
+ this.venvStageTotals.provisions += 1;
1604
1660
  const state = await this.provisionVenv(baselineDir, { log: this.warn, cacheDir });
1605
1661
  if (state !== 'synced')
1606
1662
  return state;
@@ -1633,6 +1689,7 @@ export class SkillsStore {
1633
1689
  })();
1634
1690
  const tracked = run.finally(() => {
1635
1691
  this.venvInFlight.delete(hash);
1692
+ this.venvStageTotals.ms += performance.now() - tStage;
1636
1693
  });
1637
1694
  this.venvInFlight.set(hash, tracked);
1638
1695
  return tracked;
@@ -1756,6 +1813,10 @@ export class SkillsStore {
1756
1813
  // ── Scanning ──────────────────────────────────────────────────────────────────────────────
1757
1814
  /** Every managed file under `effective/`, hashed. */
1758
1815
  scanEffective() {
1816
+ return drain(this.scanEffectiveSteps());
1817
+ }
1818
+ /** `scanEffective` as steps (crew#852): the walk's yields, then one per file hashed. */
1819
+ *scanEffectiveSteps() {
1759
1820
  this.assertRootIdentity();
1760
1821
  // ONE walker classifies every entry (tree.ts `walkEntries`; codex round 9): the files are hashed,
1761
1822
  // the links and special nodes are handed to the validation to refuse by name, the directories are
@@ -1764,9 +1825,16 @@ export class SkillsStore {
1764
1825
  // `node_modules/`, `.venv/` or `__pycache__/` is in `links` / `others` (marked `pruned`) for the
1765
1826
  // validation to refuse; the files and directories beneath one stay out of the scan — never
1766
1827
  // carried, never hashed, exactly as before.
1767
- const entries = walkEntries(this.effectiveDir());
1828
+ const entries = yield* walkEntriesSteps(this.effectiveDir());
1829
+ const files = [];
1830
+ for (const e of entries) {
1831
+ if (!isCarriedFile(e))
1832
+ continue;
1833
+ files.push({ rel: e.rel, abs: e.abs, sha: sha256Hex(readFileNoFollow(e.abs)) });
1834
+ yield;
1835
+ }
1768
1836
  return {
1769
- files: entries.filter(isCarriedFile).map((e) => ({ rel: e.rel, abs: e.abs, sha: sha256Hex(readFileNoFollow(e.abs)) })),
1837
+ files,
1770
1838
  links: entries.filter((e) => e.kind === 'symlink'),
1771
1839
  others: entries.filter((e) => e.kind === 'other'),
1772
1840
  dirs: entries.filter((e) => e.kind === 'dir' && e.pruned === null).map((e) => e.rel),
@@ -1920,6 +1988,10 @@ export class SkillsStore {
1920
1988
  * blocking; a mutation on ANOTHER skill carries it as a warning (that mutation did land).
1921
1989
  */
1922
1990
  recomputeDerived(m) {
1991
+ return drain(this.recomputeDerivedSteps(m));
1992
+ }
1993
+ /** `recomputeDerived` as steps (crew#852): it reads every file of every skill for the portability scan — one `yield` per file read, one per skill. */
1994
+ *recomputeDerivedSteps(m) {
1923
1995
  const refused = [];
1924
1996
  const catalogMd = new Map();
1925
1997
  const byOwner = this.recordsByOwner(m);
@@ -1978,6 +2050,7 @@ export class SkillsStore {
1978
2050
  refused.push(this.pathFinding(err, name, rel));
1979
2051
  continue;
1980
2052
  }
2053
+ yield;
1981
2054
  if (buf === null || looksBinary(buf))
1982
2055
  continue;
1983
2056
  for (const hit of portabilityIssuesOf(buf.toString('utf8'), ctx(rel)))
@@ -1986,6 +2059,7 @@ export class SkillsStore {
1986
2059
  const reasons = portabilityReasonsOf(hits);
1987
2060
  entry.portable = reasons.length === 0;
1988
2061
  entry.portability = { portable: entry.portable, reasons, evidence: portabilityEvidenceOf(hits) };
2062
+ yield;
1989
2063
  }
1990
2064
  // A DIRECTLY registered reference is core regardless of its readability (design v3.5 §5; codex
1991
2065
  // round 8): a skill a workflow names by `skill_ref` keeps `core: true` when its `SKILL.md` is
@@ -2272,6 +2346,7 @@ export class SkillsStore {
2272
2346
  const entry = m.skills[name];
2273
2347
  if (entry === undefined)
2274
2348
  throw new UnknownSkillError(name);
2349
+ this.refuseDuringPublish();
2275
2350
  this.assertRevision(m, expectedRevision);
2276
2351
  return entry;
2277
2352
  }
@@ -2489,6 +2564,7 @@ export class SkillsStore {
2489
2564
  /** Write one root support file (`scripts/`, `schemas/`, `.claude-plugin/`, …) — always a warning. */
2490
2565
  writeSupport(rawRel, content, expectedRevision) {
2491
2566
  const m = this.manifest();
2567
+ this.refuseDuringPublish();
2492
2568
  this.assertRevision(m, expectedRevision);
2493
2569
  let target;
2494
2570
  try {
@@ -2724,6 +2800,7 @@ export class SkillsStore {
2724
2800
  /** Add a user skill at `skills/<name minus the prefix>` — staged, then swapped in (`swapOwnFiles`). */
2725
2801
  add(name, files, expectedRevision) {
2726
2802
  const m = this.manifest();
2803
+ this.refuseDuringPublish();
2727
2804
  this.assertRevision(m, expectedRevision);
2728
2805
  const findings = this.addGuards(m, name, files);
2729
2806
  if (verdictOf(findings) === 'blocked')
@@ -2902,6 +2979,7 @@ export class SkillsStore {
2902
2979
  */
2903
2980
  refreshBaseline(expectedRevision) {
2904
2981
  const m = this.manifest();
2982
+ this.refuseDuringPublish();
2905
2983
  this.assertRevision(m, expectedRevision);
2906
2984
  const source = this.requireSource('no installed wicked-garden plugin found to refresh from (neither the marketplace cache nor the installer-managed copy)');
2907
2985
  const previous = m.baseline;
@@ -3231,18 +3309,34 @@ export class SkillsStore {
3231
3309
  const v = this.validate(m);
3232
3310
  return { verdict: verdictOf(v.findings), findings: v.findings, revision: m.revision };
3233
3311
  }
3312
+ /** `analyze()` on the event loop (crew#852): the same whole-tree validation, its scan paced. Nothing is written either way. */
3313
+ async analyzePaced(pacer) {
3314
+ const m = this.manifest();
3315
+ const v = await paced(this.validateSteps(m), pacer);
3316
+ return { verdict: verdictOf(v.findings), findings: v.findings, revision: m.revision };
3317
+ }
3234
3318
  /**
3235
3319
  * ONE publish at a time (module header): a concurrent call is refused with
3236
3320
  * `SkillsPublishInFlightError` (the route's 409) rather than queued — the caller's revision would
3237
3321
  * be stale by the time a queued publish ran.
3238
3322
  */
3239
- async publish(expectedRevision) {
3323
+ async publish(expectedRevision, afterCommit) {
3240
3324
  if (this.publishInFlight !== null)
3241
3325
  throw new SkillsPublishInFlightError(this.revision());
3242
- const run = this.publishSerialized(expectedRevision);
3243
- this.publishInFlight = run;
3326
+ const pacer = this.makePacer();
3327
+ const run = this.publishSerialized(expectedRevision, pacer);
3328
+ // `afterCommit` (the route's export: verifying the new generation and handing it to the engine)
3329
+ // runs INSIDE the in-flight window (codex r1 on #853): it is paced, so the loop turns while it
3330
+ // verifies, and a second publish admitted in that window could commit a newer generation that
3331
+ // the first request would then export over. Held here, that publish is `publish-in-flight`
3332
+ // and `isPublishing()` is true for the whole request.
3333
+ const whole = afterCommit === undefined ? run : run.then(async (result) => {
3334
+ await afterCommit(result, pacer);
3335
+ return result;
3336
+ });
3337
+ this.publishInFlight = whole;
3244
3338
  try {
3245
- return await run;
3339
+ return await whole;
3246
3340
  }
3247
3341
  finally {
3248
3342
  this.publishInFlight = null;
@@ -3272,9 +3366,20 @@ export class SkillsStore {
3272
3366
  * `snapshots/<gen>/` (staging + rename, never over an existing generation) with the generated
3273
3367
  * views, LOCK it read-only, COMMIT the manifest (venv state included), flip `current`, and reap
3274
3368
  * generations / baselines nothing references. A `blocked` verdict persists nothing — the caller's
3275
- * revision stays valid. Everything after the one await is synchronous: no interleaving.
3369
+ * revision stays valid.
3370
+ *
3371
+ * Nothing here holds the event loop for longer than one slice (crew#852): every walk, hash, copy
3372
+ * and lock over the tree runs as PACED steps (tree.ts `paced` / `budgetPacer`), so `/health`, the
3373
+ * run listing and the stall watchdog keep answering while a 15k-file catalogue publishes. The
3374
+ * loop therefore turns many times between the CAS and the commit — a read in that window sees the
3375
+ * previous generation and the manifest as it was (nothing is written before the commit); a WRITE
3376
+ * that commits in that window moves the revision, and the commit below re-checks the root identity
3377
+ * and the revision first: a moved revision removes the staged generation (no manifest, no
3378
+ * generation) and fails the caller's CAS exactly as a stale `expectedRevision` does. The
3379
+ * published-generation semantics are unchanged: `gen` increments once, the manifest lands before
3380
+ * `current` flips, a reader sees the old generation or the new one, never half of either.
3276
3381
  */
3277
- async publishSerialized(expectedRevision) {
3382
+ async publishSerialized(expectedRevision, pacer) {
3278
3383
  // Refuse a root that changed identity (codex round 4) or a symlinked storage ancestor (codex
3279
3384
  // round 3) BEFORE anything is provisioned, staged or copied: a `snapshots -> /outside` would
3280
3385
  // take the first copy out of the store.
@@ -3293,7 +3398,7 @@ export class SkillsStore {
3293
3398
  // The baseline must be the bundle its name claims BEFORE anything is provisioned in it (codex
3294
3399
  // round 7): a corrupt baseline is `baseline-corrupt`, blocking — nothing provisioned, nothing
3295
3400
  // written. `validate` re-derives the same hash AFTER the provisioner ran.
3296
- const preProblem = this.baselineProblem(pre.baseline);
3401
+ const preProblem = await paced(this.baselineProblemSteps(pre.baseline), pacer);
3297
3402
  if (preProblem !== null) {
3298
3403
  return { verdict: 'blocked', findings: [this.baselineCorruptFinding(null, `${BASELINE_DIRNAME}/${pre.baseline}`, preProblem)], revision: pre.revision, snapshot: null };
3299
3404
  }
@@ -3308,13 +3413,17 @@ export class SkillsStore {
3308
3413
  // The `unchanged` fast path (crew#547 items 1-2 / DES-L6 PR-L6-1 (b)): BEFORE the slow step, the
3309
3414
  // SAME content hash the slow path would compute is derived from the effective tree; equal to the
3310
3415
  // current generation's — and that generation still verifies under the running rules — means
3311
- // there is nothing to publish: nothing awaited, nothing written, no generation minted.
3416
+ // there is nothing to publish: nothing written, no generation minted. The steps are paced
3417
+ // (crew#852), so the `unchanged` answer is re-checked against the revision on disk: a write that
3418
+ // landed while the tree was hashed makes the caller's revision stale, exactly as before the check.
3312
3419
  const venvDir0 = baselineVenvDir(this.baselineDir(pre.baseline));
3313
3420
  const ready0 = this.venvReady(venvDir0);
3314
- const fast = this.unchangedPublish(pre, ready0);
3421
+ const fast = await paced(this.unchangedPublishSteps(pre, ready0), pacer);
3315
3422
  t.validate += fast.validateMs;
3316
3423
  t.hash += fast.hashMs;
3317
3424
  if (fast.result !== null) {
3425
+ this.assertRootUnchanged(bound, expectedRevision);
3426
+ this.assertRevision(this.manifest(), expectedRevision);
3318
3427
  timing(ready0 ? 'ready' : 'skipped', ` · unchanged (gen ${fast.result.snapshot?.gen ?? '?'})`);
3319
3428
  return fast.result;
3320
3429
  }
@@ -3328,7 +3437,7 @@ export class SkillsStore {
3328
3437
  const m = this.manifest();
3329
3438
  this.assertRevision(m, expectedRevision);
3330
3439
  const tValidate = performance.now();
3331
- const v = this.validate(m);
3440
+ const v = await paced(this.validateSteps(m), pacer);
3332
3441
  t.validate += performance.now() - tValidate; // validated twice on a CHANGED publish (accepted; the line shows the cost)
3333
3442
  if (venv === 'failed') {
3334
3443
  v.findings.push(finding('venv-failed', 'blocking', "the baseline's shared read-only Python environment is REQUIRED (the bundle carries a pyproject.toml; skills `uv run` from the plugin root): a snapshot without it hands every worker a plugin whose scripts cannot start. Install uv, fix the sync error the log names, and publish again", `uv sync did not produce ${baselineVenvDir(this.baselineDir(m.baseline))} (see the daemon log)`, {}));
@@ -3351,14 +3460,14 @@ export class SkillsStore {
3351
3460
  // later verification hash the directories they WALK, so an extra directory — empty or not — is a
3352
3461
  // mismatch, never an invisible passenger.
3353
3462
  const tHash = performance.now();
3354
- const contentHash = hashTree(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel)));
3463
+ const contentHash = await paced(hashTreeSteps(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel))), pacer);
3355
3464
  t.hash += performance.now() - tHash;
3356
3465
  const tStage = performance.now();
3357
3466
  const snapshots = this.snapshotsDir();
3358
3467
  mkdirSync(snapshots, { recursive: true });
3359
3468
  this.sweepStaging(snapshots);
3360
3469
  const staging = join(snapshots, `${STAGING_PREFIX}${randomBytes(6).toString('hex')}`);
3361
- copyFiles(allFiles, staging);
3470
+ await paced(copyFilesSteps(allFiles, staging), pacer);
3362
3471
  const record = m.baselines[m.baseline];
3363
3472
  const snapshot = {
3364
3473
  gen,
@@ -3399,7 +3508,7 @@ export class SkillsStore {
3399
3508
  this.symlink(venvLink.text, join(staging, VENV_LINKNAME), venvLink.absTarget);
3400
3509
  // Re-walk (lstat) and re-hash the staged generation AFTER the copy and BEFORE the rename (design
3401
3510
  // v3.5 §3): files and the one permitted link must hash to the content it was copied from.
3402
- const stagedHash = this.snapshotHash(walkTree(staging));
3511
+ const stagedHash = await paced(this.snapshotHashSteps(await paced(walkTreeSteps(staging), pacer)), pacer);
3403
3512
  if (stagedHash !== contentHash) {
3404
3513
  removeTreeForce(staging);
3405
3514
  throw new SkillsPublishError(`the staged generation hashes to ${stagedHash}, not to the content it was copied from (${contentHash}) — modified between copy and rename; nothing published`);
@@ -3413,12 +3522,24 @@ export class SkillsStore {
3413
3522
  // Immutable by contract, and now by mode bits: a published generation is locked read-only
3414
3523
  // (verification by hash stays the authority — the lock is what makes an accidental edit fail).
3415
3524
  try {
3416
- makeTreeReadOnly(dest);
3525
+ await paced(makeTreeReadOnlySteps(dest), pacer);
3417
3526
  }
3418
3527
  catch (err) {
3419
3528
  removeTreeForce(dest);
3420
3529
  throw new SkillsPublishError(`could not lock ${dest} read-only: ${err instanceof Error ? err.message : String(err)}`);
3421
3530
  }
3531
+ // The paced steps let the loop turn (crew#852): the manifest about to be committed is the one
3532
+ // bound at the start, so it lands only through the same root and over the same revision — a
3533
+ // write that committed meanwhile fails this publish's CAS (the caller re-reads and retries) and
3534
+ // the generation it never came to own is removed, exactly like a commit that fails below.
3535
+ try {
3536
+ this.assertRootUnchanged(bound, expectedRevision);
3537
+ this.assertRevision(this.manifest(), expectedRevision);
3538
+ }
3539
+ catch (err) {
3540
+ removeTreeForce(dest);
3541
+ throw err;
3542
+ }
3422
3543
  // Manifest FIRST, then `current`: a crash in between leaves a committed generation `ensureReady`
3423
3544
  // finishes flipping to; the reverse order would leave a live `current` the manifest disowns.
3424
3545
  const included = new Set(v.snapshotFiles.map((f) => f.rel));
@@ -3447,9 +3568,9 @@ export class SkillsStore {
3447
3568
  this.flipCurrent(gen);
3448
3569
  this.live.published(gen);
3449
3570
  for (const g of retiredGens)
3450
- removeTreeForce(this.snapshotDir(g)); // locked read-only at publish
3571
+ await removeTreeForcePaced(this.snapshotDir(g), pacer); // locked read-only at publish
3451
3572
  for (const dir of prune.dirs)
3452
- removeTreeForce(dir);
3573
+ await removeTreeForcePaced(dir, pacer);
3453
3574
  t.stage = performance.now() - tStage;
3454
3575
  timing(venvLabel, ` · gen ${gen}`);
3455
3576
  return {
@@ -3476,7 +3597,7 @@ export class SkillsStore {
3476
3597
  * touch: a rollback reads the same manifest. A blocked validation is left to the slow path so the
3477
3598
  * findings (a `venv-failed` included) are reported exactly as before.
3478
3599
  */
3479
- unchangedPublish(pre, venvReady) {
3600
+ *unchangedPublishSteps(pre, venvReady) {
3480
3601
  const none = (validateMs = 0, hashMs = 0) => ({ result: null, validateMs, hashMs });
3481
3602
  const published = pre.published;
3482
3603
  if (published === null)
@@ -3493,13 +3614,13 @@ export class SkillsStore {
3493
3614
  else
3494
3615
  return none();
3495
3616
  const tValidate = performance.now();
3496
- const v = this.validate(pre);
3617
+ const v = yield* this.validateSteps(pre);
3497
3618
  const validateMs = performance.now() - tValidate;
3498
3619
  if (verdictOf(v.findings) === 'blocked')
3499
3620
  return none(validateMs);
3500
3621
  const tHash = performance.now();
3501
3622
  const allFiles = sortedRels([...v.snapshotFiles, ...this.viewFiles(v).files]);
3502
- const candidate = hashTree(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel)));
3623
+ const candidate = yield* hashTreeSteps(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel)));
3503
3624
  const hashMs = performance.now() - tHash;
3504
3625
  if (candidate !== published.contentHash)
3505
3626
  return none(validateMs, hashMs);
@@ -3507,7 +3628,7 @@ export class SkillsStore {
3507
3628
  return none(validateMs, hashMs);
3508
3629
  let current;
3509
3630
  try {
3510
- current = this.currentSnapshot();
3631
+ current = yield* this.currentSnapshotSteps();
3511
3632
  }
3512
3633
  catch {
3513
3634
  return none(validateMs, hashMs); // an unverifiable current generation is re-minted by the slow path
@@ -3541,8 +3662,21 @@ export class SkillsStore {
3541
3662
  viewFiles(v) {
3542
3663
  const files = [];
3543
3664
  const copilotSkills = [];
3544
- const byRel = new Map(v.snapshotFiles.map((f) => [f.rel, f]));
3545
3665
  const dirs = new Set(v.enabledSkills.map(({ entry }) => entry.dir));
3666
+ // ONE pass groups the file set by its owning skill dir (crew#852): the former per-skill scan of
3667
+ // every file was skills × files `owningSkillDir` calls — millions on the real catalogue, all on
3668
+ // the event loop. A file whose owner is `entry.dir` sits under `entry.dir/` by construction.
3669
+ const byOwner = new Map();
3670
+ for (const f of v.snapshotFiles) {
3671
+ const owner = owningSkillDir(f.rel, dirs);
3672
+ if (owner === null)
3673
+ continue;
3674
+ const group = byOwner.get(owner);
3675
+ if (group === undefined)
3676
+ byOwner.set(owner, [f]);
3677
+ else
3678
+ group.push(f);
3679
+ }
3546
3680
  for (const { name, entry } of v.enabledSkills) {
3547
3681
  if (!entry.portable)
3548
3682
  continue;
@@ -3553,10 +3687,8 @@ export class SkillsStore {
3553
3687
  }
3554
3688
  copilotSkills.push(name);
3555
3689
  const prefix = `${entry.dir}/`;
3556
- for (const [rel, f] of byRel) {
3557
- if (!rel.startsWith(prefix) || owningSkillDir(rel, dirs) !== entry.dir)
3558
- continue;
3559
- files.push({ rel: `${COPILOT_VIEW_SKILLS_REL}/${name}/${rel.slice(prefix.length)}`, abs: f.abs });
3690
+ for (const f of byOwner.get(entry.dir) ?? []) {
3691
+ files.push({ rel: `${COPILOT_VIEW_SKILLS_REL}/${name}/${f.rel.slice(prefix.length)}`, abs: f.abs });
3560
3692
  }
3561
3693
  }
3562
3694
  return { files: sortedRels(files), copilotSkills: copilotSkills.sort() };
@@ -3720,14 +3852,18 @@ export class SkillsStore {
3720
3852
  * commits it; analyze and a blocked publish drop it). Never touches `effective/`.
3721
3853
  */
3722
3854
  validate(m) {
3855
+ return drain(this.validateSteps(m));
3856
+ }
3857
+ /** `validate` as steps (crew#852): the effective scan's yields (walk + per-file hash); the rules themselves are cheap. */
3858
+ *validateSteps(m) {
3723
3859
  const findings = [];
3724
3860
  // The baseline the records point at must be the bundle its name claims (codex round 7): reset
3725
3861
  // restores from it, `?side=baseline` reads it, the env is provisioned in it — and publish calls
3726
3862
  // this AFTER the provisioner ran, so a provisioner that wrote outside `.venv` is caught here.
3727
- const baselineProblem = this.baselineProblem(m.baseline);
3863
+ const baselineProblem = yield* this.baselineProblemSteps(m.baseline);
3728
3864
  if (baselineProblem !== null)
3729
3865
  findings.push(this.baselineCorruptFinding(null, `${BASELINE_DIRNAME}/${m.baseline}`, baselineProblem));
3730
- const scan = this.scanEffective();
3866
+ const scan = yield* this.scanEffectiveSteps();
3731
3867
  const scanned = scan.files;
3732
3868
  const onDisk = new Map(scanned.map((f) => [f.rel, f]));
3733
3869
  // Direct filesystem edits: detected by hash, reported, recorded — never silently trusted.
@@ -3772,7 +3908,7 @@ export class SkillsStore {
3772
3908
  }
3773
3909
  // Derived fields are recomputed CONTAINED: a skill whose dir (or a file in it) crosses a symlink
3774
3910
  // is skipped, never read through, and BLOCKS here by name (codex round 5).
3775
- findings.push(...this.recomputeDerived(m));
3911
+ findings.push(...(yield* this.recomputeDerivedSteps(m)));
3776
3912
  // EVERY entry of effective/ is classified (codex round 9): a symlink ANYWHERE under it is refused
3777
3913
  // by name — there is no permitted link there (the store never follows one, and a snapshot would
3778
3914
  // otherwise copy whatever it reaches) — and so is a node that is neither a file nor a directory.
@@ -3811,6 +3947,7 @@ export class SkillsStore {
3811
3947
  const f = onDisk.get(rel);
3812
3948
  if (f !== undefined)
3813
3949
  catalogMd.set(name, readFileNoFollow(f.abs).toString('utf8'));
3950
+ yield;
3814
3951
  }
3815
3952
  // The core closure must be COMPLETE: a registered ref or a mandate naming no catalog skill is
3816
3953
  // a phase the engine would refuse to launch — blocking here, not a warning (v3 §3/§5).