2490 lines
111 KiB
TypeScript
2490 lines
111 KiB
TypeScript
#!/usr/bin/env bun
|
||
// compose.ts — AIDLC plugin SessionStart compose hook and importable composer.
|
||
//
|
||
// Replaces the former compose.sh + compose-contributions.ts + compose-fragments.ts
|
||
// trio. Folding to one TS file removes the shell-portability bug class entirely:
|
||
// GNU-only `sed -i` becomes replaceAll; the `cp -rn || cp -r` no-clobber (which
|
||
// clobbers on BSD/coreutils>=9.2) becomes an existsSync guard + cpSync; every
|
||
// failure is caught and logged to the hooks-health file instead of swallowed by
|
||
// `2>/dev/null || true`.
|
||
//
|
||
// Runs on SessionStart (Claude/Codex/Cursor/Kiro IDE) or explicitly on Kiro CLI. Harness-agnostic:
|
||
// PLUGIN_ROOT ← CLAUDE_PLUGIN_ROOT | PLUGIN_ROOT | AIDLC_PLUGIN_ROOT |
|
||
// this file's parent plugin directory
|
||
// PROJECT_DIR ← CLAUDE_PROJECT_DIR | AIDLC_PROJECT_DIR | PWD (Codex unsets the first)
|
||
// HARNESS_LEAF ← AIDLC_HARNESS_DIR (".claude" default)
|
||
//
|
||
// Steps: (1) copy new stages/scopes/agents/knowledge/sensors/tools with
|
||
// {{HARNESS_DIR}} substitution, no-clobber; (2) merge contributions
|
||
// (produces/consumes/sensors set-union +
|
||
// prose fragments spliced) into stage SOURCE — durable across recompiles;
|
||
// (3) recompile the graph. Idempotent + short-circuits when nothing changed.
|
||
|
||
import {
|
||
existsSync,
|
||
lstatSync,
|
||
mkdirSync,
|
||
readdirSync,
|
||
readFileSync,
|
||
rmSync,
|
||
statSync,
|
||
writeFileSync,
|
||
} from "node:fs";
|
||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||
import { spawnSync } from "node:child_process";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const PLUGIN_ROOT =
|
||
process.env.CLAUDE_PLUGIN_ROOT ||
|
||
process.env.PLUGIN_ROOT ||
|
||
process.env.AIDLC_PLUGIN_ROOT ||
|
||
dirname(dirname(fileURLToPath(import.meta.url)));
|
||
const PROJECT_DIR = resolve(
|
||
process.env.CLAUDE_PROJECT_DIR ||
|
||
process.env.AIDLC_PROJECT_DIR ||
|
||
process.env.PWD ||
|
||
process.cwd(),
|
||
);
|
||
const HARNESS_LEAF = process.env.AIDLC_HARNESS_DIR || ".claude";
|
||
const HARNESS_DIR = join(PROJECT_DIR, HARNESS_LEAF);
|
||
const HARNESS_NAME = (() => {
|
||
const explicit = process.env.AIDLC_HARNESS_NAME?.trim();
|
||
if (explicit) return explicit;
|
||
try {
|
||
const parsed = JSON.parse(
|
||
readFileSync(join(HARNESS_DIR, "tools", "data", "harness.json"), "utf-8"),
|
||
) as { name?: unknown };
|
||
if (typeof parsed.name === "string" && parsed.name.trim()) return parsed.name.trim();
|
||
} catch {
|
||
// Legacy installs did not record a distribution name.
|
||
}
|
||
if (HARNESS_LEAF === ".aidlc") {
|
||
return existsSync(join(PROJECT_DIR, ".github", "hooks", "aidlc.json"))
|
||
? "copilot"
|
||
: "opencode";
|
||
}
|
||
return HARNESS_LEAF.replace(/^\./, "");
|
||
})();
|
||
const IS_COPILOT = HARNESS_NAME === "copilot";
|
||
const IS_OPENCODE = HARNESS_NAME === "opencode";
|
||
const STAGES_DIR = join(HARNESS_DIR, "aidlc-common", "stages");
|
||
const SKILLS_DIR = IS_COPILOT
|
||
? join(PROJECT_DIR, ".github", "skills")
|
||
: join(HARNESS_DIR, "skills");
|
||
const PHASES = ["initialization", "ideation", "inception", "construction", "operation"];
|
||
const COMPOSE_LOCK_RETRIES = 600;
|
||
const NATIVE_RUNTIME = Boolean(process.env.AIDLC_COMPILED_EXECUTABLE?.trim());
|
||
const SCOPE_TABLE_END = "<!-- END: compiled scope grid -->";
|
||
const STAGE_TABLE_END = "<!-- END: compiled stage graph -->";
|
||
type ParseStageFrontmatter = (raw: string) => Record<string, unknown>;
|
||
interface InstalledAidlcLib {
|
||
hooksHealthDir?: (projectDir: string) => string;
|
||
parseStageFrontmatter?: ParseStageFrontmatter;
|
||
acquireAuditLock?: (
|
||
projectDir: string,
|
||
maxRetries?: number,
|
||
retryMs?: number,
|
||
) => boolean;
|
||
releaseAuditLock?: (projectDir: string) => void;
|
||
}
|
||
interface InstalledStageSchema {
|
||
validateStageFrontmatter?: (
|
||
obj: unknown,
|
||
) => { valid: boolean; errors?: string[] };
|
||
}
|
||
|
||
let installedLibPromise: Promise<InstalledAidlcLib | null> | null = null;
|
||
let installedSchemaPromise: Promise<InstalledStageSchema | null> | null = null;
|
||
let composeOwnsWorkspaceLock = false;
|
||
|
||
function installedAidlcLib(): Promise<InstalledAidlcLib | null> {
|
||
installedLibPromise ??= import(join(HARNESS_DIR, "tools", "aidlc-lib.ts"))
|
||
.then((module) => module as InstalledAidlcLib)
|
||
.catch(() => null);
|
||
return installedLibPromise;
|
||
}
|
||
|
||
function installedStageSchema(): Promise<InstalledStageSchema | null> {
|
||
installedSchemaPromise ??= import(join(HARNESS_DIR, "tools", "aidlc-stage-schema.ts"))
|
||
.then((module) => module as InstalledStageSchema)
|
||
.catch(() => null);
|
||
return installedSchemaPromise;
|
||
}
|
||
|
||
function installedGraphSupportsInheritedLock(): boolean {
|
||
try {
|
||
return readFileSync(
|
||
join(HARNESS_DIR, "tools", "aidlc-graph.ts"),
|
||
"utf-8",
|
||
).includes("AIDLC_WORKSPACE_LOCK_OWNER_PID");
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function slugFromPath(path: string): string {
|
||
return path.replace(/\\/g, "/").split("/").pop()!.replace(/\.md$/, "");
|
||
}
|
||
|
||
const SAFE_PLUGIN_KEY = /^[a-z][a-z0-9-]*$/;
|
||
|
||
function pluginNameFromRoot(): string {
|
||
const supplied = process.env.AIDLC_PLUGIN_KEY?.trim();
|
||
if (supplied && SAFE_PLUGIN_KEY.test(supplied)) return supplied;
|
||
if (!PLUGIN_ROOT) return "plugin";
|
||
for (const md of [
|
||
".claude-plugin",
|
||
".codex-plugin",
|
||
".opencode-plugin",
|
||
".cursor-plugin",
|
||
".plugin",
|
||
".kiro-plugin",
|
||
]) {
|
||
try {
|
||
const m = JSON.parse(readFileSync(join(PLUGIN_ROOT, md, "plugin.json"), "utf-8"));
|
||
if (typeof m?.name === "string" && m.name.startsWith("aidlc-")) {
|
||
const key = m.name.slice("aidlc-".length);
|
||
if (SAFE_PLUGIN_KEY.test(key)) return key;
|
||
}
|
||
} catch { /* try next / fall through */ }
|
||
}
|
||
const fromContent = firstPluginFieldInPlugin();
|
||
if (fromContent) return fromContent;
|
||
const parts = PLUGIN_ROOT.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
|
||
return parts[parts.length - 2] || parts[parts.length - 1] || "plugin";
|
||
}
|
||
|
||
function firstPluginFieldInPlugin(): string | null {
|
||
const roots = ["stages", "scopes", "contributions"];
|
||
const visit = (dir: string): string | null => {
|
||
if (!existsSync(dir)) return null;
|
||
for (const entry of readdirSync(dir).sort()) {
|
||
const path = join(dir, entry);
|
||
if (statSync(path).isDirectory()) {
|
||
const nested = visit(path);
|
||
if (nested) return nested;
|
||
continue;
|
||
}
|
||
if (!entry.endsWith(".md")) continue;
|
||
const match = readFileSync(path, "utf-8").match(
|
||
/^plugin:\s*([a-z][a-z0-9-]*)\s*$/m,
|
||
);
|
||
if (match) return match[1];
|
||
}
|
||
return null;
|
||
};
|
||
for (const root of roots) {
|
||
const found = visit(join(PLUGIN_ROOT, root));
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// The plugin's stable IDENTITY, computed once up front so every per-plugin
|
||
// artifact (the drops file, the retry marker) is keyed the same way — including
|
||
// on the early-exit guards, which flush drops before the main body runs. NOT the
|
||
// plugin-root basename: a projection root is `dist/plugins/<name>/<harness>`, so
|
||
// its basename is the harness leaf (claude/kiro), shared by every plugin — keying
|
||
// on it would let two plugins on one harness clobber each other's drops/retry
|
||
// files. Transactional sync injects the normalized host-manifest key; direct
|
||
// compatibility composition derives the same key from that manifest.
|
||
const PLUGIN_NAME = pluginNameFromRoot();
|
||
const PLUGIN_KEY = PLUGIN_NAME.replace(/[^\w.-]/g, "_");
|
||
|
||
// Resolve the hooks-health dir from the INSTALLED tree so compose drops land
|
||
// exactly where core hooks write theirs (hooksHealthDir under docsRoot) and where
|
||
// --doctor scans — not a bespoke flat path (round-2 major: the old path was read
|
||
// by nothing). Memoized; falls back to the workspace-level dir if the lib can't be
|
||
// loaded (e.g. a partial install), so a drop is never lost.
|
||
let _healthDir: string | null = null;
|
||
async function resolveHealthDir(): Promise<string> {
|
||
if (_healthDir) return _healthDir;
|
||
let dir: string;
|
||
const lib = await installedAidlcLib();
|
||
if (typeof lib?.hooksHealthDir === "function") {
|
||
dir = lib.hooksHealthDir(PROJECT_DIR);
|
||
} else {
|
||
dir = join(PROJECT_DIR, "aidlc", "spaces", "default", "intents", ".aidlc-hooks-health");
|
||
}
|
||
_healthDir = dir;
|
||
return dir;
|
||
}
|
||
|
||
// Buffer drops synchronously so callers stay sync; flush to disk once at the end
|
||
// (and eagerly on the pre-guard early exits). No silent failures. Each drop is
|
||
// tagged with a severity so --doctor can FAIL on a genuinely-degrading drop
|
||
// (a half-applied contribution, a failed compile) but treat a benign/expected one
|
||
// (a documented-deferred surface declared, a version-skew skip) as advisory. The
|
||
// severity is a leading `[degraded]`/`[advisory]` token on the reason field.
|
||
type DropSeverity = "degraded" | "advisory";
|
||
const _drops: string[] = [];
|
||
const _installedToolPayloadDrops: string[] = [];
|
||
let installedToolPayloadAuditRan = false;
|
||
function dropLine(reason: string, severity: DropSeverity): string {
|
||
return `${new Date().toISOString()}\t[${severity}] ${reason.replace(/\r?\n/g, " ")}`;
|
||
}
|
||
function recordDrop(reason: string, severity: DropSeverity = "degraded"): void {
|
||
_drops.push(dropLine(reason, severity));
|
||
}
|
||
function recordInstalledToolPayloadDrop(reason: string): void {
|
||
_installedToolPayloadDrops.push(dropLine(reason, "advisory"));
|
||
}
|
||
// Flush drops as the CURRENT run's complete record: OVERWRITE (not append), and
|
||
// REMOVE the file when the run had none. So the drops file always reflects only
|
||
// the latest compose — it self-clears when the cause is fixed and re-composed,
|
||
// and can't grow unboundedly on a persistent collision (round-5). Doctor reading
|
||
// it therefore sees a live signal, not accumulated history.
|
||
// The drops file is PER-PLUGIN (`plugin-compose-<PLUGIN_KEY>.drops`), not a
|
||
// single shared file: SessionStart runs one compose per installed plugin against
|
||
// the same project, and an overwrite-per-run shared file let the LAST plugin win
|
||
// — a clean plugin's compose (or an early-exit guard) deleted another plugin's
|
||
// live degraded drop, so doctor went green (round-6). Per-plugin files isolate
|
||
// each plugin's signal; doctor globs `*.drops` and aggregates them all.
|
||
async function flushDrops(): Promise<void> {
|
||
try {
|
||
const healthDir = await resolveHealthDir();
|
||
const dropFile = join(healthDir, `plugin-compose-${PLUGIN_KEY}.drops`);
|
||
if (_drops.length === 0) {
|
||
if (existsSync(dropFile)) rmSync(dropFile, { force: true });
|
||
} else {
|
||
mkdirSync(healthDir, { recursive: true });
|
||
writeFileSync(dropFile, _drops.map((l) => l + "\n").join(""), { flag: "w" });
|
||
}
|
||
} catch { /* truly non-fatal */ }
|
||
_drops.length = 0;
|
||
}
|
||
|
||
// Installed test/fixture payloads are a property of ONE harness's installed
|
||
// tools tree, not of whichever plugin happens to compose next. Legacy compose
|
||
// versions recorded no tool-file provenance, so audit them in an ownership-
|
||
// neutral file instead of blaming every current plugin through its per-plugin
|
||
// drops record. The record is keyed by the harness leaf: each compose scans
|
||
// only its own HARNESS_DIR/tools, so a clean compose on one harness (e.g.
|
||
// .codex) must never erase the advisory another harness (.claude) still needs.
|
||
// --doctor scans every *.drops file in the health dir, so scoped names stay
|
||
// visible.
|
||
const HARNESS_KEY = HARNESS_LEAF.replace(/^\./, "").replace(/[^\w.-]/g, "_") || "harness";
|
||
async function flushInstalledToolPayloadDrops(): Promise<void> {
|
||
if (!installedToolPayloadAuditRan) return;
|
||
try {
|
||
const healthDir = await resolveHealthDir();
|
||
const dropFile = join(
|
||
healthDir,
|
||
`plugin-compose-installed-tool-payloads-${HARNESS_KEY}.drops`,
|
||
);
|
||
if (_installedToolPayloadDrops.length === 0) {
|
||
if (existsSync(dropFile)) rmSync(dropFile, { force: true });
|
||
} else {
|
||
mkdirSync(healthDir, { recursive: true });
|
||
writeFileSync(
|
||
dropFile,
|
||
_installedToolPayloadDrops.map((line) => line + "\n").join(""),
|
||
{ flag: "w" },
|
||
);
|
||
}
|
||
} catch { /* truly non-fatal */ }
|
||
_installedToolPayloadDrops.length = 0;
|
||
installedToolPayloadAuditRan = false;
|
||
}
|
||
|
||
function escapeRegExp(s: string): string {
|
||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
function installedOrchestratorSkillPath(): string {
|
||
const harnessSkill = join(SKILLS_DIR, "aidlc", "SKILL.md");
|
||
if (existsSync(harnessSkill)) return harnessSkill;
|
||
const agentsSkill = join(PROJECT_DIR, ".agents", "skills", "aidlc", "SKILL.md");
|
||
if (existsSync(agentsSkill)) return agentsSkill;
|
||
return harnessSkill;
|
||
}
|
||
|
||
function selectedPlugins(): Set<string> | null {
|
||
try {
|
||
const raw = readFileSync(join(HARNESS_DIR, "tools", "data", "harness.json"), "utf-8");
|
||
const parsed = JSON.parse(raw) as { plugins?: unknown };
|
||
if (!Object.hasOwn(parsed, "plugins")) return null;
|
||
if (!Array.isArray(parsed.plugins)) return null;
|
||
const names = parsed.plugins.filter((v): v is string => typeof v === "string" && v.trim().length > 0);
|
||
return new Set(names.map((s) => s.trim()));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function pluginEnabledBySelection(): boolean {
|
||
// Keep in sync with aidlc-lib.ts stageEnabledBySelection.
|
||
const selected = selectedPlugins();
|
||
return selected === null || selected.has(PLUGIN_NAME);
|
||
}
|
||
|
||
function selectCommandForPlugin(): string {
|
||
const selected = selectedPlugins();
|
||
const names = new Set<string>(selected ?? ["aidlc"]);
|
||
names.add(PLUGIN_NAME);
|
||
const selection = [...names].sort().join(",");
|
||
return NATIVE_RUNTIME
|
||
? `aidlc engine plugin select ${selection}`
|
||
: `bun ${HARNESS_LEAF}/tools/aidlc-utility.ts select-plugins ${selection}`;
|
||
}
|
||
|
||
function installedToolCommand(tool: "utility" | "graph" | "runner", args: string[]): string[] {
|
||
const executable = process.env.AIDLC_COMPILED_EXECUTABLE?.trim();
|
||
if (!executable) {
|
||
const files = {
|
||
utility: "aidlc-utility.ts",
|
||
graph: "aidlc-graph.ts",
|
||
runner: "aidlc-runner-gen.ts",
|
||
};
|
||
return [process.execPath, join(HARNESS_DIR, "tools", files[tool]), ...args];
|
||
}
|
||
if (tool === "utility") return [executable, "engine", "gen", ...args];
|
||
if (tool === "graph") return [executable, "engine", "graph", ...args];
|
||
if (args[0] === "write") return [executable, "engine", "gen", "runners", ...args.slice(1)];
|
||
if (args[0] === "check") return [executable, "engine", "gen", "runners", "--check", ...args.slice(1)];
|
||
if (args[0] === "scopes") return [executable, "engine", "gen", "runner-scopes", ...args.slice(1)];
|
||
if (args[0] === "list") return [executable, "engine", "gen", "runner-list", ...args.slice(1)];
|
||
throw new Error(`No compiled dispatcher route for aidlc-runner-gen ${args.join(" ")}`);
|
||
}
|
||
|
||
function installedToolEnv(): NodeJS.ProcessEnv {
|
||
return {
|
||
...process.env,
|
||
// Pin the RESOLVED project dir for spawned tools: a relative
|
||
// CLAUDE_PROJECT_DIR inherited via process.env would re-resolve against
|
||
// the child's cwd (landing on <proj>/<proj>), and a path-variant spelling
|
||
// would key a different workspace-lock hash than the one this hook holds.
|
||
// AIDLC_PROJECT_DIR outranks CLAUDE_PROJECT_DIR in resolveProjectDir.
|
||
AIDLC_PROJECT_DIR: PROJECT_DIR,
|
||
AIDLC_HARNESS_DIR: HARNESS_LEAF,
|
||
AIDLC_HARNESS_NAME: HARNESS_NAME,
|
||
AIDLC_STAGE_GRAPH: join(HARNESS_DIR, "tools", "data", "stage-graph.json"),
|
||
AIDLC_SCOPE_GRID: join(HARNESS_DIR, "tools", "data", "scope-grid.json"),
|
||
AIDLC_STAGES_DIR: STAGES_DIR,
|
||
AIDLC_SENSORS_DIR: join(HARNESS_DIR, "sensors"),
|
||
AIDLC_SCOPES_DIR: join(HARNESS_DIR, "scopes"),
|
||
AIDLC_AGENTS_DIR: join(HARNESS_DIR, "agents"),
|
||
AIDLC_RULES_DIR: join(PROJECT_DIR, "aidlc", "spaces", "default", "memory"),
|
||
...(composeOwnsWorkspaceLock
|
||
? { AIDLC_WORKSPACE_LOCK_OWNER_PID: String(process.pid) }
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
function refreshSkillGeneratedRegion(
|
||
verb: "scope-table" | "stage-table",
|
||
endMarker: string,
|
||
): void {
|
||
const skillMd = installedOrchestratorSkillPath();
|
||
if (!existsSync(skillMd)) {
|
||
recordDrop(`${verb} refresh skipped: ${relative(PROJECT_DIR, skillMd)} not present in this install`, "advisory");
|
||
return;
|
||
}
|
||
|
||
const before = readFileSync(skillMd, "utf-8").replace(/\r\n/g, "\n");
|
||
const kind = verb === "stage-table" ? "stage graph" : "scope grid";
|
||
const beginMatch = before.match(
|
||
new RegExp(`<!-- BEGIN: compiled ${kind}[^\\n]* -->`),
|
||
);
|
||
if (!beginMatch || beginMatch.index === undefined) {
|
||
recordDrop(`${verb} refresh skipped: SKILL.md missing BEGIN marker`, "advisory");
|
||
return;
|
||
}
|
||
const beginIdx = beginMatch.index;
|
||
const endIdx = before.indexOf(endMarker, beginIdx);
|
||
if (endIdx === -1) {
|
||
recordDrop(`${verb} refresh failed: SKILL.md missing END marker after BEGIN marker`);
|
||
return;
|
||
}
|
||
|
||
const [command, ...args] = installedToolCommand("utility", [verb]);
|
||
const r = spawnSync(command, args, {
|
||
cwd: PROJECT_DIR,
|
||
encoding: "utf-8",
|
||
env: installedToolEnv(),
|
||
});
|
||
if (r.status !== 0) {
|
||
recordDrop(`aidlc-utility ${verb} failed: ${(r.stderr || r.stdout || "").slice(0, 400)}`);
|
||
return;
|
||
}
|
||
|
||
const region = (r.stdout || "").replace(/\r\n/g, "\n").replace(/\n$/, "");
|
||
if (
|
||
!new RegExp(`<!-- BEGIN: compiled ${kind}[^\\n]* -->`).test(region) ||
|
||
!region.includes(endMarker)
|
||
) {
|
||
recordDrop(`aidlc-utility ${verb} emitted an invalid generated region`);
|
||
return;
|
||
}
|
||
|
||
const after =
|
||
before.slice(0, beginIdx) +
|
||
region +
|
||
before.slice(endIdx + endMarker.length);
|
||
if (after !== before) writeFileSync(skillMd, after);
|
||
}
|
||
|
||
// Does the INSTALLED engine accept a frontmatter key? Probes the installed
|
||
// validator (not our own copy) so compose never writes a key an older shipped
|
||
// engine would reject — which would permanently break that install's graph
|
||
// compile with only a drops line as evidence (round-3 blocker). Run the installed
|
||
// validateStageFrontmatter against a minimal-but-valid stage carrying the key; if
|
||
// it rejects specifically because of that key, the merge is unsafe here. Fails
|
||
// OPEN (returns true) if the lib can't be loaded — a partial install already
|
||
// can't compile, so we don't add a second failure mode.
|
||
async function installedSchemaAccepts(key: string, sampleValue: unknown): Promise<boolean> {
|
||
const schema = await installedStageSchema();
|
||
if (typeof schema?.validateStageFrontmatter === "function") {
|
||
try {
|
||
const base: Record<string, unknown> = {
|
||
slug: "probe-stage", phase: "construction", execution: "ALWAYS", condition: "always",
|
||
lead_agent: "aidlc-quality-agent", support_agents: [], mode: "inline",
|
||
produces: [], consumes: [], requires_stage: [], inputs: "x", outputs: "y",
|
||
};
|
||
const withKey = { ...base, [key]: sampleValue };
|
||
const res = schema.validateStageFrontmatter(withKey);
|
||
if (res.valid) return true;
|
||
// Rejected — is it BECAUSE of our key? (An unknown/!array error naming it.)
|
||
const errs: string[] = res.errors ?? [];
|
||
return !errs.some((e) => e.includes(key));
|
||
} catch {
|
||
return true; // probe failed → don't block (see note above)
|
||
}
|
||
}
|
||
return true; // module unavailable → don't block (see note above)
|
||
}
|
||
|
||
// Guard: only compose in an AIDLC project, with a resolvable plugin root.
|
||
export async function compose(): Promise<void> {
|
||
if (!existsSync(join(HARNESS_DIR, "tools", "aidlc-graph.ts"))) {
|
||
return; // not an AIDLC project — nothing to do (no drop: not our project)
|
||
}
|
||
// A set-but-wrong PLUGIN_ROOT (e.g. a mistyped path from a hand-run command)
|
||
// would otherwise pass the non-empty check and then find nothing to copy/merge —
|
||
// a silent no-op. Record it so it surfaces in --doctor rather than looking clean.
|
||
if (!existsSync(PLUGIN_ROOT)) {
|
||
recordDrop(`plugin root does not exist: "${PLUGIN_ROOT}" — check the AIDLC_PLUGIN_ROOT path`);
|
||
await flushDrops();
|
||
return;
|
||
}
|
||
|
||
const lockLib = await installedAidlcLib();
|
||
if (
|
||
typeof lockLib?.acquireAuditLock !== "function" ||
|
||
typeof lockLib.releaseAuditLock !== "function" ||
|
||
!installedGraphSupportsInheritedLock()
|
||
) {
|
||
recordDrop(
|
||
"plugin compose skipped: installed engine lacks shared compose/graph workspace-lock support; re-copy the current dist/<harness>/ shell and retry",
|
||
);
|
||
await flushDrops();
|
||
return;
|
||
}
|
||
// A sibling compose can legitimately hold the lock for compile + runner
|
||
// regeneration, so queue for ~60s rather than skipping after the default ~5s.
|
||
if (!lockLib.acquireAuditLock(PROJECT_DIR, COMPOSE_LOCK_RETRIES)) {
|
||
recordDrop("plugin compose skipped: could not acquire the shared workspace lock");
|
||
await flushDrops();
|
||
return;
|
||
}
|
||
composeOwnsWorkspaceLock = true;
|
||
try {
|
||
const composeFileSnapshots = new Map<string, Buffer | null>();
|
||
let composeTransactionOpen = true;
|
||
function writeComposeFile(path: string, data: string | Buffer): void {
|
||
if (composeTransactionOpen && !composeFileSnapshots.has(path)) {
|
||
composeFileSnapshots.set(path, existsSync(path) ? readFileSync(path) : null);
|
||
}
|
||
writeFileSync(path, data);
|
||
}
|
||
function commitComposeWrites(): void {
|
||
composeTransactionOpen = false;
|
||
composeFileSnapshots.clear();
|
||
}
|
||
function rollbackComposeWrites(): void {
|
||
if (!composeTransactionOpen) return;
|
||
const failures: string[] = [];
|
||
for (const [path, before] of [...composeFileSnapshots.entries()].reverse()) {
|
||
try {
|
||
if (before === null) rmSync(path, { force: true });
|
||
else writeFileSync(path, before);
|
||
} catch (e) {
|
||
failures.push(`${relative(PROJECT_DIR, path)}: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
}
|
||
composeTransactionOpen = false;
|
||
composeFileSnapshots.clear();
|
||
if (failures.length > 0) {
|
||
recordDrop(`compose rollback could not restore ${failures.join("; ")}`);
|
||
}
|
||
}
|
||
|
||
if (!pluginEnabledBySelection()) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" composed but is not enabled by tools/data/harness.json; run \`${selectCommandForPlugin()}\` to expose its stages, scopes, and runners`,
|
||
"advisory",
|
||
);
|
||
}
|
||
|
||
// --- helpers ---------------------------------------------------------------
|
||
|
||
function walk(dir: string): string[] {
|
||
if (!existsSync(dir)) return [];
|
||
const out: string[] = [];
|
||
for (const e of readdirSync(dir)) {
|
||
const p = join(dir, e);
|
||
if (statSync(p).isDirectory()) out.push(...walk(p));
|
||
else out.push(p);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Destination-tree walk for the installed-tools audit. Unlike walk(), which
|
||
// only ever traverses trusted projection sources, this walks the USER-writable
|
||
// installed tree, which can contain legacy junk including symlinks: lstat every
|
||
// entry and never follow a link, so a circular directory link cannot ELOOP and
|
||
// an external directory link cannot pull unrelated trees into the audit or
|
||
// escape the tools root. A symlink is returned as a leaf so name-based payload
|
||
// matching still sees a linked "tests" dir or "*.test.ts" file. An entry that
|
||
// vanishes mid-scan is skipped; a readdir failure propagates to the caller,
|
||
// which degrades the audit rather than aborting composition.
|
||
function walkInstalledNoFollow(dir: string): string[] {
|
||
if (!existsSync(dir)) return [];
|
||
const out: string[] = [];
|
||
for (const e of readdirSync(dir)) {
|
||
const p = join(dir, e);
|
||
let st: ReturnType<typeof lstatSync>;
|
||
try {
|
||
st = lstatSync(p);
|
||
} catch {
|
||
continue; // vanished mid-scan
|
||
}
|
||
if (st.isDirectory()) out.push(...walkInstalledNoFollow(p));
|
||
else out.push(p);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
type CopyContext = { file: string; rel: string; content: string };
|
||
type CopyPrecheck = (ctx: CopyContext & { dest: string }) => boolean;
|
||
type CopyTransform = (ctx: CopyContext) => string;
|
||
type ExistingCopyAction = "compare" | "handled" | "written";
|
||
type ExistingCopyHandler = (
|
||
ctx: CopyContext & { dest: string; installed: Buffer },
|
||
) => ExistingCopyAction;
|
||
|
||
function frontmatterName(content: string): string | null {
|
||
return frontmatterScalar(content, "name");
|
||
}
|
||
|
||
function yamlScalarValue(raw: string): string | null {
|
||
const value = raw.trim();
|
||
const doubleQuoted = value.match(/^"((?:\\.|[^"])*)"(?:\s+#.*)?$/);
|
||
if (doubleQuoted) {
|
||
try {
|
||
return JSON.parse(`"${doubleQuoted[1]}"`) as string;
|
||
} catch {
|
||
return doubleQuoted[1];
|
||
}
|
||
}
|
||
const singleQuoted = value.match(/^'((?:''|[^'])*)'(?:\s+#.*)?$/);
|
||
if (singleQuoted) return singleQuoted[1].replaceAll("''", "'");
|
||
const bare = value.replace(/\s+#.*$/, "").trim();
|
||
return bare || null;
|
||
}
|
||
|
||
// Read one top-level frontmatter scalar for parser-unavailable safety checks.
|
||
// Handles the quoted and unquoted forms accepted by the real YAML parser.
|
||
function frontmatterScalar(content: string, key: string): string | null {
|
||
const match = frontmatter(content).match(
|
||
new RegExp(`^${escapeRegExp(key)}:\\s*(.*?)\\s*$`, "m"),
|
||
);
|
||
if (!match) return null;
|
||
return yamlScalarValue(match[1]);
|
||
}
|
||
|
||
function installedNameRoster(dir: string): Map<string, string> {
|
||
const out = new Map<string, string>();
|
||
if (!existsSync(dir)) return out;
|
||
for (const file of readdirSync(dir).filter((f) => f.endsWith(".md")).sort()) {
|
||
const path = join(dir, file);
|
||
try {
|
||
if (statSync(path).isDirectory()) continue;
|
||
const name = frontmatterName(readFileSync(path, "utf-8"));
|
||
if (name && !out.has(name)) out.set(name, path);
|
||
} catch {
|
||
// Installed loader owns malformed-file handling. Compose only needs the
|
||
// parseable names for no-clobber by frontmatter name.
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function installedNameCollisionPrecheck(dst: string, kind: "agents" | "scopes"): CopyPrecheck {
|
||
const installedByName = installedNameRoster(dst);
|
||
return ({ file, dest, content }) => {
|
||
if (!file.endsWith(".md")) return true;
|
||
// `aidlc-` is core's namespace: a scope declaring an aidlc--prefixed
|
||
// plugin: would generate a runner dir on core's `aidlc-<name>` path and
|
||
// silently clobber it. Reject the file, mirroring the compile-side guard.
|
||
const declaredPlugin = frontmatterScalar(content, "plugin");
|
||
if (declaredPlugin?.startsWith("aidlc-")) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" ${kind} file "${relative(PLUGIN_ROOT, file)}" declares plugin "${declaredPlugin}"; the "aidlc-" prefix is reserved for core (it collides with core runner paths); not copied`,
|
||
"degraded",
|
||
);
|
||
return false;
|
||
}
|
||
if (declaredPlugin !== PLUGIN_NAME) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" ${kind} file "${relative(PLUGIN_ROOT, file)}" declares ${declaredPlugin ? `plugin "${declaredPlugin}"` : "no plugin identity"}; owned plugin content must match the host manifest identity; not copied`,
|
||
"degraded",
|
||
);
|
||
return false;
|
||
}
|
||
const name = frontmatterName(content);
|
||
if (!name) return true;
|
||
const collidingFile = installedByName.get(name);
|
||
if (collidingFile && collidingFile !== dest) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" ${kind} file "${relative(PLUGIN_ROOT, file)}" declares name "${name}", colliding with installed file "${relative(PROJECT_DIR, collidingFile)}"; not copied`,
|
||
"degraded",
|
||
);
|
||
return false;
|
||
}
|
||
installedByName.set(name, dest);
|
||
return true;
|
||
};
|
||
}
|
||
|
||
// Sensor manifests are discovered by a FLAT scan of <harness>/sensors/ that
|
||
// indexes ONLY basenames matching `aidlc-<id>.md` (aidlc-graph.ts loadSensors /
|
||
// SENSOR_FILE_REGEX; anything else is silently skipped). Unlike stages/scopes/
|
||
// agents, the sensor copy shipped no precheck, so a plugin manifest under any
|
||
// other name - or nested in a subdirectory the flat scan never reads - composed
|
||
// successfully but was never picked up by graph compile or sensor dispatch, so
|
||
// the author received no signal that the installed sensor could not fire.
|
||
// Reject such a manifest here - skip-and-drop with the required shape named, so
|
||
// the dead file never lands and --doctor surfaces the degraded drop.
|
||
const SENSOR_MANIFEST_NAME = /^aidlc-[a-z][a-z0-9-]*\.md$/;
|
||
function sensorManifestNamePrecheck(): CopyPrecheck {
|
||
const sensorsRoot = join(PLUGIN_ROOT, "sensors");
|
||
const targetRoot = join(HARNESS_DIR, "sensors");
|
||
// null = the name is discoverable; otherwise the reason it is not.
|
||
const undiscoverableReason = (relPosix: string): string | null => {
|
||
const base = relPosix.split("/").pop()!;
|
||
if (!relPosix.includes("/") && SENSOR_MANIFEST_NAME.test(base)) return null;
|
||
return relPosix.includes("/")
|
||
? "it is nested in a subdirectory that the flat sensor scan never reads"
|
||
: `"${base}" lacks the required "aidlc-" prefix`;
|
||
};
|
||
const drop = (relPosix: string, why: string, landed: boolean): void => {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" sensor manifest "${relPosix}" ${landed ? "is composed but never fires" : "would compose but never fire"}: ${why}, and sensor discovery indexes only "aidlc-<id>.md" manifests at the top of sensors/; rename it to "aidlc-<id>.md" (with a matching id)${landed ? ", remove the dead file, and re-run compose" : " and re-run compose - not copied"}`,
|
||
"degraded",
|
||
);
|
||
};
|
||
// copyTreeNoClobber skips prechecks when the destination already exists, so an
|
||
// undiscoverable manifest an OLDER (pre-guard) compose already landed would
|
||
// never reach the precheck below. Audit those up front - otherwise an upgrade
|
||
// leaves the dead sensor silently on disk forever (mirrors the stage guards).
|
||
for (const file of walk(sensorsRoot).filter((p) => p.endsWith(".md"))) {
|
||
const relPosix = relative(sensorsRoot, file).replace(/\\/g, "/");
|
||
const why = undiscoverableReason(relPosix);
|
||
if (why && existsSync(join(targetRoot, relPosix))) drop(relPosix, why, true);
|
||
}
|
||
return ({ file, rel }) => {
|
||
if (!file.endsWith(".md")) return true;
|
||
const relPosix = rel.replace(/\\/g, "/");
|
||
const why = undiscoverableReason(relPosix);
|
||
if (!why) return true;
|
||
drop(relPosix, why, false);
|
||
return false;
|
||
};
|
||
}
|
||
|
||
function doctorScriptOwnershipPrecheck(): CopyPrecheck {
|
||
const toolsRoot = join(PLUGIN_ROOT, "tools");
|
||
const targetRoot = join(HARNESS_DIR, "tools");
|
||
const foreignOwner = (relPosix: string): string | null => {
|
||
const match = basename(relPosix).match(/^(.+)-doctor\.ts$/);
|
||
return match && match[1] !== PLUGIN_NAME ? match[1] : null;
|
||
};
|
||
const drop = (relPosix: string, owner: string, landed: boolean): void => {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" doctor script "${relPosix}" names foreign plugin "${owner}"; doctor scripts must be named "${PLUGIN_NAME}-doctor.ts" so disabled plugins cannot install checks for another identity${landed ? " (the file is already installed; remove it and re-run compose)" : " - not copied"}`,
|
||
"advisory",
|
||
);
|
||
};
|
||
// Older compose versions may already have landed the foreign file. Audit that
|
||
// state up front because copyTreeNoClobber skips prechecks for existing paths.
|
||
for (const file of walk(toolsRoot).filter((p) => p.endsWith("-doctor.ts"))) {
|
||
const relPosix = relative(toolsRoot, file).replace(/\\/g, "/");
|
||
const owner = foreignOwner(relPosix);
|
||
if (owner && existsSync(join(targetRoot, relPosix))) {
|
||
drop(relPosix, owner, true);
|
||
}
|
||
}
|
||
return ({ rel }) => {
|
||
const relPosix = rel.replace(/\\/g, "/");
|
||
const owner = foreignOwner(relPosix);
|
||
if (!owner) return true;
|
||
drop(relPosix, owner, false);
|
||
return false;
|
||
};
|
||
}
|
||
|
||
function toolsTestPayloadPrecheck(): CopyPrecheck {
|
||
const targetRoot = join(HARNESS_DIR, "tools");
|
||
const payloadDirs = new Set(["tests", "__tests__", "fixtures"]);
|
||
const payloadReason = (relPosix: string): string | null => {
|
||
const segments = relPosix.split("/");
|
||
const payloadDir = segments.find((segment) => payloadDirs.has(segment));
|
||
if (payloadDir) return `it uses the reserved "${payloadDir}/" test/fixture path`;
|
||
const base = basename(relPosix);
|
||
return /\.(?:test|spec)\.ts$/.test(base)
|
||
? `its basename "${base}" matches a co-located test pattern`
|
||
: null;
|
||
};
|
||
const drop = (relPosix: string, why: string): void => {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" tool file "${relPosix}" is a test/fixture payload: ${why}; plugin tests and fixtures live in top-level "tests/", never inside "tools/" - not copied`,
|
||
"advisory",
|
||
);
|
||
};
|
||
// Audit the INSTALLED tree independently of the current source projection.
|
||
// Older compose versions recorded no owning plugin for arbitrary tool files,
|
||
// so these diagnostics deliberately do not attribute the path to PLUGIN_NAME.
|
||
// The tree is user-writable: traversal never follows symlinks, and a failed
|
||
// scan must neither abort composition nor let a partial (hence possibly
|
||
// clean-looking) result erase the previous record for this harness.
|
||
installedToolPayloadAuditRan = true;
|
||
try {
|
||
for (const file of walkInstalledNoFollow(targetRoot)) {
|
||
const relPosix = relative(targetRoot, file).replace(/\\/g, "/");
|
||
const why = payloadReason(relPosix);
|
||
if (why) {
|
||
recordInstalledToolPayloadDrop(
|
||
`installed tool file "${relPosix}" is a test/fixture payload: ${why}; originating plugin is not recorded in legacy installs, so ownership is not attributed; remove the file and re-run compose`,
|
||
);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
installedToolPayloadAuditRan = false;
|
||
_installedToolPayloadDrops.length = 0;
|
||
recordDrop(
|
||
`installed tools audit under "${HARNESS_LEAF}/tools" failed (${String(e)}); keeping the previous installed-payload record for this harness - fix the unreadable path and re-run compose`,
|
||
"degraded",
|
||
);
|
||
}
|
||
return ({ rel }) => {
|
||
const relPosix = rel.replace(/\\/g, "/");
|
||
const why = payloadReason(relPosix);
|
||
if (!why) return true;
|
||
drop(relPosix, why);
|
||
return false;
|
||
};
|
||
}
|
||
|
||
function projectOpencodeAgentMemory(raw: string): string {
|
||
return raw
|
||
.replaceAll(".aidlc/rules/aidlc-org.md", "aidlc/spaces/default/memory/org.md")
|
||
.replaceAll(".aidlc/rules/aidlc-team.md", "aidlc/spaces/default/memory/team.md")
|
||
.replaceAll(".aidlc/rules/aidlc-project.md", "aidlc/spaces/default/memory/project.md")
|
||
.replaceAll(".aidlc/rules/", "aidlc/spaces/default/memory/");
|
||
}
|
||
|
||
function projectCursorNativeAgent({ file, content }: CopyContext): string {
|
||
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
||
if (!m) throw new Error(`${file}: plugin agent has no closed frontmatter block`);
|
||
const fm = m[1]
|
||
.split(/\r?\n/)
|
||
.filter((line) => !/^(?:model|tier|effort|variant):/.test(line))
|
||
.join("\n");
|
||
return content.replace(m[0], () => `---\n${fm}\n---\n`);
|
||
}
|
||
|
||
function disallowedToolsValues(content: string): string[] {
|
||
return [
|
||
...frontmatter(content).matchAll(/^disallowedTools:\s*(.*?)\s*$/gm),
|
||
].map((match) => match[1].trim());
|
||
}
|
||
|
||
function projectKiroNativeAgent({ file, content }: CopyContext): string {
|
||
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
||
if (!m) throw new Error(`${file}: plugin agent has no closed frontmatter block`);
|
||
const disallowed = disallowedToolsValues(content);
|
||
if (
|
||
disallowed.length > 1 ||
|
||
(disallowed.length === 1 && !/^Task$/i.test(disallowed[0]))
|
||
) {
|
||
throw new Error(`${file}: Kiro cannot project this disallowedTools declaration`);
|
||
}
|
||
const fm = m[1]
|
||
.split(/\r?\n/)
|
||
.filter((line) => !/^disallowedTools:/.test(line))
|
||
.join("\n");
|
||
return content.replace(m[0], () => `---\n${fm}\n---\n`);
|
||
}
|
||
|
||
function kiroNativeAgentPrecheck(): CopyPrecheck {
|
||
return (ctx) => {
|
||
if (!ctx.content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/)) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" has no closed frontmatter block; not copied to Kiro's agent roster`,
|
||
);
|
||
return false;
|
||
}
|
||
const disallowed = disallowedToolsValues(ctx.content);
|
||
if (disallowed.length > 1) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" declares multiple disallowedTools lines; Kiro accepts at most one disallowedTools: Task line; not copied`,
|
||
);
|
||
return false;
|
||
}
|
||
if (disallowed.length === 1 && !/^Task$/i.test(disallowed[0])) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" cannot project disallowedTools "${disallowed[0]}" to Kiro; not copied`,
|
||
);
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
}
|
||
|
||
function migrateExistingKiroAgent(
|
||
ctx: CopyContext & { dest: string; installed: Buffer },
|
||
): ExistingCopyAction {
|
||
if (!ctx.file.endsWith(".md")) return "compare";
|
||
const installed = ctx.installed.toString("utf-8");
|
||
// This migration is deliberately narrower than ordinary plugin upgrades:
|
||
// only an unchanged pre-projection copy owned by this plugin is rewritten.
|
||
// User edits, core files, and another plugin's files stay under no-clobber.
|
||
if (
|
||
installed !== ctx.content ||
|
||
frontmatterScalar(ctx.content, "plugin") !== PLUGIN_NAME ||
|
||
frontmatterScalar(installed, "plugin") !== PLUGIN_NAME
|
||
) {
|
||
return "compare";
|
||
}
|
||
const disallowed = disallowedToolsValues(ctx.content);
|
||
if (disallowed.length === 0) return "compare";
|
||
if (disallowed.length > 1) {
|
||
const installedRel = relative(PROJECT_DIR, ctx.dest).replace(/\\/g, "/");
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" is already composed with multiple disallowedTools lines; fix the plugin source, remove "${installedRel}", and re-run compose`,
|
||
);
|
||
return "handled";
|
||
}
|
||
if (!/^Task$/i.test(disallowed[0])) {
|
||
const installedRel = relative(PROJECT_DIR, ctx.dest).replace(/\\/g, "/");
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" is already composed with unsupported disallowedTools "${disallowed[0]}"; fix the plugin source, remove "${installedRel}", and re-run compose`,
|
||
);
|
||
return "handled";
|
||
}
|
||
writeComposeFile(ctx.dest, projectKiroNativeAgent(ctx));
|
||
return "written";
|
||
}
|
||
|
||
function opencodeNativeAgentPrecheck(dst: string): CopyPrecheck {
|
||
const collision = installedNameCollisionPrecheck(dst, "agents");
|
||
return (ctx) => {
|
||
if (!collision(ctx)) return false;
|
||
if (!ctx.content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/)) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" has no closed frontmatter block; not copied to OpenCode's native roster`,
|
||
);
|
||
return false;
|
||
}
|
||
const disallowed = frontmatter(ctx.content).match(/^disallowedTools:\s*(.*?)\s*$/m)?.[1];
|
||
if (disallowed && !/^\s*Task\s*$/i.test(disallowed)) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" cannot project disallowedTools "${disallowed}" to OpenCode; not copied`,
|
||
);
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
}
|
||
|
||
const COPILOT_WORKER_TOOLS = ["read", "edit", "search", "execute", "web", "todo"] as const;
|
||
|
||
function copilotNativeAgentPrecheck(dst: string): CopyPrecheck {
|
||
const collision = installedNameCollisionPrecheck(dst, "agents");
|
||
return (ctx) => {
|
||
if (!collision(ctx)) return false;
|
||
if (!ctx.content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/)) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" has no closed frontmatter block; not copied to Copilot's native roster`,
|
||
);
|
||
return false;
|
||
}
|
||
const fm = frontmatter(ctx.content);
|
||
const disallowed = fm.match(/^disallowedTools:\s*(.*?)\s*$/m)?.[1];
|
||
if (!disallowed) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" must declare disallowedTools: Task for Copilot; not copied`,
|
||
);
|
||
return false;
|
||
}
|
||
if (!/^\s*Task\s*$/i.test(disallowed)) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" cannot project disallowedTools "${disallowed}" to Copilot; not copied`,
|
||
);
|
||
return false;
|
||
}
|
||
if (/^tools:/m.test(fm)) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" agent file "${ctx.rel}" declares both tools and disallowedTools; Copilot projection would be ambiguous`,
|
||
);
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
}
|
||
|
||
function emitOpencodeNativeAgent({ file, content }: CopyContext): string {
|
||
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
||
if (!m) throw new Error(`${file}: plugin agent has no closed frontmatter block`);
|
||
let fm = m[1]
|
||
.split(/\r?\n/)
|
||
.filter((line) =>
|
||
!/^disallowedTools:/.test(line) &&
|
||
!/^mode:/.test(line) &&
|
||
!/^tier:/.test(line) &&
|
||
!/^effort:/.test(line)
|
||
)
|
||
.filter((line) => {
|
||
const model = line.match(/^model:\s*(.*?)(?:\s+#.*)?\s*$/)?.[1];
|
||
return model === undefined || model.includes("/");
|
||
})
|
||
.join("\n");
|
||
if (/^permission:\s*$/m.test(fm)) {
|
||
if (/^ {2}task:/m.test(fm)) {
|
||
fm = fm.replace(/^ {2}task:.*$/m, " task: deny");
|
||
} else {
|
||
fm = fm.replace(/^permission:\s*$/m, "permission:\n task: deny");
|
||
}
|
||
} else {
|
||
fm += "\npermission:\n task: deny";
|
||
}
|
||
fm += "\nmode: subagent";
|
||
return content.replace(m[0], () => `---\n${fm}\n---\n`);
|
||
}
|
||
|
||
function emitCopilotNativeAgent({ file, content }: CopyContext): string {
|
||
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
||
if (!m) throw new Error(`${file}: plugin agent has no closed frontmatter block`);
|
||
const fm = m[1]
|
||
.split(/\r?\n/)
|
||
.flatMap((line) => {
|
||
if (/^(tier|model|effort):/.test(line)) return [];
|
||
if (/^disallowedTools:/.test(line)) {
|
||
return [`tools: [${COPILOT_WORKER_TOOLS.map((tool) => `"${tool}"`).join(", ")}]`];
|
||
}
|
||
return [line];
|
||
})
|
||
.join("\n");
|
||
return content.replace(m[0], () => `---\n${fm}\n---\n`);
|
||
}
|
||
|
||
function combinePrechecks(...checks: Array<CopyPrecheck | undefined>): CopyPrecheck {
|
||
return (ctx) => checks.every((check) => check === undefined || check(ctx));
|
||
}
|
||
|
||
// `agent-team` is accepted by the shared schema as a reserved future mode, but
|
||
// no shipped conductor can execute it. Reject new plugin stages on every
|
||
// harness until that runtime consumer exists. Existing no-clobber copies remain
|
||
// on disk and are health-reported so an upgrade never hides the unsafe stage.
|
||
async function unsupportedRuntimeModePrecheck(): Promise<CopyPrecheck> {
|
||
const lib = await installedAidlcLib();
|
||
const parse = typeof lib?.parseStageFrontmatter === "function"
|
||
? lib.parseStageFrontmatter
|
||
: null;
|
||
const parsedModeAndSlug = (
|
||
content: string,
|
||
rel: string,
|
||
): { mode: string | null; slug: string } => {
|
||
let parsed: Record<string, unknown> | null = null;
|
||
if (parse) {
|
||
try {
|
||
parsed = parse(content);
|
||
} catch {
|
||
// The installed schema precheck owns malformed-stage diagnostics.
|
||
}
|
||
}
|
||
return {
|
||
mode: typeof parsed?.mode === "string"
|
||
? parsed.mode
|
||
: frontmatterScalar(content, "mode"),
|
||
slug: typeof parsed?.slug === "string"
|
||
? parsed.slug
|
||
: slugFromPath(rel),
|
||
};
|
||
};
|
||
|
||
// copyTreeNoClobber skips prechecks when the destination exists. Audit
|
||
// installed reserved modes up front so upgrades cannot leave one silently.
|
||
const stagesRoot = join(PLUGIN_ROOT, "stages");
|
||
for (const file of walk(stagesRoot).filter((path) => path.endsWith(".md"))) {
|
||
const rel = relative(stagesRoot, file).replace(/\\/g, "/");
|
||
const dest = join(STAGES_DIR, rel);
|
||
if (!existsSync(dest)) continue;
|
||
let installed = "";
|
||
try {
|
||
installed = readFileSync(dest, "utf-8");
|
||
} catch {
|
||
continue;
|
||
}
|
||
const { mode, slug } = parsedModeAndSlug(installed, rel);
|
||
if (mode !== "agent-team") continue;
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" stage "${slug}" is already composed with reserved mode "agent-team", which has no runtime consumer; change it to inline, subagent, pipeline, or mob, then remove/re-compose the installed stage`,
|
||
);
|
||
}
|
||
|
||
return ({ file, rel, dest, content }) => {
|
||
if (!file.endsWith(".md")) return true;
|
||
const { mode, slug } = parsedModeAndSlug(content, rel);
|
||
if (mode !== "agent-team") return true;
|
||
if (existsSync(dest)) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" stage "${slug}" is already composed with reserved mode "agent-team", which has no runtime consumer; change it to inline, subagent, pipeline, or mob, then remove/re-compose the installed stage`,
|
||
);
|
||
return true;
|
||
}
|
||
composeDroppedStageSlugs.add(slugFromPath(rel));
|
||
composeDroppedStageSlugs.add(slug);
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" stage "${slug}" uses reserved mode "agent-team" and was not composed: the mode has no runtime consumer yet; change it to inline, subagent, pipeline, or mob`,
|
||
);
|
||
return false;
|
||
};
|
||
}
|
||
|
||
interface KiroPluginAgentPrechecks {
|
||
stage: CopyPrecheck;
|
||
agent: CopyPrecheck;
|
||
}
|
||
|
||
// OpenCode and Copilot dispatch from native Markdown rosters outside .aidlc.
|
||
// A plugin persona is the source for the native twin emitted later in this
|
||
// pass, so accept a stage reference only when that twin survives projection.
|
||
function nativeAgentsDir(): string {
|
||
return join(PROJECT_DIR, IS_COPILOT ? ".github" : ".opencode", "agents");
|
||
}
|
||
|
||
function pluginShipsViableNativeAgent(agent: string): boolean {
|
||
const file = join(PLUGIN_ROOT, "agents", `${agent}.md`);
|
||
if (!existsSync(file)) return false;
|
||
let content = "";
|
||
try {
|
||
content = readFileSync(file, "utf-8");
|
||
} catch {
|
||
return false;
|
||
}
|
||
if (!content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/)) return false;
|
||
const declaredPlugin = frontmatter(content).match(/^plugin:\s*(.+)$/m)?.[1].trim();
|
||
if (declaredPlugin?.startsWith("aidlc-")) return false;
|
||
const disallowed = frontmatter(content).match(/^disallowedTools:\s*(.*?)\s*$/m)?.[1];
|
||
if (IS_COPILOT && !disallowed) return false;
|
||
if (disallowed && !/^\s*Task\s*$/i.test(disallowed)) return false;
|
||
if (IS_COPILOT && disallowed && /^tools:/m.test(frontmatter(content))) return false;
|
||
const rosterDir = nativeAgentsDir();
|
||
const name = frontmatterName(content);
|
||
if (!name) return true;
|
||
const collidingFile = installedNameRoster(rosterDir).get(name);
|
||
return !collidingFile || collidingFile === join(rosterDir, `${agent}.md`);
|
||
}
|
||
|
||
function yamlIndent(line: string): number {
|
||
return line.match(/^\s*/)?.[0].length ?? 0;
|
||
}
|
||
|
||
function inlineYamlListHasValue(raw: string): boolean {
|
||
const value = raw.trim();
|
||
if (!value.startsWith("[") || !value.endsWith("]")) return false;
|
||
return value.slice(1, -1).split(",").some((item) => {
|
||
const parsed = yamlScalarValue(item);
|
||
return parsed !== null && parsed !== "null" && parsed !== "~";
|
||
});
|
||
}
|
||
|
||
function blockYamlListHasValue(
|
||
lines: string[],
|
||
start: number,
|
||
parentIndent: number,
|
||
end = lines.length,
|
||
): boolean {
|
||
for (let i = start; i < end; i++) {
|
||
const line = lines[i];
|
||
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
||
const indent = yamlIndent(line);
|
||
if (indent <= parentIndent) break;
|
||
const item = line.trimStart().match(/^-\s+(.+)$/)?.[1];
|
||
if (item && yamlScalarValue(item)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function validIdePermissionRule(
|
||
lines: string[],
|
||
start: number,
|
||
end: number,
|
||
itemIndent: number,
|
||
): boolean {
|
||
let capability: string | null = null;
|
||
let effect: string | null = null;
|
||
let match = false;
|
||
let mappingIndent: number | null = null;
|
||
for (let i = start; i < end; i++) {
|
||
const line = lines[i];
|
||
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
||
let field = line.trimStart();
|
||
let indent = yamlIndent(line);
|
||
if (i === start) {
|
||
const item = field.match(/^-\s*(.*)$/);
|
||
if (!item) return false;
|
||
field = item[1];
|
||
if (!field) continue;
|
||
indent = itemIndent + 2;
|
||
} else if (indent <= itemIndent) {
|
||
return false;
|
||
}
|
||
if (mappingIndent === null) mappingIndent = indent;
|
||
if (indent < mappingIndent) return false;
|
||
if (indent > mappingIndent) continue;
|
||
|
||
const capabilityLine = field.match(/^capability:\s*(.*)$/);
|
||
if (capabilityLine) {
|
||
capability = yamlScalarValue(capabilityLine[1]);
|
||
continue;
|
||
}
|
||
const effectLine = field.match(/^effect:\s*(.*)$/);
|
||
if (effectLine) {
|
||
effect = yamlScalarValue(effectLine[1]);
|
||
continue;
|
||
}
|
||
const matchLine = field.match(/^match:\s*(.*)$/);
|
||
if (matchLine) {
|
||
match = matchLine[1].trim()
|
||
? inlineYamlListHasValue(matchLine[1])
|
||
: blockYamlListHasValue(lines, i + 1, indent, end);
|
||
}
|
||
}
|
||
return Boolean(capability && (effect === "allow" || effect === "deny") && match);
|
||
}
|
||
|
||
// Kiro IDE dispatches Markdown agents only when their frontmatter carries a
|
||
// non-empty tools grant and a permissions.rules list made entirely of
|
||
// capability/effect/match entries. Fail closed on empty maps/lists and partial
|
||
// entries: those files exist but do not grant a usable dispatch surface.
|
||
function installedIdeAgentIsDispatchable(agentsDir: string, agent: string): boolean {
|
||
let content = "";
|
||
try {
|
||
content = readFileSync(join(agentsDir, `${agent}.md`), "utf-8");
|
||
} catch {
|
||
return false;
|
||
}
|
||
const fm = frontmatter(content);
|
||
if (!fm) return false;
|
||
const lines = fm.split(/\r?\n/);
|
||
const toolsIndex = lines.findIndex((line) => /^tools:\s*/.test(line));
|
||
if (toolsIndex < 0) return false;
|
||
const toolsValue = lines[toolsIndex].replace(/^tools:\s*/, "");
|
||
const toolsGranted = toolsValue.trim()
|
||
? inlineYamlListHasValue(toolsValue)
|
||
: blockYamlListHasValue(lines, toolsIndex + 1, 0);
|
||
if (!toolsGranted) return false;
|
||
|
||
const permissionsIndex = lines.findIndex((line) => /^permissions:\s*/.test(line));
|
||
if (permissionsIndex < 0) return false;
|
||
if (lines[permissionsIndex].replace(/^permissions:\s*/, "").trim()) return false;
|
||
const permissionsEnd = lines.findIndex(
|
||
(line, index) => index > permissionsIndex && /^[A-Za-z_][\w.-]*\s*:/.test(line),
|
||
);
|
||
const blockEnd = permissionsEnd < 0 ? lines.length : permissionsEnd;
|
||
const rulesIndex = lines.findIndex(
|
||
(line, index) =>
|
||
index > permissionsIndex &&
|
||
index < blockEnd &&
|
||
/^\s+rules:\s*/.test(line),
|
||
);
|
||
if (rulesIndex < 0) return false;
|
||
const rulesValue = lines[rulesIndex].replace(/^\s+rules:\s*/, "");
|
||
if (rulesValue.trim()) return false;
|
||
|
||
const rulesIndent = yamlIndent(lines[rulesIndex]);
|
||
let itemIndent = -1;
|
||
const itemIndexes: number[] = [];
|
||
for (let i = rulesIndex + 1; i < blockEnd; i++) {
|
||
const line = lines[i];
|
||
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
||
const indent = yamlIndent(line);
|
||
if (indent <= rulesIndent) return false;
|
||
if (line.trimStart().startsWith("-")) {
|
||
if (itemIndent < 0) itemIndent = indent;
|
||
if (indent === itemIndent) itemIndexes.push(i);
|
||
} else if (itemIndent < 0 || indent <= itemIndent) {
|
||
return false;
|
||
}
|
||
}
|
||
if (itemIndexes.length === 0) return false;
|
||
return itemIndexes.every((start, index) =>
|
||
validIdePermissionRule(
|
||
lines,
|
||
start,
|
||
itemIndexes[index + 1] ?? blockEnd,
|
||
itemIndent,
|
||
)
|
||
);
|
||
}
|
||
|
||
// Kiro CLI, Kiro IDE, Codex, OpenCode, and Copilot each require a native
|
||
// dispatch surface. The two Kiro variants share .kiro but are distinguished by
|
||
// the recorded harness name: CLI uses agent-v1 JSON + trustedAgents, while IDE
|
||
// uses capability-bearing Markdown and never reads the CLI conductor JSON.
|
||
async function kiroPluginAgentPrechecks(): Promise<KiroPluginAgentPrechecks | null> {
|
||
if (
|
||
HARNESS_LEAF !== ".kiro" &&
|
||
HARNESS_LEAF !== ".codex" &&
|
||
HARNESS_LEAF !== ".aidlc"
|
||
) {
|
||
return null;
|
||
}
|
||
const isKiroIde = HARNESS_NAME === "kiro-ide";
|
||
const isKiroCli = HARNESS_LEAF === ".kiro" && !isKiroIde;
|
||
const surfaceExt = isKiroIde
|
||
? ".md"
|
||
: HARNESS_LEAF === ".kiro"
|
||
? ".json"
|
||
: HARNESS_LEAF === ".codex"
|
||
? ".toml"
|
||
: ".md";
|
||
const surfaceDir = HARNESS_LEAF === ".aidlc"
|
||
? nativeAgentsDir()
|
||
: join(HARNESS_DIR, "agents");
|
||
const trustedAgents = new Set<string>();
|
||
if (isKiroCli) {
|
||
try {
|
||
const conductor = JSON.parse(
|
||
readFileSync(join(HARNESS_DIR, "agents", "aidlc.json"), "utf-8"),
|
||
) as {
|
||
toolsSettings?: { subagent?: { trustedAgents?: unknown } };
|
||
};
|
||
const configured = conductor.toolsSettings?.subagent?.trustedAgents;
|
||
if (Array.isArray(configured)) {
|
||
for (const agent of configured) {
|
||
if (typeof agent === "string") trustedAgents.add(agent);
|
||
}
|
||
}
|
||
} catch {
|
||
// Empty set is fail-closed: a broken conductor cannot dispatch any agent.
|
||
}
|
||
}
|
||
interface DispatchGap {
|
||
agent: string;
|
||
missingSurface: boolean;
|
||
missingTrust: boolean;
|
||
}
|
||
const remediationFor = (gap: DispatchGap, isReviewer: boolean): string => {
|
||
const requirements: string[] = [];
|
||
if (gap.missingSurface) {
|
||
requirements.push(
|
||
isKiroIde
|
||
? `author ${HARNESS_LEAF}/agents/${gap.agent}.md with a non-empty tools: grant and well-formed permissions.rules capability/effect/match entries`
|
||
: HARNESS_LEAF === ".kiro"
|
||
? `author ${HARNESS_LEAF}/agents/${gap.agent}.json (agent-v1 JSON)`
|
||
: HARNESS_LEAF === ".codex"
|
||
? `author ${HARNESS_LEAF}/agents/${gap.agent}.toml (the shipped aidlc-*-agent.toml shape)`
|
||
: IS_COPILOT
|
||
? `author .github/agents/${gap.agent}.md (a Copilot custom agent with closed frontmatter)`
|
||
: `author .opencode/agents/${gap.agent}.md (an OpenCode subagent with closed frontmatter)`,
|
||
);
|
||
}
|
||
if (gap.missingTrust) {
|
||
requirements.push(
|
||
`add "${gap.agent}" to toolsSettings.subagent.trustedAgents in ${HARNESS_LEAF}/agents/aidlc.json`,
|
||
);
|
||
}
|
||
const alternative = isReviewer
|
||
? "remove the stage's reviewer: field"
|
||
: "change the stage's mode to inline";
|
||
return `${requirements.join(" and ")}, or ${alternative}`;
|
||
};
|
||
|
||
const stagesRoot = join(PLUGIN_ROOT, "stages");
|
||
const stageFiles = walk(stagesRoot).filter((path) => path.endsWith(".md"));
|
||
if (stageFiles.length === 0) return null;
|
||
|
||
// Existing stages cannot be deleted by a no-clobber compose hook, but they
|
||
// still need a degraded health row when unsafe. Otherwise an install upgraded
|
||
// from the pre-guard composer remains silently wedged forever.
|
||
const alreadyComposed = (rel: string): boolean => existsSync(join(STAGES_DIR, rel));
|
||
// The self-heal probe below filters expected graph slugs by FILENAME STEM,
|
||
// so bookkeeping must record the stem (the frontmatter slug is recorded too
|
||
// for human-readable drop correlation, but the stem is load-bearing).
|
||
const recordDroppedStage = (rel: string, slug: string | null): void => {
|
||
composeDroppedStageSlugs.add(slugFromPath(rel));
|
||
if (slug) composeDroppedStageSlugs.add(slug);
|
||
};
|
||
|
||
const lib = await installedAidlcLib();
|
||
const parse = typeof lib?.parseStageFrontmatter === "function"
|
||
? lib.parseStageFrontmatter
|
||
: null;
|
||
if (!parse) {
|
||
// Without the installed parser, accept only an explicitly inline,
|
||
// reviewer-free stage. This scalar fallback handles quoted YAML values and
|
||
// fails closed on reserved, unknown, or missing modes.
|
||
const rejected = new Set<string>();
|
||
for (const file of stageFiles) {
|
||
let raw = "";
|
||
try {
|
||
raw = readFileSync(file, "utf-8");
|
||
} catch {
|
||
continue;
|
||
}
|
||
const rel = relative(stagesRoot, file).replace(/\\/g, "/");
|
||
const mode = frontmatterScalar(raw, "mode");
|
||
const reviewer = frontmatterScalar(raw, "reviewer");
|
||
if (mode === "inline" && !reviewer) continue;
|
||
if (alreadyComposed(rel)) {
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" stage "${rel}" is already composed but its mode/reviewer dispatch safety cannot be validated because the installed stage parser is unavailable; restore tools/aidlc-lib.ts and re-run compose, then remediate or remove the installed stage`,
|
||
);
|
||
continue;
|
||
}
|
||
rejected.add(rel);
|
||
recordDroppedStage(rel, null);
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" stage "${rel}" is not explicitly inline and reviewer-free and was not composed: the installed stage parser is unavailable, so its agent references cannot be validated for ${HARNESS_LEAF} dispatch; re-copy your dist/<harness>/ shell (restoring tools/aidlc-lib.ts) and re-run compose — explicitly inline stages and personas composed normally`,
|
||
);
|
||
}
|
||
return {
|
||
stage: ({ rel }) => !rejected.has(rel.replace(/\\/g, "/")),
|
||
agent: () => true,
|
||
};
|
||
}
|
||
|
||
const rejectedStageFiles = new Set<string>();
|
||
for (const file of stageFiles) {
|
||
let parsed: Record<string, unknown>;
|
||
try {
|
||
parsed = parse(readFileSync(file, "utf-8"));
|
||
} catch {
|
||
continue;
|
||
}
|
||
const mode = typeof parsed.mode === "string" ? parsed.mode : "";
|
||
const rel = relative(stagesRoot, file).replace(/\\/g, "/");
|
||
const slug = typeof parsed.slug === "string"
|
||
? parsed.slug
|
||
: slugFromPath(rel);
|
||
const supportAgents = Array.isArray(parsed.support_agents)
|
||
? parsed.support_agents.filter((agent): agent is string => typeof agent === "string")
|
||
: [];
|
||
// Inline is the only topology that does not dispatch the stage body.
|
||
// Treat every other parsed mode as dispatched so future schema modes
|
||
// inherit agent surface/trust validation automatically.
|
||
const dispatches = mode !== "inline";
|
||
|
||
// The reviewer dispatches on EVERY gated stage — the conductor's §12a step
|
||
// fires whenever directive.reviewer is present, independent of the stage's
|
||
// body mode — so it is checked even on inline stages. Lead + supports
|
||
// dispatch only under a dispatched body topology.
|
||
const leadAgent = typeof parsed.lead_agent === "string" ? parsed.lead_agent : "";
|
||
const reviewer = typeof parsed.reviewer === "string" ? parsed.reviewer : "";
|
||
const dispatchedAgents = [
|
||
...(dispatches ? [leadAgent, ...supportAgents] : []),
|
||
reviewer,
|
||
];
|
||
const gaps = new Map<string, DispatchGap>();
|
||
for (const agent of dispatchedAgents) {
|
||
if (!agent || gaps.has(agent)) continue;
|
||
const gap = {
|
||
agent,
|
||
missingSurface: isKiroIde
|
||
? !installedIdeAgentIsDispatchable(surfaceDir, agent)
|
||
: !existsSync(join(surfaceDir, `${agent}${surfaceExt}`)) &&
|
||
!(HARNESS_LEAF === ".aidlc" && pluginShipsViableNativeAgent(agent)),
|
||
missingTrust: isKiroCli && !trustedAgents.has(agent),
|
||
};
|
||
if (gap.missingSurface || gap.missingTrust) gaps.set(agent, gap);
|
||
}
|
||
if (gaps.size === 0) continue;
|
||
|
||
const existing = alreadyComposed(rel);
|
||
if (!existing) {
|
||
rejectedStageFiles.add(rel);
|
||
recordDroppedStage(rel, slug);
|
||
}
|
||
for (const gap of gaps.values()) {
|
||
const agent = gap.agent;
|
||
const isReviewerOnly =
|
||
agent === reviewer && !(dispatches && (agent === leadAgent || supportAgents.includes(agent)));
|
||
const role = isReviewerOnly ? "as reviewer" : `with mode "${mode}"`;
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" stage "${slug}" references agent "${agent}" ${role} and ${existing ? "is already composed but remains undispatchable" : "was not composed"}: ${remediationFor(gap, isReviewerOnly)}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return {
|
||
stage: ({ rel }) => !rejectedStageFiles.has(rel.replace(/\\/g, "/")),
|
||
// Markdown personas remain useful to accepted inline stages even when a
|
||
// different stage that references the same persona was rejected.
|
||
agent: () => true,
|
||
};
|
||
}
|
||
|
||
// Validate a plugin stage file against the INSTALLED engine's schema before
|
||
// copying it into the install. Compile is all-or-nothing - aidlc-graph.ts
|
||
// throws on the first schema-invalid stage file - so one bad copy (e.g. a
|
||
// stale plugin tree still authoring the renamed bundle: key) would brick the
|
||
// install's EVERY later graph compile until the file is hand-deleted.
|
||
// Skip-and-drop instead, naming the file and the validator's errors, so the
|
||
// bad stage never lands and the rest of the plugin composes normally. A
|
||
// frontmatter-only stage (empty body) is dropped the same way: it compiles
|
||
// and routes while being behaviorally dead. Fails OPEN (copies) when the
|
||
// installed lib can't be loaded - a partial install already can't compile,
|
||
// so we don't add a second failure mode.
|
||
// Slugs a compose precheck refused, so the "did my stages reach the compiled
|
||
// graph?" self-heal probe below does not see a deliberately-dropped stage as a
|
||
// failed compile and force a recompile every session.
|
||
const composeDroppedStageSlugs = new Set<string>();
|
||
async function installedStageSchemaPrecheck(): Promise<CopyPrecheck> {
|
||
let parse: ParseStageFrontmatter | null = null;
|
||
let validate: ((obj: unknown) => { valid: boolean; errors?: string[] }) | null = null;
|
||
const [lib, schema] = await Promise.all([
|
||
installedAidlcLib(),
|
||
installedStageSchema(),
|
||
]);
|
||
if (
|
||
typeof lib?.parseStageFrontmatter === "function" &&
|
||
typeof schema?.validateStageFrontmatter === "function"
|
||
) {
|
||
parse = lib.parseStageFrontmatter;
|
||
validate = schema.validateStageFrontmatter;
|
||
}
|
||
return ({ file, rel, content }) => {
|
||
if (!file.endsWith(".md")) return true;
|
||
let errors: string[] = [];
|
||
if (parse && validate) {
|
||
try {
|
||
const res = validate(parse(content));
|
||
errors = res.valid ? [] : (res.errors ?? ["schema validation failed"]);
|
||
} catch (e) {
|
||
errors = [e instanceof Error ? e.message : String(e)];
|
||
}
|
||
}
|
||
if (errors.length === 0) {
|
||
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
|
||
if (body.trim().length === 0) {
|
||
errors = ["stage body is empty after the frontmatter fence (a behaviorally dead stage)"];
|
||
}
|
||
}
|
||
// Mirror compile's ownership invariants (aidlc-graph.ts) - they are
|
||
// compile-time THROWS, so a landed file violating them bricks the whole
|
||
// graph compile exactly like a schema-invalid one.
|
||
if (errors.length === 0) {
|
||
const declaredPlugin = frontmatterScalar(content, "plugin");
|
||
const declaredSlug = frontmatterScalar(content, "slug") ?? "";
|
||
if (declaredPlugin === "aidlc") {
|
||
errors = ['declares plugin "aidlc"; omit plugin for core stages'];
|
||
} else if (declaredPlugin?.startsWith("aidlc-")) {
|
||
errors = [`declares plugin "${declaredPlugin}"; the "aidlc-" prefix is reserved for core (a plugin named aidlc-<x> collides with core runner paths)`];
|
||
} else if (declaredPlugin !== PLUGIN_NAME) {
|
||
errors = [`declares ${declaredPlugin ? `plugin "${declaredPlugin}"` : "no plugin identity"}; owned plugin content must match the host manifest identity "${PLUGIN_NAME}"`];
|
||
} else if (declaredPlugin && !declaredSlug.startsWith(`${declaredPlugin}-`)) {
|
||
errors = [`slug "${declaredSlug}" does not start with "${declaredPlugin}-" (plugin-owned stage slugs must carry the plugin prefix)`];
|
||
}
|
||
}
|
||
if (errors.length === 0) return true;
|
||
composeDroppedStageSlugs.add(slugFromPath(rel));
|
||
recordDrop(
|
||
`plugin "${PLUGIN_NAME}" stage file "${rel}" not composed: ${errors.join("; ")} - fix the plugin's stage file and re-run compose`,
|
||
);
|
||
return false;
|
||
};
|
||
}
|
||
|
||
// No-clobber copy of one tree into another, with {{HARNESS_DIR}} substitution on
|
||
// .md prose. NEVER overwrites an existing dest (portable no-clobber — the point
|
||
// of the former `cp -n`, done right). Returns true if anything was written.
|
||
// `kind` labels the tree for the collision drop-log: a
|
||
// dest that already exists with DIFFERENT content is a real collision (a plugin
|
||
// trying to ship a file that shadows core or another plugin) and is dropped-with-
|
||
// log — silently skipping it made a plugin "override" a no-op with no evidence
|
||
// (round-4). An identical dest is a benign idempotent re-run (no log).
|
||
function copyTreeNoClobber(
|
||
src: string,
|
||
dst: string,
|
||
kind: string,
|
||
precheck?: CopyPrecheck,
|
||
transform?: CopyTransform,
|
||
existingHandler?: ExistingCopyHandler,
|
||
composedPaths?: Set<string>,
|
||
): boolean {
|
||
if (!existsSync(src)) return false;
|
||
let wrote = false;
|
||
for (const file of walk(src)) {
|
||
const rel = relative(src, file);
|
||
const dest = join(dst, rel);
|
||
let buf = readFileSync(file);
|
||
if (file.endsWith(".md")) {
|
||
buf = Buffer.from(buf.toString("utf-8").replaceAll("{{HARNESS_DIR}}", HARNESS_LEAF));
|
||
}
|
||
if (existsSync(dest)) {
|
||
// no-clobber — never replace core/another plugin. Log only a genuine
|
||
// content collision, not an identical idempotent re-copy. The installed
|
||
// copy was written transformed, so transform before comparing; a source
|
||
// the transform rejects cannot equal any installed copy.
|
||
const installed = readFileSync(dest);
|
||
const existingAction = existingHandler?.({
|
||
file,
|
||
rel,
|
||
dest,
|
||
content: buf.toString("utf-8"),
|
||
installed,
|
||
}) ?? "compare";
|
||
if (existingAction === "written") {
|
||
composedPaths?.add(rel.replace(/\\/g, "/"));
|
||
wrote = true;
|
||
continue;
|
||
}
|
||
if (existingAction === "handled") continue;
|
||
let current: Buffer | null = buf;
|
||
if (transform) {
|
||
try {
|
||
current = Buffer.from(transform({ file, rel, content: buf.toString("utf-8") }));
|
||
} catch {
|
||
current = null;
|
||
}
|
||
}
|
||
if (current !== null && installed.equals(current)) {
|
||
composedPaths?.add(rel.replace(/\\/g, "/"));
|
||
} else {
|
||
recordDrop(`${kind} "${rel}" collides with an existing file (core or another plugin); not overwritten — rename it to a plugin-namespaced path`);
|
||
}
|
||
continue;
|
||
}
|
||
// Precheck BEFORE transform, on the pre-transform text: the precheck is
|
||
// the skip-and-drop gate for exactly the shapes a transform throws on
|
||
// (emitOpencodeNativeAgent on a frontmatter-less persona), so transforming
|
||
// first turns a one-file drop into an aborted compose. It also keeps the
|
||
// precheck's shape checks live — the emitter strips disallowedTools, so a
|
||
// post-transform precheck could never reject an un-projectable value.
|
||
if (precheck && !precheck({ file, rel, dest, content: buf.toString("utf-8") })) continue;
|
||
if (transform) {
|
||
buf = Buffer.from(transform({ file, rel, content: buf.toString("utf-8") }));
|
||
}
|
||
mkdirSync(join(dest, ".."), { recursive: true });
|
||
writeComposeFile(dest, buf);
|
||
composedPaths?.add(rel.replace(/\\/g, "/"));
|
||
wrote = true;
|
||
}
|
||
return wrote;
|
||
}
|
||
|
||
function findStageFile(slug: string): string | null {
|
||
for (const phase of PHASES) {
|
||
const p = join(STAGES_DIR, phase, `${slug}.md`);
|
||
if (existsSync(p)) return p;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Read half: a single frontmatter split (LF/CRLF tolerant) shared by every read
|
||
// in this file — after the three-file fold there is one parser here, not two, so
|
||
// a robustness fix lands once (review #8). Contribution frontmatter is a distinct
|
||
// shape (target/adds/fragments) from stage frontmatter, so it stays local rather
|
||
// than importing aidlc-lib's stage parser.
|
||
function frontmatter(content: string): string {
|
||
return content.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? "";
|
||
}
|
||
|
||
// Append items to a top-level list field, or replace the inline-empty `field: []`
|
||
// form with a block (fixes the silent-drop asymmetry, review #5). Idempotent.
|
||
// Returns the (possibly unchanged) content; logs when a field is absent entirely.
|
||
// `added` (when given) collects the values THIS call actually wrote - the
|
||
// contribution sidecar records actually-added entries, never declared ones, so
|
||
// a later removal can't strip a value core (or another plugin) already had.
|
||
function mergeListField(content: string, field: string, items: string[], target: string, added?: string[]): string {
|
||
items = [...new Set(items)];
|
||
if (items.length === 0) return content;
|
||
const emptyRe = new RegExp(`^${field}:\\s*\\[\\s*\\]\\s*$`, "m");
|
||
if (emptyRe.test(content)) {
|
||
added?.push(...items);
|
||
return content.replace(emptyRe, `${field}:\n` + items.map((i) => ` - ${i}`).join("\n"));
|
||
}
|
||
const blockRe = new RegExp(`^(${field}:\\n(?: - .+\\n)*)`, "m");
|
||
const m = content.match(blockRe);
|
||
if (!m) {
|
||
recordDrop(`contribution to ${target}: no '${field}:' field to append to (adds dropped)`);
|
||
return content;
|
||
}
|
||
const existing = new Set(
|
||
[...m[1].matchAll(/^ {2}- (.+)$/gm)]
|
||
.map((x) => yamlScalarValue(x[1]))
|
||
.filter((value): value is string => value !== null),
|
||
);
|
||
const toAdd = items.filter((i) => !existing.has(i));
|
||
if (toAdd.length === 0) return content;
|
||
added?.push(...toAdd);
|
||
return content.replace(blockRe, m[1] + toAdd.map((i) => ` - ${i}`).join("\n") + "\n");
|
||
}
|
||
|
||
// Append consumes objects (artifact + required + optional conditional_on).
|
||
// Handles block + `consumes: []`.
|
||
type ConsumeEntry = { artifact: string; required: boolean; conditional_on?: string };
|
||
function mergeConsumes(content: string, entries: ConsumeEntry[], target: string, added?: ConsumeEntry[]): string {
|
||
if (entries.length === 0) return content;
|
||
const render = (e: ConsumeEntry) =>
|
||
` - artifact: ${e.artifact}\n required: ${e.required}` +
|
||
(e.conditional_on ? `\n conditional_on: ${e.conditional_on}` : "");
|
||
const emptyRe = /^consumes:\s*\[\s*\]\s*$/m;
|
||
if (emptyRe.test(content)) {
|
||
added?.push(...entries.map((entry) => ({ ...entry })));
|
||
return content.replace(emptyRe, "consumes:\n" + entries.map(render).join("\n"));
|
||
}
|
||
// Each entry is `- artifact:` plus every following indented continuation line
|
||
// (`required:`, `conditional_on:`). Matching those continuations is what keeps
|
||
// an append AFTER the last core entry — omit `conditional_on` and the block
|
||
// ends early, splicing the new entry INSIDE a core entry and stealing its
|
||
// brownfield gate (round-2 major). The new entries land past the whole block.
|
||
const blockRe = /^(consumes:\n(?: {2}- artifact:.*\n(?: {4}(?:required|conditional_on):.*\n)*)*)/m;
|
||
const m = content.match(blockRe);
|
||
if (!m) {
|
||
recordDrop(`contribution to ${target}: no 'consumes:' field to append to`);
|
||
return content;
|
||
}
|
||
const existing = new Set([...m[1].matchAll(/- artifact:\s*([\w-]+)/g)].map((x) => x[1]));
|
||
const toAdd = entries.filter((e) => !existing.has(e.artifact));
|
||
if (toAdd.length === 0) return content;
|
||
added?.push(...toAdd.map((entry) => ({ ...entry })));
|
||
return content.replace(blockRe, m[1] + toAdd.map(render).join("\n") + "\n");
|
||
}
|
||
|
||
// Merge required_sections (quoted-string values, e.g. "Branch Coverage"). Unlike
|
||
// produces/sensors, a core stage often has NO required_sections field, so this
|
||
// ADDS the field (before the closing frontmatter `---`) when absent, appends to
|
||
// the block form, and replaces the inline-empty `[]` form. Idempotent by value.
|
||
// `meta.created` is set when this call ADDED the field itself, so a later
|
||
// removal knows to delete the whole field rather than leave an empty block.
|
||
function mergeRequiredSections(content: string, items: string[], target: string, added?: string[], meta?: { created?: boolean }): string {
|
||
if (items.length === 0) return content;
|
||
const render = (list: string[]) => list.map((s) => ` - "${s}"`).join("\n");
|
||
const emptyRe = /^required_sections:\s*\[\s*\]\s*$/m;
|
||
if (emptyRe.test(content)) {
|
||
added?.push(...items);
|
||
return content.replace(emptyRe, "required_sections:\n" + render(items));
|
||
}
|
||
const blockRe = /^(required_sections:\n(?: {2}- .+\n)*)/m;
|
||
const m = content.match(blockRe);
|
||
if (m) {
|
||
const existing = new Set([...m[1].matchAll(/^ {2}- (.+?)\s*$/gm)].map((x) => x[1].replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1")));
|
||
const toAdd = items.filter((s) => !existing.has(s));
|
||
if (toAdd.length === 0) return content;
|
||
added?.push(...toAdd);
|
||
return content.replace(blockRe, m[1] + render(toAdd) + "\n");
|
||
}
|
||
// Field absent — insert it just before the closing frontmatter `---`. The
|
||
// closing fence may be followed by a newline OR sit at EOF (a stage file with
|
||
// no trailing newline is valid) — `(?:\n|$)` tolerates both; requiring `\r?\n`
|
||
// after `---` silently dropped the whole merge on a newline-less file (round-4).
|
||
const fmClose = content.match(/^---\r?\n[\s\S]*?\n(---)(?:\r?\n|$)/);
|
||
if (!fmClose) {
|
||
recordDrop(`contribution to ${target}: cannot add required_sections (no frontmatter block)`);
|
||
return content;
|
||
}
|
||
added?.push(...items);
|
||
if (meta) meta.created = true;
|
||
const insertAt = fmClose.index! + fmClose[0].lastIndexOf("---");
|
||
return content.slice(0, insertAt) + "required_sections:\n" + render(items) + "\n" + content.slice(insertAt);
|
||
}
|
||
|
||
// Resolve a fragment anchor to a char offset. Anchors are validated + escaped
|
||
// (review #6) — a malformed anchor is skipped-with-log, never a thrown regex. A
|
||
// valid anchor whose target heading is ABSENT also returns -1 but logs a distinct
|
||
// "not found" drop (round-4: the not-found case was silent, so a contribution's
|
||
// frontmatter `adds` landed while its prose vanished — a half-applied merge).
|
||
function locateAnchor(content: string, anchor: string, target: string): number {
|
||
const stepAnchor = (kind: "after" | "before"): number => {
|
||
const n = anchor.slice(anchor.indexOf(":") + 1);
|
||
if (!/^\d+$/.test(n)) { recordDrop(`contribution to ${target}: bad ${kind}-step anchor "${anchor}" (step must be an integer)`); return -1; }
|
||
const want = Number(n);
|
||
// Match a plain `### Step 7` OR a range heading `### Step 4-8:` that CONTAINS
|
||
// `want` — core ships combined headings (e.g. build-and-test's `### Step 4-8:`),
|
||
// and `^### Step 8\b` would never match "Step 4-8". Scan all step headings.
|
||
let hit: { index: number; length: number } | null = null;
|
||
for (const m of content.matchAll(/^### Step (\d+)(?:-(\d+))?\b.*$/gm)) {
|
||
const lo = Number(m[1]); const hi = m[2] ? Number(m[2]) : lo;
|
||
if (want >= lo && want <= hi) { hit = { index: m.index!, length: m[0].length }; break; }
|
||
}
|
||
if (!hit) { recordDrop(`contribution to ${target}: ${kind}-step anchor "${anchor}" — no "### Step ${n}" heading found (a range like "### Step 4-8" counts); prose dropped`); return -1; }
|
||
if (kind === "before") return hit.index;
|
||
const from = hit.index + hit.length;
|
||
const next = content.slice(from).search(/^#{2,3} /m);
|
||
return next === -1 ? content.length : from + next;
|
||
};
|
||
if (anchor.startsWith("after-step:")) return stepAnchor("after");
|
||
if (anchor.startsWith("before-step:")) return stepAnchor("before");
|
||
if (anchor === "end-of-steps") {
|
||
const s = content.match(/^## Steps\b.*$/m);
|
||
if (!s) { recordDrop(`contribution to ${target}: anchor "end-of-steps" — no "## Steps" section found; prose dropped`); return -1; }
|
||
const from = s.index! + s[0].length;
|
||
const next = content.slice(from).search(/^## /m);
|
||
return next === -1 ? content.length : from + next;
|
||
}
|
||
if (anchor.startsWith("in:")) {
|
||
const comp = anchor.slice(3);
|
||
if (!/^[\w -]+$/.test(comp)) { recordDrop(`contribution to ${target}: bad in: anchor "${anchor}"`); return -1; }
|
||
const m = content.match(new RegExp(`^## ${escapeRegExp(comp)}\\b.*$`, "m"));
|
||
if (!m) { recordDrop(`contribution to ${target}: in: anchor "${anchor}" — no "## ${comp}" section found; prose dropped`); return -1; }
|
||
const from = m.index! + m[0].length;
|
||
const next = content.slice(from).search(/^## /m);
|
||
return next === -1 ? content.length : from + next;
|
||
}
|
||
recordDrop(`contribution to ${target}: unknown anchor "${anchor}"`);
|
||
return -1;
|
||
}
|
||
|
||
// FNV-1a 32-bit hex — a dependency-free content fingerprint. Embedded in a
|
||
// fragment's sentinel so a plugin UPGRADE (rewritten prose) is detected and the
|
||
// old block replaced, rather than filtered as already-present forever.
|
||
function hashProse(s: string): string {
|
||
let h = 0x811c9dc5;
|
||
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
||
return (h >>> 0).toString(16).padStart(8, "0");
|
||
}
|
||
|
||
interface Fragment { plugin: string; anchor: string; order: number; prose: string; }
|
||
interface FragmentRecord { anchor: string; order: number; hash: string; }
|
||
|
||
// Splice ONE fragment into stage source, idempotently and order-deterministically.
|
||
// Each spliced block is delimited by an open sentinel carrying (plugin, anchor,
|
||
// order, content-hash) and a matching close sentinel. Because blocks are
|
||
// self-delimiting we can (a) skip when the same block is already present, (b)
|
||
// replace it when only the hash changed (upgrade), and (c) insert a NEW block at
|
||
// its correct (order, plugin) slot among peer plugin blocks at the same anchor —
|
||
// so plugins composing in separate hook runs still interleave by (order, plugin),
|
||
// never by hook-firing order. Never relies on "the next heading" to bound a block.
|
||
function spliceFragment(content: string, f: Fragment, target: string): string {
|
||
const hash = hashProse(f.prose);
|
||
const pE = escapeRegExp(f.plugin), aE = escapeRegExp(f.anchor);
|
||
// The close marker carries the SAME content hash as the open, so the block's
|
||
// boundary is content-specific: a close-marker-lookalike line inside the prose
|
||
// (which lacks the exact hash) can't be mistaken for the real close on an
|
||
// upgrade re-splice (round-5 — the old hashless close matched the first
|
||
// occurrence, so prose containing the marker corrupted the block).
|
||
const closeOf = (h: string) => `<!-- /plugin:${f.plugin}:${f.anchor}:${f.order}:${h} -->`;
|
||
const block = `<!-- plugin:${f.plugin}:${f.anchor}:${f.order}:${hash} -->\n${f.prose}\n${closeOf(hash)}`;
|
||
|
||
// Present already? Skip on hash match; replace the whole block on hash change.
|
||
const mine = content.match(new RegExp(`<!-- plugin:${pE}:${aE}:${f.order}:([0-9a-f]+) -->`));
|
||
if (mine) {
|
||
const start = mine.index!;
|
||
const oldClose = closeOf(mine[1]); // the OLD block's own hash-qualified close
|
||
const end = content.indexOf(oldClose, start);
|
||
if (end === -1) { recordDrop(`contribution to ${target}: fragment block for "${f.anchor}" order ${f.order} missing close marker; left as-is`); return content; }
|
||
if (
|
||
mine[1] === hash &&
|
||
content.slice(start, end + oldClose.length) === block
|
||
) {
|
||
return content;
|
||
}
|
||
return content.slice(0, start) + block + content.slice(end + oldClose.length);
|
||
}
|
||
|
||
// Insert at the ordered slot among peer plugin blocks at this anchor (any plugin).
|
||
const peers: Array<{ order: number; plugin: string; start: number; end: number }> = [];
|
||
for (const m of content.matchAll(new RegExp(`<!-- plugin:([^:]+):${aE}:(\\d+):([0-9a-f]+) -->`, "g"))) {
|
||
const peerPlugin = m[1], pOrder = Number(m[2]), pHash = m[3];
|
||
const close = `<!-- /plugin:${peerPlugin}:${f.anchor}:${pOrder}:${pHash} -->`;
|
||
const cIdx = content.indexOf(close, m.index!);
|
||
if (cIdx === -1) continue;
|
||
peers.push({ order: pOrder, plugin: peerPlugin, start: m.index!, end: cIdx + close.length });
|
||
}
|
||
if (peers.length > 0) {
|
||
const after = peers.find((p) => p.order > f.order || (p.order === f.order && p.plugin.localeCompare(f.plugin) > 0));
|
||
if (after) return content.slice(0, after.start) + block + "\n\n" + content.slice(after.start);
|
||
const lastEnd = Math.max(...peers.map((p) => p.end));
|
||
return content.slice(0, lastEnd) + "\n\n" + block + content.slice(lastEnd);
|
||
}
|
||
|
||
// Virgin anchor — use the structural locator for the base insertion point.
|
||
const base = locateAnchor(content, f.anchor, target);
|
||
if (base === -1) return content;
|
||
return content.slice(0, base) + "\n" + block + "\n" + content.slice(base);
|
||
}
|
||
|
||
// --- main compose ----------------------------------------------------------
|
||
|
||
let changed = false;
|
||
try {
|
||
const pluginKeySafe = await installedSchemaAccepts("plugin", "probe-name");
|
||
const pluginFilesManifestPath = join(
|
||
HARNESS_DIR,
|
||
"tools",
|
||
"data",
|
||
`plugin-files-${PLUGIN_KEY}.json`,
|
||
);
|
||
const priorKnowledgeOwnership = (() => {
|
||
try {
|
||
const parsed = JSON.parse(
|
||
readFileSync(pluginFilesManifestPath, "utf-8"),
|
||
) as {
|
||
schema_version?: unknown;
|
||
plugin?: unknown;
|
||
knowledge?: unknown;
|
||
};
|
||
if (
|
||
parsed.schema_version !== 1 ||
|
||
parsed.plugin !== PLUGIN_NAME ||
|
||
!Array.isArray(parsed.knowledge)
|
||
) {
|
||
return new Set<string>();
|
||
}
|
||
return new Set(
|
||
parsed.knowledge.filter((value): value is string =>
|
||
typeof value === "string"
|
||
),
|
||
);
|
||
} catch {
|
||
return new Set<string>();
|
||
}
|
||
})();
|
||
const composedKnowledge = new Set<string>();
|
||
|
||
// 1. Copy NEW primitives (no-clobber, token-substituted).
|
||
// Plugin scopes and agents use the plugin prefix in place of core's `aidlc-`
|
||
// prefix: scopes/<plugin>-<name>.md and agents/<plugin>-<role>-agent.md, with
|
||
// the filename stem equal to frontmatter `name`.
|
||
if (!pluginKeySafe) {
|
||
recordDrop(
|
||
"plugin-owned stages/scopes/agents not composed: installed engine predates the plugin: ownership key - re-copy your dist/<harness>/ shell, then re-run compose",
|
||
);
|
||
} else {
|
||
const kiroAgentPrechecks = await kiroPluginAgentPrechecks();
|
||
const stagePrecheck = combinePrechecks(
|
||
await unsupportedRuntimeModePrecheck(),
|
||
kiroAgentPrechecks?.stage,
|
||
await installedStageSchemaPrecheck(),
|
||
);
|
||
changed = copyTreeNoClobber(join(PLUGIN_ROOT, "stages"), STAGES_DIR, "stage", stagePrecheck) || changed;
|
||
const scopesDir = join(HARNESS_DIR, "scopes");
|
||
const agentsDir = join(HARNESS_DIR, "agents");
|
||
const pluginAgentsDir =
|
||
HARNESS_LEAF === ".cursor"
|
||
? join(PLUGIN_ROOT, "aidlc", "agents")
|
||
: join(PLUGIN_ROOT, "agents");
|
||
changed = copyTreeNoClobber(join(PLUGIN_ROOT, "scopes"), scopesDir, "scopes", installedNameCollisionPrecheck(scopesDir, "scopes")) || changed;
|
||
changed = copyTreeNoClobber(
|
||
pluginAgentsDir,
|
||
agentsDir,
|
||
"agents",
|
||
combinePrechecks(
|
||
kiroAgentPrechecks?.agent,
|
||
HARNESS_LEAF === ".kiro" ? kiroNativeAgentPrecheck() : undefined,
|
||
installedNameCollisionPrecheck(agentsDir, "agents"),
|
||
),
|
||
HARNESS_LEAF === ".aidlc"
|
||
? ({ content }) => projectOpencodeAgentMemory(content)
|
||
: HARNESS_LEAF === ".cursor"
|
||
? projectCursorNativeAgent
|
||
: HARNESS_LEAF === ".kiro"
|
||
? projectKiroNativeAgent
|
||
: undefined,
|
||
HARNESS_LEAF === ".kiro" ? migrateExistingKiroAgent : undefined,
|
||
) || changed;
|
||
if (IS_OPENCODE) {
|
||
const rosterDir = nativeAgentsDir();
|
||
changed = copyTreeNoClobber(
|
||
join(PLUGIN_ROOT, "agents"),
|
||
rosterDir,
|
||
"OpenCode native agents",
|
||
opencodeNativeAgentPrecheck(rosterDir),
|
||
(ctx) => projectOpencodeAgentMemory(emitOpencodeNativeAgent(ctx)),
|
||
) || changed;
|
||
} else if (IS_COPILOT) {
|
||
const rosterDir = nativeAgentsDir();
|
||
changed = copyTreeNoClobber(
|
||
join(PLUGIN_ROOT, "agents"),
|
||
rosterDir,
|
||
"Copilot native agents",
|
||
copilotNativeAgentPrecheck(rosterDir),
|
||
(ctx) => projectOpencodeAgentMemory(emitCopilotNativeAgent(ctx)),
|
||
) || changed;
|
||
}
|
||
}
|
||
const knowledgeSource = join(PLUGIN_ROOT, "knowledge");
|
||
const knowledgeTarget = join(HARNESS_DIR, "knowledge");
|
||
changed = copyTreeNoClobber(
|
||
knowledgeSource,
|
||
knowledgeTarget,
|
||
"knowledge",
|
||
undefined,
|
||
undefined,
|
||
undefined,
|
||
composedKnowledge,
|
||
) || changed;
|
||
// Composition is no-clobber: source removal does not remove an installed
|
||
// file, so retain its prior provenance until the installed file is gone.
|
||
// Byte-identical installed files also establish ownership for upgrades from
|
||
// compose hooks that predated the ownership sidecar.
|
||
const ownedKnowledge = new Set(
|
||
[...priorKnowledgeOwnership].filter((rel) =>
|
||
existsSync(join(knowledgeTarget, rel))
|
||
),
|
||
);
|
||
for (const rel of composedKnowledge) ownedKnowledge.add(rel);
|
||
const pluginFilesManifest = `${
|
||
JSON.stringify({
|
||
schema_version: 1,
|
||
plugin: PLUGIN_NAME,
|
||
knowledge: [...ownedKnowledge].sort(),
|
||
}, null, 2)
|
||
}\n`;
|
||
try {
|
||
const current = existsSync(pluginFilesManifestPath)
|
||
? readFileSync(pluginFilesManifestPath, "utf-8")
|
||
: null;
|
||
if (current !== pluginFilesManifest) {
|
||
mkdirSync(dirname(pluginFilesManifestPath), { recursive: true });
|
||
writeComposeFile(pluginFilesManifestPath, pluginFilesManifest);
|
||
}
|
||
} catch (e) {
|
||
recordDrop(
|
||
`could not write plugin file ownership sidecar ${
|
||
relative(PROJECT_DIR, pluginFilesManifestPath)
|
||
}: ${e instanceof Error ? e.message : String(e)} - Minimal context may not recognize recursively composed knowledge`,
|
||
"advisory",
|
||
);
|
||
}
|
||
changed = copyTreeNoClobber(join(PLUGIN_ROOT, "sensors"), join(HARNESS_DIR, "sensors"), "sensor", sensorManifestNamePrecheck()) || changed;
|
||
changed = copyTreeNoClobber(
|
||
join(PLUGIN_ROOT, "tools"),
|
||
join(HARNESS_DIR, "tools"),
|
||
"tool",
|
||
combinePrechecks(toolsTestPayloadPrecheck(), doctorScriptOwnershipPrecheck()),
|
||
) || changed;
|
||
|
||
// 2. Merge contributions into stage SOURCE (structural + prose fragments).
|
||
// Probe ONCE whether the installed engine accepts required_sections — writing
|
||
// it into a stage an older engine can't parse would break every later compile.
|
||
const requiredSectionsSafe = await installedSchemaAccepts("required_sections", ["Probe Section"]);
|
||
const contribRoot = join(PLUGIN_ROOT, "contributions");
|
||
// Per-plugin sidecar of what compose ACTUALLY merged into core stage source,
|
||
// keyed by target stage. Structural additions need it for disable-time strip;
|
||
// fragment records let doctor verify sentinel-marked prose after an engine
|
||
// reinstall. Accumulated across re-runs: structural entries are unioned, while
|
||
// a fragment upgrade replaces the prior hash for its (anchor, order) identity.
|
||
type StageContribRecord = { produces?: string[]; sensors?: string[]; consumes?: Array<string | ConsumeEntry>; scopes?: string[]; required_sections?: string[]; required_sections_created?: boolean; fragments?: FragmentRecord[] };
|
||
type StringContribField = "produces" | "sensors" | "scopes" | "required_sections";
|
||
const contribManifestPath = join(HARNESS_DIR, "tools", "data", `plugin-contrib-${PLUGIN_KEY}.json`);
|
||
let contribManifestLoadError: string | null = null;
|
||
const contribManifest: Record<string, StageContribRecord> = (() => {
|
||
if (!existsSync(contribManifestPath)) return {};
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(contribManifestPath, "utf-8"));
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
throw new Error("expected a JSON object");
|
||
}
|
||
if (Object.keys(parsed).length === 0) throw new Error("has no stage records");
|
||
for (const [target, record] of Object.entries(parsed)) {
|
||
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
||
throw new Error(`target ${target} must contain an object record`);
|
||
}
|
||
}
|
||
return parsed as Record<string, StageContribRecord>;
|
||
} catch (e) {
|
||
contribManifestLoadError = e instanceof Error ? e.message : String(e);
|
||
return {};
|
||
}
|
||
})();
|
||
let contribManifestDirty = false;
|
||
const contribRecord = (target: string): StageContribRecord => {
|
||
const current = contribManifest[target];
|
||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||
contribManifest[target] = {};
|
||
}
|
||
return contribManifest[target];
|
||
};
|
||
const recordContrib = (target: string, field: StringContribField, values: string[]): void => {
|
||
if (values.length === 0) return;
|
||
const rec = contribRecord(target);
|
||
const existing = rec[field];
|
||
const prior = new Set(
|
||
Array.isArray(existing)
|
||
? existing.filter((value): value is string => typeof value === "string")
|
||
: [],
|
||
);
|
||
for (const v of values) prior.add(v);
|
||
(rec[field] as string[]) = [...prior].sort();
|
||
};
|
||
const recordConsumes = (target: string, values: ConsumeEntry[]): void => {
|
||
if (values.length === 0) return;
|
||
const rec = contribRecord(target);
|
||
const byArtifact = new Map<string, string | ConsumeEntry>();
|
||
for (const value of Array.isArray(rec.consumes) ? rec.consumes : []) {
|
||
if (typeof value === "string" && value.length > 0) {
|
||
byArtifact.set(value, value);
|
||
} else if (
|
||
value !== null &&
|
||
typeof value === "object" &&
|
||
typeof value.artifact === "string" &&
|
||
typeof value.required === "boolean" &&
|
||
(value.conditional_on === undefined || typeof value.conditional_on === "string")
|
||
) {
|
||
byArtifact.set(value.artifact, { ...value });
|
||
}
|
||
}
|
||
for (const value of values) byArtifact.set(value.artifact, { ...value });
|
||
rec.consumes = [...byArtifact.values()].sort((a, b) =>
|
||
(typeof a === "string" ? a : a.artifact).localeCompare(
|
||
typeof b === "string" ? b : b.artifact,
|
||
)
|
||
);
|
||
};
|
||
const recordFragment = (target: string, fragment: FragmentRecord): void => {
|
||
const rec = contribRecord(target);
|
||
const prior = Array.isArray(rec.fragments)
|
||
? rec.fragments.filter((entry): entry is FragmentRecord =>
|
||
entry !== null &&
|
||
typeof entry === "object" &&
|
||
typeof entry.anchor === "string" &&
|
||
Number.isSafeInteger(entry.order) &&
|
||
typeof entry.hash === "string")
|
||
: [];
|
||
const next = [
|
||
...prior.filter((entry) =>
|
||
entry.anchor !== fragment.anchor || entry.order !== fragment.order
|
||
),
|
||
fragment,
|
||
].sort((a, b) =>
|
||
a.anchor.localeCompare(b.anchor) || a.order - b.order || a.hash.localeCompare(b.hash)
|
||
);
|
||
if (JSON.stringify(prior) !== JSON.stringify(next)) {
|
||
rec.fragments = next;
|
||
contribManifestDirty = true;
|
||
}
|
||
};
|
||
// Fragment keys seen across ALL contribution files this run, so a same
|
||
// (target, plugin, anchor, order) arriving from a SECOND file drops-with-log
|
||
// rather than silently last-writer-winning via the hash-upgrade path (round-3).
|
||
const seenFragKeys = new Set<string>();
|
||
// Contributions merge ONLY for an enabled plugin. Stage/scope/agent copies
|
||
// are safe under a disabling selection (runtime loaders filter them), but
|
||
// merged contributions land in CORE stage source where no selection filter
|
||
// reaches - so composing them while disabled would weld a disabled plugin's
|
||
// produces/sensors/prose into enabled stages (and undo select-plugins'
|
||
// disable-time strip on the very next session start). The advisory drop at
|
||
// the top of this run already names the select-plugins command to enable.
|
||
if (contribManifestLoadError) {
|
||
recordDrop(
|
||
`contribution sidecar ${relative(PROJECT_DIR, contribManifestPath)} is unreadable or invalid (${contribManifestLoadError}); refusing to replace provenance from an already-composed stage - refresh the stock dist/<harness>/ engine, remove the invalid sidecar, then run plugin sync`,
|
||
);
|
||
}
|
||
const contribPhases =
|
||
!contribManifestLoadError && pluginEnabledBySelection() && existsSync(contribRoot)
|
||
? readdirSync(contribRoot)
|
||
: [];
|
||
// Installed scope roster for the adds.scopes guards, keyed by frontmatter
|
||
// `name:` (the runtime's scope identity — core files carry the `aidlc-`
|
||
// stem prefix, so filename lookup would miss them). Snapshotted once here:
|
||
// this plugin's own scope files were already copied in above, and
|
||
// contributions must not conjure new scope files.
|
||
const installedScopes = installedNameRoster(join(HARNESS_DIR, "scopes"));
|
||
for (const phase of contribPhases) {
|
||
const phaseDir = join(contribRoot, phase);
|
||
let files: string[];
|
||
try { files = readdirSync(phaseDir); } catch { continue; }
|
||
for (const file of files) {
|
||
if (!file.endsWith(".md")) continue;
|
||
// Normalize CRLF once so every downstream block/list regex is newline-safe;
|
||
// strip a leading UTF-8 BOM and any leading blank lines so the `^---`
|
||
// frontmatter anchor still matches a file saved with a BOM (common on
|
||
// Windows) or a stray blank first line — otherwise the whole contribution
|
||
// was silently skipped with no drop (round-5).
|
||
const content = readFileSync(join(phaseDir, file), "utf-8")
|
||
.replace(/\r\n/g, "\n").replace(/^/, "").replace(/^\n+/, "");
|
||
const fm = frontmatter(content);
|
||
const target = frontmatterScalar(content, "target");
|
||
// A .md in contributions/ with no parseable `target:` is a malformed
|
||
// contribution — log it (a present-but-unknown target is already logged
|
||
// below; a missing one was a silent bare continue).
|
||
if (!target) { recordDrop(`contribution "${file}" has no parseable frontmatter target: — skipped (check for a BOM, a leading blank line, or a missing target: key)`); continue; }
|
||
const plugin = frontmatterScalar(content, "plugin") ?? "";
|
||
// `bundle:` was the pre-rename ownership key. It is dead, not aliased —
|
||
// drop-log with the fix named so a stale plugin tree fails visibly
|
||
// instead of composing under wrong or ambiguous ownership.
|
||
if (/^bundle:\s*\S/m.test(fm)) {
|
||
recordDrop(`contribution "${file}" uses the renamed bundle: key; write plugin: instead — skipped`);
|
||
continue;
|
||
}
|
||
// `:` is the fragment-sentinel delimiter (<!-- plugin:<plugin>:anchor:order -->),
|
||
// so a plugin containing `:` would break the peer-block scan's `[^:]+` and
|
||
// silently misorder splices. Reject it up front (round-6).
|
||
if (plugin.includes(":")) { recordDrop(`contribution "${file}" has an invalid plugin "${plugin}" (must not contain ':'); skipped`); continue; }
|
||
if (plugin !== PLUGIN_NAME) {
|
||
recordDrop(
|
||
`contribution "${file}" declares ${plugin ? `plugin "${plugin}"` : "no plugin identity"}; owned plugin content must match the host manifest identity "${PLUGIN_NAME}"; skipped`,
|
||
);
|
||
continue;
|
||
}
|
||
const stageFile = findStageFile(target);
|
||
if (!stageFile) { recordDrop(`contribution "${file}" targets missing stage "${target}"`); continue; }
|
||
|
||
// structural: adds.produces / adds.sensors / adds.consumes
|
||
const addsBlock = fm.match(/^adds:\n([\s\S]*?)(?=^\S|$(?![\s\S]))/m)?.[1] ?? "";
|
||
// Drop-log a parse shortfall, mirroring the consumes parser: the block
|
||
// regex stops at the first non-4-space entry, so a mis-indented line
|
||
// silently truncated the list (entries after it vanished with no log).
|
||
const listOf = (f: string): string[] => {
|
||
const declaredBlock = addsBlock.match(new RegExp(`^ {2}${f}:\\n((?:\\s+- .*\\n?)*)`, "m"))?.[1] ?? "";
|
||
const parsed: string[] = [];
|
||
for (const entry of declaredBlock.matchAll(/^ {4}- (.+?)\s*$/gm)) {
|
||
const value = yamlScalarValue(entry[1]);
|
||
if (value && /^[\w-]+$/.test(value)) parsed.push(value);
|
||
}
|
||
const declared = (declaredBlock.match(/^\s+- /gm) ?? []).length;
|
||
if (declared > parsed.length) {
|
||
recordDrop(`contribution to ${target}: parsed ${parsed.length} of ${declared} adds.${f} entries (check indentation - entries must be 4-space " - kebab-name"); some dropped`);
|
||
}
|
||
return parsed;
|
||
};
|
||
const consumes = (() => {
|
||
// Parse consumes per-entry, NOT by zipping two independent artifact/required
|
||
// scans: a dash-less `required:`/`conditional_on:` continuation line must
|
||
// bind to the artifact above it, or entry 2+ is dropped and required flips
|
||
// (round-2 blocker). Each entry starts at `- artifact:` and owns every
|
||
// following indented non-dash line until the next `- artifact:`.
|
||
const block = addsBlock.match(/^ {2}consumes:\n((?: {4}-? .*\n?)*)/m)?.[1];
|
||
if (!block) return [];
|
||
const out: Array<{ artifact: string; required: boolean; conditional_on?: string }> = [];
|
||
// Split on ANY-indent `- artifact:` (a YAML-legal 6-space list must still
|
||
// yield one chunk per entry; a fixed 4-space anchor silently merged them —
|
||
// round-3). Drop-log if entries outnumber chunks (a split that failed).
|
||
for (const chunk of block.split(/^(?=\s*- artifact:)/m)) {
|
||
const artifact = chunk.match(/-\s*artifact:\s*([\w-]+)/)?.[1];
|
||
if (!artifact) continue;
|
||
// `required` defaults to true ONLY when the key is genuinely absent;
|
||
// an explicit `required: false` must survive.
|
||
const reqRaw = chunk.match(/^\s*required:\s*(true|false)\b/m)?.[1];
|
||
const conditional_on = chunk.match(/^\s*conditional_on:\s*(\w+)/m)?.[1];
|
||
out.push({ artifact, required: reqRaw !== "false", ...(conditional_on ? { conditional_on } : {}) });
|
||
}
|
||
const declared = (block.match(/-\s*artifact:/g) ?? []).length;
|
||
if (declared > out.length) {
|
||
recordDrop(`contribution to ${target}: parsed ${out.length} of ${declared} consumes entries (check indentation); some dropped`);
|
||
}
|
||
return out;
|
||
})();
|
||
|
||
// Drop-log any adds.* key compose does not implement — no silent no-op.
|
||
// Implemented merge surfaces: produces / sensors / consumes / scopes /
|
||
// required_sections. A documented-but-deferred surface (e.g.
|
||
// requires_stage) is recorded as a drop so an author sees it had no
|
||
// effect, per the no-silent-failures contract. (When a surface
|
||
// graduates, add it to IMPLEMENTED_ADDS + a merge call below.)
|
||
const IMPLEMENTED_ADDS = new Set(["produces", "sensors", "consumes", "scopes", "required_sections"]);
|
||
for (const km of addsBlock.matchAll(/^ {2}([a-z_]+):/gm)) {
|
||
if (!IMPLEMENTED_ADDS.has(km[1])) {
|
||
recordDrop(`contribution to ${target}: adds.${km[1]} is not yet an implemented merge surface (only produces/sensors/consumes/scopes/required_sections); ignored`, "advisory");
|
||
}
|
||
}
|
||
|
||
// required_sections values are quoted strings ("Branch Coverage"), unlike
|
||
// the kebab slugs in produces/sensors. Capture the whole value then strip
|
||
// only a MATCHED pair of outer quotes — a `[^"]` class dropped any value
|
||
// with an interior quote (`"Say "Hi" Section"`) silently (round-5).
|
||
const requiredSections = (() => {
|
||
const s = addsBlock.match(/^ {2}required_sections:\n((?: {4}- .*\n?)*)/m)?.[1];
|
||
if (!s) return [];
|
||
const out: string[] = [];
|
||
for (const x of s.matchAll(/^ {4}- (.+?)\s*$/gm)) {
|
||
const v = x[1].replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim();
|
||
// An empty (or quote-only) value would merge a useless `- ""` into the
|
||
// stage with no signal — drop-log it instead (round-6).
|
||
if (v === "") { recordDrop(`contribution to ${target}: empty required_sections value; dropped`); continue; }
|
||
out.push(v);
|
||
}
|
||
return out;
|
||
})();
|
||
|
||
// Normalize CRLF up front so a merge never inserts LF lines into a CRLF
|
||
// stage (mixed endings). Contribution content is already normalized above.
|
||
let stageContent = readFileSync(stageFile, "utf-8").replace(/\r\n/g, "\n");
|
||
const before = stageContent;
|
||
const addedProduces: string[] = [], addedSensors: string[] = [], addedConsumes: ConsumeEntry[] = [], addedScopes: string[] = [], addedSections: string[] = [];
|
||
const sectionsMeta: { created?: boolean } = {};
|
||
// adds.scopes — set-union the target stage into this plugin's scopes.
|
||
// Two guard rails, both drop-logged: the scope's identity file must
|
||
// already be installed (a name with no scopes/*.md declaring it
|
||
// resolves as an all-SKIP phantom with no diagnostic), and that file's
|
||
// `plugin:` frontmatter must name THIS plugin exactly — welding a core
|
||
// stage into a core or foreign-plugin scope changes selection semantics
|
||
// the other owner never agreed to. Ownership comes from the installed
|
||
// file's declared owner, NOT a name-prefix rule: a plugin named `a`
|
||
// must not pass for plugin `a-b`'s scope `a-b-x` (dash prefixes overlap
|
||
// across plugin names). A core scope declares no `plugin:` and never
|
||
// merges. Resolution is by frontmatter `name:` (the runtime's scope
|
||
// identity), not filename — core files carry the `aidlc-` stem prefix.
|
||
const mergeableScopes = listOf("scopes").filter((s) => {
|
||
const scopeFile = installedScopes.get(s);
|
||
if (!scopeFile) {
|
||
recordDrop(`contribution to ${target}: adds.scopes "${s}" has no installed scope file (no scopes/*.md declares name "${s}"); dropped`);
|
||
return false;
|
||
}
|
||
const owner = frontmatterScalar(readFileSync(scopeFile, "utf-8"), "plugin");
|
||
if (owner !== PLUGIN_NAME) {
|
||
recordDrop(`contribution to ${target}: adds.scopes "${s}" is not owned by plugin "${PLUGIN_NAME}" (installed ${basename(scopeFile)} declares ${owner ? `plugin "${owner}"` : "no plugin: field (core-owned)"}; only this plugin's own scopes merge); dropped`);
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
stageContent = mergeListField(stageContent, "produces", listOf("produces"), target, addedProduces);
|
||
stageContent = mergeListField(stageContent, "sensors", listOf("sensors"), target, addedSensors);
|
||
stageContent = mergeListField(stageContent, "scopes", mergeableScopes, target, addedScopes);
|
||
stageContent = mergeConsumes(stageContent, consumes, target, addedConsumes);
|
||
// Only merge required_sections if the installed engine accepts the key —
|
||
// otherwise skip + drop-log rather than break the install's next compile.
|
||
if (requiredSections.length > 0 && !requiredSectionsSafe) {
|
||
recordDrop(`contribution to ${target}: installed engine does not accept 'required_sections' (older dist); skipped its merge — re-copy your dist/<harness> shell to enable it`, "advisory");
|
||
} else {
|
||
stageContent = mergeRequiredSections(stageContent, requiredSections, target, addedSections, sectionsMeta);
|
||
}
|
||
recordContrib(target, "produces", addedProduces);
|
||
recordContrib(target, "sensors", addedSensors);
|
||
recordConsumes(target, addedConsumes);
|
||
recordContrib(target, "scopes", addedScopes);
|
||
recordContrib(target, "required_sections", addedSections);
|
||
if (sectionsMeta.created) {
|
||
contribRecord(target).required_sections_created = true;
|
||
}
|
||
if (addedProduces.length || addedSensors.length || addedConsumes.length || addedScopes.length || addedSections.length) {
|
||
contribManifestDirty = true;
|
||
}
|
||
|
||
// prose fragments — paired to their `## fragment: <anchor>` body block BY
|
||
// ANCHOR LABEL, not array index. Positional pairing silently mismatched
|
||
// prose to anchors when the body order differed from the frontmatter order
|
||
// (round-4). Multiple fragments may target the same anchor (test-pro has 3×
|
||
// after-step:9), so pair per-anchor FIFO: the i-th frontmatter entry for
|
||
// anchor A takes the i-th body block labelled A. A frontmatter entry with no
|
||
// matching body block (or vice versa) is dropped-with-log, not silently
|
||
// cross-paired to some other anchor's prose.
|
||
const body = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/)?.[1] ?? "";
|
||
const fragMeta = [...(fm.match(/^fragments:\n([\s\S]*?)(?=^\S|$(?![\s\S]))/m)?.[1] ?? "")
|
||
.matchAll(/-\s*anchor:\s*(\S+)\s*\n\s*order:\s*(\d+)/g)].map((m) => ({ anchor: m[1], order: Number(m[2]) }));
|
||
// Split the body into `## fragment: <anchor>` blocks with a FENCE-AWARE line
|
||
// scanner, not a global regex: a `## fragment:` line INSIDE a ``` code fence
|
||
// (exactly how an author documents the fragment format) must NOT be treated
|
||
// as a delimiter — the regex form truncated the block there and spawned
|
||
// phantom blocks, silently dropping trailing real prose (round-5).
|
||
const blocksByAnchor = new Map<string, string[]>();
|
||
{
|
||
let curAnchor: string | null = null; let curLines: string[] = [];
|
||
let inFence = false; let fenceChar = ""; let fenceLen = 0;
|
||
const flush = () => { if (curAnchor !== null) (blocksByAnchor.get(curAnchor) ?? blocksByAnchor.set(curAnchor, []).get(curAnchor)!).push(curLines.join("\n").trim()); };
|
||
for (const line of body.split("\n")) {
|
||
// CommonMark fence rules: a closing fence is the SAME char, length >=
|
||
// the opener, and carries no info string. Tracking only the char (not
|
||
// the length) let an inner ``` close an outer ```` — so documenting the
|
||
// fragment format with a nested fence corrupted the block (round-6).
|
||
const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
|
||
if (fence) {
|
||
const ch = fence[2][0]; const len = fence[2].length; const info = fence[3].trim();
|
||
if (!inFence) { inFence = true; fenceChar = ch; fenceLen = len; }
|
||
else if (ch === fenceChar && len >= fenceLen && info === "") { inFence = false; fenceChar = ""; fenceLen = 0; }
|
||
}
|
||
const hdr = !inFence && line.match(/^## fragment:\s*(\S+)\s*$/);
|
||
if (hdr) { flush(); curAnchor = hdr[1]; curLines = []; continue; }
|
||
if (curAnchor !== null) curLines.push(line);
|
||
}
|
||
flush();
|
||
}
|
||
const frags: Fragment[] = [];
|
||
for (const meta of fragMeta) {
|
||
const queue = blocksByAnchor.get(meta.anchor);
|
||
const prose = (queue && queue.length > 0 ? queue.shift()! : "").replaceAll("{{HARNESS_DIR}}", HARNESS_LEAF);
|
||
if (!prose) { recordDrop(`contribution to ${target}: fragment anchor "${meta.anchor}" order ${meta.order} has no matching "## fragment: ${meta.anchor}" prose block; dropped`); continue; }
|
||
frags.push({ ...meta, plugin, prose });
|
||
}
|
||
// Leftover body blocks with no matching frontmatter entry are dropped-with-
|
||
// log — the "or vice versa" half the prior comment promised but never did
|
||
// (round-5). An empty leftover (blank prose) is ignored, not logged.
|
||
for (const [anchor, remaining] of blocksByAnchor) {
|
||
for (const leftover of remaining) {
|
||
if (leftover) recordDrop(`contribution to ${target}: "## fragment: ${anchor}" prose block has no matching frontmatter fragments entry; dropped`);
|
||
}
|
||
}
|
||
|
||
// Splice each fragment at its ordered (order, plugin) slot. A same
|
||
// (target, plugin, anchor, order) collision — whether within this file OR
|
||
// from an earlier contribution file this run — drops-with-log rather than
|
||
// silently overwriting (the hash-upgrade path would otherwise let a second
|
||
// file replace the first, winner decided by readdir order). Aligned with
|
||
// the "collision is an error" doc claim.
|
||
const ordered = [...frags].sort((a, b) => a.order - b.order || a.plugin.localeCompare(b.plugin));
|
||
for (const f of ordered) {
|
||
const key = `${target}:${f.plugin}:${f.anchor}:${f.order}`;
|
||
if (seenFragKeys.has(key)) { recordDrop(`contribution to ${target}: duplicate fragment ${f.plugin}:${f.anchor}:${f.order} (same plugin/anchor/order, possibly across files); dropped`); continue; }
|
||
seenFragKeys.add(key);
|
||
stageContent = spliceFragment(stageContent, f, target);
|
||
const fragment = { anchor: f.anchor, order: f.order, hash: hashProse(f.prose) };
|
||
const open = `<!-- plugin:${f.plugin}:${fragment.anchor}:${fragment.order}:${fragment.hash} -->`;
|
||
const close = `<!-- /plugin:${f.plugin}:${fragment.anchor}:${fragment.order}:${fragment.hash} -->`;
|
||
const openIdx = stageContent.indexOf(open);
|
||
if (openIdx !== -1 && stageContent.indexOf(close, openIdx + open.length) !== -1) {
|
||
recordFragment(target, fragment);
|
||
}
|
||
}
|
||
|
||
if (stageContent !== before) { // compare-before-write (review #11)
|
||
writeComposeFile(stageFile, stageContent);
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Persist structural and fragment provenance when this run changes it.
|
||
// A prose-only plugin therefore leaves a sidecar that doctor can verify after
|
||
// a fresh engine distribution overwrites the composed stage source.
|
||
if (contribManifestDirty) {
|
||
try {
|
||
mkdirSync(join(HARNESS_DIR, "tools", "data"), { recursive: true });
|
||
writeComposeFile(contribManifestPath, `${JSON.stringify(contribManifest, null, 2)}\n`);
|
||
} catch (e) {
|
||
recordDrop(`could not write the contribution sidecar ${relative(PROJECT_DIR, contribManifestPath)}: ${e instanceof Error ? e.message : String(e)} - doctor cannot verify the composed surface and disabling this plugin will not strip its merged contributions`);
|
||
rollbackComposeWrites();
|
||
}
|
||
}
|
||
|
||
// 3. Recompile when something changed OR when a prior compile did not land —
|
||
// a transient failure (disk full, killed mid-session-start) must self-heal
|
||
// next session. Under the no-clobber + sentinel + compare-before-write gates
|
||
// `changed` stays false on reruns, so gating on `changed` alone would make a
|
||
// failed compile permanent (round-2 major). Detect it by checking the
|
||
// compiled graph actually contains this plugin's stage slugs.
|
||
const pluginStages: Array<{ slug: string; phase: string }> = [];
|
||
for (const phase of PHASES) {
|
||
const dir = join(PLUGIN_ROOT, "stages", phase);
|
||
if (!existsSync(dir)) continue;
|
||
for (const f of readdirSync(dir)) if (f.endsWith(".md")) pluginStages.push({ slug: f.slice(0, -3), phase });
|
||
}
|
||
// A compose-dropped stage never landed on disk, so it can never reach the
|
||
// graph - expecting it there would force a futile recompile every session.
|
||
const pluginSlugs = pluginStages.map((s) => s.slug).filter((s) => !composeDroppedStageSlugs.has(s));
|
||
const graphPath = join(HARNESS_DIR, "tools", "data", "stage-graph.json");
|
||
const readGraph = (): Array<{ slug?: string; plugin?: string; phase?: string; enabled?: boolean }> | null => {
|
||
try {
|
||
return JSON.parse(readFileSync(graphPath, "utf-8")) as Array<{ slug?: string; plugin?: string; phase?: string; enabled?: boolean }>;
|
||
} catch { return null; }
|
||
};
|
||
const graphMissingPluginStage = (() => {
|
||
if (!pluginEnabledBySelection()) return false;
|
||
if (pluginSlugs.length === 0) return false;
|
||
const graph = readGraph();
|
||
if (graph === null) return true; // unreadable/absent graph — compile
|
||
const present = new Set(
|
||
graph
|
||
.filter((s) => s.enabled !== false)
|
||
.map((s) => s.slug),
|
||
);
|
||
return pluginSlugs.some((s) => !present.has(s));
|
||
})();
|
||
const skillsDirExists = existsSync(SKILLS_DIR);
|
||
const missingPluginStageRunner = (() => {
|
||
if (!skillsDirExists || pluginSlugs.length === 0 || graphMissingPluginStage) return false;
|
||
const graph = readGraph();
|
||
if (graph === null) return false;
|
||
const pluginSlugSet = new Set(pluginSlugs);
|
||
return graph.some((s) =>
|
||
typeof s.slug === "string" &&
|
||
pluginSlugSet.has(s.slug) &&
|
||
typeof s.plugin === "string" &&
|
||
s.plugin.length > 0 &&
|
||
s.enabled !== false &&
|
||
s.phase !== "initialization" &&
|
||
!existsSync(join(SKILLS_DIR, s.slug, "SKILL.md"))
|
||
);
|
||
})();
|
||
// A contributions-only plugin has no stage slug to detect a missing compile, so
|
||
// the graph-slug check can't see its failed recompile. A persisted retry marker
|
||
// covers that case: written on compile failure, deleted on success, and any
|
||
// presence forces a retry next run — so a transient failure self-heals for
|
||
// stage-carrying AND contributions-only plugins alike (round-3). The marker is
|
||
// PROJECT-side (never in PLUGIN_ROOT, which may be read-only / under dist/), and
|
||
// keyed by the plugin's identity (PLUGIN_KEY, computed up front) so two plugins
|
||
// on one harness never share a marker.
|
||
const retryMarker = join(PROJECT_DIR, "aidlc", `.plugin-compose-retry-${PLUGIN_KEY}`);
|
||
const retryPending = existsSync(retryMarker);
|
||
let recompiled = false;
|
||
if (changed || graphMissingPluginStage || retryPending) {
|
||
const [command, ...args] = installedToolCommand("graph", ["compile"]);
|
||
const r = spawnSync(command, args, {
|
||
cwd: PROJECT_DIR,
|
||
encoding: "utf-8",
|
||
env: installedToolEnv(),
|
||
});
|
||
if (r.status !== 0) {
|
||
recordDrop(`aidlc-graph compile failed: ${(r.stderr || "").slice(0, 400)}`);
|
||
rollbackComposeWrites();
|
||
if (pluginKeySafe) {
|
||
try { mkdirSync(join(PROJECT_DIR, "aidlc"), { recursive: true }); writeFileSync(retryMarker, new Date().toISOString() + "\n"); } catch { /* best-effort */ }
|
||
}
|
||
} else {
|
||
commitComposeWrites();
|
||
recompiled = true;
|
||
if (retryPending) {
|
||
try { rmSync(retryMarker, { force: true }); } catch { /* best-effort */ }
|
||
}
|
||
refreshSkillGeneratedRegion("stage-table", STAGE_TABLE_END);
|
||
refreshSkillGeneratedRegion("scope-table", SCOPE_TABLE_END);
|
||
}
|
||
}
|
||
|
||
const pluginShipsScopes = existsSync(join(PLUGIN_ROOT, "scopes"));
|
||
if (recompiled || missingPluginStageRunner) {
|
||
if (!skillsDirExists) {
|
||
recordDrop(`runner regeneration skipped: ${HARNESS_LEAF}/skills not present in this install`, "advisory");
|
||
} else {
|
||
const runnerEnv = installedToolEnv();
|
||
const runRunnerGen = (args: string[], label: string): boolean => {
|
||
const [command, ...commandArgs] = installedToolCommand("runner", args);
|
||
const r = spawnSync(command, commandArgs, {
|
||
cwd: PROJECT_DIR,
|
||
encoding: "utf-8",
|
||
env: runnerEnv,
|
||
});
|
||
if (r.status !== 0) {
|
||
recordDrop(`aidlc-runner-gen ${label} failed: ${(r.stderr || r.stdout || "").slice(0, 400)}`);
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
runRunnerGen(["write"], "write");
|
||
if (pluginShipsScopes) runRunnerGen(["scopes"], "scopes");
|
||
}
|
||
}
|
||
commitComposeWrites();
|
||
} catch (e) {
|
||
rollbackComposeWrites();
|
||
recordDrop(`compose threw: ${e instanceof Error ? e.message : String(e)}`);
|
||
// Non-fatal: never break the user's session over a compose failure.
|
||
}
|
||
} finally {
|
||
await flushInstalledToolPayloadDrops();
|
||
composeOwnsWorkspaceLock = false;
|
||
lockLib.releaseAuditLock(PROJECT_DIR);
|
||
}
|
||
|
||
// Flush any recorded drops to the installed hooks-health dir (--doctor surfaces
|
||
// them). Best-effort — flushDrops swallows its own errors.
|
||
await flushDrops();
|
||
}
|
||
|
||
if (import.meta.main) await compose();
|