Tool

Aidlc Workflows

AI-DLC (AI-Driven Development Life Cycle) turns AI coding assistants into structured, verifiable software-delivery workflows. One harness-neutral core runs natively in Claude Code, Kiro CLI, Kiro IDE, Codex CLI, Cursor, opencode, and GitHub Copilot.


88
Spark score
out of 100
Updated 8 days ago
Source checked Oct 16, 2026
Version 2.11.0
Models
claude

Add to Favorites

Source

Get it from source

Spark does not host a copy of it.

Open source

Reports

Agent outcome reports

No reports yet

Overview

Aidlc Workflows

What it does

AI-DLC - one core, many harnesses

AI-DLC (AI-Driven Development Life Cycle) turns AI coding assistants into
structured, verifiable software-delivery workflows. One harness-neutral core
runs natively in Claude Code, Kiro CLI, Kiro IDE, Codex CLI, Cursor, opencode,
and GitHub Copilot.

version
license

The Quick Start below installs the latest stable AI-DLC release.

Quick Start

1. Install AI-DLC

macOS, Linux, or WSL:

curl -fsSL https://github.com/awslabs/aidlc-workflows/releases/latest/download/install.sh | sh

Windows PowerShell:

irm https://github.com/awslabs/aidlc-workflows/releases/latest/download/install.ps1 | iex

The installer sets up everything AI-DLC needs: the native aidlc command and
every harness runtime. You do not need to install Bun or Node.js; a native
install runs its hooks and commands through aidlc and does not call bun.
On Windows, it installs for the current account
and automatically registers the bin directory in User PATH. Run it from a normal
PowerShell window; one opened with "Run as administrator" gets a warning and a
prompt, since installing as administrator is less safe. Open a new terminal
if another session cannot find aidlc. To skip both persistent and
current-process PATH changes, use
-NoModifyPath.
Windows uninstall removes only the User PATH entry recorded as installer-owned.
On macOS, Linux, or WSL, follow the installer's PATH instruction if needed.

Prefer to manage the project files yourself, or cannot install a native
executable? The manual-copy path is the one place Bun is needed: install
Bun, download aidlc-copy-runtime-X.Y.Z.tar.gz from the
release, and copy
the complete runtime/<harness>/ directory into your project. This path does
not require the native aidlc command.

2. Configure a project

From the project root, select the harness you use:

cd /path/to/your-project
aidlc config --harness claude
aidlc doctor

Replace claude with kiro, kiro-ide, codex, cursor, opencode, or
copilot. Running aidlc config without --harness starts the interactive
setup when a terminal is available. If you use Kiro IDE's own terminal in a
project folder you have not trusted yet, Kiro first asks whether you trust it.
Choose Trust Folder & Continue only for your own project or one you have
checked, because trusting lets the folder's .kiro hooks run commands on your
machine; otherwise choose Cancel and review the folder first (see
First run).

3. Start a workflow

Open your harness in the configured project and describe the work:

/aidlc Build a REST API for inventory management

Codex CLI uses $aidlc instead of /aidlc. In Kiro IDE, first choose aidlc
in the chat panel's agent picker. AI-DLC selects a workflow from the request,
asks for missing decisions, and stops at approval gates before moving forward.

For provider setup, trust prompts, and harness-specific prerequisites, use the
guide in the table below. The complete walkthrough is in
Getting Started.

Pick your harness

Harness Configure Open Invoke Guide
Claude Code aidlc config --harness claude claude /aidlc Getting Started
Kiro: Kiro IDE >= 1.1.70 / Kiro CLI >= 2.24.1 (v3) aidlc config --harness kiro-ide Open the project in Kiro IDE and choose aidlc in the chat panel's agent picker, or run kiro-cli /aidlc Kiro IDE
Kiro CLI (V2) >= 2.6 aidlc config --harness kiro kiro-cli chat /aidlc Kiro CLI
Codex CLI >= 0.145.0 aidlc config --harness codex codex $aidlc Codex CLI
Cursor aidlc config --harness cursor Open Cursor or run agent /aidlc Cursor
opencode >= 1.17 aidlc config --harness opencode opencode /aidlc opencode
GitHub Copilot CLI >= 1.0.74 / VS Code >= 1.130 aidlc config --harness copilot Copilot CLI or VS Code /aidlc GitHub Copilot

Model-provider setup belongs to the harness. Shipped project configuration
keeps the provider and model already selected by the user. aidlc config providers can apply Amazon Bedrock settings on supported project surfaces or
record manual setup for other harnesses. Kiro CLI and Kiro IDE need no provider
answer because model access comes with Kiro. For Kiro's v3 engine on Kiro CLI
2.24.1 or later, use kiro-ide, which pins it; kiro is Kiro CLI (V2)
(Kiro CLI says how an existing kiro
project switches). The methodology itself is
provider-independent.

Recommended Model

AI-DLC works best with capable reasoning models. The current recommended model
is Claude Opus 4.8. If Opus is not available to you, or you are not sure where
to set the model and effort on your harness, see
Choosing a Model and Effort.

Why AI-DLC

Ad-hoc AI coding loses context as projects grow. AI-DLC keeps requirements,
decisions, implementation, tests, and operational work connected through one
audited lifecycle:

  • 5 phases and 33 stages from initialization through operation
  • 14 agents: 11 domain experts, 2 reviewers, and an adaptive composer
  • 11 workflow profiles for features, bug fixes, infrastructure, security,
    proofs of concept, enterprise delivery, and other common work
  • Human approval gates and source-bound review evidence
  • 119-event audit trail plus persistent state, team knowledge, and learned rules
  • The same deterministic engine across every supported harness

Start with Workflow Profiles to compare
Classic, Express, and the focused workflows. See the
AI-DLC Workflows 2.0 Specification
for the architecture and methodology.

Documentation

Guide Use it when
Getting Started Installing, configuring, and running your first workflow
User Guide Using workflows, profiles, agents, knowledge, and approval gates
Harness guides Handling provider, trust, and runtime differences
Install and Lifecycle Updating, pinning, installing offline, using mirrors, or uninstalling
Harness Engineer Guide Reshaping stages, agents, rules, sensors, and knowledge
Development and Releases Taking a PR through AI review, preview testing, and stable publication
Developer Reference Changing the engine, hooks, packaging, or tests

Repository Layout

  • core/ - hand-authored, harness-neutral methodology and engine
  • core/tools/ - 93 aidlc-*.ts engine and authoring tools
  • harness/<name>/ - thin, harness-specific manifests and integrations
  • plugins/<name>/ - optional AIDLC plugins
  • scripts/ - packaging, binary, installer, and release tooling
  • tests/ - smoke, unit, integration, and end-to-end tests
  • docs/ - user, harness-engineering, and developer documentation
  • dist/ and dist-release/ - generated, ignored local outputs

Edit core/ or harness/<name>/, never generated dist* output.

Development

Install dependencies and generate every harness:

bun install --frozen-lockfile
bun scripts/package.ts

Useful commands:

bun scripts/package.ts <name>     # generate one harness
bun scripts/package.ts --check    # determinism guard
bun tests/run-tests.ts --ci       # smoke, unit, and integration
bun tests/run-tests.ts --release  # full release acceptance

See the Contributing Guide for the complete
development workflow and Porting to a New Harness
to add another runtime.

Troubleshooting

Run aidlc doctor from the project root first. Common fixes:

Symptom Fix
aidlc is not found On Windows, quit and reopen the terminal app or IDE (a new tab inside one that was open during the install keeps the old PATH), or use the direct command printed with -NoModifyPath. On Unix, apply the installer's PATH instruction.
Project/runtime version skew Finish the active workflow, then run aidlc config
Codex hooks do not run Trust the project hooks as described in the Codex guide
Bedrock access fails Enable the configured models and verify AWS credentials and region
Plugin stages disappear after refresh Run /aidlc plugin sync
Refreshed skills do not take effect Start a new harness session

See Troubleshooting for diagnostic and
recovery procedures.

References

scripts/package.ts

#!/usr/bin/env bun
// scripts/package.ts — THE build entry for the one-core-N-harnesses layout.
//
//   bun scripts/package.ts            regenerate dist/ and dist-release/ for every harness, plus plugins
//   bun scripts/package.ts --check     determinism guard (exit 1 on mismatch)
//   bun scripts/package.ts <name>      regenerate just one harness
//   bun scripts/package.ts <name> --check
//
// PIPELINE PER HARNESS:
//   1. COPY core/<src> → dist/<name>/<harnessDir>/<dst>, substituting
//      {{HARNESS_DIR}} -> harnessDir in .md prose (see transform()) and
//      applying the manifest's rules-dir rename.
//   2. COPY harness/<name>/<src> → dist/<name>/<harnessDir>/<dst> (authored
//      surfaces: orchestrator skill, settings/config), same token substitution
//      on .md; render neutral onboarding and native setup from their skeletons.
//   3. COMPILE the stage graph into the assembled tree (emits harness-correct
//      stage-graph.json + scope-grid.json — compiled data lives only in dist).
//   4. GENERATE runners into the assembled tree by running aidlc-runner-gen's
//      write and scopes CLIs under AIDLC_HARNESS_DIR (skipped when the manifest
//      sets skipRunnerGen).
//   5. EMIT via harness/<name>/emit.ts if the manifest declares one (codex,
//      copilot and opencode today; for codex: config.toml, hooks.json,
//      trust-seed, agent TOMLs, .agents/skills).
//   6. REFRESH generated stage/scope table regions in the assembled orchestrator
//      skill from the just-compiled graph and scope grid.
//
// THE TOKENS: core/ prose carries {{HARNESS_DIR}}, {{INVOKE}}, {{TOOL_PREFIX}} and
// {{TRUSTED_NAMESPACE}}; here they become the harness's own values (the dir is
// `.claude`/`.kiro`/`.codex`/`.aidlc`/`.cursor`). Truthful carve-outs in core
// (workspace-detection's 3-dir list, the `$CLAUDE_PROJECT_DIR on Claude Code`
// note) never carried the token, so they pass through untouched.
//
// --check builds the complete copy-channel, native-release, and plugin
// projections twice in independent temp roots, then compares them byte for
// byte. It never reads dist/ or dist-release/, so it works from a fresh clone
// and fails CI only when the generator is nondeterministic.

