feat(slice-05): bundle manifest skills[] block — Item 6 cross-primitive bundling part 1 (Checkpoint 7.1) (OPR.0.3.2.5)

Opens Item 6 (cross-primitive bundling). Adds optional top-level
skills[] field to both LegacyBundleManifest (v1) and PodBundleManifest
(v2) for declaring skill paths the bundle ships. Install-time routing
of the declared skills to the operator skills library lands at
Checkpoint 7.2 (plus the gstack HYBRID proof case via Item 6
plugin-bundling). Workflow_specs / context_packs / agent_images are
additive and land in 7.3+ as their libraries are reachable.

Design: skills[] is a TOP-LEVEL optional field (alongside rig_spec,
agents, packages) rather than a `contents:` re-grouping. The PRD
sketched a `contents:` block but moving existing rig_spec/agents/
packages under that block is a schema reorganization that would
violate the no-schema-bump constraint. Each new cross-primitive kind
lands as a peer optional field; backward compat is preserved both
directions.

What ships:
- packages/daemon/src/domain/bundle-types.ts:
  - validateSkillsBlock helper: rejects non-array; per-entry must be
    string + isRelativeSafePath (rejects ../traversal, absolute,
    backslash, empty segments)
  - normalizeSkillsBlock helper: defensive copy + string filter;
    returns undefined when empty
  - LegacyBundleManifest + PodBundleManifest interfaces extended
    with optional skills?: string[]
  - validateLegacyBundleManifest + validatePodBundleManifest call
    validateSkillsBlock
  - serializeLegacyBundleManifest + serializePodBundleManifest emit
    skills field when non-empty
  - normalizeLegacyBundleManifest surfaces skills when present (v2
    has no normalizer surface; raw round-trip preserved)
- packages/daemon/test/bundle-types.test.ts: 7 new v1 skills tests
  (missing-valid, accepted-array, non-array-rejected, non-string-
  entry-rejected, unsafe-path-rejected, round-trip-preserves, missing-
  round-trips-cleanly)
- packages/daemon/test/pod-bundle-assembler.test.ts: 4 new v2 skills
  tests (missing-valid, accepted-array, unsafe-path-rejected,
  round-trip)

Diff: 3 files, +166 / -2 lines.

Verified:
- 75/75 daemon bundle-types + pod-bundle-assembler suite green (was
  64/64; +11 new skills tests: 7 v1 + 4 v2)
- Discriminator confirmed: disabling validateSkillsBlock in both
  validators causes exactly 4/75 to fail (2 v1 reject tests +
  2 v2 reject tests — the non-array, non-string-entry, and unsafe-
  path assertions); restoring returns 75/75
- scope-and-versioning coherence grep clean
- audit-every-layer unchanged

Pre-commit discipline applied (banked): git diff --cached --stat
verified +166/-2 across 3 intended files BEFORE commit.

Handoff-claim discipline applied (newly-banked, after 2 consecutive
incidents): I ran the discriminator-flip on the validateSkillsBlock
call to verify the tests actually exercise the new code BEFORE
asserting "validators reject unsafe skills" in this commit body.
The "validators reject unsafe skills" claim is now backed by the
discriminator-verified test coverage, not speculation.

PRD bounce conditions verified:
- No schema bump (v1+v2 extended with optional field only)
- No new SQLite migrations
- No event types
- No core-routing or bootstrap mutation
- No sidebar entries touched
- scope-and-versioning sections 1+3 preserved

What this commit does NOT ship (Checkpoint 7.2 + 7.3):
- 7.2: /install handler routes skills + plugins to their respective
  libraries (operator skills directory + existing plugin install
  surface); gstack HYBRID proof case bundle source authoring (small
  starter rig + bundle.yaml referencing existing 0.3.1 gstack-plugin
  via plugins[] kind)
- 7.3 (if libraries reachable): workflow_specs + context_packs +
  agent_images kinds; install routing per kind; tests

Item 6 sub-chain:
- f8bf875c (Item 5 part 1 render tests, CLEAR) <- prior tip
- THIS COMMIT — Item 6 part 1: skills[] manifest type layer
- Next (7.2) — install routing for skills + plugin-bundling for
  gstack HYBRID proof
