mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
feat(skills): add public-skill strip and mirror controls
This commit is contained in:
1 parent
d11dda32dc
commit
151774d276
13 files changed
+3318
-13
No files matched your search
@@ -6,6 +6,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/copy-workflow-specs.mjs",
|
||||
"gen:control-plane-json": "node scripts/gen-control-plane-json.mjs",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const EDGE_NAMES = ["canonical", "plugin", "spec"];
|
||||
const CATEGORIES = new Set(["core", "pm", "pods", "process", null]);
|
||||
const REQUIRED_MEMBERSHIP_CATEGORIES = [
|
||||
"clean",
|
||||
"ship_after_fix",
|
||||
"ship_misses_add",
|
||||
"sanitize_borderlines_ship",
|
||||
];
|
||||
const REQUIRED_DENYLIST_ARRAYS = [
|
||||
"path_prefixes",
|
||||
"seat_and_rig_patterns",
|
||||
"host_patterns",
|
||||
"charged_terms",
|
||||
"frontmatter_drop_keys",
|
||||
"internal_path_globs",
|
||||
"allowed_context_substrings",
|
||||
];
|
||||
|
||||
export async function generateControlPlaneJson({
|
||||
repoRoot,
|
||||
membershipPath,
|
||||
denylistPath,
|
||||
layoutPath,
|
||||
outputDir,
|
||||
}) {
|
||||
const membership = readYaml(membershipPath);
|
||||
const denylist = readYaml(denylistPath);
|
||||
const layoutConfig = readYaml(layoutPath);
|
||||
|
||||
validateMembership(membership, membershipPath);
|
||||
validateDenylist(denylist, denylistPath);
|
||||
const layout = await extractSkillEdgeLayout({
|
||||
repoRoot,
|
||||
sourcePath: layoutPath,
|
||||
config: layoutConfig,
|
||||
});
|
||||
const digests = buildEdgeDigests({ repoRoot, layout });
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeJson(join(outputDir, "product-public-skills.generated.json"), membership);
|
||||
writeJson(join(outputDir, "internal-tokens.generated.json"), denylist);
|
||||
writeJson(join(outputDir, "skill-edge-layout.generated.json"), layout);
|
||||
writeJson(join(outputDir, "skill-edge-digests.generated.json"), digests);
|
||||
}
|
||||
|
||||
export async function extractSkillEdgeLayout({
|
||||
repoRoot,
|
||||
config,
|
||||
sourcePath = "skill-edge-layout.yaml",
|
||||
}) {
|
||||
validateLayout(config, sourcePath);
|
||||
const skills = new Map();
|
||||
|
||||
for (const [edge, edgeConfig] of Object.entries(config.edges).sort()) {
|
||||
const edgeRoot = join(repoRoot, edgeConfig.path);
|
||||
for (const skillFile of walkFiles(edgeRoot).filter((path) =>
|
||||
path.endsWith("SKILL.md"),
|
||||
)) {
|
||||
const rel = relative(edgeRoot, skillFile).replaceAll("\\", "/");
|
||||
const parts = rel.split("/");
|
||||
const skill =
|
||||
edgeConfig.layout === "flat" ? parts[0] : parts.at(-2);
|
||||
const category =
|
||||
edgeConfig.layout === "flat" ? null : parts.length >= 3 ? parts[0] : null;
|
||||
const current = skills.get(skill) ?? { edges: [], category };
|
||||
if (
|
||||
category !== null &&
|
||||
current.category !== null &&
|
||||
current.category !== category
|
||||
) {
|
||||
throw new Error(
|
||||
`${sourcePath}: ${skill} has conflicting categories ${current.category} and ${category}`,
|
||||
);
|
||||
}
|
||||
current.category ??= category;
|
||||
current.edges.push(edge);
|
||||
skills.set(skill, current);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [skill, override] of Object.entries(
|
||||
config.forward_overrides ?? {},
|
||||
).sort()) {
|
||||
validateOverride(skill, override, config.edges, sourcePath);
|
||||
skills.set(skill, {
|
||||
edges: [...new Set(override.edges)].sort(),
|
||||
category: override.category ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version: config.version,
|
||||
owner: config.owner,
|
||||
edges: sortObject(config.edges),
|
||||
skills: Object.fromEntries(
|
||||
[...skills.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([skill, entry]) => [
|
||||
skill,
|
||||
{
|
||||
edges: [...new Set(entry.edges)].sort(),
|
||||
category: entry.category ?? null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEdgeDigests({ repoRoot, layout }) {
|
||||
return {
|
||||
version: 1,
|
||||
edges: Object.fromEntries(
|
||||
Object.entries(layout.edges)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([edge, config]) => {
|
||||
const root = join(repoRoot, config.path);
|
||||
return [
|
||||
edge,
|
||||
Object.fromEntries(
|
||||
walkFiles(root).map((path) => [
|
||||
relative(root, path).replaceAll("\\", "/"),
|
||||
sha256(readFileSync(path)),
|
||||
]),
|
||||
),
|
||||
];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function validateMembership(value, sourcePath) {
|
||||
if (!isObject(value?.product_public)) {
|
||||
invalid(sourcePath, "product_public must be an object");
|
||||
}
|
||||
for (const category of REQUIRED_MEMBERSHIP_CATEGORIES) {
|
||||
if (!isStringArray(value.product_public[category])) {
|
||||
invalid(sourcePath, `product_public.${category} must be an array`);
|
||||
}
|
||||
}
|
||||
if (!isStringArray(value.vendored_ship_with_provenance)) {
|
||||
invalid(sourcePath, "vendored_ship_with_provenance must be an array");
|
||||
}
|
||||
if (!isObject(value.not_public)) {
|
||||
invalid(sourcePath, "not_public must be an object");
|
||||
}
|
||||
for (const [category, skills] of Object.entries(value.not_public)) {
|
||||
if (!isStringArray(skills)) {
|
||||
invalid(sourcePath, `not_public.${category} must be an array`);
|
||||
}
|
||||
}
|
||||
if (!isStringArray(value.pending_author_public)) {
|
||||
invalid(sourcePath, "pending_author_public must be an array");
|
||||
}
|
||||
}
|
||||
|
||||
function validateDenylist(value, sourcePath) {
|
||||
if (!isObject(value)) invalid(sourcePath, "denylist must be an object");
|
||||
for (const field of REQUIRED_DENYLIST_ARRAYS) {
|
||||
if (Object.hasOwn(value, field) && !isStringArray(value[field])) {
|
||||
invalid(sourcePath, `${field} must be an array`);
|
||||
}
|
||||
}
|
||||
for (const field of REQUIRED_DENYLIST_ARRAYS) {
|
||||
if (!isStringArray(value[field])) {
|
||||
invalid(sourcePath, `${field} must be an array`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isObject(value.section_fence) ||
|
||||
typeof value.section_fence.begin !== "string" ||
|
||||
typeof value.section_fence.end !== "string"
|
||||
) {
|
||||
invalid(sourcePath, "section_fence.begin and section_fence.end are required");
|
||||
}
|
||||
}
|
||||
|
||||
function validateLayout(value, sourcePath) {
|
||||
if (!isObject(value?.edges)) invalid(sourcePath, "edges must be an object");
|
||||
const edgeNames = Object.keys(value.edges).sort();
|
||||
if (
|
||||
edgeNames.length !== EDGE_NAMES.length ||
|
||||
edgeNames.some((edge, index) => edge !== EDGE_NAMES[index])
|
||||
) {
|
||||
invalid(sourcePath, "edges must contain exactly canonical, plugin, and spec");
|
||||
}
|
||||
for (const edge of EDGE_NAMES) {
|
||||
const config = value.edges[edge];
|
||||
if (
|
||||
!isObject(config) ||
|
||||
typeof config.path !== "string" ||
|
||||
!["categorized", "mirror-of-spec", "flat"].includes(config.layout)
|
||||
) {
|
||||
invalid(sourcePath, `${edge} edge path/layout is invalid`);
|
||||
}
|
||||
}
|
||||
if (value.extract_from_committed_trees !== true) {
|
||||
invalid(sourcePath, "extract_from_committed_trees must be true");
|
||||
}
|
||||
if (!isObject(value.forward_overrides)) {
|
||||
invalid(sourcePath, "forward_overrides must be an object");
|
||||
}
|
||||
for (const [skill, override] of Object.entries(value.forward_overrides)) {
|
||||
validateOverride(skill, override, value.edges, sourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOverride(skill, override, edges, sourcePath) {
|
||||
if (
|
||||
!isObject(override) ||
|
||||
!isStringArray(override.edges) ||
|
||||
override.edges.some(
|
||||
(edge) =>
|
||||
!Object.hasOwn(edges, edge) || !["plugin", "spec"].includes(edge),
|
||||
)
|
||||
) {
|
||||
invalid(sourcePath, `${skill} edges are invalid`);
|
||||
}
|
||||
if (!CATEGORIES.has(override.category ?? null)) {
|
||||
invalid(sourcePath, `${skill} category is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function readYaml(path) {
|
||||
try {
|
||||
return parse(readFileSync(path, "utf8"));
|
||||
} catch (error) {
|
||||
throw new Error(`${path}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(path, value) {
|
||||
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function walkFiles(root) {
|
||||
const stat = lstatSync(root, { throwIfNoEntry: false });
|
||||
if (!stat) return [];
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`${root}: symlink entries are not allowed`);
|
||||
}
|
||||
if (stat.isFile()) return [root];
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`${root}: unsupported filesystem entry`);
|
||||
}
|
||||
return readdirSync(root)
|
||||
.sort()
|
||||
.flatMap((entry) => walkFiles(join(root, entry)));
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function sortObject(value) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringArray(value) {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function invalid(path, reason) {
|
||||
throw new Error(`${path}: ${reason}`);
|
||||
}
|
||||
|
||||
function cliOptions(argv) {
|
||||
const values = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!key?.startsWith("--") || value === undefined) {
|
||||
throw new Error(`Invalid argument near ${key ?? "<end>"}`);
|
||||
}
|
||||
values[key.slice(2)] = resolve(value);
|
||||
}
|
||||
const required = ["repo-root", "membership", "denylist", "layout", "output"];
|
||||
for (const key of required) {
|
||||
if (!values[key]) throw new Error(`--${key} is required`);
|
||||
}
|
||||
return {
|
||||
repoRoot: values["repo-root"],
|
||||
membershipPath: values.membership,
|
||||
denylistPath: values.denylist,
|
||||
layoutPath: values.layout,
|
||||
outputDir: values.output,
|
||||
};
|
||||
}
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
if (resolve(process.argv[1]) === scriptPath) {
|
||||
try {
|
||||
await generateControlPlaneJson(cliOptions(process.argv.slice(2)));
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const roots: string[] = [];
|
||||
const REPO_ROOT = resolve(import.meta.dirname, "../../..");
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("control-plane JSON generator", () => {
|
||||
it("exposes the daemon-workspace generation command without a root YAML dependency", () => {
|
||||
const daemonPackage = JSON.parse(
|
||||
readFileSync(join(REPO_ROOT, "packages/daemon/package.json"), "utf8"),
|
||||
);
|
||||
const rootPackage = JSON.parse(
|
||||
readFileSync(join(REPO_ROOT, "package.json"), "utf8"),
|
||||
);
|
||||
|
||||
expect(daemonPackage.scripts["gen:control-plane-json"]).toBe(
|
||||
"node scripts/gen-control-plane-json.mjs",
|
||||
);
|
||||
expect(daemonPackage.dependencies.yaml).toBeTruthy();
|
||||
expect(rootPackage.dependencies?.yaml).toBeUndefined();
|
||||
expect(rootPackage.devDependencies?.yaml).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses canon YAML and emits deterministic JSON plus edge digests", async () => {
|
||||
const generator = await loadGenerator();
|
||||
const root = tempRoot();
|
||||
const conventions = join(root, "conventions");
|
||||
const output = join(root, "scripts");
|
||||
|
||||
const membershipPath = join(conventions, "product-public-skills.yaml");
|
||||
const denylistPath = join(conventions, "internal-tokens.yaml");
|
||||
const layoutPath = join(conventions, "skill-edge-layout.yaml");
|
||||
write(
|
||||
membershipPath,
|
||||
[
|
||||
"version: 0",
|
||||
"provisional: true",
|
||||
"owner: pm-lead@example",
|
||||
"product_public:",
|
||||
" clean: [alpha]",
|
||||
" ship_after_fix: [beta]",
|
||||
" ship_misses_add: []",
|
||||
" sanitize_borderlines_ship: []",
|
||||
"vendored_ship_with_provenance: []",
|
||||
"not_public:",
|
||||
" reclass_host_only: [private]",
|
||||
"pending_author_public: [future]",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
write(
|
||||
denylistPath,
|
||||
[
|
||||
"version: 1",
|
||||
"path_prefixes: [openrig-work/]",
|
||||
"seat_and_rig_patterns: ['operator-agent@']",
|
||||
"host_patterns: [mm2-]",
|
||||
"charged_terms: [founder]",
|
||||
"frontmatter_drop_keys: [source_evidence]",
|
||||
"internal_path_globs: ['*.internal.*', '**/internal/**', '*-internal/**']",
|
||||
"section_fence:",
|
||||
" begin: '<!-- internal:begin -->'",
|
||||
" end: '<!-- internal:end -->'",
|
||||
"allowed_context_substrings: [do not ship]",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
write(layoutPath, layoutYaml());
|
||||
seedEdges(root);
|
||||
|
||||
await generator.generateControlPlaneJson({
|
||||
repoRoot: root,
|
||||
membershipPath,
|
||||
denylistPath,
|
||||
layoutPath,
|
||||
outputDir: output,
|
||||
});
|
||||
const first = readGenerated(output);
|
||||
await generator.generateControlPlaneJson({
|
||||
repoRoot: root,
|
||||
membershipPath,
|
||||
denylistPath,
|
||||
layoutPath,
|
||||
outputDir: output,
|
||||
});
|
||||
const second = readGenerated(output);
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(first.membership.product_public.clean).toEqual(["alpha"]);
|
||||
expect(first.membership.not_public.reclass_host_only).toEqual(["private"]);
|
||||
expect(first.denylist.section_fence.begin).toBe("<!-- internal:begin -->");
|
||||
expect(first.layout.skills.alpha).toEqual({
|
||||
edges: ["canonical", "plugin", "spec"],
|
||||
category: "core",
|
||||
});
|
||||
expect(first.layout.skills.pluginOnly).toEqual({
|
||||
edges: ["plugin"],
|
||||
category: null,
|
||||
});
|
||||
expect(first.digests.edges.plugin["alpha/SKILL.md"]).toBe(
|
||||
sha256("# Plugin alpha\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("exact-tree extraction ignores illustrative layout and applies only forward_overrides", async () => {
|
||||
const generator = await loadGenerator();
|
||||
const root = tempRoot();
|
||||
seedEdges(root);
|
||||
|
||||
const config = {
|
||||
version: 0,
|
||||
owner: "skills-architect@example",
|
||||
edges: {
|
||||
spec: {
|
||||
path: "packages/daemon/specs/agents/shared/skills",
|
||||
layout: "categorized",
|
||||
},
|
||||
canonical: {
|
||||
path: "skills/_canonical",
|
||||
layout: "mirror-of-spec",
|
||||
},
|
||||
plugin: {
|
||||
path: "packages/daemon/assets/plugins/openrig-core/skills",
|
||||
layout: "flat",
|
||||
},
|
||||
},
|
||||
extract_from_committed_trees: true,
|
||||
forward_overrides: {
|
||||
future: { edges: ["spec", "plugin"], category: "process" },
|
||||
},
|
||||
reference_layout_da101b29: {
|
||||
spec_categorized: { pm: ["alpha"], core: ["stale-only"] },
|
||||
plugin_flat: ["stale-only"],
|
||||
},
|
||||
};
|
||||
|
||||
const layout = await generator.extractSkillEdgeLayout({
|
||||
repoRoot: root,
|
||||
config,
|
||||
});
|
||||
|
||||
expect(layout.skills.alpha).toEqual({
|
||||
edges: ["canonical", "plugin", "spec"],
|
||||
category: "core",
|
||||
});
|
||||
expect(layout.skills["stale-only"]).toBeUndefined();
|
||||
expect(layout.skills.future).toEqual({
|
||||
edges: ["plugin", "spec"],
|
||||
category: "process",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed forward overrides with the source path and reason", async () => {
|
||||
const generator = await loadGenerator();
|
||||
const root = tempRoot();
|
||||
seedEdges(root);
|
||||
|
||||
await expect(
|
||||
generator.extractSkillEdgeLayout({
|
||||
repoRoot: root,
|
||||
sourcePath: "/canon/conventions/skill-edge-layout.yaml",
|
||||
config: {
|
||||
version: 0,
|
||||
edges: edgeConfig(),
|
||||
extract_from_committed_trees: true,
|
||||
forward_overrides: {
|
||||
broken: { edges: ["spec"], category: "not-a-category" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/skill-edge-layout\.yaml.*broken.*category|broken.*category.*skill-edge-layout\.yaml/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on malformed membership and denylist schemas with source paths", async () => {
|
||||
const generator = await loadGenerator();
|
||||
const root = tempRoot();
|
||||
const conventions = join(root, "conventions");
|
||||
const output = join(root, "scripts");
|
||||
const membershipPath = join(conventions, "product-public-skills.yaml");
|
||||
const denylistPath = join(conventions, "internal-tokens.yaml");
|
||||
const layoutPath = join(conventions, "skill-edge-layout.yaml");
|
||||
write(layoutPath, layoutYaml());
|
||||
seedEdges(root);
|
||||
|
||||
write(membershipPath, "version: 0\nowner: pm\n");
|
||||
write(
|
||||
denylistPath,
|
||||
"version: 1\npath_prefixes: []\nseat_and_rig_patterns: []\nhost_patterns: []\ncharged_terms: []\nfrontmatter_drop_keys: []\ninternal_path_globs: []\nsection_fence: {begin: a, end: b}\nallowed_context_substrings: []\n",
|
||||
);
|
||||
await expect(
|
||||
generator.generateControlPlaneJson({
|
||||
repoRoot: root,
|
||||
membershipPath,
|
||||
denylistPath,
|
||||
layoutPath,
|
||||
outputDir: output,
|
||||
}),
|
||||
).rejects.toThrow(/product-public-skills\.yaml.*product_public|product_public.*product-public-skills\.yaml/i);
|
||||
|
||||
write(
|
||||
membershipPath,
|
||||
"version: 0\nproduct_public: {clean: [], ship_after_fix: [], ship_misses_add: [], sanitize_borderlines_ship: []}\nvendored_ship_with_provenance: []\nnot_public: {}\npending_author_public: []\n",
|
||||
);
|
||||
write(denylistPath, "version: 1\ncharged_terms: not-an-array\n");
|
||||
await expect(
|
||||
generator.generateControlPlaneJson({
|
||||
repoRoot: root,
|
||||
membershipPath,
|
||||
denylistPath,
|
||||
layoutPath,
|
||||
outputDir: output,
|
||||
}),
|
||||
).rejects.toThrow(/internal-tokens\.yaml.*charged_terms|charged_terms.*internal-tokens\.yaml/i);
|
||||
});
|
||||
|
||||
it("rejects edge file symlinks before digesting outside-root bytes", async () => {
|
||||
const generator = await loadGenerator();
|
||||
const root = tempRoot();
|
||||
const input = seedGeneratorInput(root);
|
||||
seedEdges(root);
|
||||
const outside = join(root, "outside-file.txt");
|
||||
write(outside, "outside edge bytes\n");
|
||||
const link = join(
|
||||
root,
|
||||
"packages/daemon/assets/plugins/openrig-core/skills/alpha/references/linked.txt",
|
||||
);
|
||||
mkdirSync(dirname(link), { recursive: true });
|
||||
symlinkSync(outside, link, "file");
|
||||
|
||||
await expect(
|
||||
generator.generateControlPlaneJson(input),
|
||||
).rejects.toThrow(/symlink.*linked\.txt|linked\.txt.*symlink/i);
|
||||
expect(
|
||||
existsSync(join(input.outputDir, "skill-edge-digests.generated.json")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects edge directory symlinks before traversal or digesting outside-root bytes", async () => {
|
||||
const generator = await loadGenerator();
|
||||
const root = tempRoot();
|
||||
const input = seedGeneratorInput(root);
|
||||
seedEdges(root);
|
||||
const outside = join(root, "outside-directory");
|
||||
write(join(outside, "secret.txt"), "outside directory bytes\n");
|
||||
const link = join(
|
||||
root,
|
||||
"skills/_canonical/core/alpha/references/linked-directory",
|
||||
);
|
||||
mkdirSync(dirname(link), { recursive: true });
|
||||
symlinkSync(outside, link, "dir");
|
||||
|
||||
await expect(
|
||||
generator.generateControlPlaneJson(input),
|
||||
).rejects.toThrow(/symlink.*linked-directory|linked-directory.*symlink/i);
|
||||
expect(
|
||||
existsSync(join(input.outputDir, "skill-edge-digests.generated.json")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadGenerator(): Promise<Record<string, any>> {
|
||||
const url = pathToFileURL(
|
||||
join(REPO_ROOT, "packages/daemon/scripts/gen-control-plane-json.mjs"),
|
||||
).href;
|
||||
const loaded = await import(url).catch(() => null);
|
||||
expect(loaded, "daemon control-plane JSON generator must exist").not.toBeNull();
|
||||
expect(typeof loaded?.generateControlPlaneJson).toBe("function");
|
||||
expect(typeof loaded?.extractSkillEdgeLayout).toBe("function");
|
||||
return loaded as Record<string, any>;
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-control-json-red-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function seedEdges(root: string): void {
|
||||
write(
|
||||
join(
|
||||
root,
|
||||
"packages/daemon/specs/agents/shared/skills/core/alpha/SKILL.md",
|
||||
),
|
||||
"# Spec alpha\n",
|
||||
);
|
||||
write(
|
||||
join(root, "skills/_canonical/core/alpha/SKILL.md"),
|
||||
"# Spec alpha\n",
|
||||
);
|
||||
write(
|
||||
join(
|
||||
root,
|
||||
"packages/daemon/assets/plugins/openrig-core/skills/alpha/SKILL.md",
|
||||
),
|
||||
"# Plugin alpha\n",
|
||||
);
|
||||
write(
|
||||
join(
|
||||
root,
|
||||
"packages/daemon/assets/plugins/openrig-core/skills/pluginOnly/SKILL.md",
|
||||
),
|
||||
"# Plugin only\n",
|
||||
);
|
||||
}
|
||||
|
||||
function seedGeneratorInput(root: string): {
|
||||
repoRoot: string;
|
||||
membershipPath: string;
|
||||
denylistPath: string;
|
||||
layoutPath: string;
|
||||
outputDir: string;
|
||||
} {
|
||||
const conventions = join(root, "conventions");
|
||||
const membershipPath = join(conventions, "product-public-skills.yaml");
|
||||
const denylistPath = join(conventions, "internal-tokens.yaml");
|
||||
const layoutPath = join(conventions, "skill-edge-layout.yaml");
|
||||
write(
|
||||
membershipPath,
|
||||
"version: 0\nproduct_public: {clean: [alpha], ship_after_fix: [], ship_misses_add: [], sanitize_borderlines_ship: []}\nvendored_ship_with_provenance: []\nnot_public: {}\npending_author_public: []\n",
|
||||
);
|
||||
write(
|
||||
denylistPath,
|
||||
"version: 1\npath_prefixes: []\nseat_and_rig_patterns: []\nhost_patterns: []\ncharged_terms: []\nfrontmatter_drop_keys: []\ninternal_path_globs: []\nsection_fence: {begin: '<!-- internal:begin -->', end: '<!-- internal:end -->'}\nallowed_context_substrings: []\n",
|
||||
);
|
||||
write(layoutPath, layoutYaml());
|
||||
return {
|
||||
repoRoot: root,
|
||||
membershipPath,
|
||||
denylistPath,
|
||||
layoutPath,
|
||||
outputDir: join(root, "scripts"),
|
||||
};
|
||||
}
|
||||
|
||||
function layoutYaml(): string {
|
||||
return [
|
||||
"version: 0",
|
||||
"owner: skills-architect@example",
|
||||
"edges:",
|
||||
" spec:",
|
||||
" path: packages/daemon/specs/agents/shared/skills",
|
||||
" layout: categorized",
|
||||
" canonical:",
|
||||
" path: skills/_canonical",
|
||||
" layout: mirror-of-spec",
|
||||
" plugin:",
|
||||
" path: packages/daemon/assets/plugins/openrig-core/skills",
|
||||
" layout: flat",
|
||||
"extract_from_committed_trees: true",
|
||||
"forward_overrides: {}",
|
||||
"reference_layout_da101b29:",
|
||||
" spec_categorized:",
|
||||
" pm: [alpha]",
|
||||
" core: [stale-only]",
|
||||
" plugin_flat: [stale-only]",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function edgeConfig(): Record<string, unknown> {
|
||||
return {
|
||||
spec: {
|
||||
path: "packages/daemon/specs/agents/shared/skills",
|
||||
layout: "categorized",
|
||||
},
|
||||
canonical: {
|
||||
path: "skills/_canonical",
|
||||
layout: "mirror-of-spec",
|
||||
},
|
||||
plugin: {
|
||||
path: "packages/daemon/assets/plugins/openrig-core/skills",
|
||||
layout: "flat",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readGenerated(output: string): Record<string, any> {
|
||||
return {
|
||||
membership: readJson(join(output, "product-public-skills.generated.json")),
|
||||
denylist: readJson(join(output, "internal-tokens.generated.json")),
|
||||
layout: readJson(join(output, "skill-edge-layout.generated.json")),
|
||||
digests: readJson(join(output, "skill-edge-digests.generated.json")),
|
||||
};
|
||||
}
|
||||
|
||||
function readJson(path: string): any {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function write(path: string, content: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
buildInternalLeakMessage,
|
||||
scanInternalLeaks,
|
||||
} from "./internal-leak-scanner.mjs";
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArguments(argv);
|
||||
const rules = readRules(options.rules);
|
||||
const files = selectFiles(options);
|
||||
const findings = files.flatMap(({ path, bytes }) =>
|
||||
scanInternalLeaks({ path, bytes, rules }),
|
||||
);
|
||||
|
||||
if (findings.length > 0) {
|
||||
process.stderr.write(`${buildInternalLeakMessage(findings)}\n`);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const values = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!key?.startsWith("--") || value === undefined) {
|
||||
throw new Error(`Invalid argument near ${key ?? "<end>"}`);
|
||||
}
|
||||
values[key.slice(2)] = value;
|
||||
}
|
||||
if (!values.repo || !values.rules || !values.mode) {
|
||||
throw new Error("--repo, --rules, and --mode are required");
|
||||
}
|
||||
if (!["full", "staged", "range"].includes(values.mode)) {
|
||||
throw new Error(`Unknown mode: ${values.mode}`);
|
||||
}
|
||||
if (values.mode === "range" && (!values.from || !values.to)) {
|
||||
throw new Error("--from and --to are required for range mode");
|
||||
}
|
||||
return {
|
||||
...values,
|
||||
repo: resolve(values.repo),
|
||||
rules: resolve(values.rules),
|
||||
};
|
||||
}
|
||||
|
||||
function readRules(path) {
|
||||
if (!existsSync(path)) throw new Error(`Rules file not found: ${path}`);
|
||||
try {
|
||||
const rules = JSON.parse(readFileSync(path, "utf8"));
|
||||
validateRules(rules, path);
|
||||
return rules;
|
||||
} catch (error) {
|
||||
if (error.message.startsWith(`Invalid rules file ${path}:`)) throw error;
|
||||
throw new Error(`Invalid rules file ${path}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function selectFiles(options) {
|
||||
if (options.mode === "full") {
|
||||
return splitNul(git(options.repo, ["ls-files", "-z"]))
|
||||
.filter((path) => existsSync(resolve(options.repo, path)))
|
||||
.map((path) => ({
|
||||
path,
|
||||
bytes: readFileSync(resolve(options.repo, path)),
|
||||
}));
|
||||
}
|
||||
|
||||
if (options.mode === "staged") {
|
||||
return splitNul(
|
||||
git(options.repo, [
|
||||
"diff",
|
||||
"--cached",
|
||||
"--name-only",
|
||||
"--diff-filter=ACMR",
|
||||
"-z",
|
||||
]),
|
||||
).map((path) => ({
|
||||
path,
|
||||
bytes: git(options.repo, ["show", `:${path}`]),
|
||||
}));
|
||||
}
|
||||
|
||||
const commits = git(options.repo, [
|
||||
"rev-list",
|
||||
"--reverse",
|
||||
`${options.from}..${options.to}`,
|
||||
])
|
||||
.toString("utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
return commits.flatMap((commit) =>
|
||||
changedPathsAtCommit(options.repo, commit).map((path) => ({
|
||||
path,
|
||||
bytes: git(options.repo, ["show", `${commit}:${path}`]),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function changedPathsAtCommit(repo, commit) {
|
||||
const ancestry = git(repo, ["rev-list", "--parents", "-n", "1", commit])
|
||||
.toString("utf8")
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
const parents = ancestry.slice(1);
|
||||
if (parents.length === 0) {
|
||||
return splitNul(git(repo, ["ls-tree", "-r", "--name-only", "-z", commit]));
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
parents.flatMap((parent) =>
|
||||
splitNul(
|
||||
git(repo, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=ACMR",
|
||||
"-z",
|
||||
parent,
|
||||
commit,
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function validateRules(rules, path) {
|
||||
if (rules === null || typeof rules !== "object" || Array.isArray(rules)) {
|
||||
throw new Error(`Invalid rules file ${path}: rules must be an object`);
|
||||
}
|
||||
for (const field of [
|
||||
"path_prefixes",
|
||||
"seat_and_rig_patterns",
|
||||
"host_patterns",
|
||||
"charged_terms",
|
||||
"internal_path_globs",
|
||||
"allowed_context_substrings",
|
||||
]) {
|
||||
if (
|
||||
!Array.isArray(rules[field]) ||
|
||||
rules[field].length === 0 ||
|
||||
rules[field].some((value) => typeof value !== "string" || value === "")
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid rules file ${path}: ${field} must be a nonempty string array`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function git(repo, args) {
|
||||
return execFileSync("git", args, { cwd: repo });
|
||||
}
|
||||
|
||||
function splitNul(bytes) {
|
||||
return bytes
|
||||
.toString("utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${resolve(process.argv[1])}`) {
|
||||
try {
|
||||
process.exitCode = main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const GUARD = resolve("scripts/check-internal-leak-guard.mjs");
|
||||
|
||||
test("full mode scans tracked files and fails loudly on a planted leak", () => {
|
||||
requireGuard();
|
||||
withRepo((repo, rules) => {
|
||||
write(join(repo, "README.md"), "founder-only detail\n");
|
||||
git(repo, "add", "README.md");
|
||||
git(repo, "commit", "-m", "tracked leak");
|
||||
|
||||
const result = runGuard(repo, rules, "--mode", "full");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /README\.md/);
|
||||
assert.match(result.stderr, /founder/);
|
||||
assert.match(result.stderr, /line 1/i);
|
||||
assert.match(result.stderr, /sidecar|fence|genericize|host-only/i);
|
||||
});
|
||||
});
|
||||
|
||||
test("full mode passes a clean tracked tree and ignores untracked files", () => {
|
||||
requireGuard();
|
||||
withRepo((repo, rules) => {
|
||||
write(join(repo, "README.md"), "public detail\n");
|
||||
git(repo, "add", "README.md");
|
||||
git(repo, "commit", "-m", "clean");
|
||||
write(join(repo, "untracked.md"), "founder\n");
|
||||
|
||||
const result = runGuard(repo, rules, "--mode", "full");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
});
|
||||
|
||||
test("staged mode reads index bytes rather than the worktree", () => {
|
||||
requireGuard();
|
||||
withRepo((repo, rules) => {
|
||||
write(join(repo, "README.md"), "clean\n");
|
||||
git(repo, "add", "README.md");
|
||||
git(repo, "commit", "-m", "base");
|
||||
|
||||
write(join(repo, "README.md"), "operator-agent@kernel\n");
|
||||
git(repo, "add", "README.md");
|
||||
write(join(repo, "README.md"), "clean worktree replacement\n");
|
||||
|
||||
const result = runGuard(repo, rules, "--mode", "staged");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /operator-agent@/);
|
||||
assert.doesNotMatch(result.stderr, /clean worktree replacement/);
|
||||
});
|
||||
});
|
||||
|
||||
test("staged mode handles rename and deletion without reading missing worktree paths", () => {
|
||||
requireGuard();
|
||||
withRepo((repo, rules) => {
|
||||
write(join(repo, "old.md"), "clean\n");
|
||||
write(join(repo, "delete.md"), "clean\n");
|
||||
git(repo, "add", ".");
|
||||
git(repo, "commit", "-m", "base");
|
||||
|
||||
git(repo, "mv", "old.md", "renamed.md");
|
||||
git(repo, "rm", "delete.md");
|
||||
|
||||
const result = runGuard(repo, rules, "--mode", "staged");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
});
|
||||
|
||||
test("range mode scans exact committed bytes between from and to", () => {
|
||||
requireGuard();
|
||||
withRepo((repo, rules) => {
|
||||
write(join(repo, "README.md"), "clean\n");
|
||||
git(repo, "add", "README.md");
|
||||
git(repo, "commit", "-m", "base");
|
||||
const from = git(repo, "rev-parse", "HEAD").trim();
|
||||
|
||||
write(join(repo, "skills", "public", "SKILL.md"), "mm2-secret\n");
|
||||
git(repo, "add", ".");
|
||||
git(repo, "commit", "-m", "leak");
|
||||
const to = git(repo, "rev-parse", "HEAD").trim();
|
||||
write(join(repo, "README.md"), "clean uncommitted replacement\n");
|
||||
|
||||
const result = runGuard(
|
||||
repo,
|
||||
rules,
|
||||
"--mode",
|
||||
"range",
|
||||
"--from",
|
||||
from,
|
||||
"--to",
|
||||
to,
|
||||
);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /skills\/public\/SKILL\.md/);
|
||||
assert.match(result.stderr, /mm2-/);
|
||||
});
|
||||
});
|
||||
|
||||
test("range mode rejects a leak from an intermediate pushed commit even when cleaned at to", () => {
|
||||
requireGuard();
|
||||
withRepo((repo, rules) => {
|
||||
write(join(repo, "README.md"), "clean\n");
|
||||
git(repo, "add", "README.md");
|
||||
git(repo, "commit", "-m", "base");
|
||||
const from = git(repo, "rev-parse", "HEAD").trim();
|
||||
|
||||
write(join(repo, "README.md"), "founder-only intermediate\n");
|
||||
git(repo, "add", "README.md");
|
||||
git(repo, "commit", "-m", "intermediate leak");
|
||||
|
||||
write(join(repo, "README.md"), "clean again\n");
|
||||
git(repo, "add", "README.md");
|
||||
git(repo, "commit", "-m", "clean tip");
|
||||
const to = git(repo, "rev-parse", "HEAD").trim();
|
||||
|
||||
const result = runGuard(
|
||||
repo,
|
||||
rules,
|
||||
"--mode",
|
||||
"range",
|
||||
"--from",
|
||||
from,
|
||||
"--to",
|
||||
to,
|
||||
);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /README\.md/);
|
||||
assert.match(result.stderr, /founder/);
|
||||
});
|
||||
});
|
||||
|
||||
test("each explicit mode refuses a missing generated rules file", () => {
|
||||
requireGuard();
|
||||
withRepo((repo) => {
|
||||
write(join(repo, "README.md"), "clean\n");
|
||||
git(repo, "add", "README.md");
|
||||
git(repo, "commit", "-m", "base");
|
||||
const head = git(repo, "rev-parse", "HEAD").trim();
|
||||
const missing = join(repo, "missing-rules.json");
|
||||
|
||||
for (const args of [
|
||||
["--mode", "full"],
|
||||
["--mode", "staged"],
|
||||
["--mode", "range", "--from", head, "--to", head],
|
||||
]) {
|
||||
const result = runGuard(repo, missing, ...args);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /missing-rules\.json|rules/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("each explicit mode refuses an empty generated rules object", () => {
|
||||
requireGuard();
|
||||
withRepo((repo) => {
|
||||
write(join(repo, "README.md"), "clean\n");
|
||||
git(repo, "add", "README.md");
|
||||
git(repo, "commit", "-m", "base");
|
||||
const head = git(repo, "rev-parse", "HEAD").trim();
|
||||
const empty = join(repo, "empty-rules.json");
|
||||
writeFileSync(empty, "{}");
|
||||
|
||||
for (const args of [
|
||||
["--mode", "full"],
|
||||
["--mode", "staged"],
|
||||
["--mode", "range", "--from", head, "--to", head],
|
||||
]) {
|
||||
const result = runGuard(repo, empty, ...args);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /rules|path_prefixes|invalid/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("Plain B keeps guard enforcement out of root test:repo while fixture tests remain discovered", () => {
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
const command = pkg.scripts["test:repo"];
|
||||
|
||||
assert.match(command, /node --test scripts\/\*\.test\.mjs/);
|
||||
assert.doesNotMatch(command, /node scripts\/check-internal-leak-guard\.mjs/);
|
||||
});
|
||||
|
||||
function withRepo(run) {
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-guard-red-"));
|
||||
const repo = join(root, "repo");
|
||||
const rules = join(root, "rules.json");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
writeFileSync(rules, JSON.stringify(fixtureRules()));
|
||||
git(repo, "init");
|
||||
git(repo, "config", "user.email", "[email protected]");
|
||||
git(repo, "config", "user.name", "QA");
|
||||
try {
|
||||
run(repo, rules);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function requireGuard() {
|
||||
assert.equal(
|
||||
existsSync(GUARD),
|
||||
true,
|
||||
"scripts/check-internal-leak-guard.mjs must exist",
|
||||
);
|
||||
}
|
||||
|
||||
function runGuard(repo, rules, ...args) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[GUARD, "--repo", repo, "--rules", rules, ...args],
|
||||
{ cwd: repo, encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
|
||||
function git(repo, ...args) {
|
||||
return execFileSync("git", args, { cwd: repo, encoding: "utf8" });
|
||||
}
|
||||
|
||||
function write(path, content) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
|
||||
function fixtureRules() {
|
||||
return {
|
||||
path_prefixes: ["openrig-work/"],
|
||||
seat_and_rig_patterns: ["operator-agent@"],
|
||||
host_patterns: ["mm2-"],
|
||||
charged_terms: ["founder"],
|
||||
internal_path_globs: ["*.internal.*", "**/internal/**", "*-internal/**"],
|
||||
allowed_context_substrings: ["do not ship"],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export const INTERNAL_LEAK_REMEDY =
|
||||
"Move the detail to an internal sidecar, fence it, genericize it, or keep the whole file host-only.";
|
||||
|
||||
export function scanInternalLeaks({ path, bytes, rules }) {
|
||||
const normalizedPath = path.replaceAll("\\", "/");
|
||||
const pathRule = (rules.internal_path_globs ?? []).find((glob) =>
|
||||
matchesInternalPath(normalizedPath, glob),
|
||||
);
|
||||
if (pathRule) {
|
||||
return [
|
||||
{
|
||||
file: normalizedPath,
|
||||
token: pathRule,
|
||||
line: 1,
|
||||
remedy: INTERNAL_LEAK_REMEDY,
|
||||
kind: "path",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const tokens = [
|
||||
...(rules.path_prefixes ?? []),
|
||||
...(rules.seat_and_rig_patterns ?? []),
|
||||
...(rules.host_patterns ?? []),
|
||||
...(rules.charged_terms ?? []),
|
||||
];
|
||||
const allowed = rules.allowed_context_substrings ?? [];
|
||||
const findings = [];
|
||||
const lines = Buffer.from(bytes).toString("utf8").split("\n");
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lowerLine = line.toLowerCase();
|
||||
if (allowed.some((text) => lowerLine.includes(text.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
for (const token of tokens) {
|
||||
if (lowerLine.includes(token.toLowerCase())) {
|
||||
findings.push({
|
||||
file: normalizedPath,
|
||||
token,
|
||||
line: index + 1,
|
||||
remedy: INTERNAL_LEAK_REMEDY,
|
||||
kind: "content",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function buildInternalLeakMessage(findings) {
|
||||
return [
|
||||
"Internal content leak detected:",
|
||||
...findings.map(
|
||||
({ file, token, line }) => ` ${file}: line ${line}: ${token}`,
|
||||
),
|
||||
`Remedy: ${INTERNAL_LEAK_REMEDY}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function matchesInternalPath(path, glob) {
|
||||
const parts = path.split("/");
|
||||
if (glob === "*.internal.*") {
|
||||
return parts.some((part) => part.includes(".internal."));
|
||||
}
|
||||
if (glob === "**/internal/**") {
|
||||
return parts.includes("internal");
|
||||
}
|
||||
if (glob === "*-internal/**") {
|
||||
return parts.some((part) => part.endsWith("-internal"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
test("scanner blocks internal paths before reading content", async () => {
|
||||
const scanner = await loadScanner();
|
||||
const findings = scanner.scanInternalLeaks({
|
||||
path: "skills/openrig-user/internal/host.md",
|
||||
bytes: Buffer.from("clean text\n"),
|
||||
rules: fixtureRules(),
|
||||
});
|
||||
|
||||
assert.deepEqual(findings, [
|
||||
{
|
||||
file: "skills/openrig-user/internal/host.md",
|
||||
token: "**/internal/**",
|
||||
line: 1,
|
||||
remedy: scanner.INTERNAL_LEAK_REMEDY,
|
||||
kind: "path",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("scanner reports case-insensitive text tokens with file, token, line, and remedy", async () => {
|
||||
const scanner = await loadScanner();
|
||||
const findings = scanner.scanInternalLeaks({
|
||||
path: "skills/public/SKILL.md",
|
||||
bytes: Buffer.from("# Public\nContact OPERATOR-AGENT@kernel.\n"),
|
||||
rules: fixtureRules(),
|
||||
});
|
||||
|
||||
assert.equal(findings.length, 1);
|
||||
assert.deepEqual(findings[0], {
|
||||
file: "skills/public/SKILL.md",
|
||||
token: "operator-agent@",
|
||||
line: 2,
|
||||
remedy: scanner.INTERNAL_LEAK_REMEDY,
|
||||
kind: "content",
|
||||
});
|
||||
assert.match(scanner.buildInternalLeakMessage(findings), /skills\/public\/SKILL\.md/);
|
||||
assert.match(scanner.buildInternalLeakMessage(findings), /operator-agent@/);
|
||||
assert.match(scanner.buildInternalLeakMessage(findings), /line 2/i);
|
||||
assert.match(scanner.buildInternalLeakMessage(findings), /sidecar|fence|genericize|host-only/i);
|
||||
});
|
||||
|
||||
test("scanner honors an allowed-context substring on the same line only", async () => {
|
||||
const scanner = await loadScanner();
|
||||
const findings = scanner.scanInternalLeaks({
|
||||
path: "skills/public/SKILL.md",
|
||||
bytes: Buffer.from(
|
||||
[
|
||||
"Do not ship operator-agent@ examples.",
|
||||
"A later operator-agent@ reference is still a leak.",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
rules: fixtureRules(),
|
||||
});
|
||||
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0].line, 2);
|
||||
});
|
||||
|
||||
test("scanner finds tokens in NUL-containing bytes and does not crash on clean binary bytes", async () => {
|
||||
const scanner = await loadScanner();
|
||||
const planted = scanner.scanInternalLeaks({
|
||||
path: "skills/public/fixture.bin",
|
||||
bytes: Buffer.concat([
|
||||
Buffer.from([0, 1, 2]),
|
||||
Buffer.from("mm2-secret"),
|
||||
Buffer.from([0, 255]),
|
||||
]),
|
||||
rules: fixtureRules(),
|
||||
});
|
||||
assert.ok(planted.some((finding) => finding.token === "mm2-"));
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
scanner.scanInternalLeaks({
|
||||
path: "skills/public/clean.bin",
|
||||
bytes: Buffer.from([0, 1, 2, 3, 255]),
|
||||
rules: fixtureRules(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("scanner returns findings in deterministic path/token/line order", async () => {
|
||||
const scanner = await loadScanner();
|
||||
const findings = scanner.scanInternalLeaks({
|
||||
path: "skills/public/SKILL.md",
|
||||
bytes: Buffer.from("founder\noperator-agent@kernel\nfounder\n"),
|
||||
rules: fixtureRules(),
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
findings.map(({ token, line }) => ({ token, line })),
|
||||
[
|
||||
{ token: "founder", line: 1 },
|
||||
{ token: "operator-agent@", line: 2 },
|
||||
{ token: "founder", line: 3 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
async function loadScanner() {
|
||||
const moduleUrl = new URL("./internal-leak-scanner.mjs", import.meta.url);
|
||||
const scanner = await import(moduleUrl).catch(() => null);
|
||||
assert.ok(scanner, "scripts/internal-leak-scanner.mjs must exist");
|
||||
assert.equal(typeof scanner.scanInternalLeaks, "function");
|
||||
assert.equal(typeof scanner.buildInternalLeakMessage, "function");
|
||||
return scanner;
|
||||
}
|
||||
|
||||
function fixtureRules() {
|
||||
return {
|
||||
path_prefixes: ["openrig-work/"],
|
||||
seat_and_rig_patterns: ["operator-agent@"],
|
||||
host_patterns: ["mm2-"],
|
||||
charged_terms: ["founder"],
|
||||
internal_path_globs: ["*.internal.*", "**/internal/**", "*-internal/**"],
|
||||
allowed_context_substrings: ["do not ship"],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"version": 1,
|
||||
"path_prefixes": [
|
||||
"openrig-work/",
|
||||
"openrig-internal/",
|
||||
"/Users/wrandom/",
|
||||
"~/code/substrate/",
|
||||
"code/substrate/shared-docs/",
|
||||
".openrig/"
|
||||
],
|
||||
"seat_and_rig_patterns": [
|
||||
"@skill-library",
|
||||
"@kernel",
|
||||
"@openrig-pm",
|
||||
"@openrig-studio",
|
||||
"@openrig-comms",
|
||||
"@v-openrig-build",
|
||||
"operator-agent@",
|
||||
"pm-lead@",
|
||||
"skills-architect@",
|
||||
"skills-analyst@",
|
||||
"openrig-velocity",
|
||||
"redo-driver",
|
||||
"dev1-guard",
|
||||
"dev-driver3"
|
||||
],
|
||||
"host_patterns": [
|
||||
"mm2-",
|
||||
"ovh1-",
|
||||
"linkpix",
|
||||
"vm-claude-test"
|
||||
],
|
||||
"charged_terms": [
|
||||
"dogfood",
|
||||
"founder",
|
||||
"founder-owned host",
|
||||
"one open trust domain",
|
||||
"default-open",
|
||||
"bomb on this host",
|
||||
"the unforgivable sin"
|
||||
],
|
||||
"frontmatter_drop_keys": [
|
||||
"source_evidence",
|
||||
"curation_note",
|
||||
"content_curator",
|
||||
"transfer_test",
|
||||
"transfer_test_notes",
|
||||
"naming_note",
|
||||
"last_verified",
|
||||
"structure"
|
||||
],
|
||||
"internal_path_globs": [
|
||||
"*.internal.*",
|
||||
"**/internal/**",
|
||||
"*-internal/**"
|
||||
],
|
||||
"section_fence": {
|
||||
"begin": "<!-- internal:begin -->",
|
||||
"end": "<!-- internal:end -->"
|
||||
},
|
||||
"allowed_context_substrings": [
|
||||
"deprecated",
|
||||
"defunct",
|
||||
"do not ship",
|
||||
"internal-only (example)"
|
||||
]
|
||||
}
|
||||
+585
-11
@@ -1,6 +1,22 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
lstatSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import {
|
||||
buildInternalLeakMessage,
|
||||
scanInternalLeaks,
|
||||
} from "./internal-leak-scanner.mjs";
|
||||
|
||||
// Mirror canonical skills from packages/daemon/specs/agents/shared/skills/
|
||||
// to <repo-root>/skills/_canonical/. Hand-authored files at <repo-root>/skills/
|
||||
@@ -102,33 +118,591 @@ export function checkModeAbsolute(sourceDir, targetDir, exec = execFileSync) {
|
||||
return { stale: changes.length > 0, changes, output };
|
||||
}
|
||||
|
||||
const SHIP_CATEGORIES = [
|
||||
"clean",
|
||||
"ship_after_fix",
|
||||
"ship_misses_add",
|
||||
"sanitize_borderlines_ship",
|
||||
];
|
||||
|
||||
export function shipSetFromMembership(membership) {
|
||||
const productPublic = membership?.product_public ?? {};
|
||||
const excluded = new Set([
|
||||
...Object.values(membership?.not_public ?? {}).flat(),
|
||||
...(membership?.pending_author_public ?? []),
|
||||
]);
|
||||
return [
|
||||
...SHIP_CATEGORIES.flatMap((category) => productPublic[category] ?? []),
|
||||
...(membership?.vendored_ship_with_provenance ?? []),
|
||||
]
|
||||
.filter((skill) => !excluded.has(skill))
|
||||
.filter((skill, index, all) => all.indexOf(skill) === index)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export async function stagePublicSkills({
|
||||
canonRoot,
|
||||
stagingRoot,
|
||||
membership,
|
||||
rules,
|
||||
}) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true });
|
||||
mkdirSync(stagingRoot, { recursive: true });
|
||||
|
||||
for (const skill of shipSetFromMembership(membership)) {
|
||||
const sourceRoot = join(canonRoot, skill);
|
||||
if (!existsSync(sourceRoot) || isInternalPath(skill, rules)) continue;
|
||||
|
||||
for (const sourcePath of walkFiles(sourceRoot)) {
|
||||
const skillRelative = relative(sourceRoot, sourcePath).replaceAll("\\", "/");
|
||||
const publicPath = `${skill}/${skillRelative}`;
|
||||
if (isExcludedPath(skillRelative)) continue;
|
||||
if (isInternalPath(publicPath, rules)) continue;
|
||||
|
||||
let bytes = readFileSync(sourcePath);
|
||||
if (basename(sourcePath) === "SKILL.md") {
|
||||
const transformed = stripPublicSkill(
|
||||
bytes.toString("utf8"),
|
||||
publicPath,
|
||||
rules,
|
||||
);
|
||||
bytes = Buffer.from(transformed);
|
||||
} else if (/\.mdx?$/i.test(sourcePath)) {
|
||||
bytes = Buffer.from(
|
||||
stripInternalFences(
|
||||
bytes.toString("utf8"),
|
||||
publicPath,
|
||||
rules.section_fence,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const findings = scanInternalLeaks({ path: publicPath, bytes, rules });
|
||||
if (findings.length > 0) {
|
||||
throw new Error(buildInternalLeakMessage(findings));
|
||||
}
|
||||
|
||||
const targetPath = join(stagingRoot, skillRelative === "." ? skill : publicPath);
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
writeFileSync(targetPath, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkGeneratedEdges({
|
||||
repoRoot = process.cwd(),
|
||||
layout,
|
||||
digests,
|
||||
}) {
|
||||
validateGeneratedControls(layout, digests);
|
||||
const changes = [];
|
||||
|
||||
for (const [edge, edgeConfig] of Object.entries(layout.edges).sort()) {
|
||||
const edgeRoot = join(repoRoot, edgeConfig.path);
|
||||
const expected = digests?.edges?.[edge] ?? {};
|
||||
const edgeFiles = walkFiles(edgeRoot);
|
||||
const actual = Object.fromEntries(
|
||||
edgeFiles.map((path) => [
|
||||
relative(edgeRoot, path).replaceAll("\\", "/"),
|
||||
sha256(readFileSync(path)),
|
||||
]),
|
||||
);
|
||||
|
||||
for (const path of Object.keys(expected).sort()) {
|
||||
if (!(path in actual)) {
|
||||
changes.push({ edge, path, reason: "missing" });
|
||||
} else if (actual[path] !== expected[path]) {
|
||||
changes.push({ edge, path, reason: "digest" });
|
||||
}
|
||||
}
|
||||
for (const path of Object.keys(actual).sort()) {
|
||||
if (!(path in expected)) {
|
||||
changes.push({ edge, path, reason: "unexpected" });
|
||||
}
|
||||
}
|
||||
|
||||
const actualSkills = new Map(
|
||||
edgeFiles
|
||||
.filter((path) => basename(path) === "SKILL.md")
|
||||
.map((path) => {
|
||||
const rel = relative(edgeRoot, path).replaceAll("\\", "/");
|
||||
const parts = rel.split("/");
|
||||
const flat = edgeConfig.layout === "flat";
|
||||
return [
|
||||
flat ? parts[0] : parts.at(-2),
|
||||
{ path: rel, category: flat ? null : parts[0] },
|
||||
];
|
||||
}),
|
||||
);
|
||||
for (const [skill, actualEntry] of actualSkills) {
|
||||
const expectedEntry = layout.skills?.[skill];
|
||||
if (!expectedEntry?.edges?.includes(edge)) {
|
||||
changes.push({
|
||||
edge,
|
||||
path: actualEntry.path,
|
||||
reason: "layout-unexpected",
|
||||
});
|
||||
} else if (
|
||||
actualEntry.category !== null &&
|
||||
expectedEntry.category !== actualEntry.category
|
||||
) {
|
||||
changes.push({
|
||||
edge,
|
||||
path: actualEntry.path,
|
||||
reason: "layout-category",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [skill, expectedEntry] of Object.entries(
|
||||
layout.skills ?? {},
|
||||
).sort()) {
|
||||
if (expectedEntry.edges.includes(edge) && !actualSkills.has(skill)) {
|
||||
changes.push({
|
||||
edge,
|
||||
path: skill,
|
||||
reason: "layout-missing",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { stale: changes.length > 0, changes };
|
||||
}
|
||||
|
||||
export async function regeneratePublicSkills({
|
||||
canonRoot,
|
||||
repoRoot,
|
||||
membership,
|
||||
rules,
|
||||
layout,
|
||||
exec = execFileSync,
|
||||
}) {
|
||||
validateAuthoringLayout(layout);
|
||||
const shipping = shipSetFromMembership(membership);
|
||||
for (const skill of shipping) {
|
||||
if (!existsSync(join(canonRoot, skill))) {
|
||||
throw new Error(`Missing shipping skill in canon: ${skill}`);
|
||||
}
|
||||
if (!layout.skills[skill]?.edges?.length) {
|
||||
throw new Error(`Missing shipping skill in edge layout: ${skill}`);
|
||||
}
|
||||
}
|
||||
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), "openrig-public-skills-"));
|
||||
try {
|
||||
const stagingRoot = join(temporaryRoot, "staging");
|
||||
await stagePublicSkills({
|
||||
canonRoot,
|
||||
stagingRoot,
|
||||
membership,
|
||||
rules,
|
||||
});
|
||||
|
||||
const changes = [];
|
||||
for (const [edge, edgeConfig] of Object.entries(layout.edges).sort()) {
|
||||
const projectedRoot = join(temporaryRoot, `edge-${edge}`);
|
||||
mkdirSync(projectedRoot, { recursive: true });
|
||||
|
||||
for (const skill of shipping) {
|
||||
const skillLayout = layout.skills[skill];
|
||||
if (!skillLayout.edges.includes(edge)) continue;
|
||||
const category =
|
||||
edgeConfig.layout === "flat" ? null : skillLayout.category;
|
||||
if (edgeConfig.layout !== "flat" && !category) {
|
||||
throw new Error(`Missing category for shipping skill ${skill} on ${edge}`);
|
||||
}
|
||||
const destination = category
|
||||
? join(projectedRoot, category, skill)
|
||||
: join(projectedRoot, skill);
|
||||
copyTree(join(stagingRoot, skill), destination);
|
||||
}
|
||||
|
||||
const edgeRoot = join(repoRoot, edgeConfig.path);
|
||||
mkdirSync(edgeRoot, { recursive: true });
|
||||
const output = runRsyncAbsolute(projectedRoot, edgeRoot, exec);
|
||||
for (const line of parseChanges(output)) {
|
||||
changes.push({
|
||||
edge,
|
||||
path: rsyncChangePath(line),
|
||||
reason: line.startsWith("*deleting ") ? "delete" : "write",
|
||||
});
|
||||
}
|
||||
}
|
||||
return { changes };
|
||||
} finally {
|
||||
rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function authoringApplyMode({
|
||||
generateControlPlaneJson = defaultGenerateControlPlaneJson,
|
||||
readAuthoringInputs = defaultReadAuthoringInputs,
|
||||
regeneratePublicSkills: regenerate = regeneratePublicSkills,
|
||||
} = {}) {
|
||||
await generateControlPlaneJson();
|
||||
const inputs = readAuthoringInputs();
|
||||
const result = await regenerate(inputs);
|
||||
if (result.changes.length > 0) {
|
||||
await generateControlPlaneJson();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function applyMode(exec = execFileSync) {
|
||||
ensureTargetExists();
|
||||
return runRsync({ dryRun: false }, exec);
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
||||
const isCheck = argv.includes("--check");
|
||||
|
||||
if (isCheck) {
|
||||
const { stale, changes } = checkMode();
|
||||
const verify = dependencies.checkGeneratedEdges ?? checkGeneratedEdges;
|
||||
const inputs =
|
||||
dependencies.checkInputs ??
|
||||
(dependencies.checkGeneratedEdges ? {} : readGeneratedCheckInputs());
|
||||
const { stale, changes } = await verify(inputs);
|
||||
if (stale) {
|
||||
console.error(buildStaleMessage(changes));
|
||||
console.error(buildGeneratedStaleMessage(changes));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const output = applyMode();
|
||||
const changes = parseChanges(output);
|
||||
const apply = dependencies.authoringApplyMode ?? authoringApplyMode;
|
||||
const log = dependencies.log ?? console.log;
|
||||
const { changes } = await apply();
|
||||
if (changes.length === 0) {
|
||||
console.log("Skills mirror already in sync; no changes.");
|
||||
log("Public skill edges already in sync; no changes.");
|
||||
} else {
|
||||
console.log(`Skills mirror updated. ${changes.length} change(s):`);
|
||||
for (const c of changes) console.log(` ${c}`);
|
||||
log(`Public skill edge regeneration measured ${changes.length} change(s):`);
|
||||
for (const { edge, path, reason } of changes) {
|
||||
log(` ${edge}: ${path} (${reason})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${resolve(process.argv[1])}`) {
|
||||
main();
|
||||
await main();
|
||||
}
|
||||
|
||||
function readGeneratedCheckInputs() {
|
||||
const repoRoot = process.cwd();
|
||||
return {
|
||||
repoRoot,
|
||||
layout: readJson(join(repoRoot, "scripts/skill-edge-layout.generated.json")),
|
||||
digests: readJson(join(repoRoot, "scripts/skill-edge-digests.generated.json")),
|
||||
};
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function defaultGenerateControlPlaneJson() {
|
||||
const repoRoot = process.cwd();
|
||||
const required = {
|
||||
membership: process.env.OPENRIG_PRODUCT_PUBLIC_SKILLS_YAML,
|
||||
denylist: process.env.OPENRIG_INTERNAL_TOKENS_YAML,
|
||||
layout: process.env.OPENRIG_SKILL_EDGE_LAYOUT_YAML,
|
||||
};
|
||||
for (const [name, path] of Object.entries(required)) {
|
||||
if (!path) {
|
||||
throw new Error(
|
||||
`Authoring apply requires the ${name} authority path environment variable`,
|
||||
);
|
||||
}
|
||||
}
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
join(repoRoot, "packages/daemon/scripts/gen-control-plane-json.mjs"),
|
||||
"--repo-root",
|
||||
repoRoot,
|
||||
"--membership",
|
||||
required.membership,
|
||||
"--denylist",
|
||||
required.denylist,
|
||||
"--layout",
|
||||
required.layout,
|
||||
"--output",
|
||||
join(repoRoot, "scripts"),
|
||||
],
|
||||
{ cwd: repoRoot, stdio: "inherit" },
|
||||
);
|
||||
}
|
||||
|
||||
function defaultReadAuthoringInputs() {
|
||||
const repoRoot = process.cwd();
|
||||
const canonRoot = process.env.OPENRIG_SKILL_CANON_ROOT;
|
||||
if (!canonRoot) {
|
||||
throw new Error(
|
||||
"Authoring apply requires OPENRIG_SKILL_CANON_ROOT; real apply is founder-gated",
|
||||
);
|
||||
}
|
||||
return {
|
||||
repoRoot,
|
||||
canonRoot: resolve(canonRoot),
|
||||
membership: readJson(
|
||||
join(repoRoot, "scripts/product-public-skills.generated.json"),
|
||||
),
|
||||
rules: readJson(join(repoRoot, "scripts/internal-tokens.generated.json")),
|
||||
layout: readJson(
|
||||
join(repoRoot, "scripts/skill-edge-layout.generated.json"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGeneratedStaleMessage(changes) {
|
||||
return [
|
||||
"Generated skill edges are stale. Regenerate the control-plane manifests.",
|
||||
...changes.map(
|
||||
({ edge, path, reason }) => ` ${edge}: ${path} (${reason})`,
|
||||
),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function stripPublicSkill(content, path, rules) {
|
||||
const withoutFences = stripInternalFences(content, path, rules.section_fence);
|
||||
const lines = withoutFences.split("\n");
|
||||
if (lines[0] !== "---") return withoutFences;
|
||||
|
||||
const end = lines.indexOf("---", 1);
|
||||
if (end === -1) return withoutFences;
|
||||
|
||||
const frontmatter = lines.slice(1, end);
|
||||
const kept = sanitizeFrontmatterEntries(frontmatter, 0, rules);
|
||||
|
||||
return ["---", ...kept, "---", ...lines.slice(end + 1)].join("\n");
|
||||
}
|
||||
|
||||
function sanitizeFrontmatterEntries(lines, indent, rules) {
|
||||
const kept = [];
|
||||
for (let index = 0; index < lines.length; ) {
|
||||
const match = frontmatterEntry(lines[index]);
|
||||
if (!match || match.indent !== indent) {
|
||||
kept.push(lines[index]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let next = index + 1;
|
||||
while (next < lines.length) {
|
||||
const candidate = frontmatterEntry(lines[next]);
|
||||
if (candidate && candidate.indent <= indent) break;
|
||||
next += 1;
|
||||
}
|
||||
const children = lines.slice(index + 1, next);
|
||||
const configured =
|
||||
match.key === "distribution_scope" ||
|
||||
(rules.frontmatter_drop_keys ?? []).includes(match.key);
|
||||
if (configured) {
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
const childIndent = firstEntryIndent(children);
|
||||
if (match.value === "" && childIndent !== null) {
|
||||
const sanitized = sanitizeFrontmatterEntries(children, childIndent, rules);
|
||||
if (sanitized.some((line) => line.trim() !== "")) {
|
||||
kept.push(lines[index], ...sanitized);
|
||||
}
|
||||
} else {
|
||||
const entry = [lines[index], ...children];
|
||||
if (!containsInternalValue(entry.join("\n"), rules)) kept.push(...entry);
|
||||
}
|
||||
index = next;
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
function frontmatterEntry(line) {
|
||||
const match = /^(\s*)([A-Za-z0-9_-]+):(?:\s*(.*))?$/.exec(line);
|
||||
return match
|
||||
? { indent: match[1].length, key: match[2], value: match[3] ?? "" }
|
||||
: null;
|
||||
}
|
||||
|
||||
function firstEntryIndent(lines) {
|
||||
for (const line of lines) {
|
||||
const entry = frontmatterEntry(line);
|
||||
if (entry) return entry.indent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripInternalFences(content, path, fence) {
|
||||
if (!fence?.begin || !fence?.end) return content;
|
||||
|
||||
const lines = content.split("\n");
|
||||
const kept = [];
|
||||
let openedAt = null;
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (line.includes(fence.begin)) {
|
||||
if (openedAt !== null) {
|
||||
throw new Error(`${path}: unmatched internal fence at line ${index + 1}`);
|
||||
}
|
||||
openedAt = index + 1;
|
||||
continue;
|
||||
}
|
||||
if (line.includes(fence.end)) {
|
||||
if (openedAt === null) {
|
||||
throw new Error(`${path}: unmatched internal fence at line ${index + 1}`);
|
||||
}
|
||||
openedAt = null;
|
||||
continue;
|
||||
}
|
||||
if (openedAt === null) kept.push(line);
|
||||
}
|
||||
if (openedAt !== null) {
|
||||
throw new Error(`${path}: unmatched internal fence at line ${openedAt}`);
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function containsInternalValue(value, rules) {
|
||||
const lower = value.toLowerCase();
|
||||
return [
|
||||
...(rules.path_prefixes ?? []),
|
||||
...(rules.seat_and_rig_patterns ?? []),
|
||||
...(rules.host_patterns ?? []),
|
||||
...(rules.charged_terms ?? []),
|
||||
].some((token) => lower.includes(token.toLowerCase()));
|
||||
}
|
||||
|
||||
function isInternalPath(path, rules) {
|
||||
const normalized = path.replaceAll("\\", "/");
|
||||
const parts = normalized.split("/");
|
||||
return (rules.internal_path_globs ?? []).some((glob) => {
|
||||
if (glob === "*.internal.*") {
|
||||
return parts.some((part) => part.includes(".internal."));
|
||||
}
|
||||
if (glob === "**/internal/**") {
|
||||
return parts.includes("internal");
|
||||
}
|
||||
if (glob === "*-internal/**") {
|
||||
return parts.some((part) => part.endsWith("-internal"));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function isExcludedPath(path) {
|
||||
const normalized = path.replaceAll("\\", "/");
|
||||
const parts = normalized.split("/");
|
||||
const file = parts.at(-1);
|
||||
return EXCLUDES.some((pattern) => {
|
||||
if (pattern === "feedback.md") return file === pattern;
|
||||
if (pattern === "evals/") return parts.includes("evals");
|
||||
if (pattern === ".DS_Store") return file === pattern;
|
||||
if (pattern === "*.local.md") return file.endsWith(".local.md");
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function walkFiles(root) {
|
||||
const stat = lstatSync(root, { throwIfNoEntry: false });
|
||||
if (!stat) return [];
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`${root}: symlink entries are not allowed`);
|
||||
}
|
||||
if (stat.isFile()) return [root];
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`${root}: unsupported filesystem entry`);
|
||||
}
|
||||
return readdirSync(root)
|
||||
.sort()
|
||||
.flatMap((entry) => walkFiles(join(root, entry)));
|
||||
}
|
||||
|
||||
function copyTree(sourceRoot, targetRoot) {
|
||||
for (const sourcePath of walkFiles(sourceRoot)) {
|
||||
const targetPath = join(targetRoot, relative(sourceRoot, sourcePath));
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
function runRsyncAbsolute(sourceRoot, targetRoot, exec) {
|
||||
return exec(
|
||||
"rsync",
|
||||
[
|
||||
"-a",
|
||||
"--delete",
|
||||
"--delete-excluded",
|
||||
"--itemize-changes",
|
||||
...EXCLUDES.map((pattern) => `--exclude=${pattern}`),
|
||||
`${sourceRoot}/`,
|
||||
`${targetRoot}/`,
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function rsyncChangePath(line) {
|
||||
if (line.startsWith("*deleting ")) return line.slice("*deleting ".length);
|
||||
const separator = line.indexOf(" ");
|
||||
return separator === -1 ? line : line.slice(separator + 1).trim();
|
||||
}
|
||||
|
||||
function validateGeneratedControls(layout, digests) {
|
||||
validateAuthoringLayout(layout);
|
||||
if (!isRecord(digests?.edges) || Object.keys(digests.edges).length === 0) {
|
||||
throw new Error("Digest control must contain edge inventories");
|
||||
}
|
||||
for (const edge of Object.keys(layout.edges)) {
|
||||
if (!isRecord(digests.edges[edge])) {
|
||||
throw new Error(`Digest control is missing edge ${edge}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateAuthoringLayout(layout) {
|
||||
if (!isRecord(layout?.edges) || Object.keys(layout.edges).length === 0) {
|
||||
throw new Error("Layout control must contain edges");
|
||||
}
|
||||
const edgeNames = Object.keys(layout.edges).sort();
|
||||
const requiredEdges = ["canonical", "plugin", "spec"];
|
||||
if (
|
||||
edgeNames.length !== requiredEdges.length ||
|
||||
edgeNames.some((edge, index) => edge !== requiredEdges[index])
|
||||
) {
|
||||
throw new Error(
|
||||
"Layout control must contain exactly canonical, plugin, and spec edges",
|
||||
);
|
||||
}
|
||||
if (!isRecord(layout.skills) || Object.keys(layout.skills).length === 0) {
|
||||
throw new Error("Layout control must contain skills");
|
||||
}
|
||||
for (const [edge, config] of Object.entries(layout.edges)) {
|
||||
if (
|
||||
!isRecord(config) ||
|
||||
typeof config.path !== "string" ||
|
||||
config.path.length === 0 ||
|
||||
!["categorized", "mirror-of-spec", "flat"].includes(config.layout)
|
||||
) {
|
||||
throw new Error(`Layout control has invalid edge ${edge}`);
|
||||
}
|
||||
}
|
||||
for (const [skill, config] of Object.entries(layout.skills)) {
|
||||
if (
|
||||
!isRecord(config) ||
|
||||
!Array.isArray(config.edges) ||
|
||||
config.edges.length === 0 ||
|
||||
config.edges.some((edge) => !Object.hasOwn(layout.edges, edge))
|
||||
) {
|
||||
throw new Error(`Layout control has invalid skill ${skill}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
SOURCE_DIR,
|
||||
TARGET_DIR,
|
||||
@@ -10,6 +22,7 @@ import {
|
||||
parseChanges,
|
||||
buildStaleMessage,
|
||||
checkMode,
|
||||
checkModeAbsolute,
|
||||
} from "./mirror-skills.mjs";
|
||||
|
||||
test("parseChanges extracts file-change and deletion lines from itemize-changes output", () => {
|
||||
@@ -120,8 +133,863 @@ test("excluded patterns are absent in the mirror target", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("stagePublicSkills applies membership, path, frontmatter, and fence transforms", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
assert.equal(
|
||||
typeof mirror.stagePublicSkills,
|
||||
"function",
|
||||
"stagePublicSkills must be exported",
|
||||
);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-mirror-red-"));
|
||||
try {
|
||||
const canon = join(root, "canon");
|
||||
const staging = join(root, "staging");
|
||||
write(
|
||||
join(canon, "public-skill", "SKILL.md"),
|
||||
[
|
||||
"---",
|
||||
"name: public-skill",
|
||||
"description: public",
|
||||
"distribution_scope: product-bound",
|
||||
"source_evidence: openrig-work/private.md",
|
||||
"curation_note: remove-me",
|
||||
"content_curator: reviewer",
|
||||
"transfer_test: pending",
|
||||
"naming_note: historical",
|
||||
"last_verified: 2026-07-01",
|
||||
"private_path: openrig-work/host-only.md",
|
||||
"internal_owner: operator-agent@kernel",
|
||||
"metadata:",
|
||||
" openrig:",
|
||||
" stage: factory-approved",
|
||||
" sibling_skills:",
|
||||
" - public-sibling",
|
||||
"---",
|
||||
"",
|
||||
"Visible.",
|
||||
"<!-- internal:begin -->",
|
||||
"operator-agent@kernel",
|
||||
"<!-- internal:end -->",
|
||||
"Still visible.",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
write(join(canon, "public-skill", "notes.internal.md"), "secret\n");
|
||||
write(join(canon, "public-skill", "internal", "host.md"), "secret\n");
|
||||
write(join(canon, "private-skill", "SKILL.md"), "# private\n");
|
||||
write(join(canon, "operator-internal", "SKILL.md"), "# whole internal\n");
|
||||
|
||||
await mirror.stagePublicSkills({
|
||||
canonRoot: canon,
|
||||
stagingRoot: staging,
|
||||
membership: membershipFixture({
|
||||
clean: ["public-skill", "operator-internal"],
|
||||
}),
|
||||
rules: fixtureRules(),
|
||||
});
|
||||
|
||||
const shipped = readFileSync(
|
||||
join(staging, "public-skill", "SKILL.md"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(shipped, /name: public-skill/);
|
||||
assert.match(shipped, /metadata:/);
|
||||
assert.match(shipped, /stage: factory-approved/);
|
||||
assert.match(shipped, /public-sibling/);
|
||||
assert.match(shipped, /Visible\./);
|
||||
assert.match(shipped, /Still visible\./);
|
||||
assert.doesNotMatch(shipped, /distribution_scope/);
|
||||
assert.doesNotMatch(shipped, /source_evidence/);
|
||||
assert.doesNotMatch(shipped, /curation_note/);
|
||||
assert.doesNotMatch(shipped, /content_curator/);
|
||||
assert.doesNotMatch(shipped, /transfer_test/);
|
||||
assert.doesNotMatch(shipped, /naming_note/);
|
||||
assert.doesNotMatch(shipped, /last_verified/);
|
||||
assert.doesNotMatch(shipped, /private_path/);
|
||||
assert.doesNotMatch(shipped, /internal_owner/);
|
||||
assert.doesNotMatch(shipped, /operator-agent@kernel/);
|
||||
assert.equal(existsSync(join(staging, "public-skill", "notes.internal.md")), false);
|
||||
assert.equal(existsSync(join(staging, "public-skill", "internal")), false);
|
||||
assert.equal(existsSync(join(staging, "private-skill")), false);
|
||||
assert.equal(existsSync(join(staging, "operator-internal")), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stagePublicSkills applies existing EXCLUDES before scanning or copying", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-mirror-excludes-red-"));
|
||||
try {
|
||||
const canon = join(root, "canon");
|
||||
const staging = join(root, "staging");
|
||||
write(join(canon, "alpha", "SKILL.md"), "# Public alpha\n");
|
||||
for (const path of [
|
||||
"feedback.md",
|
||||
"evals/case.md",
|
||||
".DS_Store",
|
||||
"notes.local.md",
|
||||
]) {
|
||||
write(join(canon, "alpha", path), "founder-only excluded fixture\n");
|
||||
}
|
||||
|
||||
await mirror.stagePublicSkills({
|
||||
canonRoot: canon,
|
||||
stagingRoot: staging,
|
||||
membership: membershipFixture({ clean: ["alpha"] }),
|
||||
rules: fixtureRules(),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
readFileSync(join(staging, "alpha", "SKILL.md"), "utf8"),
|
||||
"# Public alpha\n",
|
||||
);
|
||||
for (const path of [
|
||||
"feedback.md",
|
||||
"evals",
|
||||
".DS_Store",
|
||||
"notes.local.md",
|
||||
]) {
|
||||
assert.equal(existsSync(join(staging, "alpha", path)), false);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stagePublicSkills recursively strips configured keys and internal values while preserving clean metadata", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
assert.equal(typeof mirror.stagePublicSkills, "function");
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-mirror-nested-red-"));
|
||||
try {
|
||||
const canon = join(root, "canon");
|
||||
const staging = join(root, "staging");
|
||||
write(
|
||||
join(canon, "public-skill", "SKILL.md"),
|
||||
[
|
||||
"---",
|
||||
"name: public-skill",
|
||||
"structure: public-sections",
|
||||
"metadata:",
|
||||
" openrig:",
|
||||
" stage: factory-approved",
|
||||
" distribution_scope: product-bound",
|
||||
" source_evidence: |",
|
||||
" private provenance",
|
||||
" curation_note: remove-me",
|
||||
" content_curator: reviewer",
|
||||
" transfer_test: pending",
|
||||
" transfer_test_notes: remove-me-too",
|
||||
" naming_note: historical",
|
||||
" last_verified: 2026-07-01",
|
||||
" structure: openrig-work/private-layout.md",
|
||||
" source_location: openrig-work/private-source.md",
|
||||
" sibling_skills:",
|
||||
" - public-sibling",
|
||||
"---",
|
||||
"",
|
||||
"# Public",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await mirror.stagePublicSkills({
|
||||
canonRoot: canon,
|
||||
stagingRoot: staging,
|
||||
membership: membershipFixture({ clean: ["public-skill"] }),
|
||||
rules: fixtureRules(),
|
||||
});
|
||||
|
||||
const shipped = readFileSync(
|
||||
join(staging, "public-skill", "SKILL.md"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(shipped, /metadata:/);
|
||||
assert.match(shipped, /stage: factory-approved/);
|
||||
assert.match(shipped, /sibling_skills:/);
|
||||
assert.match(shipped, /public-sibling/);
|
||||
for (const key of [
|
||||
"distribution_scope",
|
||||
"source_evidence",
|
||||
"curation_note",
|
||||
"content_curator",
|
||||
"transfer_test",
|
||||
"transfer_test_notes",
|
||||
"naming_note",
|
||||
"last_verified",
|
||||
"structure",
|
||||
]) {
|
||||
assert.doesNotMatch(shipped, new RegExp(`^\\s*${key}:`, "m"));
|
||||
}
|
||||
assert.doesNotMatch(shipped, /^\s*source_location:/m);
|
||||
assert.doesNotMatch(shipped, /openrig-work\/private-(?:layout|source)/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stagePublicSkills aborts an unmatched internal fence with file and line", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
assert.equal(typeof mirror.stagePublicSkills, "function");
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-mirror-red-"));
|
||||
try {
|
||||
const canon = join(root, "canon");
|
||||
write(
|
||||
join(canon, "public-skill", "SKILL.md"),
|
||||
"# Public\n\n<!-- internal:begin -->\nsecret\n",
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
mirror.stagePublicSkills({
|
||||
canonRoot: canon,
|
||||
stagingRoot: join(root, "staging"),
|
||||
membership: membershipFixture({ clean: ["public-skill"] }),
|
||||
rules: fixtureRules(),
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /public-skill\/SKILL\.md/);
|
||||
assert.match(error.message, /line 3/i);
|
||||
assert.match(error.message, /unbalanced|unmatched/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stagePublicSkills strips internal fences from non-SKILL Markdown without sanitizing ordinary content", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-mirror-markdown-red-"));
|
||||
try {
|
||||
const canon = join(root, "canon");
|
||||
const staging = join(root, "staging");
|
||||
write(join(canon, "alpha", "SKILL.md"), "# Public skill\n");
|
||||
|
||||
for (const path of ["references/guide.md", "references/walkthrough.mdx"]) {
|
||||
write(
|
||||
join(canon, "alpha", path),
|
||||
[
|
||||
"---",
|
||||
"distribution_scope: ordinary-reference-metadata",
|
||||
"---",
|
||||
"# Public before",
|
||||
"ordinary reference content remains",
|
||||
"<!-- internal:begin -->",
|
||||
"private prose deliberately outside the denylist",
|
||||
"<!-- internal:end -->",
|
||||
"# Public after",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
await mirror.stagePublicSkills({
|
||||
canonRoot: canon,
|
||||
stagingRoot: staging,
|
||||
membership: membershipFixture({ clean: ["alpha"] }),
|
||||
rules: fixtureRules(),
|
||||
});
|
||||
|
||||
for (const path of ["references/guide.md", "references/walkthrough.mdx"]) {
|
||||
const shipped = readFileSync(join(staging, "alpha", path), "utf8");
|
||||
assert.match(shipped, /distribution_scope: ordinary-reference-metadata/);
|
||||
assert.match(shipped, /# Public before/);
|
||||
assert.match(shipped, /ordinary reference content remains/);
|
||||
assert.match(shipped, /# Public after/);
|
||||
assert.doesNotMatch(shipped, /internal:(?:begin|end)/);
|
||||
assert.doesNotMatch(shipped, /private prose deliberately/);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stagePublicSkills rejects an unmatched fence in non-SKILL Markdown with file and line", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-mirror-markdown-red-"));
|
||||
try {
|
||||
const canon = join(root, "canon");
|
||||
write(join(canon, "alpha", "SKILL.md"), "# Public skill\n");
|
||||
write(
|
||||
join(canon, "alpha", "references", "guide.mdx"),
|
||||
"# Public\n\n<!-- internal:begin -->\nprivate prose\n",
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
mirror.stagePublicSkills({
|
||||
canonRoot: canon,
|
||||
stagingRoot: join(root, "staging"),
|
||||
membership: membershipFixture({ clean: ["alpha"] }),
|
||||
rules: fixtureRules(),
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /alpha\/references\/guide\.mdx/);
|
||||
assert.match(error.message, /line 3/i);
|
||||
assert.match(error.message, /unbalanced|unmatched/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stagePublicSkills rejects canon file symlinks before reading or staging outside bytes", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-mirror-symlink-red-"));
|
||||
try {
|
||||
const canon = join(root, "canon");
|
||||
const staging = join(root, "staging");
|
||||
const outside = join(root, "outside-file.txt");
|
||||
write(join(canon, "alpha", "SKILL.md"), "# Public skill\n");
|
||||
write(outside, "outside bytes deliberately outside the denylist\n");
|
||||
const link = join(canon, "alpha", "references", "linked.txt");
|
||||
mkdirSync(dirname(link), { recursive: true });
|
||||
symlinkSync(outside, link, "file");
|
||||
|
||||
await assert.rejects(
|
||||
mirror.stagePublicSkills({
|
||||
canonRoot: canon,
|
||||
stagingRoot: staging,
|
||||
membership: membershipFixture({ clean: ["alpha"] }),
|
||||
rules: fixtureRules(),
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /symlink/i);
|
||||
assert.match(error.message, /alpha\/references\/linked\.txt/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(join(staging, "alpha", "references", "linked.txt")),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stagePublicSkills rejects canon directory symlinks before recursion or staging outside bytes", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-mirror-symlink-red-"));
|
||||
try {
|
||||
const canon = join(root, "canon");
|
||||
const staging = join(root, "staging");
|
||||
const outside = join(root, "outside-directory");
|
||||
write(join(canon, "alpha", "SKILL.md"), "# Public skill\n");
|
||||
write(join(outside, "secret.txt"), "outside directory bytes\n");
|
||||
const link = join(canon, "alpha", "references", "linked-directory");
|
||||
mkdirSync(dirname(link), { recursive: true });
|
||||
symlinkSync(outside, link, "dir");
|
||||
|
||||
await assert.rejects(
|
||||
mirror.stagePublicSkills({
|
||||
canonRoot: canon,
|
||||
stagingRoot: staging,
|
||||
membership: membershipFixture({ clean: ["alpha"] }),
|
||||
rules: fixtureRules(),
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /symlink/i);
|
||||
assert.match(error.message, /alpha\/references\/linked-directory/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(join(staging, "alpha", "references", "linked-directory")),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stagePublicSkills is deterministic for the same clean input", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
assert.equal(typeof mirror.stagePublicSkills, "function");
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-mirror-red-"));
|
||||
try {
|
||||
const canon = join(root, "canon");
|
||||
write(join(canon, "public-skill", "SKILL.md"), "---\nname: public-skill\n---\n\n# Public\n");
|
||||
write(join(canon, "public-skill", "references", "guide.md"), "# Guide\n");
|
||||
|
||||
const first = join(root, "first");
|
||||
const second = join(root, "second");
|
||||
const input = {
|
||||
canonRoot: canon,
|
||||
membership: membershipFixture({ clean: ["public-skill"] }),
|
||||
rules: fixtureRules(),
|
||||
};
|
||||
await mirror.stagePublicSkills({ ...input, stagingRoot: first });
|
||||
await mirror.stagePublicSkills({ ...input, stagingRoot: second });
|
||||
|
||||
assert.deepEqual(snapshotTree(first), snapshotTree(second));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("shipSetFromMembership consumes exactly the five shipping categories", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
assert.equal(
|
||||
typeof mirror.shipSetFromMembership,
|
||||
"function",
|
||||
"shipSetFromMembership must be exported",
|
||||
);
|
||||
|
||||
const shipSet = mirror.shipSetFromMembership({
|
||||
version: 0,
|
||||
product_public: {
|
||||
clean: ["clean", "duplicate"],
|
||||
ship_after_fix: ["after", "duplicate"],
|
||||
ship_misses_add: ["miss"],
|
||||
sanitize_borderlines_ship: ["sanitize"],
|
||||
},
|
||||
vendored_ship_with_provenance: ["vendored"],
|
||||
not_public: {
|
||||
reclass_host_only: ["host-only"],
|
||||
private_with_public_sibling_pending: ["private"],
|
||||
},
|
||||
pending_author_public: ["pending"],
|
||||
});
|
||||
|
||||
assert.deepEqual(shipSet, [
|
||||
"after",
|
||||
"clean",
|
||||
"duplicate",
|
||||
"miss",
|
||||
"sanitize",
|
||||
"vendored",
|
||||
]);
|
||||
});
|
||||
|
||||
test("shipSetFromMembership subtracts not_public and pending overlaps", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
assert.equal(typeof mirror.shipSetFromMembership, "function");
|
||||
|
||||
const shipSet = mirror.shipSetFromMembership({
|
||||
version: 0,
|
||||
product_public: {
|
||||
clean: ["blocked", "ok", "pending"],
|
||||
ship_after_fix: [],
|
||||
ship_misses_add: [],
|
||||
sanitize_borderlines_ship: [],
|
||||
},
|
||||
vendored_ship_with_provenance: [],
|
||||
not_public: {
|
||||
reclass_host_only: ["blocked"],
|
||||
},
|
||||
pending_author_public: ["pending"],
|
||||
});
|
||||
|
||||
assert.deepEqual(shipSet, ["ok"]);
|
||||
});
|
||||
|
||||
test("checkGeneratedEdges detects layout and digest drift without invoking the leak scanner", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
assert.equal(
|
||||
typeof mirror.checkGeneratedEdges,
|
||||
"function",
|
||||
"checkGeneratedEdges must be exported",
|
||||
);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-digest-red-"));
|
||||
try {
|
||||
const specPath = "packages/daemon/specs/agents/shared/skills";
|
||||
const canonicalPath = "skills/_canonical";
|
||||
const pluginPath = "packages/daemon/assets/plugins/openrig-core/skills";
|
||||
write(join(root, specPath, "core", "alpha", "SKILL.md"), "# Alpha\n");
|
||||
write(join(root, specPath, "core", "alpha", "references", "guide.md"), "# Guide\n");
|
||||
write(join(root, canonicalPath, "core", "alpha", "SKILL.md"), "# Alpha\n");
|
||||
write(join(root, canonicalPath, "core", "alpha", "references", "guide.md"), "# Guide\n");
|
||||
write(join(root, pluginPath, "alpha", "SKILL.md"), "# Alpha\n");
|
||||
write(join(root, pluginPath, "alpha", "references", "guide.md"), "# Guide\n");
|
||||
|
||||
const layout = {
|
||||
version: 0,
|
||||
edges: {
|
||||
spec: { path: specPath, layout: "categorized" },
|
||||
canonical: { path: canonicalPath, layout: "mirror-of-spec" },
|
||||
plugin: { path: pluginPath, layout: "flat" },
|
||||
},
|
||||
skills: {
|
||||
alpha: { edges: ["spec", "canonical", "plugin"], category: "core" },
|
||||
},
|
||||
};
|
||||
const digests = {
|
||||
version: 1,
|
||||
edges: {
|
||||
spec: {
|
||||
"core/alpha/SKILL.md": sha256("# Alpha\n"),
|
||||
"core/alpha/references/guide.md": sha256("# Guide\n"),
|
||||
},
|
||||
canonical: {
|
||||
"core/alpha/SKILL.md": sha256("# Alpha\n"),
|
||||
"core/alpha/references/guide.md": sha256("# Guide\n"),
|
||||
},
|
||||
plugin: {
|
||||
"alpha/SKILL.md": sha256("# Alpha\n"),
|
||||
"alpha/references/guide.md": sha256("# Guide\n"),
|
||||
},
|
||||
},
|
||||
};
|
||||
let scannerCalls = 0;
|
||||
const clean = await mirror.checkGeneratedEdges({
|
||||
repoRoot: root,
|
||||
layout,
|
||||
digests,
|
||||
scan: () => {
|
||||
scannerCalls += 1;
|
||||
throw new Error("provisional scanner must not run from mirror --check");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(clean, { stale: false, changes: [] });
|
||||
assert.equal(scannerCalls, 0);
|
||||
|
||||
write(join(root, pluginPath, "alpha", "SKILL.md"), "# Changed\n");
|
||||
const drift = await mirror.checkGeneratedEdges({ repoRoot: root, layout, digests });
|
||||
assert.equal(drift.stale, true);
|
||||
assert.ok(
|
||||
drift.changes.some(
|
||||
(change) =>
|
||||
change.edge === "plugin" &&
|
||||
change.path === "alpha/SKILL.md" &&
|
||||
change.reason === "digest",
|
||||
),
|
||||
);
|
||||
|
||||
write(join(root, pluginPath, "alpha", "SKILL.md"), "# Alpha\n");
|
||||
write(join(root, pluginPath, "alpha", "unexpected.txt"), "extra\n");
|
||||
const added = await mirror.checkGeneratedEdges({ repoRoot: root, layout, digests });
|
||||
assert.ok(
|
||||
added.changes.some(
|
||||
(change) =>
|
||||
change.edge === "plugin" &&
|
||||
change.path === "alpha/unexpected.txt" &&
|
||||
change.reason === "unexpected",
|
||||
),
|
||||
);
|
||||
|
||||
rmSync(join(root, pluginPath, "alpha", "unexpected.txt"));
|
||||
rmSync(join(root, pluginPath, "alpha", "references", "guide.md"));
|
||||
const missing = await mirror.checkGeneratedEdges({ repoRoot: root, layout, digests });
|
||||
assert.ok(
|
||||
missing.changes.some(
|
||||
(change) =>
|
||||
change.edge === "plugin" &&
|
||||
change.path === "alpha/references/guide.md" &&
|
||||
change.reason === "missing",
|
||||
),
|
||||
);
|
||||
|
||||
write(join(root, pluginPath, "alpha", "guide.md"), "# Guide\n");
|
||||
const relocated = await mirror.checkGeneratedEdges({ repoRoot: root, layout, digests });
|
||||
assert.ok(
|
||||
relocated.changes.some(
|
||||
(change) =>
|
||||
change.edge === "plugin" &&
|
||||
change.path === "alpha/guide.md" &&
|
||||
change.reason === "unexpected",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("checkGeneratedEdges fails closed on empty or malformed control manifests", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
|
||||
await assert.rejects(
|
||||
mirror.checkGeneratedEdges({
|
||||
repoRoot: "/unused",
|
||||
layout: {},
|
||||
digests: {},
|
||||
}),
|
||||
/layout|edges|control/i,
|
||||
);
|
||||
await assert.rejects(
|
||||
mirror.checkGeneratedEdges({
|
||||
repoRoot: "/unused",
|
||||
layout: {
|
||||
version: 0,
|
||||
edges: {
|
||||
spec: { path: "spec", layout: "categorized" },
|
||||
},
|
||||
skills: {},
|
||||
},
|
||||
digests: { version: 1, edges: {} },
|
||||
}),
|
||||
/digest|spec|control/i,
|
||||
);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-partial-controls-red-"));
|
||||
try {
|
||||
write(join(root, "spec", "core", "alpha", "SKILL.md"), "# Alpha\n");
|
||||
await assert.rejects(
|
||||
mirror.checkGeneratedEdges({
|
||||
repoRoot: root,
|
||||
layout: {
|
||||
version: 0,
|
||||
edges: {
|
||||
spec: { path: "spec", layout: "categorized" },
|
||||
},
|
||||
skills: {
|
||||
alpha: { edges: ["spec"], category: "core" },
|
||||
},
|
||||
},
|
||||
digests: {
|
||||
version: 1,
|
||||
edges: {
|
||||
spec: {
|
||||
"core/alpha/SKILL.md": sha256("# Alpha\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
/canonical|plugin|spec|exactly three|control/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("authoring regeneration stages canon and projects exact manifest layouts to all three edges", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
assert.equal(
|
||||
typeof mirror.regeneratePublicSkills,
|
||||
"function",
|
||||
"regeneratePublicSkills must be exported",
|
||||
);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig-authoring-red-"));
|
||||
try {
|
||||
const canonRoot = join(root, "canon");
|
||||
const repoRoot = join(root, "repo");
|
||||
write(join(canonRoot, "alpha", "SKILL.md"), "# Alpha\n");
|
||||
write(join(canonRoot, "alpha", "references", "guide.md"), "# Guide\n");
|
||||
write(join(canonRoot, "alpha", "feedback.md"), "private cycle\n");
|
||||
write(join(canonRoot, "alpha", "evals", "case.md"), "private eval\n");
|
||||
|
||||
const result = await mirror.regeneratePublicSkills({
|
||||
canonRoot,
|
||||
repoRoot,
|
||||
membership: membershipFixture({ clean: ["alpha"] }),
|
||||
rules: fixtureRules(),
|
||||
layout: {
|
||||
version: 0,
|
||||
edges: {
|
||||
spec: { path: "spec", layout: "categorized" },
|
||||
canonical: { path: "canonical", layout: "mirror-of-spec" },
|
||||
plugin: { path: "plugin", layout: "flat" },
|
||||
},
|
||||
skills: {
|
||||
alpha: {
|
||||
edges: ["canonical", "plugin", "spec"],
|
||||
category: "core",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const path of [
|
||||
"spec/core/alpha/SKILL.md",
|
||||
"canonical/core/alpha/SKILL.md",
|
||||
"plugin/alpha/SKILL.md",
|
||||
"spec/core/alpha/references/guide.md",
|
||||
"canonical/core/alpha/references/guide.md",
|
||||
"plugin/alpha/references/guide.md",
|
||||
]) {
|
||||
assert.equal(readFileSync(join(repoRoot, path), "utf8").startsWith("#"), true);
|
||||
}
|
||||
assert.equal(existsSync(join(repoRoot, "spec/core/alpha/feedback.md")), false);
|
||||
assert.equal(existsSync(join(repoRoot, "plugin/alpha/evals")), false);
|
||||
assert.deepEqual(
|
||||
[...new Set(result.changes.map(({ edge }) => edge))].sort(),
|
||||
["canonical", "plugin", "spec"],
|
||||
);
|
||||
assert.ok(result.changes.every(({ path }) => !path.includes("feedback.md")));
|
||||
|
||||
await assert.rejects(
|
||||
mirror.regeneratePublicSkills({
|
||||
canonRoot,
|
||||
repoRoot,
|
||||
membership: membershipFixture({ clean: ["alpha", "missing"] }),
|
||||
rules: fixtureRules(),
|
||||
layout: {
|
||||
version: 0,
|
||||
edges: {
|
||||
spec: { path: "spec", layout: "categorized" },
|
||||
canonical: { path: "canonical", layout: "mirror-of-spec" },
|
||||
plugin: { path: "plugin", layout: "flat" },
|
||||
},
|
||||
skills: {
|
||||
alpha: { edges: ["spec"], category: "core" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
/missing.*ship|ship.*missing|missing.*membership/i,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("default apply refreshes controls after regeneration and enumerates every diff", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
assert.equal(
|
||||
typeof mirror.authoringApplyMode,
|
||||
"function",
|
||||
"authoringApplyMode must be exported",
|
||||
);
|
||||
|
||||
const order = [];
|
||||
const result = await mirror.authoringApplyMode({
|
||||
generateControlPlaneJson: async () => {
|
||||
order.push("generate");
|
||||
},
|
||||
readAuthoringInputs: () => {
|
||||
order.push("read");
|
||||
return { fixture: true };
|
||||
},
|
||||
regeneratePublicSkills: async (inputs) => {
|
||||
order.push(`regenerate:${inputs.fixture}`);
|
||||
return {
|
||||
changes: [
|
||||
{ edge: "canonical", path: "core/alpha/SKILL.md", reason: "write" },
|
||||
{ edge: "plugin", path: "alpha/SKILL.md", reason: "write" },
|
||||
{ edge: "spec", path: "core/alpha/SKILL.md", reason: "write" },
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual(order, [
|
||||
"generate",
|
||||
"read",
|
||||
"regenerate:true",
|
||||
"generate",
|
||||
]);
|
||||
assert.equal(result.changes.length, 3);
|
||||
|
||||
let defaultApplyCalls = 0;
|
||||
const messages = [];
|
||||
await mirror.main([], {
|
||||
authoringApplyMode: async () => {
|
||||
defaultApplyCalls += 1;
|
||||
return result;
|
||||
},
|
||||
log: (message) => messages.push(message),
|
||||
});
|
||||
assert.equal(defaultApplyCalls, 1);
|
||||
for (const change of result.changes) {
|
||||
assert.ok(
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.includes(change.edge) && message.includes(change.path),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("mirror main --check uses layout/digests and never activates the provisional scanner", async () => {
|
||||
const mirror = await import("./mirror-skills.mjs");
|
||||
let checkCalls = 0;
|
||||
let scannerCalls = 0;
|
||||
|
||||
await mirror.main(["--check"], {
|
||||
checkGeneratedEdges: async () => {
|
||||
checkCalls += 1;
|
||||
return { stale: false, changes: [] };
|
||||
},
|
||||
scanInternalLeaks: () => {
|
||||
scannerCalls += 1;
|
||||
throw new Error("Plain B forbids scanner activation from mirror main");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(checkCalls, 1, "main --check must call the layout/digest verifier");
|
||||
assert.equal(scannerCalls, 0, "main --check must not call the provisional scanner");
|
||||
});
|
||||
|
||||
test("checkModeAbsolute retains its one-source/one-target checksum contract", () => {
|
||||
const calls = [];
|
||||
const result = checkModeAbsolute("/source", "/target", (command, args) => {
|
||||
calls.push({ command, args });
|
||||
return "";
|
||||
});
|
||||
|
||||
assert.equal(result.stale, false);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].command, "rsync");
|
||||
assert.ok(calls[0].args.includes("--checksum"));
|
||||
assert.equal(calls[0].args.at(-2), "/source/");
|
||||
assert.equal(calls[0].args.at(-1), "/target/");
|
||||
});
|
||||
|
||||
// --- helpers (test-only) ---
|
||||
|
||||
function fixtureRules() {
|
||||
return {
|
||||
path_prefixes: ["openrig-work/"],
|
||||
seat_and_rig_patterns: ["operator-agent@"],
|
||||
host_patterns: ["mm2-"],
|
||||
charged_terms: ["founder"],
|
||||
frontmatter_drop_keys: [
|
||||
"source_evidence",
|
||||
"curation_note",
|
||||
"content_curator",
|
||||
"transfer_test",
|
||||
"transfer_test_notes",
|
||||
"naming_note",
|
||||
"last_verified",
|
||||
"structure",
|
||||
],
|
||||
internal_path_globs: ["*.internal.*", "**/internal/**", "*-internal/**"],
|
||||
section_fence: {
|
||||
begin: "<!-- internal:begin -->",
|
||||
end: "<!-- internal:end -->",
|
||||
},
|
||||
allowed_context_substrings: ["do not ship"],
|
||||
};
|
||||
}
|
||||
|
||||
function membershipFixture({ clean = [] } = {}) {
|
||||
return {
|
||||
version: 0,
|
||||
product_public: {
|
||||
clean,
|
||||
ship_after_fix: [],
|
||||
ship_misses_add: [],
|
||||
sanitize_borderlines_ship: [],
|
||||
},
|
||||
vendored_ship_with_provenance: [],
|
||||
not_public: {},
|
||||
pending_author_public: [],
|
||||
};
|
||||
}
|
||||
|
||||
function write(path, content) {
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
|
||||
function snapshotTree(root) {
|
||||
const result = {};
|
||||
walk(root, (path) => {
|
||||
result[path.slice(root.length + 1)] = readFileSync(path);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function walkSkillFiles(root) {
|
||||
const out = [];
|
||||
walk(root, (path) => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"version": 0,
|
||||
"provisional": true,
|
||||
"owner": "pm-lead@openrig-pm",
|
||||
"product_public": {
|
||||
"clean": [
|
||||
"mission-slice-sop",
|
||||
"openrig-cmux",
|
||||
"openrig-herdr"
|
||||
],
|
||||
"ship_after_fix": [
|
||||
"openrig-user",
|
||||
"forming-an-openrig-mental-model",
|
||||
"queue-handoff",
|
||||
"seat-continuity-and-handover",
|
||||
"claude-compaction-restore",
|
||||
"openrig-architect"
|
||||
],
|
||||
"ship_misses_add": [
|
||||
"topology-mutation-and-seat-management",
|
||||
"rig-lifecycle",
|
||||
"session-compaction-and-restore"
|
||||
],
|
||||
"sanitize_borderlines_ship": [
|
||||
"rig-bundles-and-shareable-artifacts",
|
||||
"cross-host-rig-commands",
|
||||
"openrig-upgrade",
|
||||
"agent-starters",
|
||||
"specification-system",
|
||||
"attention-queue",
|
||||
"human-in-the-loop",
|
||||
"watchdog",
|
||||
"session-source-fork"
|
||||
]
|
||||
},
|
||||
"vendored_ship_with_provenance": [
|
||||
"agent-browser",
|
||||
"brainstorming",
|
||||
"executing-plans",
|
||||
"systematic-debugging",
|
||||
"test-driven-development",
|
||||
"using-superpowers",
|
||||
"verification-before-completion",
|
||||
"writing-plans",
|
||||
"frontend-design",
|
||||
"dogfood"
|
||||
],
|
||||
"not_public": {
|
||||
"reclass_host_only": [
|
||||
"openrig-operator"
|
||||
],
|
||||
"private_with_public_sibling_pending": [
|
||||
"security-and-consequence-boundary-policy"
|
||||
]
|
||||
},
|
||||
"pending_author_public": [
|
||||
"openrig-operator",
|
||||
"foot-guns"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"version": 1,
|
||||
"edges": {
|
||||
"canonical": {
|
||||
"core/mission-slice-sop/SKILL.md": "d195ef466ddd09944ad4ddb073fa1c5e5f809c8167ac72d1a3b3277314321930",
|
||||
"core/openrig-architect/SKILL.md": "fe038f4ec31e47ac1d5f40fd6d940f6ec291e0f57eab04ce9b2f4ae127289545",
|
||||
"core/openrig-cmux/SKILL.md": "f54d9b0a0cf42fc9617ab26bb1a45d8b28b982091dbb4332d68b2ebda7d09208",
|
||||
"core/openrig-herdr/SKILL.md": "9301cdff9734dfaffe400c11733168a82154814023e6e021550fe28b392ef9a8",
|
||||
"core/openrig-installer/SKILL.md": "362bf919ff91dc083550e4fa2583ec349187d3ed9e0ec5443d49b704cb900e80",
|
||||
"core/openrig-operator/SKILL.md": "69d5a61f708a69b7d28aeddcd4bda0fbaec26bd4cc9f5f808bf1db16b7d8cf84",
|
||||
"core/openrig-user/SKILL.md": "a0af630b0126c870a4c9ebecb0f7df0058bae63c826a932a3b09d59f9516e5e1",
|
||||
"core/openrig-user-settings/SKILL.md": "a9650e93a669d65cabcb61409f2812bdf4a27672ba8c077c6f94fa704656fe34",
|
||||
"core/rig-lifecycle/SKILL.md": "dde7165d9c1718a154684326ea36d239b82b692b1eb8c97d65f2813a5eb82aa4",
|
||||
"pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84",
|
||||
"pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c",
|
||||
"pm/exec-summary/SKILL.md": "9c06b089b4dec325b1be12b261d6a1984627808915818259d63cbf83a5448352",
|
||||
"pm/office-hours/SKILL.md": "c09b92957e709e43b28186883aca9e73218b412a69355051b76700efac88b4cf",
|
||||
"pm/plan-review/SKILL.md": "bba9c1b1e53712c539ac716900b49c215c5f18ae99621e0e396d571106dcf6d9",
|
||||
"pm/requirements-writer/SKILL.md": "01e84e3b1fd6eb37754e45d7ba72488458c427961ec1c35a4fba4c07adc8c80d",
|
||||
"pm/ui-mockup/SKILL.md": "b527c72c73c926c3d2a61e1264279486737d429aa85d3624c4adc364c577afc8",
|
||||
"pods/development-team/SKILL.md": "8bec17cadc8bd4f06ff3593bd802e4103f08124c118c41cb1eb066e58e71ebd7",
|
||||
"pods/orchestration-team/SKILL.md": "08b87a6f73a1f163dcbefadc39adbf0c42d391b68a693e1de44ce08ac69fa312",
|
||||
"pods/review-team/SKILL.md": "1f8ad4dc3960350c373faf77e3ea90d22970766b6aa634dbbfaa765feeafe063",
|
||||
"process/agent-browser/LOCAL-INSIGHTS.md": "e793e30464517bfe0eee3485fb03206292338552cf7ccaadb1713e39bf21b043",
|
||||
"process/agent-browser/OPENRIG.md": "d15123689824c934b51ba572bc7c4d995651e2f606e0e53697263f4ecde108c9",
|
||||
"process/agent-browser/SKILL.md": "f2709bae71a8c60f6a4b80a6873560cdea9632000ed4b28d082dfae58801199b",
|
||||
"process/brainstorming/OPENRIG.md": "608f4b913e482dc12a4d201ece1d265dff1d2bfc7d609c2f11f71cb06d5cfbbe",
|
||||
"process/brainstorming/SKILL.md": "f65187f40745b2016332f426a44db5884687d71e53ea0883c5da992e6e66b802",
|
||||
"process/dogfood/SKILL.md": "8addc5d22813193649e8286b354ae2282975d75f1dda6d5d4b33d6ace47f438b",
|
||||
"process/dogfood/references/issue-taxonomy.md": "dee7e925f7fe001903de6cc14f9656bcda90d069bbf65a2273f22202efebba09",
|
||||
"process/dogfood/templates/dogfood-report-template.md": "55eef9286e32e595dcfca30db76352e9f1d149a78b60b54644eb701072364f35",
|
||||
"process/executing-plans/OPENRIG.md": "351f388447a01a27fbc35f08d278da635a922b54d7fded9859aaebbb0eb3eec1",
|
||||
"process/executing-plans/SKILL.md": "c73eca0a0f7101b6923c87c8bb1a85c06a9714927e5b8d0b5cf0afbbd8ccc529",
|
||||
"process/frontend-design/LICENSE.txt": "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594",
|
||||
"process/frontend-design/SKILL.md": "cec3b51520db90c76b044e9bb7645ba6676157dc7b60786a81a97d82dc9395b4",
|
||||
"process/systematic-debugging/CREATION-LOG.md": "b482ef9a918fbfc6c369729e8160633ddfa2332466dd362ee73f1527c239ef8b",
|
||||
"process/systematic-debugging/SKILL.md": "b2adbca870df5232f018a43f6715c7a97c3ddd9429aefa11f9248a859280c2c5",
|
||||
"process/systematic-debugging/condition-based-waiting-example.ts": "40ae5ebe497fdf310200e43fe986552546d0a22837c0d39e855db1cfd33eb88e",
|
||||
"process/systematic-debugging/condition-based-waiting.md": "e89fec8400d6cd50f43407cec9fab50976ba4d55d0ec2eb51c0bd68036b54c26",
|
||||
"process/systematic-debugging/defense-in-depth.md": "1e175fb86fc357e58c6aebf5441e481e1b7868b4380c0456b63a17eefbd18ba7",
|
||||
"process/systematic-debugging/find-polluter.sh": "6462747eae9b175ac145b78bcfaeab755654a75e32637f08eb633f065a9e1d7c",
|
||||
"process/systematic-debugging/root-cause-tracing.md": "a81bee9448794d261b4aca8487c595d11ff4bd4e10a5f96bca1117dd32527050",
|
||||
"process/systematic-debugging/test-academic.md": "fe2ba480d78ac0d686dc025f41c2a32a43d642bf533f91b0c6053a04d35d6486",
|
||||
"process/systematic-debugging/test-pressure-1.md": "0b6a915db0054577819834c79be9eb614e97bddba10d73768e1fbe91cfed048a",
|
||||
"process/systematic-debugging/test-pressure-2.md": "b2030aeffba07050e8ad573ddf87486457c4a016a786bb326235bebd856f2016",
|
||||
"process/systematic-debugging/test-pressure-3.md": "96b50a52e2c7989c9cf20fb752c47c1e9a3a70dc362f8f7989f8f5b64dac7708",
|
||||
"process/test-driven-development/SKILL.md": "4ad2e041879cb2708791de1997c9d8e491fef66f29773bb3bd7bc7412b239e8f",
|
||||
"process/test-driven-development/testing-anti-patterns.md": "bde453bc258f06543987477c837939afaa774ea2acbd9f308d702fc452bc4283",
|
||||
"process/using-superpowers/OPENRIG.md": "3af62c62fcab2ff962a5cdf398da3031c9bd4a8fbed6bd8ffc4bf52a0205b403",
|
||||
"process/using-superpowers/SKILL.md": "df82c78ef649dee45f4b3fd3e99bcd48d716411230dbe593902b395abdaa1a6e",
|
||||
"process/verification-before-completion/SKILL.md": "ecec3b8358b9c4aa95e9331bc68758c81b2ddd6a3f37046436925290eb206bb6",
|
||||
"process/writing-plans/OPENRIG.md": "cf4d50311b02841938343bb1c25ae38bca24e489396acd52cc987f6b435e26ad",
|
||||
"process/writing-plans/SKILL.md": "ab71f8b300a16a7d6eaf6e3c54c97019919199d519d34b4a149c2d8866fbd507"
|
||||
},
|
||||
"plugin": {
|
||||
"agent-startup-and-context-ingestion/SKILL.md": "c1d759ff3ef7edf689706fc1976f1695d1af21421ee4bf4707622a4b28f15f08",
|
||||
"claude-compaction-restore/SKILL.md": "58602493348d405af3501fe410289b28e12ba54efdebc4a98ce9d818a07aa71b",
|
||||
"claude-compaction-restore/scripts/precompact-hook.mjs": "be1161c8528f81dcecc7b3f88c17622496e6d8b7c4f376347bf4eb3b37e4d311",
|
||||
"claude-compaction-restore/scripts/restore-from-jsonl.mjs": "9de164582eed8270d8e257860d156fade3b1295ca952c23b10d3db8569dd964c",
|
||||
"claude-compaction-restore/templates/compact-instruction.md": "682c778464517b831902ddc2678c055545899b4751a1d32391ce965d5eb7061d",
|
||||
"claude-compaction-restore/templates/post-compact-restore-instruction.md": "10a5a7e5e052f3aa000a4209f386dc4eb90a3c2bba449ca685f124714f59e47a",
|
||||
"forming-an-openrig-mental-model/SKILL.md": "ebadef30569ea07fd27e4cac87104a1ce02fe7a17838e4591c35a9a186e2e7f9",
|
||||
"mission-slice-sop/SKILL.md": "d195ef466ddd09944ad4ddb073fa1c5e5f809c8167ac72d1a3b3277314321930",
|
||||
"openrig-architect/SKILL.md": "fe038f4ec31e47ac1d5f40fd6d940f6ec291e0f57eab04ce9b2f4ae127289545",
|
||||
"openrig-cmux/SKILL.md": "f54d9b0a0cf42fc9617ab26bb1a45d8b28b982091dbb4332d68b2ebda7d09208",
|
||||
"openrig-herdr/SKILL.md": "9301cdff9734dfaffe400c11733168a82154814023e6e021550fe28b392ef9a8",
|
||||
"openrig-operator/SKILL.md": "5fa5223914b7242455dec8c47f4c88b2c408257b5be60cd8bfb5f340a819d948",
|
||||
"openrig-user/SKILL.md": "a0af630b0126c870a4c9ebecb0f7df0058bae63c826a932a3b09d59f9516e5e1",
|
||||
"queue-handoff/SKILL.md": "d4d5d37fb88c885ea40eb2f06ccdde2584d4b471f04fa0f9ed3059cf5446c3ee",
|
||||
"seat-continuity-and-handover/SKILL.md": "bd34b87a320cd43eb1bfaea341ce5a78b3178afe07dcbca7fd524066d61212fc"
|
||||
},
|
||||
"spec": {
|
||||
"core/mission-slice-sop/SKILL.md": "d195ef466ddd09944ad4ddb073fa1c5e5f809c8167ac72d1a3b3277314321930",
|
||||
"core/openrig-architect/SKILL.md": "fe038f4ec31e47ac1d5f40fd6d940f6ec291e0f57eab04ce9b2f4ae127289545",
|
||||
"core/openrig-cmux/SKILL.md": "f54d9b0a0cf42fc9617ab26bb1a45d8b28b982091dbb4332d68b2ebda7d09208",
|
||||
"core/openrig-herdr/SKILL.md": "9301cdff9734dfaffe400c11733168a82154814023e6e021550fe28b392ef9a8",
|
||||
"core/openrig-installer/SKILL.md": "362bf919ff91dc083550e4fa2583ec349187d3ed9e0ec5443d49b704cb900e80",
|
||||
"core/openrig-operator/SKILL.md": "69d5a61f708a69b7d28aeddcd4bda0fbaec26bd4cc9f5f808bf1db16b7d8cf84",
|
||||
"core/openrig-user/SKILL.md": "a0af630b0126c870a4c9ebecb0f7df0058bae63c826a932a3b09d59f9516e5e1",
|
||||
"core/openrig-user-settings/SKILL.md": "a9650e93a669d65cabcb61409f2812bdf4a27672ba8c077c6f94fa704656fe34",
|
||||
"core/rig-lifecycle/SKILL.md": "dde7165d9c1718a154684326ea36d239b82b692b1eb8c97d65f2813a5eb82aa4",
|
||||
"pm/backlog-capture/SKILL.md": "345c2b47fc85e48c15c0d42bbb48fb27dd3b0ceec36bf59dc6ed20daaea47a84",
|
||||
"pm/context-builder/SKILL.md": "0134c9b0a9f196565109f7f51b6ec78d5c3e4b5982a0d7105a93b29952625b3c",
|
||||
"pm/exec-summary/SKILL.md": "9c06b089b4dec325b1be12b261d6a1984627808915818259d63cbf83a5448352",
|
||||
"pm/office-hours/SKILL.md": "c09b92957e709e43b28186883aca9e73218b412a69355051b76700efac88b4cf",
|
||||
"pm/plan-review/SKILL.md": "bba9c1b1e53712c539ac716900b49c215c5f18ae99621e0e396d571106dcf6d9",
|
||||
"pm/requirements-writer/SKILL.md": "01e84e3b1fd6eb37754e45d7ba72488458c427961ec1c35a4fba4c07adc8c80d",
|
||||
"pm/ui-mockup/SKILL.md": "b527c72c73c926c3d2a61e1264279486737d429aa85d3624c4adc364c577afc8",
|
||||
"pods/development-team/SKILL.md": "8bec17cadc8bd4f06ff3593bd802e4103f08124c118c41cb1eb066e58e71ebd7",
|
||||
"pods/orchestration-team/SKILL.md": "08b87a6f73a1f163dcbefadc39adbf0c42d391b68a693e1de44ce08ac69fa312",
|
||||
"pods/review-team/SKILL.md": "1f8ad4dc3960350c373faf77e3ea90d22970766b6aa634dbbfaa765feeafe063",
|
||||
"process/agent-browser/LOCAL-INSIGHTS.md": "e793e30464517bfe0eee3485fb03206292338552cf7ccaadb1713e39bf21b043",
|
||||
"process/agent-browser/OPENRIG.md": "d15123689824c934b51ba572bc7c4d995651e2f606e0e53697263f4ecde108c9",
|
||||
"process/agent-browser/SKILL.md": "f2709bae71a8c60f6a4b80a6873560cdea9632000ed4b28d082dfae58801199b",
|
||||
"process/brainstorming/OPENRIG.md": "608f4b913e482dc12a4d201ece1d265dff1d2bfc7d609c2f11f71cb06d5cfbbe",
|
||||
"process/brainstorming/SKILL.md": "f65187f40745b2016332f426a44db5884687d71e53ea0883c5da992e6e66b802",
|
||||
"process/dogfood/SKILL.md": "8addc5d22813193649e8286b354ae2282975d75f1dda6d5d4b33d6ace47f438b",
|
||||
"process/dogfood/references/issue-taxonomy.md": "dee7e925f7fe001903de6cc14f9656bcda90d069bbf65a2273f22202efebba09",
|
||||
"process/dogfood/templates/dogfood-report-template.md": "55eef9286e32e595dcfca30db76352e9f1d149a78b60b54644eb701072364f35",
|
||||
"process/executing-plans/OPENRIG.md": "351f388447a01a27fbc35f08d278da635a922b54d7fded9859aaebbb0eb3eec1",
|
||||
"process/executing-plans/SKILL.md": "c73eca0a0f7101b6923c87c8bb1a85c06a9714927e5b8d0b5cf0afbbd8ccc529",
|
||||
"process/frontend-design/LICENSE.txt": "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594",
|
||||
"process/frontend-design/SKILL.md": "cec3b51520db90c76b044e9bb7645ba6676157dc7b60786a81a97d82dc9395b4",
|
||||
"process/systematic-debugging/CREATION-LOG.md": "b482ef9a918fbfc6c369729e8160633ddfa2332466dd362ee73f1527c239ef8b",
|
||||
"process/systematic-debugging/SKILL.md": "b2adbca870df5232f018a43f6715c7a97c3ddd9429aefa11f9248a859280c2c5",
|
||||
"process/systematic-debugging/condition-based-waiting-example.ts": "40ae5ebe497fdf310200e43fe986552546d0a22837c0d39e855db1cfd33eb88e",
|
||||
"process/systematic-debugging/condition-based-waiting.md": "e89fec8400d6cd50f43407cec9fab50976ba4d55d0ec2eb51c0bd68036b54c26",
|
||||
"process/systematic-debugging/defense-in-depth.md": "1e175fb86fc357e58c6aebf5441e481e1b7868b4380c0456b63a17eefbd18ba7",
|
||||
"process/systematic-debugging/find-polluter.sh": "6462747eae9b175ac145b78bcfaeab755654a75e32637f08eb633f065a9e1d7c",
|
||||
"process/systematic-debugging/root-cause-tracing.md": "a81bee9448794d261b4aca8487c595d11ff4bd4e10a5f96bca1117dd32527050",
|
||||
"process/systematic-debugging/test-academic.md": "fe2ba480d78ac0d686dc025f41c2a32a43d642bf533f91b0c6053a04d35d6486",
|
||||
"process/systematic-debugging/test-pressure-1.md": "0b6a915db0054577819834c79be9eb614e97bddba10d73768e1fbe91cfed048a",
|
||||
"process/systematic-debugging/test-pressure-2.md": "b2030aeffba07050e8ad573ddf87486457c4a016a786bb326235bebd856f2016",
|
||||
"process/systematic-debugging/test-pressure-3.md": "96b50a52e2c7989c9cf20fb752c47c1e9a3a70dc362f8f7989f8f5b64dac7708",
|
||||
"process/test-driven-development/SKILL.md": "4ad2e041879cb2708791de1997c9d8e491fef66f29773bb3bd7bc7412b239e8f",
|
||||
"process/test-driven-development/testing-anti-patterns.md": "bde453bc258f06543987477c837939afaa774ea2acbd9f308d702fc452bc4283",
|
||||
"process/using-superpowers/OPENRIG.md": "3af62c62fcab2ff962a5cdf398da3031c9bd4a8fbed6bd8ffc4bf52a0205b403",
|
||||
"process/using-superpowers/SKILL.md": "df82c78ef649dee45f4b3fd3e99bcd48d716411230dbe593902b395abdaa1a6e",
|
||||
"process/verification-before-completion/SKILL.md": "ecec3b8358b9c4aa95e9331bc68758c81b2ddd6a3f37046436925290eb206bb6",
|
||||
"process/writing-plans/OPENRIG.md": "cf4d50311b02841938343bb1c25ae38bca24e489396acd52cc987f6b435e26ad",
|
||||
"process/writing-plans/SKILL.md": "ab71f8b300a16a7d6eaf6e3c54c97019919199d519d34b4a149c2d8866fbd507"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"version": 0,
|
||||
"owner": "skills-architect@skill-library",
|
||||
"edges": {
|
||||
"canonical": {
|
||||
"path": "skills/_canonical",
|
||||
"layout": "mirror-of-spec"
|
||||
},
|
||||
"plugin": {
|
||||
"path": "packages/daemon/assets/plugins/openrig-core/skills",
|
||||
"layout": "flat"
|
||||
},
|
||||
"spec": {
|
||||
"path": "packages/daemon/specs/agents/shared/skills",
|
||||
"layout": "categorized"
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"agent-browser": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "process"
|
||||
},
|
||||
"agent-startup-and-context-ingestion": {
|
||||
"edges": [
|
||||
"plugin"
|
||||
],
|
||||
"category": null
|
||||
},
|
||||
"backlog-capture": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "pm"
|
||||
},
|
||||
"brainstorming": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "process"
|
||||
},
|
||||
"claude-compaction-restore": {
|
||||
"edges": [
|
||||
"plugin"
|
||||
],
|
||||
"category": null
|
||||
},
|
||||
"context-builder": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "pm"
|
||||
},
|
||||
"development-team": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "pods"
|
||||
},
|
||||
"dogfood": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "process"
|
||||
},
|
||||
"exec-summary": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "pm"
|
||||
},
|
||||
"executing-plans": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "process"
|
||||
},
|
||||
"forming-an-openrig-mental-model": {
|
||||
"edges": [
|
||||
"plugin"
|
||||
],
|
||||
"category": null
|
||||
},
|
||||
"frontend-design": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "process"
|
||||
},
|
||||
"mission-slice-sop": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"plugin",
|
||||
"spec"
|
||||
],
|
||||
"category": "core"
|
||||
},
|
||||
"office-hours": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "pm"
|
||||
},
|
||||
"openrig-architect": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"plugin",
|
||||
"spec"
|
||||
],
|
||||
"category": "core"
|
||||
},
|
||||
"openrig-cmux": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"plugin",
|
||||
"spec"
|
||||
],
|
||||
"category": "core"
|
||||
},
|
||||
"openrig-herdr": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"plugin",
|
||||
"spec"
|
||||
],
|
||||
"category": "core"
|
||||
},
|
||||
"openrig-installer": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "core"
|
||||
},
|
||||
"openrig-operator": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"plugin",
|
||||
"spec"
|
||||
],
|
||||
"category": "core"
|
||||
},
|
||||
"openrig-user": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"plugin",
|
||||
"spec"
|
||||
],
|
||||
"category": "core"
|
||||
},
|
||||
"openrig-user-settings": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "core"
|
||||
},
|
||||
"orchestration-team": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "pods"
|
||||
},
|
||||
"plan-review": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "pm"
|
||||
},
|
||||
"queue-handoff": {
|
||||
"edges": [
|
||||
"plugin"
|
||||
],
|
||||
"category": null
|
||||
},
|
||||
"requirements-writer": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "pm"
|
||||
},
|
||||
"review-team": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "pods"
|
||||
},
|
||||
"rig-lifecycle": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "core"
|
||||
},
|
||||
"seat-continuity-and-handover": {
|
||||
"edges": [
|
||||
"plugin"
|
||||
],
|
||||
"category": null
|
||||
},
|
||||
"systematic-debugging": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "process"
|
||||
},
|
||||
"test-driven-development": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "process"
|
||||
},
|
||||
"ui-mockup": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "pm"
|
||||
},
|
||||
"using-superpowers": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "process"
|
||||
},
|
||||
"verification-before-completion": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "process"
|
||||
},
|
||||
"writing-plans": {
|
||||
"edges": [
|
||||
"canonical",
|
||||
"spec"
|
||||
],
|
||||
"category": "process"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user