import {
  cpSync,
  existsSync,
  lstatSync,
  mkdirSync,
  mkdtempSync,
  readdirSync,
  readFileSync,
  rmSync,
  statSync,
  writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import {
  basename,
  dirname,
  isAbsolute,
  join,
  posix,
  relative,
  sep,
} from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import type { HarnessManifest } from "./manifest-types.ts";
import {
  absorbReviewerKnowledge,
  agentNameFromPath,
  inlineAgentSet,
  injectDelegatedKnowledgePreflight,
  reviewerAgentSet,
} from "./agent-knowledge.ts";
import { renderNeutralOnboarding, renderOnboarding } from "./onboarding.ts";
import { keepOwnHarnessBindings } from "./harness-bindings.ts";
import { EARLIER_RELEASE_REGIONS, earlierReleasesReadSkill } from "./earlier-release-regions.ts";
import { forgetPackagedSources, packageInputsFingerprint, recordPackagedSources } from "./package-sources.ts";
import {
  buildPluginProjection as emitPluginProjection,
  type PluginTarget,
  type PluginTargetTable,
} from "../core/tools/aidlc-plugin-emit.ts";
import {
  type Harness,
  readEnvCap,
  readMemoryCap,
  type Tier,
} from "../core/tools/aidlc-tiers.ts";
import {
  agentTiersFromAuthoredDirectory,
  modelAgentName,
  resolveModelPolicy,
  serializeAgentTiers,
  writeKiroAgentSurface,
  writeKiroCliSurface,
  writeMarkdownAgentSurface,
} from "../core/tools/aidlc-model-policy.ts";
import {
  cursorTrustedShell,
  scanNamespaceInvocations,
  TRUSTED_COMMAND_PREFIX,
  TRUSTED_ROUTE_NAMESPACE,
  trustedCommand,
} from "../core/tools/aidlc-command.ts";
import { copyChannelDispatcherCommands, copyChannelToolScripts, ROUTES, TOOLS } from "../core/tools/aidlc.ts";
import { AIDLC_VERSION } from "../core/tools/aidlc-version.ts";
import { BUILD_VERSION_ENV, releaseBuildVersion } from "../core/tools/aidlc-channel.ts";
import { copyStartsWithout, harnessChoiceTextIsPlain, sha256Bytes, writtenRootIntegration } from "../core/tools/aidlc-distribution.ts";
import { kiroIdeSteering } from "../core/tools/aidlc-includes.ts";
import { SHIPPED_FORMAT_DOCS } from "../core/tools/aidlc-inline-context.ts";
import { AIDLC_SETTINGS_SCHEMA } from "../core/tools/aidlc-settings.ts";

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const CORE_ROOT = join(REPO_ROOT, "core");
const HARNESS_ROOT = join(REPO_ROOT, "harness");

// The pack-time tier cap, resolved ONCE for the whole build: the
// AIDLC_TIER_CAP env var (per-invocation) beats the persistent space-memory
// `tier_cap:` frontmatter key (core/memory org.md -> team.md -> project.md,
// last writer wins). The env var applies to WRITE runs only - a one-shot
// build knob. Under --check it is IGNORED: the determinism guard measures the
// repository-defined projection, and a stray AIDLC_TIER_CAP in a CI or test
// runner's environment must not alter either build. The memory cap travels
// with the repo, so it applies in both modes and keeps write and check
// consistent. The diagnostic below names the active cap and mode.
// The `codex trust` subcommand performs NO projection (it prints trust
// entries for the installer), so it skips cap resolution entirely - a
// malformed cap must not break an installer command that never uses it.
const IS_CHECK_MODE = process.argv.includes("--check");
const IS_TRUST_MODE = process.argv[2] === "codex" && process.argv[3] === "trust";
const ENV_CAP = IS_CHECK_MODE || IS_TRUST_MODE ? null : readEnvCap();
const MEMORY_CAP = IS_TRUST_MODE ? null : readMemoryCap(join(CORE_ROOT, "memory"));
const TIER_CAP = ENV_CAP ?? MEMORY_CAP;
if (TIER_CAP) {
  // stderr, not stdout: the `codex trust` subcommand's stdout is pasted
  // verbatim into a config.toml and must stay clean.
  console.error(
    `[tier] pack-time tier cap active: ${TIER_CAP} ` +
      `(source: ${ENV_CAP ? "AIDLC_TIER_CAP env var" : "core/memory tier_cap:"})`,
  );
} else if (IS_CHECK_MODE && process.env.AIDLC_TIER_CAP) {
  console.error(
    "[tier] AIDLC_TIER_CAP is set but IGNORED under --check " +
      "(the env cap is a one-shot write knob; persistent caps live in core/memory)",
  );
}

// The version stamped into every projected aidlc-version.ts copy and projection
// stamp. Unset means the source version. A release build sets AIDLC_BUILD_VERSION
// to a next-patch preview id derived from the current source version; the source
// tree is never edited. Honoured in --check mode too, so the two-build
// determinism guard measures the same stamped projection the release will ship.
const BUILD_VERSION = releaseBuildVersion();
if (BUILD_VERSION !== AIDLC_VERSION) {
  console.error(`[version] stamping projections with ${BUILD_VERSION_ENV}=${BUILD_VERSION}`);
}
const VERSION_ASSIGNMENT = /^export const AIDLC_VERSION = "[^"]+";$/m;

function stampVersionModule(content: string): string {
  if (BUILD_VERSION === AIDLC_VERSION) return content;
  const matches = content.match(new RegExp(VERSION_ASSIGNMENT.source, "gm")) ?? [];
  if (matches.length !== 1) {
    throw new Error(
      `aidlc-version.ts must declare exactly one AIDLC_VERSION assignment, found ${matches.length}`,
    );
  }
  return content.replace(VERSION_ASSIGNMENT, `export const AIDLC_VERSION = "${BUILD_VERSION}";`);
}

// Neutral and native onboarding skeletons (scripts/onboarding.ts).
const ONBOARDING_SKELETON = join(CORE_ROOT, "templates", "onboarding.md");
const HARNESS_ONBOARDING_SKELETON = join(CORE_ROOT, "templates", "onboarding-harness.md");
const HARNESS_TOKEN = /\{\{HARNESS_DIR\}\}/g;
const INVOKE_TOKEN = /\{\{INVOKE\}\}/g;
const TOOL_PREFIX_TOKEN = /\{\{TOOL_PREFIX\}\}/g;
const TRUSTED_NAMESPACE_TOKEN = /\{\{TRUSTED_NAMESPACE\}\}/g;
// Matched by PREFIX (mirroring aidlc-init.ts's marker scan): the begin marker
// embeds the harness-projected invocation, so its tail varies per channel.
const GENERATED_SKILL_REGIONS = [
  {
    verb: "stage-table",
    begin: "",
  },
  {
    verb: "scope-table",
    begin: "",
  },
] as const;

// Harnesses the packager builds = every harness/<name>/ that carries a
// manifest.ts. DISCOVERED, not hardcoded: adding harness #N is one harness/<n>/
// dir + manifest row (+ optional emit.ts), with zero edits here — the
// one-core-many-harnesses promise. Sorted so the default build/--check order is
// stable (claude first by name).
function discoverHarnessNames(): string[] {
  if (!existsSync(HARNESS_ROOT)) return [];
  return readdirSync(HARNESS_ROOT)
    .filter((n) => existsSync(join(HARNESS_ROOT, n, "manifest.ts")))
    .sort();
}

// ---------------------------------------------------------------------------
// Transform: token substitution on authored prose and the
// TypeScript invocation seam; compiled JSON is regenerated per tree.
// ---------------------------------------------------------------------------
function substituteInvocationTokens(
  s: string,
  harnessDir: string,
  invoke = `bun ${harnessDir}/tools/aidlc.ts`,
): string {
  const toolPrefix = invoke === "aidlc" ? `${TRUSTED_COMMAND_PREFIX} ` : `bun ${harnessDir}/tools/`;
  return s
    .replace(INVOKE_TOKEN, invoke)
    .replace(TOOL_PREFIX_TOKEN, toolPrefix)
    .replace(TRUSTED_NAMESPACE_TOKEN, TRUSTED_ROUTE_NAMESPACE);
}

function substituteToken(
  s: string,
  harnessDir: string,
  invoke = `bun ${harnessDir}/tools/aidlc.ts`,
): string {
  return substituteInvocationTokens(s.replace(HARNESS_TOKEN, harnessDir), harnessDir, invoke);
}

// Rewrite in-prose `<harnessDir>/rules/` → `<harnessDir>/<rulesRename>/` for a
// harness that renames its rules dir (kiro: steering, codex: aidlc-rules).
// Anchored on the post-substitution harness-dir form so it can't touch an
// unrelated `rules/` mention. No-op when rulesRename is null (claude).
function applyRulesRename(s: string, harnessDir: string, rulesRename: string | null): string {
  if (!rulesRename) return s;
  // Codex's native Starlark permission file is not an AIDLC markdown rule.
  const escapedHarnessDir = harnessDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  return s.replace(
    new RegExp(`${escapedHarnessDir}/rules/(?!default\\.rules)`, "g"),
    `${harnessDir}/${rulesRename}/`,
  );
}

// Read the authored `tier:` from an agent .md's YAML FRONTMATTER (scoped to
// the block between the `---` fences - a `tier:` token in body prose never
// matches). Fails loudly when the frontmatter or the key is missing: every
// shipped agent must carry a tier, and a silent pass-through would ship an
// unprojected agent (no model/effort keys at all) without failing the build.
function agentTierFromMd(s: string, srcPath: string): string {
  // Strip a UTF-8 BOM before anchoring - macOS/Windows editors occasionally
  // save .md with one, and the ^--- anchor would otherwise miss (same
  // tolerance as the rule-frontmatter parser).
  if (s.charCodeAt(0) === 0xfeff) s = s.slice(1);
  const m = s.match(/^---\r?\n([\s\S]*?)\r?\n---/);
  if (!m) throw new Error(`${srcPath}: agent .md has no YAML frontmatter block.`);
  const tierMatch = m[1].match(/^tier:\s*(\S+)\s*$/m);
  if (!tierMatch) {
    throw new Error(`${srcPath}: agent frontmatter has no tier: line (the authored contract).`);
  }
  return tierMatch[1];
}

// Rewrite an agent .md's frontmatter `tier: <t>` line into the harness-native
// keys (Claude: `model:` + optional `effort:`; Kiro: optional `model:`; Codex
// .md copies mirror Claude's shape - the TOMLs emit.ts writes are the binding
// surface there). Called AFTER the token substitution + rules-rename pass.
// A missing tier: on an agent file fails the build (agentTierFromMd). A null
// projected model/effort means the harness-native key is OMITTED: the
// harness's own session/config default applies for that harness/tier
// combination. When every key is omitted the `tier:` line is dropped without
// a replacement.
function projectTierFrontmatter(
  s: string,
  srcPath: string,
  harness: Harness,
): string {
  // Only apply to files under agents/. Guard on the POSIX-normalized path
  // (srcPath carries the platform separator on Windows) because a stage .md
  // legitimately talks about "tier:" in prose.
  const posixPath = srcPath.split(sep).join("/");
  if (!posixPath.includes("/agents/") || !posixPath.endsWith("-agent.md")) return s;
  const tier = agentTierFromMd(s, srcPath);
  const m = s.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
  if (!m) throw new Error(`${srcPath}: agent .md has no closed frontmatter block.`);
  const fm = m[1];
  const disallowedMatches = [
    ...fm.matchAll(/^disallowedTools:\s*(.*?)\s*$/gm),
  ];
  if (
    harness === "kiro" &&
    (disallowedMatches.length !== 1 ||
      !/^task$/i.test(disallowedMatches[0][1].trim()))
  ) {
    throw new Error(
      `${srcPath}: kiro projection requires exactly one disallowedTools: Task line.`,
    );
  }
  if (harness === "kiro") {
    // Kiro's native agent surface rejects unknown frontmatter keys: project the
    // authored disallowedTools line out before the model-policy rewrite.
    const newFm = fm
      .split(/\r?\n/)
      .filter((line) => !/^disallowedTools:/.test(line))
      .join("\n");
    // Function replacement: a literal `$&`/`$'` in frontmatter must not be
    // interpreted as a replacement pattern.
    s = s.replace(m[0], () => `---\n${newFm}\n---\n`);
  }
  const effective = resolveModelPolicy(
    null,
    modelAgentName(srcPath),
    tier as Tier,
    harness,
    TIER_CAP,
  );
  return writeMarkdownAgentSurface(s, effective, {
    effortKey: harness === "opencode" ? "variant" : "effort",
  });
}

// Project the `"model"` field of an authored Kiro agent .json from the tier
// table. The JSONs stay hand-written (tools, resources, sandbox settings) but
// the model dial is projection-owned: the authored files carry NO "model"
// field at all (so nobody edits a value the build would overwrite), and the
// tier comes from the same-name core/agents/<slug>.md (single source of
// truth). A pinned tier ADDS the field; a null projected model leaves it
// absent - the agent-v1 schema documents the fallback ("If not specified,
// uses the default model"), which is exactly the judgment-tier inherit
// contract. Files with no core .md counterpart (the aidlc.json orchestrator
// config) pass through untouched: the orchestrator is not a tier-carrying
// persona. Never writes any effort-like key - kiro-cli fail-closes on
// unknown agent-JSON fields.
function projectKiroAgentJson(srcPath: string, content: Buffer): Buffer {
  const name = srcPath.split(sep).join("/").split("/").pop() ?? "";
  if (!name.endsWith("-agent.json")) return content;
  const coreMd = join(CORE_ROOT, "agents", name.replace(/\.json$/, ".md"));
  if (!existsSync(coreMd)) return content;
  const tier = agentTierFromMd(readFileSync(coreMd, "utf-8"), coreMd);
  const effective = resolveModelPolicy(
    null,
    modelAgentName(name),
    tier as Tier,
    "kiro",
    TIER_CAP,
  );
  return Buffer.from(
    writeKiroAgentSurface(content.toString("utf-8"), effective),
    "utf-8",
  );
}

// The authored Kiro agent JSONs preload the shared knowledge with a glob; a
// dispatched agent gets the shared methodology files only, as every other
// harness's preflight names them (SHIPPED_FORMAT_DOCS is the one list).
function projectKiroSharedKnowledge(content: Buffer): Buffer {
  const parsed = JSON.parse(content.toString("utf-8")) as { resources?: unknown };
  const glob = "file://.kiro/knowledge/aidlc-shared/*.md";
  if (!Array.isArray(parsed.resources) || !parsed.resources.includes(glob)) return content;
  const files = readdirSync(join(CORE_ROOT, "knowledge", "aidlc-shared"))
    .filter((file) => file.endsWith(".md") && !SHIPPED_FORMAT_DOCS.has(file))
    .sort()
    .map((file) => `file://.kiro/knowledge/aidlc-shared/${file}`);
  parsed.resources = parsed.resources.flatMap((entry) => (entry === glob ? files : [entry]));
  return Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
}

