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.
- package/dist/api/auth.d.ts +5 -1
- package/dist/api/auth.d.ts.map +1 -1
- package/dist/api/auth.js +23 -0
- package/dist/api/auth.js.map +1 -1
- package/dist/api/governance-health.d.ts.map +1 -1
- package/dist/api/governance-health.js +9 -6
- package/dist/api/governance-health.js.map +1 -1
- package/dist/api/mcp.d.ts.map +1 -1
- package/dist/api/mcp.js +6 -1
- package/dist/api/mcp.js.map +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +5 -2
- package/dist/api/routes.js.map +1 -1
- package/dist/api/server.d.ts +29 -0
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +67 -9
- package/dist/api/server.js.map +1 -1
- package/dist/api/skills.d.ts.map +1 -1
- package/dist/api/skills.js +16 -8
- package/dist/api/skills.js.map +1 -1
- package/dist/api/stall-watchdog.d.ts +10 -2
- package/dist/api/stall-watchdog.d.ts.map +1 -1
- package/dist/api/stall-watchdog.js +32 -13
- package/dist/api/stall-watchdog.js.map +1 -1
- package/dist/cli/governance.d.ts +7 -0
- package/dist/cli/governance.d.ts.map +1 -1
- package/dist/cli/governance.js +14 -1
- package/dist/cli/governance.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +27 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/core/governance-store.d.ts +9 -0
- package/dist/core/governance-store.d.ts.map +1 -1
- package/dist/core/governance-store.js +13 -0
- package/dist/core/governance-store.js.map +1 -1
- package/dist/core/run-boot-resume.d.ts +55 -0
- package/dist/core/run-boot-resume.d.ts.map +1 -0
- package/dist/core/run-boot-resume.js +94 -0
- package/dist/core/run-boot-resume.js.map +1 -0
- package/dist/interactive/doc-grounding.d.ts +37 -0
- package/dist/interactive/doc-grounding.d.ts.map +1 -1
- package/dist/interactive/doc-grounding.js +63 -0
- package/dist/interactive/doc-grounding.js.map +1 -1
- package/dist/interactive/doc-list-routes.d.ts +5 -0
- package/dist/interactive/doc-list-routes.d.ts.map +1 -1
- package/dist/interactive/doc-list-routes.js +7 -1
- package/dist/interactive/doc-list-routes.js.map +1 -1
- package/dist/interactive/draft-events.d.ts +4 -1
- package/dist/interactive/draft-events.d.ts.map +1 -1
- package/dist/interactive/draft-events.js +15 -3
- package/dist/interactive/draft-events.js.map +1 -1
- package/dist/skills/live-generations.d.ts +16 -11
- package/dist/skills/live-generations.d.ts.map +1 -1
- package/dist/skills/live-generations.js +28 -14
- package/dist/skills/live-generations.js.map +1 -1
- package/dist/skills/runtime.d.ts +13 -2
- package/dist/skills/runtime.d.ts.map +1 -1
- package/dist/skills/runtime.js +32 -5
- package/dist/skills/runtime.js.map +1 -1
- package/dist/skills/store.d.ts +73 -5
- package/dist/skills/store.d.ts.map +1 -1
- package/dist/skills/store.js +179 -42
- package/dist/skills/store.js.map +1 -1
- package/dist/skills/tree.d.ts +39 -0
- package/dist/skills/tree.d.ts.map +1 -1
- package/dist/skills/tree.js +102 -13
- package/dist/skills/tree.js.map +1 -1
- package/dist/studio/assets/{index-DRybGZtf.js → index-B-ce66JW.js} +123 -122
- package/dist/studio/assets/index-TvS52qj5.css +32 -0
- package/dist/studio/editors/wicked-page/editor.json +1 -1
- package/dist/studio/editors/wicked-page/index.html +1 -1
- package/dist/studio/index.html +2 -2
- package/dist/studio/testid-inventory.json +259 -6
- package/endpoint-manifest.json +1 -1
- package/package.json +4 -4
- package/dist/studio/assets/index-CGjbTpLl.css +0 -32
package/dist/skills/store.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
3243
|
-
this.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
3571
|
+
await removeTreeForcePaced(this.snapshotDir(g), pacer); // locked read-only at publish
|
|
3451
3572
|
for (const dir of prune.dirs)
|
|
3452
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
|
3557
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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).
|