6585 lines
216 KiB
TypeScript
6585 lines
216 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { extractTarGz } from "./aidlc-archive.ts";
|
|
import {
|
|
EXIT,
|
|
emitResult,
|
|
failure,
|
|
globalOptions,
|
|
readTerminalLine,
|
|
success,
|
|
usage,
|
|
valueAfter,
|
|
valuesAfter,
|
|
} from "./aidlc-command.ts";
|
|
import {
|
|
cmd,
|
|
dim,
|
|
errorLabel,
|
|
heading,
|
|
success as successText,
|
|
tipLabel,
|
|
warnVerdict,
|
|
} from "./aidlc-color.ts";
|
|
import {
|
|
assertProjectionPathHasNoSymlinks,
|
|
type ProjectionDescriptor,
|
|
projectionFiles,
|
|
sha256Bytes,
|
|
sha256File,
|
|
validateProjectionDescriptor,
|
|
walkFiles,
|
|
} from "./aidlc-distribution.ts";
|
|
import {
|
|
activeVersion,
|
|
binRoot,
|
|
machineTransactionRoot,
|
|
projectDirFrom,
|
|
runtimeRoot,
|
|
} from "./aidlc-install-paths.ts";
|
|
import { defaultHarnessPath } from "./aidlc-machine-config.ts";
|
|
import { configureChannel, configureProjectPin } from "./aidlc-lifecycle.ts";
|
|
import { RELEASE_CHANNELS } from "./aidlc-channel.ts";
|
|
import {
|
|
type TransactionOperation,
|
|
type TransactionPlan,
|
|
executePlan,
|
|
transactionSourceHash,
|
|
transactionState,
|
|
validateTransactionPlan,
|
|
writeOperation,
|
|
} from "./aidlc-transaction.ts";
|
|
import { compileStageGraph, __resetGraphCache } from "./aidlc-graph.ts";
|
|
import {
|
|
_resetHarnessDataForTests,
|
|
_resetScopeMappingForTests,
|
|
_resetStageGraphForTests,
|
|
getField,
|
|
listIntents,
|
|
listSpaces,
|
|
normalizeProjectFlagsRecord,
|
|
RECORDABLE_PROJECT_BYPASSES,
|
|
stateFilePath,
|
|
type ProjectFlagsRecord,
|
|
withAuditLock,
|
|
} from "./aidlc-lib.ts";
|
|
import { regenerateRunnerSurfaces } from "./aidlc-runner-gen.ts";
|
|
import {
|
|
canonicalScopeTableRegion,
|
|
canonicalStageTableRegion,
|
|
renderScopeTable,
|
|
renderStageTable,
|
|
} from "./aidlc-utility.ts";
|
|
import {
|
|
aidlcInvocation,
|
|
discoverProjectHarnesses,
|
|
isCompiledExecutable,
|
|
} from "./aidlc-runtime-paths.ts";
|
|
import {
|
|
activeModelGroups,
|
|
applyModelPolicyToProjection,
|
|
harnessHonestyNotes,
|
|
isModelEffort,
|
|
isModelPreset,
|
|
MODEL_EFFORTS,
|
|
MODEL_GROUPS,
|
|
MODEL_PRESETS,
|
|
modelPolicyIsEmpty,
|
|
modelPolicySurfaceDrift,
|
|
normalizeModelPolicy,
|
|
profileGroups,
|
|
readAgentTiers,
|
|
resolveModelPolicy,
|
|
type AgentTiers,
|
|
type ModelEffort,
|
|
type ModelGroup,
|
|
type ModelHarness,
|
|
type ModelPolicyRecord,
|
|
type ModelProfile,
|
|
} from "./aidlc-model-policy.ts";
|
|
import { resolveTierCap } from "./aidlc-tiers.ts";
|
|
import {
|
|
applyConfigDiagnosticRecords,
|
|
applyProjectFlagsToProjection,
|
|
availableScopeNames,
|
|
completionInstruction,
|
|
detectAwsCredentials,
|
|
discoverInstalledPluginNames,
|
|
effectiveProjectFlagValues,
|
|
flagFiles,
|
|
flagIssues,
|
|
managedBlockMarkers,
|
|
normalizeProvidersRecord,
|
|
normalizeProjectChoicesRecord,
|
|
normalizeRuntimeRecord,
|
|
normalizeTrustRecord,
|
|
pendingProviderIssues,
|
|
postApplyOutstandingActions,
|
|
probeHarnessCli,
|
|
probeRuntime,
|
|
providerFiles,
|
|
providerIssues,
|
|
projectChoiceFiles,
|
|
projectChoiceIssues,
|
|
projectMcpNote,
|
|
readPluginSelection,
|
|
readConfigDiagnosticRecords,
|
|
reconcileProviderActions,
|
|
runtimeIssues,
|
|
trustStatus,
|
|
type ConfigDiagnosticOverrides,
|
|
type ConfigDiagnosticRecords,
|
|
type CompletionShell,
|
|
type ConfigOutstandingAction,
|
|
type ProjectChoicesRecord,
|
|
type ProvidersRecord,
|
|
type RuntimeRecord,
|
|
type TrustRecord,
|
|
} from "./aidlc-config-diagnostics.ts";
|
|
import {
|
|
LOCAL_SETTINGS_FILE,
|
|
invalidateSettingsCache,
|
|
modelPolicyForHarness,
|
|
readSettingsTarget,
|
|
resolveAidlcSettings,
|
|
resolveAidlcSettingsWithOverride,
|
|
serializeAidlcSettings,
|
|
settingsModelsFromHarnessPolicy,
|
|
settingsPathForTarget,
|
|
settingsSource,
|
|
updateSettingsSection,
|
|
type AidlcSettingsFile,
|
|
type ResolvedAidlcSettings,
|
|
type SettingsTarget,
|
|
} from "./aidlc-settings.ts";
|
|
|
|
type RootContribution =
|
|
| { policy: "managed-block"; hash: string; marker?: string }
|
|
| { policy: "json-map"; entries: Record<string, string>; key?: string }
|
|
| { policy: "json-array"; entries: Record<string, string>; key: string }
|
|
| { policy: "whole-file"; hash: string };
|
|
|
|
type Baseline = {
|
|
schemaVersion: 1;
|
|
frameworkVersion: string;
|
|
distribution: string;
|
|
harnessDir: string;
|
|
mcpMode: "defaults" | "none";
|
|
files: Record<string, string>;
|
|
rootContributions: Record<string, RootContribution>;
|
|
};
|
|
|
|
type PlannedAction = {
|
|
path: string;
|
|
action: "create" | "update" | "merge" | "preserve" | "remove" | "conflict";
|
|
detail?: string;
|
|
};
|
|
|
|
type ModelsMutationContext = {
|
|
harness: ModelHarness;
|
|
harnessDir: string;
|
|
previous: ModelPolicyRecord | null;
|
|
next: ModelPolicyRecord | null;
|
|
tiers: AgentTiers;
|
|
summaryLines: string[];
|
|
notes: string[];
|
|
settings: SettingsMutation;
|
|
};
|
|
|
|
type DiagnosticSection = "runtime" | "providers" | "trust";
|
|
type ChoiceSection = "flags" | "project";
|
|
type SetupWalkSection = "runtime" | "providers" | "trust";
|
|
|
|
type ConfigMainInternal = {
|
|
setupWalkChild?: boolean;
|
|
sourceRoot?: string;
|
|
};
|
|
|
|
type DiagnosticsMutationContext = {
|
|
section: DiagnosticSection;
|
|
harness: ModelHarness;
|
|
harnessDir: string;
|
|
previous: RuntimeRecord | ProvidersRecord | TrustRecord | null;
|
|
next: RuntimeRecord | ProvidersRecord | TrustRecord | null;
|
|
overrides: ConfigDiagnosticOverrides;
|
|
summaryLines: string[];
|
|
notes: string[];
|
|
};
|
|
|
|
type ChoicesMutationContext = {
|
|
section: ChoiceSection;
|
|
harness: ModelHarness;
|
|
harnessDir: string;
|
|
previous: ProjectFlagsRecord | ProjectChoicesRecord | null;
|
|
next: ProjectFlagsRecord | ProjectChoicesRecord | null;
|
|
previousPlugins: string[] | null;
|
|
nextPlugins: string[] | null;
|
|
overrides?: ConfigDiagnosticOverrides;
|
|
mcpMode?: "defaults" | "none";
|
|
summaryLines: string[];
|
|
notes: string[];
|
|
settings?: SettingsMutation;
|
|
};
|
|
|
|
type SettingsMutation = {
|
|
target: SettingsTarget;
|
|
path: string;
|
|
previous: AidlcSettingsFile | null;
|
|
next: AidlcSettingsFile | null;
|
|
};
|
|
|
|
const CONFIG_VALUE_FLAGS = new Set([
|
|
"--agent",
|
|
"--ca-bundle",
|
|
"--channel",
|
|
"--deciding-effort",
|
|
"--default-scope",
|
|
"--effort",
|
|
"--from",
|
|
"--harness",
|
|
"--mcp",
|
|
"--model",
|
|
"--output",
|
|
"--opencode-default",
|
|
"--pin",
|
|
"--plan-token",
|
|
"--profile",
|
|
"--provider",
|
|
"--preset",
|
|
"--project-dir",
|
|
"--region",
|
|
"--release-base-url",
|
|
"--mark-done",
|
|
"--plugins",
|
|
"--completions",
|
|
"--reviewing-effort",
|
|
"--save-as",
|
|
"--sensor-timeout-ms",
|
|
"--swarm",
|
|
"--hook-debug",
|
|
"--bypass",
|
|
"--clear-bypass",
|
|
"--writing-up-effort",
|
|
]);
|
|
|
|
const CHOICE_VALUE_FLAGS = new Set([
|
|
"--bypass",
|
|
"--clear-bypass",
|
|
"--completions",
|
|
"--default-scope",
|
|
"--harness",
|
|
"--hook-debug",
|
|
"--mcp",
|
|
"--plan-token",
|
|
"--plugins",
|
|
"--project-dir",
|
|
"--sensor-timeout-ms",
|
|
"--swarm",
|
|
]);
|
|
|
|
const CHOICE_BARE_FLAGS = new Set([
|
|
"--check",
|
|
"--dry-run",
|
|
"--help",
|
|
"--global",
|
|
"--json",
|
|
"--local",
|
|
"--no-color",
|
|
"--project",
|
|
"--quiet",
|
|
"--reset",
|
|
"--show",
|
|
"--verbose",
|
|
"--yes",
|
|
]);
|
|
|
|
const DIAGNOSTIC_VALUE_FLAGS = new Set([
|
|
"--harness",
|
|
"--mark-done",
|
|
"--opencode-default",
|
|
"--plan-token",
|
|
"--profile",
|
|
"--project-dir",
|
|
"--provider",
|
|
"--region",
|
|
]);
|
|
|
|
const DIAGNOSTIC_BARE_FLAGS = new Set([
|
|
"--check",
|
|
"--dry-run",
|
|
"--help",
|
|
"--json",
|
|
"--no-color",
|
|
"--quiet",
|
|
"--reset",
|
|
"--show",
|
|
"--verbose",
|
|
"--yes",
|
|
]);
|
|
|
|
const MODELS_VALUE_FLAGS = new Set([
|
|
"--agent",
|
|
"--deciding-effort",
|
|
"--effort",
|
|
"--from",
|
|
"--harness",
|
|
"--model",
|
|
"--plan-token",
|
|
"--preset",
|
|
"--project-dir",
|
|
"--reviewing-effort",
|
|
"--save-as",
|
|
"--writing-up-effort",
|
|
]);
|
|
|
|
const MODELS_BARE_FLAGS = new Set([
|
|
"--check",
|
|
"--dry-run",
|
|
"--help",
|
|
"--global",
|
|
"--json",
|
|
"--local",
|
|
"--no-color",
|
|
"--project",
|
|
"--quiet",
|
|
"--reset",
|
|
"--show",
|
|
"--verbose",
|
|
"--yes",
|
|
]);
|
|
|
|
const VALID_CONFIG_SECTIONS = new Set([
|
|
"models",
|
|
"runtime",
|
|
"providers",
|
|
"trust",
|
|
"flags",
|
|
"project",
|
|
]);
|
|
|
|
const ROOT_CONFIG_FLAGS = new Set([
|
|
"--ca-bundle",
|
|
"--channel",
|
|
"--dry-run",
|
|
"--force",
|
|
"--from",
|
|
"--harness",
|
|
"--help",
|
|
"--json",
|
|
"--mcp",
|
|
"--no-color",
|
|
"--offline",
|
|
"--pin",
|
|
"--plan-token",
|
|
"--project-dir",
|
|
"--quiet",
|
|
"--release-base-url",
|
|
"--unpin",
|
|
"--verbose",
|
|
"--yes",
|
|
]);
|
|
|
|
type ConfigOptionGrammar = {
|
|
values: ReadonlySet<string>;
|
|
bare: ReadonlySet<string>;
|
|
repeatable?: ReadonlySet<string>;
|
|
invalidKnownFlags?: ReadonlySet<string>;
|
|
invalidKnownMessage?: (flag: string) => string;
|
|
};
|
|
|
|
function duplicateConfigOptionMessage(flag: string): string {
|
|
if (flag === "--harness") {
|
|
return "multi-harness config is not supported yet; pass one --harness <name>";
|
|
}
|
|
if (flag === "--agent") {
|
|
return "one models mutation may target only one --agent";
|
|
}
|
|
return `${flag} may be specified only once`;
|
|
}
|
|
|
|
function validateConfigOptionGrammar(
|
|
argv: readonly string[],
|
|
label: string,
|
|
grammar: ConfigOptionGrammar,
|
|
): string | null {
|
|
const seen = new Set<string>();
|
|
for (let index = 0; index < argv.length; index++) {
|
|
const token = argv[index];
|
|
if (!token.startsWith("--")) {
|
|
return `unexpected ${label} positional ${JSON.stringify(token)}`;
|
|
}
|
|
if (grammar.values.has(token)) {
|
|
if (seen.has(token) && !grammar.repeatable?.has(token)) {
|
|
return duplicateConfigOptionMessage(token);
|
|
}
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) return `${token} requires a value`;
|
|
seen.add(token);
|
|
index++;
|
|
continue;
|
|
}
|
|
if (!grammar.bare.has(token)) {
|
|
if (
|
|
(CONFIG_VALUE_FLAGS.has(token) || grammar.invalidKnownFlags?.has(token)) &&
|
|
grammar.invalidKnownMessage
|
|
) {
|
|
return grammar.invalidKnownMessage(token);
|
|
}
|
|
return `unknown ${label} option ${token}`;
|
|
}
|
|
if (seen.has(token)) return duplicateConfigOptionMessage(token);
|
|
seen.add(token);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function validateConfigOutputMode(argv: readonly string[]): string | null {
|
|
return argv.includes("--json") && argv.includes("--quiet")
|
|
? "--json and --quiet are mutually exclusive"
|
|
: null;
|
|
}
|
|
|
|
function validateSettingsTargets(argv: readonly string[]): string | null {
|
|
const selected = ["--local", "--project", "--global"].filter((flag) =>
|
|
argv.includes(flag)
|
|
);
|
|
return selected.length > 1
|
|
? "pass exactly one settings target: --local, --project, or --global"
|
|
: null;
|
|
}
|
|
|
|
function validateConfigMutationModes(
|
|
argv: readonly string[],
|
|
section: string,
|
|
mutationFlags: readonly string[],
|
|
): string | null {
|
|
const hasMutation = mutationFlags.some((flag) => argv.includes(flag));
|
|
if (
|
|
(argv.includes("--show") || argv.includes("--check")) &&
|
|
(hasMutation || argv.includes("--dry-run") || argv.includes("--yes"))
|
|
) {
|
|
return section === "models"
|
|
? "--show and --check cannot be combined with models mutations"
|
|
: `--show and --check cannot be combined with ${section} mutations`;
|
|
}
|
|
if (argv.includes("--show") && argv.includes("--check")) {
|
|
return "--show and --check are mutually exclusive";
|
|
}
|
|
if (argv.includes("--reset")) {
|
|
const conflict = mutationFlags.find((flag) =>
|
|
flag !== "--reset" && argv.includes(flag)
|
|
);
|
|
if (conflict) return `--reset cannot be combined with ${conflict}`;
|
|
}
|
|
return validateConfigOutputMode(argv);
|
|
}
|
|
|
|
function configPositionals(argv: readonly string[]): Array<{ value: string; index: number }> {
|
|
const positionals: Array<{ value: string; index: number }> = [];
|
|
for (let index = 0; index < argv.length; index++) {
|
|
const token = argv[index];
|
|
if (CONFIG_VALUE_FLAGS.has(token)) {
|
|
index++;
|
|
continue;
|
|
}
|
|
if (!token.startsWith("--")) positionals.push({ value: token, index });
|
|
}
|
|
return positionals;
|
|
}
|
|
|
|
function modelHarness(value: string): ModelHarness {
|
|
if (
|
|
value === "claude" ||
|
|
value === "codex" ||
|
|
value === "copilot" ||
|
|
value === "cursor" ||
|
|
value === "kiro" ||
|
|
value === "kiro-ide" ||
|
|
value === "opencode"
|
|
) {
|
|
return value;
|
|
}
|
|
throw new Error(`models policy is not supported for harness ${JSON.stringify(value)}`);
|
|
}
|
|
|
|
function cloneModelPolicy(policy: ModelPolicyRecord | null): ModelPolicyRecord {
|
|
return policy
|
|
? JSON.parse(JSON.stringify(policy)) as ModelPolicyRecord
|
|
: { schemaVersion: 1 };
|
|
}
|
|
|
|
const SETTINGS_TARGET_FLAGS = [
|
|
["--local", "local"],
|
|
["--project", "project"],
|
|
["--global", "global"],
|
|
] as const;
|
|
|
|
function settingsProjectAvailable(projectDir: string): boolean {
|
|
return discoverProjectHarnesses(projectDir).length > 0 ||
|
|
[".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml"]
|
|
.some((entry) => existsSync(join(projectDir, entry)));
|
|
}
|
|
|
|
function settingsTargetForMutation(
|
|
argv: readonly string[],
|
|
projectDir: string,
|
|
): SettingsTarget {
|
|
const selected = SETTINGS_TARGET_FLAGS.filter(([flag]) => argv.includes(flag));
|
|
if (selected.length > 1) {
|
|
throw new Error("pass exactly one settings target: --local, --project, or --global");
|
|
}
|
|
const inProject = settingsProjectAvailable(projectDir);
|
|
if (selected.length === 1) {
|
|
const target = selected[0][1];
|
|
if (!inProject && target !== "global") {
|
|
throw new Error(
|
|
`outside an installed project only --global is valid; machine settings live at ${
|
|
settingsPathForTarget(projectDir, "global")
|
|
}`,
|
|
);
|
|
}
|
|
return target;
|
|
}
|
|
if (!inProject) return "global";
|
|
if (!configInputIsTty()) {
|
|
throw new Error(
|
|
"settings mutation requires exactly one of --local, --project, or --global; use --project for team-shared repository policy",
|
|
);
|
|
}
|
|
const answer = configPrompt(
|
|
"Record settings in [project recommended/local/global]:",
|
|
)?.trim().toLowerCase();
|
|
if (!answer || answer === "project") return "project";
|
|
if (answer === "local") return "local";
|
|
if (answer === "global" || answer === "machine") return "global";
|
|
throw new Error("settings layer selection cancelled");
|
|
}
|
|
|
|
function validateModelsArgs(argv: readonly string[]): string | null {
|
|
const grammar = validateConfigOptionGrammar(argv, "models", {
|
|
values: MODELS_VALUE_FLAGS,
|
|
bare: MODELS_BARE_FLAGS,
|
|
});
|
|
if (grammar) return grammar;
|
|
const mutationFlags = [
|
|
"--agent",
|
|
"--deciding-effort",
|
|
"--effort",
|
|
"--from",
|
|
"--model",
|
|
"--preset",
|
|
"--reset",
|
|
"--reviewing-effort",
|
|
"--save-as",
|
|
"--writing-up-effort",
|
|
];
|
|
const modes = validateConfigMutationModes(argv, "models", mutationFlags) ??
|
|
validateSettingsTargets(argv);
|
|
if (modes) return modes;
|
|
if (argv.includes("--preset") && argv.includes("--from")) {
|
|
return "--preset and --from are mutually exclusive";
|
|
}
|
|
if (argv.includes("--save-as") && !argv.includes("--from")) {
|
|
return "--save-as requires --from <preset|profile>";
|
|
}
|
|
if (argv.includes("--agent") && !argv.includes("--effort")) {
|
|
return "--agent requires --effort <value>";
|
|
}
|
|
if (
|
|
!argv.includes("--agent") &&
|
|
(argv.includes("--effort") || argv.includes("--model"))
|
|
) {
|
|
return "--effort and --model require --agent <name>";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// `config --channel [stable|preview]` reads or sets the machine release
|
|
// channel. The value is optional: bare `--channel` prints the channel in force.
|
|
function validateChannelConfigArgs(argv: readonly string[]): string | null {
|
|
const index = argv.indexOf("--channel");
|
|
const value = argv[index + 1];
|
|
const hasValue = value !== undefined && !value.startsWith("--");
|
|
if (hasValue && !(RELEASE_CHANNELS as readonly string[]).includes(value)) {
|
|
return `--channel must be ${RELEASE_CHANNELS.join(" or ")}`;
|
|
}
|
|
const rest = [
|
|
...argv.slice(0, index),
|
|
...argv.slice(index + (hasValue ? 2 : 1)),
|
|
];
|
|
if (rest.includes("--channel")) return "--channel may be specified only once";
|
|
const grammar = validateConfigOptionGrammar(rest, "config --channel", {
|
|
values: new Set(["--project-dir"]),
|
|
bare: new Set(["--help", "--json", "--no-color", "--quiet", "--verbose"]),
|
|
invalidKnownFlags: ROOT_CONFIG_FLAGS,
|
|
invalidKnownMessage: (flag) => `${flag} is not valid with config --channel`,
|
|
});
|
|
if (grammar) return grammar;
|
|
return validateConfigOutputMode(argv);
|
|
}
|
|
|
|
function validateRootConfigArgs(argv: readonly string[]): string | null {
|
|
const hasPin = argv.includes("--pin");
|
|
const hasUnpin = argv.includes("--unpin");
|
|
if (hasPin && hasUnpin) return "--pin and --unpin are mutually exclusive";
|
|
if (argv.includes("--channel")) {
|
|
if (hasPin || hasUnpin) return "--channel cannot be combined with --pin or --unpin";
|
|
return validateChannelConfigArgs(argv);
|
|
}
|
|
|
|
const commonValues = ["--project-dir"];
|
|
const commonBare = [
|
|
"--dry-run",
|
|
"--help",
|
|
"--json",
|
|
"--no-color",
|
|
"--quiet",
|
|
"--verbose",
|
|
"--yes",
|
|
];
|
|
const mode = hasPin ? "config --pin" : hasUnpin ? "config --unpin" : "config";
|
|
const values = new Set(
|
|
hasPin
|
|
? [
|
|
...commonValues,
|
|
"--ca-bundle",
|
|
"--from",
|
|
"--pin",
|
|
"--release-base-url",
|
|
]
|
|
: hasUnpin
|
|
? commonValues
|
|
: [
|
|
...commonValues,
|
|
"--from",
|
|
"--harness",
|
|
"--mcp",
|
|
"--plan-token",
|
|
],
|
|
);
|
|
const bare = new Set(
|
|
hasPin
|
|
? [...commonBare, "--offline"]
|
|
: hasUnpin
|
|
? [...commonBare, "--unpin"]
|
|
: [...commonBare, "--force"],
|
|
);
|
|
const grammar = validateConfigOptionGrammar(argv, mode, {
|
|
values,
|
|
bare,
|
|
invalidKnownFlags: ROOT_CONFIG_FLAGS,
|
|
invalidKnownMessage: (flag) => `${flag} is not valid with ${mode}`,
|
|
});
|
|
if (grammar) return grammar;
|
|
return validateConfigOutputMode(argv);
|
|
}
|
|
|
|
export function validatePublicConfigArgs(input: readonly string[]): string | null {
|
|
const argv = stripVerb([...input]);
|
|
const positionals = configPositionals(argv);
|
|
const section = positionals[0];
|
|
if (!section) return validateRootConfigArgs(argv);
|
|
if (!VALID_CONFIG_SECTIONS.has(section.value)) {
|
|
return `unknown config section ${JSON.stringify(section.value)}; valid sections: models, runtime, providers, trust, flags, project`;
|
|
}
|
|
const sectionArgv = [
|
|
...argv.slice(0, section.index),
|
|
...argv.slice(section.index + 1),
|
|
];
|
|
if (section.value === "models") return validateModelsArgs(sectionArgv);
|
|
if (
|
|
section.value === "runtime" ||
|
|
section.value === "providers" ||
|
|
section.value === "trust"
|
|
) {
|
|
return validateDiagnosticArgs(section.value, sectionArgv);
|
|
}
|
|
return validateChoiceArgs(section.value as ChoiceSection, sectionArgv);
|
|
}
|
|
|
|
function modelPolicyHelp(): string {
|
|
const invoke = aidlcInvocation();
|
|
const out = process.stdout;
|
|
return [
|
|
"Choose model and effort policy for each agent",
|
|
"",
|
|
heading("USAGE", out),
|
|
` ${cmd(`${invoke} config models [flags]`, out)}`,
|
|
"",
|
|
"Pins bind in both directions: a pinned agent stays pinned if the session later moves to a larger model.",
|
|
"Judgment and Writing up inherit by default; the balanced reviewer baseline is the disclosed shipped step-down.",
|
|
"",
|
|
heading("POLICY", out),
|
|
" --preset <thorough|balanced|minimal>",
|
|
" balanced explicitly matches the shipped reviewing default",
|
|
" --from <preset|profile> [--save-as <name>]",
|
|
" --deciding-effort <low|medium|high|xhigh|max>",
|
|
" --reviewing-effort <low|medium|high|xhigh|max>",
|
|
" --writing-up-effort <low|medium|high|xhigh|max>",
|
|
" --agent <name> --effort <value> [--model <raw-id>]",
|
|
" --reset",
|
|
"",
|
|
heading("WRITE TARGET", out),
|
|
" --project committed team policy (recommended in a repository)",
|
|
" --local personal project policy in aidlc.settings.local.json",
|
|
" --global machine policy in the install-root aidlc.settings.json",
|
|
"Outside an installed project, --global is the only valid target and is inferred.",
|
|
"",
|
|
heading("INSPECTION", out),
|
|
" --show [--json]",
|
|
" --check",
|
|
"",
|
|
heading("MUTATION CONTROL", out),
|
|
" --dry-run",
|
|
" --yes",
|
|
"",
|
|
heading("EXAMPLE", out),
|
|
` ${cmd(`${invoke} config models --preset thorough --project --yes`, out)}`,
|
|
"",
|
|
dim(`Run '${invoke} config models --show' to inspect the effective result.`, out),
|
|
].join("\n");
|
|
}
|
|
|
|
function modelStateData(
|
|
policy: ModelPolicyRecord | null,
|
|
tiers: AgentTiers,
|
|
harness: ModelHarness,
|
|
projectDir: string,
|
|
resolved: ResolvedAidlcSettings,
|
|
): {
|
|
harness: ModelHarness;
|
|
policy: ModelPolicyRecord | null;
|
|
effective: Array<
|
|
ReturnType<typeof resolveModelPolicy> & {
|
|
modelSource: ReturnType<typeof settingsSource>;
|
|
effortSource: ReturnType<typeof settingsSource>;
|
|
}
|
|
>;
|
|
notes: string[];
|
|
} {
|
|
const cap = resolveTierCap(join(projectDir, "aidlc", "spaces", "default", "memory"));
|
|
const effective = Object.entries(tiers).sort(([left], [right]) =>
|
|
left.localeCompare(right)
|
|
).map(([name, tier]) => {
|
|
const item = resolveModelPolicy(policy, name, tier, harness, cap);
|
|
const agent = resolved.models?.agents?.[name];
|
|
const group = resolved.models?.groups?.[item.group];
|
|
const presetGroups = resolved.models?.preset
|
|
? MODEL_PRESETS[resolved.models.preset as keyof typeof MODEL_PRESETS]?.groups as
|
|
Partial<Record<ModelGroup, { effort: ModelEffort }>>
|
|
: undefined;
|
|
const presetGroup = presetGroups?.[item.group];
|
|
const fallbackSource = process.env.AIDLC_TIER_CAP ? "env" : "shipped default";
|
|
return {
|
|
...item,
|
|
modelSource: agent?.model?.[harness] !== undefined
|
|
? settingsSource(resolved, `models.agents.${name}.model.${harness}`)
|
|
: fallbackSource,
|
|
effortSource: agent?.effort !== undefined
|
|
? settingsSource(resolved, `models.agents.${name}.effort`)
|
|
: group?.effort !== undefined
|
|
? settingsSource(resolved, `models.groups.${item.group}.effort`)
|
|
: presetGroup?.effort !== undefined
|
|
? settingsSource(resolved, "models.preset")
|
|
: fallbackSource,
|
|
};
|
|
});
|
|
return {
|
|
harness,
|
|
policy,
|
|
effective,
|
|
notes: harnessHonestyNotes(policy, tiers, harness, cap),
|
|
};
|
|
}
|
|
|
|
function showModels(
|
|
policy: ModelPolicyRecord | null,
|
|
tiers: AgentTiers,
|
|
harness: ModelHarness,
|
|
projectDir: string,
|
|
resolved: ResolvedAidlcSettings,
|
|
options: ReturnType<typeof globalOptions>,
|
|
): void {
|
|
const data = modelStateData(policy, tiers, harness, projectDir, resolved);
|
|
if (options.mode === "json") {
|
|
emitResult(success(`model policy for ${harness}`, data), options);
|
|
return;
|
|
}
|
|
const out = process.stdout;
|
|
let output = `${heading(`Model policy for ${harness}`, out)}\n\n`;
|
|
output += `Preset: ${policy?.preset ?? "none (shipped defaults)"}\n\n`;
|
|
output += `All ${data.effective.length} agents inherit your session model and effort, except:\n`;
|
|
const grouped = new Map<string, typeof data.effective>();
|
|
for (const item of data.effective) {
|
|
const differs = (item.model !== undefined && item.model !== "inherit") ||
|
|
item.effort !== undefined ||
|
|
item.requestedModel !== undefined ||
|
|
item.requestedEffort !== undefined;
|
|
if (!differs) continue;
|
|
const key = [
|
|
item.group,
|
|
item.model ?? "inherit",
|
|
item.effort ?? "inherit",
|
|
item.layer,
|
|
item.modelSource,
|
|
item.effortSource,
|
|
].join("|");
|
|
grouped.set(key, [...(grouped.get(key) ?? []), item]);
|
|
}
|
|
if (grouped.size === 0) output += " none\n";
|
|
for (const items of grouped.values()) {
|
|
const item = items[0];
|
|
const label = item.layer === "agent-exception" && items.length === 1
|
|
? item.agent
|
|
: `${MODEL_GROUPS[item.group].label} (${items.length} agents)`;
|
|
output += ` ${label}: ${item.model ?? "inherit"} / ${item.effort ?? "inherit"}\n`;
|
|
if (item.group === "reviewing") {
|
|
output +=
|
|
" Review-only agents use the measured medium baseline; raising effort trades review time and cost for deeper correctness checking.\n";
|
|
}
|
|
if (item.layer === "agent-exception" || item.layer === "group-dial") {
|
|
output += ` Recorded override: ${dim(item.layer, out)}; model ${
|
|
dim(item.modelSource, out)
|
|
}, effort ${dim(item.effortSource, out)}.\n`;
|
|
}
|
|
if (item.unexpressed.length > 0) {
|
|
output += ` Not expressible on ${harness}: ${item.unexpressed.join(", ")}.\n`;
|
|
}
|
|
}
|
|
for (const note of data.notes) output += ` Note: ${note}\n`;
|
|
const recorded = ([
|
|
["global", resolved.files.machine],
|
|
["project", resolved.files.project],
|
|
["local", resolved.files.local],
|
|
] as const).filter(([target, file]) =>
|
|
file.present &&
|
|
readSettingsTarget(projectDir, target)?.models !== undefined
|
|
).map(([, file]) => file.path);
|
|
const displayedRecorded = recorded.map((path) => {
|
|
const rel = relative(projectDir, path).replaceAll("\\", "/");
|
|
return rel && !rel.startsWith("../") ? rel : path;
|
|
});
|
|
output += `\nRecorded in: ${
|
|
displayedRecorded.length > 0
|
|
? displayedRecorded.join(", ")
|
|
: `nothing yet - run '${aidlcInvocation()} config models --preset balanced --project --yes'`
|
|
}\n`;
|
|
output += `${dim(
|
|
`Full per-agent list: ${aidlcInvocation()} config models --show --json`,
|
|
out,
|
|
)}\n`;
|
|
process.stdout.write(output);
|
|
process.exitCode = EXIT.ok;
|
|
}
|
|
|
|
function modelsPipelineArgv(argv: readonly string[]): string[] {
|
|
const out: string[] = [];
|
|
const keptValues = new Set(["--harness", "--plan-token", "--project-dir"]);
|
|
const keptBare = new Set([
|
|
"--dry-run",
|
|
"--json",
|
|
"--no-color",
|
|
"--quiet",
|
|
"--verbose",
|
|
"--yes",
|
|
]);
|
|
for (let index = 0; index < argv.length; index++) {
|
|
const token = argv[index];
|
|
if (keptValues.has(token)) {
|
|
out.push(token, argv[++index]);
|
|
} else if (keptBare.has(token)) {
|
|
out.push(token);
|
|
} else if (MODELS_VALUE_FLAGS.has(token)) {
|
|
index++;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function modelEffortFlag(
|
|
argv: readonly string[],
|
|
group: ModelGroup,
|
|
): ModelEffort | undefined {
|
|
const flag = `--${group}-effort`;
|
|
const value = valueAfter(argv, flag);
|
|
if (value === undefined) return undefined;
|
|
if (!isModelEffort(value)) {
|
|
throw new Error(`${flag} must be one of ${MODEL_EFFORTS.join(", ")}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function applyModelsFlags(
|
|
current: ModelPolicyRecord | null,
|
|
argv: readonly string[],
|
|
tiers: AgentTiers,
|
|
profileSource: ModelPolicyRecord | null = current,
|
|
): ModelPolicyRecord | null {
|
|
if (argv.includes("--reset")) {
|
|
const conflicting = [
|
|
"--agent",
|
|
"--deciding-effort",
|
|
"--effort",
|
|
"--from",
|
|
"--model",
|
|
"--preset",
|
|
"--reviewing-effort",
|
|
"--save-as",
|
|
"--writing-up-effort",
|
|
].find((flag) => argv.includes(flag));
|
|
if (conflicting) throw new Error(`--reset cannot be combined with ${conflicting}`);
|
|
return null;
|
|
}
|
|
const next = cloneModelPolicy(current);
|
|
const preset = valueAfter(argv, "--preset");
|
|
const from = valueAfter(argv, "--from");
|
|
const saveAs = valueAfter(argv, "--save-as");
|
|
if (preset && from) throw new Error("--preset and --from are mutually exclusive");
|
|
if (preset) {
|
|
if (!isModelPreset(preset)) {
|
|
throw new Error(`--preset must be one of ${Object.keys(MODEL_PRESETS).join(", ")}`);
|
|
}
|
|
next.preset = preset;
|
|
delete next.groups;
|
|
}
|
|
if (saveAs && !from) throw new Error("--save-as requires --from <preset|profile>");
|
|
if (saveAs && !/^[a-z0-9][a-z0-9-]*$/.test(saveAs)) {
|
|
throw new Error("--save-as must use lowercase letters, digits, and hyphens");
|
|
}
|
|
if (from) {
|
|
const groups = profileGroups(profileSource, from);
|
|
next.groups = groups;
|
|
if (isModelPreset(from) && !saveAs) next.preset = from;
|
|
else delete next.preset;
|
|
}
|
|
for (const group of Object.keys(MODEL_GROUPS) as ModelGroup[]) {
|
|
const effort = modelEffortFlag(argv, group);
|
|
if (!effort) continue;
|
|
next.groups ??= {};
|
|
next.groups[group] = { effort };
|
|
}
|
|
if (saveAs) {
|
|
next.profiles ??= {};
|
|
next.profiles[saveAs] = {
|
|
groups: JSON.parse(JSON.stringify(next.groups ?? {})) as ModelProfile["groups"],
|
|
};
|
|
}
|
|
const agent = valueAfter(argv, "--agent");
|
|
const effort = valueAfter(argv, "--effort");
|
|
const model = valueAfter(argv, "--model");
|
|
if (agent && !(agent in tiers)) {
|
|
throw new Error(
|
|
`unknown agent ${JSON.stringify(agent)}; use one of ${Object.keys(tiers).sort().join(", ")}`,
|
|
);
|
|
}
|
|
if (agent && !effort) throw new Error("--agent requires --effort <value>");
|
|
if (!agent && (effort || model)) throw new Error("--effort and --model require --agent <name>");
|
|
if (effort && !isModelEffort(effort)) {
|
|
throw new Error(`--effort must be one of ${MODEL_EFFORTS.join(", ")}`);
|
|
}
|
|
if (agent && effort) {
|
|
next.agents ??= {};
|
|
next.agents[agent] = {
|
|
...(next.agents[agent] ?? {}),
|
|
effort: effort as ModelEffort,
|
|
...(model ? { model } : {}),
|
|
};
|
|
}
|
|
return modelPolicyIsEmpty(next) ? null : normalizeModelPolicy(next);
|
|
}
|
|
|
|
function groupPolicyEffort(
|
|
policy: ModelPolicyRecord | null,
|
|
group: ModelGroup,
|
|
): ModelEffort | undefined {
|
|
return activeModelGroups(policy)[group]?.effort;
|
|
}
|
|
|
|
function effortTradeoff(group: ModelGroup, effort: ModelEffort): string {
|
|
if (group === "reviewing" && effort === "xhigh") {
|
|
return "Deepest review passes - built for correctness-critical work; reviews run slower and cost more.";
|
|
}
|
|
if (effort === "low" || effort === "medium") {
|
|
return group === "deciding"
|
|
? "Faster decisions with less deliberation."
|
|
: group === "reviewing"
|
|
? "Faster review passes with less deliberation."
|
|
: "Faster plans, pipelines, and runbooks with less polish.";
|
|
}
|
|
return MODEL_GROUPS[group].tradeoff;
|
|
}
|
|
|
|
function modelSummaryLines(
|
|
previous: ModelPolicyRecord | null,
|
|
next: ModelPolicyRecord | null,
|
|
tiers: AgentTiers,
|
|
harness: ModelHarness,
|
|
projectDir: string,
|
|
): { lines: string[]; notes: string[] } {
|
|
const cap = resolveTierCap(join(projectDir, "aidlc", "spaces", "default", "memory"));
|
|
const lines: string[] = [];
|
|
for (const group of Object.keys(MODEL_GROUPS) as ModelGroup[]) {
|
|
const beforeDial = groupPolicyEffort(previous, group);
|
|
const afterDial = groupPolicyEffort(next, group);
|
|
if (beforeDial === afterDial) continue;
|
|
const names = Object.entries(tiers)
|
|
.filter(([, tier]) => MODEL_GROUPS[group].tier === tier)
|
|
.map(([name]) => name)
|
|
.sort();
|
|
const name = names[0];
|
|
if (!name) continue;
|
|
const tier = tiers[name];
|
|
const before = resolveModelPolicy(previous, name, tier, harness, cap);
|
|
const after = resolveModelPolicy(next, name, tier, harness, cap);
|
|
if (after.unexpressed.includes("effort")) {
|
|
lines.push(
|
|
` ${MODEL_GROUPS[group].label.padEnd(11)} ${names.length} agents ` +
|
|
`${afterDial ?? "inherit"} requested; ${harnessHonestyNotes(next, tiers, harness, cap)[0]}`,
|
|
);
|
|
continue;
|
|
}
|
|
const beforeModel = before.model ?? "inherit";
|
|
const afterModel = after.model ?? "inherit";
|
|
const suffix = beforeModel === afterModel ? " (model unchanged)" : "";
|
|
lines.push(
|
|
` ${MODEL_GROUPS[group].label.padEnd(11)} ${names.length} agents ` +
|
|
`${beforeModel}/${before.effort ?? "inherit"} -> ` +
|
|
`${afterModel}/${after.effort ?? "inherit"}${suffix}`,
|
|
);
|
|
if (afterDial) lines.push(` ${effortTradeoff(group, afterDial)}`);
|
|
}
|
|
const notes = harnessHonestyNotes(next, tiers, harness, cap);
|
|
return { lines, notes };
|
|
}
|
|
|
|
function modelsWizard(
|
|
current: ModelPolicyRecord | null,
|
|
targetCurrent: ModelPolicyRecord | null,
|
|
tiers: AgentTiers,
|
|
harness: ModelHarness,
|
|
projectDir: string,
|
|
resolved: ResolvedAidlcSettings,
|
|
): ModelPolicyRecord | null {
|
|
showModels(current, tiers, harness, projectDir, resolved, {
|
|
mode: "human",
|
|
color: false,
|
|
yes: false,
|
|
offline: true,
|
|
verbose: false,
|
|
});
|
|
process.stdout.write(
|
|
"Pins bind in both directions, and shipped tiers never raise an agent above the session.\n",
|
|
);
|
|
const choice = configPrompt(
|
|
"Models [Enter keep everything, 1 preset, 2 group efforts, 3 set each one myself]:",
|
|
)?.trim();
|
|
if (!choice) return current;
|
|
if (choice === "1") {
|
|
process.stdout.write(
|
|
"Presets: thorough raises reviewing to xhigh; balanced matches the shipped reviewing default; minimal also lowers Writing up to low.\n",
|
|
);
|
|
const selected = configPrompt("Preset [thorough/balanced/minimal]:")?.trim() ?? "";
|
|
if (!isModelPreset(selected)) throw new Error("preset selection cancelled");
|
|
return applyModelsFlags(targetCurrent, ["--preset", selected], tiers, current);
|
|
}
|
|
if (choice === "2") {
|
|
const args: string[] = [];
|
|
for (const group of Object.keys(MODEL_GROUPS) as ModelGroup[]) {
|
|
const currentValue = groupPolicyEffort(current, group) ?? "shipped";
|
|
process.stdout.write(
|
|
`${MODEL_GROUPS[group].label}: current ${currentValue}. ${MODEL_GROUPS[group].tradeoff}\n`,
|
|
);
|
|
const answer = configPrompt(
|
|
`${MODEL_GROUPS[group].label} effort [low/medium/high/xhigh/max, Enter keep]:`,
|
|
)?.trim();
|
|
if (!answer) continue;
|
|
if (!isModelEffort(answer)) throw new Error(`invalid effort ${JSON.stringify(answer)}`);
|
|
args.push(`--${group}-effort`, answer);
|
|
}
|
|
return args.length > 0
|
|
? applyModelsFlags(targetCurrent, args, tiers, current)
|
|
: targetCurrent;
|
|
}
|
|
if (choice === "3") {
|
|
let next = targetCurrent;
|
|
for (const name of Object.keys(tiers).sort()) {
|
|
const currentValue = resolveModelPolicy(current, name, tiers[name], harness);
|
|
process.stdout.write(
|
|
`${name}: current ${currentValue.model ?? "inherit"}/${currentValue.effort ?? "inherit"}.\n`,
|
|
);
|
|
const effort = configPrompt(
|
|
`${name} effort [low/medium/high/xhigh/max, Enter keep]:`,
|
|
)?.trim();
|
|
if (!effort) continue;
|
|
if (!isModelEffort(effort)) throw new Error(`invalid effort ${JSON.stringify(effort)}`);
|
|
const model = configPrompt(`${name} raw model id [Enter inherit]:`)?.trim();
|
|
next = applyModelsFlags(
|
|
next,
|
|
["--agent", name, "--effort", effort, ...(model ? ["--model", model] : [])],
|
|
tiers,
|
|
current,
|
|
);
|
|
}
|
|
return next;
|
|
}
|
|
throw new Error("models selection cancelled");
|
|
}
|
|
|
|
function validateDiagnosticArgs(
|
|
section: DiagnosticSection,
|
|
argv: readonly string[],
|
|
): string | null {
|
|
const sectionValues = section === "providers"
|
|
? new Set([
|
|
"--harness",
|
|
"--mark-done",
|
|
"--opencode-default",
|
|
"--plan-token",
|
|
"--profile",
|
|
"--project-dir",
|
|
"--provider",
|
|
"--region",
|
|
])
|
|
: new Set(["--harness", "--plan-token", "--project-dir"]);
|
|
const sectionBare = section === "runtime"
|
|
? new Set([...DIAGNOSTIC_BARE_FLAGS, "--record-paths"])
|
|
: section === "providers"
|
|
? new Set([...DIAGNOSTIC_BARE_FLAGS, "--acknowledge"])
|
|
: new Set([...DIAGNOSTIC_BARE_FLAGS, "--acknowledge"]);
|
|
const grammar = validateConfigOptionGrammar(argv, section, {
|
|
values: sectionValues,
|
|
bare: sectionBare,
|
|
repeatable: section === "providers"
|
|
? new Set(["--mark-done"])
|
|
: undefined,
|
|
invalidKnownMessage: (flag) => `${flag} is not valid for config ${section}`,
|
|
});
|
|
if (grammar) return grammar;
|
|
const mutationFlags = section === "runtime"
|
|
? ["--record-paths", "--reset"]
|
|
: section === "providers"
|
|
? [
|
|
"--acknowledge",
|
|
"--mark-done",
|
|
"--opencode-default",
|
|
"--profile",
|
|
"--provider",
|
|
"--region",
|
|
"--reset",
|
|
]
|
|
: ["--acknowledge", "--reset"];
|
|
return validateConfigMutationModes(argv, section, mutationFlags);
|
|
}
|
|
|
|
function diagnosticHelp(section: DiagnosticSection): string {
|
|
const invoke = aidlcInvocation();
|
|
const out = process.stdout;
|
|
const common = [
|
|
heading("Inspection:", out),
|
|
" --show [--json]",
|
|
" --check",
|
|
"",
|
|
heading("Mutation control:", out),
|
|
" --reset",
|
|
" --dry-run",
|
|
" --yes",
|
|
];
|
|
const specific = section === "runtime"
|
|
? [
|
|
heading("Runtime answers:", out),
|
|
" --record-paths",
|
|
"",
|
|
"The probe uses the non-interactive hook PATH, not interactive shell rc files.",
|
|
"Recorded paths are diagnostic answers only. Hook commands are not rewritten because host trust rules bind the bare command prefix.",
|
|
]
|
|
: section === "providers"
|
|
? [
|
|
heading("Provider answers:", out),
|
|
" --provider <amazon-bedrock|other>",
|
|
" --region <aws-region>",
|
|
" --profile <aws-profile>",
|
|
" --opencode-default <yes|no>",
|
|
" --acknowledge",
|
|
" --mark-done <pending-action-id>",
|
|
"",
|
|
"Credential detection is offline only. No provider or model endpoint is contacted.",
|
|
]
|
|
: [
|
|
heading("Trust answers:", out),
|
|
" --acknowledge",
|
|
"",
|
|
"Trust is read, verified, and instructed. This section never regenerates trust seeds or permission rules.",
|
|
];
|
|
return [
|
|
section === "runtime"
|
|
? "Check and record the non-interactive hook runtime"
|
|
: section === "providers"
|
|
? "Record model-provider choices and pending manual actions"
|
|
: "Review host trust and command allowlists",
|
|
"",
|
|
heading("USAGE", out),
|
|
` ${cmd(`${invoke} config ${section} [flags]`, out)}`,
|
|
"",
|
|
...specific,
|
|
"",
|
|
...common,
|
|
"",
|
|
heading("EXAMPLE", out),
|
|
` ${cmd(`${invoke} config ${section} --show`, out)}`,
|
|
"",
|
|
dim(
|
|
`Run '${invoke} config ${section} --check' for a non-writing verification.`,
|
|
out,
|
|
),
|
|
].join("\n");
|
|
}
|
|
|
|
function selectedDiagnosticHarness(
|
|
projectDir: string,
|
|
requested: string | undefined,
|
|
section: DiagnosticSection | ChoiceSection,
|
|
): {
|
|
distribution: string;
|
|
harnessDir: string;
|
|
root: string;
|
|
harness: ModelHarness;
|
|
} {
|
|
const harnesses = discoverProjectHarnesses(projectDir);
|
|
const selected = requested
|
|
? harnesses.find((candidate) => candidate.distribution === requested)
|
|
: harnesses[0];
|
|
if (!selected) {
|
|
throw new Error(
|
|
requested && harnesses.length > 0
|
|
? `project uses ${harnesses.map((item) => item.distribution).join(", ")}; refusing ${requested}`
|
|
: `${configCommand(section)} requires an installed project harness; run ${configCommand()} first`,
|
|
);
|
|
}
|
|
if (!requested && harnesses.length > 1) {
|
|
throw new Error("multiple project harnesses are present; pass one --harness <name>");
|
|
}
|
|
return {
|
|
...selected,
|
|
harness: modelHarness(selected.distribution),
|
|
};
|
|
}
|
|
|
|
function diagnosticPipelineArgv(argv: readonly string[]): string[] {
|
|
const out: string[] = [];
|
|
const keptValues = new Set(["--harness", "--plan-token", "--project-dir"]);
|
|
const keptBare = new Set([
|
|
"--dry-run",
|
|
"--json",
|
|
"--no-color",
|
|
"--quiet",
|
|
"--verbose",
|
|
"--yes",
|
|
]);
|
|
for (let index = 0; index < argv.length; index++) {
|
|
const token = argv[index];
|
|
if (keptValues.has(token)) {
|
|
out.push(token, argv[++index]);
|
|
} else if (keptBare.has(token)) {
|
|
out.push(token);
|
|
} else if (DIAGNOSTIC_VALUE_FLAGS.has(token)) {
|
|
index++;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function currentDiagnosticRecord(
|
|
records: ConfigDiagnosticRecords,
|
|
section: DiagnosticSection,
|
|
): RuntimeRecord | ProvidersRecord | TrustRecord | null {
|
|
return records[section];
|
|
}
|
|
|
|
function diagnosticOverrides(
|
|
section: DiagnosticSection,
|
|
next: RuntimeRecord | ProvidersRecord | TrustRecord | null,
|
|
): ConfigDiagnosticOverrides {
|
|
return { [section]: next };
|
|
}
|
|
|
|
function compactHumanFileList<T>(
|
|
items: readonly T[],
|
|
section: DiagnosticSection,
|
|
render: (item: T) => string,
|
|
): string {
|
|
const visible = items.length > 8 ? items.slice(0, 5) : items;
|
|
let output = visible.map((item) => ` ${render(item)}\n`).join("");
|
|
if (items.length > 8) {
|
|
output +=
|
|
` ... and ${items.length - visible.length} more ` +
|
|
`(aidlc config ${section} --show --json lists all)\n`;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function showDiagnosticSection(
|
|
section: DiagnosticSection,
|
|
projectDir: string,
|
|
selected: ReturnType<typeof selectedDiagnosticHarness>,
|
|
records: ConfigDiagnosticRecords,
|
|
options: ReturnType<typeof globalOptions>,
|
|
): void {
|
|
const current = currentDiagnosticRecord(records, section);
|
|
let data: Record<string, unknown>;
|
|
if (section === "runtime") {
|
|
const diagnostics = probeRuntime(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
);
|
|
data = {
|
|
section,
|
|
harness: selected.harness,
|
|
record: current,
|
|
diagnostics,
|
|
issues: runtimeIssues(diagnostics),
|
|
files: [
|
|
join(selected.root, "tools", "data", "harness.json"),
|
|
...diagnostics.commandFiles,
|
|
],
|
|
};
|
|
} else if (section === "providers") {
|
|
const record = current as ProvidersRecord | null;
|
|
const credentials = detectAwsCredentials();
|
|
data = {
|
|
section,
|
|
harness: selected.harness,
|
|
record,
|
|
credentials,
|
|
pendingActions: record ? pendingProviderIssues(record) : [],
|
|
issues: providerIssues(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
record,
|
|
credentials,
|
|
),
|
|
files: providerFiles(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
record,
|
|
),
|
|
};
|
|
} else {
|
|
const status = trustStatus(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
);
|
|
data = {
|
|
section,
|
|
harness: selected.harness,
|
|
record: current,
|
|
...status,
|
|
};
|
|
}
|
|
if (options.mode === "json") {
|
|
emitResult(success(`${section} configuration for ${selected.harness}`, data), options);
|
|
return;
|
|
}
|
|
let output = `${section[0].toUpperCase()}${section.slice(1)} configuration for ${selected.harness}\n`;
|
|
if (section === "runtime") {
|
|
const diagnostics = data.diagnostics as ReturnType<typeof probeRuntime>;
|
|
output += ` Hook baseline PATH: ${diagnostics.baselinePath}\n`;
|
|
for (const binary of diagnostics.binaries) {
|
|
output += ` ${binary.name}: ${binary.status}`;
|
|
if (binary.baselinePath) output += ` -> ${binary.baselinePath}`;
|
|
if (binary.interactivePath && !binary.baselinePath) {
|
|
output += ` -> interactive only at ${binary.interactivePath}`;
|
|
}
|
|
output += "\n";
|
|
}
|
|
output += ` Harness CLI: ${diagnostics.cli.status}`;
|
|
if (diagnostics.cli.path) output += ` -> ${diagnostics.cli.path}`;
|
|
output += "\n";
|
|
output += " Files carrying hook commands:\n";
|
|
output += compactHumanFileList(
|
|
diagnostics.commandFiles,
|
|
"runtime",
|
|
(file) => file,
|
|
);
|
|
} else if (section === "providers") {
|
|
const record = data.record as ProvidersRecord | null;
|
|
const credentials = data.credentials as ReturnType<typeof detectAwsCredentials>;
|
|
output += ` Provider: ${record?.provider ?? "shipped fallback"}\n`;
|
|
output += ` Region: ${record?.region ?? "shipped fallback"}\n`;
|
|
output += ` Profile: ${record?.profile ?? "default credential chain"}\n`;
|
|
output += ` Offline credentials: ${credentials.hasCredentials ? "found" : "not found"}\n`;
|
|
for (const source of credentials.sources) output += ` source: ${source}\n`;
|
|
const pending = data.pendingActions as ReturnType<typeof pendingProviderIssues>;
|
|
for (const issue of pending) output += ` Pending: ${issue.id} - ${issue.message}\n`;
|
|
output += " Files carrying provider settings:\n";
|
|
for (const entry of data.files as ReturnType<typeof providerFiles>) {
|
|
output += ` ${entry.setting}: ${entry.file}\n`;
|
|
}
|
|
} else {
|
|
const status = data as unknown as ReturnType<typeof trustStatus> & {
|
|
record: TrustRecord | null;
|
|
};
|
|
output += ` Allowlist reviewed: ${status.record?.reviewed === true ? "yes" : "not recorded"}\n`;
|
|
output += " Trust and allowlist files:\n";
|
|
output += compactHumanFileList(status.files, "trust", (file) => file);
|
|
for (const issue of status.issues) output += ` Unmet: ${issue.id} - ${issue.message}\n`;
|
|
}
|
|
process.stdout.write(output);
|
|
process.exitCode = EXIT.ok;
|
|
}
|
|
|
|
function checkDiagnosticSection(
|
|
section: DiagnosticSection,
|
|
projectDir: string,
|
|
selected: ReturnType<typeof selectedDiagnosticHarness>,
|
|
records: ConfigDiagnosticRecords,
|
|
options: ReturnType<typeof globalOptions>,
|
|
): void {
|
|
let issues: Array<{ id: string; message: string }>;
|
|
if (section === "runtime") {
|
|
issues = runtimeIssues(
|
|
probeRuntime(projectDir, selected.harnessDir, selected.harness),
|
|
);
|
|
} else if (section === "providers") {
|
|
issues = providerIssues(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
records.providers,
|
|
);
|
|
} else {
|
|
issues = trustStatus(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
).issues;
|
|
}
|
|
emitResult(
|
|
issues.length === 0
|
|
? success(`${section} configuration is clean for ${selected.harness}`, {
|
|
section,
|
|
harness: selected.harness,
|
|
issues: [],
|
|
})
|
|
: failure(
|
|
`${section} configuration has ${issues.length} unmet item(s): ${
|
|
issues.map((issue) => `${issue.id} (${issue.message})`).join("; ")
|
|
}`,
|
|
EXIT.failure,
|
|
configCommand(`${section} --show`),
|
|
),
|
|
options,
|
|
);
|
|
}
|
|
|
|
function cloneDiagnosticRecord<T>(value: T | null): T | null {
|
|
return value === null ? null : JSON.parse(JSON.stringify(value)) as T;
|
|
}
|
|
|
|
function runtimeRecordFromProbe(
|
|
projectDir: string,
|
|
selected: ReturnType<typeof selectedDiagnosticHarness>,
|
|
): RuntimeRecord {
|
|
const diagnostics = probeRuntime(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
);
|
|
const issues = runtimeIssues(diagnostics);
|
|
if (issues.length > 0) {
|
|
throw new Error(
|
|
`runtime paths cannot be recorded until the non-interactive probe is clean: ${
|
|
issues.map((issue) => issue.message).join("; ")
|
|
}`,
|
|
);
|
|
}
|
|
const bun = diagnostics.binaries.find((binary) => binary.name === "bun");
|
|
const aidlc = diagnostics.binaries.find((binary) => binary.name === "aidlc");
|
|
return {
|
|
schemaVersion: 1,
|
|
baselinePath: diagnostics.baselinePath,
|
|
...(bun?.baselinePath ? { bunPath: bun.baselinePath } : {}),
|
|
...(aidlc?.baselinePath ? { aidlcPath: aidlc.baselinePath } : {}),
|
|
...(diagnostics.cli.path ? { cliPath: diagnostics.cli.path } : {}),
|
|
};
|
|
}
|
|
|
|
function providerRecordFromArgs(
|
|
current: ProvidersRecord | null,
|
|
argv: readonly string[],
|
|
selected: ReturnType<typeof selectedDiagnosticHarness>,
|
|
): ProvidersRecord {
|
|
const next = cloneDiagnosticRecord(current) ?? { schemaVersion: 1 };
|
|
const provider = valueAfter(argv, "--provider");
|
|
if (provider !== undefined && provider !== "amazon-bedrock" && provider !== "other") {
|
|
throw new Error("--provider must be amazon-bedrock or other");
|
|
}
|
|
if (provider) next.provider = provider;
|
|
const region = valueAfter(argv, "--region");
|
|
const profile = valueAfter(argv, "--profile");
|
|
if (region) next.region = region;
|
|
if (profile) next.profile = profile;
|
|
const opencodeDefault = valueAfter(argv, "--opencode-default");
|
|
if (opencodeDefault !== undefined) {
|
|
if (selected.harness !== "opencode") {
|
|
throw new Error("--opencode-default is only valid for the opencode harness");
|
|
}
|
|
if (opencodeDefault !== "yes" && opencodeDefault !== "no") {
|
|
throw new Error("--opencode-default must be yes or no");
|
|
}
|
|
next.opencodeDefault = opencodeDefault === "yes";
|
|
}
|
|
if (argv.includes("--acknowledge")) next.acknowledged = true;
|
|
if (!next.provider) throw new Error("provider configuration requires --provider <amazon-bedrock|other>");
|
|
if (next.provider === "amazon-bedrock" && !next.region) {
|
|
throw new Error("Amazon Bedrock configuration requires --region <aws-region>");
|
|
}
|
|
if (
|
|
next.provider === "amazon-bedrock" &&
|
|
selected.harness === "opencode" &&
|
|
next.opencodeDefault === undefined
|
|
) {
|
|
throw new Error("OpenCode Bedrock configuration requires --opencode-default <yes|no>");
|
|
}
|
|
if (
|
|
next.provider === "other" &&
|
|
next.acknowledged !== true
|
|
) {
|
|
throw new Error(
|
|
`${selected.harness} provider setup is instruct-only; pass --acknowledge after completing the manual provider step`,
|
|
);
|
|
}
|
|
let reconciled = reconcileProviderActions(
|
|
normalizeProvidersRecord(next) as ProvidersRecord,
|
|
selected.harness,
|
|
);
|
|
const done = new Set(valuesAfter(argv, "--mark-done"));
|
|
if (done.size > 0) {
|
|
const known = new Set((reconciled.pendingActions ?? []).map((action) => action.id));
|
|
const unknown = [...done].filter((id) => !known.has(id));
|
|
if (unknown.length > 0) {
|
|
throw new Error(`unknown pending action(s): ${unknown.join(", ")}`);
|
|
}
|
|
reconciled = normalizeProvidersRecord({
|
|
...reconciled,
|
|
pendingActions: (reconciled.pendingActions ?? []).map((action) =>
|
|
done.has(action.id) ? { ...action, status: "done" } : action
|
|
),
|
|
}) as ProvidersRecord;
|
|
}
|
|
return reconciled;
|
|
}
|
|
|
|
function diagnosticWizard(
|
|
section: DiagnosticSection,
|
|
projectDir: string,
|
|
selected: ReturnType<typeof selectedDiagnosticHarness>,
|
|
records: ConfigDiagnosticRecords,
|
|
_options: ReturnType<typeof globalOptions>,
|
|
): RuntimeRecord | ProvidersRecord | TrustRecord | null {
|
|
if (section === "runtime") {
|
|
const diagnostics = probeRuntime(projectDir, selected.harnessDir, selected.harness);
|
|
const issues = runtimeIssues(diagnostics);
|
|
if (issues.length > 0) {
|
|
process.stdout.write("\n Runtime needs one manual action:\n\n");
|
|
for (const issue of issues) {
|
|
process.stdout.write(` ${issue.remediation}\n`);
|
|
}
|
|
process.stdout.write(
|
|
`\n Full diagnostics: ${
|
|
configCommandForHarness(selected.harnessDir, "runtime --show")
|
|
}\n\n`,
|
|
);
|
|
return records.runtime;
|
|
}
|
|
const answer = promptYesDefault(
|
|
" Record the detected non-interactive runtime paths?",
|
|
false,
|
|
);
|
|
process.stdout.write(
|
|
answer
|
|
? " Using the detected runtime paths.\n\n"
|
|
: " Leaving runtime paths unchanged.\n\n",
|
|
);
|
|
return answer ? runtimeRecordFromProbe(projectDir, selected) : records.runtime;
|
|
}
|
|
if (section === "providers") {
|
|
const credentials = detectAwsCredentials();
|
|
const detected = awsSummary(credentials);
|
|
process.stdout.write("\n Model provider\n");
|
|
process.stdout.write(
|
|
credentials.hasCredentials
|
|
? ` Found AWS credentials (${detected.source}); ${
|
|
detected.regionSource === "detected" ? "detected" : "fallback"
|
|
} region ${detected.region}.\n`
|
|
: " No AWS credentials were detected.\n",
|
|
);
|
|
process.stdout.write(` 1. amazon-bedrock ${
|
|
credentials.hasCredentials ? "(detected, default)" : ""
|
|
}\n`);
|
|
process.stdout.write(" 2. other\n");
|
|
const providerAnswer = promptChoice(
|
|
" Provider",
|
|
2,
|
|
credentials.hasCredentials ? 1 : 2,
|
|
) === 1
|
|
? "amazon-bedrock"
|
|
: "other";
|
|
const args = ["--provider", providerAnswer];
|
|
const skipMarkDone = new Set<string>();
|
|
if (providerAnswer === "amazon-bedrock") {
|
|
const region = promptTextDefault(" AWS region", detected.region);
|
|
const profileAnswer = promptTextDefault(
|
|
" AWS profile",
|
|
"default credential chain",
|
|
);
|
|
const profile = profileAnswer === "default credential chain"
|
|
? ""
|
|
: profileAnswer;
|
|
args.push("--region", region);
|
|
if (profile) args.push("--profile", profile);
|
|
process.stdout.write(
|
|
` Using amazon-bedrock in ${region} with ${
|
|
profile || "the default credential chain"
|
|
}.\n\n`,
|
|
);
|
|
if (selected.harness === "opencode") {
|
|
const offer = promptYesDefault(
|
|
" Write amazon-bedrock provider options to opencode.json?",
|
|
false,
|
|
);
|
|
args.push("--opencode-default", offer ? "yes" : "no");
|
|
}
|
|
if (selected.harness === "copilot" || selected.harness === "cursor") {
|
|
process.stdout.write(
|
|
selected.harness === "copilot"
|
|
? "Configure Copilot BYOK provider variables before acknowledging this step.\n"
|
|
: "Configure the provider and select the model in Cursor before acknowledging this step.\n",
|
|
);
|
|
const acknowledged = promptYesDefault(
|
|
" Manual provider setup complete?",
|
|
false,
|
|
);
|
|
if (acknowledged) {
|
|
args.push("--acknowledge");
|
|
} else {
|
|
const action = selected.harness === "copilot"
|
|
? "copilot-byok-configuration"
|
|
: "cursor-provider-configuration";
|
|
skipMarkDone.add(action);
|
|
process.stdout.write(
|
|
` ${action} remains pending. Complete it with --acknowledge or --mark-done ${action}.\n`,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
process.stdout.write(" Using other provider setup.\n");
|
|
const acknowledged = promptYesDefault(
|
|
" Manual provider setup complete?",
|
|
false,
|
|
);
|
|
if (acknowledged) {
|
|
args.push("--acknowledge");
|
|
} else {
|
|
process.stdout.write(
|
|
" Provider setup remains pending; no provider answer was recorded.\n\n",
|
|
);
|
|
return records.providers;
|
|
}
|
|
}
|
|
let next = providerRecordFromArgs(records.providers, args, selected);
|
|
for (const action of next.pendingActions ?? []) {
|
|
if (action.status === "done") continue;
|
|
if (skipMarkDone.has(action.id)) continue;
|
|
const answer = promptYesDefault(
|
|
` Mark ${action.id} done now?`,
|
|
false,
|
|
);
|
|
if (answer) {
|
|
next = providerRecordFromArgs(
|
|
next,
|
|
["--mark-done", action.id],
|
|
selected,
|
|
);
|
|
}
|
|
}
|
|
return next;
|
|
}
|
|
const answer = promptYesDefault(
|
|
" Record that you reviewed the trust and allowlist files?",
|
|
false,
|
|
);
|
|
process.stdout.write(
|
|
answer
|
|
? " Trust review acknowledged.\n\n"
|
|
: " Leaving trust acknowledgement unchanged.\n\n",
|
|
);
|
|
return answer
|
|
? { schemaVersion: 1, reviewed: true }
|
|
: records.trust;
|
|
}
|
|
|
|
function diagnosticSummary(
|
|
section: DiagnosticSection,
|
|
next: RuntimeRecord | ProvidersRecord | TrustRecord | null,
|
|
): { lines: string[]; notes: string[] } {
|
|
if (section === "runtime") {
|
|
return {
|
|
lines: [
|
|
next
|
|
? " Runtime recorded non-interactive executable paths in harness.json"
|
|
: " Runtime reset to live detection",
|
|
],
|
|
notes: [
|
|
"Hook command strings were not rewritten because host allowlists and Codex trust hashes bind the bare command prefix.",
|
|
],
|
|
};
|
|
}
|
|
if (section === "providers") {
|
|
const record = next as ProvidersRecord | null;
|
|
return {
|
|
lines: [
|
|
record
|
|
? ` Providers ${record.provider} region=${record.region ?? "manual"} profile=${
|
|
record.profile ?? "default-chain"
|
|
}`
|
|
: " Providers reset to shipped fallback bytes",
|
|
],
|
|
notes: record
|
|
? pendingProviderIssues(record).map((issue) => `${issue.id}: ${issue.message}`)
|
|
: [],
|
|
};
|
|
}
|
|
return {
|
|
lines: [
|
|
next
|
|
? " Trust recorded allowlist review acknowledgement"
|
|
: " Trust reset recorded acknowledgement",
|
|
],
|
|
notes: ["Trust seeds and permission rules were not regenerated or modified."],
|
|
};
|
|
}
|
|
|
|
function configCompletionMessage(
|
|
base: string,
|
|
actions: readonly ConfigOutstandingAction[],
|
|
mode: "human" | "quiet" | "json",
|
|
): string {
|
|
if (actions.length === 0 || mode === "json") return base;
|
|
if (mode === "quiet") {
|
|
const commands = [...new Set(actions.map((action) => action.command))];
|
|
return `${base}\nOutstanding actions: ${commands.join("; ")}`;
|
|
}
|
|
return [
|
|
base,
|
|
"Outstanding actions:",
|
|
...actions.map((action) =>
|
|
` ${action.section}/${action.id}: ${action.message} - run \`${action.command}\``
|
|
),
|
|
].join("\n");
|
|
}
|
|
|
|
// Test-only interactivity seam. It is read at call time so one test process can
|
|
// exercise TTY and non-TTY branches with piped answers. Production behavior is
|
|
// unchanged unless the explicitly test-named variable is set.
|
|
function configInputIsTty(): boolean {
|
|
return Boolean(
|
|
process.stdin.isTTY ||
|
|
process.env.AIDLC_TEST_CONFIG_TTY === "1",
|
|
);
|
|
}
|
|
|
|
function configCommand(args = ""): string {
|
|
return `${aidlcInvocation()} config${args ? ` ${args}` : ""}`;
|
|
}
|
|
|
|
function commandToken(value: string): string {
|
|
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value)
|
|
? value
|
|
: JSON.stringify(value);
|
|
}
|
|
|
|
function configMutationRerun(
|
|
section: "models" | "runtime" | "providers" | "trust" | "flags" | "project",
|
|
argv: readonly string[],
|
|
): string {
|
|
const args = argv.filter((arg) => arg !== "--yes").map(commandToken);
|
|
return configCommand([section, ...args, "--yes"].join(" "));
|
|
}
|
|
|
|
function configCommandForHarness(harnessDir: string, args = ""): string {
|
|
const invoke = aidlcInvocation() === "aidlc"
|
|
? "aidlc"
|
|
: `bun ${harnessDir}/tools/aidlc.ts`;
|
|
return `${invoke} config${args ? ` ${args}` : ""}`;
|
|
}
|
|
|
|
let scriptedPromptAnswers: string[] | null = null;
|
|
|
|
function configPrompt(label: string): string | null {
|
|
if (
|
|
process.env.AIDLC_TEST_CONFIG_TTY === "1" &&
|
|
!process.stdin.isTTY
|
|
) {
|
|
if (scriptedPromptAnswers === null) {
|
|
const scripted = process.env.AIDLC_TEST_CONFIG_INPUT ??
|
|
readFileSync(0, "utf-8");
|
|
scriptedPromptAnswers = scripted.split(/\r?\n/);
|
|
if (scriptedPromptAnswers.at(-1) === "") scriptedPromptAnswers.pop();
|
|
}
|
|
process.stdout.write(`${label} `);
|
|
return scriptedPromptAnswers.shift() ?? null;
|
|
}
|
|
return readTerminalLine(label);
|
|
}
|
|
|
|
type SetupMapRow = {
|
|
label: string;
|
|
detail: string;
|
|
section?: SetupWalkSection;
|
|
needs: boolean;
|
|
};
|
|
|
|
function setupMapRows(
|
|
projectDir: string,
|
|
harnessDir: string,
|
|
distribution: string,
|
|
outstanding: readonly ConfigOutstandingAction[],
|
|
): SetupMapRow[] {
|
|
const root = join(projectDir, harnessDir);
|
|
const records = readConfigDiagnosticRecords(root);
|
|
const resolved = resolveAidlcSettings(projectDir);
|
|
const policy = modelPolicyForHarness(
|
|
resolved.models,
|
|
modelHarness(distribution),
|
|
);
|
|
const runtime = outstanding.filter((action) => action.section === "runtime");
|
|
const trust = outstanding.filter((action) => action.section === "trust");
|
|
const providers = outstanding.filter((action) => action.section === "providers");
|
|
const providerNeeds = records.providers === null || providers.length > 0;
|
|
const modelDetail = !policy || modelPolicyIsEmpty(policy)
|
|
? "shipped defaults"
|
|
: policy.preset
|
|
? `preset ${policy.preset}`
|
|
: "recorded project policy";
|
|
const flagDetail = resolved.flags
|
|
? `scope ${resolved.flags.defaultScope ?? "inherit"}, swarm ${
|
|
resolved.flags.swarm === undefined ? "inherit" : resolved.flags.swarm ? "on" : "off"
|
|
}`
|
|
: "defaults";
|
|
const plugins = readPluginSelection(root);
|
|
const pluginDetail = plugins === null
|
|
? "all installed"
|
|
: plugins.length > 0
|
|
? plugins.join(",")
|
|
: "none";
|
|
const projectDetail =
|
|
`plugins: ${pluginDetail}, MCP: ${records.project?.mcp ?? "none"}, ` +
|
|
`completions: ${records.project?.completions ?? "none"}`;
|
|
const trustDetail = trust.length > 0
|
|
? `${trust.length} host trust issue${trust.length === 1 ? "" : "s"}`
|
|
:
|
|
(records.trust?.reviewed ? "review acknowledged" : "no unmet host trust");
|
|
const providerDetail = records.providers === null
|
|
? "no recorded answers; provider access unverified"
|
|
: providers.length > 0
|
|
? `${providers.length} pending provider action${providers.length === 1 ? "" : "s"}`
|
|
:
|
|
`${records.providers.provider ?? "shipped fallback"}; no pending actions`;
|
|
return [
|
|
{
|
|
label: "Harnesses",
|
|
detail: `${distribution} recorded`,
|
|
needs: false,
|
|
},
|
|
{
|
|
label: "Models",
|
|
detail: modelDetail,
|
|
needs: false,
|
|
},
|
|
{
|
|
label: "Runtime",
|
|
detail: runtime.length > 0
|
|
? runtime[0].message
|
|
: "hook PATH ready",
|
|
section: "runtime",
|
|
needs: runtime.length > 0,
|
|
},
|
|
{
|
|
label: "Flags",
|
|
detail: flagDetail,
|
|
needs: false,
|
|
},
|
|
{
|
|
label: "Project",
|
|
detail: projectDetail,
|
|
needs: false,
|
|
},
|
|
{
|
|
label: "Providers",
|
|
detail: providerDetail,
|
|
section: "providers",
|
|
needs: providerNeeds,
|
|
},
|
|
{
|
|
label: "Trust",
|
|
detail: trustDetail,
|
|
section: "trust",
|
|
needs: trust.length > 0,
|
|
},
|
|
];
|
|
}
|
|
|
|
function renderSetupMap(rows: readonly SetupMapRow[]): SetupWalkSection[] {
|
|
const needed = rows.filter((row) => row.needs);
|
|
process.stdout.write(
|
|
`\n Setup check - ${needed.length} of ${rows.length} sections need you.\n\n`,
|
|
);
|
|
for (const row of rows) {
|
|
const state = row.needs ? "[needs]" : "[ok]";
|
|
const renderedState = row.needs
|
|
? warnVerdict(state.padEnd(7), process.stdout)
|
|
: state.padEnd(7);
|
|
process.stdout.write(
|
|
` ${renderedState} ${row.label.padEnd(11)} ${row.detail}\n`,
|
|
);
|
|
}
|
|
const order: SetupWalkSection[] = ["runtime", "providers", "trust"];
|
|
const flagged = new Set(
|
|
needed.map((row) => row.section).filter(
|
|
(section): section is SetupWalkSection => section !== undefined,
|
|
),
|
|
);
|
|
return order.filter((section) => flagged.has(section));
|
|
}
|
|
|
|
function renderSetupLedger(
|
|
actions: readonly ConfigOutstandingAction[],
|
|
): void {
|
|
process.stdout.write(
|
|
`\n Setup complete. ${actions.length} action${
|
|
actions.length === 1 ? "" : "s"
|
|
} still need${actions.length === 1 ? "s" : ""} you\n`,
|
|
);
|
|
for (const action of actions) {
|
|
process.stdout.write(
|
|
` ${action.section.padEnd(12)} ${action.command}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function setupLedgerActions(
|
|
projectDir: string,
|
|
harnessDir: string,
|
|
actions: readonly ConfigOutstandingAction[],
|
|
): ConfigOutstandingAction[] {
|
|
const next = [...actions];
|
|
if (next.some((action) => action.section === "providers")) return next;
|
|
try {
|
|
const record = readConfigDiagnosticRecords(
|
|
join(projectDir, harnessDir),
|
|
).providers;
|
|
if (record === null) {
|
|
next.push({
|
|
section: "providers",
|
|
id: "provider-record-missing",
|
|
message: "Choose and configure a model provider, then record the completed setup.",
|
|
command: configCommandForHarness(harnessDir, "providers"),
|
|
});
|
|
}
|
|
} catch {
|
|
// The shared outstanding-action collector already reports unreadable data.
|
|
}
|
|
return next;
|
|
}
|
|
|
|
async function runSetupWalk(
|
|
projectDir: string,
|
|
harnessDir: string,
|
|
distribution: string,
|
|
initialOutstanding: readonly ConfigOutstandingAction[],
|
|
): Promise<void> {
|
|
const initialLedger = setupLedgerActions(
|
|
projectDir,
|
|
harnessDir,
|
|
initialOutstanding,
|
|
);
|
|
const flagged = renderSetupMap(
|
|
setupMapRows(
|
|
projectDir,
|
|
harnessDir,
|
|
distribution,
|
|
initialOutstanding,
|
|
),
|
|
);
|
|
if (flagged.length === 0) {
|
|
if (initialLedger.length > 0) {
|
|
renderSetupLedger(initialLedger);
|
|
}
|
|
return;
|
|
}
|
|
const answer = promptYesDefault(
|
|
`\n Fix the ${flagged.length} sections that need you now?`,
|
|
true,
|
|
);
|
|
if (!answer) {
|
|
renderSetupLedger(initialLedger);
|
|
return;
|
|
}
|
|
for (const section of flagged) {
|
|
await main(
|
|
[
|
|
"config",
|
|
section,
|
|
"--project-dir",
|
|
projectDir,
|
|
"--harness",
|
|
distribution,
|
|
"--yes",
|
|
],
|
|
{
|
|
setupWalkChild: true,
|
|
},
|
|
);
|
|
if ((process.exitCode ?? EXIT.ok) !== EXIT.ok) {
|
|
process.stdout.write(
|
|
`\n Setup stopped while configuring ${section}. Completed answers remain recorded; rerun ${
|
|
configCommandForHarness(harnessDir)
|
|
} to continue.\n`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
const remaining = setupLedgerActions(
|
|
projectDir,
|
|
harnessDir,
|
|
postApplyOutstandingActions(
|
|
projectDir,
|
|
harnessDir,
|
|
modelHarness(distribution),
|
|
),
|
|
);
|
|
renderSetupLedger(remaining);
|
|
}
|
|
|
|
function prepareDiagnosticSection(
|
|
section: DiagnosticSection,
|
|
argv: string[],
|
|
options: ReturnType<typeof globalOptions>,
|
|
): { argv: string[]; context: DiagnosticsMutationContext } | null {
|
|
const validation = validateDiagnosticArgs(section, argv);
|
|
if (validation) {
|
|
emitResult(usage(validation, configCommand(`${section} --help`)), options);
|
|
return null;
|
|
}
|
|
if (argv.includes("--help")) {
|
|
process.stdout.write(`${diagnosticHelp(section)}\n`);
|
|
process.exitCode = EXIT.ok;
|
|
return null;
|
|
}
|
|
const mutationFlags = section === "runtime"
|
|
? ["--record-paths", "--reset"]
|
|
: section === "providers"
|
|
? [
|
|
"--acknowledge",
|
|
"--mark-done",
|
|
"--opencode-default",
|
|
"--profile",
|
|
"--provider",
|
|
"--region",
|
|
"--reset",
|
|
]
|
|
: ["--acknowledge", "--reset"];
|
|
const hasMutationFlags = mutationFlags.some((flag) => argv.includes(flag));
|
|
if (
|
|
(argv.includes("--show") || argv.includes("--check")) &&
|
|
(hasMutationFlags || argv.includes("--dry-run") || argv.includes("--yes"))
|
|
) {
|
|
emitResult(
|
|
usage(`--show and --check cannot be combined with ${section} mutations`),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
if (argv.includes("--show") && argv.includes("--check")) {
|
|
emitResult(usage("--show and --check are mutually exclusive"), options);
|
|
return null;
|
|
}
|
|
const projectDir = projectDirFrom(argv);
|
|
const selected = selectedDiagnosticHarness(
|
|
projectDir,
|
|
valueAfter(argv, "--harness"),
|
|
section,
|
|
);
|
|
const records = readConfigDiagnosticRecords(selected.root);
|
|
if (argv.includes("--show")) {
|
|
showDiagnosticSection(section, projectDir, selected, records, options);
|
|
return null;
|
|
}
|
|
if (argv.includes("--check")) {
|
|
checkDiagnosticSection(section, projectDir, selected, records, options);
|
|
return null;
|
|
}
|
|
let next: RuntimeRecord | ProvidersRecord | TrustRecord | null;
|
|
if (argv.includes("--reset")) {
|
|
const conflicting = mutationFlags.find((flag) => flag !== "--reset" && argv.includes(flag));
|
|
if (conflicting) throw new Error(`--reset cannot be combined with ${conflicting}`);
|
|
next = null;
|
|
} else if (hasMutationFlags) {
|
|
if (section === "runtime") {
|
|
next = runtimeRecordFromProbe(projectDir, selected);
|
|
} else if (section === "providers") {
|
|
next = providerRecordFromArgs(records.providers, argv, selected);
|
|
} else {
|
|
next = { schemaVersion: 1, reviewed: true };
|
|
}
|
|
} else {
|
|
if (!configInputIsTty()) {
|
|
const flags = section === "runtime"
|
|
? "--show, --check, --record-paths, or --reset"
|
|
: section === "providers"
|
|
? "--show, --check, --provider with its required answers, --mark-done, or --reset"
|
|
: "--show, --check, --acknowledge, or --reset";
|
|
emitResult(
|
|
usage(
|
|
`non-interactive ${section} configuration requires ${flags}; --yes confirms but never chooses`,
|
|
configCommand(`${section} --help`),
|
|
),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
next = diagnosticWizard(section, projectDir, selected, records, options);
|
|
}
|
|
const previous = currentDiagnosticRecord(records, section);
|
|
if (canonical(previous) === canonical(next)) {
|
|
emitResult(success(`${section} configuration unchanged`), options);
|
|
return null;
|
|
}
|
|
if (!argv.includes("--dry-run") && !options.yes) {
|
|
if (!configInputIsTty()) {
|
|
emitResult(
|
|
usage(
|
|
`non-interactive ${section} mutation requires --yes; --yes confirms but never chooses`,
|
|
configMutationRerun(section, argv),
|
|
),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
const answer = configPrompt(`Apply ${section} configuration changes? [y/N]:`);
|
|
if (!answer || !/^y(?:es)?$/i.test(answer.trim())) {
|
|
emitResult(usage(`${section} configuration change cancelled`), options);
|
|
return null;
|
|
}
|
|
}
|
|
const summary = diagnosticSummary(section, next);
|
|
return {
|
|
argv: diagnosticPipelineArgv(argv),
|
|
context: {
|
|
section,
|
|
harness: selected.harness,
|
|
harnessDir: selected.harnessDir,
|
|
previous,
|
|
next,
|
|
overrides: diagnosticOverrides(section, next),
|
|
summaryLines: summary.lines,
|
|
notes: summary.notes,
|
|
},
|
|
};
|
|
}
|
|
|
|
function validateChoiceArgs(
|
|
section: ChoiceSection,
|
|
argv: readonly string[],
|
|
): string | null {
|
|
const values = section === "flags"
|
|
? new Set([
|
|
"--bypass",
|
|
"--clear-bypass",
|
|
"--default-scope",
|
|
"--harness",
|
|
"--hook-debug",
|
|
"--plan-token",
|
|
"--project-dir",
|
|
"--sensor-timeout-ms",
|
|
"--swarm",
|
|
])
|
|
: new Set([
|
|
"--completions",
|
|
"--harness",
|
|
"--mcp",
|
|
"--plan-token",
|
|
"--plugins",
|
|
"--project-dir",
|
|
]);
|
|
const bare = section === "flags"
|
|
? CHOICE_BARE_FLAGS
|
|
: new Set(
|
|
[...CHOICE_BARE_FLAGS].filter((flag) =>
|
|
flag !== "--local" && flag !== "--project" && flag !== "--global"
|
|
),
|
|
);
|
|
const grammar = validateConfigOptionGrammar(argv, section, {
|
|
values,
|
|
bare,
|
|
repeatable: section === "flags"
|
|
? new Set(["--bypass", "--clear-bypass"])
|
|
: undefined,
|
|
invalidKnownMessage: (flag) => `${flag} is not valid for config ${section}`,
|
|
});
|
|
if (grammar) return grammar;
|
|
const mutationFlags = section === "flags"
|
|
? [
|
|
"--bypass",
|
|
"--clear-bypass",
|
|
"--default-scope",
|
|
"--hook-debug",
|
|
"--reset",
|
|
"--sensor-timeout-ms",
|
|
"--swarm",
|
|
]
|
|
: ["--completions", "--mcp", "--plugins", "--reset"];
|
|
return validateConfigMutationModes(argv, section, mutationFlags) ??
|
|
(section === "flags" ? validateSettingsTargets(argv) : null);
|
|
}
|
|
|
|
function choiceHelp(section: ChoiceSection): string {
|
|
const invoke = aidlcInvocation();
|
|
const out = process.stdout;
|
|
const specific = section === "flags"
|
|
? [
|
|
heading("Recorded flags:", out),
|
|
" --default-scope <installed-scope>",
|
|
" --swarm <on|off>",
|
|
" --hook-debug <on|off>",
|
|
" --sensor-timeout-ms <positive-integer>",
|
|
" --bypass <AIDLC_SKIP_*|AIDLC_DISABLE_*>",
|
|
" --clear-bypass <AIDLC_SKIP_*|AIDLC_DISABLE_*>",
|
|
"",
|
|
"Environment variables always override recorded answers.",
|
|
"Bypasses weaken deterministic guards and are accepted only through explicit --bypass flags.",
|
|
"",
|
|
heading("Write target (required for mutations):", out),
|
|
" --project committed team policy (recommended in a repository)",
|
|
" --local personal project policy in aidlc.settings.local.json",
|
|
" --global machine policy in the install-root aidlc.settings.json",
|
|
"Outside an installed project, --global is the only valid target and is inferred.",
|
|
]
|
|
: [
|
|
heading("Project choices:", out),
|
|
" --plugins <comma-separated-installed-names|all>",
|
|
" --mcp <defaults|none>",
|
|
" --completions <bash|zsh|fish|powershell|none>",
|
|
"",
|
|
"--yes confirms but never implies MCP consent. Without an explicit answer, MCP consent records none.",
|
|
];
|
|
return [
|
|
section === "flags"
|
|
? "Record project defaults and explicit guard bypasses"
|
|
: "Choose plugins, MCP servers, and shell completions",
|
|
"",
|
|
heading("USAGE", out),
|
|
` ${cmd(`${invoke} config ${section} [flags]`, out)}`,
|
|
"",
|
|
...specific,
|
|
"",
|
|
heading("INSPECTION", out),
|
|
" --show [--json]",
|
|
" --check",
|
|
"",
|
|
heading("MUTATION CONTROL", out),
|
|
" --reset",
|
|
" --dry-run",
|
|
" --yes",
|
|
"",
|
|
heading("EXAMPLE", out),
|
|
` ${cmd(`${invoke} config ${section} --show`, out)}`,
|
|
"",
|
|
dim(
|
|
`Run '${invoke} config ${section} --check' for a non-writing verification.`,
|
|
out,
|
|
),
|
|
].join("\n");
|
|
}
|
|
|
|
function choicePipelineArgv(argv: readonly string[]): string[] {
|
|
const out: string[] = [];
|
|
const keptValues = new Set(["--harness", "--plan-token", "--project-dir"]);
|
|
const keptBare = new Set([
|
|
"--dry-run",
|
|
"--json",
|
|
"--no-color",
|
|
"--quiet",
|
|
"--verbose",
|
|
"--yes",
|
|
]);
|
|
for (let index = 0; index < argv.length; index++) {
|
|
const token = argv[index];
|
|
if (keptValues.has(token)) {
|
|
out.push(token, argv[++index]);
|
|
} else if (keptBare.has(token)) {
|
|
out.push(token);
|
|
} else if (CHOICE_VALUE_FLAGS.has(token)) {
|
|
index++;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function parseOnOff(value: string | undefined, flag: string): boolean | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (value !== "on" && value !== "off") {
|
|
throw new Error(`${flag} must be on or off`);
|
|
}
|
|
return value === "on";
|
|
}
|
|
|
|
function buildFlagsRecord(
|
|
current: ProjectFlagsRecord | null,
|
|
argv: readonly string[],
|
|
harnessRoot: string,
|
|
): ProjectFlagsRecord {
|
|
const next: ProjectFlagsRecord = cloneDiagnosticRecord(current) ?? {
|
|
schemaVersion: 1,
|
|
};
|
|
const defaultScope = valueAfter(argv, "--default-scope");
|
|
if (defaultScope) {
|
|
const scopes = availableScopeNames(harnessRoot);
|
|
if (!scopes.includes(defaultScope)) {
|
|
throw new Error(
|
|
`--default-scope must be one of the installed scopes: ${scopes.join(", ")}`,
|
|
);
|
|
}
|
|
next.defaultScope = defaultScope;
|
|
}
|
|
const swarm = parseOnOff(valueAfter(argv, "--swarm"), "--swarm");
|
|
if (swarm !== undefined) next.swarm = swarm;
|
|
const hookDebug = parseOnOff(valueAfter(argv, "--hook-debug"), "--hook-debug");
|
|
if (hookDebug !== undefined) next.hookDebug = hookDebug;
|
|
const timeout = valueAfter(argv, "--sensor-timeout-ms");
|
|
if (timeout !== undefined) {
|
|
const parsed = Number(timeout);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
throw new Error("--sensor-timeout-ms must be a positive integer");
|
|
}
|
|
next.sensorTimeoutMs = parsed;
|
|
}
|
|
const bypasses = new Set(next.bypasses ?? []);
|
|
for (const name of valuesAfter(argv, "--bypass")) {
|
|
if (!(RECORDABLE_PROJECT_BYPASSES as readonly string[]).includes(name)) {
|
|
throw new Error(
|
|
`--bypass must be one of ${RECORDABLE_PROJECT_BYPASSES.join(", ")}`,
|
|
);
|
|
}
|
|
bypasses.add(name as (typeof RECORDABLE_PROJECT_BYPASSES)[number]);
|
|
}
|
|
for (const name of valuesAfter(argv, "--clear-bypass")) {
|
|
if (!(RECORDABLE_PROJECT_BYPASSES as readonly string[]).includes(name)) {
|
|
throw new Error(
|
|
`--clear-bypass must be one of ${RECORDABLE_PROJECT_BYPASSES.join(", ")}`,
|
|
);
|
|
}
|
|
bypasses.delete(name as (typeof RECORDABLE_PROJECT_BYPASSES)[number]);
|
|
}
|
|
if (bypasses.size > 0) next.bypasses = [...bypasses].sort();
|
|
else delete next.bypasses;
|
|
return normalizeProjectFlagsRecord(next) as ProjectFlagsRecord;
|
|
}
|
|
|
|
function parsePluginAnswer(
|
|
value: string | undefined,
|
|
known: readonly string[],
|
|
current: string[] | null,
|
|
): string[] | null {
|
|
if (value === undefined) return current;
|
|
if (value === "all") return null;
|
|
const names = [...new Set(value.split(",").map((name) => name.trim()).filter(Boolean))]
|
|
.sort();
|
|
if (names.length === 0) throw new Error("--plugins requires at least one name or all");
|
|
const knownSet = new Set(known);
|
|
const unknown = names.filter((name) => !knownSet.has(name));
|
|
if (unknown.length > 0) {
|
|
throw new Error(
|
|
`unknown plugin name(s): ${unknown.join(", ")}; installed plugins: ${known.join(", ")}`,
|
|
);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function buildProjectRecord(
|
|
current: ProjectChoicesRecord | null,
|
|
currentPlugins: string[] | null,
|
|
argv: readonly string[],
|
|
selected: ReturnType<typeof selectedDiagnosticHarness>,
|
|
): {
|
|
record: ProjectChoicesRecord;
|
|
plugins: string[] | null;
|
|
} {
|
|
const next: ProjectChoicesRecord = cloneDiagnosticRecord(current) ?? {
|
|
schemaVersion: 1,
|
|
};
|
|
const mcp = valueAfter(argv, "--mcp");
|
|
if (mcp !== undefined && mcp !== "defaults" && mcp !== "none") {
|
|
throw new Error("--mcp must be defaults or none");
|
|
}
|
|
next.mcp = (mcp as "defaults" | "none" | undefined) ?? next.mcp ?? "none";
|
|
const completions = valueAfter(argv, "--completions");
|
|
if (
|
|
completions !== undefined &&
|
|
!["bash", "zsh", "fish", "powershell", "none"].includes(completions)
|
|
) {
|
|
throw new Error(
|
|
"--completions must be bash, zsh, fish, powershell, or none",
|
|
);
|
|
}
|
|
if (completions) next.completions = completions as CompletionShell;
|
|
const known = discoverInstalledPluginNames(
|
|
dirname(selected.root),
|
|
selected.harnessDir,
|
|
);
|
|
const plugins = parsePluginAnswer(
|
|
valueAfter(argv, "--plugins"),
|
|
known,
|
|
currentPlugins,
|
|
);
|
|
return {
|
|
record: normalizeProjectChoicesRecord(next) as ProjectChoicesRecord,
|
|
plugins,
|
|
};
|
|
}
|
|
|
|
function showChoiceSection(
|
|
section: ChoiceSection,
|
|
projectDir: string,
|
|
selected: ReturnType<typeof selectedDiagnosticHarness>,
|
|
records: ConfigDiagnosticRecords,
|
|
resolved: ResolvedAidlcSettings,
|
|
options: ReturnType<typeof globalOptions>,
|
|
): void {
|
|
const plugins = readPluginSelection(selected.root);
|
|
let data: Record<string, unknown>;
|
|
if (section === "flags") {
|
|
const effective = effectiveProjectFlagValues(resolved.flags);
|
|
const sources = Object.fromEntries([
|
|
["AWS_AIDLC_DEFAULT_SCOPE", "defaultScope"],
|
|
["AIDLC_USE_SWARM", "swarm"],
|
|
["AIDLC_HOOK_DEBUG", "hookDebug"],
|
|
["AIDLC_SENSOR_TIMEOUT_MS", "sensorTimeoutMs"],
|
|
].map(([envName, field]) => [
|
|
envName,
|
|
Object.hasOwn(process.env, envName)
|
|
? "env"
|
|
: settingsSource(resolved, `flags.${field}`),
|
|
]));
|
|
for (const bypass of RECORDABLE_PROJECT_BYPASSES) {
|
|
sources[bypass] = Object.hasOwn(process.env, bypass)
|
|
? "env"
|
|
: settingsSource(resolved, "flags.bypasses");
|
|
}
|
|
data = {
|
|
section,
|
|
harness: selected.harness,
|
|
record: resolved.flags,
|
|
effective,
|
|
sources,
|
|
issues: flagIssues(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
resolved.flags,
|
|
),
|
|
files: flagFiles(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
resolved.flags,
|
|
resolved,
|
|
),
|
|
};
|
|
} else {
|
|
const completion = records.project?.completions;
|
|
data = {
|
|
section,
|
|
harness: selected.harness,
|
|
record: records.project,
|
|
plugins,
|
|
installedPlugins: discoverInstalledPluginNames(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
),
|
|
completionInstruction:
|
|
completion && completion !== "none"
|
|
? completionInstruction(projectDir, selected.harnessDir, completion)
|
|
: null,
|
|
issues: projectChoiceIssues(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
records.project,
|
|
plugins,
|
|
),
|
|
files: projectChoiceFiles(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
),
|
|
mcpNote: projectMcpNote(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
records.project,
|
|
),
|
|
};
|
|
}
|
|
if (options.mode === "json") {
|
|
emitResult(success(`${section} configuration for ${selected.harness}`, data), options);
|
|
return;
|
|
}
|
|
const out = process.stdout;
|
|
let output = `${heading(
|
|
`${section[0].toUpperCase()}${section.slice(1)} configuration for ${selected.harness}`,
|
|
out,
|
|
)}\n`;
|
|
if (section === "flags") {
|
|
const sources = data.sources as Record<string, string>;
|
|
const effective = data.effective as Record<string, string | undefined>;
|
|
const sourceLabel = (name: string): string =>
|
|
dim(`[${sources[name]}]`, out);
|
|
const effectiveBoolean = (
|
|
envName: string,
|
|
recorded: boolean | undefined,
|
|
): string => {
|
|
if (sources[envName] !== "env") {
|
|
return recorded === undefined ? "inherit" : recorded ? "on" : "off";
|
|
}
|
|
const value = effective[envName]?.trim().toLowerCase();
|
|
return value === undefined
|
|
? "inherit"
|
|
: ["", "0", "false", "no", "off"].includes(value)
|
|
? "off"
|
|
: "on";
|
|
};
|
|
output += ` Default scope: ${
|
|
sources.AWS_AIDLC_DEFAULT_SCOPE === "env"
|
|
? effective.AWS_AIDLC_DEFAULT_SCOPE ?? "inherit"
|
|
: resolved.flags?.defaultScope ?? "inherit"
|
|
} ${sourceLabel("AWS_AIDLC_DEFAULT_SCOPE")}\n`;
|
|
output += ` Swarm: ${
|
|
effectiveBoolean("AIDLC_USE_SWARM", resolved.flags?.swarm)
|
|
} ${sourceLabel("AIDLC_USE_SWARM")}\n`;
|
|
output += ` Hook debug: ${
|
|
effectiveBoolean("AIDLC_HOOK_DEBUG", resolved.flags?.hookDebug)
|
|
} ${sourceLabel("AIDLC_HOOK_DEBUG")}\n`;
|
|
output += ` Sensor timeout: ${
|
|
sources.AIDLC_SENSOR_TIMEOUT_MS === "env"
|
|
? effective.AIDLC_SENSOR_TIMEOUT_MS ?? "inherit"
|
|
: resolved.flags?.sensorTimeoutMs ?? "inherit"
|
|
} ${sourceLabel("AIDLC_SENSOR_TIMEOUT_MS")}\n`;
|
|
for (const bypass of resolved.flags?.bypasses ?? []) {
|
|
output += ` Bypass enabled: ${bypass} ${sourceLabel(bypass)}\n`;
|
|
}
|
|
const files = data.files as ReturnType<typeof flagFiles>;
|
|
if (files.length === 0) {
|
|
output += " Files carrying flags: none\n";
|
|
} else {
|
|
output += " Files carrying flags:\n";
|
|
for (const entry of files) {
|
|
output += ` ${dim(entry.setting, out)}: ${entry.file}\n`;
|
|
}
|
|
}
|
|
for (const issue of data.issues as ReturnType<typeof flagIssues>) {
|
|
output += ` Override: ${issue.message}\n`;
|
|
}
|
|
} else {
|
|
output += ` Plugins: ${plugins === null ? "all installed" : plugins.join(", ")}\n`;
|
|
output += ` MCP consent: ${records.project?.mcp ?? "inherit"}\n`;
|
|
if (data.mcpNote) output += ` MCP note: ${data.mcpNote}\n`;
|
|
output += ` Completions: ${records.project?.completions ?? "not offered"}\n`;
|
|
if (data.completionInstruction) {
|
|
output += ` Install completions with: ${data.completionInstruction}\n`;
|
|
}
|
|
output += " Files carrying project choices:\n";
|
|
for (const entry of data.files as ReturnType<typeof projectChoiceFiles>) {
|
|
output += ` ${dim(entry.setting, out)}: ${entry.file}\n`;
|
|
}
|
|
}
|
|
process.stdout.write(output);
|
|
process.exitCode = EXIT.ok;
|
|
}
|
|
|
|
function checkChoiceSection(
|
|
section: ChoiceSection,
|
|
projectDir: string,
|
|
selected: ReturnType<typeof selectedDiagnosticHarness>,
|
|
records: ConfigDiagnosticRecords,
|
|
resolved: ResolvedAidlcSettings,
|
|
options: ReturnType<typeof globalOptions>,
|
|
): void {
|
|
const plugins = readPluginSelection(selected.root);
|
|
const issues = section === "flags"
|
|
? flagIssues(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
resolved.flags,
|
|
)
|
|
: projectChoiceIssues(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
selected.harness,
|
|
records.project,
|
|
plugins,
|
|
);
|
|
emitResult(
|
|
issues.length === 0
|
|
? success(`${section} configuration is clean for ${selected.harness}`, {
|
|
section,
|
|
harness: selected.harness,
|
|
issues: [],
|
|
})
|
|
: failure(
|
|
`${section} configuration has ${issues.length} unmet item(s): ${
|
|
issues.map((issue) => `${issue.id} (${issue.message})`).join("; ")
|
|
}`,
|
|
EXIT.failure,
|
|
configCommand(`${section} --show`),
|
|
),
|
|
options,
|
|
);
|
|
}
|
|
|
|
function choiceWizard(
|
|
section: ChoiceSection,
|
|
projectDir: string,
|
|
selected: ReturnType<typeof selectedDiagnosticHarness>,
|
|
records: ConfigDiagnosticRecords,
|
|
resolved: ResolvedAidlcSettings,
|
|
targetCurrentFlags: ProjectFlagsRecord | null,
|
|
options: ReturnType<typeof globalOptions>,
|
|
): {
|
|
next: ProjectFlagsRecord | ProjectChoicesRecord;
|
|
plugins: string[] | null;
|
|
} {
|
|
showChoiceSection(section, projectDir, selected, records, resolved, {
|
|
...options,
|
|
mode: "human",
|
|
});
|
|
if (section === "flags") {
|
|
const args: string[] = [];
|
|
const scopes = availableScopeNames(selected.root);
|
|
const scope = configPrompt(
|
|
`Default scope [${scopes.join("/")}, Enter keep]:`,
|
|
)?.trim();
|
|
if (scope) args.push("--default-scope", scope);
|
|
for (const [flag, label] of [
|
|
["--swarm", "Swarm"],
|
|
["--hook-debug", "Hook debug"],
|
|
] as const) {
|
|
const answer = configPrompt(`${label} [on/off, Enter keep]:`)?.trim();
|
|
if (answer) args.push(flag, answer);
|
|
}
|
|
const timeout = configPrompt("Sensor timeout ms [Enter keep]:")?.trim();
|
|
if (timeout) args.push("--sensor-timeout-ms", timeout);
|
|
return {
|
|
next: buildFlagsRecord(targetCurrentFlags, args, selected.root),
|
|
plugins: readPluginSelection(selected.root),
|
|
};
|
|
}
|
|
const known = discoverInstalledPluginNames(projectDir, selected.harnessDir);
|
|
const currentPlugins = readPluginSelection(selected.root);
|
|
const args: string[] = [];
|
|
const pluginAnswer = configPrompt(
|
|
`Enabled plugins [${known.join(",")}; all; Enter keep]:`,
|
|
)?.trim();
|
|
if (pluginAnswer) args.push("--plugins", pluginAnswer);
|
|
const currentMcp = records.project?.mcp ?? "none";
|
|
const mcp = configPrompt(`MCP consent [defaults/none, Enter ${currentMcp}]:`)?.trim();
|
|
if (mcp) args.push("--mcp", mcp);
|
|
const completions = configPrompt(
|
|
"Completions [bash/zsh/fish/powershell/none, Enter keep]:",
|
|
)?.trim();
|
|
if (completions) args.push("--completions", completions);
|
|
const built = buildProjectRecord(records.project, currentPlugins, args, selected);
|
|
return { next: built.record, plugins: built.plugins };
|
|
}
|
|
|
|
function choiceSummary(
|
|
section: ChoiceSection,
|
|
next: ProjectFlagsRecord | ProjectChoicesRecord | null,
|
|
plugins: string[] | null,
|
|
projectDir: string,
|
|
harnessDir: string,
|
|
): { lines: string[]; notes: string[] } {
|
|
if (section === "flags") {
|
|
const record = next as ProjectFlagsRecord | null;
|
|
return {
|
|
lines: [
|
|
record
|
|
? ` Flags default-scope=${record.defaultScope ?? "inherit"} swarm=${
|
|
record.swarm === undefined ? "inherit" : record.swarm ? "on" : "off"
|
|
}`
|
|
: " Flags reset to environment and shipped defaults",
|
|
],
|
|
notes: (record?.bypasses ?? []).map((name) =>
|
|
`${name} weakens a deterministic guard and is enabled only by explicit opt-in.`
|
|
),
|
|
};
|
|
}
|
|
const record = next as ProjectChoicesRecord | null;
|
|
const notes: string[] = [];
|
|
if (record?.completions && record.completions !== "none") {
|
|
notes.push(
|
|
`Install completions with: ${
|
|
completionInstruction(projectDir, harnessDir, record.completions)
|
|
}`,
|
|
);
|
|
}
|
|
return {
|
|
lines: [
|
|
record
|
|
? ` Project plugins=${plugins === null ? "all" : plugins.join(",")} mcp=${
|
|
record.mcp ?? "none"
|
|
} completions=${record.completions ?? "none"}`
|
|
: " Project reset to all plugins, no MCP consent, and no completion answer",
|
|
],
|
|
notes,
|
|
};
|
|
}
|
|
|
|
function prepareChoiceSection(
|
|
section: ChoiceSection,
|
|
argv: string[],
|
|
options: ReturnType<typeof globalOptions>,
|
|
): { argv: string[]; context: ChoicesMutationContext } | null {
|
|
const validation = validateChoiceArgs(section, argv);
|
|
if (validation) {
|
|
emitResult(usage(validation, configCommand(`${section} --help`)), options);
|
|
return null;
|
|
}
|
|
if (argv.includes("--help")) {
|
|
process.stdout.write(`${choiceHelp(section)}\n`);
|
|
process.exitCode = EXIT.ok;
|
|
return null;
|
|
}
|
|
const mutationFlags = section === "flags"
|
|
? [
|
|
"--bypass",
|
|
"--clear-bypass",
|
|
"--default-scope",
|
|
"--hook-debug",
|
|
"--reset",
|
|
"--sensor-timeout-ms",
|
|
"--swarm",
|
|
]
|
|
: ["--completions", "--mcp", "--plugins", "--reset"];
|
|
const hasMutationFlags = mutationFlags.some((flag) => argv.includes(flag));
|
|
if (
|
|
(argv.includes("--show") || argv.includes("--check")) &&
|
|
(hasMutationFlags || argv.includes("--dry-run") || argv.includes("--yes"))
|
|
) {
|
|
emitResult(
|
|
usage(`--show and --check cannot be combined with ${section} mutations`),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
if (argv.includes("--show") && argv.includes("--check")) {
|
|
emitResult(usage("--show and --check are mutually exclusive"), options);
|
|
return null;
|
|
}
|
|
const projectDir = projectDirFrom(argv);
|
|
const selected = selectedDiagnosticHarness(
|
|
projectDir,
|
|
valueAfter(argv, "--harness"),
|
|
section,
|
|
);
|
|
const records = readConfigDiagnosticRecords(selected.root);
|
|
const resolved = resolveAidlcSettings(projectDir);
|
|
if (argv.includes("--show")) {
|
|
showChoiceSection(section, projectDir, selected, records, resolved, options);
|
|
return null;
|
|
}
|
|
if (argv.includes("--check")) {
|
|
checkChoiceSection(section, projectDir, selected, records, resolved, options);
|
|
return null;
|
|
}
|
|
const previous = section === "flags" ? resolved.flags : records.project;
|
|
const previousPlugins = readPluginSelection(selected.root);
|
|
let next: ProjectFlagsRecord | ProjectChoicesRecord | null;
|
|
let nextPlugins = previousPlugins;
|
|
let mcpMode: "defaults" | "none" | undefined;
|
|
let settings: SettingsMutation | undefined;
|
|
const target = section === "flags" && (hasMutationFlags || configInputIsTty())
|
|
? settingsTargetForMutation(argv, projectDir)
|
|
: undefined;
|
|
const targetCurrentSettings = target
|
|
? readSettingsTarget(projectDir, target)
|
|
: null;
|
|
const targetCurrentFlags = targetCurrentSettings?.flags ?? null;
|
|
if (argv.includes("--reset")) {
|
|
const conflict = mutationFlags.find((flag) => flag !== "--reset" && argv.includes(flag));
|
|
if (conflict) throw new Error(`--reset cannot be combined with ${conflict}`);
|
|
next = null;
|
|
if (section === "project") {
|
|
nextPlugins = null;
|
|
mcpMode = "none";
|
|
}
|
|
} else if (hasMutationFlags) {
|
|
if (section === "flags") {
|
|
next = buildFlagsRecord(targetCurrentFlags, argv, selected.root);
|
|
} else {
|
|
const built = buildProjectRecord(
|
|
records.project,
|
|
previousPlugins,
|
|
argv,
|
|
selected,
|
|
);
|
|
next = built.record;
|
|
nextPlugins = built.plugins;
|
|
mcpMode = built.record.mcp;
|
|
}
|
|
} else {
|
|
if (!configInputIsTty()) {
|
|
const flags = section === "flags"
|
|
? "--default-scope, --swarm, --hook-debug, --sensor-timeout-ms, --bypass, or --reset"
|
|
: "--plugins, --mcp, --completions, or --reset";
|
|
emitResult(
|
|
usage(
|
|
`non-interactive ${section} configuration requires ${flags}; --yes confirms but never chooses`,
|
|
configCommand(`${section} --help`),
|
|
),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
const built = choiceWizard(
|
|
section,
|
|
projectDir,
|
|
selected,
|
|
records,
|
|
resolved,
|
|
targetCurrentFlags,
|
|
options,
|
|
);
|
|
next = built.next;
|
|
nextPlugins = built.plugins;
|
|
if (section === "project") {
|
|
mcpMode = (built.next as ProjectChoicesRecord).mcp;
|
|
}
|
|
}
|
|
if (section === "flags" && target) {
|
|
const nextSettings = updateSettingsSection(
|
|
targetCurrentSettings,
|
|
"flags",
|
|
next as ProjectFlagsRecord | null,
|
|
);
|
|
if (canonical(targetCurrentSettings) === canonical(nextSettings)) {
|
|
emitResult(success("flags configuration unchanged"), options);
|
|
return null;
|
|
}
|
|
const nextResolved = resolveAidlcSettingsWithOverride(
|
|
projectDir,
|
|
target,
|
|
nextSettings,
|
|
);
|
|
next = nextResolved.flags;
|
|
settings = {
|
|
target,
|
|
path: settingsPathForTarget(projectDir, target),
|
|
previous: targetCurrentSettings,
|
|
next: nextSettings,
|
|
};
|
|
}
|
|
if (
|
|
section !== "flags" &&
|
|
canonical(previous) === canonical(next) &&
|
|
canonical(previousPlugins) === canonical(nextPlugins)
|
|
) {
|
|
emitResult(success(`${section} configuration unchanged`), options);
|
|
return null;
|
|
}
|
|
if (!argv.includes("--dry-run") && !options.yes) {
|
|
if (!configInputIsTty()) {
|
|
emitResult(
|
|
usage(
|
|
`non-interactive ${section} mutation requires --yes; --yes confirms but never chooses`,
|
|
configMutationRerun(section, argv),
|
|
),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
const answer = configPrompt(`Apply ${section} configuration changes? [y/N]:`);
|
|
if (!answer || !/^y(?:es)?$/i.test(answer.trim())) {
|
|
emitResult(usage(`${section} configuration change cancelled`), options);
|
|
return null;
|
|
}
|
|
}
|
|
const summary = choiceSummary(
|
|
section,
|
|
next,
|
|
nextPlugins,
|
|
projectDir,
|
|
selected.harnessDir,
|
|
);
|
|
return {
|
|
argv: choicePipelineArgv(argv),
|
|
context: {
|
|
section,
|
|
harness: selected.harness,
|
|
harnessDir: selected.harnessDir,
|
|
previous,
|
|
next,
|
|
previousPlugins,
|
|
nextPlugins,
|
|
...(section === "project"
|
|
? { overrides: { project: next, plugins: nextPlugins } }
|
|
: {}),
|
|
...(mcpMode ? { mcpMode } : {}),
|
|
summaryLines: summary.lines,
|
|
notes: summary.notes,
|
|
...(settings ? { settings } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function stripVerb(argv: string[]): string[] {
|
|
return argv[0] === "config" || argv[0] === "init" ? argv.slice(1) : argv;
|
|
}
|
|
|
|
function readBaseline(path: string): Baseline | null {
|
|
if (!pathPresent(path)) return null;
|
|
if (!regularFile(path)) throw new Error(`cannot refresh from ${path}: baseline is not a regular file`);
|
|
try {
|
|
const value = JSON.parse(readFileSync(path, "utf-8")) as Baseline;
|
|
if (value.schemaVersion !== 1) throw new Error(`unsupported schema ${value.schemaVersion}`);
|
|
return value;
|
|
} catch (error) {
|
|
throw new Error(`cannot refresh from ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
function canonical(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
if (value && typeof value === "object") {
|
|
const object = value as Record<string, unknown>;
|
|
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function expected(path: string): string | "absent" {
|
|
return transactionState(path);
|
|
}
|
|
|
|
function pathPresent(path: string): boolean {
|
|
try {
|
|
lstatSync(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function regularFile(path: string): boolean {
|
|
return pathPresent(path) && lstatSync(path).isFile();
|
|
}
|
|
|
|
function planProjectSettingsMutation(
|
|
projectDir: string,
|
|
mutation: SettingsMutation | undefined,
|
|
operations: TransactionOperation[],
|
|
actions: PlannedAction[],
|
|
): void {
|
|
if (!mutation || mutation.target === "global") return;
|
|
const rel = relative(projectDir, mutation.path);
|
|
if (mutation.next === null) {
|
|
if (!pathPresent(mutation.path)) return;
|
|
operations.push({ kind: "remove", path: rel, expected: expected(mutation.path) });
|
|
actions.push({ path: rel, action: "remove" });
|
|
return;
|
|
}
|
|
const creating = !pathPresent(mutation.path);
|
|
operations.push(writeOperation(
|
|
rel,
|
|
serializeAidlcSettings(mutation.next),
|
|
expected(mutation.path),
|
|
));
|
|
actions.push({ path: rel, action: creating ? "create" : "update" });
|
|
if (mutation.target !== "local" || !creating) return;
|
|
const gitignorePath = join(projectDir, ".gitignore");
|
|
const current = regularFile(gitignorePath)
|
|
? readFileSync(gitignorePath, "utf-8")
|
|
: "";
|
|
const lines = current.split(/\r?\n/);
|
|
if (lines.includes(LOCAL_SETTINGS_FILE)) return;
|
|
const separator = current.length === 0 || current.endsWith("\n") ? "" : "\n";
|
|
const next = `${current}${separator}${LOCAL_SETTINGS_FILE}\n`;
|
|
operations.push(writeOperation(
|
|
".gitignore",
|
|
next,
|
|
expected(gitignorePath),
|
|
));
|
|
actions.push({
|
|
path: ".gitignore",
|
|
action: regularFile(gitignorePath) ? "update" : "create",
|
|
detail: `ignore ${LOCAL_SETTINGS_FILE}`,
|
|
});
|
|
}
|
|
|
|
function globalSettingsOperation(
|
|
mutation: SettingsMutation | undefined,
|
|
): TransactionOperation | null {
|
|
if (mutation?.target !== "global") return null;
|
|
const root = machineTransactionRoot();
|
|
const rel = relative(root, mutation.path);
|
|
if (mutation.next === null) {
|
|
return pathPresent(mutation.path)
|
|
? { kind: "remove", path: rel, expected: expected(mutation.path) }
|
|
: null;
|
|
}
|
|
return writeOperation(
|
|
rel,
|
|
serializeAidlcSettings(mutation.next),
|
|
expected(mutation.path),
|
|
0o600,
|
|
);
|
|
}
|
|
|
|
function executeGlobalSettingsMutation(
|
|
mutation: SettingsMutation | undefined,
|
|
): void {
|
|
const operation = globalSettingsOperation(mutation);
|
|
if (!operation || !mutation) return;
|
|
executePlan({
|
|
schemaVersion: 1,
|
|
root: machineTransactionRoot(),
|
|
operations: [operation],
|
|
});
|
|
invalidateSettingsCache(mutation.path);
|
|
}
|
|
|
|
function executeSettingsAndProjectMutation(
|
|
mutation: SettingsMutation | undefined,
|
|
projectPlan: TransactionPlan,
|
|
): void {
|
|
const operation = globalSettingsOperation(mutation);
|
|
if (!operation || !mutation) {
|
|
executePlan(projectPlan);
|
|
return;
|
|
}
|
|
const machinePlan: TransactionPlan = {
|
|
schemaVersion: 1,
|
|
root: machineTransactionRoot(),
|
|
operations: [operation],
|
|
};
|
|
validateTransactionPlan(machinePlan);
|
|
validateTransactionPlan(projectPlan);
|
|
const priorPresent = pathPresent(mutation.path);
|
|
const priorBytes = priorPresent ? readFileSync(mutation.path) : null;
|
|
const priorMode = priorPresent ? lstatSync(mutation.path).mode & 0o777 : undefined;
|
|
const committed = operation.kind === "remove"
|
|
? "absent"
|
|
: operation.kind === "write"
|
|
? sha256Bytes(Buffer.from(operation.data, "base64"))
|
|
: transactionState(mutation.path);
|
|
executePlan(machinePlan, {
|
|
validateLocked: () => {
|
|
const baseline =
|
|
process.env.AIDLC_FIRST_RUN_GLOBAL_BASELINE_STATE;
|
|
if (
|
|
baseline !== undefined &&
|
|
transactionState(mutation.path) !== baseline
|
|
) {
|
|
throw new Error(
|
|
"global settings changed while first-run setup was preparing the mutation",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
invalidateSettingsCache(mutation.path);
|
|
const rollbackInterference =
|
|
process.env.AIDLC_TEST_SETTINGS_ROLLBACK_INTERFERENCE;
|
|
if (rollbackInterference !== undefined) {
|
|
writeFileSync(mutation.path, rollbackInterference);
|
|
invalidateSettingsCache(mutation.path);
|
|
}
|
|
try {
|
|
executePlan(projectPlan);
|
|
} catch (error) {
|
|
const restoreOperations: TransactionOperation[] = priorBytes === null
|
|
? committed === "absent"
|
|
? []
|
|
: [{
|
|
kind: "remove",
|
|
path: relative(machinePlan.root, mutation.path),
|
|
expected: committed,
|
|
}]
|
|
: [writeOperation(
|
|
relative(machinePlan.root, mutation.path),
|
|
priorBytes.toString("utf-8"),
|
|
committed,
|
|
priorMode,
|
|
)];
|
|
try {
|
|
executePlan({
|
|
schemaVersion: 1,
|
|
root: machinePlan.root,
|
|
operations: restoreOperations,
|
|
});
|
|
invalidateSettingsCache(mutation.path);
|
|
} catch (rollbackError) {
|
|
throw new AggregateError(
|
|
[error, rollbackError],
|
|
"project configuration failed and global settings rollback was incomplete",
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function regularFilesBelow(root: string): string[] {
|
|
const files: string[] = [];
|
|
const visit = (directory: string): void => {
|
|
for (const entry of readdirSync(directory).sort()) {
|
|
const path = join(directory, entry);
|
|
const stat = lstatSync(path);
|
|
if (stat.isDirectory()) visit(path);
|
|
else if (stat.isFile()) files.push(relative(root, path));
|
|
}
|
|
};
|
|
visit(root);
|
|
return files;
|
|
}
|
|
|
|
function runtimeGenerated(
|
|
rel: string,
|
|
harnessDir: string,
|
|
regenerated: ReadonlySet<string>,
|
|
): boolean {
|
|
const normalized = rel.replaceAll("\\", "/");
|
|
return regenerated.has(normalized) || [
|
|
`${harnessDir}/tools/data/harness.json`,
|
|
`${harnessDir}/tools/data/stage-graph.json`,
|
|
`${harnessDir}/tools/data/scope-grid.json`,
|
|
].includes(normalized);
|
|
}
|
|
|
|
type StageContribRecord = {
|
|
produces?: string[];
|
|
sensors?: string[];
|
|
consumes?: string[];
|
|
required_sections?: string[];
|
|
required_sections_created?: boolean;
|
|
};
|
|
|
|
function resetProjectionCaches(): void {
|
|
__resetGraphCache();
|
|
_resetHarnessDataForTests();
|
|
_resetScopeMappingForTests();
|
|
_resetStageGraphForTests();
|
|
}
|
|
|
|
function mergeListField(content: string, field: string, items: readonly string[]): string {
|
|
if (items.length === 0) return content;
|
|
const empty = new RegExp(`^${field}:\\s*\\[\\s*\\]\\s*$`, "m");
|
|
if (empty.test(content)) {
|
|
return content.replace(empty, `${field}:\n${items.map((item) => ` - ${item}`).join("\n")}`);
|
|
}
|
|
const block = new RegExp(`^(${field}:\\n(?: - .+\\n)*)`, "m");
|
|
const match = content.match(block);
|
|
if (!match) return content;
|
|
const existing = new Set(
|
|
[...match[1].matchAll(/^ {2}- (.+)$/gm)].map((item) =>
|
|
item[1].trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1")
|
|
),
|
|
);
|
|
const additions = items.filter((item) => !existing.has(item));
|
|
if (additions.length === 0) return content;
|
|
const quoted = field === "required_sections";
|
|
return content.replace(
|
|
block,
|
|
`${match[1]}${additions.map((item) => ` - ${quoted ? JSON.stringify(item) : item}`).join("\n")}\n`,
|
|
);
|
|
}
|
|
|
|
function mergeRequiredSections(content: string, record: StageContribRecord): string {
|
|
const items = record.required_sections ?? [];
|
|
if (items.length === 0) return content;
|
|
if (/^required_sections:/m.test(content)) {
|
|
return mergeListField(content, "required_sections", items);
|
|
}
|
|
const close = /^---\r?\n[\s\S]*?\n(---)(?:\r?\n|$)/.exec(content);
|
|
if (!close) return content;
|
|
const at = (close.index ?? 0) + close[0].lastIndexOf("---");
|
|
return `${content.slice(0, at)}required_sections:\n${
|
|
items.map((item) => ` - ${JSON.stringify(item)}`).join("\n")
|
|
}\n${content.slice(at)}`;
|
|
}
|
|
|
|
function consumeBlocks(content: string, names: ReadonlySet<string>): string[] {
|
|
const block = /^consumes:\n((?: {2}- artifact:.*\n(?: {4}(?:required|conditional_on):.*\n)*)*)/m.exec(content);
|
|
if (!block) return [];
|
|
return [...block[1].matchAll(/^ {2}- artifact:\s*([\w-]+).*\n(?: {4}(?:required|conditional_on):.*\n)*/gm)]
|
|
.filter((entry) => names.has(entry[1]))
|
|
.map((entry) => entry[0].trimEnd());
|
|
}
|
|
|
|
function mergeConsumes(content: string, blocks: readonly string[]): string {
|
|
if (blocks.length === 0) return content;
|
|
if (/^consumes:\s*\[\s*\]\s*$/m.test(content)) {
|
|
return content.replace(/^consumes:\s*\[\s*\]\s*$/m, `consumes:\n${blocks.join("\n")}`);
|
|
}
|
|
const match = /^(consumes:\n(?: {2}- artifact:.*\n(?: {4}(?:required|conditional_on):.*\n)*)*)/m.exec(content);
|
|
if (!match) return content;
|
|
const existing = new Set([...match[1].matchAll(/- artifact:\s*([\w-]+)/g)].map((item) => item[1]));
|
|
const additions = blocks.filter((block) => {
|
|
const name = /- artifact:\s*([\w-]+)/.exec(block)?.[1];
|
|
return name && !existing.has(name);
|
|
});
|
|
return additions.length === 0
|
|
? content
|
|
: content.replace(match[0], `${match[1]}${additions.join("\n")}\n`);
|
|
}
|
|
|
|
function stripRecordedContributions(content: string, record: StageContribRecord): string {
|
|
let value = content;
|
|
for (const [field, items] of [
|
|
["produces", record.produces],
|
|
["sensors", record.sensors],
|
|
["required_sections", record.required_sections],
|
|
] as const) {
|
|
if (!items?.length) continue;
|
|
const values = new Set(items);
|
|
const block = new RegExp(`^${field}:\\n((?: {2}- .+\\n)*)`, "m");
|
|
const match = value.match(block);
|
|
if (!match) continue;
|
|
const kept = [...match[1].matchAll(/^ {2}- (.+)$/gm)]
|
|
.map((item) => item[1])
|
|
.filter((item) => !values.has(item.trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1")));
|
|
const replacement = kept.length > 0
|
|
? `${field}:\n${kept.map((item) => ` - ${item}`).join("\n")}\n`
|
|
: field === "required_sections" && record.required_sections_created
|
|
? ""
|
|
: `${field}: []\n`;
|
|
value = value.replace(block, replacement);
|
|
}
|
|
if (record.consumes?.length) {
|
|
const names = new Set(record.consumes);
|
|
const block = /^consumes:\n((?: {2}- artifact:.*\n(?: {4}(?:required|conditional_on):.*\n)*)*)/m.exec(value);
|
|
if (block) {
|
|
const kept = [...block[1].matchAll(/^ {2}- artifact:\s*([\w-]+).*\n(?: {4}(?:required|conditional_on):.*\n)*/gm)]
|
|
.filter((entry) => !names.has(entry[1]))
|
|
.map((entry) => entry[0]);
|
|
value = value.replace(block[0], kept.length > 0 ? `consumes:\n${kept.join("")}` : "consumes: []\n");
|
|
}
|
|
}
|
|
return stripPluginFragments(value);
|
|
}
|
|
|
|
function stripPluginFragments(content: string): string {
|
|
return content.replace(
|
|
/<!-- plugin:([^:\n]+):([^\n]+?):(\d+):([0-9a-f]+) -->\n[\s\S]*?<!-- \/plugin:\1:\2:\3:\4 -->\n?/g,
|
|
"",
|
|
).replace(/\n{3,}/g, "\n\n");
|
|
}
|
|
|
|
function pluginFragments(content: string): Array<{ marker: string; anchor: string; block: string }> {
|
|
const fragments: Array<{ marker: string; anchor: string; block: string }> = [];
|
|
const open = /<!-- plugin:([^:\n]+):([^\n]+?):(\d+):([0-9a-f]+) -->/g;
|
|
for (const match of content.matchAll(open)) {
|
|
const marker = match[0];
|
|
const close = `<!-- /plugin:${match[1]}:${match[2]}:${match[3]}:${match[4]} -->`;
|
|
const end = content.indexOf(close, match.index);
|
|
if (end < 0) continue;
|
|
fragments.push({
|
|
marker,
|
|
anchor: match[2],
|
|
block: content.slice(match.index, end + close.length),
|
|
});
|
|
}
|
|
return fragments;
|
|
}
|
|
|
|
function anchorOffset(content: string, anchor: string): number {
|
|
const step = /^(after|before)-step:(\d+)$/.exec(anchor);
|
|
if (step) {
|
|
const wanted = Number(step[2]);
|
|
for (const match of content.matchAll(/^### Step (\d+)(?:-(\d+))?\b.*$/gm)) {
|
|
const low = Number(match[1]);
|
|
const high = match[2] ? Number(match[2]) : low;
|
|
if (wanted < low || wanted > high) continue;
|
|
if (step[1] === "before") return match.index ?? -1;
|
|
const from = (match.index ?? 0) + match[0].length;
|
|
const next = content.slice(from).search(/^#{2,3} /m);
|
|
return next < 0 ? content.length : from + next;
|
|
}
|
|
return -1;
|
|
}
|
|
if (anchor === "end-of-steps") {
|
|
const section = /^## Steps\b.*$/m.exec(content);
|
|
if (!section) return -1;
|
|
const from = (section.index ?? 0) + section[0].length;
|
|
const next = content.slice(from).search(/^## /m);
|
|
return next < 0 ? content.length : from + next;
|
|
}
|
|
if (anchor.startsWith("in:")) {
|
|
const section = new RegExp(`^## ${anchor.slice(3).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b.*$`, "m")
|
|
.exec(content);
|
|
if (!section) return -1;
|
|
const from = (section.index ?? 0) + section[0].length;
|
|
const next = content.slice(from).search(/^## /m);
|
|
return next < 0 ? content.length : from + next;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function mergePluginFragments(
|
|
fresh: string,
|
|
fragments: readonly { marker: string; anchor: string; block: string }[],
|
|
): string {
|
|
let value = fresh;
|
|
for (const fragment of fragments) {
|
|
if (value.includes(fragment.marker)) continue;
|
|
const offset = anchorOffset(value, fragment.anchor);
|
|
if (offset < 0) {
|
|
throw new Error(`cannot reapply plugin fragment at missing anchor ${fragment.anchor}`);
|
|
}
|
|
value = `${value.slice(0, offset)}\n${fragment.block}\n${value.slice(offset)}`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function replaceGeneratedRegion(
|
|
current: string,
|
|
generated: string,
|
|
kind: "stage" | "scope",
|
|
): string {
|
|
const noun = kind === "stage" ? "stage graph" : "scope grid";
|
|
const beginPrefix = `<!-- BEGIN: compiled ${noun} via `;
|
|
const end = `<!-- END: compiled ${noun} -->`;
|
|
const locate = (content: string): {
|
|
begin: number;
|
|
beginLineEnd: number;
|
|
endStart: number;
|
|
end: number;
|
|
} => {
|
|
const begin = content.indexOf(beginPrefix);
|
|
const beginLineEnd = content.indexOf("-->", begin);
|
|
const endAt = content.indexOf(end, beginLineEnd);
|
|
if (begin < 0 || beginLineEnd < 0 || endAt < 0) {
|
|
throw new Error(`SKILL.md is missing the compiled ${noun} region`);
|
|
}
|
|
return {
|
|
begin,
|
|
beginLineEnd: beginLineEnd + 3,
|
|
endStart: endAt,
|
|
end: endAt + end.length,
|
|
};
|
|
};
|
|
const target = locate(current);
|
|
const source = locate(generated);
|
|
return `${current.slice(0, target.begin)}${
|
|
current.slice(target.begin, target.beginLineEnd)
|
|
}${generated.slice(source.beginLineEnd, source.endStart)}${
|
|
current.slice(target.endStart, target.end)
|
|
}${current.slice(target.end)}`;
|
|
}
|
|
|
|
function generatedOverlayCandidate(rel: string, harnessDir: string): boolean {
|
|
return rel.startsWith(`${harnessDir}/aidlc-common/stages/`) ||
|
|
rel.startsWith(`${harnessDir}/scopes/`) ||
|
|
rel.startsWith(`${harnessDir}/agents/`) ||
|
|
rel.startsWith(`${harnessDir}/knowledge/`) ||
|
|
rel.startsWith(`${harnessDir}/sensors/`) ||
|
|
rel.startsWith(`${harnessDir}/tools/`) ||
|
|
rel.startsWith(`${harnessDir}/skills/`) ||
|
|
rel.startsWith(".agents/skills/");
|
|
}
|
|
|
|
const HARNESS_IDENTITY_KEYS = new Set([
|
|
"schemaVersion",
|
|
"distribution",
|
|
"productName",
|
|
"configNextStep",
|
|
"harnessDir",
|
|
"rulesSubdir",
|
|
]);
|
|
|
|
function prepareRefreshSource(
|
|
projectDir: string,
|
|
sourceRoot: string,
|
|
descriptor: ProjectionDescriptor,
|
|
prior: Baseline | null,
|
|
modelPolicy: ModelPolicyRecord | null,
|
|
projectFlags: ProjectFlagsRecord | null,
|
|
diagnosticsOverride?: ConfigDiagnosticOverrides,
|
|
): { root: string; cleanup?: string; regenerated: Set<string> } {
|
|
const currentHarness = join(projectDir, descriptor.harnessDir);
|
|
const currentHarnessData = join(currentHarness, "tools", "data", "harness.json");
|
|
if (
|
|
!prior &&
|
|
!regularFile(currentHarnessData) &&
|
|
modelPolicy === null &&
|
|
projectFlags === null &&
|
|
diagnosticsOverride === undefined
|
|
) {
|
|
return { root: sourceRoot, regenerated: new Set() };
|
|
}
|
|
const cleanup = mkdtempSync(join(tmpdir(), "aidlc-init-refresh-"));
|
|
try {
|
|
const root = join(cleanup, "projection");
|
|
cpSync(sourceRoot, root, { recursive: true, preserveTimestamps: true });
|
|
const regenerated = new Set<string>();
|
|
const stagedHarness = join(root, descriptor.harnessDir);
|
|
const beforeGeneratedWrites = new Map<string, string>();
|
|
for (const directory of descriptor.managedDirectories) {
|
|
const stagedDirectory = join(root, directory);
|
|
if (!existsSync(stagedDirectory) || !lstatSync(stagedDirectory).isDirectory()) continue;
|
|
for (const nested of walkFiles(stagedDirectory)) {
|
|
const rel = join(directory, nested).replaceAll("\\", "/");
|
|
beforeGeneratedWrites.set(rel, sha256File(join(root, rel)));
|
|
}
|
|
}
|
|
for (const integration of descriptor.rootIntegrations) {
|
|
const path = join(root, integration.path);
|
|
if (regularFile(path)) {
|
|
beforeGeneratedWrites.set(integration.path, sha256File(path));
|
|
}
|
|
}
|
|
|
|
const stagedHarnessData = join(stagedHarness, "tools", "data", "harness.json");
|
|
const staged = JSON.parse(readFileSync(stagedHarnessData, "utf-8")) as Record<string, unknown>;
|
|
if (regularFile(currentHarnessData)) {
|
|
const current = JSON.parse(readFileSync(currentHarnessData, "utf-8")) as Record<string, unknown>;
|
|
const policyKeys = ["models", "flags"].filter((key) => Object.hasOwn(current, key));
|
|
if (policyKeys.length > 0) {
|
|
throw new Error(
|
|
`${currentHarnessData}: harness.json contains legacy policy key(s) ${policyKeys.join(", ")}. ` +
|
|
`Remove ${policyKeys.join(", ")} from ${currentHarnessData}, then run ` +
|
|
`'${aidlcInvocation()} config' to record policy in aidlc.settings.json.`,
|
|
);
|
|
}
|
|
for (const [key, value] of Object.entries(current)) {
|
|
if (!HARNESS_IDENTITY_KEYS.has(key)) staged[key] = value;
|
|
}
|
|
}
|
|
delete staged.models;
|
|
delete staged.flags;
|
|
for (const key of ["runtime", "providers", "trust", "project"] as const) {
|
|
if (!diagnosticsOverride || !Object.hasOwn(diagnosticsOverride, key)) continue;
|
|
const value = diagnosticsOverride[key];
|
|
if (value === null) delete staged[key];
|
|
else staged[key] = value;
|
|
}
|
|
if (diagnosticsOverride && Object.hasOwn(diagnosticsOverride, "plugins")) {
|
|
if (diagnosticsOverride.plugins === null) delete staged.plugins;
|
|
else staged.plugins = diagnosticsOverride.plugins;
|
|
}
|
|
if (
|
|
regularFile(currentHarnessData) ||
|
|
diagnosticsOverride !== undefined
|
|
) {
|
|
writeFileSync(stagedHarnessData, `${JSON.stringify(staged, null, 2)}\n`);
|
|
regenerated.add(`${descriptor.harnessDir}/tools/data/harness.json`);
|
|
}
|
|
const distribution = staged.distribution;
|
|
if (typeof distribution !== "string") {
|
|
throw new Error(`${stagedHarnessData}: distribution must be a string`);
|
|
}
|
|
applyModelPolicyToProjection(
|
|
root,
|
|
descriptor.harnessDir,
|
|
modelHarness(distribution),
|
|
modelPolicy,
|
|
);
|
|
applyProjectFlagsToProjection(
|
|
root,
|
|
descriptor.harnessDir,
|
|
modelHarness(distribution),
|
|
projectFlags,
|
|
);
|
|
applyConfigDiagnosticRecords(
|
|
root,
|
|
descriptor.harnessDir,
|
|
modelHarness(distribution),
|
|
{
|
|
runtime: normalizeRuntimeRecord(staged.runtime),
|
|
providers: normalizeProvidersRecord(staged.providers),
|
|
trust: normalizeTrustRecord(staged.trust),
|
|
project: normalizeProjectChoicesRecord(staged.project),
|
|
},
|
|
);
|
|
for (const directory of descriptor.managedDirectories) {
|
|
const stagedDirectory = join(root, directory);
|
|
if (!existsSync(stagedDirectory) || !lstatSync(stagedDirectory).isDirectory()) continue;
|
|
for (const nested of walkFiles(stagedDirectory)) {
|
|
const rel = join(directory, nested).replaceAll("\\", "/");
|
|
if (beforeGeneratedWrites.get(rel) !== sha256File(join(root, rel))) {
|
|
regenerated.add(rel);
|
|
}
|
|
}
|
|
}
|
|
for (const integration of descriptor.rootIntegrations) {
|
|
const path = join(root, integration.path);
|
|
if (
|
|
regularFile(path) &&
|
|
beforeGeneratedWrites.get(integration.path) !== sha256File(path)
|
|
) {
|
|
regenerated.add(integration.path);
|
|
}
|
|
}
|
|
|
|
const currentGrid = join(currentHarness, "tools", "data", "scope-grid.json");
|
|
const stagedGrid = join(stagedHarness, "tools", "data", "scope-grid.json");
|
|
if (regularFile(currentGrid)) {
|
|
cpSync(currentGrid, stagedGrid);
|
|
regenerated.add(`${descriptor.harnessDir}/tools/data/scope-grid.json`);
|
|
}
|
|
|
|
for (const directory of descriptor.managedDirectories) {
|
|
if (directory !== descriptor.harnessDir && directory !== ".agents") continue;
|
|
const currentDir = join(projectDir, directory);
|
|
if (!pathPresent(currentDir) || !lstatSync(currentDir).isDirectory()) continue;
|
|
for (const nested of regularFilesBelow(currentDir)) {
|
|
const rel = join(directory, nested).replaceAll("\\", "/");
|
|
const staged = join(root, rel);
|
|
if (
|
|
existsSync(staged) ||
|
|
prior?.files[rel] ||
|
|
!generatedOverlayCandidate(rel, descriptor.harnessDir)
|
|
) continue;
|
|
mkdirSync(dirname(staged), { recursive: true });
|
|
cpSync(join(projectDir, rel), staged, { preserveTimestamps: true });
|
|
regenerated.add(rel);
|
|
}
|
|
}
|
|
|
|
const records = new Map<string, StageContribRecord>();
|
|
const dataDir = join(currentHarness, "tools", "data");
|
|
if (pathPresent(dataDir) && lstatSync(dataDir).isDirectory()) {
|
|
for (const file of readdirSync(dataDir).filter((name) => /^plugin-contrib-.+\.json$/.test(name))) {
|
|
if (!regularFile(join(dataDir, file))) continue;
|
|
const parsed = JSON.parse(readFileSync(join(dataDir, file), "utf-8")) as Record<string, StageContribRecord>;
|
|
for (const [slug, record] of Object.entries(parsed)) {
|
|
const priorRecord = records.get(slug) ?? {};
|
|
records.set(slug, {
|
|
produces: [...new Set([...(priorRecord.produces ?? []), ...(record.produces ?? [])])],
|
|
sensors: [...new Set([...(priorRecord.sensors ?? []), ...(record.sensors ?? [])])],
|
|
consumes: [...new Set([...(priorRecord.consumes ?? []), ...(record.consumes ?? [])])],
|
|
required_sections: [
|
|
...new Set([...(priorRecord.required_sections ?? []), ...(record.required_sections ?? [])]),
|
|
],
|
|
required_sections_created:
|
|
priorRecord.required_sections_created || record.required_sections_created,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const stageRoot = join(currentHarness, "aidlc-common", "stages");
|
|
if (pathPresent(stageRoot) && lstatSync(stageRoot).isDirectory()) {
|
|
for (const phase of readdirSync(stageRoot)) {
|
|
const currentPhase = join(stageRoot, phase);
|
|
if (!lstatSync(currentPhase).isDirectory()) continue;
|
|
for (const file of readdirSync(currentPhase).filter((name) => name.endsWith(".md"))) {
|
|
const rel = `${descriptor.harnessDir}/aidlc-common/stages/${phase}/${file}`;
|
|
const priorHash = prior?.files[rel];
|
|
const currentPath = join(projectDir, rel);
|
|
const stagedPath = join(root, rel);
|
|
if (!regularFile(currentPath) || !existsSync(stagedPath)) continue;
|
|
const current = readFileSync(currentPath, "utf-8");
|
|
const record = records.get(file.slice(0, -3)) ?? {};
|
|
const fragments = pluginFragments(current);
|
|
const hasRecordedContribution = Object.entries(record).some(([key, value]) =>
|
|
key === "required_sections_created" ? value === true : Array.isArray(value) && value.length > 0
|
|
);
|
|
if (fragments.length === 0 && !hasRecordedContribution) {
|
|
continue;
|
|
}
|
|
const currentHash = sha256Bytes(current);
|
|
const strippedHash = sha256Bytes(stripRecordedContributions(current, record));
|
|
if (priorHash && currentHash !== priorHash && strippedHash !== priorHash) continue;
|
|
let fresh = readFileSync(stagedPath, "utf-8");
|
|
fresh = mergeListField(fresh, "produces", record.produces ?? []);
|
|
fresh = mergeListField(fresh, "sensors", record.sensors ?? []);
|
|
fresh = mergeConsumes(fresh, consumeBlocks(current, new Set(record.consumes ?? [])));
|
|
fresh = mergeRequiredSections(fresh, record);
|
|
fresh = mergePluginFragments(fresh, fragments);
|
|
writeFileSync(stagedPath, fresh);
|
|
if (prior) regenerated.add(rel);
|
|
}
|
|
}
|
|
}
|
|
|
|
const envKeys = [
|
|
"AIDLC_RUNTIME_PROJECT_DIR",
|
|
"AIDLC_PROJECT_DIR",
|
|
"AIDLC_HARNESS_DIR",
|
|
"AIDLC_RUNTIME_HARNESS_ROOT",
|
|
"AIDLC_RULES_DIR",
|
|
"AIDLC_STAGE_GRAPH",
|
|
"AIDLC_SCOPE_GRID",
|
|
"AIDLC_SCOPES_DIR",
|
|
"AIDLC_SENSORS_DIR",
|
|
"AIDLC_AGENTS_DIR",
|
|
] as const;
|
|
const saved = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
|
|
try {
|
|
process.env.AIDLC_RUNTIME_PROJECT_DIR = root;
|
|
process.env.AIDLC_PROJECT_DIR = root;
|
|
process.env.AIDLC_HARNESS_DIR = descriptor.harnessDir;
|
|
process.env.AIDLC_RUNTIME_HARNESS_ROOT = stagedHarness;
|
|
process.env.AIDLC_RULES_DIR = join(root, "aidlc", "spaces", "default", "memory");
|
|
process.env.AIDLC_STAGE_GRAPH = join(stagedHarness, "tools", "data", "stage-graph.json");
|
|
process.env.AIDLC_SCOPE_GRID = stagedGrid;
|
|
process.env.AIDLC_SCOPES_DIR = join(stagedHarness, "scopes");
|
|
process.env.AIDLC_SENSORS_DIR = join(stagedHarness, "sensors");
|
|
process.env.AIDLC_AGENTS_DIR = join(stagedHarness, "agents");
|
|
resetProjectionCaches();
|
|
const compiled = compileStageGraph();
|
|
writeFileSync(process.env.AIDLC_STAGE_GRAPH, compiled.json);
|
|
writeFileSync(stagedGrid, compiled.gridJson);
|
|
resetProjectionCaches();
|
|
regenerateRunnerSurfaces();
|
|
resetProjectionCaches();
|
|
|
|
const skillPath = existsSync(join(stagedHarness, "skills", "aidlc", "SKILL.md"))
|
|
? join(stagedHarness, "skills", "aidlc", "SKILL.md")
|
|
: join(root, ".agents", "skills", "aidlc", "SKILL.md");
|
|
if (existsSync(skillPath)) {
|
|
let generated = readFileSync(skillPath, "utf-8");
|
|
generated = replaceGeneratedRegion(
|
|
generated,
|
|
canonicalStageTableRegion(renderStageTable()),
|
|
"stage",
|
|
);
|
|
generated = replaceGeneratedRegion(
|
|
generated,
|
|
canonicalScopeTableRegion(renderScopeTable()),
|
|
"scope",
|
|
);
|
|
writeFileSync(skillPath, generated);
|
|
}
|
|
regenerated.add(`${descriptor.harnessDir}/tools/data/stage-graph.json`);
|
|
for (const directory of [join(stagedHarness, "skills"), join(root, ".agents", "skills")]) {
|
|
if (!existsSync(directory)) continue;
|
|
for (const nested of walkFiles(directory)) {
|
|
const path = join(directory, nested);
|
|
if (readFileSync(path, "utf-8").includes("generated-by: aidlc-runner-gen")) {
|
|
regenerated.add(relative(root, path).replaceAll("\\", "/"));
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
for (const key of envKeys) {
|
|
const value = saved[key];
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
resetProjectionCaches();
|
|
}
|
|
return { root, cleanup, regenerated };
|
|
} catch (error) {
|
|
rmSync(cleanup, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function activeWorkflowDescriptions(projectDir: string): string[] {
|
|
const active: string[] = [];
|
|
for (const space of listSpaces(projectDir)) {
|
|
for (const intent of listIntents(projectDir, space.name)) {
|
|
if (intent.status === "complete" || !intent.dirName) continue;
|
|
const path = stateFilePath(projectDir, intent.dirName, space.name);
|
|
if (regularFile(path)) {
|
|
const status = getField(readFileSync(path, "utf-8"), "Status");
|
|
if (status === "Completed") continue;
|
|
}
|
|
active.push(`${space.name}/${intent.dirName}`);
|
|
}
|
|
}
|
|
return active;
|
|
}
|
|
|
|
function assertRefreshSafe(projectDir: string): void {
|
|
const activeWorkflows = activeWorkflowDescriptions(projectDir);
|
|
if (activeWorkflows.length === 0) return;
|
|
throw new Error(
|
|
`refusing to refresh while ${activeWorkflows.length} workflow(s) are active: ${
|
|
activeWorkflows.join(", ")
|
|
}. Complete the workflow before rerunning aidlc config; update and use do not modify project files.`,
|
|
);
|
|
}
|
|
|
|
function mergeBlock(
|
|
path: string,
|
|
current: string,
|
|
shipped: string,
|
|
identity: string,
|
|
legacyWholeFileHashes: readonly string[] = [],
|
|
): {
|
|
value?: string;
|
|
currentHash?: string;
|
|
nextHash?: string;
|
|
adoptedLegacy?: boolean;
|
|
error?: string;
|
|
} {
|
|
const { begin, end } = managedBlockMarkers(path, identity);
|
|
const begins = current.split(begin).length - 1;
|
|
const ends = current.split(end).length - 1;
|
|
if (begins > 1 || ends > 1 || (begins === 1) !== (ends === 1)) {
|
|
return { error: "managed markers are missing, duplicated, or malformed" };
|
|
}
|
|
const beginAt = current.indexOf(begin);
|
|
const endAt = current.indexOf(end);
|
|
const newline = current.includes("\r\n") ? "\r\n" : "\n";
|
|
const body = shipped.trim().replace(/\r?\n/g, newline);
|
|
const block = `${begin}${newline}${body}${newline}${end}`;
|
|
if (beginAt >= 0) {
|
|
if (endAt < beginAt) return { error: "managed end marker precedes its begin marker" };
|
|
const currentBlock = current.slice(beginAt, endAt + end.length);
|
|
return {
|
|
value: `${current.slice(0, beginAt)}${block}${current.slice(endAt + end.length)}`,
|
|
currentHash: sha256Bytes(currentBlock),
|
|
nextHash: sha256Bytes(block),
|
|
};
|
|
}
|
|
if (current.length > 0 && legacyWholeFileHashes.includes(sha256Bytes(current))) {
|
|
return {
|
|
value: `${block}${newline}`,
|
|
nextHash: sha256Bytes(block),
|
|
adoptedLegacy: true,
|
|
};
|
|
}
|
|
if (/\baidlc\b|AI-DLC/i.test(current)) {
|
|
return { error: "legacy root integration ambiguous; move or delete the unmarked AI-DLC content" };
|
|
}
|
|
const prefix = current.length === 0 || current.endsWith(newline) ? current : `${current}${newline}`;
|
|
return {
|
|
value: `${prefix}${prefix ? newline : ""}${block}${newline}`,
|
|
nextHash: sha256Bytes(block),
|
|
};
|
|
}
|
|
|
|
function addRuntimeDistributions(roots: string[], root: string): boolean {
|
|
if (!existsSync(root)) return false;
|
|
let added = false;
|
|
for (const entry of readdirSync(root).sort()) {
|
|
const candidate = join(root, entry);
|
|
if (!statSync(candidate).isDirectory()) continue;
|
|
roots.push(candidate);
|
|
added = true;
|
|
}
|
|
return added;
|
|
}
|
|
|
|
function dedupeSourceRoots(roots: readonly string[]): string[] {
|
|
const seen = new Set<string>();
|
|
return roots.filter((root) => {
|
|
let identity: string;
|
|
try {
|
|
identity = realpathSync(root);
|
|
} catch {
|
|
identity = resolve(root);
|
|
}
|
|
if (seen.has(identity)) return false;
|
|
seen.add(identity);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function installedSources(
|
|
requiredVersion?: string,
|
|
executablePath = process.execPath,
|
|
): string[] {
|
|
const roots: string[] = [];
|
|
const explicit = process.env.AIDLC_RUNTIME_ROOT;
|
|
if (explicit && existsSync(explicit)) {
|
|
addRuntimeDistributions(roots, explicit);
|
|
try {
|
|
projectionFiles(explicit);
|
|
roots.push(explicit);
|
|
} catch {
|
|
// The explicit root may be a parent of distributions.
|
|
}
|
|
}
|
|
let selectedVersionRuntimeFound = false;
|
|
const active = activeVersion();
|
|
if (active) {
|
|
const activeFound = addRuntimeDistributions(roots, runtimeRoot(active));
|
|
if (!requiredVersion || requiredVersion === active) {
|
|
selectedVersionRuntimeFound = activeFound;
|
|
}
|
|
}
|
|
if (requiredVersion && requiredVersion !== active) {
|
|
selectedVersionRuntimeFound = addRuntimeDistributions(
|
|
roots,
|
|
runtimeRoot(requiredVersion),
|
|
);
|
|
}
|
|
if (!selectedVersionRuntimeFound) {
|
|
let executable = executablePath;
|
|
try {
|
|
executable = realpathSync(executable);
|
|
} catch {
|
|
executable = resolve(executable);
|
|
}
|
|
addRuntimeDistributions(roots, join(dirname(executable), "runtime"));
|
|
}
|
|
return dedupeSourceRoots(roots);
|
|
}
|
|
|
|
export function _installedSourcesForTests(
|
|
requiredVersion?: string,
|
|
executablePath?: string,
|
|
): string[] {
|
|
return installedSources(requiredVersion, executablePath);
|
|
}
|
|
|
|
function materializeSource(path: string): { root: string; cleanup?: string } {
|
|
const absolute = isAbsolute(path) ? path : resolve(process.cwd(), path);
|
|
if (!existsSync(absolute)) throw new Error(`init source does not exist: ${absolute}`);
|
|
if (statSync(absolute).isDirectory()) return { root: absolute };
|
|
const temporary = mkdtempSync(join(tmpdir(), "aidlc-init-source-"));
|
|
extractTarGz(absolute, temporary);
|
|
return { root: temporary, cleanup: temporary };
|
|
}
|
|
|
|
function configuredDefaultHarness(): string | undefined {
|
|
const path = defaultHarnessPath();
|
|
if (!existsSync(path)) return undefined;
|
|
const value = readFileSync(path, "utf-8").trim();
|
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(value)) {
|
|
throw new Error(
|
|
`${path} contains an invalid harness name; pass --harness <name>`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
type InstalledSourceCandidate = {
|
|
root: string;
|
|
stamp: ReturnType<typeof projectionFiles>["stamp"];
|
|
descriptor: ReturnType<typeof projectionFiles>["descriptor"];
|
|
};
|
|
|
|
type ConfigSource = {
|
|
root: string;
|
|
cleanup?: string;
|
|
stamp: ReturnType<typeof projectionFiles>["stamp"];
|
|
descriptor: ReturnType<typeof projectionFiles>["descriptor"];
|
|
projectProjection?: boolean;
|
|
};
|
|
|
|
function installedSourceCandidates(
|
|
requiredVersion?: string,
|
|
): InstalledSourceCandidate[] {
|
|
const candidates = installedSources(requiredVersion).flatMap((root) => {
|
|
try {
|
|
const projection = projectionFiles(root);
|
|
return [{ root, stamp: projection.stamp, descriptor: projection.descriptor }];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
return requiredVersion
|
|
? candidates.filter((candidate) =>
|
|
candidate.stamp.frameworkVersion === requiredVersion
|
|
)
|
|
: candidates;
|
|
}
|
|
|
|
function selectSource(
|
|
requested: string | undefined,
|
|
from: string | undefined,
|
|
existingDistribution: string | undefined,
|
|
requiredVersion?: string,
|
|
): ConfigSource {
|
|
if (from) {
|
|
const source = materializeSource(from);
|
|
const { stamp, descriptor } = projectionFiles(source.root);
|
|
if (requested && stamp.distribution !== requested) {
|
|
if (source.cleanup) rmSync(source.cleanup, { recursive: true, force: true });
|
|
throw new Error(`source is ${stamp.distribution}, not requested harness ${requested}`);
|
|
}
|
|
if (existingDistribution && stamp.distribution !== existingDistribution) {
|
|
if (source.cleanup) rmSync(source.cleanup, { recursive: true, force: true });
|
|
throw new Error(`existing project uses ${existingDistribution}; refusing ${stamp.distribution}`);
|
|
}
|
|
return { ...source, stamp, descriptor };
|
|
}
|
|
const candidates = installedSourceCandidates(requiredVersion);
|
|
const selectedName = existingDistribution || requested;
|
|
const versionFiltered = candidates;
|
|
if (selectedName) {
|
|
const selected = versionFiltered.filter((candidate) =>
|
|
candidate.stamp.distribution === selectedName
|
|
);
|
|
if (selected.length === 1) return selected[0];
|
|
throw new Error(
|
|
requiredVersion && versionFiltered.length === 0
|
|
? `project requires ${requiredVersion}, which is not installed; run aidlc config --pin ${requiredVersion}`
|
|
: requiredVersion
|
|
? `harness ${selectedName} is not installed in ${requiredVersion}; run aidlc config --pin ${requiredVersion}`
|
|
: `harness ${selectedName} is not installed`,
|
|
);
|
|
}
|
|
const configuredDefault = configuredDefaultHarness();
|
|
if (configuredDefault) {
|
|
const selected = versionFiltered.filter((candidate) =>
|
|
candidate.stamp.distribution === configuredDefault
|
|
);
|
|
if (selected.length === 1) return selected[0];
|
|
if (versionFiltered.length > 0) {
|
|
throw new Error(
|
|
requiredVersion
|
|
? `configured default harness ${configuredDefault} is not installed in ${requiredVersion}; run aidlc config --pin ${requiredVersion}`
|
|
: `configured default harness ${configuredDefault} is unavailable; pass --harness <name>`,
|
|
);
|
|
}
|
|
}
|
|
if (versionFiltered.length === 1) return versionFiltered[0];
|
|
if (versionFiltered.length === 0) {
|
|
throw new Error(
|
|
requiredVersion
|
|
? `project requires ${requiredVersion}, which is not installed; run aidlc config --pin ${requiredVersion}`
|
|
: "no installed harness runtime is available",
|
|
);
|
|
}
|
|
if (configInputIsTty()) {
|
|
process.stdout.write("Select a harness for this project:\n\n");
|
|
for (const [index, candidate] of versionFiltered.entries()) {
|
|
process.stdout.write(
|
|
` ${index + 1}. ${candidate.descriptor.productName}\n`,
|
|
);
|
|
}
|
|
const selected = versionFiltered[
|
|
promptChoice("Harness", versionFiltered.length) - 1
|
|
];
|
|
process.stdout.write(`Using ${selected.descriptor.productName}.\n`);
|
|
return selected;
|
|
}
|
|
throw new Error(
|
|
`multiple harnesses are installed; pass --harness <${
|
|
versionFiltered.map((item) => item.stamp.distribution).join("|")
|
|
}>`,
|
|
);
|
|
}
|
|
|
|
function copiedProjectSource(
|
|
projectDir: string,
|
|
requested?: string,
|
|
): ConfigSource {
|
|
const harnesses = discoverProjectHarnesses(projectDir);
|
|
const selected = requested
|
|
? harnesses.find((candidate) => candidate.distribution === requested)
|
|
: harnesses[0];
|
|
if (!selected) {
|
|
throw new Error("the project does not contain a copied AI-DLC projection");
|
|
}
|
|
if (!requested && harnesses.length > 1) {
|
|
throw new Error("multiple project harnesses are present; pass one --harness <name>");
|
|
}
|
|
const dataDir = join(selected.root, "tools", "data");
|
|
const stampPath = join(dataDir, "aidlc-stamp.json");
|
|
const descriptorPath = join(dataDir, "aidlc-projection.json");
|
|
assertProjectionPathHasNoSymlinks(
|
|
projectDir,
|
|
relative(projectDir, stampPath).replaceAll("\\", "/"),
|
|
);
|
|
assertProjectionPathHasNoSymlinks(
|
|
projectDir,
|
|
relative(projectDir, descriptorPath).replaceAll("\\", "/"),
|
|
);
|
|
if (
|
|
!existsSync(stampPath) ||
|
|
!lstatSync(stampPath).isFile() ||
|
|
!existsSync(descriptorPath) ||
|
|
!lstatSync(descriptorPath).isFile()
|
|
) {
|
|
throw new Error(`${dataDir}: copied projection metadata must be regular files`);
|
|
}
|
|
const stamp = JSON.parse(
|
|
readFileSync(stampPath, "utf-8"),
|
|
) as ReturnType<typeof projectionFiles>["stamp"];
|
|
const descriptor = JSON.parse(
|
|
readFileSync(descriptorPath, "utf-8"),
|
|
) as ReturnType<typeof projectionFiles>["descriptor"];
|
|
if (
|
|
stamp.schemaVersion !== 1 ||
|
|
descriptor.schemaVersion !== 1 ||
|
|
stamp.distribution !== selected.distribution ||
|
|
descriptor.distribution !== selected.distribution ||
|
|
stamp.harnessDir !== selected.harnessDir ||
|
|
descriptor.harnessDir !== selected.harnessDir
|
|
) {
|
|
throw new Error(`${dataDir}: copied projection identity is inconsistent`);
|
|
}
|
|
validateProjectionDescriptor(projectDir, stamp, descriptor, {
|
|
allowMissingRootIntegrations: true,
|
|
});
|
|
const cleanup = mkdtempSync(join(tmpdir(), "aidlc-config-copy-source-"));
|
|
const root = join(cleanup, "projection");
|
|
mkdirSync(root, { recursive: true });
|
|
for (const directory of descriptor.managedDirectories) {
|
|
assertProjectionPathHasNoSymlinks(projectDir, directory);
|
|
const source = join(projectDir, directory);
|
|
if (!existsSync(source) || !lstatSync(source).isDirectory()) {
|
|
rmSync(cleanup, { recursive: true, force: true });
|
|
throw new Error(`copied projection is missing managed directory ${directory}`);
|
|
}
|
|
cpSync(source, join(root, directory), {
|
|
recursive: true,
|
|
preserveTimestamps: true,
|
|
});
|
|
}
|
|
for (const integration of descriptor.rootIntegrations) {
|
|
assertProjectionPathHasNoSymlinks(projectDir, integration.path);
|
|
const source = join(projectDir, integration.path);
|
|
if (!existsSync(source) || !lstatSync(source).isFile()) continue;
|
|
const target = join(root, integration.path);
|
|
mkdirSync(dirname(target), { recursive: true });
|
|
cpSync(source, target, { preserveTimestamps: true });
|
|
}
|
|
walkFiles(root);
|
|
rmSync(
|
|
join(root, selected.harnessDir, "tools", "data", "aidlc-manifest.json"),
|
|
{ force: true },
|
|
);
|
|
return {
|
|
root,
|
|
cleanup,
|
|
stamp,
|
|
descriptor,
|
|
projectProjection: true,
|
|
};
|
|
}
|
|
|
|
type FirstRunDetection = {
|
|
harnesses: Record<string, {
|
|
found: boolean;
|
|
probed?: boolean;
|
|
version?: string;
|
|
path?: string;
|
|
}>;
|
|
aws: ReturnType<typeof detectAwsCredentials>;
|
|
runtimeIssues: ReturnType<typeof runtimeIssues>;
|
|
bedrockReachable?: boolean;
|
|
};
|
|
|
|
type FirstRunChoices = {
|
|
candidate: InstalledSourceCandidate;
|
|
provider: "amazon-bedrock" | "other";
|
|
region: string;
|
|
profile: string;
|
|
preset: "balanced" | "thorough" | "minimal";
|
|
plugins: string;
|
|
pluginLabel: string;
|
|
mcp: "defaults" | "none";
|
|
target: SettingsTarget;
|
|
providerVerified: boolean;
|
|
opencodeDefault: boolean;
|
|
};
|
|
|
|
class FirstRunCancelled extends Error {}
|
|
|
|
function firstRunPromptValue(value: string | null): string {
|
|
if (value === null) throw new FirstRunCancelled();
|
|
const normalized = value.trim();
|
|
if (normalized.includes("\u0003")) throw new FirstRunCancelled();
|
|
return normalized;
|
|
}
|
|
|
|
function promptChoice(
|
|
label: string,
|
|
count: number,
|
|
defaultIndex?: number,
|
|
): number {
|
|
while (true) {
|
|
const suffix = defaultIndex === undefined
|
|
? ` [1-${count}]`
|
|
: ` [${defaultIndex}]`;
|
|
const value = firstRunPromptValue(configPrompt(`${label}${suffix}:`));
|
|
if (!value && defaultIndex !== undefined) return defaultIndex;
|
|
if (/^\d+$/.test(value)) {
|
|
const selected = Number(value);
|
|
if (selected >= 1 && selected <= count) return selected;
|
|
}
|
|
process.stdout.write(
|
|
`\n That's not one of the choices - enter ${
|
|
count === 2 ? "1 or 2" : `1, 2${count > 3 ? `, ... or ${count}` : ", or 3"}`
|
|
}.\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function promptTextDefault(label: string, fallback: string): string {
|
|
return firstRunPromptValue(configPrompt(`${label} [${fallback}]:`)) || fallback;
|
|
}
|
|
|
|
function promptYesDefault(label: string, defaultYes = true): boolean {
|
|
while (true) {
|
|
const value = firstRunPromptValue(
|
|
configPrompt(`${label} [${defaultYes ? "Y/n" : "y/N"}]:`),
|
|
).toLowerCase();
|
|
if (!value) return defaultYes;
|
|
if (value === "y" || value === "yes") return true;
|
|
if (value === "n" || value === "no") return false;
|
|
process.stdout.write("\n Enter y or n.\n");
|
|
}
|
|
}
|
|
|
|
function injectedFirstRunDetection(): Partial<FirstRunDetection> | null {
|
|
const raw = process.env.AIDLC_TEST_CONFIG_DETECTION_JSON;
|
|
if (!raw) return null;
|
|
return JSON.parse(raw) as Partial<FirstRunDetection>;
|
|
}
|
|
|
|
function detectFirstRun(
|
|
_projectDir: string,
|
|
candidates: readonly InstalledSourceCandidate[],
|
|
): FirstRunDetection {
|
|
const injected = injectedFirstRunDetection();
|
|
const harnesses: FirstRunDetection["harnesses"] = {};
|
|
for (const candidate of candidates) {
|
|
const override = injected?.harnesses?.[candidate.stamp.distribution];
|
|
if (override) {
|
|
harnesses[candidate.stamp.distribution] = override;
|
|
continue;
|
|
}
|
|
const probe = probeHarnessCli(modelHarness(candidate.stamp.distribution));
|
|
harnesses[candidate.stamp.distribution] = {
|
|
found: probe.status === "found",
|
|
probed: probe.status !== "not-applicable",
|
|
...(probe.version ? { version: probe.version } : {}),
|
|
...(probe.path ? { path: probe.path } : {}),
|
|
};
|
|
}
|
|
const first = candidates[0];
|
|
const runtime = first
|
|
? runtimeIssues(probeRuntime(
|
|
first.root,
|
|
first.descriptor.harnessDir,
|
|
modelHarness(first.stamp.distribution),
|
|
{ includeHarnessCli: false },
|
|
))
|
|
: [];
|
|
return {
|
|
harnesses,
|
|
aws: injected?.aws ?? detectAwsCredentials(),
|
|
runtimeIssues: injected?.runtimeIssues ?? runtime,
|
|
...(injected?.bedrockReachable !== undefined
|
|
? { bedrockReachable: injected.bedrockReachable }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function detectedCandidateChoices(
|
|
candidates: readonly InstalledSourceCandidate[],
|
|
detection: FirstRunDetection,
|
|
): InstalledSourceCandidate[] {
|
|
return candidates.filter((candidate) =>
|
|
detection.harnesses[candidate.stamp.distribution]?.found
|
|
);
|
|
}
|
|
|
|
function renderHarnessChoices(
|
|
candidates: readonly InstalledSourceCandidate[],
|
|
detection: FirstRunDetection,
|
|
defaultDistribution?: string,
|
|
): void {
|
|
for (const [index, candidate] of candidates.entries()) {
|
|
const detected = detection.harnesses[candidate.stamp.distribution];
|
|
const tags = [
|
|
...(detected?.found ? ["detected"] : []),
|
|
...(!detected?.found && detected?.probed === false ? ["not probed"] : []),
|
|
...(candidate.stamp.distribution === defaultDistribution ? ["default"] : []),
|
|
];
|
|
process.stdout.write(
|
|
` ${index + 1}. ${candidate.descriptor.productName.padEnd(16)}${
|
|
tags.length > 0 ? `(${tags.join(", ")})` : ""
|
|
}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function chooseHarness(
|
|
candidates: readonly InstalledSourceCandidate[],
|
|
detection: FirstRunDetection,
|
|
defaultDistribution?: string,
|
|
): InstalledSourceCandidate {
|
|
renderHarnessChoices(candidates, detection, defaultDistribution);
|
|
const defaultIndex = defaultDistribution
|
|
? candidates.findIndex((candidate) =>
|
|
candidate.stamp.distribution === defaultDistribution
|
|
) + 1
|
|
: undefined;
|
|
const selected = promptChoice(
|
|
" Harness",
|
|
candidates.length,
|
|
defaultIndex && defaultIndex > 0 ? defaultIndex : undefined,
|
|
);
|
|
const candidate = candidates[selected - 1];
|
|
process.stdout.write(` Using ${candidate.descriptor.productName}.\n\n`);
|
|
return candidate;
|
|
}
|
|
|
|
function awsSummary(credentials: ReturnType<typeof detectAwsCredentials>): {
|
|
source: string;
|
|
region: string;
|
|
regionSource: "detected" | "fallback";
|
|
} {
|
|
return {
|
|
source: credentials.sources[0] ?? "default credential chain",
|
|
region: credentials.regions[0] ?? "us-east-1",
|
|
regionSource: credentials.regions.length > 0 ? "detected" : "fallback",
|
|
};
|
|
}
|
|
|
|
let firstRunChildCount = 0;
|
|
|
|
type FirstRunMutationSnapshot = {
|
|
recoveryPath: string;
|
|
prepareChild: (args: readonly string[]) => NodeJS.ProcessEnv;
|
|
recordCommitted: (result: Record<string, unknown>) => void;
|
|
restore: () => void;
|
|
cleanup: () => void;
|
|
};
|
|
|
|
function runConfigChild(
|
|
args: string[],
|
|
cwd: string,
|
|
snapshot: FirstRunMutationSnapshot,
|
|
): Record<string, unknown> {
|
|
const env = { ...process.env, ...snapshot.prepareChild(args) };
|
|
delete env.AIDLC_TEST_CONFIG_TTY;
|
|
delete env.AIDLC_TEST_CONFIG_DETECTION_JSON;
|
|
const commandArgs = isCompiledExecutable()
|
|
? ["config", ...args]
|
|
: [fileURLToPath(import.meta.url), "config", ...args];
|
|
const result = spawnSync(process.execPath, commandArgs, {
|
|
cwd,
|
|
env,
|
|
encoding: "utf-8",
|
|
input: "",
|
|
timeout: 120_000,
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error((result.stdout || result.stderr || "configuration failed").trim());
|
|
}
|
|
const parsed = JSON.parse(result.stdout) as Record<string, unknown>;
|
|
snapshot.recordCommitted(parsed);
|
|
firstRunChildCount++;
|
|
if (
|
|
Number(process.env.AIDLC_TEST_FIRST_RUN_FAIL_AFTER_CHILD ?? "0") ===
|
|
firstRunChildCount
|
|
) {
|
|
const interference =
|
|
process.env.AIDLC_TEST_FIRST_RUN_ROLLBACK_INTERFERENCE;
|
|
if (interference !== undefined) {
|
|
writeFileSync(
|
|
process.env.AIDLC_TEST_FIRST_RUN_ROLLBACK_INTERFERENCE_PATH ??
|
|
join(cwd, "aidlc.settings.json"),
|
|
interference,
|
|
);
|
|
}
|
|
throw new Error(`injected first-run failure after child ${firstRunChildCount}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function firstRunNextCommands(distribution: string): [string, string] {
|
|
if (distribution === "codex") {
|
|
return ["codex open Codex CLI in this repo", '$aidlc "what you want built" describe your first intent'];
|
|
}
|
|
if (distribution === "kiro") {
|
|
return ["kiro-cli chat open Kiro CLI in this repo", '/aidlc "what you want built" describe your first intent'];
|
|
}
|
|
if (distribution === "opencode") {
|
|
return ["opencode open opencode in this repo", '/aidlc "what you want built" describe your first intent'];
|
|
}
|
|
if (distribution === "cursor") {
|
|
return ["cursor open Cursor in this repo", '/aidlc "what you want built" describe your first intent'];
|
|
}
|
|
if (distribution === "kiro-ide") {
|
|
return ["kiro open Kiro IDE in this repo", '/aidlc "what you want built" describe your first intent'];
|
|
}
|
|
if (distribution === "copilot") {
|
|
return ["copilot open Copilot CLI in this repo", '/aidlc "what you want built" describe your first intent'];
|
|
}
|
|
return ["claude open Claude Code in this repo", '/aidlc "what you want built" describe your first intent'];
|
|
}
|
|
|
|
function firstRunSettingsTargetLabel(target: SettingsTarget): string {
|
|
return target === "project"
|
|
? "this project, committed"
|
|
: target === "local"
|
|
? "this project, just for you"
|
|
: "this machine";
|
|
}
|
|
|
|
export function firstRunPathRemediation(
|
|
platform: NodeJS.Platform,
|
|
directory: string,
|
|
): string[] {
|
|
return platform === "win32"
|
|
? [
|
|
`Add ${directory} to your User PATH in Windows Settings, then open a new terminal.`,
|
|
]
|
|
: [
|
|
"Add this line to ~/.profile, then open a new shell:",
|
|
' export PATH="$HOME/.local/bin:$PATH"',
|
|
];
|
|
}
|
|
|
|
function applyFirstRunChoices(
|
|
projectDir: string,
|
|
choices: FirstRunChoices,
|
|
snapshot: FirstRunMutationSnapshot,
|
|
): void {
|
|
firstRunChildCount = 0;
|
|
const common = [
|
|
"--project-dir",
|
|
projectDir,
|
|
"--from",
|
|
choices.candidate.root,
|
|
"--harness",
|
|
choices.candidate.stamp.distribution,
|
|
"--mcp",
|
|
choices.mcp,
|
|
"--yes",
|
|
"--json",
|
|
];
|
|
runConfigChild(common, projectDir, snapshot);
|
|
runConfigChild([
|
|
"models",
|
|
"--project-dir",
|
|
projectDir,
|
|
`--${choices.target}`,
|
|
"--preset",
|
|
choices.preset,
|
|
"--yes",
|
|
"--json",
|
|
], projectDir, snapshot);
|
|
runConfigChild([
|
|
"project",
|
|
"--project-dir",
|
|
projectDir,
|
|
"--plugins",
|
|
choices.plugins,
|
|
"--mcp",
|
|
choices.mcp,
|
|
"--completions",
|
|
"none",
|
|
"--yes",
|
|
"--json",
|
|
], projectDir, snapshot);
|
|
const providerArgs = [
|
|
"providers",
|
|
"--project-dir",
|
|
projectDir,
|
|
"--provider",
|
|
choices.provider,
|
|
];
|
|
if (choices.provider === "amazon-bedrock") {
|
|
providerArgs.push("--region", choices.region);
|
|
if (choices.profile) providerArgs.push("--profile", choices.profile);
|
|
if (choices.candidate.stamp.distribution === "opencode") {
|
|
providerArgs.push(
|
|
"--opencode-default",
|
|
choices.opencodeDefault ? "yes" : "no",
|
|
);
|
|
}
|
|
if (choices.providerVerified) {
|
|
providerArgs.push("--mark-done", "bedrock-model-access");
|
|
}
|
|
providerArgs.push("--yes", "--json");
|
|
runConfigChild(providerArgs, projectDir, snapshot);
|
|
}
|
|
}
|
|
|
|
function firstRunMutationPaths(
|
|
projectDir: string,
|
|
choices: FirstRunChoices,
|
|
): string[] {
|
|
return [...new Set([
|
|
...choices.candidate.descriptor.managedDirectories.map((path) =>
|
|
join(projectDir, path)
|
|
),
|
|
...choices.candidate.descriptor.rootIntegrations.map((integration) =>
|
|
join(projectDir, integration.path)
|
|
),
|
|
join(projectDir, ".gitignore"),
|
|
settingsPathForTarget(projectDir, choices.target),
|
|
].map((path) => resolve(path)))];
|
|
}
|
|
|
|
function snapshotFirstRunMutationPaths(
|
|
projectDir: string,
|
|
choices: FirstRunChoices,
|
|
): FirstRunMutationSnapshot {
|
|
const root = mkdtempSync(join(tmpdir(), "aidlc-first-run-rollback-"));
|
|
const snapshots = firstRunMutationPaths(projectDir, choices).map((path, index) => {
|
|
const backup = join(root, String(index));
|
|
return {
|
|
path,
|
|
backup,
|
|
existed: false,
|
|
committed: "absent" as string | "absent",
|
|
owned: false,
|
|
};
|
|
});
|
|
const capture = (snapshot: (typeof snapshots)[number]): void => {
|
|
rmSync(snapshot.backup, { recursive: true, force: true });
|
|
snapshot.existed = pathPresent(snapshot.path);
|
|
if (snapshot.existed) {
|
|
cpSync(snapshot.path, snapshot.backup, {
|
|
recursive: true,
|
|
dereference: false,
|
|
verbatimSymlinks: true,
|
|
preserveTimestamps: true,
|
|
});
|
|
}
|
|
snapshot.committed = transactionState(snapshot.path);
|
|
};
|
|
for (const snapshot of snapshots) capture(snapshot);
|
|
return {
|
|
recoveryPath: root,
|
|
prepareChild: (args) => {
|
|
if (!args.includes("--global")) return {};
|
|
const globalPath = resolve(settingsPathForTarget(projectDir, "global"));
|
|
const snapshot = snapshots.find((candidate) =>
|
|
candidate.path === globalPath
|
|
);
|
|
if (!snapshot || snapshot.owned) return {};
|
|
capture(snapshot);
|
|
return {
|
|
AIDLC_FIRST_RUN_GLOBAL_BASELINE_STATE: snapshot.committed,
|
|
};
|
|
},
|
|
recordCommitted: (result) => {
|
|
const data = result.data;
|
|
if (!data || typeof data !== "object" || !("actions" in data)) return;
|
|
const actions = (data as { actions?: unknown }).actions;
|
|
if (!Array.isArray(actions)) return;
|
|
const changed = new Set(
|
|
actions.flatMap((action) => {
|
|
if (
|
|
!action ||
|
|
typeof action !== "object" ||
|
|
!("path" in action) ||
|
|
typeof action.path !== "string"
|
|
) {
|
|
return [];
|
|
}
|
|
return [resolve(projectDir, action.path)];
|
|
}),
|
|
);
|
|
for (const snapshot of snapshots) {
|
|
if (
|
|
[...changed].some((path) =>
|
|
path === snapshot.path || path.startsWith(`${snapshot.path}${sep}`)
|
|
)
|
|
) {
|
|
snapshot.committed = transactionState(snapshot.path);
|
|
snapshot.owned = true;
|
|
}
|
|
}
|
|
},
|
|
restore: () => {
|
|
const errors: unknown[] = [];
|
|
for (const snapshot of [...snapshots].reverse()) {
|
|
try {
|
|
const projectRelative = relative(projectDir, snapshot.path);
|
|
const projectOwned = projectRelative !== "" &&
|
|
!isAbsolute(projectRelative) &&
|
|
projectRelative !== ".." &&
|
|
!projectRelative.startsWith(`..${sep}`);
|
|
const transactionRoot = projectOwned
|
|
? projectDir
|
|
: machineTransactionRoot();
|
|
const path = relative(transactionRoot, snapshot.path);
|
|
const operations: TransactionOperation[] = snapshot.existed
|
|
? [{
|
|
kind: lstatSync(snapshot.backup).isDirectory() ? "tree" : "copy",
|
|
path,
|
|
source: snapshot.backup,
|
|
sourceHash: transactionSourceHash(snapshot.backup),
|
|
expected: snapshot.committed,
|
|
}]
|
|
: snapshot.committed === "absent"
|
|
? []
|
|
: [{
|
|
kind: "remove",
|
|
path,
|
|
expected: snapshot.committed,
|
|
}];
|
|
executePlan({
|
|
schemaVersion: 1,
|
|
root: transactionRoot,
|
|
operations,
|
|
});
|
|
} catch (error) {
|
|
errors.push(error);
|
|
}
|
|
}
|
|
if (errors.length > 0) {
|
|
throw new AggregateError(errors, "first-run rollback was incomplete");
|
|
}
|
|
},
|
|
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
};
|
|
}
|
|
|
|
function renderFirstRunEnding(
|
|
projectDir: string,
|
|
choices: FirstRunChoices,
|
|
): void {
|
|
const manifest = JSON.parse(readFileSync(
|
|
join(
|
|
projectDir,
|
|
choices.candidate.descriptor.harnessDir,
|
|
"tools",
|
|
"data",
|
|
"aidlc-manifest.json",
|
|
),
|
|
"utf-8",
|
|
)) as { files?: Record<string, string> };
|
|
const count = Object.keys(manifest.files ?? {}).length;
|
|
process.stdout.write(
|
|
`\n Writing project files ... ${successText("done", process.stdout)} (${choices.candidate.descriptor.harnessDir}/ and aidlc/, ${count} files)\n`,
|
|
);
|
|
process.stdout.write(
|
|
` Recording your choices ... ${successText("done", process.stdout)} (${
|
|
choices.target === "project"
|
|
? "aidlc.settings.json in this project"
|
|
: choices.target === "local"
|
|
? "aidlc.settings.local.json in this project"
|
|
: settingsPathForTarget(projectDir, choices.target)
|
|
})\n`,
|
|
);
|
|
const remaining = postApplyOutstandingActions(
|
|
projectDir,
|
|
choices.candidate.descriptor.harnessDir,
|
|
modelHarness(choices.candidate.stamp.distribution),
|
|
);
|
|
if (
|
|
choices.provider === "other" &&
|
|
!remaining.some((action) => action.section === "providers")
|
|
) {
|
|
remaining.push({
|
|
section: "providers",
|
|
id: "provider-manual-setup",
|
|
message: "Choose and configure a model provider, then acknowledge the completed setup.",
|
|
command: configCommandForHarness(
|
|
choices.candidate.descriptor.harnessDir,
|
|
"providers",
|
|
),
|
|
});
|
|
}
|
|
if (remaining.length > 0) {
|
|
process.stdout.write(
|
|
`\n ${remaining.length === 1 ? "One thing needs you" : `${remaining.length} things need you`} - ${
|
|
remaining.length === 1 ? "it can't" : "they can't"
|
|
} be done automatically:\n\n`,
|
|
);
|
|
for (const action of remaining) {
|
|
if (action.id === "runtime-aidlc-missing") {
|
|
process.stdout.write(
|
|
" Hooks run outside your interactive shell PATH, and aidlc is not available there.\n",
|
|
);
|
|
for (const line of firstRunPathRemediation(process.platform, binRoot())) {
|
|
process.stdout.write(` ${line}\n`);
|
|
}
|
|
process.stdout.write("\n");
|
|
process.stdout.write(
|
|
` Full diagnostics: ${
|
|
configCommandForHarness(
|
|
choices.candidate.descriptor.harnessDir,
|
|
"runtime --show",
|
|
)
|
|
}\n\n`,
|
|
);
|
|
continue;
|
|
}
|
|
process.stdout.write(` ${action.message}\n`);
|
|
process.stdout.write(` fix: ${action.command}\n\n`);
|
|
}
|
|
}
|
|
const [open, invoke] = firstRunNextCommands(
|
|
choices.candidate.stamp.distribution,
|
|
);
|
|
process.stdout.write(" Setup complete. Start your first workflow:\n\n");
|
|
process.stdout.write(` ${open}\n`);
|
|
process.stdout.write(` ${invoke}\n`);
|
|
}
|
|
|
|
function customizeFirstRun(
|
|
initial: InstalledSourceCandidate,
|
|
candidates: readonly InstalledSourceCandidate[],
|
|
detection: FirstRunDetection,
|
|
): FirstRunChoices | null {
|
|
const aws = awsSummary(detection.aws);
|
|
const choices: FirstRunChoices = {
|
|
candidate: initial,
|
|
provider: detection.aws.hasCredentials ? "amazon-bedrock" : "other",
|
|
region: aws.region,
|
|
profile: "",
|
|
preset: "balanced",
|
|
plugins: "all",
|
|
pluginLabel: "all installed",
|
|
mcp: initial.stamp.distribution === "claude" ? "defaults" : "none",
|
|
target: "project",
|
|
providerVerified: detection.bedrockReachable === true,
|
|
opencodeDefault: true,
|
|
};
|
|
const editStep = (step: number): void => {
|
|
if (step === 1) {
|
|
process.stdout.write(" Step 1 of 6 - Harness\n");
|
|
const detected = detectedCandidateChoices(candidates, detection);
|
|
process.stdout.write(
|
|
` Detected on this machine: ${
|
|
detected.length > 0
|
|
? detected.map((item) => item.descriptor.productName).join(", ")
|
|
: "none"
|
|
}.\n\n`,
|
|
);
|
|
choices.candidate = chooseHarness(
|
|
candidates,
|
|
detection,
|
|
choices.candidate.stamp.distribution,
|
|
);
|
|
choices.mcp = choices.candidate.stamp.distribution === "claude"
|
|
? "defaults"
|
|
: "none";
|
|
return;
|
|
}
|
|
if (step === 2) {
|
|
process.stdout.write(" Step 2 of 6 - Model provider\n");
|
|
process.stdout.write(
|
|
detection.aws.hasCredentials
|
|
? ` Found AWS credentials (${aws.source}); ${
|
|
aws.regionSource === "detected" ? "detected" : "fallback"
|
|
} region ${aws.region}.\n`
|
|
: " No AWS credentials were detected.\n",
|
|
);
|
|
process.stdout.write(` 1. amazon-bedrock ${
|
|
detection.aws.hasCredentials ? "(detected, default)" : ""
|
|
}\n`);
|
|
process.stdout.write(" 2. other record your own provider setup\n");
|
|
const selected = promptChoice(
|
|
" Provider",
|
|
2,
|
|
detection.aws.hasCredentials ? 1 : 2,
|
|
);
|
|
choices.provider = selected === 1 ? "amazon-bedrock" : "other";
|
|
if (choices.provider === "amazon-bedrock") {
|
|
choices.region = promptTextDefault(" AWS region", choices.region);
|
|
const profile = promptTextDefault(
|
|
" AWS profile",
|
|
choices.profile || "default credential chain",
|
|
);
|
|
choices.profile = profile === "default credential chain" ? "" : profile;
|
|
if (choices.candidate.stamp.distribution === "opencode") {
|
|
choices.opencodeDefault = promptYesDefault(
|
|
" Make Bedrock OpenCode's default provider",
|
|
choices.opencodeDefault,
|
|
);
|
|
}
|
|
process.stdout.write(
|
|
` Using amazon-bedrock in ${choices.region} with ${
|
|
choices.profile || "the default credential chain"
|
|
}.\n\n`,
|
|
);
|
|
} else {
|
|
process.stdout.write(" Using other provider setup.\n\n");
|
|
}
|
|
return;
|
|
}
|
|
if (step === 3) {
|
|
process.stdout.write(" Step 3 of 6 - Model effort preset\n");
|
|
process.stdout.write(" 1. balanced reviewing at medium effort - the shipped default\n");
|
|
process.stdout.write(" 2. thorough reviewing at xhigh effort - deepest correctness checking; slower, costlier reviews\n");
|
|
process.stdout.write(" 3. minimal lightest touch - review medium, write-ups at low effort\n");
|
|
const selected = promptChoice(
|
|
" Preset",
|
|
3,
|
|
choices.preset === "balanced" ? 1 : choices.preset === "thorough" ? 2 : 3,
|
|
);
|
|
choices.preset = selected === 1 ? "balanced" : selected === 2 ? "thorough" : "minimal";
|
|
process.stdout.write(` Using the ${choices.preset} preset.\n\n`);
|
|
return;
|
|
}
|
|
if (step === 4) {
|
|
process.stdout.write(" Step 4 of 6 - Plugins\n");
|
|
process.stdout.write(" 1. all installed (default)\n");
|
|
process.stdout.write(" 2. none optional core AI-DLC only\n");
|
|
process.stdout.write(" 3. choose comma-separated installed names\n");
|
|
const selected = promptChoice(" Plugins", 3, 1);
|
|
if (selected === 1) {
|
|
choices.plugins = "all";
|
|
choices.pluginLabel = "all installed";
|
|
} else if (selected === 2) {
|
|
choices.plugins = "aidlc";
|
|
choices.pluginLabel = "none optional";
|
|
} else {
|
|
choices.plugins = promptTextDefault(" Plugin names", "aidlc");
|
|
choices.pluginLabel = choices.plugins;
|
|
}
|
|
process.stdout.write(` Using ${choices.pluginLabel} plugins.\n\n`);
|
|
return;
|
|
}
|
|
if (step === 5) {
|
|
process.stdout.write(" Step 5 of 6 - MCP servers\n");
|
|
process.stdout.write(" 1. on\n 2. off\n");
|
|
const selected = promptChoice(
|
|
" MCP",
|
|
2,
|
|
choices.mcp === "defaults" ? 1 : 2,
|
|
);
|
|
choices.mcp = selected === 1 ? "defaults" : "none";
|
|
process.stdout.write(` MCP servers ${selected === 1 ? "on" : "off"}.\n\n`);
|
|
return;
|
|
}
|
|
process.stdout.write(" Step 6 of 6 - Where to record these choices\n");
|
|
process.stdout.write(" 1. this project, committed aidlc.settings.json - shared with your team (default)\n");
|
|
process.stdout.write(" 2. this project, just for you aidlc.settings.local.json - gitignored\n");
|
|
process.stdout.write(" 3. this machine every project you set up here\n");
|
|
const selected = promptChoice(
|
|
" Record in",
|
|
3,
|
|
choices.target === "project" ? 1 : choices.target === "local" ? 2 : 3,
|
|
);
|
|
choices.target = selected === 1 ? "project" : selected === 2 ? "local" : "global";
|
|
process.stdout.write(` Recording choices in ${firstRunSettingsTargetLabel(choices.target)}.\n\n`);
|
|
};
|
|
|
|
process.stdout.write("\n Customize setup - 6 steps, Enter accepts the [default].\n\n");
|
|
for (let step = 1; step <= 6; step++) editStep(step);
|
|
while (true) {
|
|
process.stdout.write(" Your choices - Enter to apply, or a number to change:\n");
|
|
process.stdout.write(` 1. Harness ${choices.candidate.descriptor.productName}\n`);
|
|
process.stdout.write(` 2. Provider ${
|
|
choices.provider === "amazon-bedrock"
|
|
? `amazon-bedrock, ${choices.region}, ${choices.profile || "default credential chain"}`
|
|
: "other"
|
|
}\n`);
|
|
process.stdout.write(` 3. Preset ${choices.preset}\n`);
|
|
process.stdout.write(` 4. Plugins ${choices.pluginLabel}\n`);
|
|
process.stdout.write(` 5. MCP ${choices.mcp === "defaults" ? "on" : "off"}\n`);
|
|
process.stdout.write(` 6. Record in ${firstRunSettingsTargetLabel(choices.target)}\n`);
|
|
const value = firstRunPromptValue(configPrompt(" Apply? [Y/n]:")).toLowerCase();
|
|
if (!value || value === "y" || value === "yes") return choices;
|
|
if (value === "n" || value === "no") {
|
|
process.stdout.write("\n Nothing written.\n");
|
|
return null;
|
|
}
|
|
if (/^[1-6]$/.test(value)) {
|
|
process.stdout.write("\n");
|
|
editStep(Number(value));
|
|
continue;
|
|
}
|
|
process.stdout.write("\n Enter y, n, or a step number from 1 to 6.\n\n");
|
|
}
|
|
}
|
|
|
|
async function runFirstRunWizard(projectDir: string): Promise<boolean> {
|
|
try {
|
|
const candidates = installedSourceCandidates();
|
|
if (candidates.length === 0) return false;
|
|
const detection = detectFirstRun(projectDir, candidates);
|
|
const detected = detectedCandidateChoices(candidates, detection);
|
|
let candidate: InstalledSourceCandidate;
|
|
if (detected.length === 1) {
|
|
candidate = detected[0];
|
|
} else if (detected.length > 1) {
|
|
process.stdout.write("\n Choose the harness for this project first.\n\n");
|
|
candidate = chooseHarness(candidates, detection, detected[0].stamp.distribution);
|
|
} else {
|
|
process.stdout.write("\n No supported harness CLI was detected. Choose one to configure:\n\n");
|
|
candidate = chooseHarness(candidates, detection);
|
|
}
|
|
const aws = awsSummary(detection.aws);
|
|
const runtimeCount = detection.runtimeIssues.length;
|
|
process.stdout.write("\n AI-DLC setup - first run in this project.\n\n");
|
|
process.stdout.write(" Checked your machine and this repo:\n\n");
|
|
const harnessDetection = detection.harnesses[candidate.stamp.distribution];
|
|
const displayedVersion = harnessDetection?.version
|
|
? /\d+\.\d+\.\d+(?:[-+][^\s)]+)?/.exec(harnessDetection.version)?.[0] ??
|
|
harnessDetection.version
|
|
: undefined;
|
|
process.stdout.write(
|
|
` Harness ${candidate.descriptor.productName} ${
|
|
harnessDetection?.found ? "detected" : "selected"
|
|
}${
|
|
displayedVersion
|
|
? ` (${displayedVersion} on your PATH)`
|
|
: harnessDetection?.probed === false
|
|
? " (CLI not probed)"
|
|
: ""
|
|
}\n`,
|
|
);
|
|
process.stdout.write(
|
|
` Project ${
|
|
existsSync(join(projectDir, ".git")) ? "git repo" : "project directory"
|
|
}, no AI-DLC files yet\n`,
|
|
);
|
|
process.stdout.write(
|
|
` AWS ${
|
|
detection.aws.hasCredentials
|
|
? `credentials found (${aws.source}, ${
|
|
aws.regionSource === "detected" ? "detected" : "fallback"
|
|
} region ${aws.region})`
|
|
: "credentials not found"
|
|
}\n`,
|
|
);
|
|
process.stdout.write(
|
|
` Runtime ${
|
|
runtimeCount === 0
|
|
? "ready"
|
|
: `${runtimeCount === 1 ? "one" : runtimeCount} PATH fix${
|
|
runtimeCount === 1 ? "" : "es"
|
|
} needed - shown at the end`
|
|
}\n\n`,
|
|
);
|
|
process.stdout.write(
|
|
` Set up AI-DLC for ${candidate.descriptor.productName} with recommended defaults?\n\n`,
|
|
);
|
|
process.stdout.write(
|
|
` 1. Yes, use recommended defaults ${
|
|
candidate.stamp.distribution === "claude" ? "MCP servers on, " : ""
|
|
}all plugins, ${
|
|
detection.aws.hasCredentials
|
|
? "Bedrock via your AWS credentials"
|
|
: "provider recorded as other; manual provider setup remains"
|
|
}\n`,
|
|
);
|
|
process.stdout.write(
|
|
" 2. No, customize step by step harness, provider, preset, plugins, MCP, record layer\n",
|
|
);
|
|
process.stdout.write(" 3. Exit, nothing written\n\n");
|
|
const selected = promptChoice(" Choice", 3, 1);
|
|
if (selected === 3) {
|
|
process.stdout.write("\n Nothing written.\n");
|
|
return true;
|
|
}
|
|
let choices: FirstRunChoices | null;
|
|
if (selected === 1) {
|
|
choices = {
|
|
candidate,
|
|
provider: detection.aws.hasCredentials ? "amazon-bedrock" : "other",
|
|
region: aws.region,
|
|
profile: "",
|
|
preset: "balanced",
|
|
plugins: "all",
|
|
pluginLabel: "all installed",
|
|
mcp: candidate.stamp.distribution === "claude" ? "defaults" : "none",
|
|
target: "project",
|
|
providerVerified: detection.bedrockReachable === true,
|
|
opencodeDefault: true,
|
|
};
|
|
} else {
|
|
choices = customizeFirstRun(candidate, candidates, detection);
|
|
}
|
|
if (!choices) return true;
|
|
const snapshot = snapshotFirstRunMutationPaths(projectDir, choices);
|
|
let preserveSnapshot = false;
|
|
try {
|
|
applyFirstRunChoices(projectDir, choices, snapshot);
|
|
renderFirstRunEnding(projectDir, choices);
|
|
} catch (error) {
|
|
try {
|
|
snapshot.restore();
|
|
} catch (rollbackError) {
|
|
preserveSnapshot = true;
|
|
throw new AggregateError(
|
|
[error, rollbackError],
|
|
`setup failed and rollback was incomplete; recovery snapshot preserved at ${snapshot.recoveryPath}`,
|
|
);
|
|
}
|
|
process.stdout.write(
|
|
`\n Setup stopped: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
);
|
|
process.stdout.write(" No setup changes were kept.\n");
|
|
process.exitCode = EXIT.failure;
|
|
} finally {
|
|
if (!preserveSnapshot) snapshot.cleanup();
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof FirstRunCancelled) {
|
|
process.stdout.write("\n Nothing written.\n");
|
|
process.exitCode = EXIT.usage;
|
|
return true;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function existingProject(projectDir: string, requested?: string): {
|
|
distribution?: string;
|
|
baseline?: Baseline;
|
|
} {
|
|
const harnesses = discoverProjectHarnesses(projectDir);
|
|
const harness = requested
|
|
? harnesses.find((candidate) => candidate.distribution === requested)
|
|
: harnesses[0];
|
|
if (!harness && requested && harnesses.length > 0) {
|
|
throw new Error(
|
|
`project uses ${harnesses.map((candidate) => candidate.distribution).join(", ")}; refusing ${requested}`,
|
|
);
|
|
}
|
|
if (!harness) return {};
|
|
const baselinePath = join(harness.root, "tools", "data", "aidlc-manifest.json");
|
|
const baseline = readBaseline(baselinePath);
|
|
if (
|
|
baseline &&
|
|
(
|
|
baseline.distribution !== harness.distribution ||
|
|
baseline.harnessDir !== harness.harnessDir
|
|
)
|
|
) {
|
|
throw new Error(`${baselinePath}: baseline identity does not match the installed harness`);
|
|
}
|
|
return {
|
|
distribution: harness.distribution,
|
|
...(baseline ? { baseline } : {}),
|
|
};
|
|
}
|
|
|
|
function planManagedFiles(
|
|
projectDir: string,
|
|
sourceRoot: string,
|
|
descriptor: ProjectionDescriptor,
|
|
prior: Baseline | null,
|
|
force: boolean,
|
|
operations: TransactionOperation[],
|
|
actions: PlannedAction[],
|
|
nextHashes: Record<string, string>,
|
|
regenerated: ReadonlySet<string>,
|
|
): void {
|
|
const shipped = new Set<string>();
|
|
for (const directory of descriptor.managedDirectories) {
|
|
const sourceDir = join(sourceRoot, directory);
|
|
if (!existsSync(sourceDir)) throw new Error(`projection is missing managed directory ${directory}`);
|
|
for (const nested of walkFiles(sourceDir)) {
|
|
const rel = join(directory, nested).replaceAll("\\", "/");
|
|
shipped.add(rel);
|
|
const source = join(sourceRoot, rel);
|
|
const target = join(projectDir, rel);
|
|
const targetExists = pathPresent(target);
|
|
const targetRegular = targetExists && lstatSync(target).isFile();
|
|
const hash = sha256File(source);
|
|
const adoptedManagedFile = prior === null &&
|
|
targetRegular &&
|
|
(
|
|
descriptor.legacyManagedFileHashes?.[rel]?.includes(
|
|
sha256File(target),
|
|
) ?? false
|
|
);
|
|
const seedOnly = rel === "aidlc/active-space" ||
|
|
(rel.startsWith("aidlc/spaces/") && rel.includes("/memory/"));
|
|
if (seedOnly) {
|
|
if (targetExists) {
|
|
actions.push({ path: rel, action: "preserve", detail: "project-owned seed" });
|
|
} else {
|
|
operations.push({
|
|
kind: "copy",
|
|
path: rel,
|
|
source,
|
|
sourceHash: hash,
|
|
expected: "absent",
|
|
mode: statSync(source).mode & 0o777,
|
|
});
|
|
actions.push({ path: rel, action: "create" });
|
|
}
|
|
continue;
|
|
}
|
|
if (runtimeGenerated(rel, descriptor.harnessDir, regenerated)) {
|
|
if (
|
|
![
|
|
`${descriptor.harnessDir}/tools/data/harness.json`,
|
|
`${descriptor.harnessDir}/tools/data/stage-graph.json`,
|
|
`${descriptor.harnessDir}/tools/data/scope-grid.json`,
|
|
].includes(rel)
|
|
) {
|
|
nextHashes[rel] = hash;
|
|
}
|
|
if (targetRegular && sha256File(target) === hash) {
|
|
actions.push({ path: rel, action: "preserve", detail: "runtime-generated" });
|
|
continue;
|
|
}
|
|
if (targetExists && !targetRegular && !force) {
|
|
actions.push({ path: rel, action: "conflict", detail: "managed path is not a regular file" });
|
|
continue;
|
|
}
|
|
operations.push({
|
|
kind: "copy",
|
|
path: rel,
|
|
source,
|
|
sourceHash: hash,
|
|
expected: expected(target),
|
|
mode: statSync(source).mode & 0o777,
|
|
});
|
|
actions.push({
|
|
path: rel,
|
|
action: targetExists ? "update" : "create",
|
|
detail: "runtime-generated",
|
|
});
|
|
continue;
|
|
}
|
|
nextHashes[rel] = hash;
|
|
if (targetRegular && sha256File(target) === hash) {
|
|
actions.push({ path: rel, action: "preserve" });
|
|
continue;
|
|
}
|
|
const priorHash = prior?.files[rel];
|
|
if (
|
|
targetExists &&
|
|
(
|
|
!targetRegular ||
|
|
(!adoptedManagedFile && (!priorHash || sha256File(target) !== priorHash))
|
|
) &&
|
|
!force
|
|
) {
|
|
actions.push({ path: rel, action: "conflict", detail: "locally modified or unowned" });
|
|
continue;
|
|
}
|
|
operations.push({
|
|
kind: "copy",
|
|
path: rel,
|
|
source,
|
|
sourceHash: hash,
|
|
expected: expected(target),
|
|
mode: statSync(source).mode & 0o777,
|
|
});
|
|
actions.push({
|
|
path: rel,
|
|
action: targetExists ? "update" : "create",
|
|
detail: adoptedManagedFile ? "adopted exact copy-channel signature" : undefined,
|
|
});
|
|
}
|
|
}
|
|
for (const [rel, priorHash] of Object.entries(prior?.files ?? {})) {
|
|
if (shipped.has(rel) || rel.endsWith("/tools/data/aidlc-manifest.json")) continue;
|
|
const target = join(projectDir, rel);
|
|
if (!pathPresent(target)) continue;
|
|
if ((!regularFile(target) || sha256File(target) !== priorHash) && !force) {
|
|
actions.push({ path: rel, action: "conflict", detail: "removed upstream but locally modified" });
|
|
continue;
|
|
}
|
|
operations.push({ kind: "remove", path: rel, expected: expected(target) });
|
|
actions.push({ path: rel, action: "remove" });
|
|
}
|
|
}
|
|
|
|
function planRootIntegrations(
|
|
projectDir: string,
|
|
sourceRoot: string,
|
|
descriptor: ProjectionDescriptor,
|
|
prior: Baseline | null,
|
|
mcpMode: "defaults" | "none",
|
|
force: boolean,
|
|
operations: TransactionOperation[],
|
|
actions: PlannedAction[],
|
|
contributions: Record<string, RootContribution>,
|
|
): void {
|
|
for (const integration of descriptor.rootIntegrations) {
|
|
const sourcePath = join(sourceRoot, integration.path);
|
|
const targetPath = join(projectDir, integration.path);
|
|
const targetExists = pathPresent(targetPath);
|
|
const targetRegular = targetExists && lstatSync(targetPath).isFile();
|
|
if (targetExists && !targetRegular && !force) {
|
|
actions.push({
|
|
path: integration.path,
|
|
action: "conflict",
|
|
detail: "root integration is not a regular file",
|
|
});
|
|
continue;
|
|
}
|
|
const current = targetRegular ? readFileSync(targetPath, "utf-8") : "";
|
|
const priorContribution = prior?.rootContributions[integration.path];
|
|
if (integration.policy === "managed-block") {
|
|
const merged = mergeBlock(
|
|
integration.path,
|
|
current,
|
|
readFileSync(sourcePath, "utf-8"),
|
|
integration.marker || basename(integration.path),
|
|
integration.legacySignatures?.wholeFileHashes,
|
|
);
|
|
if (merged.error) {
|
|
actions.push({ path: integration.path, action: "conflict", detail: merged.error });
|
|
continue;
|
|
}
|
|
const value = merged.value as string;
|
|
const priorHash = priorContribution?.policy === "managed-block"
|
|
? priorContribution.hash
|
|
: undefined;
|
|
if (
|
|
merged.currentHash &&
|
|
merged.currentHash !== merged.nextHash &&
|
|
merged.currentHash !== priorHash &&
|
|
!force
|
|
) {
|
|
actions.push({
|
|
path: integration.path,
|
|
action: "conflict",
|
|
detail: priorHash ? "managed block was locally modified" : "managed block has no ownership baseline",
|
|
});
|
|
continue;
|
|
}
|
|
contributions[integration.path] = {
|
|
policy: "managed-block",
|
|
hash: merged.nextHash as string,
|
|
marker: integration.marker,
|
|
};
|
|
if (value === current) {
|
|
actions.push({ path: integration.path, action: "preserve" });
|
|
} else {
|
|
operations.push(writeOperation(integration.path, value, expected(targetPath)));
|
|
actions.push({
|
|
path: integration.path,
|
|
action: targetExists ? "merge" : "create",
|
|
detail: merged.adoptedLegacy ? "adopted exact legacy signature" : undefined,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
if (integration.policy === "json-map") {
|
|
let targetValue: unknown;
|
|
let sourceValue: unknown;
|
|
try {
|
|
targetValue = current ? JSON.parse(current) : {};
|
|
sourceValue = JSON.parse(readFileSync(sourcePath, "utf-8"));
|
|
} catch {
|
|
actions.push({ path: integration.path, action: "conflict", detail: "malformed JSON" });
|
|
continue;
|
|
}
|
|
if (!isRecord(targetValue) || !isRecord(sourceValue)) {
|
|
actions.push({ path: integration.path, action: "conflict", detail: "JSON root must be an object" });
|
|
continue;
|
|
}
|
|
const target = targetValue;
|
|
const source = sourceValue;
|
|
const key = integration.jsonKey as string;
|
|
const rawTargetMap = target[key] ?? {};
|
|
const rawSourceMap = source[key] ?? {};
|
|
if (!isRecord(rawTargetMap) || !isRecord(rawSourceMap)) {
|
|
actions.push({
|
|
path: integration.path,
|
|
action: "conflict",
|
|
detail: `${key} must be a JSON object`,
|
|
});
|
|
continue;
|
|
}
|
|
const targetMap = { ...rawTargetMap };
|
|
const sourceMap = rawSourceMap;
|
|
const priorEntries = priorContribution?.policy === "json-map"
|
|
? priorContribution.entries
|
|
: {};
|
|
const nextEntries: Record<string, string> = {};
|
|
if (!current && integration.optional && mcpMode === "none") {
|
|
contributions[integration.path] = {
|
|
policy: "json-map",
|
|
entries: {},
|
|
key: integration.jsonKey,
|
|
};
|
|
actions.push({ path: integration.path, action: "preserve", detail: "optional integration disabled" });
|
|
continue;
|
|
}
|
|
if (mcpMode === "defaults") {
|
|
for (const [entry, value] of Object.entries(sourceMap)) {
|
|
const desiredHash = sha256Bytes(canonical(value));
|
|
if (!(entry in targetMap)) {
|
|
targetMap[entry] = value;
|
|
nextEntries[entry] = desiredHash;
|
|
continue;
|
|
}
|
|
const currentHash = sha256Bytes(canonical(targetMap[entry]));
|
|
const priorHash = priorEntries[entry];
|
|
if (priorHash && (currentHash === priorHash || force)) {
|
|
targetMap[entry] = value;
|
|
nextEntries[entry] = desiredHash;
|
|
} else if (priorHash && currentHash === desiredHash) {
|
|
nextEntries[entry] = desiredHash;
|
|
} else if (
|
|
!priorHash &&
|
|
(integration.legacySignatures?.jsonEntryHashes?.[entry] ?? []).includes(currentHash)
|
|
) {
|
|
targetMap[entry] = value;
|
|
nextEntries[entry] = desiredHash;
|
|
}
|
|
}
|
|
for (const [entry, priorHash] of Object.entries(priorEntries)) {
|
|
if (entry in sourceMap || !(entry in targetMap)) continue;
|
|
const currentHash = sha256Bytes(canonical(targetMap[entry]));
|
|
if (currentHash === priorHash || force) delete targetMap[entry];
|
|
}
|
|
} else {
|
|
for (const [entry, priorHash] of Object.entries(priorEntries)) {
|
|
if (!(entry in targetMap)) continue;
|
|
const currentHash = sha256Bytes(canonical(targetMap[entry]));
|
|
if (currentHash === priorHash || force) {
|
|
delete targetMap[entry];
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(targetMap).length > 0) target[key] = targetMap;
|
|
else delete target[key];
|
|
contributions[integration.path] = {
|
|
policy: "json-map",
|
|
entries: nextEntries,
|
|
key: integration.jsonKey,
|
|
};
|
|
const semanticChanged = canonical(targetValue) !== canonical(current ? JSON.parse(current) : {});
|
|
if (!semanticChanged) {
|
|
actions.push({ path: integration.path, action: "preserve" });
|
|
} else {
|
|
const value = `${JSON.stringify(target, null, 2)}\n`;
|
|
operations.push(writeOperation(integration.path, value, expected(targetPath)));
|
|
actions.push({ path: integration.path, action: targetExists ? "merge" : "create" });
|
|
}
|
|
continue;
|
|
}
|
|
if (integration.policy === "json-array") {
|
|
let targetValue: unknown;
|
|
let sourceValue: unknown;
|
|
try {
|
|
targetValue = current ? JSON.parse(current) : {};
|
|
sourceValue = JSON.parse(readFileSync(sourcePath, "utf-8"));
|
|
} catch {
|
|
actions.push({ path: integration.path, action: "conflict", detail: "malformed JSON" });
|
|
continue;
|
|
}
|
|
if (!isRecord(targetValue) || !isRecord(sourceValue)) {
|
|
actions.push({ path: integration.path, action: "conflict", detail: "JSON root must be an object" });
|
|
continue;
|
|
}
|
|
const key = integration.jsonKey as string;
|
|
const targetArray = targetValue[key] ?? [];
|
|
const sourceArray = sourceValue[key] ?? [];
|
|
if (
|
|
!Array.isArray(targetArray) ||
|
|
!Array.isArray(sourceArray) ||
|
|
!targetArray.every((item) => typeof item === "string") ||
|
|
!sourceArray.every((item) => typeof item === "string")
|
|
) {
|
|
actions.push({ path: integration.path, action: "conflict", detail: `${key} must be a string array` });
|
|
continue;
|
|
}
|
|
const priorEntries = priorContribution?.policy === "json-array"
|
|
? priorContribution.entries
|
|
: {};
|
|
const desired = new Map(
|
|
sourceArray.map((item) => [item, sha256Bytes(canonical(item))]),
|
|
);
|
|
const nextEntries: Record<string, string> = {};
|
|
const retained = targetArray.filter((item) => {
|
|
const priorHash = priorEntries[item];
|
|
if (priorHash && desired.has(item)) nextEntries[item] = desired.get(item) as string;
|
|
return !priorHash || desired.has(item) || sha256Bytes(canonical(item)) !== priorHash;
|
|
});
|
|
for (const item of sourceArray) {
|
|
if (!retained.includes(item)) {
|
|
retained.push(item);
|
|
nextEntries[item] = desired.get(item) as string;
|
|
}
|
|
}
|
|
if (retained.length > 0) targetValue[key] = retained;
|
|
else delete targetValue[key];
|
|
contributions[integration.path] = {
|
|
policy: "json-array",
|
|
entries: nextEntries,
|
|
key,
|
|
};
|
|
const semanticChanged = canonical(targetValue) !== canonical(current ? JSON.parse(current) : {});
|
|
if (!semanticChanged) {
|
|
actions.push({ path: integration.path, action: "preserve" });
|
|
} else {
|
|
operations.push(writeOperation(
|
|
integration.path,
|
|
`${JSON.stringify(targetValue, null, 2)}\n`,
|
|
expected(targetPath),
|
|
));
|
|
actions.push({ path: integration.path, action: targetExists ? "merge" : "create" });
|
|
}
|
|
continue;
|
|
}
|
|
const shipped = readFileSync(sourcePath);
|
|
const shippedHash = sha256Bytes(shipped);
|
|
const priorHash = priorContribution?.policy === "whole-file"
|
|
? priorContribution.hash
|
|
: undefined;
|
|
const currentHash = sha256Bytes(current);
|
|
const adoptedLegacy = integration.legacySignatures?.wholeFileHashes?.includes(currentHash) ?? false;
|
|
contributions[integration.path] = { policy: "whole-file", hash: shippedHash };
|
|
if (
|
|
targetExists &&
|
|
currentHash !== priorHash &&
|
|
currentHash !== shippedHash &&
|
|
!adoptedLegacy
|
|
) {
|
|
actions.push({ path: integration.path, action: "conflict", detail: "unowned whole file" });
|
|
} else if (currentHash === shippedHash) {
|
|
actions.push({ path: integration.path, action: "preserve" });
|
|
} else {
|
|
operations.push(writeOperation(integration.path, shipped, expected(targetPath)));
|
|
actions.push({
|
|
path: integration.path,
|
|
action: targetExists ? "update" : "create",
|
|
detail: adoptedLegacy ? "adopted exact legacy signature" : undefined,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function planRemovedRootIntegrations(
|
|
projectDir: string,
|
|
descriptor: ProjectionDescriptor,
|
|
prior: Baseline | null,
|
|
force: boolean,
|
|
operations: TransactionOperation[],
|
|
actions: PlannedAction[],
|
|
): void {
|
|
const current = new Set(descriptor.rootIntegrations.map((item) => item.path));
|
|
for (const [path, contribution] of Object.entries(prior?.rootContributions ?? {})) {
|
|
if (current.has(path)) continue;
|
|
const targetPath = join(projectDir, path);
|
|
if (!pathPresent(targetPath)) continue;
|
|
if (!regularFile(targetPath)) {
|
|
if (!force) {
|
|
actions.push({ path, action: "conflict", detail: "retired root integration is not a regular file" });
|
|
continue;
|
|
}
|
|
operations.push({ kind: "remove", path, expected: expected(targetPath) });
|
|
actions.push({ path, action: "remove" });
|
|
continue;
|
|
}
|
|
const text = readFileSync(targetPath, "utf-8");
|
|
if (contribution.policy === "managed-block") {
|
|
const fallback = basename(path).replace(/\.[^.]+$/, "").toLowerCase();
|
|
const { begin, end } = managedBlockMarkers(
|
|
path,
|
|
contribution.marker ?? fallback,
|
|
);
|
|
const beginAt = text.indexOf(begin);
|
|
const endAt = text.indexOf(end, beginAt + begin.length);
|
|
if (beginAt < 0 || endAt < beginAt) {
|
|
actions.push({ path, action: "conflict", detail: "retired managed block markers are missing" });
|
|
continue;
|
|
}
|
|
const blockEnd = endAt + end.length;
|
|
if (sha256Bytes(text.slice(beginAt, blockEnd)) !== contribution.hash && !force) {
|
|
actions.push({ path, action: "conflict", detail: "retired managed block was locally modified" });
|
|
continue;
|
|
}
|
|
let value = `${text.slice(0, beginAt)}${text.slice(blockEnd)}`;
|
|
value = value.replace(/^\r?\n/, "").replace(/\r?\n\r?\n$/, "\n");
|
|
if (!value) {
|
|
operations.push({ kind: "remove", path, expected: expected(targetPath) });
|
|
actions.push({ path, action: "remove" });
|
|
} else {
|
|
operations.push(writeOperation(path, value, expected(targetPath)));
|
|
actions.push({ path, action: "merge", detail: "removed retired managed block" });
|
|
}
|
|
continue;
|
|
}
|
|
if (contribution.policy === "json-map") {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch {
|
|
actions.push({ path, action: "conflict", detail: "retired JSON integration is malformed" });
|
|
continue;
|
|
}
|
|
if (!isRecord(parsed)) {
|
|
actions.push({ path, action: "conflict", detail: "retired JSON integration root is not an object" });
|
|
continue;
|
|
}
|
|
const maps = contribution.key && isRecord(parsed[contribution.key])
|
|
? [parsed[contribution.key] as Record<string, unknown>]
|
|
: Object.values(parsed).filter(isRecord);
|
|
let conflict = false;
|
|
for (const [entry, priorHash] of Object.entries(contribution.entries)) {
|
|
for (const map of maps) {
|
|
if (!(entry in map)) continue;
|
|
if (sha256Bytes(canonical(map[entry])) !== priorHash && !force) conflict = true;
|
|
else delete map[entry];
|
|
}
|
|
}
|
|
if (conflict) {
|
|
actions.push({ path, action: "conflict", detail: "retired JSON entry was locally modified" });
|
|
continue;
|
|
}
|
|
operations.push(writeOperation(path, `${JSON.stringify(parsed, null, 2)}\n`, expected(targetPath)));
|
|
actions.push({ path, action: "merge", detail: "removed retired JSON entries" });
|
|
continue;
|
|
}
|
|
if (contribution.policy === "json-array") {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch {
|
|
actions.push({ path, action: "conflict", detail: "retired JSON integration is malformed" });
|
|
continue;
|
|
}
|
|
if (!isRecord(parsed) || !Array.isArray(parsed[contribution.key])) {
|
|
actions.push({ path, action: "conflict", detail: "retired JSON array integration is malformed" });
|
|
continue;
|
|
}
|
|
const values = parsed[contribution.key] as unknown[];
|
|
const retired = new Set(Object.keys(contribution.entries));
|
|
parsed[contribution.key] = values.filter((value) =>
|
|
typeof value !== "string" || !retired.has(value) ||
|
|
sha256Bytes(canonical(value)) !== contribution.entries[value]
|
|
);
|
|
if ((parsed[contribution.key] as unknown[]).length === 0) delete parsed[contribution.key];
|
|
operations.push(writeOperation(path, `${JSON.stringify(parsed, null, 2)}\n`, expected(targetPath)));
|
|
actions.push({ path, action: "merge", detail: "removed retired JSON array entries" });
|
|
continue;
|
|
}
|
|
if (sha256File(targetPath) !== contribution.hash && !force) {
|
|
actions.push({ path, action: "conflict", detail: "retired whole-file integration was locally modified" });
|
|
continue;
|
|
}
|
|
operations.push({ kind: "remove", path, expected: expected(targetPath) });
|
|
actions.push({ path, action: "remove" });
|
|
}
|
|
}
|
|
|
|
function prepareModelsSection(
|
|
argv: string[],
|
|
options: ReturnType<typeof globalOptions>,
|
|
): { argv: string[]; context: ModelsMutationContext } | null {
|
|
const validation = validateModelsArgs(argv);
|
|
if (validation) {
|
|
emitResult(usage(validation, configCommand("models --help")), options);
|
|
return null;
|
|
}
|
|
if (argv.includes("--help")) {
|
|
process.stdout.write(`${modelPolicyHelp()}\n`);
|
|
process.exitCode = EXIT.ok;
|
|
return null;
|
|
}
|
|
const mutationFlags = [
|
|
"--agent",
|
|
"--deciding-effort",
|
|
"--effort",
|
|
"--from",
|
|
"--model",
|
|
"--preset",
|
|
"--reset",
|
|
"--reviewing-effort",
|
|
"--save-as",
|
|
"--writing-up-effort",
|
|
];
|
|
const hasMutationFlags = mutationFlags.some((flag) => argv.includes(flag));
|
|
if (
|
|
(argv.includes("--show") || argv.includes("--check")) &&
|
|
(hasMutationFlags || argv.includes("--dry-run") || argv.includes("--yes"))
|
|
) {
|
|
emitResult(
|
|
usage("--show and --check cannot be combined with model policy mutations"),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
if (argv.includes("--show") && argv.includes("--check")) {
|
|
emitResult(usage("--show and --check are mutually exclusive"), options);
|
|
return null;
|
|
}
|
|
const projectDir = projectDirFrom(argv);
|
|
const requested = valueAfter(argv, "--harness");
|
|
const harnesses = discoverProjectHarnesses(projectDir);
|
|
const selected = requested
|
|
? harnesses.find((candidate) => candidate.distribution === requested)
|
|
: harnesses[0];
|
|
if (!selected) {
|
|
emitResult(
|
|
usage(
|
|
requested && harnesses.length > 0
|
|
? `project uses ${harnesses.map((item) => item.distribution).join(", ")}; refusing ${requested}`
|
|
: `${configCommand("models")} requires an installed project harness; run ${configCommand()} first`,
|
|
),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
if (!requested && harnesses.length > 1) {
|
|
emitResult(
|
|
usage("multiple project harnesses are present; pass one --harness <name>"),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
const harness = modelHarness(selected.distribution);
|
|
const resolved = resolveAidlcSettings(projectDir);
|
|
const current = modelPolicyForHarness(resolved.models, harness);
|
|
const tiers = readAgentTiers(selected.root);
|
|
if (argv.includes("--show")) {
|
|
showModels(current, tiers, harness, projectDir, resolved, options);
|
|
return null;
|
|
}
|
|
if (argv.includes("--check")) {
|
|
const drift = modelPolicySurfaceDrift(
|
|
projectDir,
|
|
selected.harnessDir,
|
|
harness,
|
|
current,
|
|
);
|
|
emitResult(
|
|
drift.length === 0
|
|
? success(`model policy is reflected on ${harness}`, {
|
|
harness,
|
|
drift: [],
|
|
})
|
|
: failure(
|
|
`model policy drift: ${drift.join("; ")}`,
|
|
EXIT.failure,
|
|
configCommand("models --show"),
|
|
),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (!hasMutationFlags && !configInputIsTty()) {
|
|
emitResult(
|
|
usage(
|
|
"non-interactive model configuration requires a policy flag: --preset, --from, a group effort flag, --agent, or --reset; --yes confirms but never chooses a policy",
|
|
configCommand("models --help"),
|
|
),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
const target = settingsTargetForMutation(argv, projectDir);
|
|
const targetPath = settingsPathForTarget(projectDir, target);
|
|
const targetCurrentSettings = readSettingsTarget(projectDir, target);
|
|
const targetCurrent = modelPolicyForHarness(
|
|
targetCurrentSettings?.models ?? null,
|
|
harness,
|
|
);
|
|
let targetNext: ModelPolicyRecord | null;
|
|
if (hasMutationFlags) {
|
|
targetNext = applyModelsFlags(targetCurrent, argv, tiers, current);
|
|
} else {
|
|
targetNext = modelsWizard(
|
|
current,
|
|
targetCurrent,
|
|
tiers,
|
|
harness,
|
|
projectDir,
|
|
resolved,
|
|
);
|
|
}
|
|
const targetModels = settingsModelsFromHarnessPolicy(
|
|
targetCurrentSettings?.models ?? null,
|
|
harness,
|
|
targetNext,
|
|
);
|
|
const targetNextSettings = updateSettingsSection(
|
|
targetCurrentSettings,
|
|
"models",
|
|
targetModels,
|
|
);
|
|
if (canonical(targetCurrentSettings) === canonical(targetNextSettings)) {
|
|
emitResult(success("model policy unchanged"), options);
|
|
return null;
|
|
}
|
|
const nextResolved = resolveAidlcSettingsWithOverride(
|
|
projectDir,
|
|
target,
|
|
targetNextSettings,
|
|
);
|
|
const next = modelPolicyForHarness(nextResolved.models, harness);
|
|
if (!argv.includes("--dry-run") && !options.yes) {
|
|
if (!configInputIsTty()) {
|
|
emitResult(
|
|
usage(
|
|
"non-interactive model policy mutation requires --yes; --yes confirms the selected policy but never chooses one",
|
|
configMutationRerun("models", argv),
|
|
),
|
|
options,
|
|
);
|
|
return null;
|
|
}
|
|
const answer = configPrompt("Apply model policy changes? [y/N]:");
|
|
if (!answer || !/^y(?:es)?$/i.test(answer.trim())) {
|
|
emitResult(usage("model policy change cancelled"), options);
|
|
return null;
|
|
}
|
|
}
|
|
const summary = modelSummaryLines(current, next, tiers, harness, projectDir);
|
|
return {
|
|
argv: modelsPipelineArgv(argv),
|
|
context: {
|
|
harness,
|
|
harnessDir: selected.harnessDir,
|
|
previous: current,
|
|
next,
|
|
tiers,
|
|
summaryLines: summary.lines,
|
|
notes: summary.notes,
|
|
settings: {
|
|
target,
|
|
path: targetPath,
|
|
previous: targetCurrentSettings,
|
|
next: targetNextSettings,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function handleSettingsOnlySection(
|
|
section: "models" | "flags",
|
|
argv: string[],
|
|
options: ReturnType<typeof globalOptions>,
|
|
): boolean {
|
|
const projectDir = projectDirFrom(argv);
|
|
if (discoverProjectHarnesses(projectDir).length > 0 || argv.includes("--help")) {
|
|
return false;
|
|
}
|
|
const validation = section === "models"
|
|
? validateModelsArgs(argv)
|
|
: validateChoiceArgs("flags", argv);
|
|
if (validation) {
|
|
emitResult(usage(validation, configCommand(`${section} --help`)), options);
|
|
return true;
|
|
}
|
|
const modelMutationFlags = [
|
|
"--agent",
|
|
"--deciding-effort",
|
|
"--effort",
|
|
"--from",
|
|
"--model",
|
|
"--preset",
|
|
"--reset",
|
|
"--reviewing-effort",
|
|
"--save-as",
|
|
"--writing-up-effort",
|
|
];
|
|
const flagMutationFlags = [
|
|
"--bypass",
|
|
"--clear-bypass",
|
|
"--default-scope",
|
|
"--hook-debug",
|
|
"--reset",
|
|
"--sensor-timeout-ms",
|
|
"--swarm",
|
|
];
|
|
const mutationFlags = section === "models" ? modelMutationFlags : flagMutationFlags;
|
|
const inspecting = argv.includes("--show") || argv.includes("--check");
|
|
if (
|
|
inspecting &&
|
|
(
|
|
mutationFlags.some((flag) => argv.includes(flag)) ||
|
|
argv.includes("--dry-run") ||
|
|
argv.includes("--yes")
|
|
)
|
|
) {
|
|
emitResult(
|
|
usage(`--show and --check cannot be combined with ${section} mutations`),
|
|
options,
|
|
);
|
|
return true;
|
|
}
|
|
if (argv.includes("--show") && argv.includes("--check")) {
|
|
emitResult(usage("--show and --check are mutually exclusive"), options);
|
|
return true;
|
|
}
|
|
let resolved: ResolvedAidlcSettings;
|
|
try {
|
|
resolved = resolveAidlcSettings(projectDir);
|
|
} catch (error) {
|
|
emitResult(failure(
|
|
error instanceof Error ? error.message : String(error),
|
|
EXIT.usage,
|
|
), options);
|
|
return true;
|
|
}
|
|
if (argv.includes("--show")) {
|
|
emitResult(success(
|
|
`${section} settings without an installed harness`,
|
|
section === "models"
|
|
? {
|
|
policy: resolved.models,
|
|
sources: resolved.sources,
|
|
effective: [],
|
|
}
|
|
: {
|
|
record: resolved.flags,
|
|
effective: effectiveProjectFlagValues(resolved.flags),
|
|
sources: resolved.sources,
|
|
},
|
|
), options);
|
|
return true;
|
|
}
|
|
if (argv.includes("--check")) {
|
|
emitResult(success(`${section} settings files are valid`, {
|
|
files: resolved.files,
|
|
}), options);
|
|
return true;
|
|
}
|
|
if (!mutationFlags.some((flag) => argv.includes(flag))) {
|
|
emitResult(usage(
|
|
`non-interactive ${section} configuration requires an explicit policy flag`,
|
|
configCommand(`${section} --help`),
|
|
), options);
|
|
return true;
|
|
}
|
|
try {
|
|
const target = settingsTargetForMutation(argv, projectDir);
|
|
const path = settingsPathForTarget(projectDir, target);
|
|
const currentFile = readSettingsTarget(projectDir, target);
|
|
let nextFile: AidlcSettingsFile | null;
|
|
if (section === "models") {
|
|
const requestedHarness = valueAfter(argv, "--harness");
|
|
const harness = requestedHarness ? modelHarness(requestedHarness) : "claude";
|
|
if (argv.includes("--model") && !requestedHarness) {
|
|
throw new Error(
|
|
"outside an installed harness, --agent ... --model requires --harness so the model ID is stored under one harness key",
|
|
);
|
|
}
|
|
const currentEffective = modelPolicyForHarness(resolved.models, harness);
|
|
const currentTarget = modelPolicyForHarness(
|
|
currentFile?.models ?? null,
|
|
harness,
|
|
);
|
|
const agent = valueAfter(argv, "--agent");
|
|
const tiers: AgentTiers = agent ? { [agent]: "judgment" } : {};
|
|
const nextPolicy = applyModelsFlags(
|
|
currentTarget,
|
|
argv,
|
|
tiers,
|
|
currentEffective,
|
|
);
|
|
nextFile = updateSettingsSection(
|
|
currentFile,
|
|
"models",
|
|
settingsModelsFromHarnessPolicy(
|
|
currentFile?.models ?? null,
|
|
harness,
|
|
nextPolicy,
|
|
),
|
|
);
|
|
} else {
|
|
if (argv.includes("--default-scope")) {
|
|
throw new Error(
|
|
"--default-scope requires an installed project harness so the scope name can be validated",
|
|
);
|
|
}
|
|
const nextFlags = argv.includes("--reset")
|
|
? null
|
|
: buildFlagsRecord(currentFile?.flags ?? null, argv, projectDir);
|
|
nextFile = updateSettingsSection(currentFile, "flags", nextFlags);
|
|
}
|
|
if (canonical(currentFile) === canonical(nextFile)) {
|
|
emitResult(success(`${section} configuration unchanged`), options);
|
|
return true;
|
|
}
|
|
const mutation: SettingsMutation = {
|
|
target,
|
|
path,
|
|
previous: currentFile,
|
|
next: nextFile,
|
|
};
|
|
if (argv.includes("--dry-run")) {
|
|
emitResult(success(`${section} settings plan`, {
|
|
target,
|
|
path,
|
|
previous: currentFile,
|
|
next: nextFile,
|
|
}), options);
|
|
return true;
|
|
}
|
|
if (!options.yes) {
|
|
emitResult(usage(
|
|
`non-interactive ${section} mutation requires --yes; --yes confirms but never chooses`,
|
|
configMutationRerun(section, argv),
|
|
), options);
|
|
return true;
|
|
}
|
|
if (target === "global") {
|
|
executeGlobalSettingsMutation(mutation);
|
|
} else {
|
|
const operations: TransactionOperation[] = [];
|
|
const actions: PlannedAction[] = [];
|
|
planProjectSettingsMutation(projectDir, mutation, operations, actions);
|
|
executePlan({ schemaVersion: 1, root: projectDir, operations });
|
|
invalidateSettingsCache(path);
|
|
}
|
|
emitResult(success(
|
|
`configured ${section} settings in ${path}`,
|
|
{ target, path },
|
|
), options);
|
|
} catch (error) {
|
|
emitResult(usage(
|
|
error instanceof Error ? error.message : String(error),
|
|
configCommand(`${section} --help`),
|
|
), options);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export async function main(
|
|
input: string[],
|
|
internal: ConfigMainInternal = {},
|
|
): Promise<void> {
|
|
let argv = stripVerb(input);
|
|
const options = globalOptions(argv);
|
|
const positionals = configPositionals(argv);
|
|
const section = positionals[0];
|
|
if (section && !VALID_CONFIG_SECTIONS.has(section.value)) {
|
|
if (configInputIsTty() && options.mode === "human") {
|
|
const distance = (left: string, right: string): number => {
|
|
const row = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
for (let i = 1; i <= left.length; i++) {
|
|
let diagonal = row[0];
|
|
row[0] = i;
|
|
for (let j = 1; j <= right.length; j++) {
|
|
const prior = row[j];
|
|
row[j] = Math.min(
|
|
row[j] + 1,
|
|
row[j - 1] + 1,
|
|
diagonal + (left[i - 1] === right[j - 1] ? 0 : 1),
|
|
);
|
|
diagonal = prior;
|
|
}
|
|
}
|
|
return row[right.length];
|
|
};
|
|
const nearest = [...VALID_CONFIG_SECTIONS]
|
|
.map((candidate) => ({
|
|
candidate,
|
|
distance: distance(section.value, candidate),
|
|
}))
|
|
.sort((left, right) =>
|
|
left.distance - right.distance ||
|
|
left.candidate.localeCompare(right.candidate)
|
|
)[0];
|
|
process.stderr.write(
|
|
`${errorLabel("error:", process.stderr)} unknown config section '${section.value}'\n`,
|
|
);
|
|
if (nearest && nearest.distance <= 2) {
|
|
process.stderr.write(
|
|
`\n ${tipLabel("tip:", process.stderr)} did you mean '${nearest.candidate}'?\n`,
|
|
);
|
|
}
|
|
process.stderr.write(
|
|
`\n${heading("usage:", process.stderr)} ${configCommand("<section> [flags]")}\n`,
|
|
);
|
|
process.stderr.write(
|
|
`For the full list, run '${configCommand("--help")}'.\n`,
|
|
);
|
|
process.exitCode = EXIT.usage;
|
|
return;
|
|
}
|
|
emitResult(
|
|
usage(
|
|
`unknown config section ${JSON.stringify(section.value)}; valid sections: models, runtime, providers, trust, flags, project`,
|
|
configCommand("<models|runtime|providers|trust|flags|project> --help"),
|
|
),
|
|
options,
|
|
);
|
|
return;
|
|
}
|
|
if (!section) {
|
|
const validation = validateRootConfigArgs(argv);
|
|
if (validation) {
|
|
emitResult(usage(validation, configCommand("--help")), options);
|
|
return;
|
|
}
|
|
}
|
|
if (
|
|
(section?.value === "models" || section?.value === "flags") &&
|
|
handleSettingsOnlySection(section.value, [
|
|
...argv.slice(0, section.index),
|
|
...argv.slice(section.index + 1),
|
|
], options)
|
|
) {
|
|
return;
|
|
}
|
|
let modelsContext: ModelsMutationContext | null = null;
|
|
let diagnosticsContext: DiagnosticsMutationContext | null = null;
|
|
let choicesContext: ChoicesMutationContext | null = null;
|
|
if (section?.value === "models") {
|
|
argv = [...argv.slice(0, section.index), ...argv.slice(section.index + 1)];
|
|
try {
|
|
const preparedModels = prepareModelsSection(argv, options);
|
|
if (!preparedModels) return;
|
|
argv = preparedModels.argv;
|
|
modelsContext = preparedModels.context;
|
|
} catch (error) {
|
|
emitResult(
|
|
usage(
|
|
error instanceof Error ? error.message : String(error),
|
|
configCommand("models --help"),
|
|
),
|
|
options,
|
|
);
|
|
return;
|
|
}
|
|
} else if (
|
|
section?.value === "runtime" ||
|
|
section?.value === "providers" ||
|
|
section?.value === "trust"
|
|
) {
|
|
const diagnosticSection = section.value;
|
|
argv = [...argv.slice(0, section.index), ...argv.slice(section.index + 1)];
|
|
try {
|
|
const preparedDiagnostics = prepareDiagnosticSection(
|
|
diagnosticSection,
|
|
argv,
|
|
options,
|
|
);
|
|
if (!preparedDiagnostics) return;
|
|
argv = preparedDiagnostics.argv;
|
|
diagnosticsContext = preparedDiagnostics.context;
|
|
} catch (error) {
|
|
emitResult(
|
|
usage(
|
|
error instanceof Error ? error.message : String(error),
|
|
configCommand(`${diagnosticSection} --help`),
|
|
),
|
|
options,
|
|
);
|
|
return;
|
|
}
|
|
} else if (section?.value === "flags" || section?.value === "project") {
|
|
const choiceSection = section.value;
|
|
argv = [...argv.slice(0, section.index), ...argv.slice(section.index + 1)];
|
|
try {
|
|
const preparedChoices = prepareChoiceSection(
|
|
choiceSection,
|
|
argv,
|
|
options,
|
|
);
|
|
if (!preparedChoices) return;
|
|
argv = preparedChoices.argv;
|
|
choicesContext = preparedChoices.context;
|
|
} catch (error) {
|
|
emitResult(
|
|
usage(
|
|
error instanceof Error ? error.message : String(error),
|
|
configCommand(`${choiceSection} --help`),
|
|
),
|
|
options,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
if (argv.includes("--channel")) {
|
|
emitResult(configureChannel(argv), options);
|
|
return;
|
|
}
|
|
if (argv.includes("--pin") || argv.includes("--unpin")) {
|
|
emitResult(await configureProjectPin(argv), options);
|
|
return;
|
|
}
|
|
const requestedHarnesses = valuesAfter(argv, "--harness");
|
|
const requestedHarness = requestedHarnesses[0];
|
|
const from = internal.sourceRoot ?? valueAfter(argv, "--from");
|
|
const mcpValue = valueAfter(argv, "--mcp");
|
|
if (argv.includes("--harness") && !requestedHarness) {
|
|
emitResult(usage("--harness requires a distribution name"), options);
|
|
return;
|
|
}
|
|
if (requestedHarnesses.length > 1) {
|
|
emitResult(
|
|
usage("multi-harness config is not supported yet; pass one --harness <name>"),
|
|
options,
|
|
);
|
|
return;
|
|
}
|
|
if (mcpValue && mcpValue !== "defaults" && mcpValue !== "none") {
|
|
emitResult(usage("--mcp must be defaults or none"), options);
|
|
return;
|
|
}
|
|
const projectDir = projectDirFrom(argv);
|
|
const projectHarnesses = discoverProjectHarnesses(projectDir);
|
|
const explicitProject = argv.includes("--project-dir") ||
|
|
Boolean(process.env.AIDLC_PROJECT_DIR) ||
|
|
Boolean(process.env.CLAUDE_PROJECT_DIR) ||
|
|
Boolean(process.env.KIRO_PROJECT_DIR);
|
|
const recognized = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml"]
|
|
.some((entry) => existsSync(join(projectDir, entry)));
|
|
const firstRunWizard = !section &&
|
|
configInputIsTty() &&
|
|
options.mode === "human" &&
|
|
projectHarnesses.length === 0 &&
|
|
!argv.some((token) =>
|
|
[
|
|
"--dry-run",
|
|
"--force",
|
|
"--from",
|
|
"--harness",
|
|
"--json",
|
|
"--mcp",
|
|
"--pin",
|
|
"--plan-token",
|
|
"--quiet",
|
|
"--unpin",
|
|
"--yes",
|
|
].includes(token)
|
|
);
|
|
if (firstRunWizard && await runFirstRunWizard(projectDir)) return;
|
|
const existingWalk = !section &&
|
|
configInputIsTty() &&
|
|
options.mode === "human" &&
|
|
projectHarnesses.length > 0 &&
|
|
!argv.some((token) =>
|
|
[
|
|
"--dry-run",
|
|
"--force",
|
|
"--from",
|
|
"--harness",
|
|
"--json",
|
|
"--mcp",
|
|
"--pin",
|
|
"--plan-token",
|
|
"--quiet",
|
|
"--unpin",
|
|
"--yes",
|
|
].includes(token)
|
|
);
|
|
if (existingWalk) {
|
|
if (projectHarnesses.length > 1) {
|
|
emitResult(
|
|
usage("multiple project harnesses are present; pass one --harness <name>"),
|
|
options,
|
|
);
|
|
return;
|
|
}
|
|
const installed = projectHarnesses[0];
|
|
process.stdout.write(
|
|
`\n Found ${installed.distribution} in ${installed.harnessDir}/; using the existing copied projection.\n`,
|
|
);
|
|
const outstanding = postApplyOutstandingActions(
|
|
projectDir,
|
|
installed.harnessDir,
|
|
modelHarness(installed.distribution),
|
|
);
|
|
await runSetupWalk(
|
|
projectDir,
|
|
installed.harnessDir,
|
|
installed.distribution,
|
|
outstanding,
|
|
);
|
|
return;
|
|
}
|
|
if (!recognized && !explicitProject && !options.yes) {
|
|
if (!configInputIsTty()) {
|
|
emitResult(usage("non-interactive config outside a recognized project requires --project-dir"), options);
|
|
return;
|
|
}
|
|
const answer = configPrompt(`Initialize AI-DLC in ${projectDir}? [y/N]:`);
|
|
if (!answer || !/^y(?:es)?$/i.test(answer.trim())) {
|
|
emitResult(usage("configuration cancelled; pass --project-dir to select the target explicitly"), options);
|
|
return;
|
|
}
|
|
}
|
|
let selected: ConfigSource | null = null;
|
|
let prepared: { root: string; cleanup?: string; regenerated: Set<string> } | null = null;
|
|
try {
|
|
const existing = existingProject(projectDir, requestedHarness);
|
|
const pinPath = join(projectDir, ".aidlc-version");
|
|
if (pathPresent(pinPath) && !regularFile(pinPath)) {
|
|
throw new Error("project pin .aidlc-version is not a regular file");
|
|
}
|
|
const requiredVersion = regularFile(pinPath) ? readFileSync(pinPath, "utf-8").trim() : undefined;
|
|
const recordOnly = Boolean(
|
|
modelsContext ||
|
|
diagnosticsContext ||
|
|
choicesContext?.section === "flags",
|
|
);
|
|
if (
|
|
recordOnly &&
|
|
existing.distribution &&
|
|
!from &&
|
|
!process.env.AIDLC_RUNTIME_ROOT &&
|
|
!isCompiledExecutable()
|
|
) {
|
|
selected = copiedProjectSource(projectDir, requestedHarness);
|
|
} else {
|
|
try {
|
|
selected = selectSource(
|
|
requestedHarness,
|
|
from,
|
|
existing.distribution,
|
|
requiredVersion,
|
|
);
|
|
} catch (error) {
|
|
if (!recordOnly || !existing.distribution || from) throw error;
|
|
selected = copiedProjectSource(projectDir, requestedHarness);
|
|
}
|
|
}
|
|
const { stamp, descriptor } = selected;
|
|
if (existing.distribution && existing.distribution !== stamp.distribution) {
|
|
throw new Error(`project uses ${existing.distribution}; refusing ${stamp.distribution}`);
|
|
}
|
|
if (existing.distribution) assertRefreshSafe(projectDir);
|
|
if (regularFile(pinPath) && readFileSync(pinPath, "utf-8").trim() !== stamp.frameworkVersion) {
|
|
throw new Error(
|
|
`project pin requires ${readFileSync(pinPath, "utf-8").trim()}, but source is ${stamp.frameworkVersion}; run aidlc config --pin ${readFileSync(pinPath, "utf-8").trim()}`,
|
|
);
|
|
}
|
|
const baselinePath = join(projectDir, descriptor.harnessDir, "tools", "data", "aidlc-manifest.json");
|
|
const prior = readBaseline(baselinePath);
|
|
const settingsMutation = modelsContext?.settings ?? choicesContext?.settings;
|
|
const projectedSettings = settingsMutation
|
|
? resolveAidlcSettingsWithOverride(
|
|
projectDir,
|
|
settingsMutation.target,
|
|
settingsMutation.next,
|
|
)
|
|
: resolveAidlcSettings(projectDir);
|
|
const projectedPolicy = modelPolicyForHarness(
|
|
projectedSettings.models,
|
|
modelHarness(stamp.distribution),
|
|
);
|
|
prepared = prepareRefreshSource(
|
|
projectDir,
|
|
selected.root,
|
|
descriptor,
|
|
prior,
|
|
projectedPolicy,
|
|
projectedSettings.flags,
|
|
diagnosticsContext?.overrides ?? choicesContext?.overrides,
|
|
);
|
|
const preparedRoot = prepared.root;
|
|
const preparedRegenerated = prepared.regenerated;
|
|
let recordedProjectMcp: "defaults" | "none" | undefined;
|
|
try {
|
|
const stagedHarnessData = JSON.parse(
|
|
readFileSync(
|
|
join(
|
|
prepared.root,
|
|
descriptor.harnessDir,
|
|
"tools",
|
|
"data",
|
|
"harness.json",
|
|
),
|
|
"utf-8",
|
|
),
|
|
) as Record<string, unknown>;
|
|
recordedProjectMcp =
|
|
normalizeProjectChoicesRecord(stagedHarnessData.project)?.mcp;
|
|
} catch {
|
|
recordedProjectMcp = undefined;
|
|
}
|
|
let mcpMode = (
|
|
mcpValue ??
|
|
choicesContext?.mcpMode ??
|
|
recordedProjectMcp ??
|
|
prior?.mcpMode
|
|
) as "defaults" | "none" | undefined;
|
|
if (
|
|
!mcpMode &&
|
|
configInputIsTty() &&
|
|
descriptor.rootIntegrations.some((integration) =>
|
|
integration.policy === "json-map" && integration.optional
|
|
)
|
|
) {
|
|
process.stdout.write("\n MCP servers\n 1. on\n 2. off\n");
|
|
const answer = promptChoice(" MCP", 2, 2);
|
|
mcpMode = answer === 1 ? "defaults" : "none";
|
|
process.stdout.write(` MCP servers ${answer === 1 ? "on" : "off"}.\n\n`);
|
|
}
|
|
mcpMode ??= "none";
|
|
const operations: TransactionOperation[] = [];
|
|
const actions: PlannedAction[] = [];
|
|
const files: Record<string, string> = {};
|
|
const rootContributions: Record<string, RootContribution> = {};
|
|
planManagedFiles(
|
|
projectDir,
|
|
prepared.root,
|
|
descriptor,
|
|
prior,
|
|
argv.includes("--force"),
|
|
operations,
|
|
actions,
|
|
files,
|
|
prepared.regenerated,
|
|
);
|
|
if (!selected.projectProjection) {
|
|
planRootIntegrations(
|
|
projectDir,
|
|
prepared.root,
|
|
descriptor,
|
|
prior,
|
|
mcpMode,
|
|
argv.includes("--force"),
|
|
operations,
|
|
actions,
|
|
rootContributions,
|
|
);
|
|
planRemovedRootIntegrations(
|
|
projectDir,
|
|
descriptor,
|
|
prior,
|
|
argv.includes("--force"),
|
|
operations,
|
|
actions,
|
|
);
|
|
} else {
|
|
if (prior) Object.assign(rootContributions, prior.rootContributions);
|
|
const presentRootIntegrations = descriptor.rootIntegrations.filter(
|
|
(integration) =>
|
|
preparedRegenerated.has(integration.path) &&
|
|
regularFile(join(preparedRoot, integration.path)),
|
|
);
|
|
if (presentRootIntegrations.length > 0) {
|
|
planRootIntegrations(
|
|
projectDir,
|
|
preparedRoot,
|
|
{ ...descriptor, rootIntegrations: presentRootIntegrations },
|
|
prior,
|
|
mcpMode,
|
|
argv.includes("--force"),
|
|
operations,
|
|
actions,
|
|
rootContributions,
|
|
);
|
|
}
|
|
}
|
|
planProjectSettingsMutation(
|
|
projectDir,
|
|
settingsMutation,
|
|
operations,
|
|
actions,
|
|
);
|
|
const externalSettingsOperation = globalSettingsOperation(settingsMutation);
|
|
if (settingsMutation?.target === "global" && externalSettingsOperation) {
|
|
actions.push({
|
|
path: settingsMutation.path,
|
|
action: settingsMutation.next === null
|
|
? "remove"
|
|
: pathPresent(settingsMutation.path)
|
|
? "update"
|
|
: "create",
|
|
});
|
|
}
|
|
const conflicts = actions.filter((action) => action.action === "conflict");
|
|
if (conflicts.length > 0) {
|
|
actions.sort((left, right) =>
|
|
left.path.localeCompare(right.path) || left.action.localeCompare(right.action)
|
|
);
|
|
const counts = Object.fromEntries(
|
|
["create", "update", "merge", "preserve", "remove", "conflict"].map((name) => [
|
|
name,
|
|
actions.filter((item) => item.action === name).length,
|
|
]),
|
|
);
|
|
emitResult({
|
|
...failure(
|
|
`${conflicts.length} config conflict(s): ${conflicts.map((item) => `${item.path} (${item.detail})`).join(", ")}`,
|
|
EXIT.integrity,
|
|
configCommand("--dry-run --verbose"),
|
|
),
|
|
data: { projectDir, distribution: stamp.distribution, counts, actions },
|
|
}, options);
|
|
return;
|
|
}
|
|
const baseline: Baseline = {
|
|
schemaVersion: 1,
|
|
frameworkVersion: stamp.frameworkVersion,
|
|
distribution: stamp.distribution,
|
|
harnessDir: stamp.harnessDir,
|
|
mcpMode,
|
|
files,
|
|
rootContributions,
|
|
};
|
|
const baselineRel = join(descriptor.harnessDir, "tools", "data", "aidlc-manifest.json");
|
|
operations.push(writeOperation(
|
|
baselineRel,
|
|
`${JSON.stringify(baseline, null, 2)}\n`,
|
|
expected(baselinePath),
|
|
));
|
|
actions.push({ path: baselineRel, action: pathPresent(baselinePath) ? "update" : "create" });
|
|
actions.sort((left, right) =>
|
|
left.path.localeCompare(right.path) || left.action.localeCompare(right.action)
|
|
);
|
|
const counts = Object.fromEntries(
|
|
["create", "update", "merge", "preserve", "remove", "conflict"].map((name) => [
|
|
name,
|
|
actions.filter((item) => item.action === name).length,
|
|
]),
|
|
);
|
|
const plan: TransactionPlan = { schemaVersion: 1, root: projectDir, operations };
|
|
const approvalPlan = {
|
|
...plan,
|
|
operations: plan.operations.map((operation) =>
|
|
operation.kind === "copy"
|
|
? {
|
|
...operation,
|
|
source: {
|
|
sha256: sha256File(operation.source),
|
|
mode: statSync(operation.source).mode & 0o777,
|
|
},
|
|
}
|
|
: operation
|
|
),
|
|
...(externalSettingsOperation
|
|
? {
|
|
externalSettings: {
|
|
root: machineTransactionRoot(),
|
|
operation: externalSettingsOperation,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
const planToken = sha256Bytes(canonical(approvalPlan));
|
|
if (argv.includes("--dry-run")) {
|
|
if (modelsContext && options.mode === "human") {
|
|
for (const line of modelsContext.summaryLines) process.stdout.write(`${line}\n`);
|
|
for (const note of modelsContext.notes) process.stdout.write(` Note: ${note}\n`);
|
|
}
|
|
if (diagnosticsContext && options.mode === "human") {
|
|
for (const line of diagnosticsContext.summaryLines) process.stdout.write(`${line}\n`);
|
|
for (const note of diagnosticsContext.notes) process.stdout.write(` Note: ${note}\n`);
|
|
}
|
|
if (choicesContext && options.mode === "human") {
|
|
for (const line of choicesContext.summaryLines) process.stdout.write(`${line}\n`);
|
|
for (const note of choicesContext.notes) process.stdout.write(` Note: ${note}\n`);
|
|
}
|
|
const configuredSection = diagnosticsContext?.section ??
|
|
choicesContext?.section ??
|
|
(modelsContext ? "models" : null);
|
|
emitResult(success(
|
|
`${configuredSection ? `${configuredSection} configuration` : "config"} plan for ${projectDir}: ${
|
|
Object.entries(counts).map(([key, value]) => `${key}=${value}`).join(" ")
|
|
}`,
|
|
{
|
|
projectDir,
|
|
distribution: stamp.distribution,
|
|
counts,
|
|
actions,
|
|
planToken,
|
|
...(modelsContext
|
|
? {
|
|
models: {
|
|
previous: modelsContext.previous,
|
|
next: modelsContext.next,
|
|
summaries: modelsContext.summaryLines,
|
|
notes: modelsContext.notes,
|
|
},
|
|
}
|
|
: {}),
|
|
...(diagnosticsContext
|
|
? {
|
|
diagnostics: {
|
|
section: diagnosticsContext.section,
|
|
previous: diagnosticsContext.previous,
|
|
next: diagnosticsContext.next,
|
|
summaries: diagnosticsContext.summaryLines,
|
|
notes: diagnosticsContext.notes,
|
|
},
|
|
}
|
|
: {}),
|
|
...(choicesContext
|
|
? {
|
|
choices: {
|
|
section: choicesContext.section,
|
|
previous: choicesContext.previous,
|
|
next: choicesContext.next,
|
|
previousPlugins: choicesContext.previousPlugins,
|
|
nextPlugins: choicesContext.nextPlugins,
|
|
summaries: choicesContext.summaryLines,
|
|
notes: choicesContext.notes,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
), options);
|
|
return;
|
|
}
|
|
const approvedToken = valueAfter(argv, "--plan-token");
|
|
if (argv.includes("--plan-token") && !approvedToken) {
|
|
emitResult(usage("--plan-token requires the token emitted by init --dry-run"), options);
|
|
return;
|
|
}
|
|
if (approvedToken && approvedToken !== planToken) {
|
|
emitResult(failure(
|
|
"config plan changed after approval; run aidlc config --dry-run again",
|
|
EXIT.integrity,
|
|
configCommand("--dry-run --json"),
|
|
), options);
|
|
return;
|
|
}
|
|
if (existing.distribution) {
|
|
withAuditLock(
|
|
projectDir,
|
|
() => {
|
|
assertRefreshSafe(projectDir);
|
|
executeSettingsAndProjectMutation(settingsMutation, plan);
|
|
},
|
|
undefined,
|
|
undefined,
|
|
600,
|
|
);
|
|
} else {
|
|
executeSettingsAndProjectMutation(settingsMutation, plan);
|
|
}
|
|
if (settingsMutation && settingsMutation.target !== "global") {
|
|
invalidateSettingsCache(settingsMutation.path);
|
|
}
|
|
if (modelsContext && options.mode === "human") {
|
|
for (const line of modelsContext.summaryLines) process.stdout.write(`${line}\n`);
|
|
for (const note of modelsContext.notes) process.stdout.write(` Note: ${note}\n`);
|
|
}
|
|
if (diagnosticsContext && options.mode === "human") {
|
|
for (const line of diagnosticsContext.summaryLines) process.stdout.write(`${line}\n`);
|
|
for (const note of diagnosticsContext.notes) process.stdout.write(` Note: ${note}\n`);
|
|
}
|
|
if (choicesContext && options.mode === "human") {
|
|
for (const line of choicesContext.summaryLines) process.stdout.write(`${line}\n`);
|
|
for (const note of choicesContext.notes) process.stdout.write(` Note: ${note}\n`);
|
|
}
|
|
const outstandingActions = internal.setupWalkChild
|
|
? []
|
|
: postApplyOutstandingActions(
|
|
projectDir,
|
|
descriptor.harnessDir,
|
|
modelHarness(stamp.distribution),
|
|
{
|
|
skipSections: diagnosticsContext
|
|
? [diagnosticsContext.section]
|
|
: [],
|
|
},
|
|
);
|
|
const baseMessage = choicesContext
|
|
? `configured ${choicesContext.section} settings for ${projectDir}`
|
|
: diagnosticsContext
|
|
? `configured ${diagnosticsContext.section} settings for ${projectDir}`
|
|
: modelsContext
|
|
? `configured model policy for ${projectDir}`
|
|
: `configured ${projectDir} for ${descriptor.productName} ${stamp.frameworkVersion}; next: ${descriptor.configNextStep}`;
|
|
const setupMapWillRender =
|
|
!internal.setupWalkChild &&
|
|
!section &&
|
|
options.mode === "human" &&
|
|
configInputIsTty();
|
|
emitResult(success(
|
|
configCompletionMessage(
|
|
baseMessage,
|
|
setupMapWillRender ? [] : outstandingActions,
|
|
options.mode,
|
|
),
|
|
{
|
|
projectDir,
|
|
distribution: stamp.distribution,
|
|
version: stamp.frameworkVersion,
|
|
counts,
|
|
actions,
|
|
planToken,
|
|
outstandingActions,
|
|
...(modelsContext
|
|
? {
|
|
models: {
|
|
previous: modelsContext.previous,
|
|
next: modelsContext.next,
|
|
summaries: modelsContext.summaryLines,
|
|
notes: modelsContext.notes,
|
|
},
|
|
}
|
|
: {}),
|
|
...(diagnosticsContext
|
|
? {
|
|
diagnostics: {
|
|
section: diagnosticsContext.section,
|
|
previous: diagnosticsContext.previous,
|
|
next: diagnosticsContext.next,
|
|
summaries: diagnosticsContext.summaryLines,
|
|
notes: diagnosticsContext.notes,
|
|
},
|
|
}
|
|
: {}),
|
|
...(choicesContext
|
|
? {
|
|
choices: {
|
|
section: choicesContext.section,
|
|
previous: choicesContext.previous,
|
|
next: choicesContext.next,
|
|
previousPlugins: choicesContext.previousPlugins,
|
|
nextPlugins: choicesContext.nextPlugins,
|
|
summaries: choicesContext.summaryLines,
|
|
notes: choicesContext.notes,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
), options);
|
|
if (
|
|
setupMapWillRender
|
|
) {
|
|
await runSetupWalk(
|
|
projectDir,
|
|
descriptor.harnessDir,
|
|
stamp.distribution,
|
|
outstandingActions,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
const copiedHarness = discoverProjectHarnesses(projectDir)[0];
|
|
const copiedRefreshWithoutSource = Boolean(
|
|
copiedHarness &&
|
|
!from &&
|
|
/(harness .+ is not installed|no installed harness runtime is available)/.test(rawMessage),
|
|
);
|
|
const message = copiedRefreshWithoutSource
|
|
? `This copy-channel project already contains ${copiedHarness?.harnessDir}, but refreshing project files needs release source bytes. ` +
|
|
"Install the native aidlc command when a release is available, or re-copy the matching dist/<harness>/ tree from the aidlc-workflows checkout."
|
|
: rawMessage;
|
|
emitResult(failure(
|
|
message,
|
|
/pass (?:one )?--harness|--harness requires|multi-harness config/.test(message)
|
|
? EXIT.usage
|
|
: EXIT.integrity,
|
|
copiedRefreshWithoutSource
|
|
? "re-copy the matching dist/<harness>/ tree, then rerun this command"
|
|
: from
|
|
? configCommand("--from <valid-release-data>")
|
|
: selected?.projectProjection
|
|
? "install a native release when available, or re-copy the projection from the aidlc-workflows checkout"
|
|
: configCommand("--harness <name>"),
|
|
), options);
|
|
} finally {
|
|
if (prepared?.cleanup) rmSync(prepared.cleanup, { recursive: true, force: true });
|
|
if (selected?.cleanup) rmSync(selected.cleanup, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
main(process.argv.slice(2)).catch((error) => {
|
|
process.stderr.write(`aidlc config: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = EXIT.failure;
|
|
});
|
|
}
|