// Merge the tier-derived chat.modelDefaults entries into an authored Kiro
// settings/cli.json: one entry per distinct pinned Kiro model, carrying the
// highest sharing tier's effort (the collapse rule - kiroModelDefaults()).
// Authored entries are preserved and join the same higher-effort collapse on
// collision; none ship today, because a project map replaces the person's
// personal one. CLI-only: the Kiro IDE ignores cli.json.
function projectKiroCliJson(content: Buffer): Buffer {
  return Buffer.from(
    writeKiroCliSurface(content.toString("utf-8"), [], TIER_CAP),
    "utf-8",
  );
}

function transform(
  srcPath: string,
  content: Buffer,
  harnessDir: string,
  rulesRename: string | null,
  harness?: Harness,
  invoke = `bun ${harnessDir}/tools/aidlc.ts`,
): Buffer {
  if (srcPath.endsWith(".md")) {
    // Reviewer knowledge absorption runs FIRST (on the raw core text) so the
    // token substitution below covers the absorbed prose like any core .md.
    let s = content.toString("utf-8");
    const agentName = agentNameFromPath(srcPath);
    if (agentName) {
      s = absorbReviewerKnowledge(s, agentName, CORE_ROOT);
      s = injectDelegatedKnowledgePreflight(s, agentName, harnessDir);
    }
    s = substituteToken(s, harnessDir, invoke);
    s = applyRulesRename(s, harnessDir, rulesRename);
    if (harness) s = projectTierFrontmatter(s, srcPath, harness);
    // Cursor, opencode, and Copilot persona bodies read the method by path:
    // the engine's git-ignored copy of the active space's memory
    // (aidlc-includes.ts ACTIVE_MEMORY_DIR), the same path on every install.
    const posixPath = srcPath.split(sep).join("/");
    if (
      (harness === "cursor" || harness === "opencode" || harness === "copilot") &&
      posixPath.includes("/agents/") &&
      posixPath.endsWith("-agent.md")
    ) {
      s = s.replaceAll("aidlc/spaces/<active-space>/memory/", "aidlc/active-memory/");
    }
    return Buffer.from(s, "utf-8");
  }
  if (srcPath.endsWith(".ts")) {
    const source = basename(srcPath) === "aidlc-version.ts"
      ? stampVersionModule(content.toString("utf-8"))
      : content.toString("utf-8");
    return Buffer.from(
      substituteInvocationTokens(source, harnessDir, invoke),
      "utf-8",
    );
  }
  if (/\.(?:hook|json|toml)$/.test(srcPath)) {
    return Buffer.from(
      substituteToken(content.toString("utf-8"), harnessDir, invoke),
      "utf-8",
    );
  }
  return content;
}

// Append manifest-declared frontmatter lines to a projected .md, just before
// the closing `---` of its YAML block (manifest-types.ts frontmatterAdditions).
// Hard errors, never silent: the file must open with a frontmatter block, and
// no added line's key may already exist in it - if core later grows the same
// key, the build fails loudly instead of shipping a double.
function applyFrontmatterAdditions(
  content: string,
  lines: string[],
  file: string,
): string {
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n(---\r?\n)/);
  if (!m) {
    throw new Error(
      `frontmatterAdditions: ${file} has no leading frontmatter block to extend.`,
    );
  }
  const fm = m[1];
  const added = new Set<string>();
  for (const line of lines) {
    if (/^\s/.test(line)) continue;
    const key = line.split(":")[0]?.trim();
    if (!key || !/^[A-Za-z_][\w-]*$/.test(key)) {
      throw new Error(
        `frontmatterAdditions: line "${line}" for ${file} does not start with a YAML key.`,
      );
    }
    if (added.has(key)) {
      throw new Error(
        `frontmatterAdditions: ${file} declares "${key}:" more than once in additions.`,
      );
    }
    added.add(key);
    if (new RegExp(`^${key}:`, "m").test(fm)) {
      throw new Error(
        `frontmatterAdditions: ${file} already declares "${key}:" in core - ` +
          `resolve the collision instead of shipping a duplicate key.`,
      );
    }
  }
  const insertAt = m[0].length - m[2].length;
  return `${content.slice(0, insertAt)}${lines.join("\n")}\n${content.slice(insertAt)}`;
}

function* walk(dir: string): Generator<string> {
  for (const entry of readdirSync(dir).sort()) {
    const full = join(dir, entry);
    if (statSync(full).isDirectory()) yield* walk(full);
    else yield full;
  }
}

// Byte-diff two independently built trees. The caller supplies a stable
// projection prefix so diagnostics never expose temp-directory names.
function diffTrees(
  first: string,
  second: string,
  relPrefix: string,
): string[] {
  const problems: string[] = [];
  const firstFiles = new Set<string>();
  const files = existsSync(first) ? [...walk(first)] : [];
  for (const f of files) {
    const rel = relative(first, f);
    firstFiles.add(rel);
    const counterpart = join(second, rel);
    if (!existsSync(counterpart)) {
      problems.push(`MISSING in second build: ${relPrefix}/${rel}`);
    } else if (!readFileSync(f).equals(readFileSync(counterpart))) {
      problems.push(`DIFFERS between builds: ${relPrefix}/${rel}`);
    }
  }
  if (existsSync(second)) {
    for (const f of walk(second)) {
      const rel = relative(second, f);
      if (firstFiles.has(rel)) continue;
      problems.push(`ONLY in second build: ${relPrefix}/${rel}`);
    }
  }
  return problems;
}

// The two compiled-data files regenerated into every tree. The rename backstop
// below scans both after compile.
const COMPILED_DATA = ["tools/data/stage-graph.json", "tools/data/scope-grid.json"];

// The packager-emitted harness descriptor (vision T1 open-set seam): the
// runtime reads tools/data/harness.json to learn this harness's rules-subdir
// without a hardcoded map. Derived from the manifest, written into every tree.
const HARNESS_DATA = "tools/data/harness.json";
const AGENT_TIERS_DATA = "tools/data/agent-tiers.json";
const SETTINGS_SCHEMA_DATA = "tools/data/aidlc-settings.schema.json";
const STAMP_DATA = "tools/data/aidlc-stamp.json";
const PROJECTION_DATA = "tools/data/aidlc-projection.json";

// The method ("memory"): the single hand-editable source of truth
// for the layered practices (org/team/project + phases/). It is HARNESS-NEUTRAL
// (identical bytes on every harness — neutral filenames, no {{HARNESS_DIR}}
// token), so the source dir + the dist destination are constants here, not
// per-manifest, and the packager copies the tree verbatim. The destination sits
// at the WORKSPACE ROOT (beside the harness dir), under the always-present `default`
// space, so a fresh `dist/<harness>/` copy ships a resolving method tree and the
// per-harness native include points at it (see aidlc-includes.ts) - one copy,
// no drift.
const MEMORY_SRC = "memory";
const MEMORY_DST = join("aidlc", "spaces", "default", "memory");

// Engine-only-install self-heal: the SAME method content (core/memory/) ALSO emitted
// INSIDE the engine dir at <harnessDir>/tools/data/memory-seed/, mirroring how
// tools/data/templates ships (an engine-bundled, copy-out-at-runtime data dir
// resolved relative to the running tool — see frameworkTemplatesDir/DATA_DIR in
// aidlc-graph.ts). This lets an ENGINE-ONLY install (a user who copies only
// dist/<h>/.<engine>/ and NOT the sibling aidlc/ shell) self-heal: the first
// /aidlc seeds aidlc/spaces/default/memory/ from this bundled copy if (and only
// if) it is absent (see ensureWorkspaceDirs). The sibling MEMORY_DST shell STILL
// ships for normal installs — this is an additive fallback, not a replacement.
const MEMORY_SEED_DST = join("tools", "data", "memory-seed");

// The plugin authoring validator must run from a copied distribution with no
// framework checkout. Ship the compose hook's source of truth beside the tool
// so a vendored hooks/compose.ts can be byte-compared offline.
const PLUGIN_HOOKS_TEMPLATE_SRC = join(
  REPO_ROOT,
  "scripts",
  "plugin-hooks-template",
);
const PLUGIN_HOOKS_TEMPLATE_DST = join(
  "tools",
  "data",
  "plugin-hooks-template",
);
const PLUGIN_TARGETS_DST = join("tools", "data", "plugin-targets.json");
const PLUGIN_AUTHORING_CONTEXT_DST = join(
  "tools",
  "data",
  "plugin-authoring-context.json",
);

// The active-space CURSOR shipped as part of the workspace shell (SEED). It
// lives at aidlc/active-space (ABOVE spaces/, not inside memory/) and holds the
// name of the space the next /aidlc resolves against. Ships pointed at the
// always-present "default" space so a fresh copy resolves with zero ceremony.
// NOTE: it is GITIGNORED in the user's workspace (a per-user session cursor,
// vision 5.1 - teammates legitimately point at different spaces at once), yet
// dist must SHIP it as part of the shell. The two reconcile: the projected
// .gitignore ignores aidlc/active-space for the END USER; a clone's first
// SessionStart or active-intent write atomically recreates the missing pointer
// and keeps it untracked. The local dist root itself is ignored.
const ACTIVE_SPACE_REL = join("aidlc", "active-space");
const ACTIVE_SPACE_VALUE = "default\n";

// Write tools/data/harness.json from manifest data. The runtime reads the
// rules-subdir and any host-native generated-runner frontmatter from this
// open-set descriptor. Pretty-printed + trailing newline keeps output
// diff-friendly and stable under --check.
function writeHarnessData(treeRoot: string, m: HarnessManifest): void {
  // A FRESH object, deliberately: the runtime READER tolerates unknown keys, but
  // this writer alone decides what ships. Anything hand-added to a local
  // harness.json is erased on the next build, so a new configuration field has
  // to be added HERE to exist at all.
  const data: Record<string, unknown> = {
    schemaVersion: 1,
    distribution: m.name,
    productName: m.productName,
    configNextStep: m.configNextStep,
    name: m.name,
    harnessDir: m.harnessDir,
    rulesSubdir: m.rulesRename ?? "rules",
    ...(m.runnerFrontmatterAdditions?.length
      ? { runnerFrontmatterAdditions: m.runnerFrontmatterAdditions }
      : {}),
  };
  // Emitted only when a manifest sets it, so the output stays
  // byte-identical for every harness that does not -- which is all of them today.
  if (m.documentExtractors) data.documentExtractors = m.documentExtractors;
  // Likewise conditional: only a host whose hooks wait on the person (trust, engine) declares it.
  if (m.hookActivation) data.hookActivation = m.hookActivation;
  // Only the Kiro rows declare a layout; the runtime reads it in place of the row name.
  if (m.kiroLayout) data.kiroLayout = m.kiroLayout;
  // And only a host that cuts a shell result below the engine's cap.
  if (m.directiveMaxBytes) data.directiveMaxBytes = m.directiveMaxBytes;
  // And only a host where AI-DLC can show its progress in the status line.
  if (m.statusLine) data.statusLine = m.statusLine;
  // And only a host whose stop event records an agent's finish before its result.
  if (m.agentFinishRecorded) data.agentFinishRecorded = m.agentFinishRecorded;
  const dst = join(treeRoot, HARNESS_DATA);
  mkdirSync(dirname(dst), { recursive: true });
  writeFileSync(dst, `${JSON.stringify(data, null, 2)}\n`);
}

function writeAgentTiersData(treeRoot: string): void {
  const dst = join(treeRoot, AGENT_TIERS_DATA);
  mkdirSync(dirname(dst), { recursive: true });
  writeFileSync(
    dst,
    serializeAgentTiers(agentTiersFromAuthoredDirectory(join(CORE_ROOT, "agents"))),
  );
}

function writeSettingsSchemaData(treeRoot: string): void {
  const dst = join(treeRoot, SETTINGS_SCHEMA_DATA);
  mkdirSync(dirname(dst), { recursive: true });
  writeFileSync(dst, `${JSON.stringify(AIDLC_SETTINGS_SCHEMA, null, 2)}\n`);
}