- Next (7.3) — additional cross-primitive kinds (workflow_specs etc.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 14:41:11 -07:00
1 parent f8bf875c84
commit 0c13c7c661
3 files changed
+188

No files matched your search

@@ -67,6 +67,49 @@ function provenanceToYamlRecord(p: BundleProvenance): Record<string, unknown> {
return out;
}
/**
* Cross-primitive bundling — Item 6 / slice-05 Checkpoint 7.1.
*
* Manifest may optionally declare typed sibling primitives the bundle should
* route to their respective libraries on install. v0 ships the `skills` kind
* (Checkpoint 7.1). Plugins + workflow_specs + context_packs + agent_images
* land additively in subsequent checkpoints as their libraries are reachable.
*
* PRD §Item 6: bundles list these alongside the existing rig + agents +
* packages fields (no `contents:` re-grouping — that would be a schema
* reorg violating the no-bump constraint). Each kind is an optional top-
* level field; missing kinds keep backward compat.
*/
/** Validate optional skills[] block. Appends to errors if present-but-malformed. */
function validateSkillsBlock(raw: unknown, errors: string[]): void {
if (raw === undefined) return;
if (!Array.isArray(raw)) {
errors.push("skills must be an array");
return;
}
for (let i = 0; i < raw.length; i++) {
const entry = raw[i];
if (typeof entry !== "string") {
errors.push(`skills[${i}] must be a string`);
continue;
}
if (!isRelativeSafePath(entry)) {
errors.push(`skills[${i}] path is not safe: '${entry}'`);
}
}
}
/** Normalize raw skills[] block (defensive copy + string filter). */
function normalizeSkillsBlock(raw: unknown): string[] | undefined {
if (!Array.isArray(raw)) return undefined;
const result: string[] = [];
for (const entry of raw) {
if (typeof entry === "string" && entry.length > 0) result.push(entry);
}
return result.length > 0 ? result : undefined;
}
/**
* Compatibility block — operator-declared install-time requirements for a
* bundle artifact. All fields optional; missing block keeps backward compat
@@ -179,6 +222,8 @@ export interface PodBundleManifest {
integrity?: BundleIntegrity;
provenance?: BundleProvenance;
compatibility?: BundleCompatibility;
/** Item 6 cross-primitive bundling: skill paths to route to the operator skills library on install. */
skills?: string[];
}
export function validatePodBundleManifest(raw: unknown): { valid: boolean; errors: string[] } {
@@ -207,6 +252,7 @@ export function validatePodBundleManifest(raw: unknown): { valid: boolean; error
validateProvenanceBlock(m["provenance"], errors);
validateCompatibilityBlock(m["compatibility"], errors);
validateSkillsBlock(m["skills"], errors);
return { valid: errors.length === 0, errors };
}
@@ -237,6 +283,7 @@ export function serializePodBundleManifest(manifest: PodBundleManifest): string
if (manifest.integrity) doc["integrity"] = { algorithm: manifest.integrity.algorithm, files: manifest.integrity.files };
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
if (manifest.skills && manifest.skills.length > 0) doc["skills"] = manifest.skills;
return stringifyYaml(doc);
}
@@ -274,6 +321,8 @@ export interface LegacyBundleManifest {
integrity?: BundleIntegrity;
provenance?: BundleProvenance;
compatibility?: BundleCompatibility;
/** Item 6 cross-primitive bundling: skill paths to route to the operator skills library on install. */
skills?: string[];
}
/** Validation options */
@@ -366,6 +415,7 @@ export function validateLegacyBundleManifest(
validateProvenanceBlock(m["provenance"], errors);
validateCompatibilityBlock(m["compatibility"], errors);
validateSkillsBlock(m["skills"], errors);
return { valid: errors.length === 0, errors };
}
@@ -414,6 +464,9 @@ export function normalizeLegacyBundleManifest(raw: unknown): LegacyBundleManifes
const compatibility = normalizeCompatibilityBlock(m["compatibility"]);
if (compatibility) result.compatibility = compatibility;
const skills = normalizeSkillsBlock(m["skills"]);
if (skills) result.skills = skills;
return result;
}
@@ -445,5 +498,7 @@ export function serializeLegacyBundleManifest(manifest: LegacyBundleManifest): s
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
if (manifest.skills && manifest.skills.length > 0) doc["skills"] = manifest.skills;
return stringifyYaml(doc);
}
+77
View File
@@ -361,6 +361,83 @@ describe("Bundle types", () => {
const normalized = normalizeBundleManifest(parsed);
expect(normalized.compatibility).toBeUndefined();
});
// -- Item 6 skills block tests (slice-05 Checkpoint 7.1) --
it("missing skills block passes validation (backward compat)", () => {
const raw = { ...VALID_RAW };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("skills as a string array of safe relative paths passes validation", () => {
const raw = { ...VALID_RAW, skills: ["skills/review-kit/SKILL.md", "skills/test-runner/SKILL.md"] };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("skills as a non-array rejected", () => {
const raw = { ...VALID_RAW, skills: "skills/a.md" };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("skills must be an array"))).toBe(true);
});
it("non-string skill entry rejected", () => {
const raw = { ...VALID_RAW, skills: [123, "skills/ok.md"] };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("skills[0]"))).toBe(true);
});
it("unsafe skill path (dot-dot traversal) rejected", () => {
const raw = { ...VALID_RAW, skills: ["../escape/skill.md"] };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("skills[0]"))).toBe(true);
expect(result.errors.some((e) => e.includes("not safe"))).toBe(true);
});
it("round-trip preserves skills through serialize → parse → normalize", () => {
const manifest: BundleManifest = {
schemaVersion: 1,
name: "rt-skills",
version: "1.0.0",
createdAt: "2026-05-18T12:00:00Z",
rigSpec: "rig.yaml",
packages: [{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" }],
integrity: {
algorithm: "sha256",
files: { "rig.yaml": "9".repeat(64), "packages/pkg/package.yaml": "a".repeat(64) },
},
skills: ["skills/foo/SKILL.md", "skills/bar/SKILL.md"],
};
const yaml = serializeBundleManifest(manifest);
expect(yaml).toContain("skills:");
expect(yaml).toContain("skills/foo/SKILL.md");
const parsed = parseBundleManifest(yaml);
const validation = validateBundleManifest(parsed);
expect(validation.valid).toBe(true);
const normalized = normalizeBundleManifest(parsed);
expect(normalized.skills).toBeDefined();
expect(normalized.skills).toEqual(["skills/foo/SKILL.md", "skills/bar/SKILL.md"]);
});
it("missing skills round-trips cleanly (no field emitted in YAML)", () => {
const manifest: BundleManifest = {
schemaVersion: 1,
name: "no-skills",
version: "1.0.0",
createdAt: "2026-05-18T00:00:00Z",
rigSpec: "rig.yaml",
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
};
const yaml = serializeBundleManifest(manifest);
expect(yaml).not.toContain("skills:");
const parsed = parseBundleManifest(yaml);
const normalized = normalizeBundleManifest(parsed);
expect(normalized.skills).toBeUndefined();
});
});
describe("isRelativeSafePath", () => {
@@ -795,6 +795,62 @@ describe("PodBundleManifest validation", () => {
expect(result.errors.some((e) => e.includes("compatibility.min_daemon_version"))).toBe(true);
});
// -- Item 6 skills block tests for v2 (slice-05 Checkpoint 7.1) --
it("v2: missing skills block passes validation (backward compat)", () => {
const raw = {
schema_version: 2, name: "no-skills", version: "1.0",
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("v2: skills as string array passes validation", () => {
const raw = {
schema_version: 2, name: "with-skills", version: "1.0",
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
skills: ["skills/foo/SKILL.md", "skills/bar/SKILL.md"],
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("v2: unsafe skill path rejected", () => {
const raw = {
schema_version: 2, name: "bad-skills", version: "1.0",
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
skills: ["../escape/skill.md"],
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("skills[0]") && e.includes("not safe"))).toBe(true);
});
it("v2: round-trip preserves skills through serialize -> parse", () => {
const manifest: PodBundleManifest = {
schemaVersion: 2, name: "rt-skills-v2", version: "2.0",
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
agents: [{
name: "impl", version: "1.0", path: "agents/impl",
originalRef: "local:agents/impl", hash: "def",
importEntries: [],
}],
skills: ["skills/v2-foo/SKILL.md", "skills/v2-bar/SKILL.md"],
};
const yaml = serializePodBundleManifest(manifest);
expect(yaml).toContain("skills:");
expect(yaml).toContain("skills/v2-foo/SKILL.md");
const parsed = parsePodBundleManifest(yaml);
const validation = validatePodBundleManifest(parsed);
expect(validation.valid).toBe(true);
const m = parsed as Record<string, unknown>;
expect(m["skills"]).toEqual(["skills/v2-foo/SKILL.md", "skills/v2-bar/SKILL.md"]);
});
it("v2: round-trip preserves compatibility through serialize -> parse", () => {
const manifest: PodBundleManifest = {
schemaVersion: 2, name: "rt-compat", version: "2.0",