function writeProjectionData(outRoot: string, treeRoot: string, m: HarnessManifest): void {
  if (!harnessChoiceTextIsPlain(m)) {
    throw new Error(`[${m.name}] choiceName or choiceNote is not text the harness lists can show`);
  }
  const rootIntegrationPaths = new Set(m.rootIntegrations.map((item) => item.path));
  if (rootIntegrationPaths.size !== m.rootIntegrations.length) {
    throw new Error(`[${m.name}] duplicate root integration path`);
  }
  for (const integration of m.rootIntegrations) {
    if (!existsSync(join(outRoot, integration.path))) {
      throw new Error(`[${m.name}] root integration is not projected: ${integration.path}`);
    }
  }
  // A directory that holds only root integrations (.vscode/ for settings.json)
  // is the project's, not a managed engine directory.
  const onlyRootIntegrations = (entry: string): boolean => {
    const files: string[] = [];
    const visit = (dir: string): void => {
      for (const name of readdirSync(join(outRoot, dir))) {
        const rel = `${dir}/${name}`;
        if (statSync(join(outRoot, rel)).isDirectory()) visit(rel);
        else files.push(rel);
      }
    };
    visit(entry);
    return files.length > 0 && files.every((file) => rootIntegrationPaths.has(file));
  };
  const managedDirectories = readdirSync(outRoot)
    .filter((entry) => statSync(join(outRoot, entry)).isDirectory())
    .filter((entry) => !rootIntegrationPaths.has(entry) && !onlyRootIntegrations(entry))
    .sort();
  const allowedTopLevel = new Set([
    ...managedDirectories,
    ...m.rootIntegrations.map((item) => item.path.split("/")[0]),
  ]);
  const unclassified = readdirSync(outRoot)
    .filter((entry) => !allowedTopLevel.has(entry))
    .sort();
  if (unclassified.length > 0) {
    throw new Error(`[${m.name}] unclassified projection entries: ${unclassified.join(", ")}`);
  }
  const rootIntegrations = m.rootIntegrations.map((integration) => {
    if (copyStartsWithout(integration) || integration.policy === "json-entries") {
      // A copy starts without this file (or leaves the team's own in place);
      // its shipped part rides along so config and the engine can add it
      // without a download.
      const dst = join(treeRoot, "tools", "data", "root-blocks", basename(integration.path));
      mkdirSync(dirname(dst), { recursive: true });
      writeFileSync(dst, readFileSync(join(outRoot, integration.path)));
      return integration;
    }
    if (integration.policy !== "managed-block") return integration;
    const bytes = readFileSync(join(outRoot, integration.path));
    // Every managed block ships a copy inside the harness folder: siblings
    // read it to combine .gitignore lines, the copy runtime leaves the root
    // file out, and config and the engine add AI-DLC's part from it.
    const dst = join(
      treeRoot,
      "tools",
      "data",
      "root-blocks",
      integration.marker || basename(integration.path),
    );
    mkdirSync(dirname(dst), { recursive: true });
    writeFileSync(dst, bytes);
    const currentHash = sha256Bytes(bytes);
    return {
      ...integration,
      legacySignatures: {
        ...integration.legacySignatures,
        wholeFileHashes: [
          ...new Set([
            ...(integration.legacySignatures?.wholeFileHashes ?? []),
            currentHash,
          ]),
        ],
      },
    };
  });
  const projection = {
    schemaVersion: 1,
    distribution: m.name,
    productName: m.productName,
    ...(m.choiceName ? { choiceName: m.choiceName } : {}),
    ...(m.choiceNote ? { choiceNote: m.choiceNote } : {}),
    configNextStep: m.configNextStep,
    ...(m.firstRunSteps ? { firstRunSteps: m.firstRunSteps } : {}),
    ...(m.editorTerminalApp ? { editorTerminalApp: m.editorTerminalApp } : {}),
    harnessDir: m.harnessDir,
    ...(m.onboarding?.harnessDst
      ? { onboarding: `${m.harnessDir}/${m.onboarding.harnessDst}` }
      : m.onboarding && !m.onboarding.projectRoot
        ? { onboarding: `${m.harnessDir}/${m.onboarding.dst}` }
        : {}),
    managedDirectories,
    rootIntegrations: rootIntegrations.map(writtenRootIntegration),
  };
  const stamp = {
    schemaVersion: 1,
    frameworkVersion: BUILD_VERSION,
    distribution: m.name,
    harnessDir: m.harnessDir,
  };
  mkdirSync(join(treeRoot, "tools", "data"), { recursive: true });
  writeFileSync(join(treeRoot, PROJECTION_DATA), `${JSON.stringify(projection, null, 2)}\n`);
  writeFileSync(join(treeRoot, STAMP_DATA), `${JSON.stringify(stamp, null, 2)}\n`);
}

// Emit the method ("memory") tree at the WORKSPACE ROOT of the dist tree
// (dist/<name>/aidlc/spaces/default/memory/), copying core/memory/ verbatim with
// the standard .md token transform (a no-op on the neutral method files, which
// carry no {{HARNESS_DIR}} token). Same source + destination for every harness
// — the method is harness-neutral; the per-harness native include is what
// differs.
function emitMemory(
  outRoot: string,
  harnessDir: string,
  rulesRename: string | null,
  harness: Harness,
  invoke: string,
): void {
  const srcDir = join(CORE_ROOT, MEMORY_SRC);
  if (!existsSync(srcDir)) return;
  for (const file of walk(srcDir)) {
    const rel = relative(srcDir, file);
    const outPath = join(outRoot, MEMORY_DST, rel);
    mkdirSync(dirname(outPath), { recursive: true });
    writeFileSync(outPath, transform(file, readFileSync(file), harnessDir, rulesRename, harness, invoke));
  }
}

// Engine-only-install self-heal: emit the SAME core/memory/ tree a SECOND time,
// INSIDE the engine dir at <treeRoot>/tools/data/memory-seed/, so an engine-only
// install carries the method content with it (the first /aidlc copies it out via
// ensureWorkspaceDirs/frameworkMemorySeedDir). Mirrors emitMemory's transform
// (a no-op on the neutral method files) but writes into treeRoot (the harness
// engine dir), so the generated-root inventory covers it. Same source as
// emitMemory, different destination.
function emitMemorySeed(
  treeRoot: string,
  harnessDir: string,
  rulesRename: string | null,
  harness: Harness,
  invoke: string,
): void {
  const srcDir = join(CORE_ROOT, MEMORY_SRC);
  if (!existsSync(srcDir)) return;
  for (const file of walk(srcDir)) {
    const rel = relative(srcDir, file);
    const outPath = join(treeRoot, MEMORY_SEED_DST, rel);
    mkdirSync(dirname(outPath), { recursive: true });
    writeFileSync(outPath, transform(file, readFileSync(file), harnessDir, rulesRename, harness, invoke));
  }
}

function emitPluginAuthoringData(treeRoot: string): void {
  for (const file of walk(PLUGIN_HOOKS_TEMPLATE_SRC)) {
    const outPath = join(
      treeRoot,
      PLUGIN_HOOKS_TEMPLATE_DST,
      relative(PLUGIN_HOOKS_TEMPLATE_SRC, file),
    );
    mkdirSync(dirname(outPath), { recursive: true });
    cpSync(file, outPath);
  }
  const targetsPath = join(treeRoot, PLUGIN_TARGETS_DST);
  mkdirSync(dirname(targetsPath), { recursive: true });
  writeFileSync(
    targetsPath,
    `${JSON.stringify(pluginTargets(), null, 2)}\n`,
  );
  const contextPath = join(treeRoot, PLUGIN_AUTHORING_CONTEXT_DST);
  const agents = [...walk(join(CORE_ROOT, "agents"))]
    .filter((file) => file.endsWith("-agent.md"))
    .map((file) => basename(file, ".md"))
    .sort();
  const stages = [...walk(join(CORE_ROOT, "aidlc-common", "stages"))]
    .filter((file) => file.endsWith(".md"))
    .map((file) => basename(file, ".md"))
    .sort();
  writeFileSync(
    contextPath,
    `${JSON.stringify({ agents, stages }, null, 2)}\n`,
  );
}

// Emit the active-space CURSOR (aidlc/active-space -> "default") into the dist
// tree, as part of the workspace shell (SEED). Lives at the dist root beside
// the harness dir (dist/<name>/aidlc/active-space), OUTSIDE <harnessDir>, like
// the memory tree and projectRoot harness files. Harness-neutral: same pointer
// value for every harness (the resolver follows it identically). The projected
// .gitignore ignores this path for the END USER's workspace.
function emitActiveSpace(outRoot: string): void {
  const outPath = join(outRoot, ACTIVE_SPACE_REL);
  mkdirSync(dirname(outPath), { recursive: true });
  writeFileSync(outPath, ACTIVE_SPACE_VALUE);
}

// ---------------------------------------------------------------------------
// Build one harness tree into `outRoot` (the dist/<name> dir). Returns the full
// generated-file inventory rooted there, including project-root files beside
// <harnessDir> (onboarding/config, workspace memory, emitted skills, and so on).
// ---------------------------------------------------------------------------
function buildTree(
  m: HarnessManifest,
  outRoot: string,
  invoke = `bun ${m.harnessDir}/tools/aidlc.ts`,
): string[] {
  const harnessDir = m.harnessDir;
  const treeRoot = join(outRoot, harnessDir);
  // Every harness projects onto ONE of the six flavors the tier module
  // knows (Kiro CLI and Kiro IDE share the "kiro" flavor - identical model
  // dial). Declared per manifest, never inferred from the harness name.
  const harnessKind = m.tierFlavor;
  // 1. Copy core dirs with token substitution + rules rename. Manifest-declared
  //    frontmatter additions (harness-native fields, e.g. the Kiro IDE's
  //    subagent `tools:` grant) are appended during this projection; every
  //    declared file must be hit exactly once (typo/rename guard).
  const fmAdditions = new Map(
    (m.frontmatterAdditions ?? []).map(({ file, lines }) => [file, lines]),
  );
  const fmApplied = new Set<string>();
  for (const { src, dst } of m.coreDirs) {
    const srcDir = join(CORE_ROOT, src);
    if (!existsSync(srcDir)) continue;
    const finalDst = m.rulesRename && dst === "rules" ? m.rulesRename : dst;
    for (const file of walk(srcDir)) {
      const rel = relative(srcDir, file);
      const outPath = join(treeRoot, finalDst, rel);
      mkdirSync(dirname(outPath), { recursive: true });
      let out = transform(
        file,
        readFileSync(file),
        harnessDir,
        m.rulesRename,
        harnessKind,
        invoke,
      );
      // Manifest keys are POSIX; normalize the platform separator so the
      // lookup works on Windows too.
      const harnessRel = join(finalDst, rel).split(sep).join("/");
      // A protocol module's per-tool binding subsections: this tree keeps its own.
      if (/^aidlc-common\/protocols\/[^/]+\.md$/.test(harnessRel)) {
        out = Buffer.from(keepOwnHarnessBindings(out.toString("utf-8"), m.name, harnessRel), "utf-8");
      }
      const fmLines = fmAdditions.get(harnessRel);
      if (fmLines) {
        // The additions are for the harness's dispatch (Kiro IDE: about 32 KB
        // of tool permissions); the lead reading the persona in its own chat
        // gets the persona without them, from the copy the roster names. A
        // tools line alone (Kiro CLI) is no weight to keep out: no copy.
        if (
          harnessRel.startsWith("agents/") &&
          fmLines.some((line) => line.split(":", 1)[0] === "permissions") &&
          inlineAgentSet(CORE_ROOT).has(basename(outPath, ".md"))
        ) {
          const copy = join(treeRoot, "aidlc-common", "personas", basename(outPath));
          mkdirSync(dirname(copy), { recursive: true });
          writeFileSync(copy, out);
        }
        out = Buffer.from(
          applyFrontmatterAdditions(out.toString("utf-8"), fmLines, harnessRel),
          "utf-8",
        );
        fmApplied.add(harnessRel);
      }
      writeFileSync(outPath, out);
    }
  }
  const fmMissed = [...fmAdditions.keys()].filter((f) => !fmApplied.has(f));
  if (fmMissed.length > 0) {
    throw new Error(
      `[${m.name}] frontmatterAdditions name file(s) the core projection never produced: ` +
        `${fmMissed.join(", ")} - fix the path(s) in the manifest.`,
    );
  }
  // 2. Copy authored harness surfaces (token substitution on .md). projectRoot
  //    files land beside the harness dir (e.g. dist/kiro/AGENTS.md), the rest
  //    inside <harnessDir>/. On the kiro harnesses two authored JSON surfaces
  //    are additionally tier-projected: the agent .json "model" fields and the
  //    settings/cli.json chat.modelDefaults...
Source README

AI-DLC - one core, many harnesses

AI-DLC (AI-Driven Development Life Cycle) turns AI coding assistants into
structured, verifiable software-delivery workflows. One harness-neutral core
runs natively in Claude Code, Kiro CLI, Kiro IDE, Codex CLI, Cursor, opencode,
and GitHub Copilot.

version
license

The Quick Start below installs the latest stable AI-DLC release.

Quick Start

1. Install AI-DLC

macOS, Linux, or WSL:

curl -fsSL https://github.com/awslabs/aidlc-workflows/releases/latest/download/install.sh | sh

Windows PowerShell:

irm https://github.com/awslabs/aidlc-workflows/releases/latest/download/install.ps1 | iex

The installer sets up everything AI-DLC needs: the native aidlc command and
every harness runtime. You do not need to install Bun or Node.js; a native
install runs its hooks and commands through aidlc and does not call bun.
On Windows, it installs for the current account
and automatically registers the bin directory in User PATH. Run it from a normal
PowerShell window; one opened with "Run as administrator" gets a warning and a
prompt, since installing as administrator is less safe. Open a new terminal
if another session cannot find aidlc. To skip both persistent and
current-process PATH changes, use
-NoModifyPath.
Windows uninstall removes only the User PATH entry recorded as installer-owned.
On macOS, Linux, or WSL, follow the installer's PATH instruction if needed.

Prefer to manage the project files yourself, or cannot install a native
executable? The manual-copy path is the one place Bun is needed: install
Bun, download aidlc-copy-runtime-X.Y.Z.tar.gz from the
release, and copy
the complete runtime/<harness>/ directory into your project. This path does
not require the native aidlc command.

2. Configure a project

From the project root, select the harness you use:

cd /path/to/your-project
aidlc config --harness claude
aidlc doctor

Replace claude with kiro, kiro-ide, codex, cursor, opencode, or
copilot. Running aidlc config without --harness starts the interactive
setup when a terminal is available. If you use Kiro IDE's own terminal in a
project folder you have not trusted yet, Kiro first asks whether you trust it.
Choose Trust Folder & Continue only for your own project or one you have
checked, because trusting lets the folder's .kiro hooks run commands on your
machine; otherwise choose Cancel and review the folder first (see
First run).

3. Start a workflow

Open your harness in the configured project and describe the work:

/aidlc Build a REST API for inventory management

Codex CLI uses $aidlc instead of /aidlc. In Kiro IDE, first choose aidlc
in the chat panel's agent picker. AI-DLC selects a workflow from the request,
asks for missing decisions, and stops at approval gates before moving forward.

For provider setup, trust prompts, and harness-specific prerequisites, use the
guide in the table below. The complete walkthrough is in
Getting Started.

Pick your harness

Harness Configure Open Invoke Guide
Claude Code aidlc config --harness claude claude /aidlc Getting Started
Kiro: Kiro IDE >= 1.1.70 / Kiro CLI >= 2.24.1 (v3) aidlc config --harness kiro-ide Open the project in Kiro IDE and choose aidlc in the chat panel's agent picker, or run kiro-cli /aidlc Kiro IDE
Kiro CLI (V2) >= 2.6 aidlc config --harness kiro kiro-cli chat /aidlc Kiro CLI
Codex CLI >= 0.145.0 aidlc config --harness codex codex $aidlc Codex CLI
Cursor aidlc config --harness cursor Open Cursor or run agent /aidlc Cursor
opencode >= 1.17 aidlc config --harness opencode opencode /aidlc opencode
GitHub Copilot CLI >= 1.0.74 / VS Code >= 1.130 aidlc config --harness copilot Copilot CLI or VS Code /aidlc GitHub Copilot

Model-provider setup belongs to the harness. Shipped project configuration
keeps the provider and model already selected by the user. aidlc config providers can apply Amazon Bedrock settings on supported project surfaces or
record manual setup for other harnesses. Kiro CLI and Kiro IDE need no provider
answer because model access comes with Kiro. For Kiro's v3 engine on Kiro CLI
2.24.1 or later, use kiro-ide, which pins it; kiro is Kiro CLI (V2)
(Kiro CLI says how an existing kiro
project switches). The methodology itself is
provider-independent.

Recommended Model

AI-DLC works best with capable reasoning models. The current recommended model
is Claude Opus 4.8. If Opus is not available to you, or you are not sure where
to set the model and effort on your harness, see
Choosing a Model and Effort.

Why AI-DLC

Ad-hoc AI coding loses context as projects grow. AI-DLC keeps requirements,
decisions, implementation, tests, and operational work connected through one
audited lifecycle:

  • 5 phases and 33 stages from initialization through operation
  • 14 agents: 11 domain experts, 2 reviewers, and an adaptive composer
  • 11 workflow profiles for features, bug fixes, infrastructure, security,
    proofs of concept, enterprise delivery, and other common work
  • Human approval gates and source-bound review evidence
  • 119-event audit trail plus persistent state, team knowledge, and learned rules
  • The same deterministic engine across every supported harness

Start with Workflow Profiles to compare
Classic, Express, and the focused workflows. See the
AI-DLC Workflows 2.0 Specification
for the architecture and methodology.

Documentation

Guide Use it when
Getting Started Installing, configuring, and running your first workflow
User Guide Using workflows, profiles, agents, knowledge, and approval gates
Harness guides Handling provider, trust, and runtime differences
Install and Lifecycle Updating, pinning, installing offline, using mirrors, or uninstalling
Harness Engineer Guide Reshaping stages, agents, rules, sensors, and knowledge
Development and Releases Taking a PR through AI review, preview testing, and stable publication
Developer Reference Changing the engine, hooks, packaging, or tests

Repository Layout

  • core/ - hand-authored, harness-neutral methodology and engine
  • core/tools/ - 93 aidlc-*.ts engine and authoring tools
  • harness/<name>/ - thin, harness-specific manifests and integrations
  • plugins/<name>/ - optional AIDLC plugins
  • scripts/ - packaging, binary, installer, and release tooling
  • tests/ - smoke, unit, integration, and end-to-end tests
  • docs/ - user, harness-engineering, and developer documentation
  • dist/ and dist-release/ - generated, ignored local outputs

Edit core/ or harness/<name>/, never generated dist* output.

Development

Install dependencies and generate every harness:

bun install --frozen-lockfile
bun scripts/package.ts

Useful commands:

bun scripts/package.ts <name>     # generate one harness
bun scripts/package.ts --check    # determinism guard
bun tests/run-tests.ts --ci       # smoke, unit, and integration
bun tests/run-tests.ts --release  # full release acceptance

See the Contributing Guide for the complete
development workflow and Porting to a New Harness
to add another runtime.

Troubleshooting

Run aidlc doctor from the project root first. Common fixes:

Symptom Fix
aidlc is not found On Windows, quit and reopen the terminal app or IDE (a new tab inside one that was open during the install keeps the old PATH), or use the direct command printed with -NoModifyPath. On Unix, apply the installer's PATH instruction.
Project/runtime version skew Finish the active workflow, then run aidlc config
Codex hooks do not run Trust the project hooks as described in the Codex guide
Bedrock access fails Enable the configured models and verify AWS credentials and region
Plugin stages disappear after refresh Run /aidlc plugin sync
Refreshed skills do not take effect Start a new harness session

See Troubleshooting for diagnostic and
recovery procedures.

References

scripts/package.ts

#!/usr/bin/env bun
// scripts/package.ts — THE build entry for the one-core-N-harnesses layout.
//
//   bun scripts/package.ts            regenerate dist/ and dist-release/ for every harness, plus plugins
//   bun scripts/package.ts --check     determinism guard (exit 1 on mismatch)
//   bun scripts/package.ts <name>      regenerate just one harness
//   bun scripts/package.ts <name> --check
//
// PIPELINE PER HARNESS:
//   1. COPY core/<src> → dist/<name>/<harnessDir>/<dst>, substituting
//      {{HARNESS_DIR}} -> harnessDir in .md prose (see transform()) and
//      applying the manifest's rules-dir rename.
//   2. COPY harness/<name>/<src> → dist/<name>/<harnessDir>/<dst> (authored
//      surfaces: orchestrator skill, settings/config), same token substitution
//      on .md; render neutral onboarding and native setup from their skeletons.
//   3. COMPILE the stage graph into the assembled tree (emits harness-correct
//      stage-graph.json + scope-grid.json — compiled data lives only in dist).
//   4. GENERATE runners into the assembled tree by running aidlc-runner-gen's
//      write and scopes CLIs under AIDLC_HARNESS_DIR (skipped when the manifest
//      sets skipRunnerGen).
//   5. EMIT via harness/<name>/emit.ts if the manifest declares one (codex,
//      copilot and opencode today; for codex: config.toml, hooks.json,
//      trust-seed, agent TOMLs, .agents/skills).
//   6. REFRESH generated stage/scope table regions in the assembled orchestrator
//      skill from the just-compiled graph and scope grid.
//
// THE TOKENS: core/ prose carries {{HARNESS_DIR}}, {{INVOKE}}, {{TOOL_PREFIX}} and
// {{TRUSTED_NAMESPACE}}; here they become the harness's own values (the dir is
// `.claude`/`.kiro`/`.codex`/`.aidlc`/`.cursor`). Truthful carve-outs in core
// (workspace-detection's 3-dir list, the `$CLAUDE_PROJECT_DIR on Claude Code`
// note) never carried the token, so they pass through untouched.
//
// --check builds the complete copy-channel, native-release, and plugin
// projections twice in independent temp roots, then compares them byte for
// byte. It never reads dist/ or dist-release/, so it works from a fresh clone
// and fails CI only when the generator is nondeterministic.

import {
  cpSync,
  existsSync,
  lstatSync,
  mkdirSync,
  mkdtempSync,
  readdirSync,
  readFileSync,
  rmSync,
  statSync,
  writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import {
  basename,
  dirname,
  isAbsolute,
  join,
  posix,
  relative,
  sep,
} from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import type { HarnessManifest } from "./manifest-types.ts";
import {
  absorbReviewerKnowledge,
  agentNameFromPath,
  inlineAgentSet,
  injectDelegatedKnowledgePreflight,
  reviewerAgentSet,
} from "./agent-knowledge.ts";
import { renderNeutralOnboarding, renderOnboarding } from "./onboarding.ts";
import { keepOwnHarnessBindings } from "./harness-bindings.ts";
import { EARLIER_RELEASE_REGIONS, earlierReleasesReadSkill } from "./earlier-release-regions.ts";
import { forgetPackagedSources, packageInputsFingerprint, recordPackagedSources } from "./package-sources.ts";
import {
  buildPluginProjection as emitPluginProjection,
  type PluginTarget,
  type PluginTargetTable,
} from "../core/tools/aidlc-plugin-emit.ts";
import {
  type Harness,
  readEnvCap,
  readMemoryCap,
  type Tier,
} from "../core/tools/aidlc-tiers.ts";
import {
  agentTiersFromAuthoredDirectory,
  modelAgentName,
  resolveModelPolicy,
  serializeAgentTiers,
  writeKiroAgentSurface,
  writeKiroCliSurface,
  writeMarkdownAgentSurface,
} from "../core/tools/aidlc-model-policy.ts";
import {
  cursorTrustedShell,
  scanNamespaceInvocations,
  TRUSTED_COMMAND_PREFIX,
  TRUSTED_ROUTE_NAMESPACE,
  trustedCommand,
} from "../core/tools/aidlc-command.ts";
import { copyChannelDispatcherCommands, copyChannelToolScripts, ROUTES, TOOLS } from "../core/tools/aidlc.ts";
import { AIDLC_VERSION } from "../core/tools/aidlc-version.ts";
import { BUILD_VERSION_ENV, releaseBuildVersion } from "../core/tools/aidlc-channel.ts";
import { copyStartsWithout, harnessChoiceTextIsPlain, sha256Bytes, writtenRootIntegration } from "../core/tools/aidlc-distribution.ts";
import { kiroIdeSteering } from "../core/tools/aidlc-includes.ts";
import { SHIPPED_FORMAT_DOCS } from "../core/tools/aidlc-inline-context.ts";
import { AIDLC_SETTINGS_SCHEMA } from "../core/tools/aidlc-settings.ts";

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const CORE_ROOT = join(REPO_ROOT, "core");
const HARNESS_ROOT = join(REPO_ROOT, "harness");

// The pack-time tier cap, resolved ONCE for the whole build: the
// AIDLC_TIER_CAP env var (per-invocation) beats the persistent space-memory
// `tier_cap:` frontmatter key (core/memory org.md -> team.md -> project.md,
// last writer wins). The env var applies to WRITE runs only - a one-shot
// build knob. Under --check it is IGNORED: the determinism guard measures the
// repository-defined projection, and a stray AIDLC_TIER_CAP in a CI or test
// runner's environment must not alter either build. The memory cap travels
// with the repo, so it applies in both modes and keeps write and check
// consistent. The diagnostic below names the active cap and mode.
// The `codex trust` subcommand performs NO projection (it prints trust
// entries for the installer), so it skips cap resolution entirely - a
// malformed cap must not break an installer command that never uses it.
const IS_CHECK_MODE = process.argv.includes("--check");
const IS_TRUST_MODE = process.argv[2] === "codex" && process.argv[3] === "trust";
const ENV_CAP = IS_CHECK_MODE || IS_TRUST_MODE ? null : readEnvCap();
const MEMORY_CAP = IS_TRUST_MODE ? null : readMemoryCap(join(CORE_ROOT, "memory"));
const TIER_CAP = ENV_CAP ?? MEMORY_CAP;
if (TIER_CAP) {
  // stderr, not stdout: the `codex trust` subcommand's stdout is pasted
  // verbatim into a config.toml and must stay clean.
  console.error(
    `[tier] pack-time tier cap active: ${TIER_CAP} ` +
      `(source: ${ENV_CAP ? "AIDLC_TIER_CAP env var" : "core/memory tier_cap:"})`,
  );
} else if (IS_CHECK_MODE && process.env.AIDLC_TIER_CAP) {
  console.error(
    "[tier] AIDLC_TIER_CAP is set but IGNORED under --check " +
      "(the env cap is a one-shot write knob; persistent caps live in core/memory)",
  );
}

// The version stamped into every projected aidlc-version.ts copy and projection
// stamp. Unset means the source version. A release build sets AIDLC_BUILD_VERSION
// to a next-patch preview id derived from the current source version; the source
// tree is never edited. Honoured in --check mode too, so the two-build
// determinism guard measures the same stamped projection the release will ship.
const BUILD_VERSION = releaseBuildVersion();
if (BUILD_VERSION !== AIDLC_VERSION) {
  console.error(`[version] stamping projections with ${BUILD_VERSION_ENV}=${BUILD_VERSION}`);
}
const VERSION_ASSIGNMENT = /^export const AIDLC_VERSION = "[^"]+";$/m;

function stampVersionModule(content: string): string {
  if (BUILD_VERSION === AIDLC_VERSION) return content;
  const matches = content.match(new RegExp(VERSION_ASSIGNMENT.source, "gm")) ?? [];
  if (matches.length !== 1) {
    throw new Error(
      `aidlc-version.ts must declare exactly one AIDLC_VERSION assignment, found ${matches.length}`,
    );
  }
  return content.replace(VERSION_ASSIGNMENT, `export const AIDLC_VERSION = "${BUILD_VERSION}";`);
}

// Neutral and native onboarding skeletons (scripts/onboarding.ts).
const ONBOARDING_SKELETON = join(CORE_ROOT, "templates", "onboarding.md");
const HARNESS_ONBOARDING_SKELETON = join(CORE_ROOT, "templates", "onboarding-harness.md");
const HARNESS_TOKEN = /\{\{HARNESS_DIR\}\}/g;
const INVOKE_TOKEN = /\{\{INVOKE\}\}/g;
const TOOL_PREFIX_TOKEN = /\{\{TOOL_PREFIX\}\}/g;
const TRUSTED_NAMESPACE_TOKEN = /\{\{TRUSTED_NAMESPACE\}\}/g;
// Matched by PREFIX (mirroring aidlc-init.ts's marker scan): the begin marker
// embeds the harness-projected invocation, so its tail varies per channel.
const GENERATED_SKILL_REGIONS = [
  {
    verb: "stage-table",
    begin: "",
  },
  {
    verb: "scope-table",
    begin: "",
  },
] as const;

// Harnesses the packager builds = every harness/<name>/ that carries a
// manifest.ts. DISCOVERED, not hardcoded: adding harness #N is one harness/<n>/
// dir + manifest row (+ optional emit.ts), with zero edits here — the
// one-core-many-harnesses promise. Sorted so the default build/--check order is
// stable (claude first by name).
function discoverHarnessNames(): string[] {
  if (!existsSync(HARNESS_ROOT)) return [];
  return readdirSync(HARNESS_ROOT)
    .filter((n) => existsSync(join(HARNESS_ROOT, n, "manifest.ts")))
    .sort();
}

// ---------------------------------------------------------------------------
// Transform: token substitution on authored prose and the
// TypeScript invocation seam; compiled JSON is regenerated per tree.
// ---------------------------------------------------------------------------
function substituteInvocationTokens(
  s: string,
  harnessDir: string,
  invoke = `bun ${harnessDir}/tools/aidlc.ts`,
): string {
  const toolPrefix = invoke === "aidlc" ? `${TRUSTED_COMMAND_PREFIX} ` : `bun ${harnessDir}/tools/`;
  return s
    .replace(INVOKE_TOKEN, invoke)
    .replace(TOOL_PREFIX_TOKEN, toolPrefix)
    .replace(TRUSTED_NAMESPACE_TOKEN, TRUSTED_ROUTE_NAMESPACE);
}

function substituteToken(
  s: string,
  harnessDir: string,
  invoke = `bun ${harnessDir}/tools/aidlc.ts`,
): string {
  return substituteInvocationTokens(s.replace(HARNESS_TOKEN, harnessDir), harnessDir, invoke);
}

// Rewrite in-prose `<harnessDir>/rules/` → `<harnessDir>/<rulesRename>/` for a
// harness that renames its rules dir (kiro: steering, codex: aidlc-rules).
// Anchored on the post-substitution harness-dir form so it can't touch an
// unrelated `rules/` mention. No-op when rulesRename is null (claude).
function applyRulesRename(s: string, harnessDir: string, rulesRename: string | null): string {
  if (!rulesRename) return s;
  // Codex's native Starlark permission file is not an AIDLC markdown rule.
  const escapedHarnessDir = harnessDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  return s.replace(
    new RegExp(`${escapedHarnessDir}/rules/(?!default\\.rules)`, "g"),
    `${harnessDir}/${rulesRename}/`,
  );
}

// Read the authored `tier:` from an agent .md's YAML FRONTMATTER (scoped to
// the block between the `---` fences - a `tier:` token in body prose never
// matches). Fails loudly when the frontmatter or the key is missing: every
// shipped agent must carry a tier, and a silent pass-through would ship an
// unprojected agent (no model/effort keys at all) without failing the build.
function agentTierFromMd(s: string, srcPath: string): string {
  // Strip a UTF-8 BOM before anchoring - macOS/Windows editors occasionally
  // save .md with one, and the ^--- anchor would otherwise miss (same
  // tolerance as the rule-frontmatter parser).
  if (s.charCodeAt(0) === 0xfeff) s = s.slice(1);
  const m = s.match(/^---\r?\n([\s\S]*?)\r?\n---/);
  if (!m) throw new Error(`${srcPath}: agent .md has no YAML frontmatter block.`);
  const tierMatch = m[1].match(/^tier:\s*(\S+)\s*$/m);
  if (!tierMatch) {
    throw new Error(`${srcPath}: agent frontmatter has no tier: line (the authored contract).`);
  }
  return tierMatch[1];
}

// Rewrite an agent .md's frontmatter `tier: <t>` line into the harness-native
// keys (Claude: `model:` + optional `effort:`; Kiro: optional `model:`; Codex
// .md copies mirror Claude's shape - the TOMLs emit.ts writes are the binding
// surface there). Called AFTER the token substitution + rules-rename pass.
// A missing tier: on an agent file fails the build (agentTierFromMd). A null
// projected model/effort means the harness-native key is OMITTED: the
// harness's own session/config default applies for that harness/tier
// combination. When every key is omitted the `tier:` line is dropped without
// a replacement.
function projectTierFrontmatter(
  s: string,
  srcPath: string,
  harness: Harness,
): string {
  // Only apply to files under agents/. Guard on the POSIX-normalized path
  // (srcPath carries the platform separator on Windows) because a stage .md
  // legitimately talks about "tier:" in prose.
  const posixPath = srcPath.split(sep).join("/");
  if (!posixPath.includes("/agents/") || !posixPath.endsWith("-agent.md")) return s;
  const tier = agentTierFromMd(s, srcPath);
  const m = s.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
  if (!m) throw new Error(`${srcPath}: agent .md has no closed frontmatter block.`);
  const fm = m[1];
  const disallowedMatches = [
    ...fm.matchAll(/^disallowedTools:\s*(.*?)\s*$/gm),
  ];
  if (
    harness === "kiro" &&
    (disallowedMatches.length !== 1 ||
      !/^task$/i.test(disallowedMatches[0][1].trim()))
  ) {
    throw new Error(
      `${srcPath}: kiro projection requires exactly one disallowedTools: Task line.`,
    );
  }
  if (harness === "kiro") {
    // Kiro's native agent surface rejects unknown frontmatter keys: project the
    // authored disallowedTools line out before the model-policy rewrite.
    const newFm = fm
      .split(/\r?\n/)
      .filter((line) => !/^disallowedTools:/.test(line))
      .join("\n");
    // Function replacement: a literal `$&`/`$'` in frontmatter must not be
    // interpreted as a replacement pattern.
    s = s.replace(m[0], () => `---\n${newFm}\n---\n`);
  }
  const effective = resolveModelPolicy(
    null,
    modelAgentName(srcPath),
    tier as Tier,
    harness,
    TIER_CAP,
  );
  return writeMarkdownAgentSurface(s, effective, {
    effortKey: harness === "opencode" ? "variant" : "effort",
  });
}

// Project the `"model"` field of an authored Kiro agent .json from the tier
// table. The JSONs stay hand-written (tools, resources, sandbox settings) but
// the model dial is projection-owned: the authored files carry NO "model"
// field at all (so nobody edits a value the build would overwrite), and the
// tier comes from the same-name core/agents/<slug>.md (single source of
// truth). A pinned tier ADDS the field; a null projected model leaves it
// absent - the agent-v1 schema documents the fallback ("If not specified,
// uses the default model"), which is exactly the judgment-tier inherit
// contract. Files with no core .md counterpart (the aidlc.json orchestrator
// config) pass through untouched: the orchestrator is not a tier-carrying
// persona. Never writes any effort-like key - kiro-cli fail-closes on
// unknown agent-JSON fields.
function projectKiroAgentJson(srcPath: string, content: Buffer): Buffer {
  const name = srcPath.split(sep).join("/").split("/").pop() ?? "";
  if (!name.endsWith("-agent.json")) return content;
  const coreMd = join(CORE_ROOT, "agents", name.replace(/\.json$/, ".md"));
  if (!existsSync(coreMd)) return content;
  const tier = agentTierFromMd(readFileSync(coreMd, "utf-8"), coreMd);
  const effective = resolveModelPolicy(
    null,
    modelAgentName(name),
    tier as Tier,
    "kiro",
    TIER_CAP,
  );
  return Buffer.from(
    writeKiroAgentSurface(content.toString("utf-8"), effective),
    "utf-8",
  );
}

// The authored Kiro agent JSONs preload the shared knowledge with a glob; a
// dispatched agent gets the shared methodology files only, as every other
// harness's preflight names them (SHIPPED_FORMAT_DOCS is the one list).
function projectKiroSharedKnowledge(content: Buffer): Buffer {
  const parsed = JSON.parse(content.toString("utf-8")) as { resources?: unknown };
  const glob = "file://.kiro/knowledge/aidlc-shared/*.md";
  if (!Array.isArray(parsed.resources) || !parsed.resources.includes(glob)) return content;
  const files = readdirSync(join(CORE_ROOT, "knowledge", "aidlc-shared"))
    .filter((file) => file.endsWith(".md") && !SHIPPED_FORMAT_DOCS.has(file))
    .sort()
    .map((file) => `file://.kiro/knowledge/aidlc-shared/${file}`);
  parsed.resources = parsed.resources.flatMap((entry) => (entry === glob ? files : [entry]));
  return Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
}

// Merge the tier-derived chat.modelDefaults entries into an authored Kiro
// settings/cli.json: one entry per distinct pinned Kiro model, carrying the
// highest sharing tier's effort (the collapse rule - kiroModelDefaults()).
// Authored entries are preserved and join the same higher-effort collapse on
// collision; none ship today, because a project map replaces the person's
// personal one. CLI-only: the Kiro IDE ignores cli.json.
function projectKiroCliJson(content: Buffer): Buffer {
  return Buffer.from(
    writeKiroCliSurface(content.toString("utf-8"), [], TIER_CAP),
    "utf-8",
  );
}

function transform(
  srcPath: string,
  content: Buffer,
  harnessDir: string,
  rulesRename: string | null,
  harness?: Harness,
  invoke = `bun ${harnessDir}/tools/aidlc.ts`,
): Buffer {
  if (srcPath.endsWith(".md")) {
    // Reviewer knowledge absorption runs FIRST (on the raw core text) so the
    // token substitution below covers the absorbed prose like any core .md.
    let s = content.toString("utf-8");
    const agentName = agentNameFromPath(srcPath);
    if (agentName) {
      s = absorbReviewerKnowledge(s, agentName, CORE_ROOT);
      s = injectDelegatedKnowledgePreflight(s, agentName, harnessDir);
    }
    s = substituteToken(s, harnessDir, invoke);
    s = applyRulesRename(s, harnessDir, rulesRename);
    if (harness) s = projectTierFrontmatter(s, srcPath, harness);
    // Cursor, opencode, and Copilot persona bodies read the method by path:
    // the engine's git-ignored copy of the active space's memory
    // (aidlc-includes.ts ACTIVE_MEMORY_DIR), the same path on every install.
    const posixPath = srcPath.split(sep).join("/");
    if (
      (harness === "cursor" || harness === "opencode" || harness === "copilot") &&
      posixPath.includes("/agents/") &&
      posixPath.endsWith("-agent.md")
    ) {
      s = s.replaceAll("aidlc/spaces/<active-space>/memory/", "aidlc/active-memory/");
    }
    return Buffer.from(s, "utf-8");
  }
  if (srcPath.endsWith(".ts")) {
    const source = basename(srcPath) === "aidlc-version.ts"
      ? stampVersionModule(content.toString("utf-8"))
      : content.toString("utf-8");
    return Buffer.from(
      substituteInvocationTokens(source, harnessDir, invoke),
      "utf-8",
    );
  }
  if (/\.(?:hook|json|toml)$/.test(srcPath)) {
    return Buffer.from(
      substituteToken(content.toString("utf-8"), harnessDir, invoke),
      "utf-8",
    );
  }
  return content;
}

// Append manifest-declared frontmatter lines to a projected .md, just before
// the closing `---` of its YAML block (manifest-types.ts frontmatterAdditions).
// Hard errors, never silent: the file must open with a frontmatter block, and
// no added line's key may already exist in it - if core later grows the same
// key, the build fails loudly instead of shipping a double.
function applyFrontmatterAdditions(
  content: string,
  lines: string[],
  file: string,
): string {
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n(---\r?\n)/);
  if (!m) {
    throw new Error(
      `frontmatterAdditions: ${file} has no leading frontmatter block to extend.`,
    );
  }
  const fm = m[1];
  const added = new Set<string>();
  for (const line of lines) {
    if (/^\s/.test(line)) continue;
    const key = line.split(":")[0]?.trim();
    if (!key || !/^[A-Za-z_][\w-]*$/.test(key)) {
      throw new Error(
        `frontmatterAdditions: line "${line}" for ${file} does not start with a YAML key.`,
      );
    }
    if (added.has(key)) {
      throw new Error(
        `frontmatterAdditions: ${file} declares "${key}:" more than once in additions.`,
      );
    }
    added.add(key);
    if (new RegExp(`^${key}:`, "m").test(fm)) {
      throw new Error(
        `frontmatterAdditions: ${file} already declares "${key}:" in core - ` +
          `resolve the collision instead of shipping a duplicate key.`,
      );
    }
  }
  const insertAt = m[0].length - m[2].length;
  return `${content.slice(0, insertAt)}${lines.join("\n")}\n${content.slice(insertAt)}`;
}

function* walk(dir: string): Generator<string> {
  for (const entry of readdirSync(dir).sort()) {
    const full = join(dir, entry);
    if (statSync(full).isDirectory()) yield* walk(full);
    else yield full;
  }
}

// Byte-diff two independently built trees. The caller supplies a stable
// projection prefix so diagnostics never expose temp-directory names.
function diffTrees(
  first: string,
  second: string,
  relPrefix: string,
): string[] {
  const problems: string[] = [];
  const firstFiles = new Set<string>();
  const files = existsSync(first) ? [...walk(first)] : [];
  for (const f of files) {
    const rel = relative(first, f);
    firstFiles.add(rel);
    const counterpart = join(second, rel);
    if (!existsSync(counterpart)) {
      problems.push(`MISSING in second build: ${relPrefix}/${rel}`);
    } else if (!readFileSync(f).equals(readFileSync(counterpart))) {
      problems.push(`DIFFERS between builds: ${relPrefix}/${rel}`);
    }
  }
  if (existsSync(second)) {
    for (const f of walk(second)) {
      const rel = relative(second, f);
      if (firstFiles.has(rel)) continue;
      problems.push(`ONLY in second build: ${relPrefix}/${rel}`);
    }
  }
  return problems;
}

// The two compiled-data files regenerated into every tree. The rename backstop
// below scans both after compile.
const COMPILED_DATA = ["tools/data/stage-graph.json", "tools/data/scope-grid.json"];

// The packager-emitted harness descriptor (vision T1 open-set seam): the
// runtime reads tools/data/harness.json to learn this harness's rules-subdir
// without a hardcoded map. Derived from the manifest, written into every tree.
const HARNESS_DATA = "tools/data/harness.json";
const AGENT_TIERS_DATA = "tools/data/agent-tiers.json";
const SETTINGS_SCHEMA_DATA = "tools/data/aidlc-settings.schema.json";
const STAMP_DATA = "tools/data/aidlc-stamp.json";
const PROJECTION_DATA = "tools/data/aidlc-projection.json";

// The method ("memory"): the single hand-editable source of truth
// for the layered practices (org/team/project + phases/). It is HARNESS-NEUTRAL
// (identical bytes on every harness — neutral filenames, no {{HARNESS_DIR}}
// token), so the source dir + the dist destination are constants here, not
// per-manifest, and the packager copies the tree verbatim. The destination sits
// at the WORKSPACE ROOT (beside the harness dir), under the always-present `default`
// space, so a fresh `dist/<harness>/` copy ships a resolving method tree and the
// per-harness native include points at it (see aidlc-includes.ts) - one copy,
// no drift.
const MEMORY_SRC = "memory";
const MEMORY_DST = join("aidlc", "spaces", "default", "memory");

// Engine-only-install self-heal: the SAME method content (core/memory/) ALSO emitted
// INSIDE the engine dir at <harnessDir>/tools/data/memory-seed/, mirroring how
// tools/data/templates ships (an engine-bundled, copy-out-at-runtime data dir
// resolved relative to the running tool — see frameworkTemplatesDir/DATA_DIR in
// aidlc-graph.ts). This lets an ENGINE-ONLY install (a user who copies only
// dist/<h>/.<engine>/ and NOT the sibling aidlc/ shell) self-heal: the first
// /aidlc seeds aidlc/spaces/default/memory/ from this bundled copy if (and only
// if) it is absent (see ensureWorkspaceDirs). The sibling MEMORY_DST shell STILL
// ships for normal installs — this is an additive fallback, not a replacement.
const MEMORY_SEED_DST = join("tools", "data", "memory-seed");

// The plugin authoring validator must run from a copied distribution with no
// framework checkout. Ship the compose hook's source of truth beside the tool
// so a vendored hooks/compose.ts can be byte-compared offline.
const PLUGIN_HOOKS_TEMPLATE_SRC = join(
  REPO_ROOT,
  "scripts",
  "plugin-hooks-template",
);
const PLUGIN_HOOKS_TEMPLATE_DST = join(
  "tools",
  "data",
  "plugin-hooks-template",
);
const PLUGIN_TARGETS_DST = join("tools", "data", "plugin-targets.json");
const PLUGIN_AUTHORING_CONTEXT_DST = join(
  "tools",
  "data",
  "plugin-authoring-context.json",
);

// The active-space CURSOR shipped as part of the workspace shell (SEED). It
// lives at aidlc/active-space (ABOVE spaces/, not inside memory/) and holds the
// name of the space the next /aidlc resolves against. Ships pointed at the
// always-present "default" space so a fresh copy resolves with zero ceremony.
// NOTE: it is GITIGNORED in the user's workspace (a per-user session cursor,
// vision 5.1 - teammates legitimately point at different spaces at once), yet
// dist must SHIP it as part of the shell. The two reconcile: the projected
// .gitignore ignores aidlc/active-space for the END USER; a clone's first
// SessionStart or active-intent write atomically recreates the missing pointer
// and keeps it untracked. The local dist root itself is ignored.
const ACTIVE_SPACE_REL = join("aidlc", "active-space");
const ACTIVE_SPACE_VALUE = "default\n";

// Write tools/data/harness.json from manifest data. The runtime reads the
// rules-subdir and any host-native generated-runner frontmatter from this
// open-set descriptor. Pretty-printed + trailing newline keeps output
// diff-friendly and stable under --check.
function writeHarnessData(treeRoot: string, m: HarnessManifest): void {
  // A FRESH object, deliberately: the runtime READER tolerates unknown keys, but
  // this writer alone decides what ships. Anything hand-added to a local
  // harness.json is erased on the next build, so a new configuration field has
  // to be added HERE to exist at all.
  const data: Record<string, unknown> = {
    schemaVersion: 1,
    distribution: m.name,
    productName: m.productName,
    configNextStep: m.configNextStep,
    name: m.name,
    harnessDir: m.harnessDir,
    rulesSubdir: m.rulesRename ?? "rules",
    ...(m.runnerFrontmatterAdditions?.length
      ? { runnerFrontmatterAdditions: m.runnerFrontmatterAdditions }
      : {}),
  };
  // Emitted only when a manifest sets it, so the output stays
  // byte-identical for every harness that does not -- which is all of them today.
  if (m.documentExtractors) data.documentExtractors = m.documentExtractors;
  // Likewise conditional: only a host whose hooks wait on the person (trust, engine) declares it.
  if (m.hookActivation) data.hookActivation = m.hookActivation;
  // Only the Kiro rows declare a layout; the runtime reads it in place of the row name.
  if (m.kiroLayout) data.kiroLayout = m.kiroLayout;
  // And only a host that cuts a shell result below the engine's cap.
  if (m.directiveMaxBytes) data.directiveMaxBytes = m.directiveMaxBytes;
  // And only a host where AI-DLC can show its progress in the status line.
  if (m.statusLine) data.statusLine = m.statusLine;
  // And only a host whose stop event records an agent's finish before its result.
  if (m.agentFinishRecorded) data.agentFinishRecorded = m.agentFinishRecorded;
  const dst = join(treeRoot, HARNESS_DATA);
  mkdirSync(dirname(dst), { recursive: true });
  writeFileSync(dst, `${JSON.stringify(data, null, 2)}\n`);
}

function writeAgentTiersData(treeRoot: string): void {
  const dst = join(treeRoot, AGENT_TIERS_DATA);
  mkdirSync(dirname(dst), { recursive: true });
  writeFileSync(
    dst,
    serializeAgentTiers(agentTiersFromAuthoredDirectory(join(CORE_ROOT, "agents"))),
  );
}

function writeSettingsSchemaData(treeRoot: string): void {
  const dst = join(treeRoot, SETTINGS_SCHEMA_DATA);
  mkdirSync(dirname(dst), { recursive: true });
  writeFileSync(dst, `${JSON.stringify(AIDLC_SETTINGS_SCHEMA, null, 2)}\n`);
}

function writeProjectionData(outRoot: string, treeRoot: string, m: HarnessManifest): void {
  if (!harnessChoiceTextIsPlain(m)) {
    throw new Error(`[${m.name}] choiceName or choiceNote is not text the harness lists can show`);
  }
  const rootIntegrationPaths = new Set(m.rootIntegrations.map((item) => item.path));
  if (rootIntegrationPaths.size !== m.rootIntegrations.length) {
    throw new Error(`[${m.name}] duplicate root integration path`);
  }
  for (const integration of m.rootIntegrations) {
    if (!existsSync(join(outRoot, integration.path))) {
      throw new Error(`[${m.name}] root integration is not projected: ${integration.path}`);
    }
  }
  // A directory that holds only root integrations (.vscode/ for settings.json)
  // is the project's, not a managed engine directory.
  const onlyRootIntegrations = (entry: string): boolean => {
    const files: string[] = [];
    const visit = (dir: string): void => {
      for (const name of readdirSync(join(outRoot, dir))) {
        const rel = `${dir}/${name}`;
        if (statSync(join(outRoot, rel)).isDirectory()) visit(rel);
        else files.push(rel);
      }
    };
    visit(entry);
    return files.length > 0 && files.every((file) => rootIntegrationPaths.has(file));
  };
  const managedDirectories = readdirSync(outRoot)
    .filter((entry) => statSync(join(outRoot, entry)).isDirectory())
    .filter((entry) => !rootIntegrationPaths.has(entry) && !onlyRootIntegrations(entry))
    .sort();
  const allowedTopLevel = new Set([
    ...managedDirectories,
    ...m.rootIntegrations.map((item) => item.path.split("/")[0]),
  ]);
  const unclassified = readdirSync(outRoot)
    .filter((entry) => !allowedTopLevel.has(entry))
    .sort();
  if (unclassified.length > 0) {
    throw new Error(`[${m.name}] unclassified projection entries: ${unclassified.join(", ")}`);
  }
  const rootIntegrations = m.rootIntegrations.map((integration) => {
    if (copyStartsWithout(integration) || integration.policy === "json-entries") {
      // A copy starts without this file (or leaves the team's own in place);
      // its shipped part rides along so config and the engine can add it
      // without a download.
      const dst = join(treeRoot, "tools", "data", "root-blocks", basename(integration.path));
      mkdirSync(dirname(dst), { recursive: true });
      writeFileSync(dst, readFileSync(join(outRoot, integration.path)));
      return integration;
    }
    if (integration.policy !== "managed-block") return integration;
    const bytes = readFileSync(join(outRoot, integration.path));
    // Every managed block ships a copy inside the harness folder: siblings
    // read it to combine .gitignore lines, the copy runtime leaves the root
    // file out, and config and the engine add AI-DLC's part from it.
    const dst = join(
      treeRoot,
      "tools",
      "data",
      "root-blocks",
      integration.marker || basename(integration.path),
    );
    mkdirSync(dirname(dst), { recursive: true });
    writeFileSync(dst, bytes);
    const currentHash = sha256Bytes(bytes);
    return {
      ...integration,
      legacySignatures: {
        ...integration.legacySignatures,
        wholeFileHashes: [
          ...new Set([
            ...(integration.legacySignatures?.wholeFileHashes ?? []),
            currentHash,
          ]),
        ],
      },
    };
  });
  const projection = {
    schemaVersion: 1,
    distribution: m.name,
    productName: m.productName,
    ...(m.choiceName ? { choiceName: m.choiceName } : {}),
    ...(m.choiceNote ? { choiceNote: m.choiceNote } : {}),
    configNextStep: m.configNextStep,
    ...(m.firstRunSteps ? { firstRunSteps: m.firstRunSteps } : {}),
    ...(m.editorTerminalApp ? { editorTerminalApp: m.editorTerminalApp } : {}),
    harnessDir: m.harnessDir,
    ...(m.onboarding?.harnessDst
      ? { onboarding: `${m.harnessDir}/${m.onboarding.harnessDst}` }
      : m.onboarding && !m.onboarding.projectRoot
        ? { onboarding: `${m.harnessDir}/${m.onboarding.dst}` }
        : {}),
    managedDirectories,
    rootIntegrations: rootIntegrations.map(writtenRootIntegration),
  };
  const stamp = {
    schemaVersion: 1,
    frameworkVersion: BUILD_VERSION,
    distribution: m.name,
    harnessDir: m.harnessDir,
  };
  mkdirSync(join(treeRoot, "tools", "data"), { recursive: true });
  writeFileSync(join(treeRoot, PROJECTION_DATA), `${JSON.stringify(projection, null, 2)}\n`);
  writeFileSync(join(treeRoot, STAMP_DATA), `${JSON.stringify(stamp, null, 2)}\n`);
}

// Emit the method ("memory") tree at the WORKSPACE ROOT of the dist tree
// (dist/<name>/aidlc/spaces/default/memory/), copying core/memory/ verbatim with
// the standard .md token transform (a no-op on the neutral method files, which
// carry no {{HARNESS_DIR}} token). Same source + destination for every harness
// — the method is harness-neutral; the per-harness native include is what
// differs.
function emitMemory(
  outRoot: string,
  harnessDir: string,
  rulesRename: string | null,
  harness: Harness,
  invoke: string,
): void {
  const srcDir = join(CORE_ROOT, MEMORY_SRC);
  if (!existsSync(srcDir)) return;
  for (const file of walk(srcDir)) {
    const rel = relative(srcDir, file);
    const outPath = join(outRoot, MEMORY_DST, rel);
    mkdirSync(dirname(outPath), { recursive: true });
    writeFileSync(outPath, transform(file, readFileSync(file), harnessDir, rulesRename, harness, invoke));
  }
}

// Engine-only-install self-heal: emit the SAME core/memory/ tree a SECOND time,
// INSIDE the engine dir at <treeRoot>/tools/data/memory-seed/, so an engine-only
// install carries the method content with it (the first /aidlc copies it out via
// ensureWorkspaceDirs/frameworkMemorySeedDir). Mirrors emitMemory's transform
// (a no-op on the neutral method files) but writes into treeRoot (the harness
// engine dir), so the generated-root inventory covers it. Same source as
// emitMemory, different destination.
function emitMemorySeed(
  treeRoot: string,
  harnessDir: string,
  rulesRename: string | null,
  harness: Harness,
  invoke: string,
): void {
  const srcDir = join(CORE_ROOT, MEMORY_SRC);
  if (!existsSync(srcDir)) return;
  for (const file of walk(srcDir)) {
    const rel = relative(srcDir, file);
    const outPath = join(treeRoot, MEMORY_SEED_DST, rel);
    mkdirSync(dirname(outPath), { recursive: true });
    writeFileSync(outPath, transform(file, readFileSync(file), harnessDir, rulesRename, harness, invoke));
  }
}

function emitPluginAuthoringData(treeRoot: string): void {
  for (const file of walk(PLUGIN_HOOKS_TEMPLATE_SRC)) {
    const outPath = join(
      treeRoot,
      PLUGIN_HOOKS_TEMPLATE_DST,
      relative(PLUGIN_HOOKS_TEMPLATE_SRC, file),
    );
    mkdirSync(dirname(outPath), { recursive: true });
    cpSync(file, outPath);
  }
  const targetsPath = join(treeRoot, PLUGIN_TARGETS_DST);
  mkdirSync(dirname(targetsPath), { recursive: true });
  writeFileSync(
    targetsPath,
    `${JSON.stringify(pluginTargets(), null, 2)}\n`,
  );
  const contextPath = join(treeRoot, PLUGIN_AUTHORING_CONTEXT_DST);
  const agents = [...walk(join(CORE_ROOT, "agents"))]
    .filter((file) => file.endsWith("-agent.md"))
    .map((file) => basename(file, ".md"))
    .sort();
  const stages = [...walk(join(CORE_ROOT, "aidlc-common", "stages"))]
    .filter((file) => file.endsWith(".md"))
    .map((file) => basename(file, ".md"))
    .sort();
  writeFileSync(
    contextPath,
    `${JSON.stringify({ agents, stages }, null, 2)}\n`,
  );
}

// Emit the active-space CURSOR (aidlc/active-space -> "default") into the dist
// tree, as part of the workspace shell (SEED). Lives at the dist root beside
// the harness dir (dist/<name>/aidlc/active-space), OUTSIDE <harnessDir>, like
// the memory tree and projectRoot harness files. Harness-neutral: same pointer
// value for every harness (the resolver follows it identically). The projected
// .gitignore ignores this path for the END USER's workspace.
function emitActiveSpace(outRoot: string): void {
  const outPath = join(outRoot, ACTIVE_SPACE_REL);
  mkdirSync(dirname(outPath), { recursive: true });
  writeFileSync(outPath, ACTIVE_SPACE_VALUE);
}

// ---------------------------------------------------------------------------
// Build one harness tree into `outRoot` (the dist/<name> dir). Returns the full
// generated-file inventory rooted there, including project-root files beside
// <harnessDir> (onboarding/config, workspace memory, emitted skills, and so on).
// ---------------------------------------------------------------------------
function buildTree(
  m: HarnessManifest,
  outRoot: string,
  invoke = `bun ${m.harnessDir}/tools/aidlc.ts`,
): string[] {
  const harnessDir = m.harnessDir;
  const treeRoot = join(outRoot, harnessDir);
  // Every harness projects onto ONE of the six flavors the tier module
  // knows (Kiro CLI and Kiro IDE share the "kiro" flavor - identical model
  // dial). Declared per manifest, never inferred from the harness name.
  const harnessKind = m.tierFlavor;
  // 1. Copy core dirs with token substitution + rules rename. Manifest-declared
  //    frontmatter additions (harness-native fields, e.g. the Kiro IDE's
  //    subagent `tools:` grant) are appended during this projection; every
  //    declared file must be hit exactly once (typo/rename guard).
  const fmAdditions = new Map(
    (m.frontmatterAdditions ?? []).map(({ file, lines }) => [file, lines]),
  );
  const fmApplied = new Set<string>();
  for (const { src, dst } of m.coreDirs) {
    const srcDir = join(CORE_ROOT, src);
    if (!existsSync(srcDir)) continue;
    const finalDst = m.rulesRename && dst === "rules" ? m.rulesRename : dst;
    for (const file of walk(srcDir)) {
      const rel = relative(srcDir, file);
      const outPath = join(treeRoot, finalDst, rel);
      mkdirSync(dirname(outPath), { recursive: true });
      let out = transform(
        file,
        readFileSync(file),
        harnessDir,
        m.rulesRename,
        harnessKind,
        invoke,
      );
      // Manifest keys are POSIX; normalize the platform separator so the
      // lookup works on Windows too.
      const harnessRel = join(finalDst, rel).split(sep).join("/");
      // A protocol module's per-tool binding subsections: this tree keeps its own.
      if (/^aidlc-common\/protocols\/[^/]+\.md$/.test(harnessRel)) {
        out = Buffer.from(keepOwnHarnessBindings(out.toString("utf-8"), m.name, harnessRel), "utf-8");
      }
      const fmLines = fmAdditions.get(harnessRel);
      if (fmLines) {
        // The additions are for the harness's dispatch (Kiro IDE: about 32 KB
        // of tool permissions); the lead reading the persona in its own chat
        // gets the persona without them, from the copy the roster names. A
        // tools line alone (Kiro CLI) is no weight to keep out: no copy.
        if (
          harnessRel.startsWith("agents/") &&
          fmLines.some((line) => line.split(":", 1)[0] === "permissions") &&
          inlineAgentSet(CORE_ROOT).has(basename(outPath, ".md"))
        ) {
          const copy = join(treeRoot, "aidlc-common", "personas", basename(outPath));
          mkdirSync(dirname(copy), { recursive: true });
          writeFileSync(copy, out);
        }
        out = Buffer.from(
          applyFrontmatterAdditions(out.toString("utf-8"), fmLines, harnessRel),
          "utf-8",
        );
        fmApplied.add(harnessRel);
      }
      writeFileSync(outPath, out);
    }
  }
  const fmMissed = [...fmAdditions.keys()].filter((f) => !fmApplied.has(f));
  if (fmMissed.length > 0) {
    throw new Error(
      `[${m.name}] frontmatterAdditions name file(s) the core projection never produced: ` +
        `${fmMissed.join(", ")} - fix the path(s) in the manifest.`,
    );
  }
  // 2. Copy authored harness surfaces (token substitution on .md). projectRoot
  //    files land beside the harness dir (e.g. dist/kiro/AGENTS.md), the rest
  //    inside <harnessDir>/. On the kiro harnesses two authored JSON surfaces
  //    are additionally tier-projected: the agent .json "model" fields and the
  //    settings/cli.json chat.modelDefaults...

Discussion

Questions & comments · 0

Sign In Sign in to leave a comment.