9001 lines
356 KiB
TypeScript
9001 lines
356 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
||
import {
|
||
cpSync,
|
||
existsSync,
|
||
lstatSync,
|
||
mkdtempSync,
|
||
mkdirSync,
|
||
readdirSync,
|
||
readFileSync,
|
||
realpathSync,
|
||
renameSync,
|
||
rmSync,
|
||
statSync,
|
||
writeFileSync,
|
||
} from "node:fs";
|
||
import { spawnSync } from "node:child_process";
|
||
import { tmpdir } from "node:os";
|
||
import {
|
||
basename,
|
||
dirname,
|
||
isAbsolute,
|
||
join,
|
||
relative,
|
||
resolve,
|
||
sep,
|
||
win32 as winPath,
|
||
} from "node:path";
|
||
import { pathToFileURL } from "node:url";
|
||
import {
|
||
appendAuditEntries,
|
||
appendAuditEntry,
|
||
appendAuditEntryUnlocked,
|
||
type AuditEntryInput,
|
||
} from "./aidlc-audit.ts";
|
||
import { VERSION_ID } from "./aidlc-channel.ts";
|
||
import { main as pluginBuildMain } from "./aidlc-plugin-build.ts";
|
||
import { main as pluginValidateMain } from "./aidlc-plugin-validate.ts";
|
||
import {
|
||
type LegacyDoctorResult,
|
||
redactSecretPatterns,
|
||
} from "./aidlc-doctor-bundle.ts";
|
||
import {
|
||
artifactsRegistryFor,
|
||
consumedArtifactProducerCollisions,
|
||
findCycles,
|
||
frameworkMemorySeedDir,
|
||
loadGraph,
|
||
loadRules,
|
||
memoryDirFor,
|
||
selectionDroppedOrderingEdges,
|
||
stageGraphDrift,
|
||
type GraphStage,
|
||
validateGrid,
|
||
validateScope,
|
||
} from "./aidlc-graph.ts";
|
||
import { repointHarnessIncludes } from "./aidlc-includes.ts";
|
||
import {
|
||
TRUSTED_COMMAND_PREFIX,
|
||
TRUSTED_COMMAND_TOKENS,
|
||
trustedCommand,
|
||
} from "./aidlc-command.ts";
|
||
import { workspaceManifestChecks } from "./aidlc-workspace-doctor.ts";
|
||
import {
|
||
instructionFileDoctorCheck,
|
||
runtimeDoctorChecks,
|
||
} from "./aidlc-config-diagnostics.ts";
|
||
import {
|
||
type cachedUnitClaimOverview,
|
||
localUnitClaimOverviewForIntent,
|
||
main as unitMain,
|
||
} from "./aidlc-unit.ts";
|
||
import {
|
||
activeIntent,
|
||
activeSpace,
|
||
authoritativeProjectDescription,
|
||
assertNoSymlinkInChainOrThrow,
|
||
auditBlockField,
|
||
auditFilePath,
|
||
auditShards,
|
||
assertChangeControlLedgerWritable,
|
||
CHANGE_CONTROL_FIELD,
|
||
CHANGE_CONTROL_VALUES,
|
||
type ChangeControlMemoryDeclaration,
|
||
changeControlMemoryStrictRefusal,
|
||
formatChangeControl,
|
||
memoryChangeControlDeclarations,
|
||
parseChangeControl,
|
||
recordChangeControlSet,
|
||
resolveChangeControl,
|
||
createIntent,
|
||
composeMarkerPath,
|
||
COMPOSE_MARKER_TTL_MS,
|
||
DEFAULT_SCOPE,
|
||
DEFAULT_SPACE,
|
||
detectLeakedLocks,
|
||
documentInputRequestFilePath,
|
||
DOCUMENT_INPUT_REQUEST_FILE,
|
||
docsDir,
|
||
envDefaultScope,
|
||
knowledgeDir,
|
||
agentsDir,
|
||
emitError,
|
||
errorMessage,
|
||
escapeRegex,
|
||
findAllEvents,
|
||
findStageBySlug,
|
||
frontmatterBlock,
|
||
getField,
|
||
hasUnsafeSingleLineCharacter,
|
||
holdsAuditLock,
|
||
hooksHealthDir,
|
||
isAutonomousMode,
|
||
isPlainObject,
|
||
isTeamUnitOwnership,
|
||
isPluginEnabled,
|
||
isoTimestamp,
|
||
isPackageJson,
|
||
isValidRepoName,
|
||
codekbDir,
|
||
intentsDir,
|
||
codekbRepoName,
|
||
codekbScopeFingerprint,
|
||
codekbSourceFingerprint,
|
||
codekbStoreGeneration,
|
||
parseReScope,
|
||
relativeCodekbDir,
|
||
RESERVED_RECORD_NAMES,
|
||
scopePathCovered,
|
||
gridCostSummary,
|
||
listIntents,
|
||
listSpaces,
|
||
loadAgents,
|
||
loadScopeMapping,
|
||
loadStageGraph,
|
||
loadStageGraphAll,
|
||
loadScopeMetadataAll,
|
||
MERGE_SUCCEEDED_TAG_REGEX,
|
||
migrateFlatLayout,
|
||
needsFlatMigration,
|
||
nextInScopeStage,
|
||
PHASES,
|
||
parseArgs,
|
||
parseCheckboxes,
|
||
parseRefsList,
|
||
parseStageFrontmatter,
|
||
parseStateStageSuffixes,
|
||
readAllAuditShards,
|
||
readAuditShardEvents,
|
||
readActiveDirectiveMarker,
|
||
readUnitClaimRegistryCache,
|
||
readUnitScopeStamp,
|
||
recordHookDrop,
|
||
readCurrentSessionId,
|
||
readProjectDescriptionAuthority,
|
||
resolveWorkflowSelection,
|
||
readStateFile,
|
||
refreshActiveDirectiveMarker,
|
||
resolveIntentRepoSet,
|
||
resolveProjectDir,
|
||
setActiveIntentCursor,
|
||
setActiveSpaceCursor,
|
||
slugify,
|
||
SLUG_TAG_REGEX,
|
||
spacesRoot,
|
||
type StageEntry,
|
||
setCheckbox,
|
||
setField,
|
||
setPhaseProgress,
|
||
setStageSuffix,
|
||
scopeGridPath,
|
||
scopesDir,
|
||
inspectSubagentInflight,
|
||
harnessDataPath,
|
||
pluginsEnabled,
|
||
resolveProjectFlag,
|
||
selectionAwareDefaultScope,
|
||
resolveDefaultScope,
|
||
projectDescriptionFilePath,
|
||
PROJECT_DESCRIPTION_FILE,
|
||
scalarField,
|
||
stageEnabledBySelection,
|
||
stagesInScope,
|
||
stateFilePath,
|
||
clearSessionIntentUuid,
|
||
sourceBaselineAuditFields,
|
||
unitDependencyPath,
|
||
withAuditLock,
|
||
validateBoltSlug,
|
||
validScopes,
|
||
worktreeAuditFilePath,
|
||
worktreePath,
|
||
worktreeStateFilePath,
|
||
writeFileAtomic,
|
||
writeSessionIntentUuid,
|
||
writeSessionBinding,
|
||
writeStateFile,
|
||
harnessDir,
|
||
rulesSubdir,
|
||
_resetHarnessDataForTests,
|
||
_resetScopeMappingForTests,
|
||
_resetStageGraphForTests,
|
||
classifyStateVersion,
|
||
clearSessionRebindOffer,
|
||
CURRENT_STATE_VERSION,
|
||
type AuditShardEvent,
|
||
idSuffix,
|
||
lastWorkspaceSourceFailure,
|
||
hookExecutionRecoveryText,
|
||
hookLiveness,
|
||
workspaceSourceState,
|
||
type WorkspaceSourceState,
|
||
} from "./aidlc-lib.ts";
|
||
import { validateStageFrontmatter } from "./aidlc-stage-schema.ts";
|
||
import { isRuleStale } from "./aidlc-rule-schema.ts";
|
||
import {
|
||
captureStageValidationBasis,
|
||
inspectStageValidity,
|
||
} from "./aidlc-validity.ts";
|
||
import { AIDLC_VERSION } from "./aidlc-version.ts";
|
||
import {
|
||
copyProjectSurfaces,
|
||
projectDiffPlan,
|
||
} from "./aidlc-plugin.ts";
|
||
import { executePlan } from "./aidlc-transaction.ts";
|
||
import {
|
||
aidlcInvocation,
|
||
aidlcDispatcherInvocation,
|
||
aidlcToolInvocation,
|
||
compiledExecutable,
|
||
isCompiledExecutable,
|
||
resolveHarnessPath,
|
||
resolveSkillsPath,
|
||
runtimeHarnessName,
|
||
} from "./aidlc-runtime-paths.ts";
|
||
import {
|
||
activeVersion,
|
||
binRoot,
|
||
commandPath,
|
||
inspectProjectPinTarget,
|
||
inspectInstalledVersion,
|
||
installRoot,
|
||
readActiveExecutable,
|
||
rollbackVersionPath,
|
||
versionRoot as installedVersionRoot,
|
||
} from "./aidlc-install-paths.ts";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const VALID_DEPTHS: Record<string, string> = {
|
||
minimal: "Minimal",
|
||
standard: "Standard",
|
||
comprehensive: "Comprehensive",
|
||
};
|
||
|
||
const VALID_TEST_STRATEGIES: Record<string, string> = {
|
||
minimal: "Minimal",
|
||
standard: "Standard",
|
||
comprehensive: "Comprehensive",
|
||
};
|
||
|
||
const CONFIG_KEYS = ["depth", "test-strategy", "review"] as const;
|
||
type ReviewOverride = "adversarial" | "advisory" | "none";
|
||
|
||
function parseReviewOverride(raw: string | undefined): ReviewOverride | undefined {
|
||
if (!raw) return undefined;
|
||
const value = raw.toLowerCase();
|
||
if (value !== "adversarial" && value !== "advisory" && value !== "none") {
|
||
die(`Unknown review class: "${raw}". Valid: adversarial, advisory, none.`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function storedReviewOverride(value: ReviewOverride): string {
|
||
// "adversarial" means no per-run ceiling; stage declarations and scope caps
|
||
// still apply, so represent it with the same empty field as config-change.
|
||
return value === "adversarial" ? "" : value;
|
||
}
|
||
|
||
function applyReviewOverride(
|
||
content: string,
|
||
value: ReviewOverride | undefined,
|
||
): {
|
||
content: string;
|
||
oldReview: string | null;
|
||
storedReview: string | undefined;
|
||
changed: boolean;
|
||
} {
|
||
const oldReview = getField(content, "Review Override");
|
||
if (value === undefined) {
|
||
return { content, oldReview, storedReview: undefined, changed: false };
|
||
}
|
||
const storedReview = storedReviewOverride(value);
|
||
const changed = storedReview !== (oldReview ?? "");
|
||
if (!changed) return { content, oldReview, storedReview, changed };
|
||
if (oldReview === null) {
|
||
const beforeInsert = content;
|
||
content = content.replace(
|
||
/^(- \*\*Test Strategy\*\*:[^\n]*)$/m,
|
||
"$1\n- **Review Override**:",
|
||
);
|
||
if (content === beforeInsert) {
|
||
content = content.replace(
|
||
/^(- \*\*Scope\*\*:[^\n]*)$/m,
|
||
"$1\n- **Review Override**:",
|
||
);
|
||
}
|
||
if (content === beforeInsert) {
|
||
content = `${content.trimEnd()}\n- **Review Override**:\n`;
|
||
}
|
||
}
|
||
content = setField(content, "Review Override", storedReview);
|
||
return { content, oldReview, storedReview, changed };
|
||
}
|
||
|
||
// These workspace transactions can legitimately queue behind a full plugin
|
||
// compose (compile + runner regeneration), so they share its ~60s lock budget.
|
||
const WORKSPACE_MUTATION_LOCK_RETRIES = 600;
|
||
const INTENT_CREATE_VALUE_FLAGS = [
|
||
"scope",
|
||
"arguments",
|
||
"label",
|
||
"depth",
|
||
"test-strategy",
|
||
"review",
|
||
"change-control",
|
||
"repos",
|
||
"space",
|
||
"project-dir",
|
||
] as const;
|
||
const INTENT_CREATE_DESCRIPTIVE_FLAGS = ["scope", "arguments", "label"] as const;
|
||
const NO_STATE_FILE_MESSAGE =
|
||
"No state file found. Start a workflow first by describing what to build (/aidlc \"build the auth service\").";
|
||
const INIT_TRANSITION_MESSAGE =
|
||
"init now lays down the project data tree and is not yet available in this release. To start work, describe what to build: /aidlc \"build the auth service\".";
|
||
const UPGRADE_UNAVAILABLE_MESSAGE =
|
||
"upgrade is not available in this install; it arrives with the packaged binary distribution.";
|
||
|
||
let errorArgs: string[] = [];
|
||
let errorProjectDirArg: string | undefined;
|
||
let errorSelection: { intent?: string; space?: string } = {};
|
||
|
||
function die(msg: string): never {
|
||
// main(argv) seeds this context before dispatch so ERROR_LOGGED lands in the
|
||
// same workflow the argv-selected command was targeting. Fall back to default
|
||
// resolution (env var / cwd) for direct in-process helper calls.
|
||
const args = errorArgs;
|
||
const pd = resolveProjectDir(errorProjectDirArg);
|
||
const command = `aidlc-utility ${args.join(" ")}`.trim();
|
||
emitError(
|
||
pd,
|
||
"aidlc-utility",
|
||
command,
|
||
msg,
|
||
errorSelection.intent,
|
||
errorSelection.space,
|
||
);
|
||
}
|
||
|
||
function validateIntentCreateFlagValues(
|
||
flags: Record<string, string>,
|
||
missingValueFlags: ReadonlySet<string>,
|
||
): void {
|
||
// Creation names the new intent itself, so an --intent selector has nothing
|
||
// to select; --space is the one selector creation takes (the target space).
|
||
if (flags.intent !== undefined || missingValueFlags.has("intent")) {
|
||
die(
|
||
"intent-create does not accept --intent: it creates a new intent and names " +
|
||
"it itself. Use --space <name> to choose the space it is created in.",
|
||
);
|
||
}
|
||
const invalid = INTENT_CREATE_VALUE_FLAGS.filter(
|
||
(name) =>
|
||
missingValueFlags.has(name) ||
|
||
(flags[name] !== undefined && flags[name].trim().length === 0),
|
||
);
|
||
if (invalid.length > 0) {
|
||
die(
|
||
`intent-create refused: ${invalid.map((name) => `--${name}`).join(", ")} ` +
|
||
`${invalid.length === 1 ? "requires" : "require"} a nonblank value.`,
|
||
);
|
||
}
|
||
for (const name of INTENT_CREATE_VALUE_FLAGS) {
|
||
if (flags[name] !== undefined) flags[name] = flags[name].trim();
|
||
}
|
||
}
|
||
|
||
// Thin wrapper around the canonical appendAuditEntry. All events must be in
|
||
// aidlc-audit.ts VALID_EVENT_TYPES. Throws on invalid event or audit failure —
|
||
// caller is expected to let that propagate (creation failures should stop creation).
|
||
//
|
||
// Lock-aware (mirrors aidlc-state.ts emitAudit): handleIntentCreate wraps the
|
||
// whole creation transaction in withAuditLock on the WORKSPACE sentinel bucket, so
|
||
// this process already owns that OS lock. Routing through appendAuditEntry
|
||
// (which calls the NON-reentrant acquireAuditLock keyed on the same sentinel
|
||
// when intent is omitted) would self-deadlock and burn the 5s retry budget
|
||
// before throwing — so detect the held lock and use the unlocked variant.
|
||
// Outside a held lock (every other caller — status/doctor/etc.) it takes its
|
||
// own lock as before.
|
||
function appendAuditEvent(
|
||
projectDir: string,
|
||
event: string,
|
||
fields: Record<string, string>,
|
||
intent?: string,
|
||
space?: string,
|
||
): void {
|
||
// Held on either bucket this process could own: the workspace sentinel (the
|
||
// creation transaction) or the named record's own per-intent bucket.
|
||
const held =
|
||
holdsAuditLock(projectDir) ||
|
||
(intent !== undefined && holdsAuditLock(projectDir, intent, space));
|
||
if (held) {
|
||
appendAuditEntryUnlocked(event, fields, projectDir, intent, space);
|
||
} else {
|
||
appendAuditEntry(event, fields, projectDir, intent, space);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// help
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// HELP_TEXT is no longer a static constant — the scopes block renders
|
||
// from loadScopeMapping() so stage counts stay fresh by construction.
|
||
// Previously hardcoded counts drifted as scopes evolved; sourcing from
|
||
// the live mapping makes that impossible.
|
||
|
||
const HELP_TEXT_HEAD = `AI-DLC - AI-Driven Development Life Cycle
|
||
|
||
Usage: /aidlc [command]
|
||
|
||
Scopes (set depth, test strategy, and stage count):
|
||
`;
|
||
|
||
const HELP_TEXT_TAIL = `
|
||
Utilities:
|
||
--status Show current workflow progress (read-only)
|
||
--config [section] Configure models, runtime, providers, trust, flags, or project in-session
|
||
--claim <unit> Atomically claim a team-owned Unit in this checkout
|
||
--release <unit> Release a Unit claim from the unscoped main checkout
|
||
unit adopt <unit> Adopt the checked-out live claim branch in a fresh clone
|
||
unit participate Mark this checkout for the guided Unit-claim picker
|
||
unit publish <unit> Publish this scoped checkout's committed candidate
|
||
unit pin <unit> Pin and validate a completed candidate from main
|
||
unit gate <unit> Record approve/reject against the pinned candidate
|
||
unit land <unit> Land pinned content, fold state, and finalize receipts (explicit post-git release recovery supported)
|
||
unit merge-status <unit> Show the local pinned-merge transaction
|
||
unit status Show claimable, claimed, and dependency-blocked Units
|
||
compose "<task>" Suggest a plan tailored to this task (mid-workflow: adjust the steps not yet run)
|
||
compose --report <path> Build a plan from a scan report (sort findings into a fix-and-ship run)
|
||
--new-scope "<task>" Build a custom plan even when a ready-made one matches
|
||
intent list List intents in the active space (read-only; --json for structured output)
|
||
intent switch <name> Switch the active intent (bare intent <name> still works)
|
||
space list List spaces (read-only; --json for structured output)
|
||
space switch <name> Switch the active space (bare space <name> still works)
|
||
space create <name> Create a new space (space-create <name> still works)
|
||
config get <key> Show active workflow config (depth, test-strategy, review)
|
||
config set <key> <value> Change active workflow config (depth, test-strategy, review)
|
||
config list List active workflow config (--json for structured output)
|
||
plugin select [names] Show or set the enabled plugin list
|
||
plugin list List installed plugins and enabled state (--json for structured output)
|
||
plugin sync Compose installed plugins into the current install
|
||
plugin validate [path] Validate authored plugin content (--json for structured output)
|
||
plugin build <harness> [outDir] Build a host plugin projection (--plugin-root <path>)
|
||
knowledge onboard [path] Index customer documents into the space DocumentKB
|
||
knowledge sync Reconcile the catalog with disk; retries extractor_unavailable rows
|
||
knowledge list The DocumentKB catalog (--json for structured output)
|
||
knowledge show <id> One document's record, plus its extracted text
|
||
knowledge associate <id> --intent [slug] Scope a document to one intent
|
||
knowledge dissociate <id> --intent [slug] Remove that scoping
|
||
knowledge rebind <id> --to <path> Repair a row whose original moved AND changed
|
||
--doctor Run health check on hooks, settings, and directory structure
|
||
--doctor --export Write a redacted diagnostic report (timeline + findings, no work product); --output <dir> to relocate
|
||
--stage <id> Jump to a specific stage (by slug or number, e.g., code-generation or 3.5)
|
||
--phase <name> Jump to the first in-scope stage of a phase (e.g., construction or 3)
|
||
--scope <scope> Set or change scope (standalone or with --stage/--phase)
|
||
--depth <level> Override depth (minimal, standard, comprehensive)
|
||
--test-strategy <level> Override test strategy (minimal, standard, comprehensive)
|
||
--review <class> Cap stage reviews for this run (adversarial, advisory, none)
|
||
--change-control <value> Set what an input change after an approval does for this piece of work (strict, relaxed)
|
||
--version Show the framework version
|
||
--help Show this help message
|
||
|
||
Other:
|
||
<description> Describe what to build - scope is auto-detected
|
||
(no arguments) Resume existing workflow, or start fresh if none exists
|
||
|
||
Examples:
|
||
/aidlc feature Start a feature workflow
|
||
/aidlc Fix the login timeout bug Auto-detected as bugfix scope
|
||
/aidlc compose "harden the deploy pipeline" Composer proposes a tailored plan
|
||
/aidlc config list Show depth, test strategy, and review override
|
||
/aidlc plugin list Show installed plugin selection
|
||
/aidlc plugin validate Validate the plugin in the current directory
|
||
/aidlc plugin build claude Build its Claude projection
|
||
/aidlc Resume or begin
|
||
/aidlc --stage code-generation Jump to code-generation stage
|
||
/aidlc --phase construction --scope bugfix Jump to construction with bugfix scope
|
||
/aidlc --scope bugfix --depth comprehensive Bugfix with comprehensive depth
|
||
/aidlc --depth minimal Change depth of active workflow
|
||
/aidlc --depth standard --test-strategy minimal Full artifacts, minimal tests
|
||
/aidlc --review advisory Single-pass reviews, findings at the gate
|
||
/aidlc --change-control relaxed Record and announce input changes after approval instead of re-approving`;
|
||
|
||
/** Exported for t67 unit tests. */
|
||
export function renderHelpText(): string {
|
||
const mapping = loadScopeMapping();
|
||
const defaultResolution = selectionAwareDefaultScope();
|
||
const defaultScope = defaultResolution.error ? "" : defaultResolution.scope;
|
||
const scopeLines = [...validScopes()].map((name) => {
|
||
const def = mapping[name];
|
||
const execute = Object.values(def.stages).filter((v) => v === "EXECUTE")
|
||
.length;
|
||
const total = Object.keys(def.stages).length;
|
||
const depth = def.depth.toLowerCase();
|
||
const ts = def.testStrategy
|
||
? `, ${def.testStrategy.toLowerCase()} test strategy`
|
||
: "";
|
||
const desc = def.description ? ` - ${def.description}` : "";
|
||
const defaultMarker = name === defaultScope ? " (default)" : "";
|
||
const countStr =
|
||
execute === total ? `All ${total} stages` : `${execute} of ${total} stages`;
|
||
return ` ${name.padEnd(18)}${countStr}, ${depth} depth${ts}${defaultMarker}${desc}`;
|
||
});
|
||
// Blank line before HELP_TEXT_TAIL so the `Utilities:` header is visually
|
||
// separated from the scope list.
|
||
return `${HELP_TEXT_HEAD + scopeLines.join("\n")}\n${HELP_TEXT_TAIL}`;
|
||
}
|
||
|
||
function handleHelp(): void {
|
||
process.stdout.write(`${renderHelpText()}\n`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// version
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function handleVersion(): void {
|
||
process.stdout.write(`aidlc ${AIDLC_VERSION}\n`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// select-plugins
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function resetSelectionSensitiveCaches(): void {
|
||
_resetHarnessDataForTests();
|
||
_resetStageGraphForTests();
|
||
_resetScopeMappingForTests();
|
||
}
|
||
|
||
function mutableHarnessDataPath(projectDir: string): string {
|
||
return resolveHarnessPath(
|
||
["tools", "data", "harness.json"],
|
||
{ mutable: true, projectDir },
|
||
);
|
||
}
|
||
|
||
function requireInstalledHarness(projectDir: string): void {
|
||
const installedLib = resolveHarnessPath(
|
||
["tools", "aidlc-lib.ts"],
|
||
{ mutable: true, projectDir },
|
||
);
|
||
if (!existsSync(installedLib)) {
|
||
die(
|
||
`select-plugins requires an installed project harness at ${dirname(dirname(installedLib))}.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function knownPluginNames(): string[] {
|
||
const names = new Set<string>(["aidlc"]);
|
||
try {
|
||
for (const stage of loadStageGraphAll()) {
|
||
if (stage.plugin) names.add(stage.plugin);
|
||
}
|
||
} catch {
|
||
// Scope files still provide known plugin identities when the graph is stale.
|
||
}
|
||
for (const meta of Object.values(loadScopeMetadataAll())) {
|
||
names.add(meta.plugin ?? "aidlc");
|
||
}
|
||
return [...names].sort();
|
||
}
|
||
|
||
function selectionOwner(stage: Pick<StageEntry, "plugin">): string {
|
||
return stage.plugin ?? "aidlc";
|
||
}
|
||
|
||
function countOwner(stage: Pick<StageEntry, "plugin" | "phase">): string {
|
||
return stage.phase === "initialization" ? "bootstrap" : selectionOwner(stage);
|
||
}
|
||
|
||
function expectedEnabledBySelection(stage: Pick<StageEntry, "plugin" | "phase">): boolean {
|
||
return stageEnabledBySelection(stage);
|
||
}
|
||
|
||
function parsePluginSelectionArgs(positional: string[]): { names: string[]; hasEmpty: boolean } {
|
||
const parts = positional.slice(1).join(",").split(",").map((s) => s.trim());
|
||
return {
|
||
names: parts.filter((s) => s.length > 0),
|
||
hasEmpty: parts.some((s) => s.length === 0),
|
||
};
|
||
}
|
||
|
||
function renderPluginSelection(selected: ReadonlySet<string> | null): string {
|
||
return selected === null ? "all enabled (no selection)" : [...selected].sort().join(", ");
|
||
}
|
||
|
||
function readHarnessDataObject(): Record<string, unknown> {
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(harnessDataPath(), "utf-8"));
|
||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||
return parsed as Record<string, unknown>;
|
||
}
|
||
} catch {
|
||
// Reconstruct a legacy/missing file from runtime defaults.
|
||
}
|
||
return { harnessDir: harnessDir(), rulesSubdir: rulesSubdir() };
|
||
}
|
||
|
||
function writePluginSelection(projectDir: string, names: string[]): void {
|
||
const data = readHarnessDataObject();
|
||
data.plugins = names;
|
||
const path = mutableHarnessDataPath(projectDir);
|
||
mkdirSync(dirname(path), { recursive: true });
|
||
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
||
resetSelectionSensitiveCaches();
|
||
}
|
||
|
||
function runBunTool(projectDir: string, rel: string, args: string[], label: string): void {
|
||
let dispatcherArgs: string[];
|
||
if (rel === "aidlc-graph.ts") {
|
||
dispatcherArgs = ["engine", "graph", ...args];
|
||
} else if (rel === "aidlc-runner-gen.ts" && args[0] === "write") {
|
||
dispatcherArgs = ["engine", "gen", "runners", ...args.slice(1)];
|
||
} else if (rel === "aidlc-runner-gen.ts" && args[0] === "scopes") {
|
||
dispatcherArgs = ["engine", "gen", "runner-scopes", ...args.slice(1)];
|
||
} else {
|
||
throw new Error(`No dispatcher route for ${rel} ${args.join(" ")}`);
|
||
}
|
||
dispatcherArgs.push("--project-dir", projectDir);
|
||
const executable = compiledExecutable();
|
||
const command = executable
|
||
? [executable, ...dispatcherArgs]
|
||
: [
|
||
process.execPath,
|
||
resolveHarnessPath(["tools", rel], { projectDir }),
|
||
...args,
|
||
"--project-dir",
|
||
projectDir,
|
||
];
|
||
const result = Bun.spawnSync({
|
||
cmd: command,
|
||
cwd: projectDir,
|
||
stdout: "pipe",
|
||
stderr: "pipe",
|
||
env: {
|
||
...process.env,
|
||
AIDLC_HARNESS_DIR: harnessDir(),
|
||
AIDLC_HARNESS_NAME: runtimeHarnessName(projectDir, harnessDir()),
|
||
AIDLC_PROJECT_DIR: projectDir,
|
||
...(holdsAuditLock(projectDir)
|
||
? { AIDLC_WORKSPACE_LOCK_OWNER_PID: String(process.pid) }
|
||
: {}),
|
||
},
|
||
});
|
||
if (result.exitCode !== 0) {
|
||
const stdout = new TextDecoder().decode(result.stdout).trim();
|
||
const stderr = new TextDecoder().decode(result.stderr).trim();
|
||
throw new Error(`${label} failed: ${(stderr || stdout || `exit ${result.exitCode}`).slice(0, 800)}`);
|
||
}
|
||
}
|
||
|
||
interface GeneratedRegionLocation {
|
||
beginIdx: number;
|
||
endIdx: number;
|
||
regionEndIdx: number;
|
||
}
|
||
|
||
function findGeneratedRegion(
|
||
body: string,
|
||
beginMarker: string,
|
||
endMarker: string,
|
||
verb: string,
|
||
skillPath: string,
|
||
): GeneratedRegionLocation {
|
||
const beginIdx = body.indexOf(beginMarker);
|
||
const lastBeginIdx = body.lastIndexOf(beginMarker);
|
||
const endIdx = body.indexOf(endMarker);
|
||
const lastEndIdx = body.lastIndexOf(endMarker);
|
||
if (beginIdx === -1 || endIdx === -1) {
|
||
throw new Error(
|
||
`SKILL.md at ${skillPath} is missing ${verb} markers. Expected:\n ${beginMarker}\n ${endMarker}`,
|
||
);
|
||
}
|
||
if (beginIdx !== lastBeginIdx || endIdx !== lastEndIdx) {
|
||
throw new Error(
|
||
`SKILL.md at ${skillPath} has duplicate ${verb} markers. Expected exactly one BEGIN and one END.`,
|
||
);
|
||
}
|
||
if (endIdx < beginIdx) {
|
||
throw new Error(
|
||
`SKILL.md at ${skillPath} has ${verb} markers out of order (END before BEGIN).`,
|
||
);
|
||
}
|
||
return { beginIdx, endIdx, regionEndIdx: endIdx + endMarker.length };
|
||
}
|
||
|
||
function replaceGeneratedRegion(
|
||
verb: string,
|
||
beginMarker: string,
|
||
endMarker: string,
|
||
region: string,
|
||
): void {
|
||
const path = skillMdPath();
|
||
const before = readFileSync(path, "utf-8").replace(/\r\n/g, "\n");
|
||
const located = findGeneratedRegion(before, beginMarker, endMarker, verb, path);
|
||
const after = before.slice(0, located.beginIdx) + region + before.slice(located.regionEndIdx);
|
||
if (after !== before) writeFileSync(path, after, "utf-8");
|
||
}
|
||
|
||
export function regenerateSelectionSurfaces(
|
||
projectDir: string,
|
||
displayProjectDir = projectDir,
|
||
): void {
|
||
runBunTool(projectDir, "aidlc-graph.ts", ["compile"], "aidlc-graph compile");
|
||
resetSelectionSensitiveCaches();
|
||
const skillsDir = resolveSkillsPath([], { mutable: true, projectDir });
|
||
if (existsSync(skillsDir)) {
|
||
runBunTool(projectDir, "aidlc-runner-gen.ts", ["write"], "aidlc-runner-gen write");
|
||
runBunTool(projectDir, "aidlc-runner-gen.ts", ["scopes"], "aidlc-runner-gen scopes");
|
||
} else {
|
||
process.stdout.write(
|
||
`note: runner regeneration skipped: ${
|
||
resolveSkillsPath([], { mutable: true, projectDir: displayProjectDir })
|
||
} not present in this install\n`,
|
||
);
|
||
}
|
||
resetSelectionSensitiveCaches();
|
||
replaceGeneratedRegion(
|
||
"stage-table",
|
||
STAGE_TABLE_BEGIN,
|
||
STAGE_TABLE_END,
|
||
canonicalStageTableRegion(renderStageTable()),
|
||
);
|
||
replaceGeneratedRegion(
|
||
"scope-table",
|
||
SCOPE_TABLE_BEGIN,
|
||
SCOPE_TABLE_END,
|
||
canonicalScopeTableRegion(renderScopeTable()),
|
||
);
|
||
}
|
||
|
||
// --- disable-time contribution strip -----------------------------------------
|
||
//
|
||
// Compose merges a plugin's structural adds (produces/sensors/consumes/
|
||
// scopes/required_sections) into CORE stage source, where no selection filter
|
||
// reaches, and records what it actually added in a per-plugin sidecar
|
||
// (tools/data/plugin-contrib-<key>.json). Prose fragments carry their own
|
||
// sentinel markers. On disable, select-plugins strips both, so a disabled
|
||
// plugin's contributions stop steering enabled stages; re-enabling restores
|
||
// them on the next session start (the plugin's compose hook re-merges).
|
||
|
||
interface ConsumeContribRecord {
|
||
artifact: string;
|
||
required: boolean;
|
||
conditional_on?: string;
|
||
}
|
||
|
||
interface StageContribRecord {
|
||
produces?: string[];
|
||
sensors?: string[];
|
||
consumes?: Array<string | ConsumeContribRecord>;
|
||
scopes?: string[];
|
||
required_sections?: string[];
|
||
required_sections_created?: boolean;
|
||
fragments?: Array<{ anchor: string; order: number; hash: string }>;
|
||
}
|
||
|
||
function pluginContribSidecarPath(plugin: string): string {
|
||
return resolveHarnessPath(
|
||
["tools", "data", `plugin-contrib-${plugin.replace(/[^\w.-]/g, "_")}.json`],
|
||
{ mutable: true },
|
||
);
|
||
}
|
||
|
||
function installedStagesRoot(): string {
|
||
return resolveHarnessPath(["aidlc-common", "stages"], { mutable: true });
|
||
}
|
||
|
||
// Remove recorded values from a `field:` block. An emptied block collapses to
|
||
// the inline `field: []` form (the shape compose's merge expanded from); a
|
||
// created-by-compose required_sections field is deleted outright.
|
||
function removeListValues(content: string, field: string, values: ReadonlySet<string>, dropEmptyField: boolean): string {
|
||
const blockRe = new RegExp(`^${field}:\\n((?: - .+\\n)*)`, "m");
|
||
const m = content.match(blockRe);
|
||
if (!m) return content;
|
||
const entries = [...m[1].matchAll(/^ {2}- (.+)$/gm)].map((x) => x[1]);
|
||
const removeIndexes = new Set<number>();
|
||
const remaining = new Set(values);
|
||
// Compose renders ordinary structural additions unquoted. Prefer that exact
|
||
// spelling so a legacy sidecar cannot remove an equivalent quoted membership
|
||
// that predated the plugin.
|
||
for (let i = 0; i < entries.length; i++) {
|
||
if (remaining.delete(entries[i].trim())) removeIndexes.add(i);
|
||
}
|
||
// required_sections are rendered quoted while their sidecar values are bare.
|
||
// Remove at most one canonical match for each recorded addition.
|
||
for (const value of remaining) {
|
||
const index = entries.findIndex((entry, i) => {
|
||
if (removeIndexes.has(i)) return false;
|
||
const bare = entry.trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
|
||
return bare === value;
|
||
});
|
||
if (index !== -1) removeIndexes.add(index);
|
||
}
|
||
const kept = entries.filter((_, i) => !removeIndexes.has(i));
|
||
const replacement = kept.length > 0
|
||
? `${field}:\n${kept.map((v) => ` - ${v}`).join("\n")}\n`
|
||
: dropEmptyField ? "" : `${field}: []\n`;
|
||
return content.replace(blockRe, replacement);
|
||
}
|
||
|
||
function removeConsumesEntries(content: string, artifacts: ReadonlySet<string>): string {
|
||
const blockRe = /^consumes:\n((?: {2}- artifact:.*\n(?: {4}(?:required|conditional_on):.*\n)*)*)/m;
|
||
const m = content.match(blockRe);
|
||
if (!m) return content;
|
||
const kept = [...m[1].matchAll(/^ {2}- artifact:\s*([\w-]+).*\n(?: {4}(?:required|conditional_on):.*\n)*/gm)]
|
||
.filter((entry) => !artifacts.has(entry[1]))
|
||
.map((entry) => entry[0]);
|
||
const replacement = kept.length > 0 ? `consumes:\n${kept.join("")}` : "consumes: []\n";
|
||
return content.replace(blockRe, replacement);
|
||
}
|
||
|
||
function fragmentProseHash(content: string): string {
|
||
let hash = 0x811c9dc5;
|
||
for (let i = 0; i < content.length; i++) {
|
||
hash ^= content.charCodeAt(i);
|
||
hash = Math.imul(hash, 0x01000193);
|
||
}
|
||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||
}
|
||
|
||
function missingRecordedContributions(
|
||
parsed: Record<string, unknown>,
|
||
content: string,
|
||
plugin: string,
|
||
record: StageContribRecord,
|
||
): string[] {
|
||
const missing: string[] = [];
|
||
for (const field of ["produces", "sensors", "scopes", "required_sections"] as const) {
|
||
const recorded = record[field];
|
||
if (!Array.isArray(recorded) || recorded.length === 0) continue;
|
||
const present = new Set(
|
||
(Array.isArray(parsed[field]) ? parsed[field] : [])
|
||
.filter((value): value is string => typeof value === "string"),
|
||
);
|
||
const absent = recorded.filter((value) => !present.has(value));
|
||
if (absent.length > 0) missing.push(`${field}=[${absent.join(", ")}]`);
|
||
}
|
||
|
||
if (Array.isArray(record.consumes) && record.consumes.length > 0) {
|
||
const present = (Array.isArray(parsed.consumes) ? parsed.consumes : [])
|
||
.flatMap((entry): ConsumeContribRecord[] => {
|
||
if (
|
||
!isPlainObject(entry) ||
|
||
typeof entry.artifact !== "string" ||
|
||
typeof entry.required !== "boolean"
|
||
) {
|
||
return [];
|
||
}
|
||
return [{
|
||
artifact: entry.artifact,
|
||
required: entry.required,
|
||
...(typeof entry.conditional_on === "string"
|
||
? { conditional_on: entry.conditional_on }
|
||
: {}),
|
||
}];
|
||
});
|
||
const absent = record.consumes.filter((expected) => {
|
||
if (typeof expected === "string") {
|
||
return !present.some((entry) => entry.artifact === expected);
|
||
}
|
||
return !present.some((entry) =>
|
||
entry.artifact === expected.artifact &&
|
||
entry.required === expected.required &&
|
||
entry.conditional_on === expected.conditional_on
|
||
);
|
||
}).map((expected) =>
|
||
typeof expected === "string"
|
||
? expected
|
||
: `${expected.artifact}(required=${expected.required}${
|
||
expected.conditional_on ? `, conditional_on=${expected.conditional_on}` : ""
|
||
})`
|
||
);
|
||
if (absent.length > 0) missing.push(`consumes=[${absent.join(", ")}]`);
|
||
}
|
||
if (Array.isArray(record.fragments) && record.fragments.length > 0) {
|
||
const absent = record.fragments.flatMap((fragment) => {
|
||
const id = `${fragment.anchor}@${fragment.order}:${fragment.hash}`;
|
||
const open =
|
||
`<!-- plugin:${plugin}:${fragment.anchor}:${fragment.order}:${fragment.hash} -->`;
|
||
const close =
|
||
`<!-- /plugin:${plugin}:${fragment.anchor}:${fragment.order}:${fragment.hash} -->`;
|
||
const openIdx = content.indexOf(open);
|
||
if (openIdx === -1) return [id];
|
||
const bodyStart = openIdx + open.length;
|
||
const closeIdx = content.indexOf(close, bodyStart);
|
||
if (closeIdx === -1) return [id];
|
||
const wrapped = content.slice(bodyStart, closeIdx);
|
||
if (!wrapped.startsWith("\n") || !wrapped.endsWith("\n")) return [id];
|
||
return fragmentProseHash(wrapped.slice(1, -1)) === fragment.hash ? [] : [id];
|
||
});
|
||
if (absent.length > 0) missing.push(`fragments=[${absent.join(", ")}]`);
|
||
}
|
||
return missing;
|
||
}
|
||
|
||
function contributionRecordError(value: unknown): string | undefined {
|
||
if (!isPlainObject(value)) return "expected an object";
|
||
let hasContribution = false;
|
||
for (const field of ["produces", "sensors", "scopes", "required_sections"] as const) {
|
||
if (!(field in value)) continue;
|
||
if (!Array.isArray(value[field])) return `${field} must be an array`;
|
||
if (value[field].some((entry) => typeof entry !== "string" || entry.length === 0)) {
|
||
return `${field} must contain non-empty strings`;
|
||
}
|
||
if (value[field].length > 0) hasContribution = true;
|
||
}
|
||
if ("consumes" in value) {
|
||
if (!Array.isArray(value.consumes)) return "consumes must be an array";
|
||
for (const [index, consume] of value.consumes.entries()) {
|
||
if (typeof consume === "string") {
|
||
if (consume.length === 0) return `consumes[${index}] must be a non-empty string`;
|
||
continue;
|
||
}
|
||
if (!isPlainObject(consume)) {
|
||
return `consumes[${index}] must be a legacy string or an object`;
|
||
}
|
||
if (typeof consume.artifact !== "string" || consume.artifact.length === 0) {
|
||
return `consumes[${index}].artifact must be a non-empty string`;
|
||
}
|
||
if (typeof consume.required !== "boolean") {
|
||
return `consumes[${index}].required must be a boolean`;
|
||
}
|
||
if (
|
||
"conditional_on" in consume &&
|
||
consume.conditional_on !== undefined &&
|
||
consume.conditional_on !== "brownfield" &&
|
||
consume.conditional_on !== "greenfield"
|
||
) {
|
||
return `consumes[${index}].conditional_on must be brownfield or greenfield`;
|
||
}
|
||
}
|
||
if (value.consumes.length > 0) hasContribution = true;
|
||
}
|
||
if (
|
||
"required_sections_created" in value &&
|
||
typeof value.required_sections_created !== "boolean"
|
||
) {
|
||
return "required_sections_created must be a boolean";
|
||
}
|
||
if ("fragments" in value) {
|
||
if (!Array.isArray(value.fragments)) return "fragments must be an array";
|
||
const identities = new Set<string>();
|
||
for (const [index, fragment] of value.fragments.entries()) {
|
||
if (!isPlainObject(fragment)) return `fragments[${index}] must be an object`;
|
||
if (
|
||
typeof fragment.anchor !== "string" ||
|
||
!/^\S+$/.test(fragment.anchor)
|
||
) {
|
||
return `fragments[${index}].anchor must be a non-empty token`;
|
||
}
|
||
if (
|
||
typeof fragment.order !== "number" ||
|
||
!Number.isSafeInteger(fragment.order) ||
|
||
fragment.order < 0
|
||
) {
|
||
return `fragments[${index}].order must be a non-negative integer`;
|
||
}
|
||
if (typeof fragment.hash !== "string" || !/^[0-9a-f]{8}$/.test(fragment.hash)) {
|
||
return `fragments[${index}].hash must be an 8-character lowercase hex hash`;
|
||
}
|
||
const identity = `${fragment.anchor}\0${fragment.order}`;
|
||
if (identities.has(identity)) {
|
||
return `fragments contains duplicate identity ${fragment.anchor}@${fragment.order}`;
|
||
}
|
||
identities.add(identity);
|
||
}
|
||
if (value.fragments.length > 0) hasContribution = true;
|
||
}
|
||
return hasContribution ? undefined : "record has no contributions";
|
||
}
|
||
|
||
// Strip every sentinel-marked prose fragment this plugin spliced. The open
|
||
// and close markers carry the plugin name, so removal needs no sidecar.
|
||
// Anchors may themselves contain colons (after-step:9, in:Sensors), so the
|
||
// anchor segment is matched non-greedily up to the trailing :order:hash.
|
||
function removePluginFragments(content: string, plugin: string): string {
|
||
const pE = escapeRegex(plugin);
|
||
const openRe = new RegExp(`<!-- plugin:${pE}:.+?:\\d+:[0-9a-f]+ -->`, "g");
|
||
let out = content;
|
||
let match = openRe.exec(out);
|
||
while (match !== null) {
|
||
const close = `<!-- /${match[0].slice(5)}`;
|
||
const closeIdx = out.indexOf(close, match.index);
|
||
if (closeIdx === -1) break; // unpaired marker: leave as-is (doctor territory)
|
||
const end = closeIdx + close.length;
|
||
out = `${out.slice(0, match.index)}${out.slice(end)}`.replace(/\n{3,}/g, "\n\n");
|
||
openRe.lastIndex = 0;
|
||
match = openRe.exec(out);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Strip the merged contributions of every named plugin from staged stage
|
||
// source. The caller commits the resulting staged-project diff through the
|
||
// shared transaction engine; consumed sidecars are deleted in staging and
|
||
// compose recreates them when the plugin is re-enabled.
|
||
function stripDisabledPluginContributions(
|
||
plugins: readonly string[],
|
||
): string[] {
|
||
const stagesRoot = installedStagesRoot();
|
||
const stripped: string[] = [];
|
||
for (const plugin of plugins) {
|
||
const sidecar = pluginContribSidecarPath(plugin);
|
||
let manifest: Record<string, StageContribRecord> = {};
|
||
if (existsSync(sidecar)) {
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(sidecar, "utf-8"));
|
||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) manifest = parsed;
|
||
} catch {
|
||
// Unreadable sidecar: fragments still strip below; structural adds stay.
|
||
}
|
||
}
|
||
let pluginTouched = false;
|
||
for (const phase of PHASES) {
|
||
const dir = join(stagesRoot, phase);
|
||
if (!existsSync(dir)) continue;
|
||
for (const f of readdirSync(dir).filter((name) => name.endsWith(".md")).sort()) {
|
||
const path = join(dir, f);
|
||
const before = readFileSync(path, "utf-8");
|
||
let content = before;
|
||
const record = manifest[f.replace(/\.md$/, "")];
|
||
if (record) {
|
||
if (record.produces?.length) content = removeListValues(content, "produces", new Set(record.produces), false);
|
||
if (record.sensors?.length) content = removeListValues(content, "sensors", new Set(record.sensors), false);
|
||
if (record.scopes?.length) content = removeListValues(content, "scopes", new Set(record.scopes), false);
|
||
if (record.consumes?.length) {
|
||
const artifacts = record.consumes.flatMap((entry) =>
|
||
typeof entry === "string"
|
||
? [entry]
|
||
: entry && typeof entry.artifact === "string"
|
||
? [entry.artifact]
|
||
: []
|
||
);
|
||
content = removeConsumesEntries(content, new Set(artifacts));
|
||
}
|
||
if (record.required_sections?.length) {
|
||
content = removeListValues(content, "required_sections", new Set(record.required_sections), record.required_sections_created === true);
|
||
}
|
||
}
|
||
content = removePluginFragments(content, plugin);
|
||
if (content !== before) {
|
||
writeFileSync(path, content, "utf-8");
|
||
pluginTouched = true;
|
||
}
|
||
}
|
||
}
|
||
if (existsSync(sidecar)) {
|
||
rmSync(sidecar, { force: true });
|
||
pluginTouched = true;
|
||
}
|
||
if (pluginTouched) stripped.push(plugin);
|
||
}
|
||
return stripped;
|
||
}
|
||
|
||
// A selection change must not strand a live workflow: after disable, a state
|
||
// file whose Scope belongs to a disabled plugin makes every later /aidlc on
|
||
// that workflow hard-error ("Unknown scope") with no in-band way out (the
|
||
// state file's scope out-ranks --scope), and a plugin-owned EXECUTE stage
|
||
// still pending in the plan either errors (it is Current Stage) or silently
|
||
// vanishes from the walk. Enumerate every non-complete workflow across all
|
||
// spaces and name each dependency on a plugin the new selection disables.
|
||
function activeWorkflowDependencyViolations(
|
||
projectDir: string,
|
||
enabled: ReadonlySet<string>,
|
||
): string[] {
|
||
const violations: string[] = [];
|
||
const scopeOwner = new Map<string, string>();
|
||
for (const [name, meta] of Object.entries(loadScopeMetadataAll())) {
|
||
scopeOwner.set(name, meta.plugin ?? "aidlc");
|
||
}
|
||
// Mirror stageEnabledBySelection: initialization stages are always enabled,
|
||
// so they can never strand a plan regardless of the selection.
|
||
const stageOwner = new Map<string, string>();
|
||
for (const stage of loadStageGraphAll()) {
|
||
if (stage.phase === "initialization") continue;
|
||
stageOwner.set(stage.slug, stage.plugin ?? "aidlc");
|
||
}
|
||
for (const space of listSpaces(projectDir)) {
|
||
for (const intent of listIntents(projectDir, space.name)) {
|
||
if (intent.status === "complete" || !intent.dirName) continue;
|
||
const sp = stateFilePath(projectDir, intent.dirName, space.name);
|
||
if (!existsSync(sp)) continue;
|
||
const content = readFileSync(sp, "utf-8");
|
||
if ((getField(content, "Status") ?? "") === "Completed") continue;
|
||
const where = `workflow "${intent.dirName}" (space ${space.name})`;
|
||
const scope = getField(content, "Scope");
|
||
if (scope) {
|
||
const owner = scopeOwner.get(scope);
|
||
if (owner && !enabled.has(owner)) {
|
||
violations.push(`${where} runs under scope "${scope}" owned by plugin "${owner}"`);
|
||
}
|
||
}
|
||
// Pending/active plugin-owned stages in the plan (EXECUTE rows that are
|
||
// not yet completed/skipped) - the walk would error on or silently drop
|
||
// them. Completed rows are history; they don't depend on the plugin.
|
||
for (const cb of parseCheckboxes(content)) {
|
||
if (cb.state === "completed" || cb.state === "skipped") continue;
|
||
if (!cb.suffix.startsWith("EXECUTE")) continue;
|
||
const owner = stageOwner.get(cb.slug);
|
||
if (owner && !enabled.has(owner)) {
|
||
violations.push(`${where} has pending stage "${cb.slug}" owned by plugin "${owner}"`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return violations;
|
||
}
|
||
|
||
function handleSelectPlugins(projectDir: string, positional: string[]): void {
|
||
if (positional.length === 1) {
|
||
const selection = renderPluginSelection(pluginsEnabled());
|
||
process.stdout.write(
|
||
`Current plugin selection: ${selection}\nKnown plugins: ${knownPluginNames().join(", ")}\n`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const parsedSelection = parsePluginSelectionArgs(positional);
|
||
if (parsedSelection.hasEmpty || parsedSelection.names.length === 0) {
|
||
die("select-plugins requires at least one non-empty plugin name, or no arguments to print the current selection.");
|
||
}
|
||
const names = [...new Set(parsedSelection.names)].sort();
|
||
requireInstalledHarness(projectDir);
|
||
|
||
// A plugin compose holds the workspace lock across compile + runner
|
||
// regeneration (can exceed the default ~5s acquire budget on a loaded
|
||
// machine), and select-plugins legitimately queues behind it - so wait up
|
||
// to ~60s. Dead holders are reaped immediately regardless of budget.
|
||
withAuditLock(projectDir, () => {
|
||
// Compose can install a plugin while this command waits for the lock, so
|
||
// discover and validate identities only after entering the transaction.
|
||
const known = knownPluginNames();
|
||
const knownSet = new Set(known);
|
||
const unknown = names.filter((name) => !knownSet.has(name));
|
||
if (unknown.length > 0) {
|
||
die(`Unknown plugin name(s): ${unknown.join(", ")}. Valid plugins: ${known.join(", ")}.`);
|
||
}
|
||
const violations = activeWorkflowDependencyViolations(projectDir, new Set(names));
|
||
if (violations.length > 0) {
|
||
die(
|
||
`select-plugins refused: the new selection would strand ${violations.length} active workflow dependency(ies):\n` +
|
||
violations.map((v) => ` - ${v}`).join("\n") +
|
||
`\nComplete or park the workflow(s) first (or keep the plugin enabled), then re-run select-plugins.`,
|
||
);
|
||
}
|
||
|
||
const previousSelection = renderPluginSelection(pluginsEnabled());
|
||
const newSelection = names.join(", ");
|
||
const nameSet = new Set(names);
|
||
// Plugins this change DISABLES (known but not selected; the implicit core
|
||
// plugin has no composed contributions to strip).
|
||
const disabling = known.filter((n) => n !== "aidlc" && !nameSet.has(n));
|
||
|
||
const stagingRoot = mkdtempSync(join(tmpdir(), "aidlc-plugin-select-"));
|
||
const stagedProject = join(stagingRoot, "project");
|
||
try {
|
||
const selectedHarness = harnessDir();
|
||
copyProjectSurfaces(projectDir, stagedProject, selectedHarness);
|
||
const envKeys = [
|
||
"AIDLC_RUNTIME_PROJECT_DIR",
|
||
"AIDLC_PROJECT_DIR",
|
||
"AIDLC_HARNESS_DIR",
|
||
"AIDLC_RUNTIME_HARNESS_ROOT",
|
||
] as const;
|
||
const saved = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
|
||
let strippedPlugins: string[] = [];
|
||
try {
|
||
process.env.AIDLC_RUNTIME_PROJECT_DIR = stagedProject;
|
||
process.env.AIDLC_PROJECT_DIR = stagedProject;
|
||
process.env.AIDLC_HARNESS_DIR = selectedHarness;
|
||
process.env.AIDLC_RUNTIME_HARNESS_ROOT = join(stagedProject, selectedHarness);
|
||
resetSelectionSensitiveCaches();
|
||
// Strip disabled contributions in staging before recompiling. The live
|
||
// project remains byte-untouched until the shared transaction commits.
|
||
strippedPlugins = stripDisabledPluginContributions(disabling);
|
||
writePluginSelection(stagedProject, names);
|
||
regenerateSelectionSurfaces(stagedProject, projectDir);
|
||
} finally {
|
||
for (const key of envKeys) {
|
||
const value = saved[key];
|
||
if (value === undefined) delete process.env[key];
|
||
else process.env[key] = value;
|
||
}
|
||
resetSelectionSensitiveCaches();
|
||
}
|
||
const plan = projectDiffPlan(projectDir, stagedProject, selectedHarness);
|
||
const failAfter = Number(process.env.AIDLC_PLUGIN_SELECT_FAIL_AFTER ?? "0");
|
||
executePlan(plan, {
|
||
failAfter: Number.isInteger(failAfter) && failAfter > 0
|
||
? failAfter
|
||
: undefined,
|
||
validateCommitted: () => {
|
||
appendAuditEvent(projectDir, "PLUGIN_SELECTION_CHANGED", {
|
||
"Previous Selection": previousSelection,
|
||
"New Selection": newSelection,
|
||
});
|
||
},
|
||
});
|
||
resetSelectionSensitiveCaches();
|
||
if (strippedPlugins.length > 0) {
|
||
process.stdout.write(
|
||
`Stripped merged contributions of disabled plugin(s): ${strippedPlugins.join(", ")} (re-enabling restores them on the next session start)\n`,
|
||
);
|
||
}
|
||
process.stdout.write(`Enabled plugins: ${names.join(", ")}\n`);
|
||
} catch (err) {
|
||
resetSelectionSensitiveCaches();
|
||
die(`select-plugins failed: ${errorMessage(err)}`);
|
||
} finally {
|
||
rmSync(stagingRoot, { recursive: true, force: true });
|
||
}
|
||
}, undefined, undefined, WORKSPACE_MUTATION_LOCK_RETRIES);
|
||
}
|
||
|
||
function pluginListRows(): Array<{ name: string; enabled: boolean }> {
|
||
const selected = pluginsEnabled();
|
||
return knownPluginNames().map((name) => ({
|
||
name,
|
||
enabled: selected === null || selected.has(name),
|
||
}));
|
||
}
|
||
|
||
function handlePluginList(flags: Record<string, string>): void {
|
||
const selected = pluginsEnabled();
|
||
const rows = pluginListRows();
|
||
if (flags.json === "true") {
|
||
process.stdout.write(
|
||
`${JSON.stringify({
|
||
plugins: rows,
|
||
selectionActive: selected !== null,
|
||
})}\n`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
process.stdout.write(
|
||
`Plugin selection: ${renderPluginSelection(selected)}\n` +
|
||
rows.map((row) => `${row.name} ${row.enabled ? "enabled" : "disabled"}`).join("\n") +
|
||
(rows.length > 0 ? "\n" : ""),
|
||
);
|
||
}
|
||
|
||
function pluginAuthorCommandCode(code: number): void {
|
||
if (code !== 0) process.exitCode = code;
|
||
}
|
||
|
||
function handlePluginValidate(
|
||
positional: string[],
|
||
flags: Record<string, string>,
|
||
): void {
|
||
const unknown = Object.keys(flags).filter(
|
||
(key) => key !== "json" && key !== "help",
|
||
);
|
||
if (unknown.length > 0 || positional.length > 2) {
|
||
pluginAuthorCommandCode(pluginValidateMain([]));
|
||
return;
|
||
}
|
||
if (
|
||
flags.help === "true" ||
|
||
positional[1] === "-h" ||
|
||
positional[1] === "--help"
|
||
) {
|
||
pluginAuthorCommandCode(pluginValidateMain(["--help"]));
|
||
return;
|
||
}
|
||
const args = [positional[1] ?? process.cwd()];
|
||
if (flags.json === "true") args.push("--json");
|
||
pluginAuthorCommandCode(pluginValidateMain(args));
|
||
}
|
||
|
||
function handlePluginBuild(
|
||
positional: string[],
|
||
flags: Record<string, string>,
|
||
missingValueFlags: ReadonlySet<string>,
|
||
): void {
|
||
const unknown = Object.keys(flags).filter(
|
||
(key) =>
|
||
key !== "json" &&
|
||
key !== "help" &&
|
||
key !== "plugin-root",
|
||
);
|
||
if (
|
||
unknown.length > 0 ||
|
||
positional.length > 3 ||
|
||
missingValueFlags.has("plugin-root")
|
||
) {
|
||
pluginAuthorCommandCode(pluginBuildMain([]));
|
||
return;
|
||
}
|
||
if (
|
||
flags.help === "true" ||
|
||
positional[1] === "-h" ||
|
||
positional[1] === "--help"
|
||
) {
|
||
pluginAuthorCommandCode(pluginBuildMain(["--help"]));
|
||
return;
|
||
}
|
||
const args = [
|
||
flags["plugin-root"] ?? process.cwd(),
|
||
...(positional[1] ? [positional[1]] : []),
|
||
...(positional[2] ? [positional[2]] : []),
|
||
];
|
||
if (flags.json === "true") args.push("--json");
|
||
pluginAuthorCommandCode(pluginBuildMain(args));
|
||
}
|
||
|
||
function pluginRootCandidatesFromEnv(): string[] {
|
||
const roots = [
|
||
process.env.CLAUDE_PLUGIN_ROOT,
|
||
process.env.PLUGIN_ROOT,
|
||
process.env.AIDLC_PLUGIN_ROOT,
|
||
]
|
||
.map((value) => value?.trim() ?? "")
|
||
.filter((value) => value.length > 0);
|
||
return [...new Set(roots)];
|
||
}
|
||
|
||
async function handlePluginSync(projectDir: string): Promise<void> {
|
||
const roots = pluginRootCandidatesFromEnv();
|
||
if (roots.length === 0) {
|
||
process.stdout.write("no installed plugins; nothing to sync\n");
|
||
return;
|
||
}
|
||
|
||
const pluginRoots = roots.map((root) => {
|
||
const compose = join(root, "hooks", "compose.ts");
|
||
return {
|
||
root,
|
||
compose,
|
||
reason: existsSync(compose)
|
||
? null
|
||
: existsSync(root)
|
||
? "missing hooks/compose.ts"
|
||
: "root directory does not exist",
|
||
};
|
||
});
|
||
const composePaths = pluginRoots.filter((item) => item.reason === null);
|
||
const skippedRoots = pluginRoots.filter((item) => item.reason !== null);
|
||
const skippedDetails = skippedRoots
|
||
.map((item) => `- ${item.root}: ${item.reason}`)
|
||
.join("\n");
|
||
|
||
if (composePaths.length === 0) {
|
||
die(
|
||
`plugin-sync: no compose hook found in ${roots.length} configured plugin root(s):\n${skippedDetails}`,
|
||
);
|
||
}
|
||
|
||
if (skippedRoots.length > 0) {
|
||
process.stderr.write(
|
||
`plugin-sync warning: skipped ${skippedRoots.length} configured plugin root(s):\n${skippedDetails}\n`,
|
||
);
|
||
}
|
||
|
||
for (const item of composePaths) {
|
||
const composeEnv: NodeJS.ProcessEnv = {
|
||
...process.env,
|
||
AIDLC_HARNESS_DIR: harnessDir(),
|
||
AIDLC_HARNESS_NAME: runtimeHarnessName(projectDir, harnessDir()),
|
||
AIDLC_PROJECT_DIR: projectDir,
|
||
AIDLC_PLUGIN_ROOT: item.root,
|
||
CLAUDE_PLUGIN_ROOT: item.root,
|
||
PLUGIN_ROOT: item.root,
|
||
};
|
||
if (isCompiledExecutable()) {
|
||
const envKeys = [
|
||
"AIDLC_HARNESS_DIR",
|
||
"AIDLC_HARNESS_NAME",
|
||
"AIDLC_PROJECT_DIR",
|
||
"AIDLC_PLUGIN_ROOT",
|
||
"CLAUDE_PLUGIN_ROOT",
|
||
"PLUGIN_ROOT",
|
||
"AIDLC_COMPILED_EXECUTABLE",
|
||
] as const;
|
||
const previous = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
|
||
Object.assign(process.env, composeEnv, {
|
||
AIDLC_COMPILED_EXECUTABLE: process.execPath,
|
||
});
|
||
try {
|
||
const mod = await import(pathToFileURL(item.compose).href) as {
|
||
compose?: () => void | Promise<void>;
|
||
};
|
||
if (typeof mod.compose !== "function") {
|
||
die(`plugin-sync failed for ${item.root}: compose.ts does not export compose()`);
|
||
}
|
||
await mod.compose();
|
||
} catch (error) {
|
||
die(`plugin-sync failed for ${item.root}: ${errorMessage(error)}`);
|
||
} finally {
|
||
for (const key of envKeys) {
|
||
const value = previous[key];
|
||
if (value === undefined) delete process.env[key];
|
||
else process.env[key] = value;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
const result = spawnSync(process.execPath, [item.compose], {
|
||
cwd: projectDir,
|
||
encoding: "utf-8",
|
||
env: composeEnv,
|
||
});
|
||
if (result.status !== 0) {
|
||
const detail = (result.stderr || result.stdout || `exit ${result.status ?? 1}`).trim();
|
||
die(`plugin-sync failed for ${item.root}: ${detail}`);
|
||
}
|
||
}
|
||
|
||
process.stdout.write(`plugin sync complete: ${composePaths.length} plugin(s)\n`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// status
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export const GATE_PENDING_ADVISORY_MS = 24 * 60 * 60 * 1000;
|
||
|
||
interface PendingOrganicGate {
|
||
timestamp: string;
|
||
timestampMs: number;
|
||
}
|
||
|
||
function pendingOrganicGate(
|
||
audit: AuditShardEvent[],
|
||
stage: string,
|
||
): PendingOrganicGate | null {
|
||
const relevant = new Set([
|
||
"WORKFLOW_STARTED",
|
||
"STAGE_JUMPED",
|
||
"STAGE_STARTED",
|
||
"STAGE_AWAITING_APPROVAL",
|
||
"GATE_APPROVED",
|
||
"GATE_REJECTED",
|
||
]);
|
||
const events = audit
|
||
.filter((event) => relevant.has(event.event))
|
||
.sort((a, b) => {
|
||
if (a.timestamp !== b.timestamp) return a.timestamp < b.timestamp ? -1 : 1;
|
||
if (a.shardIndex !== b.shardIndex) return a.shardIndex - b.shardIndex;
|
||
return a.pos - b.pos;
|
||
});
|
||
|
||
let pending: PendingOrganicGate | null = null;
|
||
for (let start = 0; start < events.length;) {
|
||
let end = start + 1;
|
||
while (
|
||
end < events.length &&
|
||
events[end].timestamp === events[start].timestamp
|
||
) {
|
||
end++;
|
||
}
|
||
|
||
// Only same-shard append order is causal. The last effective row in each
|
||
// shard can be globally last; disagreeing effects therefore fail closed.
|
||
const effectByShard = new Map<string, "open" | "clear">();
|
||
for (const event of events.slice(start, end)) {
|
||
const eventStage = auditBlockField(event.block, "Stage");
|
||
const boundary =
|
||
event.event === "WORKFLOW_STARTED" ||
|
||
event.event === "STAGE_JUMPED" ||
|
||
(
|
||
event.event === "STAGE_STARTED" &&
|
||
eventStage === stage &&
|
||
!auditBlockField(event.block, "Workflow")?.startsWith("single-stage:")
|
||
);
|
||
if (boundary) {
|
||
effectByShard.set(event.shard, "clear");
|
||
continue;
|
||
}
|
||
if (eventStage !== stage) continue;
|
||
if (event.event === "STAGE_AWAITING_APPROVAL") {
|
||
if (
|
||
auditBlockField(event.block, "Recovered") === "true" ||
|
||
auditBlockField(event.block, "Revalidated") === "true"
|
||
) {
|
||
continue;
|
||
}
|
||
effectByShard.set(event.shard, "open");
|
||
} else {
|
||
effectByShard.set(event.shard, "clear");
|
||
}
|
||
}
|
||
|
||
const effects = new Set(effectByShard.values());
|
||
if (effects.size > 1 || effects.has("clear")) {
|
||
pending = null;
|
||
} else if (effects.has("open")) {
|
||
const timestamp = events[start].timestamp;
|
||
const timestampMs = Date.parse(timestamp);
|
||
pending = Number.isFinite(timestampMs)
|
||
? { timestamp, timestampMs }
|
||
: null;
|
||
}
|
||
start = end;
|
||
}
|
||
return pending;
|
||
}
|
||
|
||
function pendingDuration(ageMs: number): string {
|
||
const minutes = Math.floor(Math.max(0, ageMs) / (60 * 1000));
|
||
if (minutes < 60) return `${minutes}m`;
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 48) return `${hours}h`;
|
||
return `${Math.floor(hours / 24)}d`;
|
||
}
|
||
|
||
function handleStatus(projectDir: string, flags: Record<string, string>): void {
|
||
// --intent <record> / --space <name> target a specific intent's status
|
||
// (vision §5); omitted -> the active record.
|
||
const selection = resolveWorkflowSelection(projectDir, {
|
||
space: flags.space,
|
||
intent: flags.intent,
|
||
});
|
||
const sp =
|
||
selection.intent === null
|
||
? join(intentsDir(projectDir, selection.space), "aidlc-state.md")
|
||
: stateFilePath(projectDir, selection.intent, selection.space);
|
||
if (!existsSync(sp)) {
|
||
process.stdout.write(
|
||
`No active AI-DLC workflow found.
|
||
|
||
To get started:
|
||
/aidlc "build the auth service" Describe what to build (creates the workflow record automatically)
|
||
/aidlc <scope> Start a workflow by scope (e.g., /aidlc feature)
|
||
/aidlc --help Show all commands and scopes
|
||
`
|
||
);
|
||
return;
|
||
}
|
||
|
||
const content = readFileSync(sp, "utf-8");
|
||
const graph = loadStageGraph();
|
||
|
||
// Extract key fields
|
||
const project = getField(content, "Project") || "Unknown";
|
||
const scope = getField(content, "Scope") || "Unknown";
|
||
const phase = getField(content, "Lifecycle Phase") || "Unknown";
|
||
const currentStage = getField(content, "Current Stage") || "Unknown";
|
||
const status = getField(content, "Status") || "Unknown";
|
||
const activeAgent = getField(content, "Active Agent") || "None";
|
||
const lastCompleted = getField(content, "Last Completed Stage") || "None";
|
||
const nextStage = getField(content, "Next Stage") || "None";
|
||
// Resolved, not the raw line: a memory layer holding strict shows as strict
|
||
// from that file even when the intent's own line says relaxed.
|
||
let changeControlDisplay: string;
|
||
try {
|
||
const resolution = resolveChangeControl(projectDir, content, {
|
||
selection: { intent: selection.intent ?? undefined, space: selection.space },
|
||
});
|
||
changeControlDisplay = formatChangeControl(resolution.value, resolution.source);
|
||
} catch (error) {
|
||
changeControlDisplay = `unavailable (${errorMessage(error)})`;
|
||
}
|
||
|
||
// Find current stage number
|
||
const currentEntry = graph.find((s) => s.slug === currentStage);
|
||
const stageDisplay = currentEntry
|
||
? `${currentEntry.name} (${currentEntry.number})`
|
||
: currentStage;
|
||
|
||
// Gate awareness — when the current stage's checkbox is [?] or [R], the
|
||
// user (not the LLM) is the blocker. Surface this explicitly in Status so
|
||
// `/aidlc --status` answers "what's blocking this workflow?" correctly.
|
||
const checkboxesAll = parseCheckboxes(content);
|
||
const currentCheckbox = checkboxesAll.find((c) => c.slug === currentStage);
|
||
let statusLine = status;
|
||
if (currentCheckbox?.state === "awaiting-approval") {
|
||
const displayName = currentEntry?.name ?? currentStage;
|
||
statusLine = `Awaiting your approval on ${displayName}`;
|
||
try {
|
||
const pending = pendingOrganicGate(
|
||
readAuditShardEvents(projectDir, flags.intent, flags.space),
|
||
currentStage,
|
||
);
|
||
if (pending) {
|
||
statusLine +=
|
||
` (waiting since ${pending.timestamp}, ` +
|
||
`~${pendingDuration(Date.now() - pending.timestampMs)})`;
|
||
}
|
||
} catch {
|
||
// Status remains useful when the ledger is absent, unreadable, or stale.
|
||
}
|
||
} else if (currentCheckbox?.state === "revising") {
|
||
const displayName = currentEntry?.name ?? currentStage;
|
||
const revisionCount = getField(content, "Revision Count");
|
||
// If the Revision Count field is missing, omit the count rather than
|
||
// render a literal "?" — state files authored before the field existed
|
||
// would otherwise render "revision ? of 3".
|
||
statusLine = revisionCount
|
||
? `Revising ${displayName} (revision ${revisionCount} of 3)`
|
||
: `Revising ${displayName}`;
|
||
} else if (currentCheckbox?.state === "completed" && status === "Running") {
|
||
// Post-approve window: the stage was approved (→ [x]) but the orchestrator
|
||
// hasn't called `advance` yet, so Current Stage still points here. Tell
|
||
// the user honestly rather than showing "Running" on a completed stage.
|
||
const displayName = currentEntry?.name ?? currentStage;
|
||
statusLine = `${displayName} approved - ready to advance`;
|
||
}
|
||
|
||
// Checkbox counts - filter to the EFFECTIVE plan when scope is known: the
|
||
// state file's per-stage EXECUTE/SKIP suffixes (a recomposed plan) override
|
||
// the static grid, so status counts against what the router will actually
|
||
// run, not the pre-recompose column.
|
||
const checkboxes = parseCheckboxes(content);
|
||
const suffixOverrides = parseStateStageSuffixes(content);
|
||
const inScopeInfo = stagesInScope(scope);
|
||
const inScopeSlugs = new Set(
|
||
inScopeInfo
|
||
.filter((s) => (suffixOverrides.get(s.slug) ?? s.action) === "EXECUTE")
|
||
.map((s) => s.slug)
|
||
);
|
||
const scopedCheckboxes =
|
||
scope !== "Unknown" && inScopeSlugs.size > 0
|
||
? checkboxes.filter((c) => inScopeSlugs.has(c.slug))
|
||
: checkboxes;
|
||
const total = scopedCheckboxes.length;
|
||
const completed = scopedCheckboxes.filter((c) => c.state === "completed").length;
|
||
const skipped = scopedCheckboxes.filter((c) => c.state === "skipped").length;
|
||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||
|
||
// Build phase progress bars
|
||
const phaseLabels: Record<string, string> = {
|
||
initialization: "INITIALIZATION",
|
||
ideation: "IDEATION",
|
||
inception: "INCEPTION",
|
||
construction: "CONSTRUCTION",
|
||
operation: "OPERATION",
|
||
};
|
||
|
||
let phaseProgress = "";
|
||
for (const p of PHASES) {
|
||
const phaseStages = graph.filter((s) => s.phase === p);
|
||
const phaseSlugs = new Set(phaseStages.map((s) => s.slug));
|
||
const phaseCheckboxes = scopedCheckboxes.filter((c) => phaseSlugs.has(c.slug));
|
||
if (phaseCheckboxes.length === 0) continue;
|
||
|
||
const bar = phaseCheckboxes
|
||
.map((c) => {
|
||
switch (c.state) {
|
||
case "completed":
|
||
return "\u2588";
|
||
case "in-progress":
|
||
return "\u2592";
|
||
case "awaiting-approval":
|
||
return "?";
|
||
case "revising":
|
||
return "R";
|
||
case "skipped":
|
||
return "S";
|
||
default:
|
||
return "\u2591";
|
||
}
|
||
})
|
||
.join("");
|
||
|
||
const done = phaseCheckboxes.filter(
|
||
(c) => c.state === "completed"
|
||
).length;
|
||
phaseProgress += ` ${(phaseLabels[p] || p).padEnd(16)} ${bar} ${done}/${phaseCheckboxes.length}\n`;
|
||
}
|
||
|
||
let validityOutput = "Validity: Current\n";
|
||
try {
|
||
const validity = inspectStageValidity(projectDir, content, {
|
||
stages: graph,
|
||
audit: readAllAuditShards(projectDir, flags.intent, flags.space),
|
||
currentBasis: (stage, stages) =>
|
||
captureStageValidationBasis(projectDir, stage, content, stages, {
|
||
resolution: { recordPath: dirname(sp), stateContent: content },
|
||
}),
|
||
});
|
||
const directlyStale = validity.issues
|
||
.filter((issue) => issue.direct)
|
||
.map((issue) => issue.stage);
|
||
const needsRevalidation = validity.issues
|
||
.filter((issue) => !issue.direct)
|
||
.map((issue) => issue.stage);
|
||
const earliest = directlyStale[0] ?? validity.issues[0]?.stage ?? null;
|
||
if (validity.warnings.length > 0) {
|
||
validityOutput =
|
||
"Validity: Inspection partly unavailable - advisory; routing continues\n" +
|
||
`Directly stale: ${directlyStale.join(", ") || "none"}\n` +
|
||
`Revalidate: ${needsRevalidation.join(", ") || "none"}\n` +
|
||
`Untracked: ${validity.untracked.join(", ") || "none"}\n` +
|
||
`Warnings: ${validity.warnings.join(" ")}\n`;
|
||
} else if (validity.issues.length > 0) {
|
||
validityOutput =
|
||
"Validity: Drift detected - advisory; routing continues\n" +
|
||
`Directly stale: ${directlyStale.join(", ") || "none"}\n` +
|
||
`Revalidate: ${needsRevalidation.join(", ") || "none"}\n` +
|
||
`Suggested redo: ${earliest ? `/aidlc --stage ${earliest}` : "none"}\n` +
|
||
`Untracked: ${validity.untracked.join(", ") || "none"}\n`;
|
||
} else if (validity.untracked.length > 0) {
|
||
validityOutput =
|
||
"Validity: Untracked completions - advisory; routing continues\n" +
|
||
`Untracked: ${validity.untracked.join(", ")}\n`;
|
||
}
|
||
} catch (error) {
|
||
validityOutput =
|
||
"Validity: Inspection unavailable - advisory; routing continues\n" +
|
||
`Warnings: ${errorMessage(error)}\n`;
|
||
}
|
||
|
||
const output = `AI-DLC Workflow Status
|
||
==============================
|
||
Project: ${project}
|
||
Scope: ${scope}
|
||
Phase: ${phase}
|
||
Current Stage: ${stageDisplay}
|
||
Status: ${statusLine}
|
||
Active Agent: ${activeAgent}
|
||
Change Control: ${changeControlDisplay}
|
||
Completion: ${completed}/${total} stages (${pct}%)${skipped > 0 ? ` - ${skipped} skipped` : ""}
|
||
|
||
Phase Progress:
|
||
${phaseProgress}
|
||
${validityOutput}
|
||
Last Completed: ${lastCompleted}
|
||
Next Stage: ${nextStage}
|
||
`;
|
||
if (isTeamUnitOwnership(content)) {
|
||
const selectorArgs = [
|
||
...(flags.intent ? ["--intent", flags.intent] : []),
|
||
...(flags.space ? ["--space", flags.space] : []),
|
||
];
|
||
const executable = compiledExecutable();
|
||
const command = executable
|
||
? [
|
||
executable,
|
||
"team-board",
|
||
"--snapshot",
|
||
...selectorArgs,
|
||
"--project-dir",
|
||
projectDir,
|
||
]
|
||
: [
|
||
process.execPath,
|
||
resolveHarnessPath(["tools", "aidlc-orchestrate.ts"], { projectDir }),
|
||
"team-board",
|
||
"--snapshot",
|
||
...selectorArgs,
|
||
"--project-dir",
|
||
projectDir,
|
||
];
|
||
const board = spawnSync(command[0], command.slice(1), {
|
||
cwd: projectDir,
|
||
encoding: "utf-8",
|
||
env: process.env,
|
||
});
|
||
if ((board.status ?? 1) !== 0) {
|
||
die(
|
||
`Cannot render Team Construction snapshot: ${
|
||
(board.stderr || board.stdout || `exit ${board.status ?? 1}`).trim()
|
||
}`,
|
||
);
|
||
}
|
||
process.stdout.write(`${output}\n${board.stdout.trimEnd()}\n`);
|
||
return;
|
||
}
|
||
process.stdout.write(output);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// doctor
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Threshold (days) beyond which doctor flags practices as stale and prompts
|
||
// re-affirmation.
|
||
export const PRACTICES_STALENESS_DAYS = 90;
|
||
|
||
// MERGE_DISPATCH INVOKED-orphan window for advisory reconciliation. Window
|
||
// covers a generous LLM Task call budget (Haiku 30s + retry + parse).
|
||
export const MERGE_DISPATCH_TIMEOUT_SEC = 60;
|
||
export const CLAIM_ACTIVITY_STALE_HOURS = 24;
|
||
|
||
/**
|
||
* Resolve the ordered list of Claude Code `managed-settings.json` paths to probe
|
||
* for a `disableAllHooks` override, most-authoritative first. Pure and
|
||
* platform/env-injected so every OS can be unit-tested without a host of that OS.
|
||
*
|
||
* Paths per Claude Code's settings docs (code.claude.com/docs/en/settings):
|
||
* - macOS: /Library/Application Support/ClaudeCode/managed-settings.json
|
||
* - Linux / WSL: /etc/claude-code/managed-settings.json
|
||
* - Windows: %ProgramFiles%\ClaudeCode\managed-settings.json
|
||
* (legacy %PROGRAMDATA%\ClaudeCode\ — unsupported since v2.1.75,
|
||
* kept only as a secondary probe)
|
||
*
|
||
* AIDLC_MANAGED_SETTINGS_PATH overrides the list entirely — a custom managed
|
||
* path, and the seam tests use to stay hermetic against the host's real file.
|
||
*/
|
||
export function resolveManagedSettingsCandidates(
|
||
platform: NodeJS.Platform,
|
||
env: NodeJS.ProcessEnv,
|
||
): string[] {
|
||
if (env.AIDLC_MANAGED_SETTINGS_PATH) return [env.AIDLC_MANAGED_SETTINGS_PATH];
|
||
if (platform === "darwin") return ["/Library/Application Support/ClaudeCode/managed-settings.json"];
|
||
if (platform === "win32") {
|
||
// Use the win32 joiner explicitly so paths carry backslashes regardless of
|
||
// the host OS running doctor's tests (native `join` would use the host's).
|
||
return [
|
||
winPath.join(env.ProgramFiles || "C:\\Program Files", "ClaudeCode", "managed-settings.json"),
|
||
winPath.join(env.PROGRAMDATA || "C:\\ProgramData", "ClaudeCode", "managed-settings.json"),
|
||
];
|
||
}
|
||
return ["/etc/claude-code/managed-settings.json"];
|
||
}
|
||
|
||
type ClaudeManagedBooleanKey = "disableAllHooks" | "allowManagedHooksOnly";
|
||
|
||
function managedSettingsFiles(candidate: string): string[] {
|
||
const files = [candidate];
|
||
const fragmentsDir = join(dirname(candidate), "managed-settings.d");
|
||
try {
|
||
for (const name of readdirSync(fragmentsDir).sort()) {
|
||
if (!name.endsWith(".json")) continue;
|
||
const path = join(fragmentsDir, name);
|
||
try {
|
||
if (statSync(path).isFile()) files.push(path);
|
||
} catch {
|
||
// A fragment that vanished during enumeration carries no policy.
|
||
}
|
||
}
|
||
} catch {
|
||
// An absent or unreadable fragment directory carries no policy.
|
||
}
|
||
return files;
|
||
}
|
||
|
||
function resolveManagedBooleanSetting(
|
||
key: ClaudeManagedBooleanKey,
|
||
platform: NodeJS.Platform,
|
||
env: NodeJS.ProcessEnv,
|
||
): boolean | undefined {
|
||
for (const candidate of resolveManagedSettingsCandidates(platform, env)) {
|
||
let effective: boolean | undefined;
|
||
for (const path of managedSettingsFiles(candidate)) {
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
||
const value = parsed[key];
|
||
if (typeof value === "boolean") effective = value;
|
||
} catch {
|
||
// Absent, unreadable, or malformed managed files do not define the key.
|
||
}
|
||
}
|
||
if (effective !== undefined) return effective;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
interface NamingMismatch {
|
||
file: string;
|
||
stem: string;
|
||
name: string;
|
||
}
|
||
|
||
type DoctorCheckResult = LegacyDoctorResult;
|
||
|
||
const DEFAULT_PLUGIN_DOCTOR_TIMEOUT_MS = 10_000;
|
||
const PLUGIN_DOCTOR_MAX_BUFFER = 256 * 1024;
|
||
const PLUGIN_DOCTOR_MAX_ROWS = 50;
|
||
const PLUGIN_DOCTOR_MAX_TEXT = 300;
|
||
const PLUGIN_DOCTOR_FINDING_ID_MAX = 48;
|
||
const PLUGIN_NAME_REGEX = /^[a-z][a-z0-9-]*$/;
|
||
const PLUGIN_DOCTOR_REQUIRED_JSON =
|
||
'{"checks":[{"pass":boolean,"label":string,"fix"?:string,"severity"?:"error"|"advisory"}]}';
|
||
|
||
function frontmatterFields(filePath: string, kind: "Agent" | "Scope"): { name: string; plugin: string } {
|
||
const body = readFileSync(filePath, "utf-8");
|
||
const fm = frontmatterBlock(body);
|
||
if (fm === null) throw new Error(`${kind} file missing frontmatter: ${filePath}`);
|
||
const name = scalarField(fm, "name");
|
||
if (!name) throw new Error(`${kind} file ${filePath} missing required frontmatter: name`);
|
||
return { name, plugin: scalarField(fm, "plugin") };
|
||
}
|
||
|
||
function scopeFilenameMatchesDeclaredName(stem: string, name: string, plugin: string): boolean {
|
||
if (plugin) return stem === name;
|
||
return stem === name || stem === `aidlc-${name}`;
|
||
}
|
||
|
||
function namingMismatches(
|
||
dir: string,
|
||
kind: "Agent" | "Scope",
|
||
matches: (stem: string, name: string, plugin: string) => boolean,
|
||
): NamingMismatch[] {
|
||
if (!existsSync(dir)) return [];
|
||
const mismatches: NamingMismatch[] = [];
|
||
for (const f of readdirSync(dir).filter((name) => name.endsWith(".md")).sort()) {
|
||
const filePath = join(dir, f);
|
||
if (!statSync(filePath).isFile()) continue;
|
||
const { name, plugin } = frontmatterFields(filePath, kind);
|
||
const stem = basename(f, ".md");
|
||
if (!matches(stem, name, plugin)) {
|
||
mismatches.push({ file: filePath, stem, name });
|
||
}
|
||
}
|
||
return mismatches;
|
||
}
|
||
|
||
function pushNamingAdvisory(
|
||
results: DoctorCheckResult[],
|
||
label: "Agent" | "Scope",
|
||
mismatches: NamingMismatch[],
|
||
): void {
|
||
if (mismatches.length === 0) {
|
||
results.push({
|
||
pass: true,
|
||
label: `${label} filename/name consistency: all ${label.toLowerCase()} files match declared names`,
|
||
});
|
||
return;
|
||
}
|
||
const detail = mismatches
|
||
.map((m) => `${m.file} stem "${m.stem}" declares name "${m.name}"`)
|
||
.join("; ");
|
||
results.push({
|
||
pass: true,
|
||
label: `${label} filename/name consistency: ${mismatches.length} mismatch(es) (advisory): ${detail}. Rename the file or fix the name.`,
|
||
});
|
||
}
|
||
|
||
function codexNativeTrustHashes(hooksPath: string): string[] {
|
||
const eventNames: Record<string, string> = {
|
||
SessionStart: "session_start",
|
||
UserPromptSubmit: "user_prompt_submit",
|
||
PreToolUse: "pre_tool_use",
|
||
PostToolUse: "post_tool_use",
|
||
PermissionRequest: "permission_request",
|
||
PreCompact: "pre_compact",
|
||
PostCompact: "post_compact",
|
||
SubagentStart: "subagent_start",
|
||
SubagentStop: "subagent_stop",
|
||
Stop: "stop",
|
||
};
|
||
const parsed = JSON.parse(readFileSync(hooksPath, "utf-8")) as {
|
||
hooks?: Record<string, Array<{ hooks?: Array<{ command?: unknown }> }>>;
|
||
};
|
||
const sortKeys = (value: unknown): unknown => {
|
||
if (Array.isArray(value)) return value.map(sortKeys);
|
||
if (value && typeof value === "object") {
|
||
const record = value as Record<string, unknown>;
|
||
return Object.fromEntries(
|
||
Object.keys(record).sort().map((key) => [key, sortKeys(record[key])]),
|
||
);
|
||
}
|
||
return value;
|
||
};
|
||
const hashes: string[] = [];
|
||
for (const [event, groups] of Object.entries(parsed.hooks ?? {})) {
|
||
const eventName = eventNames[event];
|
||
if (!eventName || !Array.isArray(groups)) continue;
|
||
for (const group of groups) {
|
||
for (const hook of group.hooks ?? []) {
|
||
if (
|
||
typeof hook.command !== "string" ||
|
||
!hook.command.startsWith(`${trustedCommand("adapter codex")} `)
|
||
) continue;
|
||
const identity = {
|
||
event_name: eventName,
|
||
hooks: [{
|
||
async: false,
|
||
command: hook.command,
|
||
timeout: 600,
|
||
type: "command",
|
||
}],
|
||
};
|
||
hashes.push(
|
||
`sha256:${
|
||
createHash("sha256").update(JSON.stringify(sortKeys(identity)), "utf-8").digest("hex")
|
||
}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return hashes;
|
||
}
|
||
function truncatePluginDoctorText(value: string): string {
|
||
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping C0/DEL controls from plugin-supplied text is the point of this line.
|
||
const sanitized = value.replace(/[\u0000-\u001f\u007f]/g, "");
|
||
if (sanitized.length <= PLUGIN_DOCTOR_MAX_TEXT) return sanitized;
|
||
return `${sanitized.slice(0, PLUGIN_DOCTOR_MAX_TEXT - 3)}...`;
|
||
}
|
||
|
||
function pluginDoctorResult(
|
||
pass: boolean,
|
||
label: string,
|
||
options: {
|
||
fix?: string;
|
||
id?: string;
|
||
severity?: DoctorCheckResult["severity"];
|
||
} = {},
|
||
): DoctorCheckResult {
|
||
return {
|
||
pass,
|
||
label: truncatePluginDoctorText(label),
|
||
...(options.fix === undefined
|
||
? {}
|
||
: { fix: truncatePluginDoctorText(options.fix) }),
|
||
...(options.id === undefined ? {} : { id: options.id }),
|
||
...(options.severity === undefined ? {} : { severity: options.severity }),
|
||
};
|
||
}
|
||
|
||
function validPluginIdentity(plugin: string): boolean {
|
||
return (
|
||
PLUGIN_NAME_REGEX.test(plugin) &&
|
||
plugin !== "aidlc" &&
|
||
!plugin.startsWith("aidlc-")
|
||
);
|
||
}
|
||
|
||
function pathContainedBy(root: string, candidate: string): boolean {
|
||
const rel = relative(root, candidate);
|
||
return (
|
||
rel !== "" &&
|
||
rel !== ".." &&
|
||
!rel.startsWith(`..${sep}`) &&
|
||
!isAbsolute(rel)
|
||
);
|
||
}
|
||
|
||
function uniquePluginDoctorFindingId(
|
||
plugin: string,
|
||
check: string,
|
||
emitted: Set<string>,
|
||
): string {
|
||
const raw = `plugin-${plugin}-${check}`
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "");
|
||
const base = (raw || "plugin-check")
|
||
.slice(0, PLUGIN_DOCTOR_FINDING_ID_MAX)
|
||
.replace(/-+$/g, "") || "plugin-check";
|
||
let candidate = base;
|
||
let occurrence = 2;
|
||
while (emitted.has(candidate)) {
|
||
const suffix = `-${occurrence}`;
|
||
const head = base
|
||
.slice(0, PLUGIN_DOCTOR_FINDING_ID_MAX - suffix.length)
|
||
.replace(/-+$/g, "");
|
||
candidate = `${head || "plugin-check"}${suffix}`;
|
||
occurrence++;
|
||
}
|
||
emitted.add(candidate);
|
||
return candidate;
|
||
}
|
||
|
||
function pluginDoctorTimeoutMs(): number {
|
||
const raw = process.env.AIDLC_PLUGIN_DOCTOR_TIMEOUT_MS?.trim() ?? "";
|
||
if (!/^[1-9]\d*$/.test(raw)) return DEFAULT_PLUGIN_DOCTOR_TIMEOUT_MS;
|
||
const parsed = Number(raw);
|
||
return Number.isSafeInteger(parsed) ? parsed : DEFAULT_PLUGIN_DOCTOR_TIMEOUT_MS;
|
||
}
|
||
|
||
function invalidPluginDoctorOutput(
|
||
plugin: string,
|
||
scriptPath: string,
|
||
exitCode: number | null,
|
||
detail: string,
|
||
findingId: string,
|
||
): DoctorCheckResult {
|
||
return pluginDoctorResult(
|
||
false,
|
||
`Plugin check (${plugin}): ${scriptPath} returned exit code ${exitCode ?? "null"}; required JSON shape ${PLUGIN_DOCTOR_REQUIRED_JSON}`,
|
||
{ fix: detail, id: findingId, severity: "error" },
|
||
);
|
||
}
|
||
|
||
function appendPluginDoctorChecks(
|
||
results: DoctorCheckResult[],
|
||
projectDir: string,
|
||
): void {
|
||
const selected = pluginsEnabled();
|
||
const installed = knownPluginNames().filter((name) => name !== "aidlc");
|
||
const enabled = selected === null
|
||
? installed
|
||
: installed.filter((name) => selected.has(name));
|
||
const harness = harnessDir();
|
||
const timeoutMs = pluginDoctorTimeoutMs();
|
||
const toolsDir = resolve(projectDir, harness, "tools");
|
||
const findingIds = new Set<string>();
|
||
|
||
for (const plugin of enabled) {
|
||
if (!validPluginIdentity(plugin)) {
|
||
results.push(pluginDoctorResult(
|
||
false,
|
||
`Plugin check identity: invalid plugin name "${plugin}"`,
|
||
{
|
||
fix: "Plugin names must be lowercase kebab-case, start with a letter, and must not use the reserved aidlc namespace.",
|
||
id: uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"invalid-identity",
|
||
findingIds,
|
||
),
|
||
severity: "error",
|
||
},
|
||
));
|
||
continue;
|
||
}
|
||
|
||
const scriptPath = resolve(toolsDir, `${plugin}-doctor.ts`);
|
||
if (!pathContainedBy(toolsDir, scriptPath)) {
|
||
results.push(pluginDoctorResult(
|
||
false,
|
||
`Plugin check (${plugin}): doctor script path escapes the harness tools directory`,
|
||
{
|
||
fix: `Expected the script under ${toolsDir}.`,
|
||
id: uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"script-path-boundary",
|
||
findingIds,
|
||
),
|
||
severity: "error",
|
||
},
|
||
));
|
||
continue;
|
||
}
|
||
if (!existsSync(scriptPath)) continue;
|
||
let realToolsDir: string;
|
||
let realScriptPath: string;
|
||
try {
|
||
realToolsDir = realpathSync(toolsDir);
|
||
realScriptPath = realpathSync(scriptPath);
|
||
} catch (e) {
|
||
results.push(pluginDoctorResult(
|
||
false,
|
||
`Plugin check (${plugin}): doctor script path could not be resolved`,
|
||
{
|
||
fix: errorMessage(e),
|
||
id: uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"script-path-resolution",
|
||
findingIds,
|
||
),
|
||
severity: "error",
|
||
},
|
||
));
|
||
continue;
|
||
}
|
||
if (!pathContainedBy(realToolsDir, realScriptPath)) {
|
||
results.push(pluginDoctorResult(
|
||
false,
|
||
`Plugin check (${plugin}): doctor script resolves outside the harness tools directory`,
|
||
{
|
||
fix: `Replace ${scriptPath} with a regular file contained by ${toolsDir}.`,
|
||
id: uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"script-realpath-boundary",
|
||
findingIds,
|
||
),
|
||
severity: "error",
|
||
},
|
||
));
|
||
continue;
|
||
}
|
||
|
||
// Installing a plugin is the trust boundary for its code. Spawn its doctor
|
||
// script directly through Bun (never a shell), following the sensor
|
||
// dispatcher's sibling-script precedent. Sensors fail open because they are
|
||
// advisory runtime checks; doctor fails loud because this is the diagnostic
|
||
// surface users rely on to explain a broken install.
|
||
const startedAt = Date.now();
|
||
// SIGKILL hard-bounds the direct script process. Detached grandchildren can
|
||
// still outlive that process; plugins must not create them.
|
||
const run = spawnSync(process.execPath, [realScriptPath], {
|
||
cwd: projectDir,
|
||
encoding: "utf-8",
|
||
env: {
|
||
...process.env,
|
||
AIDLC_PROJECT_DIR: projectDir,
|
||
AIDLC_HARNESS_DIR: harness,
|
||
AIDLC_PLUGIN_NAME: plugin,
|
||
},
|
||
maxBuffer: PLUGIN_DOCTOR_MAX_BUFFER,
|
||
timeout: timeoutMs,
|
||
killSignal: "SIGKILL",
|
||
windowsHide: true,
|
||
});
|
||
const elapsedMs = Date.now() - startedAt;
|
||
const spawnCode = (run.error as NodeJS.ErrnoException | undefined)?.code;
|
||
const timedOut =
|
||
spawnCode === "ETIMEDOUT" ||
|
||
(run.signal === "SIGKILL" &&
|
||
elapsedMs >= Math.max(0, timeoutMs - 100));
|
||
|
||
if (timedOut) {
|
||
results.push(pluginDoctorResult(
|
||
false,
|
||
`Plugin check (${plugin}): check script timed out after ${timeoutMs}ms`,
|
||
{
|
||
fix: `Inspect or replace ${scriptPath}.`,
|
||
id: uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"script-timeout",
|
||
findingIds,
|
||
),
|
||
severity: "error",
|
||
},
|
||
));
|
||
continue;
|
||
}
|
||
|
||
if (run.error) {
|
||
results.push(pluginDoctorResult(
|
||
false,
|
||
`Plugin check (${plugin}): check script spawn failed: ${errorMessage(run.error)}`,
|
||
{
|
||
fix: `Verify ${scriptPath} can be run with Bun.`,
|
||
id: uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"script-spawn",
|
||
findingIds,
|
||
),
|
||
severity: "error",
|
||
},
|
||
));
|
||
continue;
|
||
}
|
||
|
||
if (run.status !== 0) {
|
||
results.push(invalidPluginDoctorOutput(
|
||
plugin,
|
||
scriptPath,
|
||
run.status,
|
||
"The script exited non-zero; repair it and emit only the required JSON object on stdout.",
|
||
uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"script-nonzero",
|
||
findingIds,
|
||
),
|
||
));
|
||
continue;
|
||
}
|
||
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(typeof run.stdout === "string" ? run.stdout.trim() : "");
|
||
} catch (e) {
|
||
results.push(invalidPluginDoctorOutput(
|
||
plugin,
|
||
scriptPath,
|
||
run.status,
|
||
`stdout was not valid JSON: ${errorMessage(e)}`,
|
||
uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"script-invalid-json",
|
||
findingIds,
|
||
),
|
||
));
|
||
continue;
|
||
}
|
||
if (!isPlainObject(parsed) || !Array.isArray(parsed.checks)) {
|
||
results.push(invalidPluginDoctorOutput(
|
||
plugin,
|
||
scriptPath,
|
||
run.status,
|
||
`stdout did not contain a checks array matching ${PLUGIN_DOCTOR_REQUIRED_JSON}.`,
|
||
uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"script-invalid-shape",
|
||
findingIds,
|
||
),
|
||
));
|
||
continue;
|
||
}
|
||
|
||
let malformed = 0;
|
||
let emitted = 0;
|
||
let truncated = 0;
|
||
for (const entry of parsed.checks) {
|
||
if (
|
||
!isPlainObject(entry) ||
|
||
typeof entry.pass !== "boolean" ||
|
||
typeof entry.label !== "string" ||
|
||
(Object.hasOwn(entry, "fix") && typeof entry.fix !== "string") ||
|
||
(Object.hasOwn(entry, "severity") &&
|
||
entry.severity !== "error" &&
|
||
entry.severity !== "advisory")
|
||
) {
|
||
malformed++;
|
||
continue;
|
||
}
|
||
if (emitted >= PLUGIN_DOCTOR_MAX_ROWS) {
|
||
truncated++;
|
||
continue;
|
||
}
|
||
|
||
const label = `Plugin check (${plugin}): ${entry.label}`;
|
||
const fix = typeof entry.fix === "string" ? entry.fix : undefined;
|
||
const id = uniquePluginDoctorFindingId(
|
||
plugin,
|
||
redactSecretPatterns(entry.label),
|
||
findingIds,
|
||
);
|
||
if (entry.pass) {
|
||
results.push(pluginDoctorResult(true, label, {
|
||
fix,
|
||
id,
|
||
severity: "info",
|
||
}));
|
||
} else if (entry.severity === "advisory") {
|
||
results.push(pluginDoctorResult(
|
||
true,
|
||
`${label} (advisory)`,
|
||
{
|
||
fix: fix ?? "review this plugin-provided finding",
|
||
id,
|
||
severity: "warn",
|
||
},
|
||
));
|
||
} else {
|
||
results.push(pluginDoctorResult(false, label, {
|
||
fix,
|
||
id,
|
||
severity: "error",
|
||
}));
|
||
}
|
||
emitted++;
|
||
}
|
||
|
||
if (malformed > 0) {
|
||
results.push(pluginDoctorResult(
|
||
false,
|
||
`Plugin check (${plugin}): ${malformed} malformed check entr${malformed === 1 ? "y" : "ies"} skipped`,
|
||
{
|
||
fix: `Every entry must match ${PLUGIN_DOCTOR_REQUIRED_JSON}.`,
|
||
id: uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"malformed-entries",
|
||
findingIds,
|
||
),
|
||
severity: "error",
|
||
},
|
||
));
|
||
}
|
||
if (truncated > 0) {
|
||
results.push(pluginDoctorResult(
|
||
false,
|
||
`Plugin check (${plugin}): ${truncated} check result(s) truncated after ${PLUGIN_DOCTOR_MAX_ROWS} rows`,
|
||
{
|
||
fix: `Reduce the number of checks emitted by ${scriptPath}.`,
|
||
id: uniquePluginDoctorFindingId(
|
||
plugin,
|
||
"truncated-entries",
|
||
findingIds,
|
||
),
|
||
severity: "error",
|
||
},
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
export type DoctorCheck = {
|
||
pass: boolean;
|
||
severity?: "warn";
|
||
label: string;
|
||
fix?: string;
|
||
};
|
||
|
||
export type DoctorReport = {
|
||
checks: DoctorCheck[];
|
||
passed: number;
|
||
warnings: number;
|
||
failed: number;
|
||
};
|
||
|
||
function collapseLegacyPolicyChecks(checks: readonly DoctorCheck[]): DoctorCheck[] {
|
||
const marker = "harness.json contains legacy policy key(s)";
|
||
const indexes = checks.flatMap((check, index) =>
|
||
check.fix?.includes(marker) ? [index] : []
|
||
);
|
||
if (indexes.length === 0) return [...checks];
|
||
const direct = indexes
|
||
.map((index) => checks[index].fix ?? "")
|
||
.sort((left, right) => left.length - right.length)[0];
|
||
const details =
|
||
/^(.*harness\.json): harness\.json contains legacy policy key\(s\) ([^.]+)\./
|
||
.exec(direct);
|
||
const path = details?.[1] ?? "tools/data/harness.json";
|
||
const keys = details?.[2] ?? "models or flags";
|
||
const collapsed: DoctorCheck = {
|
||
pass: false,
|
||
label: `Harness data: legacy policy key(s) ${keys} in ${path}`,
|
||
fix: direct,
|
||
};
|
||
const first = indexes[0];
|
||
const duplicates = new Set(indexes);
|
||
return checks.flatMap((check, index) =>
|
||
index === first ? [collapsed] : duplicates.has(index) ? [] : [check]
|
||
);
|
||
}
|
||
|
||
function projectedFileRepair(
|
||
distribution: string,
|
||
relativePath: string,
|
||
): string {
|
||
const invoke = aidlcInvocation();
|
||
if (invoke === "aidlc") {
|
||
return `run \`${invoke} config --force\` to restore ${relativePath} from the installed runtime`;
|
||
}
|
||
return `restore ${relativePath} from git, or re-copy \`dist/${distribution}/${relativePath}\` from the aidlc-workflows checkout`;
|
||
}
|
||
|
||
export async function collectDoctorReport(
|
||
projectDir: string,
|
||
extraChecks: readonly DoctorCheck[] = [],
|
||
): Promise<DoctorReport> {
|
||
const results: DoctorCheck[] = [];
|
||
results.push(...runtimeDoctorChecks(projectDir, harnessDir()));
|
||
results.push(instructionFileDoctorCheck(projectDir, harnessDir()));
|
||
const compiled = isCompiledExecutable();
|
||
|
||
const installedVersion = activeVersion();
|
||
if (compiled || installedVersion) {
|
||
const installedState = installedVersion
|
||
? inspectInstalledVersion(installedVersion)
|
||
: { complete: false, distributions: [], reason: "active version marker unavailable" };
|
||
const distributions = installedState.distributions;
|
||
const runtimeReady = installedState.complete && distributions.length > 0;
|
||
results.push({
|
||
pass: installedVersion !== null && runtimeReady,
|
||
label: installedVersion && runtimeReady
|
||
? `Installed runtime: ${installedVersion} [${distributions.join(", ")}]`
|
||
: installedVersion && installedState.complete
|
||
? `Installed runtime ${installedVersion} has no harness installed`
|
||
: installedVersion
|
||
? `Installed runtime ${installedVersion} is incomplete: ${installedState.reason ?? "unknown reason"}`
|
||
: "Installed runtime: active version marker unavailable",
|
||
fix: installedState.complete
|
||
? `run \`${aidlcInvocation()} config --harness <name>\``
|
||
: `run \`${aidlcInvocation()} config --harness <name>\``,
|
||
});
|
||
const command = commandPath();
|
||
const expectedExecutable = installedVersion
|
||
? join(installedVersionRoot(installedVersion), process.platform === "win32" ? "aidlc.exe" : "aidlc")
|
||
: "";
|
||
let pointerValid = false;
|
||
try {
|
||
pointerValid = Boolean(expectedExecutable) &&
|
||
existsSync(command) &&
|
||
statSync(command).isFile() &&
|
||
readActiveExecutable() === resolve(expectedExecutable);
|
||
} catch {
|
||
pointerValid = false;
|
||
}
|
||
results.push({
|
||
pass: pointerValid,
|
||
label: pointerValid
|
||
? `Command pointer: ${command} -> ${installedVersion}`
|
||
: installedVersion
|
||
? `Command pointer is missing or does not select active version ${installedVersion}`
|
||
: "Command pointer is missing or does not select an active version",
|
||
fix: "re-run `aidlc update --version <version> --from <release-directory>`",
|
||
});
|
||
|
||
const rollbackPath = rollbackVersionPath();
|
||
if (existsSync(rollbackPath)) {
|
||
const rollback = readFileSync(rollbackPath, "utf-8").trim();
|
||
let rollbackState: ReturnType<typeof inspectInstalledVersion> = {
|
||
complete: false,
|
||
distributions: [],
|
||
reason: "invalid version marker",
|
||
};
|
||
try {
|
||
rollbackState = inspectInstalledVersion(rollback);
|
||
} catch {
|
||
// The diagnostic below reports an invalid marker as ineligible.
|
||
}
|
||
const eligible = rollback !== installedVersion && rollbackState.complete;
|
||
results.push({
|
||
pass: eligible,
|
||
label: eligible
|
||
? `Rollback target: ${rollback} is complete and eligible`
|
||
: `Rollback target is not eligible: ${JSON.stringify(rollback)}`,
|
||
fix: "run `aidlc use <version>` with a complete retained version",
|
||
});
|
||
} else {
|
||
results.push({ pass: true, label: "Rollback target: none recorded" });
|
||
}
|
||
|
||
const pinsPath = join(installRoot(), "pins.json");
|
||
let stalePins: string[] = [];
|
||
try {
|
||
const pins = existsSync(pinsPath)
|
||
? JSON.parse(readFileSync(pinsPath, "utf-8")) as Record<string, unknown>
|
||
: {};
|
||
stalePins = Object.keys(pins).filter((path) => !existsSync(path)).sort();
|
||
} catch {
|
||
stalePins = ["<malformed pins.json>"];
|
||
}
|
||
results.push({
|
||
pass: stalePins.length === 0,
|
||
severity: stalePins.length > 0 ? "warn" : undefined,
|
||
label: stalePins.length === 0
|
||
? "Project pin registry: no stale registrations"
|
||
: `Project pin registry: stale registrations: ${stalePins.join(", ")}`,
|
||
fix: "run a pinned command from the moved project to self-heal its registration",
|
||
});
|
||
|
||
if (compiled) {
|
||
const currentHarnessDir = harnessDir();
|
||
const currentHarnessName = runtimeHarnessName(projectDir, currentHarnessDir);
|
||
const commands: string[] = [];
|
||
const collectCommands = (value: unknown): void => {
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) collectCommands(item);
|
||
} else if (value && typeof value === "object") {
|
||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||
if (
|
||
(key === "command" || key === "bash" || key === "powershell") &&
|
||
typeof item === "string"
|
||
) commands.push(item);
|
||
else collectCommands(item);
|
||
}
|
||
}
|
||
};
|
||
const harnessRoot = join(projectDir, currentHarnessDir);
|
||
const trustFiles = [
|
||
join(harnessRoot, "settings.json"),
|
||
join(harnessRoot, "hooks.json"),
|
||
join(projectDir, ".vscode", "settings.json"),
|
||
];
|
||
if (currentHarnessDir === ".cursor") {
|
||
trustFiles.push(join(harnessRoot, "cli.json"));
|
||
}
|
||
if (currentHarnessName === "copilot") {
|
||
trustFiles.push(join(projectDir, ".github", "hooks", "aidlc.json"));
|
||
}
|
||
const agentsDir = join(harnessRoot, "agents");
|
||
if (existsSync(agentsDir)) {
|
||
trustFiles.push(...readdirSync(agentsDir)
|
||
.filter((name) => name.endsWith(".json"))
|
||
.map((name) => join(agentsDir, name)));
|
||
}
|
||
const hooksDir = join(harnessRoot, "hooks");
|
||
if (existsSync(hooksDir)) {
|
||
trustFiles.push(...readdirSync(hooksDir)
|
||
.filter((name) => name.endsWith(".kiro.hook"))
|
||
.map((name) => join(hooksDir, name)));
|
||
}
|
||
for (const path of trustFiles) {
|
||
if (!existsSync(path)) continue;
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
||
collectCommands(parsed);
|
||
const allowed = (parsed as {
|
||
toolsSettings?: { execute_bash?: { allowedCommands?: unknown } };
|
||
}).toolsSettings?.execute_bash?.allowedCommands;
|
||
if (Array.isArray(allowed)) {
|
||
commands.push(...allowed.filter((entry): entry is string => typeof entry === "string"));
|
||
}
|
||
const permissions = (parsed as {
|
||
permissions?: { allow?: unknown };
|
||
}).permissions?.allow;
|
||
if (Array.isArray(permissions)) {
|
||
commands.push(...permissions.filter((entry): entry is string => typeof entry === "string"));
|
||
}
|
||
const trusted = (parsed as {
|
||
kiroAgent?: unknown;
|
||
"kiroAgent.trustedCommands"?: unknown;
|
||
})["kiroAgent.trustedCommands"];
|
||
if (Array.isArray(trusted)) {
|
||
commands.push(...trusted.filter((entry): entry is string => typeof entry === "string"));
|
||
}
|
||
} catch {
|
||
// Existing structure checks report malformed host configuration.
|
||
}
|
||
}
|
||
const legacy = commands.filter((command) =>
|
||
/\bbun\s+[^\n]*(?:\/(?:tools|hooks)\/aidlc|\\?\.kiro\/tools\/)/.test(command)
|
||
);
|
||
let nativeHooks = commands.some((command) =>
|
||
["hook", "adapter", "statusline"].some((noun) =>
|
||
command.includes(`${TRUSTED_COMMAND_PREFIX} ${noun}`)
|
||
)
|
||
);
|
||
let nativePermission = false;
|
||
if (currentHarnessDir === ".claude") {
|
||
nativePermission = commands.includes(`Bash(${trustedCommand("*")})`) &&
|
||
!commands.includes("Bash");
|
||
} else if (currentHarnessDir === ".kiro") {
|
||
nativePermission = commands.includes(trustedCommand(".*")) ||
|
||
commands.includes(trustedCommand("*"));
|
||
} else if (currentHarnessDir === ".codex") {
|
||
const rules = join(harnessRoot, "rules", "default.rules");
|
||
const seed = join(harnessRoot, "trust-seed.toml");
|
||
const hooks = join(harnessRoot, "hooks.json");
|
||
let hashes: string[] = [];
|
||
try {
|
||
hashes = existsSync(hooks) ? codexNativeTrustHashes(hooks) : [];
|
||
} catch {
|
||
hashes = [];
|
||
}
|
||
const seedText = existsSync(seed) ? readFileSync(seed, "utf-8") : "";
|
||
nativePermission =
|
||
existsSync(rules) &&
|
||
readFileSync(rules, "utf-8").includes(
|
||
`prefix_rule(pattern = [${
|
||
TRUSTED_COMMAND_TOKENS.map((token) => JSON.stringify(token)).join(", ")
|
||
}], decision = "allow")`,
|
||
) &&
|
||
hashes.length > 0 &&
|
||
hashes.every((hash) => seedText.includes(`trusted_hash = "${hash}"`));
|
||
} else if (currentHarnessDir === ".cursor") {
|
||
nativePermission = commands.includes(`Shell(${trustedCommand("*")})`);
|
||
} else if (currentHarnessName === "copilot") {
|
||
// Copilot has no project command allowlist. Its folder-trust contract
|
||
// is checked separately below; this row verifies native hook wiring.
|
||
nativePermission = nativeHooks;
|
||
} else if (currentHarnessName === "opencode") {
|
||
const configPath = ["opencode.json", "opencode.jsonc"]
|
||
.map((name) => join(projectDir, name))
|
||
.find(existsSync);
|
||
try {
|
||
const config = configPath
|
||
? Bun.JSONC.parse(readFileSync(configPath, "utf-8")) as {
|
||
permission?: { bash?: Record<string, unknown> };
|
||
}
|
||
: {};
|
||
nativePermission =
|
||
config.permission?.bash?.[trustedCommand("*")] === "allow";
|
||
} catch {
|
||
nativePermission = false;
|
||
}
|
||
nativeHooks = existsSync(
|
||
join(projectDir, ".opencode", "plugin", "aidlc-opencode-adapter.ts"),
|
||
);
|
||
}
|
||
const nativeTrustReady = legacy.length === 0 && nativeHooks && nativePermission;
|
||
results.push({
|
||
pass: nativeTrustReady,
|
||
label: nativeTrustReady
|
||
? "Native command trust: host hooks and permission entries select the installed `aidlc` command"
|
||
: `Native command trust is incomplete: ${legacy.length} Bun-shaped entr${
|
||
legacy.length === 1 ? "y" : "ies"
|
||
}, native hooks ${nativeHooks ? "present" : "missing"}, native permission/trust ${
|
||
nativePermission ? "present" : "missing"
|
||
}`,
|
||
fix: `refresh the project with \`${aidlcInvocation()} config\``,
|
||
});
|
||
}
|
||
} else {
|
||
results.push({
|
||
pass: true,
|
||
label: "Execution mode: source checkout (no machine runtime expected)",
|
||
});
|
||
}
|
||
|
||
// Project-domain transactions (init, plugin sync) root at the project dir,
|
||
// so this scan runs on every channel, not only under an installed machine
|
||
// runtime.
|
||
const stagingRoots = new Set([projectDir]);
|
||
if (compiled || installedVersion) {
|
||
stagingRoots.add(installRoot());
|
||
stagingRoots.add(binRoot());
|
||
stagingRoots.add(dirname(installRoot()));
|
||
stagingRoots.add(dirname(dirname(installRoot())));
|
||
}
|
||
const abandoned: string[] = [];
|
||
const recovery: string[] = [];
|
||
for (const root of stagingRoots) {
|
||
if (!existsSync(root)) continue;
|
||
for (const entry of readdirSync(root)) {
|
||
if (/^\.aidlc-txn-[0-9a-f-]+$/.test(entry)) abandoned.push(join(root, entry));
|
||
if (/^\.aidlc-recovery-\d+-[0-9a-f-]+$/.test(entry)) {
|
||
recovery.push(join(root, entry));
|
||
}
|
||
}
|
||
}
|
||
abandoned.sort();
|
||
recovery.sort();
|
||
results.push({
|
||
pass: abandoned.length === 0,
|
||
label: abandoned.length === 0
|
||
? "Transaction staging: no abandoned directories"
|
||
: `Transaction staging: ${abandoned.length} abandoned path(s): ${abandoned.join(", ")}`,
|
||
fix: "finish any active AI-DLC command, then rerun the command to trigger the safe staging sweep",
|
||
});
|
||
results.push({
|
||
pass: recovery.length === 0,
|
||
label: recovery.length === 0
|
||
? "Transaction recovery: no quarantined directories"
|
||
: `Transaction recovery: ${recovery.length} quarantined path(s): ${recovery.join(", ")}`,
|
||
fix:
|
||
"inspect each listed directory, recover any needed files, then remove the directory manually",
|
||
});
|
||
|
||
const projectStamp = join(projectDir, harnessDir(), "tools", "data", "aidlc-stamp.json");
|
||
if (existsSync(projectStamp)) {
|
||
try {
|
||
const stamp = JSON.parse(readFileSync(projectStamp, "utf-8")) as {
|
||
frameworkVersion?: string;
|
||
distribution?: string;
|
||
};
|
||
const stampVersion = stamp.frameworkVersion ?? "unknown";
|
||
const currentMajor = AIDLC_VERSION.split(".")[0];
|
||
const stampMajor = stampVersion.split(".")[0];
|
||
results.push({
|
||
pass: stampVersion === AIDLC_VERSION || stampMajor === currentMajor,
|
||
severity: stampVersion !== AIDLC_VERSION && stampMajor === currentMajor ? "warn" : undefined,
|
||
label: stampVersion === AIDLC_VERSION
|
||
? `Project runtime stamp: ${stampVersion} (${stamp.distribution ?? "unknown"})`
|
||
: `Project runtime stamp: ${stampVersion}; selected engine: ${AIDLC_VERSION}`,
|
||
fix: `run \`${aidlcInvocation()} config\` or select the machine release with \`${aidlcInvocation()} use ${stampVersion}\``,
|
||
});
|
||
} catch {
|
||
results.push({
|
||
pass: false,
|
||
label: "Project runtime stamp is malformed",
|
||
fix: `refresh the project with \`${aidlcInvocation()} config\``,
|
||
});
|
||
}
|
||
}
|
||
|
||
const pinPath = join(projectDir, ".aidlc-version");
|
||
if (existsSync(pinPath)) {
|
||
const pinned = readFileSync(pinPath, "utf-8").trim();
|
||
if (!VERSION_ID.test(pinned)) {
|
||
results.push({
|
||
pass: false,
|
||
label: `Project pin is malformed: ${JSON.stringify(pinned)}`,
|
||
fix: `run \`${aidlcInvocation()} config --unpin\` or write one release version id`,
|
||
});
|
||
} else {
|
||
const distribution = (() => {
|
||
try {
|
||
return JSON.parse(readFileSync(projectStamp, "utf-8")).distribution as string;
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();
|
||
let pinState: ReturnType<typeof inspectInstalledVersion> = {
|
||
complete: false,
|
||
distributions: [],
|
||
};
|
||
try {
|
||
pinState = inspectInstalledVersion(pinned, distribution);
|
||
} catch {
|
||
// The diagnostic below reports invalid or incomplete installed state.
|
||
}
|
||
results.push({
|
||
pass: pinState.complete,
|
||
label: pinState.complete
|
||
? `Project pin: ${pinned} is installed`
|
||
: `Project pin: ${pinned} is not installed completely`,
|
||
fix: `run \`${aidlcInvocation()} config --pin ${pinned}\``,
|
||
});
|
||
const targetState = inspectProjectPinTarget(projectDir, pinned);
|
||
results.push({
|
||
pass: targetState.valid,
|
||
label: targetState.valid
|
||
? `Project pin target: ${pinned} resolves before engine startup`
|
||
: `Project pin target: ${targetState.reason ?? "invalid"}`,
|
||
fix: `run \`${aidlcInvocation()} config --pin ${pinned}\``,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 2. Hook presence. Shipped projects route hook targets through the native
|
||
// command; direct source execution may still invoke these TypeScript files.
|
||
// The Kiro and Codex trees also carry the authored host adapter.
|
||
const harness = harnessDir();
|
||
const harnessName = runtimeHarnessName(projectDir, harness);
|
||
const isCopilot = harnessName === "copilot";
|
||
if (harness === ".claude") {
|
||
// Claude Code: the EXPECTED roster is the set of aidlc-*.ts hooks that
|
||
// settings.json actually wires (its `hooks` event blocks + the `statusLine`
|
||
// command) — that is the CONTRACT Claude Code will try to run. Each
|
||
// expected hook's PRESENCE is then probed against the project's own
|
||
// .claude/hooks/ directory. A hook wired in settings.json but missing on
|
||
// disk is a real runtime breakage, and this surfaces it as a loud ✗.
|
||
//
|
||
// Why settings.json, not readdirSync of the hooks dir: doctor's normal
|
||
// invocation derives projectDir from the tool's OWN location
|
||
// (resolveProjectDir step 3), so the hooks dir IS the dir a roster would be
|
||
// enumerated from — probing an enumerated-from-itself roster is tautological
|
||
// (every hook trivially "present", a deleted hook silently absent from the
|
||
// roster). Sourcing the expectation from settings.json instead means the
|
||
// roster and the probe target genuinely diverge, so a missing hook is caught
|
||
// in the real single-install path. It is also self-maintaining: wire a new
|
||
// hook in settings.json and doctor checks it automatically (no hardcoded
|
||
// list to drift — the old list named only 7 of the 10 shipped hooks).
|
||
const settingsForHooks = join(projectDir, harness, "settings.json");
|
||
let expectedHooks: string[] = [];
|
||
let settingsReadable = true;
|
||
try {
|
||
const raw = readFileSync(settingsForHooks, "utf-8");
|
||
// jq-free: collect every distinct aidlc-*.ts basename referenced anywhere
|
||
// in settings.json (hook command paths like
|
||
// "bun $CLAUDE_PROJECT_DIR/.claude/hooks/aidlc-write-audit-log.ts" and the
|
||
// statusLine command). Basename, not path, so the probe is dir-relative.
|
||
const parsed = JSON.parse(raw) as unknown;
|
||
const commands: string[] = [];
|
||
const collectCommands = (value: unknown): void => {
|
||
if (Array.isArray(value)) return void value.forEach(collectCommands);
|
||
if (!value || typeof value !== "object") return;
|
||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||
if (key === "command" && typeof item === "string") commands.push(item);
|
||
else collectCommands(item);
|
||
}
|
||
};
|
||
collectCommands(parsed);
|
||
const refs = new Set<string>();
|
||
const dispatcher =
|
||
'(?:\\baidlc|\\bbun\\s+(?:"[^"]*[\\\\/]aidlc\\.ts"|\'[^\']*[\\\\/]aidlc\\.ts\'|[^\\s"\']*[\\\\/]aidlc\\.ts))';
|
||
for (const command of commands) {
|
||
for (const match of command.matchAll(/aidlc-[A-Za-z0-9_-]+\.ts/g)) {
|
||
refs.add(match[0]);
|
||
}
|
||
const dispatcherHook = new RegExp(
|
||
`${dispatcher}\\s+engine\\s+hook\\s+([A-Za-z0-9_-]+)\\b`,
|
||
).exec(command);
|
||
if (dispatcherHook) refs.add(`aidlc-${dispatcherHook[1]}.ts`);
|
||
if (new RegExp(`${dispatcher}\\s+engine\\s+statusline\\b`).test(command)) {
|
||
refs.add("aidlc-statusline.ts");
|
||
}
|
||
}
|
||
expectedHooks = [...refs].sort();
|
||
} catch {
|
||
settingsReadable = false;
|
||
}
|
||
if (!settingsReadable) {
|
||
// settings.json missing/unreadable: fail LOUD (the wiring-config check
|
||
// below also flags its absence, but the hook contract genuinely cannot be
|
||
// verified, so say so rather than silently checking nothing).
|
||
results.push({
|
||
pass: false,
|
||
label: "Hook contract: settings.json unreadable - cannot verify wired hooks",
|
||
fix: projectedFileRepair("claude", ".claude/settings.json"),
|
||
});
|
||
} else if (expectedHooks.length === 0) {
|
||
// settings.json parsed but wires no aidlc hooks — also loud (a stripped
|
||
// settings.json that lost its hooks block is a real misconfiguration).
|
||
results.push({
|
||
pass: false,
|
||
label: "Hook contract: settings.json wires no aidlc-*.ts hooks",
|
||
fix: projectedFileRepair("claude", ".claude/settings.json"),
|
||
});
|
||
} else {
|
||
for (const h of expectedHooks) {
|
||
const hookPath = join(projectDir, harness, "hooks", h);
|
||
results.push({
|
||
pass: existsSync(hookPath),
|
||
label: `${h} present`,
|
||
fix: "verify file exists in .claude/hooks/",
|
||
});
|
||
}
|
||
}
|
||
|
||
// Hooks GLOBALLY disabled (issue #802). Every check above verifies the hook
|
||
// files are present and wired, but Claude Code honours `disableAllHooks:
|
||
// true` in any settings layer, which silently skips EVERY hook — audit
|
||
// emission, state sync, sensor dispatch, stage-graph rebuild, the lot. A
|
||
// regulated-industry install (IT policy sets the flag) then passes doctor
|
||
// clean yet blocks at runtime on the first stage: the exact false positive
|
||
// reported. The presence rows can't catch it, so probe the flag explicitly.
|
||
//
|
||
// Resolve it the way Claude Code does — the HIGHEST-precedence layer that
|
||
// sets the key wins — so a lower layer's `true` overridden by a higher
|
||
// layer's `false` does not false-alarm. Precedence (high→low): enterprise
|
||
// managed settings, project settings.local.json, project settings.json,
|
||
// user ~/.claude/settings.json. (The command line can also disable hooks
|
||
// via `--settings '{"disableAllHooks": true}'`, sitting between managed and
|
||
// local, but that is not persisted to a file so it is unprobeable here.)
|
||
//
|
||
// We inspect the on-disk managed-settings file and its alphabetical
|
||
// managed-settings.d fragments. Claude Code can also receive managed policy
|
||
// through channels we cannot read here (MDM, Windows registry, or a
|
||
// remote/server-managed source), so a pass is not a guarantee that every
|
||
// enterprise channel is clean.
|
||
//
|
||
// Managed-settings file location is platform-specific (resolved by the pure,
|
||
// per-platform-tested resolveManagedSettingsCandidates below).
|
||
if (harnessName === "claude") {
|
||
const managedDisableAllHooks = resolveManagedBooleanSetting(
|
||
"disableAllHooks",
|
||
process.platform,
|
||
process.env,
|
||
);
|
||
const home = process.env.HOME || process.env.USERPROFILE || "";
|
||
const MANAGED_LABEL = "enterprise managed settings";
|
||
const hookDisableLayers: Array<[string, string]> = [
|
||
[
|
||
join(projectDir, harness, "settings.local.json"),
|
||
".claude/settings.local.json",
|
||
],
|
||
[join(projectDir, harness, "settings.json"), ".claude/settings.json"],
|
||
...(home
|
||
? [
|
||
[
|
||
join(home, ".claude", "settings.json"),
|
||
"~/.claude/settings.json",
|
||
] as [string, string],
|
||
]
|
||
: []),
|
||
];
|
||
let hooksDisabledBy: string | null =
|
||
managedDisableAllHooks === true ? MANAGED_LABEL : null;
|
||
if (managedDisableAllHooks === undefined) {
|
||
for (const [path, label] of hookDisableLayers) {
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(path, "utf-8")) as {
|
||
disableAllHooks?: unknown;
|
||
};
|
||
// Only a layer that EXPLICITLY sets the boolean resolves it; a layer
|
||
// that omits the key defers to the next-lower layer.
|
||
if (typeof parsed.disableAllHooks === "boolean") {
|
||
if (parsed.disableAllHooks) hooksDisabledBy = label;
|
||
break; // highest-precedence definition wins, true or false
|
||
}
|
||
} catch {
|
||
// Absent/unreadable/malformed layer — the wiring-config rows own
|
||
// those cases; only an explicit disableAllHooks value matters here.
|
||
}
|
||
}
|
||
}
|
||
// Enterprise managed settings is the highest-precedence layer: nothing
|
||
// in a project or user file can override it.
|
||
const disabledByManaged = hooksDisabledBy === MANAGED_LABEL;
|
||
results.push({
|
||
pass: hooksDisabledBy === null,
|
||
label:
|
||
hooksDisabledBy === null
|
||
? "Hooks enabled (resolved disableAllHooks is not true)"
|
||
: `Hooks DISABLED via "disableAllHooks": true in ${hooksDisabledBy} — AI-DLC cannot run (audit, state sync, sensors, and stage-graph rebuild are all silently skipped even though the hook files are present)`,
|
||
fix:
|
||
hooksDisabledBy === null
|
||
? undefined
|
||
: disabledByManaged
|
||
? `"disableAllHooks": true is enforced by enterprise managed settings — the highest-precedence layer, which a project or user setting cannot override. IT policy must remove it (or set it to false) for AI-DLC to run. If policy mandates disabled hooks, AI-DLC v2 is not compatible with this environment — its workflow engine is hook-driven.`
|
||
: `remove "disableAllHooks": true from ${hooksDisabledBy} (or set it to false in a higher-precedence layer such as .claude/settings.local.json) and restart the Claude Code session — AI-DLC's workflow engine is hook-driven and cannot advance while hooks are disabled.`,
|
||
});
|
||
|
||
if (
|
||
resolveManagedBooleanSetting(
|
||
"allowManagedHooksOnly",
|
||
process.platform,
|
||
process.env,
|
||
) === true
|
||
) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Claude managed hook policy: allowManagedHooksOnly=true",
|
||
fix: "hooks from .claude/settings.json are blocked by organization policy (allowManagedHooksOnly); only the Claude Code administrator can lift it in managed-settings.json. Until then, the workflow's human-presence and summary-confirmation receipts cannot be minted; attended sessions can set AIDLC_SKIP_HUMAN_PRESENCE_GUARD=1 and AIDLC_SKIP_SUMMARY_CONFIRMATION_GUARD=1 in the environment that launches the CLI as a temporary bypass",
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
// Kiro / Codex: the wiring config is not settings.json (it is
|
||
// agents/aidlc.json / hooks.json — checked below). The core hook bodies
|
||
// ship in every tree plus an authored adapter, so probe the explicit roster.
|
||
const tsHooks = [
|
||
"aidlc-write-audit-log",
|
||
"aidlc-sync-workflow-state",
|
||
"aidlc-validate-state",
|
||
"aidlc-log-subagent",
|
||
"aidlc-session-start",
|
||
"aidlc-session-end",
|
||
"aidlc-statusline",
|
||
];
|
||
if (harness === ".kiro") tsHooks.push("aidlc-kiro-adapter");
|
||
if (harness === ".codex") tsHooks.push("aidlc-codex-adapter");
|
||
if (isCopilot) {
|
||
tsHooks.push(
|
||
"aidlc-state-transition-guard",
|
||
"aidlc-reviewer-scope",
|
||
"aidlc-continue-workflow",
|
||
"aidlc-run-sensors",
|
||
"aidlc-rebuild-stage-graph",
|
||
"aidlc-deliver-stage-rules",
|
||
"aidlc-plan-approval-guard",
|
||
"aidlc-review-freeze",
|
||
);
|
||
}
|
||
if (harness === ".cursor") tsHooks.push("aidlc-cursor-adapter");
|
||
for (const h of tsHooks) {
|
||
const hookPath = join(projectDir, harness, "hooks", `${h}.ts`);
|
||
results.push({
|
||
pass: existsSync(hookPath),
|
||
label: `${h}.ts present`,
|
||
fix: `verify file exists in ${harness}/hooks/`,
|
||
});
|
||
}
|
||
if (harness === ".aidlc") {
|
||
// Two harnesses ship the .aidlc runtime dir; the adapter file names the
|
||
// flavor. Copilot: a hooks/ shim inside the engine dir (wired by
|
||
// .github/hooks/aidlc.json). opencode: a plugin in the .opencode shell.
|
||
const copilotAdapter = join(projectDir, harness, "hooks", "aidlc-copilot-adapter.ts");
|
||
if (isCopilot) {
|
||
results.push({
|
||
pass: existsSync(copilotAdapter),
|
||
label: "hooks/aidlc-copilot-adapter.ts present (hook shim)",
|
||
fix: projectedFileRepair(
|
||
"copilot",
|
||
".aidlc/hooks/aidlc-copilot-adapter.ts",
|
||
),
|
||
});
|
||
} else {
|
||
const adapterPath = join(projectDir, ".opencode", "plugin", "aidlc-opencode-adapter.ts");
|
||
results.push({
|
||
pass: existsSync(adapterPath),
|
||
label: "plugin/aidlc-opencode-adapter.ts present (hook wiring)",
|
||
fix: projectedFileRepair(
|
||
"opencode",
|
||
".opencode/plugin/aidlc-opencode-adapter.ts",
|
||
),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. Harness wiring config present. Claude Code: settings.json (hooks +
|
||
// permissions live there). Kiro CLI: agents/aidlc.json plus
|
||
// settings/cli.json; Kiro IDE: agents/aidlc.md. Codex CLI: config.toml +
|
||
// hooks.json (the hook wiring) + rules/default.rules (permissions).
|
||
if (harness === ".kiro") {
|
||
const jsonAgentPath = join(projectDir, harness, "agents", "aidlc.json");
|
||
const markdownAgentPath = join(projectDir, harness, "agents", "aidlc.md");
|
||
const hasJsonAgent = existsSync(jsonAgentPath);
|
||
results.push({
|
||
pass: hasJsonAgent || existsSync(markdownAgentPath),
|
||
label: "agents/aidlc.{json,md} present (conductor wiring)",
|
||
fix: `${projectedFileRepair("kiro", ".kiro/agents/aidlc.json")} (Kiro CLI) or ${projectedFileRepair("kiro-ide", ".kiro/agents/aidlc.md")} (Kiro IDE)`,
|
||
});
|
||
if (hasJsonAgent) {
|
||
const cliSettingsPath = join(projectDir, harness, "settings", "cli.json");
|
||
results.push({
|
||
pass: existsSync(cliSettingsPath),
|
||
label: "settings/cli.json present (workspace default-agent activation)",
|
||
fix: `${projectedFileRepair("kiro", ".kiro/settings/cli.json")} (or use \`kiro-cli chat --agent aidlc\`)`,
|
||
});
|
||
}
|
||
} else if (harness === ".codex") {
|
||
for (const [file, what] of [
|
||
["config.toml", "model/provider/sandbox config"],
|
||
["hooks.json", "hook wiring"],
|
||
["rules/default.rules", "permission prefix rules"],
|
||
] as const) {
|
||
results.push({
|
||
pass: existsSync(join(projectDir, harness, file)),
|
||
label: `${file} present (${what})`,
|
||
fix: projectedFileRepair("codex", `.codex/${file}`),
|
||
});
|
||
}
|
||
// Hook trust reminder: untrusted project hooks never fire.
|
||
results.push({
|
||
pass: true,
|
||
label: compiled
|
||
? "hook trust: merge the shipped native trust-seed.toml entries into $CODEX_HOME/config.toml or run one TUI trust pass"
|
||
: "hook trust: pre-seed [hooks.state] with `bun scripts/package.ts codex trust --project <dir>` or run one TUI trust pass",
|
||
});
|
||
} else if (harness === ".aidlc" && isCopilot) {
|
||
// Copilot (CLI + VS Code, one install): the wiring config is
|
||
// .github/hooks/aidlc.json; skills and personas ride .github/{skills,agents}.
|
||
for (const [file, what] of [
|
||
[".github/hooks/aidlc.json", "hook wiring"],
|
||
[".github/skills/aidlc/SKILL.md", "/aidlc entry point"],
|
||
[".github/agents/aidlc-developer-agent.md", "persona custom agents"],
|
||
["AGENTS.md", "onboarding + method imports"],
|
||
] as const) {
|
||
results.push({
|
||
pass: existsSync(join(projectDir, file)),
|
||
label: `${file} present (${what})`,
|
||
fix: projectedFileRepair("copilot", file),
|
||
});
|
||
}
|
||
// Folder trust: untrusted project hooks silently never fire (no warning
|
||
// anywhere on the Copilot side — the doctor is the only surface that says
|
||
// so). trustedFolders lives in ~/.copilot/config.json (COPILOT_HOME).
|
||
// Tolerances, all field-observed: the CLI writes JSONC (line/block/inline
|
||
// comments plus trailing commas), entries may carry trailing
|
||
// slashes, and the project may be reached via a symlink (compare
|
||
// realpath-normalized). An absent config is ADVISORY because a VS
|
||
// Code-only install has no CLI config; an existing unreadable or malformed
|
||
// config fails because CLI hook trust cannot be verified.
|
||
try {
|
||
const configPath = join(
|
||
process.env.COPILOT_HOME ?? join(process.env.HOME ?? "", ".copilot"),
|
||
"config.json",
|
||
);
|
||
if (!existsSync(configPath)) {
|
||
results.push({
|
||
pass: true,
|
||
label:
|
||
"~/.copilot/config.json absent (fine for VS Code-only installs; for the CLI, one interactive run records folder trust - hooks silently no-op untrusted)",
|
||
});
|
||
} else {
|
||
const raw = readFileSync(configPath, "utf-8");
|
||
const trusted =
|
||
(Bun.JSONC.parse(raw) as { trustedFolders?: string[] }).trustedFolders ?? [];
|
||
const norm = (p: string) => {
|
||
let out = p.replace(/[/\\]+$/, "");
|
||
try {
|
||
out = realpathSync(out);
|
||
} catch {
|
||
// keep the trimmed form — a recorded-but-deleted path never matches
|
||
}
|
||
return out;
|
||
};
|
||
const projectNorm = norm(projectDir);
|
||
results.push({
|
||
pass: trusted.some((t) => norm(t) === projectNorm),
|
||
label:
|
||
"project folder in ~/.copilot/config.json trustedFolders (CLI hooks silently no-op without it)",
|
||
fix: `add "${projectDir}" to trustedFolders in ~/.copilot/config.json (or accept the CLI's interactive trust prompt)`,
|
||
});
|
||
}
|
||
} catch {
|
||
results.push({
|
||
pass: false,
|
||
label:
|
||
"could not parse ~/.copilot/config.json to verify folder trust (CLI hooks silently no-op untrusted)",
|
||
fix:
|
||
"repair ~/.copilot/config.json as valid JSONC, then re-run doctor",
|
||
});
|
||
}
|
||
// Headless reminder (advisory pass-with-label): -p/prompt-mode runs skip
|
||
// repo hooks unless the env var opts in.
|
||
results.push({
|
||
pass: true,
|
||
label:
|
||
"headless runs: set GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS=1 for `copilot -p` sessions - repo hooks are off by default in prompt mode",
|
||
});
|
||
} else if (harness === ".cursor") {
|
||
// Cursor: hooks.json (the hook wiring), cli.json (permissions), and the
|
||
// standing + phase method rule pointers are all inside .cursor/.
|
||
for (const [file, what] of [
|
||
["hooks.json", "hook wiring"],
|
||
["cli.json", "Shell(bun) permission pre-approval"],
|
||
["rules/aidlc.mdc", "standing method rule (alwaysApply read instruction)"],
|
||
["rules/aidlc-phase-ideation.mdc", "Ideation phase rule (agent-decided read instruction)"],
|
||
["rules/aidlc-phase-inception.mdc", "Inception phase rule (agent-decided read instruction)"],
|
||
["rules/aidlc-phase-construction.mdc", "Construction phase rule (agent-decided read instruction)"],
|
||
["rules/aidlc-phase-operation.mdc", "Operation phase rule (agent-decided read instruction)"],
|
||
] as const) {
|
||
results.push({
|
||
pass: existsSync(join(projectDir, harness, file)),
|
||
label: `${file} present (${what})`,
|
||
fix: projectedFileRepair("cursor", `.cursor/${file}`),
|
||
});
|
||
}
|
||
} else if (harness === ".aidlc") {
|
||
// opencode: the wiring config is the project-root opencode.json/jsonc
|
||
// (permissions + the method-include instructions glob) plus the /aidlc
|
||
// command entry; the plugin adapter is checked with the hook roster above.
|
||
const opencodeJson = join(projectDir, "opencode.json");
|
||
const opencodeJsonc = join(projectDir, "opencode.jsonc");
|
||
results.push({
|
||
pass: existsSync(opencodeJson) || existsSync(opencodeJsonc),
|
||
label: "opencode.json or opencode.jsonc present (permissions + method instructions glob)",
|
||
fix: projectedFileRepair("opencode", "opencode.json"),
|
||
});
|
||
results.push({
|
||
pass: existsSync(join(projectDir, ".opencode", "command", "aidlc.md")),
|
||
label: ".opencode/command/aidlc.md present (/aidlc entry point)",
|
||
fix: projectedFileRepair("opencode", ".opencode/command/aidlc.md"),
|
||
});
|
||
} else {
|
||
const settingsPath = join(projectDir, harness, "settings.json");
|
||
results.push({
|
||
pass: existsSync(settingsPath),
|
||
label: "settings.json present",
|
||
fix: projectedFileRepair("claude", ".claude/settings.json"),
|
||
});
|
||
}
|
||
|
||
// 4b. Dual-harness coexistence (D-11): another harness tree installed AND a
|
||
// workflow active is supported-but-untested — warn (advisory pass with a
|
||
// visible label), never block.
|
||
const otherTrees = [".claude", ".kiro", ".codex", ".aidlc", ".cursor"].filter(
|
||
(h) => h !== harness && existsSync(join(projectDir, h, "tools", "aidlc-lib.ts")),
|
||
);
|
||
if (
|
||
otherTrees.length > 0 &&
|
||
existsSync(join(projectDir, harness, "tools", "aidlc-lib.ts")) &&
|
||
existsSync(stateFilePath(projectDir))
|
||
) {
|
||
results.push({
|
||
pass: true,
|
||
label: `Multi-harness install detected (${harness} + ${otherTrees.join(" + ")}) with an active workflow - supported but untested; keep all trees at the same framework version`,
|
||
});
|
||
}
|
||
|
||
// 4a. AWS_AIDLC_DEFAULT_SCOPE env var — project-default scope from settings.json env.
|
||
// Only observable inside a Claude Code session (where settings.json env is exposed
|
||
// to Bash invocations). When doctor is invoked directly via bun, the env is unset
|
||
// and we report "unset — no project default" as a pass.
|
||
const envScope = (resolveProjectFlag("AWS_AIDLC_DEFAULT_SCOPE") || "").trim();
|
||
if (envScope === "") {
|
||
results.push({
|
||
pass: true,
|
||
label: "AWS_AIDLC_DEFAULT_SCOPE (unset - no project default)",
|
||
});
|
||
} else if (validScopes().has(envScope)) {
|
||
results.push({
|
||
pass: true,
|
||
label: `AWS_AIDLC_DEFAULT_SCOPE=${envScope} (valid)`,
|
||
});
|
||
} else {
|
||
results.push({
|
||
pass: false,
|
||
label: `AWS_AIDLC_DEFAULT_SCOPE=${envScope} (invalid)`,
|
||
fix: `valid values: ${[...validScopes()].join(", ")}`,
|
||
});
|
||
}
|
||
|
||
// 4c. Plugin selection — doctor is a full-graph consumer. Runtime consumers
|
||
// read the filtered graph, but doctor must verify the persisted enabled flags
|
||
// still agree with tools/data/harness.json and that enabled stage files were
|
||
// not lost by a torn select-plugins run.
|
||
try {
|
||
const selected = pluginsEnabled();
|
||
const graphAll = loadStageGraphAll();
|
||
const enabledStages = graphAll.filter((s) => s.enabled !== false);
|
||
const counts = new Map<string, number>();
|
||
for (const stage of enabledStages) {
|
||
const owner = countOwner(stage);
|
||
counts.set(owner, (counts.get(owner) ?? 0) + 1);
|
||
}
|
||
const countText = [...counts.entries()]
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([owner, count]) => `${owner}=${count}`)
|
||
.join(", ");
|
||
results.push({
|
||
pass: true,
|
||
label:
|
||
selected === null
|
||
? `Enabled plugins: all enabled (no selection); enabled stage counts: ${countText}`
|
||
: `Enabled plugins: ${[...selected].sort().join(", ")}; enabled stage counts: ${countText}`,
|
||
});
|
||
|
||
const disagreements: string[] = [];
|
||
for (const stage of graphAll) {
|
||
const expected = expectedEnabledBySelection(stage);
|
||
const actual = stage.enabled !== false;
|
||
if (expected !== actual) {
|
||
disagreements.push(
|
||
`${stage.slug}: expected ${expected ? "enabled" : "disabled"}, graph is ${actual ? "enabled" : "disabled"}`,
|
||
);
|
||
}
|
||
}
|
||
results.push({
|
||
pass: disagreements.length === 0,
|
||
label: disagreements.length === 0
|
||
? "Plugin selection flags: harness.json agrees with stage-graph.json"
|
||
: `Plugin selection flags: ${disagreements.length} disagreement(s)`,
|
||
fix: disagreements.length > 0
|
||
? `${disagreements.join("; ")} - run \`${aidlcDispatcherInvocation("plugin select")} ${
|
||
selected === null ? knownPluginNames().join(",") : [...selected].sort().join(",")
|
||
}\` to recover`
|
||
: undefined,
|
||
});
|
||
|
||
const graphSlugs = new Set(graphAll.map((s) => s.slug));
|
||
const missingEnabled: string[] = [];
|
||
const missingPluginStages: string[] = [];
|
||
const stageSources = new Map<
|
||
string,
|
||
{ path: string; content: string; parsed: Record<string, unknown> }
|
||
>();
|
||
const stagesRoot = resolveHarnessPath(["aidlc-common", "stages"]);
|
||
for (const phase of PHASES) {
|
||
const dir = join(stagesRoot, phase);
|
||
if (!existsSync(dir)) continue;
|
||
for (const f of readdirSync(dir).filter((name) => name.endsWith(".md")).sort()) {
|
||
const path = join(dir, f);
|
||
try {
|
||
const content = readFileSync(path, "utf-8");
|
||
const parsed = parseStageFrontmatter(content) as Record<string, unknown>;
|
||
const slug = typeof parsed.slug === "string" ? parsed.slug : f.replace(/\.md$/, "");
|
||
const plugin = typeof parsed.plugin === "string" ? parsed.plugin : undefined;
|
||
const stagePhase = typeof parsed.phase === "string" ? parsed.phase : phase;
|
||
stageSources.set(slug, { path, content, parsed });
|
||
if (
|
||
expectedEnabledBySelection({ plugin, phase: stagePhase }) &&
|
||
!graphSlugs.has(slug)
|
||
) {
|
||
missingEnabled.push(`${slug} (${path})`);
|
||
if (plugin) missingPluginStages.push(`${plugin}: stage ${slug}`);
|
||
}
|
||
} catch (e) {
|
||
if (selected !== null) {
|
||
missingEnabled.push(
|
||
`${f.replace(/\.md$/, "")} (${path}) - frontmatter parse failed: ${errorMessage(e)}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Hard-fail ONLY under an active selection: there the missing node means a
|
||
// torn select-plugins run (selection installs regenerate via select-plugins,
|
||
// which compiles in-chain). Without a selection an uncompiled stage file is
|
||
// deliberate authoring state - the pre-existing "Uncompiled stage files"
|
||
// advisory row below owns that case as an exit-zero advisory.
|
||
const torn = selected !== null && missingEnabled.length > 0;
|
||
results.push({
|
||
pass: !torn,
|
||
label: missingEnabled.length === 0
|
||
? "Enabled stage compile coverage: every enabled stage file is in the full graph"
|
||
: torn
|
||
? `Enabled stage compile coverage: ${missingEnabled.length} enabled stage file(s) missing from the full graph`
|
||
: `Enabled stage compile coverage: ${missingEnabled.length} uncompiled stage file(s) - no selection active, see the Uncompiled stage files advisory`,
|
||
fix: torn
|
||
? `${missingEnabled.join("; ")} - recover with \`${aidlcDispatcherInvocation("plugin select")} ${
|
||
[...(selected as ReadonlySet<string>)].sort().join(",")
|
||
}\``
|
||
: undefined,
|
||
});
|
||
|
||
const missingComposition: string[] = [...missingPluginStages];
|
||
const dataDir = resolveHarnessPath(["tools", "data"]);
|
||
if (existsSync(dataDir)) {
|
||
for (const file of readdirSync(dataDir).filter((name) =>
|
||
/^plugin-contrib-.+\.json$/.test(name)
|
||
).sort()) {
|
||
const plugin = file.replace(/^plugin-contrib-/, "").replace(/\.json$/, "");
|
||
if (!isPluginEnabled(plugin)) continue;
|
||
const sidecar = join(dataDir, file);
|
||
let manifest: Record<string, StageContribRecord>;
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(sidecar, "utf-8"));
|
||
if (!isPlainObject(parsed)) throw new Error("expected a JSON object");
|
||
manifest = parsed as Record<string, StageContribRecord>;
|
||
} catch (e) {
|
||
missingComposition.push(
|
||
`${plugin}: contribution sidecar ${sidecar} is unreadable or invalid (${errorMessage(e)}); refresh the stock engine and remove the invalid sidecar before syncing`,
|
||
);
|
||
continue;
|
||
}
|
||
if (Object.keys(manifest).length === 0) {
|
||
missingComposition.push(
|
||
`${plugin}: contribution sidecar ${sidecar} has no stage records; refresh the stock engine and remove the invalid sidecar before syncing`,
|
||
);
|
||
continue;
|
||
}
|
||
for (const [target, record] of Object.entries(manifest).sort(([a], [b]) =>
|
||
a.localeCompare(b)
|
||
)) {
|
||
const invalid = contributionRecordError(record);
|
||
if (invalid) {
|
||
missingComposition.push(
|
||
`${plugin}: contribution sidecar ${sidecar} target ${target} is invalid (${invalid}); refresh the stock engine and remove the invalid sidecar before syncing`,
|
||
);
|
||
continue;
|
||
}
|
||
const source = stageSources.get(target);
|
||
if (!source) {
|
||
missingComposition.push(
|
||
`${plugin}: contribution sidecar ${sidecar} target ${target} has no installed stage source; restore a compatible engine or plugin version before syncing`,
|
||
);
|
||
continue;
|
||
}
|
||
const missing = missingRecordedContributions(
|
||
source.parsed,
|
||
source.content,
|
||
plugin,
|
||
record as StageContribRecord,
|
||
);
|
||
if (missing.length > 0) {
|
||
missingComposition.push(
|
||
`${plugin}: stage ${target} (${source.path}) missing ${missing.join("; ")}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
results.push({
|
||
pass: missingComposition.length === 0,
|
||
label: missingComposition.length === 0
|
||
? "Composed plugin surface: all enabled plugin stages and recorded contributions are present"
|
||
: `Composed plugin surface: ${missingComposition.length} missing composition item(s)`,
|
||
fix: missingComposition.length > 0
|
||
? `${missingComposition.join("; ")} - correct any sidecar or target issue named above, then re-run \`/aidlc plugin sync\` (or \`bun ${harnessDir()}/tools/aidlc-utility.ts plugin-sync\` with the plugin root environment set). Hook-carrying hosts retry sync on the next session start.`
|
||
: undefined,
|
||
});
|
||
|
||
// Active workflows stranded by the CURRENT selection (a selection written
|
||
// before this guard existed, or a hand-edited harness.json): every /aidlc
|
||
// on such a workflow hard-errors, so doctor must not stay green.
|
||
if (selected !== null) {
|
||
const stranded = activeWorkflowDependencyViolations(projectDir, selected);
|
||
results.push({
|
||
pass: stranded.length === 0,
|
||
label: stranded.length === 0
|
||
? "Plugin selection vs active workflows: no stranded dependencies"
|
||
: `Plugin selection vs active workflows: ${stranded.length} stranded dependency(ies)`,
|
||
fix: stranded.length > 0
|
||
? `${stranded.join("; ")} - re-enable the plugin(s) with \`${aidlcDispatcherInvocation("plugin select")}\`, or complete/park the workflow(s)`
|
||
: undefined,
|
||
});
|
||
|
||
// Ordering edges the selection silently drops (an enabled stage's
|
||
// requires_stage names a disabled stage). Legitimate in plugin-only
|
||
// installs (plugin stages ordering after core ones), so ADVISORY - but
|
||
// surfaced, or a surprising walk order has no explanation anywhere.
|
||
const droppedEdges = selectionDroppedOrderingEdges(graphAll);
|
||
if (droppedEdges.length > 0) {
|
||
results.push({
|
||
pass: true,
|
||
label: `Selection-dropped ordering edges (advisory): ${droppedEdges.length} requires_stage edge(s) point at disabled stages - ${droppedEdges.join("; ")}`,
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Plugin selection: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// 4d. Optional plugin-authored install diagnostics. Discovery is
|
||
// selection-aware, so a disabled plugin's executable check remains inert.
|
||
try {
|
||
appendPluginDoctorChecks(results, projectDir);
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Plugin checks: discovery failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// 5. Workspace shell ready (P4: no --init artifact to check). With auto-create
|
||
// there is no scaffolded aidlc-docs/ to verify; readiness is the SHIPPED SHELL
|
||
// the user copies from dist/: the metadata-declared harness engine directory
|
||
// present AND the default space's memory dir present (the source of truth the
|
||
// native include resolves). When both are present the first /aidlc auto-creates
|
||
// with no ceremony; a missing piece means the dist/ copy was incomplete.
|
||
const harnessEngineDir = join(projectDir, harnessDir());
|
||
// Pin to the DEFAULT space explicitly: readiness is "did the dist/ shell copy
|
||
// in?", and `default` is the always-shipped space. memoryDirFor() now follows
|
||
// the active-space cursor, so pass DEFAULT_SPACE to keep this probe checking
|
||
// the shipped baseline rather than a (possibly absent) switched-to space. The
|
||
// harness includes are committed (generated-on-demand only for their pointer),
|
||
// so their presence is not part of shell-readiness.
|
||
const defaultMemoryDir = memoryDirFor(projectDir, DEFAULT_SPACE);
|
||
const shellReady = existsSync(harnessEngineDir) && existsSync(defaultMemoryDir);
|
||
results.push({
|
||
pass: shellReady,
|
||
label: `workspace shell ready (${harnessDir()}/ + aidlc/spaces/default/memory/)`,
|
||
fix: "run `aidlc config` in the project root to create the harness tree and workspace shell",
|
||
});
|
||
|
||
// 5a. Naming consistency for agent/scope files. Duplicate declared names are
|
||
// loader corruption and fail through loadAgents()/validScopes(); stem/name
|
||
// drift is recoverable authoring drift, so it is advisory and names the file.
|
||
try {
|
||
pushNamingAdvisory(
|
||
results,
|
||
"Agent",
|
||
namingMismatches(agentsDir(), "Agent", (stem, name) => stem === name),
|
||
);
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Agent filename/name consistency: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
try {
|
||
pushNamingAdvisory(
|
||
results,
|
||
"Scope",
|
||
namingMismatches(scopesDir(), "Scope", scopeFilenameMatchesDeclaredName),
|
||
);
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Scope filename/name consistency: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// 5b. Git submodules - an uninitialized submodule leaves its dir empty, so the
|
||
// scanner would classify a submodule-only workspace greenfield and auto-skip
|
||
// reverse-engineering. This ADVISORY row surfaces the state and the remedy.
|
||
// pass:true always (an uninitialized submodule is a user-environment pre-flight
|
||
// state, not framework breakage, and doctor's exit code feeds CI/scripts): the
|
||
// detail lives in the LABEL because the renderer prints `fix` only on a FAILED
|
||
// row (mirrors the intent-registry advisory).
|
||
if (!existsSync(join(projectDir, ".gitmodules"))) {
|
||
results.push({
|
||
pass: true,
|
||
label: "Submodules: no .gitmodules at workspace root",
|
||
});
|
||
} else {
|
||
const submodules = scanSubmodules(projectDir);
|
||
const uninit = submodules.filter((s) => !s.initialized);
|
||
if (submodules.length === 0) {
|
||
results.push({
|
||
pass: true,
|
||
label:
|
||
"Submodules: .gitmodules present but no parseable submodule entries",
|
||
});
|
||
} else if (uninit.length === 0) {
|
||
results.push({
|
||
pass: true,
|
||
label: `Submodules: ${submodules.length} declared, all initialized`,
|
||
});
|
||
} else {
|
||
results.push({
|
||
pass: true,
|
||
label: `Submodules: ${submodules.length} declared, ${uninit.length} uninitialized (advisory) (${enumerateSubmodulePaths(uninit)}) - run \`${SUBMODULE_INIT_REMEDY}\` to fetch them so reverse-engineering can read the code`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Read across every per-clone audit shard (single shard in the common case).
|
||
// Both hook-health and state-drift checks use the same intent-scoped ledger.
|
||
const auditAllShards = readAllAuditShards(projectDir);
|
||
const auditShardEvents = readAuditShardEvents(projectDir);
|
||
const stateMdPath = stateFilePath(projectDir);
|
||
let stateContent = "";
|
||
try {
|
||
if (existsSync(stateMdPath)) {
|
||
stateContent = readFileSync(stateMdPath, "utf-8");
|
||
}
|
||
} catch {
|
||
// An unreadable state contributes no progress evidence; audit remains usable.
|
||
}
|
||
const stateProgressedStages = new Set(
|
||
parseCheckboxes(stateContent)
|
||
.filter((entry) => entry.state !== "pending")
|
||
.map((entry) => entry.slug),
|
||
);
|
||
const stageOrGateEvents = auditShardEvents.filter(
|
||
(event) => event.event.startsWith("STAGE_") || event.event.startsWith("GATE_"),
|
||
);
|
||
const auditProgressedStages = new Set(
|
||
stageOrGateEvents
|
||
.map(
|
||
(event) =>
|
||
auditBlockField(event.block, "Stage") ??
|
||
auditBlockField(event.block, "Slug"),
|
||
)
|
||
.filter((slug): slug is string => slug !== null),
|
||
);
|
||
const progressedStageCount = Math.max(
|
||
stateProgressedStages.size,
|
||
auditProgressedStages.size,
|
||
);
|
||
const workflowHasProgress = progressedStageCount > 0;
|
||
const workflowStageStarted = auditAllShards.includes("**Event**: STAGE_STARTED");
|
||
const hookExecutionRecovery = hookExecutionRecoveryText(harnessName);
|
||
|
||
// 6. Hook heartbeats
|
||
// Three states, discriminated by health-dir presence, readable heartbeats,
|
||
// and evidence that the workflow advanced in the same intent-scoped ledger:
|
||
// (a) .aidlc-hooks-health/ missing entirely, or present without .last files
|
||
// before workflow progress → hooks have not had a chance to fire. Pass.
|
||
// This preserves debug-only dirs and ignores doctor's HEALTH_CHECKED.
|
||
// (b) No readable heartbeat after progress, or unreadable .last files →
|
||
// hooks should have fired. Fail.
|
||
// (c) Readable .last files exist → compare the newest one to progress.
|
||
// The comparison itself lives in aidlc-lib.ts (hookLiveness) because the Plan
|
||
// Approval decision refuses on the same staleness.
|
||
const liveness = hookLiveness(projectDir, auditShardEvents);
|
||
const heartbeatEntries = liveness.heartbeatEntries;
|
||
const heartbeatDirExists = liveness.healthDirExists;
|
||
const hasHookFiredContent = liveness.hasHookFiredContent;
|
||
const healthDir = hooksHealthDir(projectDir);
|
||
if (heartbeatEntries.length > 0) {
|
||
if (liveness.stale) {
|
||
results.push({
|
||
pass: false,
|
||
label: `Hooks last fired ${liveness.newestHeartbeat?.timestampRaw}, but the workflow last advanced ${liveness.newestStageOrGateEvent?.timestampRaw}`,
|
||
fix: hookExecutionRecovery,
|
||
});
|
||
} else {
|
||
results.push({
|
||
pass: true,
|
||
label: `Hooks last fired: ${heartbeatEntries.join(", ")}`,
|
||
});
|
||
}
|
||
} else if (
|
||
!heartbeatDirExists &&
|
||
workflowHasProgress
|
||
) {
|
||
const stages = progressedStageCount === 1 ? "stage" : "stages";
|
||
results.push({
|
||
pass: false,
|
||
label: `Hooks have never executed although this workflow has progressed ${progressedStageCount} ${stages}`,
|
||
fix: hookExecutionRecovery,
|
||
});
|
||
} else if (
|
||
heartbeatDirExists &&
|
||
!hasHookFiredContent &&
|
||
workflowStageStarted
|
||
) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Hook heartbeat data",
|
||
fix: "health dir exists and the ledger shows STAGE_STARTED, but no hook has ever fired — verify hooks are registered in settings.json",
|
||
});
|
||
} else if (
|
||
!heartbeatDirExists ||
|
||
(!hasHookFiredContent && !workflowStageStarted)
|
||
) {
|
||
// (a) fresh install, pre-created dir, or debug-only dir before progress.
|
||
results.push({
|
||
pass: true,
|
||
label: "Hook heartbeats: not yet fired (first workflow stage will populate)",
|
||
});
|
||
} else {
|
||
// (b) heartbeat files exist but are unreadable.
|
||
results.push({
|
||
pass: false,
|
||
label: "Hook heartbeat data",
|
||
fix: "health dir exists but heartbeat files are unreadable - verify permissions and hook registration",
|
||
});
|
||
}
|
||
|
||
if (
|
||
stageOrGateEvents.length > 0 &&
|
||
!auditShardEvents.some((event) => event.event === "HUMAN_TURN")
|
||
) {
|
||
results.push({
|
||
pass: true,
|
||
label: `Human-turn receipts: 0 HUMAN_TURN rows across ${stageOrGateEvents.length} stage/gate event(s) (advisory) - receipts are not being minted, so presence-gated checkpoints will refuse`,
|
||
});
|
||
}
|
||
|
||
// 6b. Hook drop records. A hook that hit a non-fatal failure appends a line
|
||
// to `<hook>.drops` in the health dir (recordHookDrop: ISO timestamp, TAB,
|
||
// reason). Severity-split: a `[degraded]` line means something was silently
|
||
// half-applied (a dropped plugin contribution, a failed recompile) and must
|
||
// FAIL doctor so a CI gate catches it; everything else ([advisory] or
|
||
// untagged, e.g. core recordHookDrop telemetry) is a PASSING advisory row -
|
||
// a drop is telemetry about a PAST swallowed failure, and a failing row
|
||
// would pin doctor's exit at 1 long after the cause was fixed. The compose
|
||
// hook rewrites its .drops each run, so a fixed + re-composed install
|
||
// self-clears a degraded drop. The advisory label carries count + last
|
||
// timestamp per hook (detail lives in the LABEL because the renderer prints
|
||
// `fix` only on a FAILED row); the newest line is the likeliest to be torn
|
||
// (recordHookDrop fires under disk-full/EACCES), so only a timestamp-shaped
|
||
// first token is shown, else a placeholder. Unlike the sibling probes this
|
||
// one does NOT absorb read errors into the clean row: EACCES is exactly the
|
||
// environment that produces drops, so an unreadable dir/file is named
|
||
// rather than reported "none recorded".
|
||
const advisoryEntries: string[] = [];
|
||
let dropsUnreadable = 0;
|
||
if (heartbeatDirExists) {
|
||
try {
|
||
const dropFiles = readdirSync(healthDir).filter((f) => f.endsWith(".drops"));
|
||
for (const f of dropFiles) {
|
||
try {
|
||
const lines = readFileSync(join(healthDir, f), "utf-8")
|
||
.split("\n")
|
||
.filter((l) => l.trim().length > 0);
|
||
if (lines.length === 0) continue;
|
||
const hook = f.replace(".drops", "");
|
||
const reasons = lines.map((l) => l.split("\t").slice(1).join(" "));
|
||
const degraded = reasons.filter((r) => r.includes("[degraded]"));
|
||
if (degraded.length > 0) {
|
||
const last = reasons[reasons.length - 1].slice(0, 160);
|
||
results.push({
|
||
pass: false,
|
||
label: `Hook drops (${hook}): ${degraded.length} degraded of ${lines.length}`,
|
||
fix: `${hook} degraded silently - read ${join(healthDir, f)} (latest: ${last}); fix the cause and re-compose (the file self-clears on a clean run)`,
|
||
});
|
||
} else {
|
||
const lastToken = lines[lines.length - 1].split("\t")[0].trim();
|
||
const lastTs = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z?$/.test(lastToken)
|
||
? lastToken
|
||
: "unparseable line";
|
||
advisoryEntries.push(`${hook} x${lines.length} (last ${lastTs})`);
|
||
}
|
||
} catch {
|
||
dropsUnreadable++;
|
||
}
|
||
}
|
||
} catch {
|
||
dropsUnreadable = -1; // whole dir unreadable
|
||
}
|
||
}
|
||
if (dropsUnreadable !== 0) {
|
||
results.push({
|
||
pass: true,
|
||
label:
|
||
dropsUnreadable === -1
|
||
? "Hook drops: health dir unreadable (advisory) - check permissions on .aidlc-hooks-health/"
|
||
: `Hook drops: ${dropsUnreadable} .drops file(s) unreadable (advisory)${advisoryEntries.length > 0 ? `; readable: ${advisoryEntries.join(", ")}` : ""} - check permissions on .aidlc-hooks-health/`,
|
||
});
|
||
} else if (advisoryEntries.length > 0) {
|
||
results.push({
|
||
pass: true,
|
||
label: `Hook drops recorded (advisory): ${advisoryEntries.join(", ")} - a hook swallowed a failure and fail-opened; inspect the named .drops file(s) under .aidlc-hooks-health/ for the reasons, then delete them once investigated`,
|
||
});
|
||
} else {
|
||
results.push({
|
||
pass: true,
|
||
label: "Hook drops: none recorded",
|
||
});
|
||
}
|
||
|
||
// 6c. Workspace source boundary. Plan Approval binds a plan to the source
|
||
// fingerprint of the workspace; when that walk fails, every decision on a
|
||
// Code Generation plan is refused as "unbindable". Run the same walk here so
|
||
// the reason (which budget or path) is visible before the checkpoint is, and
|
||
// only when workflow state exists (the same self-gate the project checks use).
|
||
if (existsSync(stateMdPath)) {
|
||
let sourceState: WorkspaceSourceState | null = null;
|
||
try {
|
||
sourceState = workspaceSourceState(projectDir);
|
||
} catch {
|
||
sourceState = null;
|
||
}
|
||
if (sourceState !== null) {
|
||
results.push({
|
||
pass: true,
|
||
label: `Workspace source boundary binds: ${sourceState.fingerprint.slice(0, 12)}`,
|
||
});
|
||
} else {
|
||
const failure = lastWorkspaceSourceFailure();
|
||
const where = failure === null
|
||
? "no reason was recorded"
|
||
: `${failure.code}${failure.path !== undefined ? ` at ${failure.repo !== undefined ? `${failure.repo}/${failure.path}` : failure.path}` : ""}: ${failure.detail}`;
|
||
results.push({
|
||
pass: false,
|
||
label: `Workspace source boundary binds: no (${where})`,
|
||
fix:
|
||
"Plan Approval decisions are refused while the source cannot be bound. " +
|
||
"Shrink or exclude the offending path, declare the real source under excluded " +
|
||
"directories in .aidlc-source-paths.json, or remove the broken symlink; then re-run " +
|
||
"the fingerprint command and re-present the plan. Last resort, human only: type " +
|
||
"`Override Plan Approval: <reason>` in chat and let the conductor run answer --override.",
|
||
});
|
||
}
|
||
}
|
||
|
||
// State / audit drift check — if latest audit event implies the state file
|
||
// should be in a certain shape (e.g., Status=Completed after WORKFLOW_COMPLETED),
|
||
// verify the state actually matches. Covers the rare case where audit-first
|
||
// succeeded but the state write failed (disk full, permission lost mid-run).
|
||
if (existsSync(stateMdPath) && auditAllShards.length > 0) {
|
||
try {
|
||
const auditContent = auditAllShards;
|
||
const stateContent = readFileSync(stateMdPath, "utf-8");
|
||
// Find last WORKFLOW_COMPLETED event
|
||
const wcIdx = auditContent.lastIndexOf("**Event**: WORKFLOW_COMPLETED");
|
||
if (wcIdx !== -1) {
|
||
const status = stateContent.match(/^- \*\*Status\*\*:\s*(\S+)/m);
|
||
if (status && status[1] !== "Completed") {
|
||
results.push({
|
||
pass: false,
|
||
label: `State/audit drift: audit has WORKFLOW_COMPLETED but state Status=${status[1]}`,
|
||
fix: "manually set Status=Completed in aidlc-state.md or restart the workflow",
|
||
});
|
||
} else {
|
||
results.push({
|
||
pass: true,
|
||
label: "State matches last audit event (no drift)",
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
// Drift-check failure is non-fatal for doctor report
|
||
}
|
||
}
|
||
|
||
// Leaked-lock probe (P3 reaper surface). Doctor automatically clears only a
|
||
// provably-dead valid owner or an old genuinely-missing stamp. Live,
|
||
// malformed, and unreadable owners fail closed and require quiescent manual
|
||
// recovery.
|
||
try {
|
||
const leaks = detectLeakedLocks(projectDir, true);
|
||
if (leaks.length === 0) {
|
||
results.push({ pass: true, label: "Runtime locks: none leaked" });
|
||
} else {
|
||
for (const leak of leaks) {
|
||
const subject = leak.kind === "audit" ? "audit lock"
|
||
: leak.kind === "active-directive" ? "active-directive lock"
|
||
: leak.kind === "coordination-gate" ? "lock coordination gate"
|
||
: "legacy active-directive transaction";
|
||
const outcome = leak.cleared ? "cleared" : "not cleared";
|
||
const manual = !leak.cleared;
|
||
results.push({
|
||
pass: false,
|
||
label: `Leaked ${subject} on bucket "${leak.bucket}" (${leak.reason}${leak.ownerPid !== null ? `, pid ${leak.ownerPid}` : ""}) - ${outcome}`,
|
||
fix: manual
|
||
? `stop all AI-DLC processes, inspect ${leak.lockDir}, then remove or restore it under quiescence`
|
||
: "the stale lock was cleared automatically; re-run your /aidlc command",
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
// Lock-probe failure is non-fatal for the doctor report.
|
||
}
|
||
|
||
// State version check — v8 reshapes the Inception design graph:
|
||
// `application-design` is renamed to `domain-design` and a new
|
||
// `contract-design` stage is inserted, so a pre-v8 state file carries
|
||
// stage-progress rows keyed by slugs that no longer exist in the graph.
|
||
// Advancing such a state hits `emitRunStageForSlug()` on a missing slug
|
||
// (or silently no-ops a checkbox while `Current Stage` moves on, then
|
||
// fails in `report`). The framework ships no user-visible migration
|
||
// pre-1.0, so fail loud here with archive-and-reinit guidance rather than
|
||
// let a stale-graph state look healthy.
|
||
if (existsSync(stateMdPath)) {
|
||
try {
|
||
const stateContent = readFileSync(stateMdPath, "utf-8");
|
||
// Shared classifier (aidlc-lib.ts): the SAME parse + branch selection the
|
||
// runtime guard uses, so doctor and next/report never disagree on whether
|
||
// a state is unparseable / past / future / ok. Doctor's per-branch rows
|
||
// let a human see WHICH kind of incompatibility the state hit rather
|
||
// than routing everything through a generic "not current" line.
|
||
const verdict = classifyStateVersion(stateContent);
|
||
if (verdict.kind === "unparseable") {
|
||
results.push({
|
||
pass: false,
|
||
label: "state version readable",
|
||
fix: verdict.message,
|
||
});
|
||
} else if (verdict.kind === "past") {
|
||
results.push({
|
||
pass: false,
|
||
label: "state version current",
|
||
fix: verdict.message,
|
||
});
|
||
} else if (verdict.kind === "future") {
|
||
results.push({
|
||
pass: false,
|
||
label: "state version compatible",
|
||
fix: verdict.message,
|
||
});
|
||
} else {
|
||
results.push({
|
||
pass: true,
|
||
label: `State Version: ${CURRENT_STATE_VERSION}`,
|
||
});
|
||
}
|
||
} catch {
|
||
// State-version check failure is non-fatal for doctor report
|
||
}
|
||
}
|
||
|
||
// Orphaned compose-marker probe: a read-only tripwire. The conductor writes
|
||
// the compose marker before an in-flight compose gate and deletes it on
|
||
// resolve; the Stop hook treats a FRESH marker as a carve-out (the turn may
|
||
// end at the gate). A crash between write and resolve can leave the marker on
|
||
// disk, so doctor reports a present marker with its age and the remediation
|
||
// (delete it if no compose gate is actually pending). Pass/fail follows the
|
||
// shared freshness window: a FRESH marker is the normal state while a compose
|
||
// gate is legitimately open (written before the gate, deleted on resolve), so
|
||
// it renders as an advisory pass (running doctor in a second terminal during
|
||
// a live gate must not exit 1 on a healthy workspace). Only a STALE marker
|
||
// (older than the TTL, i.e. an orphan the Stop hook has begun ignoring) is a
|
||
// fault. Silent when absent (no marker means nothing to report). Read-only:
|
||
// doctor never deletes it (the Stop hook is the janitor for a stale one).
|
||
try {
|
||
const composeMarker = composeMarkerPath(projectDir);
|
||
if (existsSync(composeMarker)) {
|
||
const ageMs = Date.now() - statSync(composeMarker).mtimeMs;
|
||
const ageHours = Math.floor(ageMs / (60 * 60 * 1000));
|
||
const ageLabel = ageHours >= 1 ? `${ageHours}h old` : "under 1h old";
|
||
const stale = ageMs > COMPOSE_MARKER_TTL_MS;
|
||
const staleLabel = stale ? ", stale" : ", fresh";
|
||
results.push({
|
||
pass: !stale,
|
||
label: `Compose marker present (aidlc/.aidlc-compose-pending, ${ageLabel}${staleLabel})`,
|
||
fix: "if no in-flight compose gate is actually pending, delete it ('rm aidlc/.aidlc-compose-pending') or resolve the pending gate. A stale marker no longer disables the Stop hook, but it should not linger.",
|
||
});
|
||
}
|
||
} catch {
|
||
// Compose-marker probe failure is non-fatal for the doctor report.
|
||
}
|
||
|
||
// A long-open approval gate is healthy waiting, not a hung workflow. Surface
|
||
// it as an advisory PASS so operators can distinguish human latency from a
|
||
// stuck engine without changing doctor's exit code.
|
||
try {
|
||
if (existsSync(stateMdPath)) {
|
||
const stateContent = readFileSync(stateMdPath, "utf-8");
|
||
const currentStage = getField(stateContent, "Current Stage");
|
||
const currentCheckbox = currentStage
|
||
? parseCheckboxes(stateContent).find((c) => c.slug === currentStage)
|
||
: undefined;
|
||
if (currentStage && currentCheckbox?.state === "awaiting-approval") {
|
||
const pending = pendingOrganicGate(auditShardEvents, currentStage);
|
||
if (pending) {
|
||
const ageMs = Date.now() - pending.timestampMs;
|
||
if (ageMs > GATE_PENDING_ADVISORY_MS) {
|
||
const displayName =
|
||
loadStageGraph().find((stage) => stage.slug === currentStage)?.name ??
|
||
currentStage;
|
||
const duration = pendingDuration(ageMs);
|
||
results.push({
|
||
pass: true,
|
||
label:
|
||
`Approval gate pending: ${displayName} (~${duration}); ` +
|
||
"waiting for a human, not stuck. Run /aidlc --status to review the current gate.",
|
||
fix: "run `/aidlc --status` to review and resolve the pending approval",
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Gate-pending probe failure is non-fatal for the doctor report.
|
||
}
|
||
|
||
// Background-subagent ledger probe. Fresh entries are expected while
|
||
// accepted run_in_background dispatches are active, so they are advisory.
|
||
// Stale or malformed entries fail with manual remediation. Read-only: doctor
|
||
// never rewrites the ledger; the Stop hook prunes stale entries.
|
||
try {
|
||
const subagents = inspectSubagentInflight(projectDir);
|
||
if (subagents.exists) {
|
||
const ageMs = subagents.oldestAgeMs ?? 0;
|
||
const ageHours = Math.floor(ageMs / (60 * 60 * 1000));
|
||
const ageLabel = ageHours >= 1 ? `${ageHours}h old` : "under 1h old";
|
||
const countLabel = subagents.malformed
|
||
? "malformed"
|
||
: `${subagents.freshCount} fresh, ${subagents.staleCount} stale, oldest ${ageLabel}`;
|
||
results.push({
|
||
pass: !subagents.malformed && subagents.staleCount === 0,
|
||
label: `Background-subagent ledger present (aidlc/.aidlc-subagent-inflight, ${countLabel})`,
|
||
fix: "if no background subagent is actually running, delete it ('rm aidlc/.aidlc-subagent-inflight'). Stale or malformed entries never authorize the Stop hook, but the ledger should not linger.",
|
||
});
|
||
}
|
||
} catch {
|
||
// Background-subagent ledger probe failure is non-fatal for doctor.
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Reconciliation checks
|
||
//
|
||
// Doctor's role: read-only reconciliation against on-disk state, audit, and
|
||
// git for the worktree / state-fork / audit-fork / practices surfaces. Each
|
||
// check anchors on a specific drift class:
|
||
//
|
||
// Check 1 — orphan worktrees (cleanup-orphan, BOLT_FAILED rows)
|
||
// Check 2 — stale branches (git branch -l 'bolt-*')
|
||
// Check 3 — orphan state files (STATE_FORKED slug-tag)
|
||
// Check 4 — orphan audit drift (AUDIT_FORKED, PRACTICES_OVERRIDE)
|
||
// Check 5 — practices staleness (Practices Affirmed Timestamp)
|
||
// Check 6 — MERGE_DISPATCH advisory (LLM-dispatch reconciliation)
|
||
//
|
||
// One surface remains deferred to a future release:
|
||
// - orphan `Merge-Held: true` reconciliation (graph traversal, not a
|
||
// check; needs workshop-resume false-positive guard)
|
||
// ===========================================================================
|
||
|
||
const auditMd = auditAllShards;
|
||
const stateMd = existsSync(stateMdPath) ? readFileSync(stateMdPath, "utf-8") : "";
|
||
const boltRefs = stateMd
|
||
? parseRefsList(getField(stateMd, "Bolt Refs") ?? "")
|
||
: [];
|
||
|
||
// Team claim reconciliation stays local-only. Registry refs are read from
|
||
// local refs/cache; doctor never fetches and never releases a claim. The
|
||
// presence gate preserves exact dormancy for workspaces that have never
|
||
// enabled team Unit ownership while retaining orphan detection after a team
|
||
// intent is removed but its local cache or checkout stamp remains.
|
||
const teamClaimDiagnostics =
|
||
readUnitScopeStamp(projectDir) !== null ||
|
||
readUnitClaimRegistryCache(projectDir) !== null ||
|
||
listSpaces(projectDir).some((space) =>
|
||
listIntents(projectDir, space.name).some((intent) => {
|
||
if (!intent.dirName) return false;
|
||
try {
|
||
return isTeamUnitOwnership(
|
||
readStateFile(projectDir, intent.dirName, space.name),
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
})
|
||
);
|
||
if (teamClaimDiagnostics) {
|
||
try {
|
||
const overviewForIdentity = (
|
||
space: string,
|
||
intentUuid: string,
|
||
): ReturnType<typeof cachedUnitClaimOverview> | null => {
|
||
const intent = listIntents(projectDir, space).find(
|
||
(candidate) =>
|
||
candidate.uuid === intentUuid &&
|
||
candidate.dirName !== null,
|
||
);
|
||
if (!intent?.dirName) return null;
|
||
try {
|
||
return localUnitClaimOverviewForIntent(projectDir, {
|
||
space,
|
||
intentUuid,
|
||
stateContent: readStateFile(projectDir, intent.dirName, space),
|
||
dependencyBody: readFileSync(
|
||
unitDependencyPath(projectDir, intent.dirName, space),
|
||
"utf-8",
|
||
),
|
||
});
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
const stamp = readUnitScopeStamp(projectDir);
|
||
if (stamp) {
|
||
const overview = overviewForIdentity(
|
||
stamp.space,
|
||
stamp.intent_uuid,
|
||
);
|
||
const current = overview?.claims.get(stamp.unit);
|
||
if (
|
||
current &&
|
||
(
|
||
current.status === "released" ||
|
||
current.generation !== stamp.generation ||
|
||
current.nonce !== stamp.nonce
|
||
)
|
||
) {
|
||
results.push({
|
||
pass: false,
|
||
label:
|
||
`Unit claim stamp stale: ${stamp.unit} generation ${stamp.generation} is tombstoned or superseded`,
|
||
fix:
|
||
`the checkout stamp may linger after release; preserve any useful work, then delete ${join("aidlc", ".aidlc-unit-scope.json")} and re-claim explicitly`,
|
||
});
|
||
}
|
||
}
|
||
|
||
const claimCache = readUnitClaimRegistryCache(projectDir);
|
||
if (claimCache) {
|
||
const now = Date.now();
|
||
const observedOverview = overviewForIdentity(
|
||
claimCache.space,
|
||
claimCache.intent_uuid,
|
||
);
|
||
const observedClaims = [...(observedOverview?.claims.values() ?? [])]
|
||
.filter((claim) => claim.status === "claimed")
|
||
.filter((claim) => !claim.movementObserved);
|
||
const missingObservation = observedClaims
|
||
.filter(
|
||
(claim) =>
|
||
!claim.observedAt ||
|
||
Number.isNaN(Date.parse(claim.observedAt)),
|
||
)
|
||
.map((claim) => claim.unit)
|
||
.sort();
|
||
if (missingObservation.length > 0) {
|
||
results.push({
|
||
pass: true,
|
||
label:
|
||
`Unit claim activity baseline missing (advisory): ${missingObservation.join(", ")} - run /aidlc --status after the next explicit fetch to establish a local observed-ref timestamp`,
|
||
});
|
||
}
|
||
const staleActivity = observedClaims
|
||
.filter(
|
||
(claim) =>
|
||
!!claim.observedAt &&
|
||
!Number.isNaN(Date.parse(claim.observedAt)),
|
||
)
|
||
.filter((claim) => {
|
||
const observed = Date.parse(claim.observedAt!);
|
||
return now - observed >
|
||
CLAIM_ACTIVITY_STALE_HOURS * 60 * 60 * 1000;
|
||
})
|
||
.map((claim) => claim.unit)
|
||
.sort();
|
||
if (staleActivity.length > 0) {
|
||
results.push({
|
||
pass: false,
|
||
label:
|
||
`Unit claim activity: ${staleActivity.length} claim(s) with no observed ref movement for ${CLAIM_ACTIVITY_STALE_HOURS}h (${staleActivity.join(", ")}) - report only; inspect the team checkout and release only after a human decision`,
|
||
fix:
|
||
"inspect the owning checkout and candidate history; release only after a human confirms the attempt is abandoned",
|
||
});
|
||
}
|
||
}
|
||
|
||
const refs = spawnSync(
|
||
"git",
|
||
[
|
||
"for-each-ref",
|
||
"--format=%(refname)",
|
||
"refs/heads/claim/",
|
||
"refs/remotes/",
|
||
],
|
||
{
|
||
cwd: projectDir,
|
||
encoding: "utf-8",
|
||
env: { ...process.env, GIT_NO_LAZY_FETCH: "1" },
|
||
},
|
||
);
|
||
if ((refs.status ?? 1) === 0) {
|
||
const knownIntentIds = new Set<string>();
|
||
for (const space of listSpaces(projectDir)) {
|
||
for (const intent of listIntents(projectDir, space.name)) {
|
||
if (intent.uuid) knownIntentIds.add(idSuffix(intent.uuid));
|
||
}
|
||
}
|
||
const orphanRefs = [
|
||
...new Set(
|
||
(refs.stdout ?? "")
|
||
.split(/\r?\n/)
|
||
.map((ref) => ({
|
||
ref,
|
||
id8:
|
||
/^refs\/heads\/claim\/([^/]+)\/[^/]+$/.exec(ref)?.[1] ??
|
||
/^refs\/remotes\/[^/]+\/claim\/([^/]+)\/[^/]+$/.exec(ref)?.[1],
|
||
}))
|
||
.filter(
|
||
(row): row is { ref: string; id8: string } =>
|
||
!!row.id8 && !knownIntentIds.has(row.id8),
|
||
)
|
||
.map((row) => row.ref),
|
||
),
|
||
].sort();
|
||
if (orphanRefs.length > 0) {
|
||
results.push({
|
||
pass: false,
|
||
label:
|
||
`Orphan Unit claim refs: ${orphanRefs.length} ref(s) match no local intent (${orphanRefs.join(", ")})`,
|
||
fix:
|
||
"confirm the intent was removed or renamed, preserve any candidate commit needed for salvage, then delete the orphan refs manually",
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
// Claim reconciliation is additive; existing doctor checks still render.
|
||
}
|
||
}
|
||
|
||
// Helper: extract the Bolt slug from an audit block. Returns null if absent.
|
||
const blockBoltSlug = (block: string): string | null => {
|
||
const m = block.match(/^\*\*Bolt slug\*\*:\s*(\S+)/m);
|
||
return m ? m[1] : null;
|
||
};
|
||
|
||
// Helper: extract a named field value from an audit block.
|
||
const blockField = (block: string, field: string): string | null => {
|
||
const re = new RegExp(`^\\*\\*${escapeRegex(field)}\\*\\*:\\s*(.+)$`, "m");
|
||
const m = block.match(re);
|
||
return m ? m[1].trim() : null;
|
||
};
|
||
|
||
// Helper: was a slug terminated (worktree merged or discarded) in audit?
|
||
const slugTerminated = (slug: string): boolean => {
|
||
if (
|
||
findAllEvents(auditMd, "WORKTREE_MERGED", slug).length > 0 ||
|
||
findAllEvents(auditMd, "WORKTREE_DISCARDED", slug).length > 0
|
||
) {
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Check 1 — Orphan worktrees
|
||
//
|
||
// Walk `.aidlc/worktrees/bolt-*/` directories on disk; cross-reference each
|
||
// against:
|
||
// (a) main state's Bolt Refs (active fork → ✓)
|
||
// (b) audit WORKTREE_DISCARDED / WORKTREE_MERGED (terminated → orphan dir)
|
||
// (c) ERROR_LOGGED rows with [merge-succeeded:<sha>] tag (cleanup-orphan
|
||
// after a successful merge)
|
||
//
|
||
// Reports `0 worktrees observed` with pass=true when the directory is empty
|
||
// or absent — the issue 75 line 215 "fail-clean on no-worktrees" guarantee.
|
||
// ---------------------------------------------------------------------------
|
||
try {
|
||
const worktreesDir = join(projectDir, ".aidlc", "worktrees");
|
||
let observed = 0;
|
||
let activeForks = 0;
|
||
let preservedByAbort = 0;
|
||
const orphanActive: string[] = []; // dir present but no audit/Bolt Refs trail
|
||
const cleanupOrphans: string[] = []; // dir present, merge succeeded, cleanup failed
|
||
|
||
// Helper: did this slug get aborted via `aidlc-bolt abort` (BOLT_FAILED
|
||
// with `Reason: aborted` from multi-failure halt-and-ask)?
|
||
// Default-path abort preserves the worktree, so the slug remains in
|
||
// Bolt Refs but it's not "in flight" — it's awaiting /aidlc --resume.
|
||
// Doctor output distinguishes "3 active forks (in flight)" from "3
|
||
// preserved-by-abort (awaiting resume)".
|
||
const isAbortedSlug = (slug: string): boolean => {
|
||
return findAllEvents(auditMd, "BOLT_FAILED", slug).some((b) => {
|
||
const reason = blockField(b.block, "Reason") ?? "";
|
||
return reason === "aborted";
|
||
});
|
||
};
|
||
|
||
if (existsSync(worktreesDir)) {
|
||
for (const entry of readdirSync(worktreesDir)) {
|
||
if (!entry.startsWith("bolt-")) continue;
|
||
const slug = entry.slice("bolt-".length);
|
||
if (validateBoltSlug(slug) !== null) continue;
|
||
observed++;
|
||
|
||
// Active fork — slug is in main state's Bolt Refs. Expected; not orphan.
|
||
// Sub-classify into "preserved-by-abort" (BOLT_FAILED Reason: aborted
|
||
// exists for the slug — the user aborted multi-failure AUQ at index k
|
||
// and these dirs are awaiting /aidlc --resume) vs "in flight".
|
||
if (boltRefs.includes(slug)) {
|
||
if (isAbortedSlug(slug)) {
|
||
preservedByAbort++;
|
||
} else {
|
||
activeForks++;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Cleanup-orphan: a WORKTREE_MERGED landed (or ERROR_LOGGED carries
|
||
// [merge-succeeded:<sha>] on a post-merge cleanup failure) but the
|
||
// directory persists. The worktree primitive guarantees the tag.
|
||
const errBlocks = findAllEvents(auditMd, "ERROR_LOGGED");
|
||
const matchesMergeSucceeded = errBlocks.some((b) => {
|
||
const tag = b.block.match(MERGE_SUCCEEDED_TAG_REGEX);
|
||
if (!tag) return false;
|
||
const slugTag = b.block.match(SLUG_TAG_REGEX);
|
||
return slugTag !== null && slugTag[1] === slug;
|
||
});
|
||
if (matchesMergeSucceeded || findAllEvents(auditMd, "WORKTREE_MERGED", slug).length > 0) {
|
||
cleanupOrphans.push(slug);
|
||
continue;
|
||
}
|
||
if (findAllEvents(auditMd, "WORKTREE_DISCARDED", slug).length > 0) {
|
||
// Terminated explicitly via discard but directory persists — discard
|
||
// failed mid-cleanup. Surface so the operator can `rm -rf` manually.
|
||
cleanupOrphans.push(slug);
|
||
continue;
|
||
}
|
||
orphanActive.push(slug);
|
||
}
|
||
}
|
||
|
||
const pass = orphanActive.length === 0 && cleanupOrphans.length === 0;
|
||
let label: string;
|
||
let fix: string | undefined;
|
||
if (observed === 0) {
|
||
label = "Orphan worktrees: 0 observed";
|
||
} else if (pass) {
|
||
const segments: string[] = [];
|
||
if (activeForks > 0) segments.push(`${activeForks} active fork${activeForks === 1 ? "" : "s"}`);
|
||
if (preservedByAbort > 0) segments.push(`${preservedByAbort} preserved-by-abort (awaiting resume)`);
|
||
label = `Orphan worktrees: 0 (${segments.join(", ")})`;
|
||
} else {
|
||
const parts: string[] = [];
|
||
if (orphanActive.length > 0) {
|
||
parts.push(`${orphanActive.length} unmatched (no audit trail): ${orphanActive.join(", ")}`);
|
||
}
|
||
if (cleanupOrphans.length > 0) {
|
||
parts.push(
|
||
`${cleanupOrphans.length} cleanup-orphan${cleanupOrphans.length === 1 ? "" : "s"} (merge/discard landed, dir persists): ${cleanupOrphans.join(", ")}`,
|
||
);
|
||
}
|
||
label = `Orphan worktrees: ${orphanActive.length + cleanupOrphans.length} drift`;
|
||
fix = `${parts.join("; ")}. Inspect, then remove via 'aidlc-worktree discard --slug <slug>'.`;
|
||
}
|
||
results.push({ pass, label, fix });
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Orphan worktrees: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Check 2 — Stale branches
|
||
//
|
||
// Walk `git branch --list 'bolt-*'`; flag any `bolt-<slug>` branch whose
|
||
// worktree directory is gone but no terminal WORKTREE_DISCARDED or
|
||
// WORKTREE_MERGED audit row landed for that slug.
|
||
//
|
||
// Skips branches that aren't valid Bolt slugs — e.g. user-created
|
||
// `bolt-experiment` outside the framework. Skips silently when not a git
|
||
// repo (smoke / fresh fixtures) so doctor remains usable in non-git contexts.
|
||
// ---------------------------------------------------------------------------
|
||
try {
|
||
const proc = Bun.spawnSync({
|
||
cmd: ["git", "-C", projectDir, "branch", "--list", "bolt-*"],
|
||
stdout: "pipe",
|
||
stderr: "pipe",
|
||
});
|
||
if (proc.exitCode !== 0) {
|
||
// Not a git repo or git failure — skip silently with informational pass.
|
||
results.push({ pass: true, label: "Stale branches: 0 observed (not a git repo)" });
|
||
} else {
|
||
const stdout = new TextDecoder().decode(proc.stdout);
|
||
const branchSlugs: string[] = [];
|
||
for (const line of stdout.split("\n")) {
|
||
const trimmed = line.replace(/^\*?\s+/, "").trim();
|
||
if (!trimmed.startsWith("bolt-")) continue;
|
||
const slug = trimmed.slice("bolt-".length);
|
||
if (validateBoltSlug(slug) !== null) continue;
|
||
branchSlugs.push(slug);
|
||
}
|
||
|
||
const stale: string[] = [];
|
||
for (const slug of branchSlugs) {
|
||
const wtDir = worktreePath(projectDir, slug);
|
||
if (existsSync(wtDir)) continue; // worktree intact — branch is live
|
||
// Worktree gone — needs a terminal audit row to be legitimate.
|
||
if (slugTerminated(slug)) continue;
|
||
stale.push(slug);
|
||
}
|
||
|
||
if (stale.length === 0) {
|
||
results.push({
|
||
pass: true,
|
||
label: `Stale branches: 0 (${branchSlugs.length} bolt-* observed)`,
|
||
});
|
||
} else {
|
||
results.push({
|
||
pass: false,
|
||
label: `Stale branches: ${stale.length} drift`,
|
||
fix: `branches ${stale.join(", ")} have no worktree directory and no WORKTREE_MERGED/_DISCARDED audit row. Delete via 'git branch -D bolt-<slug>' if abandoned.`,
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Stale branches: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Check 3 — Orphan state files (paired with STATE_FORKED slug-tag)
|
||
//
|
||
// Walk `.aidlc/worktrees/*/aidlc-docs/aidlc-state.md`; each found state file
|
||
// must map to a slug in main's Bolt Refs (active fork) OR pair with a
|
||
// WORKTREE_DISCARDED audit row (pre-discard). Anything else is post-fork
|
||
// drift — STATE_FORKED emitted, slug added to Bolt Refs, but state-write or
|
||
// STATE_MERGED never landed.
|
||
// ---------------------------------------------------------------------------
|
||
try {
|
||
const worktreesDir = join(projectDir, ".aidlc", "worktrees");
|
||
const orphan: string[] = [];
|
||
let observed = 0;
|
||
|
||
if (existsSync(worktreesDir)) {
|
||
for (const entry of readdirSync(worktreesDir)) {
|
||
if (!entry.startsWith("bolt-")) continue;
|
||
const slug = entry.slice("bolt-".length);
|
||
if (validateBoltSlug(slug) !== null) continue;
|
||
const wtStatePath = worktreeStateFilePath(join(worktreesDir, entry));
|
||
if (!existsSync(wtStatePath)) continue;
|
||
observed++;
|
||
if (boltRefs.includes(slug)) continue;
|
||
if (findAllEvents(auditMd, "WORKTREE_DISCARDED", slug).length > 0) continue;
|
||
orphan.push(slug);
|
||
}
|
||
}
|
||
|
||
if (orphan.length === 0) {
|
||
results.push({
|
||
pass: true,
|
||
label: observed === 0
|
||
? "Orphan state files: 0 observed"
|
||
: `Orphan state files: 0 (${observed} active)`,
|
||
});
|
||
} else {
|
||
results.push({
|
||
pass: false,
|
||
label: `Orphan state files: ${orphan.length} drift`,
|
||
fix: `state files for ${orphan.join(", ")} exist but slug not in Bolt Refs and no WORKTREE_DISCARDED row. Recover via 'aidlc-worktree discard --slug <slug>' (idempotent).`,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Orphan state files: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Check 4 — Orphan audit drift (3 sub-cases)
|
||
//
|
||
// Sub-case (a): AUDIT_FORKED-without-disk-state — main has AUDIT_FORKED but
|
||
// <wtPath>/aidlc-docs/audit.md is absent on disk.
|
||
// Sub-case (b): orphan-delta — main has AUDIT_FORKED but no matching
|
||
// AUDIT_MERGED for an unterminated, non-active slug.
|
||
// Sub-case (c): PRACTICES_OVERRIDE Reason filter — write-failure-* rows
|
||
// without a following PRACTICES_AFFIRMED are flagged as orphan; rows
|
||
// carrying Reason: bolt-plan-marker-conflict are expected behaviour and
|
||
// ignored. audit-format.md:138 anchors the discriminator routing.
|
||
//
|
||
// Sub-case (c) shares the orphan-audit umbrella because both classes ride
|
||
// the same audit-walker pass; per plan-v3 §51, this is one Check, not two.
|
||
// ---------------------------------------------------------------------------
|
||
try {
|
||
const forkedDriftDisk: string[] = []; // (a)
|
||
const forkedDriftMerge: string[] = []; // (b)
|
||
const overrideDrift: string[] = []; // (c)
|
||
|
||
const forks = findAllEvents(auditMd, "AUDIT_FORKED");
|
||
for (const fork of forks) {
|
||
const slug = blockBoltSlug(fork.block);
|
||
if (!slug) continue;
|
||
// Terminal short-circuits run BEFORE the disk check. A successfully
|
||
// merged-and-cleaned Bolt has AUDIT_MERGED + WORKTREE_MERGED in main
|
||
// audit and the worktree directory removed by `aidlc-worktree merge`'s
|
||
// cleanup — without the short-circuit, sub-case (a) would flag every
|
||
// healthy historical AUDIT_FORKED as drift forever. Same logic for
|
||
// active forks (still in flight) and explicit discards.
|
||
if (findAllEvents(auditMd, "AUDIT_MERGED", slug).length > 0) continue;
|
||
if (boltRefs.includes(slug)) continue;
|
||
if (findAllEvents(auditMd, "WORKTREE_DISCARDED", slug).length > 0) continue;
|
||
// Sub-case (a): no terminal pairing — is the worktree audit on disk?
|
||
// If yes, we're mid-fork (orphan-delta — sub-case b). If no, the fork
|
||
// emitted but disk copy never landed.
|
||
const wtAudit = worktreeAuditFilePath(worktreePath(projectDir, slug));
|
||
if (!existsSync(wtAudit)) {
|
||
forkedDriftDisk.push(slug);
|
||
continue;
|
||
}
|
||
// Sub-case (b): disk audit landed but no AUDIT_MERGED — orphan-delta.
|
||
forkedDriftMerge.push(slug);
|
||
}
|
||
|
||
// Sub-case (c): PRACTICES_OVERRIDE Reason filter.
|
||
let unknownReasonCount = 0;
|
||
const overrides = findAllEvents(auditMd, "PRACTICES_OVERRIDE");
|
||
for (const o of overrides) {
|
||
const reason = blockField(o.block, "Reason") ?? "";
|
||
// bolt-plan-marker-conflict is expected behaviour (orchestrator override
|
||
// per team practices) — skip per audit-format.md routing.
|
||
if (reason.startsWith("bolt-plan-marker-conflict")) continue;
|
||
// write-failure-* rows are practices-promote failures. Orphan if no
|
||
// following PRACTICES_AFFIRMED row; matched-pair otherwise. Compare
|
||
// timestamps via Date.parse — ISO 8601 strings only sort lexicographically
|
||
// when in identical format, but `2026-05-19T11:00:00.123Z` sorts before
|
||
// `2026-05-19T11:00:00Z` (`.` 0x2E < `Z` 0x5A) and `Z` vs `+00:00` shapes
|
||
// also break naive string compare. Date.parse normalises both to ms.
|
||
if (reason.startsWith("write-failure")) {
|
||
const overrideMs = Date.parse(o.timestamp);
|
||
const affirmAfter = findAllEvents(auditMd, "PRACTICES_AFFIRMED").some(
|
||
(a) => {
|
||
const am = Date.parse(a.timestamp);
|
||
return Number.isFinite(am) && am > overrideMs;
|
||
},
|
||
);
|
||
if (!affirmAfter) {
|
||
overrideDrift.push(`${reason}@${o.timestamp}`);
|
||
}
|
||
continue;
|
||
}
|
||
// Reason value matched neither prefix — track for follow-up. Future
|
||
// PRACTICES_OVERRIDE Reason variants may need their own routing rule;
|
||
// doctor surfaces the count for later reconciliation.
|
||
unknownReasonCount++;
|
||
}
|
||
|
||
const total = forkedDriftDisk.length + forkedDriftMerge.length + overrideDrift.length;
|
||
if (total === 0) {
|
||
const reconciled = forks.length + overrides.length - unknownReasonCount;
|
||
let label: string;
|
||
if (reconciled === 0) {
|
||
label = "Orphan audit: 0 observed";
|
||
} else {
|
||
label = `Orphan audit: 0 (${reconciled} reconciled)`;
|
||
}
|
||
if (unknownReasonCount > 0) {
|
||
label += `; ${unknownReasonCount} PRACTICES_OVERRIDE row(s) with unknown Reason - track for follow-up`;
|
||
}
|
||
results.push({ pass: true, label });
|
||
} else {
|
||
const parts: string[] = [];
|
||
if (forkedDriftDisk.length > 0) parts.push(`${forkedDriftDisk.length} AUDIT_FORKED-without-disk: ${forkedDriftDisk.join(", ")}`);
|
||
if (forkedDriftMerge.length > 0) parts.push(`${forkedDriftMerge.length} orphan-delta (no AUDIT_MERGED): ${forkedDriftMerge.join(", ")}`);
|
||
if (overrideDrift.length > 0) parts.push(`${overrideDrift.length} PRACTICES_OVERRIDE write-failure(s) without follow-up PRACTICES_AFFIRMED`);
|
||
if (unknownReasonCount > 0) parts.push(`${unknownReasonCount} PRACTICES_OVERRIDE row(s) with unknown Reason`);
|
||
results.push({
|
||
pass: false,
|
||
label: `Orphan audit: ${total} drift`,
|
||
fix: parts.join("; "),
|
||
});
|
||
}
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Orphan audit: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Check 5 — Practices staleness
|
||
//
|
||
// Read `Practices Affirmed Timestamp` from main state. Compare to now.
|
||
// Empty / missing → informational pass (never affirmed). Within 90 days → ✓.
|
||
// Older → advisory pass=true (does NOT fail exit code; mirrors heartbeat
|
||
// and state/audit drift advisory pattern at aidlc-utility.ts:421-466).
|
||
// Invalid ISO timestamp → fail readable.
|
||
// ---------------------------------------------------------------------------
|
||
try {
|
||
if (!stateMd) {
|
||
results.push({ pass: true, label: "Practices staleness: state file absent (informational)" });
|
||
} else {
|
||
const value = (getField(stateMd, "Practices Affirmed Timestamp") ?? "").trim();
|
||
if (value === "" || value.startsWith("[")) {
|
||
// Empty placeholder OR `[ISO 8601 timestamp on affirmation]` template
|
||
// string that hasn't been replaced by practices-promote yet.
|
||
results.push({ pass: true, label: "Practices staleness: never affirmed (informational)" });
|
||
} else {
|
||
const affirmed = Date.parse(value);
|
||
if (Number.isNaN(affirmed)) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Practices staleness: timestamp unreadable",
|
||
fix: `Practices Affirmed Timestamp value "${value}" is not a valid ISO 8601 datetime. Re-run practices-discovery (stage 2.2) to re-affirm.`,
|
||
});
|
||
} else {
|
||
const ageDays = Math.floor((Date.now() - affirmed) / (1000 * 60 * 60 * 24));
|
||
if (ageDays < 0) {
|
||
// Future-dated timestamp — clock skew or hand-edit. Advisory pass
|
||
// so doctor doesn't fail loud, but surfaces the anomaly.
|
||
results.push({
|
||
pass: true,
|
||
label: `Practices staleness: affirmed in the future (clock skew or hand-edited timestamp ${Math.abs(ageDays)} day${Math.abs(ageDays) === 1 ? "" : "s"} ahead)`,
|
||
});
|
||
} else if (ageDays <= PRACTICES_STALENESS_DAYS) {
|
||
results.push({
|
||
pass: true,
|
||
label: `Practices staleness: affirmed ${ageDays} day${ageDays === 1 ? "" : "s"} ago`,
|
||
});
|
||
} else {
|
||
results.push({
|
||
pass: true,
|
||
label: `Practices staleness: affirmed ${ageDays} days ago (advisory: > ${PRACTICES_STALENESS_DAYS} days; consider re-running practices-discovery)`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Practices-staleness check failure is non-fatal for doctor report
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Check 6 — MERGE_DISPATCH advisory
|
||
//
|
||
// Walk MERGE_DISPATCH_INVOKED rows; an INVOKED row should pair with either
|
||
// _RETURNED or _FALLBACK for the same slug within MERGE_DISPATCH_TIMEOUT_SEC.
|
||
// Orphan INVOKED rows are reported as advisory (pass=true) — observation-
|
||
// time drift on an in-memory LLM dispatch is not a fail-loud condition. A
|
||
// future observer layer may take over this reconciliation.
|
||
//
|
||
// No correlation tag — slug + timestamp window is sufficient for doctor
|
||
// reconciliation (the LLM call has no disk artifact to anchor against).
|
||
// ---------------------------------------------------------------------------
|
||
try {
|
||
const invokedRows = findAllEvents(auditMd, "MERGE_DISPATCH_INVOKED");
|
||
let orphans = 0;
|
||
const now = Date.now();
|
||
// Pair-match per slug: each terminal row (RETURNED or FALLBACK) consumed
|
||
// by at most one preceding INVOKED. Without consumption tracking, two
|
||
// consecutive INVOKED + 1 RETURNED for the same slug would report 0
|
||
// orphans because `.some(r >= invokedTs)` is satisfied by ANY later
|
||
// terminal, not the next-unmatched one.
|
||
const invokedBySlug = new Map<string, number[]>(); // slug → INVOKED timestamps (ms)
|
||
for (const inv of invokedRows) {
|
||
const slug = blockBoltSlug(inv.block);
|
||
if (!slug) continue;
|
||
const invokedMs = Date.parse(inv.timestamp);
|
||
if (Number.isNaN(invokedMs)) continue;
|
||
const list = invokedBySlug.get(slug) ?? [];
|
||
list.push(invokedMs);
|
||
invokedBySlug.set(slug, list);
|
||
}
|
||
for (const [slug, invokedList] of invokedBySlug) {
|
||
invokedList.sort((a, b) => a - b);
|
||
// Build a chronological list of terminal events (RETURNED + FALLBACK)
|
||
// for this slug, then consume each in pair order with the earliest
|
||
// not-yet-paired INVOKED that precedes it.
|
||
const terminals: number[] = [];
|
||
for (const r of findAllEvents(auditMd, "MERGE_DISPATCH_RETURNED", slug)) {
|
||
const ms = Date.parse(r.timestamp);
|
||
if (Number.isFinite(ms)) terminals.push(ms);
|
||
}
|
||
for (const f of findAllEvents(auditMd, "MERGE_DISPATCH_FALLBACK", slug)) {
|
||
const ms = Date.parse(f.timestamp);
|
||
if (Number.isFinite(ms)) terminals.push(ms);
|
||
}
|
||
terminals.sort((a, b) => a - b);
|
||
const consumed = new Array<boolean>(terminals.length).fill(false);
|
||
for (const invokedMs of invokedList) {
|
||
// Active session within the timeout window — still in flight, skip.
|
||
if (now - invokedMs < MERGE_DISPATCH_TIMEOUT_SEC * 1000) continue;
|
||
// Find the first not-yet-consumed terminal at or after invokedMs.
|
||
let matched = false;
|
||
for (let i = 0; i < terminals.length; i++) {
|
||
if (consumed[i]) continue;
|
||
if (terminals[i] < invokedMs) continue;
|
||
consumed[i] = true;
|
||
matched = true;
|
||
break;
|
||
}
|
||
if (!matched) orphans++;
|
||
}
|
||
}
|
||
results.push({
|
||
pass: true,
|
||
label: orphans === 0
|
||
? `MERGE_DISPATCH: 0 orphan INVOKED (${invokedRows.length} bracketed)`
|
||
: `MERGE_DISPATCH: ${orphans} orphan INVOKED (advisory - a merge started but no matching finish was recorded within ${MERGE_DISPATCH_TIMEOUT_SEC}s)`,
|
||
});
|
||
} catch {
|
||
// MERGE_DISPATCH check failure is non-fatal for doctor report
|
||
}
|
||
|
||
// --- Graph-level checks (library-direct, no subprocess) ---
|
||
|
||
// Cycle detection — findCycles returns [] on a healthy DAG
|
||
try {
|
||
const cycles = findCycles(loadGraph());
|
||
results.push({
|
||
pass: cycles.length === 0,
|
||
label: cycles.length === 0
|
||
? "Cycle detection: 0 cycles"
|
||
: `Cycle detection: ${cycles.length} cycle(s) found`,
|
||
fix: cycles.length > 0
|
||
? `cycles: ${cycles.map((c) => c.join(" -> ")).join("; ")}`
|
||
: undefined,
|
||
});
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Cycle detection: graph load failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// Stage-graph <-> disk drift, both directions (stageGraphDrift()):
|
||
// - graph->disk (missingFiles): a slug in stage-graph.json with no
|
||
// <phase>/<slug>.md on disk. Real runtime breakage (conductor handed a
|
||
// path to a missing file) -> hard FAIL.
|
||
// - disk->graph (uncompiledStages): a <phase>/<slug>.md whose slug is absent
|
||
// from the compiled graph. The runtime resolves stages from the compiled
|
||
// graph only, so the file is silently never executed. The file is inert,
|
||
// not corrupt, and recompiling is a deliberate authoring act -> ADVISORY
|
||
// (pass:true; does not fail the doctor exit code, mirroring
|
||
// the rule-drift / MERGE_DISPATCH advisory rows).
|
||
try {
|
||
const { missingFiles, uncompiledStages, graphCount } = stageGraphDrift();
|
||
results.push({
|
||
pass: missingFiles.length === 0,
|
||
label: missingFiles.length === 0
|
||
? `Orphan stage files: ${graphCount} graph entries all have files`
|
||
: `Orphan stage files: ${missingFiles.length} graph entries have no file on disk`,
|
||
fix: missingFiles.length > 0 ? `missing files: ${missingFiles.join(", ")}` : undefined,
|
||
});
|
||
// Advisory row (pass:true), the detail must live in the LABEL, not the
|
||
// `fix` field: the report renderer only prints `fix` on a FAILED (pass:false)
|
||
// row (see the render loop below). Fold the slug list + the compile hint into
|
||
// the label so the operator can act on it, mirroring the MERGE_DISPATCH /
|
||
// rule-drift advisory rows that carry their detail inline.
|
||
const uncompiledPluginStages: string[] = [];
|
||
if (uncompiledStages.length > 0) {
|
||
const uncompiled = new Set(uncompiledStages);
|
||
const stagesRoot = resolveHarnessPath(["aidlc-common", "stages"]);
|
||
for (const phase of PHASES) {
|
||
const dir = join(stagesRoot, phase);
|
||
if (!existsSync(dir)) continue;
|
||
for (const file of readdirSync(dir).filter((name) => name.endsWith(".md")).sort()) {
|
||
const fallbackSlug = file.replace(/\.md$/, "");
|
||
if (!uncompiled.has(fallbackSlug)) continue;
|
||
try {
|
||
const parsed = parseStageFrontmatter(readFileSync(join(dir, file), "utf-8"));
|
||
const slug = typeof parsed.slug === "string" ? parsed.slug : fallbackSlug;
|
||
const plugin = typeof parsed.plugin === "string" ? parsed.plugin : undefined;
|
||
if (plugin) uncompiledPluginStages.push(`${slug} (${plugin})`);
|
||
} catch {
|
||
// Schema validation below owns malformed frontmatter.
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const uncompiledHint = uncompiledPluginStages.length > 0
|
||
? ` - plugin-owned files ${uncompiledPluginStages.join(", ")} require \`/aidlc plugin sync\` (or \`bun ${harnessDir()}/tools/aidlc-utility.ts plugin-sync\` with the plugin root environment set); run \`${aidlcToolInvocation("graph")} compile\` for other authored stages`
|
||
: ` - run \`${aidlcToolInvocation("graph")} compile\` to include them`;
|
||
results.push({
|
||
pass: true,
|
||
label: uncompiledStages.length === 0
|
||
? "Uncompiled stage files: 0 stage files missing from the compiled graph"
|
||
: `Uncompiled stage files: ${uncompiledStages.length} stage file(s) not in the compiled graph (advisory, will not execute until recompiled): ${uncompiledStages.join(", ")}${uncompiledHint}`,
|
||
});
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Orphan stage files: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// Scope validation — run validateScope over all 11 scopes, tally errors
|
||
// and advisories. Repo-level setup check, not workflow-state.
|
||
try {
|
||
const scopes = [...validScopes()];
|
||
let totalErrors = 0;
|
||
let totalAdvisories = 0;
|
||
const failingScopes: { scope: string; errors: string[] }[] = [];
|
||
for (const scope of scopes) {
|
||
const r = validateScope(scope);
|
||
totalAdvisories += r.advisories.length;
|
||
if (r.errors.length > 0) {
|
||
totalErrors += r.errors.length;
|
||
failingScopes.push({ scope, errors: r.errors });
|
||
}
|
||
}
|
||
results.push({
|
||
pass: totalErrors === 0,
|
||
label: totalErrors === 0
|
||
? `Scope validation: ${scopes.length} scopes valid (${totalAdvisories} advisories)`
|
||
: `Scope validation: ${failingScopes.length} of ${scopes.length} scopes have errors`,
|
||
fix: totalErrors > 0
|
||
? failingScopes.map((f) => `${f.scope}: ${f.errors.join("; ")}`).join(" | ")
|
||
: undefined,
|
||
});
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Scope validation: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// Schema validation — parse + validate every stage's YAML frontmatter.
|
||
// Uses the same library functions every other caller does; drift impossible.
|
||
// Tracks attempted vs valid separately so the label can't silently say
|
||
// "N/N valid" when files are missing (that's the orphan-files check's job).
|
||
try {
|
||
const stagesDir = resolveHarnessPath(["aidlc-common", "stages"]);
|
||
const graph = loadStageGraphAll();
|
||
const agentSlugs = loadAgents().map((a) => a.slug);
|
||
const schemaFails: { slug: string; errors: string[] }[] = [];
|
||
let attempted = 0;
|
||
for (const stage of graph) {
|
||
const filePath = join(stagesDir, stage.phase, `${stage.slug}.md`);
|
||
if (!existsSync(filePath)) continue; // orphan-files check handles this
|
||
attempted++;
|
||
const raw = readFileSync(filePath, "utf-8");
|
||
try {
|
||
const parsed = parseStageFrontmatter(raw);
|
||
// Initialization stages lead with the orchestrator (SKILL.md itself),
|
||
// not a .claude/agents/ file — skip agent cross-reference there.
|
||
// Matches t65's convention. This phase-based skip agrees with the
|
||
// compile guard's RESERVED_AGENT_SLUG exemption on the shipped graph
|
||
// (the 3 orchestrator-led stages ARE the 3 initialization stages);
|
||
// the compile guard is slug-precise, this is phase-coarse — both
|
||
// correct for their purpose.
|
||
const ctx = stage.phase === "initialization" ? undefined : { agents: agentSlugs };
|
||
const vr = validateStageFrontmatter(parsed, ctx);
|
||
if (!vr.valid) schemaFails.push({ slug: stage.slug, errors: vr.errors });
|
||
} catch (parseErr) {
|
||
schemaFails.push({ slug: stage.slug, errors: [errorMessage(parseErr)] });
|
||
}
|
||
}
|
||
const valid = attempted - schemaFails.length;
|
||
results.push({
|
||
pass: schemaFails.length === 0,
|
||
label: schemaFails.length === 0
|
||
? `Schema validation: ${valid}/${attempted} stages validated`
|
||
: `Schema validation: ${schemaFails.length} of ${attempted} stage(s) failed`,
|
||
fix: schemaFails.length > 0
|
||
? schemaFails.map((f) => `${f.slug}: ${f.errors[0]}`).join("; ")
|
||
: undefined,
|
||
});
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Schema validation: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// Graph references — every consumes[].artifact and requires_stage[] slug
|
||
// must resolve to something real. Catches typos that pure schema-lint
|
||
// and scope-walk both miss.
|
||
try {
|
||
const graph = loadStageGraphAll();
|
||
const allSlugs = new Set(graph.map((s) => s.slug));
|
||
const allArtifacts = artifactsRegistryFor(graph as unknown as readonly GraphStage[]);
|
||
const refFails: string[] = [];
|
||
for (const stage of graph) {
|
||
for (const c of stage.consumes ?? []) {
|
||
if (!allArtifacts.has(c.artifact)) {
|
||
refFails.push(`${stage.slug}: consumes unknown artifact "${c.artifact}"`);
|
||
}
|
||
}
|
||
for (const r of stage.requires_stage ?? []) {
|
||
if (!allSlugs.has(r)) {
|
||
refFails.push(`${stage.slug}: requires_stage unknown slug "${r}"`);
|
||
}
|
||
}
|
||
}
|
||
results.push({
|
||
pass: refFails.length === 0,
|
||
label: refFails.length === 0
|
||
? `Graph references: ${allArtifacts.size} artifacts + edges resolved`
|
||
: `Graph references: ${refFails.length} broken reference(s)`,
|
||
fix: refFails.length > 0 ? refFails.join("; ") : undefined,
|
||
});
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Graph references: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// Advisory only: runtime resolves producersOf(artifact)[0], so duplicate
|
||
// producers are deterministic but ambiguous rather than an immediate setup
|
||
// failure. Keep all actionable detail in the label because passing rows do
|
||
// not render their `fix` field.
|
||
try {
|
||
const collisions = consumedArtifactProducerCollisions();
|
||
results.push({
|
||
pass: true,
|
||
label: collisions.length === 0
|
||
? "Duplicate producers: every consumed artifact has a single producer"
|
||
: `Duplicate producers: ${collisions.length} consumed artifact(s) with multiple producers (advisory); runtime resolves the first by load order: ${collisions.map(({ artifact, producers }) => `"${artifact}" <- [${producers.join(", ")}]`).join("; ")} - re-run \`bun ${harnessDir()}/tools/aidlc-graph.ts compile\``,
|
||
});
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Duplicate producers: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// Keyword overlap — no keyword should be claimed by >1 scope. A conflict
|
||
// means /aidlc "<freeform>" has ambiguous scope routing, which silently
|
||
// burns artifacts. findScopeByKeyword (exported from this file) resolves
|
||
// the other direction; this check inverts it to scan for collisions.
|
||
try {
|
||
const keywordToScopes = new Map<string, string[]>();
|
||
const mapping = loadScopeMapping();
|
||
for (const [scope, def] of Object.entries(mapping)) {
|
||
for (const kw of def.keywords ?? []) {
|
||
const list = keywordToScopes.get(kw) ?? [];
|
||
list.push(scope);
|
||
keywordToScopes.set(kw, list);
|
||
}
|
||
}
|
||
const conflicts = [...keywordToScopes.entries()].filter(
|
||
([, scopes]) => scopes.length > 1
|
||
);
|
||
results.push({
|
||
pass: conflicts.length === 0,
|
||
label: conflicts.length === 0
|
||
? "Keyword overlap: no conflicts"
|
||
: `Keyword overlap: ${conflicts.length} conflict(s)`,
|
||
fix: conflicts.length > 0
|
||
? conflicts
|
||
.map(([kw, scopes]) => `"${kw}" claimed by ${scopes.join(", ")}`)
|
||
.join("; ")
|
||
: undefined,
|
||
});
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Keyword overlap: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// Rule drift (advisory, always pass:true) — surface team/project rule files
|
||
// whose `##` headings overlap a POPULATED heading in the org layer
|
||
// (aidlc/spaces/default/memory/org.md), quoting the org sentence inline so
|
||
// the orchestrator-LLM can review for contradiction at observation time. A
|
||
// learning is a practice (vision §6) — it lands in team.md / project.md, so
|
||
// those two scopes are the whole team/project surface the walk reads.
|
||
//
|
||
// Three-concerns seam (T2): doctor is a deterministic tool — it detects
|
||
// same-heading structural overlap (byte-reproducible), NOT semantic
|
||
// contradiction. The contradiction VERDICT is the orchestrator-LLM's at
|
||
// observation time, non-blocking. The row never fails the health check.
|
||
//
|
||
// Read seam: heading bodies come from loadRules().headings (surfaced from
|
||
// the same `raw` loadRules reads under rulesDir(), honouring
|
||
// AIDLC_RULES_DIR), never a second read from the relative .path.
|
||
try {
|
||
const rules = loadRules();
|
||
const org = rules.find(
|
||
(r) => r.scope === "org" && r.path.endsWith("org.md")
|
||
);
|
||
if (!org) {
|
||
results.push({
|
||
pass: true,
|
||
label: "Rule drift: org rules absent (informational)",
|
||
});
|
||
} else {
|
||
// Populated org headings only — multi-line-comment-only headings
|
||
// (e.g. ## Corrections) read as empty and are excluded.
|
||
const orgPopulated = new Map<string, string>();
|
||
for (const [h, text] of org.headings) {
|
||
if (text.trim() !== "") orgPopulated.set(h, text);
|
||
}
|
||
const drifts: Array<{ file: string; heading: string; orgSentence: string }> = [];
|
||
const staleSuppressed: Array<{ file: string; heading: string; orgSentence: string }> = [];
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
for (const rule of rules) {
|
||
if (rule.scope !== "team" && rule.scope !== "project") continue;
|
||
const stale = isRuleStale(rule.frontmatter, today);
|
||
for (const [h, text] of rule.headings) {
|
||
if (text.trim() === "") continue;
|
||
const orgText = orgPopulated.get(h);
|
||
if (orgText === undefined) continue;
|
||
// First sentence of the org body under that heading, quoted
|
||
// verbatim. Split on the first sentence terminator; fall back to
|
||
// the whole first non-empty line when none is present.
|
||
const firstLine = orgText.split("\n")[0] ?? orgText;
|
||
const sentenceMatch = firstLine.match(/^.*?[.!?](?=\s|$)/);
|
||
const orgSentence = (sentenceMatch ? sentenceMatch[0] : firstLine).trim();
|
||
const overlap = { file: rule.path, heading: h, orgSentence };
|
||
if (stale) {
|
||
staleSuppressed.push(overlap);
|
||
} else {
|
||
drifts.push(overlap);
|
||
}
|
||
}
|
||
}
|
||
if (drifts.length === 0) {
|
||
results.push({
|
||
pass: true,
|
||
label: "Rule drift: no team/project rule overlaps org policy",
|
||
});
|
||
} else {
|
||
const detail = drifts
|
||
.map((d) => `${d.file} ## ${d.heading} <-> org "${d.orgSentence}"`)
|
||
.join("; ");
|
||
results.push({
|
||
pass: true,
|
||
label: `Rule drift: ${drifts.length} team/project rule(s) overlap org policy (review for contradiction): ${detail}`,
|
||
});
|
||
}
|
||
if (staleSuppressed.length > 0) {
|
||
const detail = staleSuppressed
|
||
.map((d) => `${d.file} ## ${d.heading} ⇄ org "${d.orgSentence}"`)
|
||
.join("; ");
|
||
results.push({
|
||
pass: true,
|
||
label: `Rule drift: ${staleSuppressed.length} stale-suppressed: ${detail}`,
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Rule drift: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// Paired sensor coverage (advisory, always pass:true) — for each rule
|
||
// carrying frontmatter.pairing, confirm the named sensor exists in some
|
||
// stage's resolved sensor set. File-existence check only (structural):
|
||
// it confirms the binding resolves, NOT that the sensor semantically
|
||
// fits the rule. feedforward-only rules never need a sensor.
|
||
//
|
||
// Read seams: pairing via loadRules().frontmatter (it is NOT on the
|
||
// graph node); sensor ids via loadGraph() -> sensors_applicable[].id.
|
||
// Manifest ids are bare ("required-sections"); a rule's pairing value is
|
||
// aidlc-prefixed — strip "aidlc-" before matching (milestone-7b-frozen join).
|
||
//
|
||
// Emits GUARDRAIL_LOADED once per doctor run — but ONLY when an audit trail
|
||
// already exists (cold-safe, see auditExists below); appendAuditEntry
|
||
// self-creates the audit shard/dir, so an unconditional emit on a pristine
|
||
// project would create a record as a side effect, making --doctor NOT
|
||
// read-only. Doctor runs on a fresh checkout before any workflow is created, so
|
||
// it must create nothing. On a project with a created intent the emit fires
|
||
// exactly as before (BARE appendAuditEvent — the only throw is a real write
|
||
// failure, which the rest of the codebase lets propagate).
|
||
let pairedRuleCount: number | null = null;
|
||
try {
|
||
const pairedRules = loadRules();
|
||
pairedRuleCount = pairedRules.length;
|
||
// sensors_applicable is REQUIRED on a compiled graph node, but a
|
||
// hand-rolled or pre-milestone-9 graph JSON can omit it; `?? []` keeps this
|
||
// advisory row from crashing doctor on a malformed/legacy graph (the
|
||
// same defensive posture the cycle/orphan/scope checks take above).
|
||
const sensorIds = new Set(
|
||
loadGraph().flatMap((n) => (n.sensors_applicable ?? []).map((s) => s.id))
|
||
);
|
||
let pairM = 0;
|
||
let pairX = 0;
|
||
let pairP = 0;
|
||
// unpaired holds the U set (sensor id named but absent anywhere);
|
||
// unpaired.length is U, so no separate counter is needed.
|
||
const unpaired: Array<{ file: string; sensor: string }> = [];
|
||
for (const rule of pairedRules) {
|
||
const pairing = rule.frontmatter.pairing;
|
||
if (pairing === undefined) continue;
|
||
pairM++;
|
||
if (pairing === "feedforward-only") {
|
||
pairX++;
|
||
continue;
|
||
}
|
||
const bareId = pairing.replace(/^aidlc-/, "");
|
||
if (sensorIds.has(bareId)) {
|
||
pairP++;
|
||
} else {
|
||
unpaired.push({ file: rule.path, sensor: pairing });
|
||
}
|
||
}
|
||
const needing = pairM - pairX;
|
||
let coverageLabel: string;
|
||
if (needing === 0) {
|
||
coverageLabel = `Paired sensor coverage: no sensor-bound rules (${pairX} feedforward-only)`;
|
||
} else {
|
||
coverageLabel = `Paired sensor coverage: ${pairP}/${needing} guardrails paired (${pairX} feedforward-only)`;
|
||
}
|
||
if (unpaired.length > 0) {
|
||
const unpairedDetail = unpaired
|
||
.map((u) => `unpaired: ${u.file} -> ${u.sensor} (no stage binds it)`)
|
||
.join("; ");
|
||
coverageLabel = `${coverageLabel}; ${unpairedDetail}`;
|
||
}
|
||
results.push({ pass: true, label: coverageLabel });
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Paired sensor coverage: check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Check 7 — Intent registry ⇄ record-dir reconciliation
|
||
//
|
||
// The record dir name is the join key between a registry row and its on-disk
|
||
// dir; a HAND-RENAME of the dir (e.g. in a file tree) breaks that pairing in
|
||
// two directions, both of which listIntents() already surfaces:
|
||
// (a) a registry row whose stored dirName no longer resolves on disk
|
||
// (listIntents → dirName: null) — the intent's status/repos detach,
|
||
// and in a multi-intent space its cursor can no longer resolve it.
|
||
// (b) a record dir on disk with no registry row (listIntents → an orphan
|
||
// row with empty uuid + status "unknown").
|
||
// Advisory (pass=true): a rename is a user action, not a framework fault, and
|
||
// the lone-intent fallback keeps a single renamed intent working. The fix
|
||
// names the editable repair: set the row's `dirName` (or rename the dir back).
|
||
// Runs across EVERY space so a rename in a non-active space is still surfaced.
|
||
// ---------------------------------------------------------------------------
|
||
try {
|
||
const danglingRows: string[] = []; // registry rows whose dir vanished
|
||
const orphanDirs: string[] = []; // on-disk dirs with no registry row
|
||
for (const sp of listSpaces(projectDir)) {
|
||
for (const i of listIntents(projectDir, sp.name)) {
|
||
if (i.uuid !== "" && i.dirName === null) {
|
||
danglingRows.push(`${sp.name}/${i.slug} (uuid ${i.uuid.slice(0, 8)}...)`);
|
||
} else if (i.uuid === "" && i.status === "unknown") {
|
||
orphanDirs.push(`${sp.name}/${i.dirName}`);
|
||
}
|
||
}
|
||
}
|
||
const total = danglingRows.length + orphanDirs.length;
|
||
if (total === 0) {
|
||
results.push({ pass: true, label: "Intent registry: all rows match their record dirs" });
|
||
} else {
|
||
const detail = [
|
||
danglingRows.length > 0 ? `${danglingRows.length} row(s) with a missing dir [${danglingRows.join(", ")}]` : "",
|
||
orphanDirs.length > 0 ? `${orphanDirs.length} dir(s) with no row [${orphanDirs.join(", ")}]` : "",
|
||
].filter(Boolean).join("; ");
|
||
results.push({
|
||
pass: true,
|
||
label: `Intent registry: ${total} record-dir mismatch (advisory - likely a hand-renamed intent dir): ${detail}. Fix: set the row's \`dirName\` in the space's intents.json to the on-disk dir name, or rename the dir back.`,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
results.push({
|
||
pass: false,
|
||
label: "Intent registry: reconciliation check failed",
|
||
fix: errorMessage(e),
|
||
});
|
||
}
|
||
|
||
try {
|
||
const shadows: string[] = [];
|
||
for (const sp of listSpaces(projectDir)) {
|
||
if (RESERVED_RECORD_NAMES.has(sp.name)) shadows.push(`space '${sp.name}'`);
|
||
}
|
||
const active = activeSpace(projectDir);
|
||
for (const intent of listIntents(projectDir, active)) {
|
||
if (RESERVED_RECORD_NAMES.has(intent.slug)) shadows.push(`intent '${intent.slug}'`);
|
||
}
|
||
if (shadows.length > 0) {
|
||
results.push({
|
||
pass: true,
|
||
label: `Workspace names shadowing grammar verbs (advisory): ${shadows.join(", ")} - reachable via explicit switch; consider renaming.`,
|
||
});
|
||
}
|
||
} catch {
|
||
// Advisory only; a scan failure must not hide the main doctor report.
|
||
}
|
||
|
||
// Workspace-manifest rows (W1: uncommitted records; W2: repos.json vs disk
|
||
// drift; W3: stale managed .gitignore block). All advisory (pass:true) so
|
||
// they never change the exit code; W2/W3 only emit when a repos.json manifest
|
||
// exists, avoiding manifest-specific rows on a single-repo install.
|
||
try {
|
||
for (const row of workspaceManifestChecks(projectDir)) results.push(row);
|
||
} catch {
|
||
// Advisory only; a scan failure must not hide the main doctor report.
|
||
}
|
||
|
||
results.push(...extraChecks);
|
||
const reportResults = collapseLegacyPolicyChecks(results);
|
||
|
||
// Cold-safe gate: only emit audit when an audit trail already exists. On a
|
||
// pristine project (no audit shard / flat audit.md) doctor prints its health
|
||
// report and creates NOTHING — it stays a pure read-only diagnostic. On an
|
||
// initialized project both GUARDRAIL_LOADED and HEALTH_CHECKED emit as before.
|
||
const auditExists = auditShards(projectDir).length > 0;
|
||
|
||
if (auditExists && pairedRuleCount !== null) {
|
||
appendAuditEvent(projectDir, "GUARDRAIL_LOADED", {
|
||
Scope: "all",
|
||
Path: `${harnessDir()}/${rulesSubdir()}/`,
|
||
"Rule count": String(pairedRuleCount),
|
||
});
|
||
}
|
||
|
||
let passed = 0;
|
||
let warnings = 0;
|
||
let failed = 0;
|
||
for (const r of reportResults) {
|
||
if (r.severity === "warn") {
|
||
warnings++;
|
||
} else if (r.pass) {
|
||
passed++;
|
||
} else {
|
||
failed++;
|
||
}
|
||
}
|
||
|
||
// Audit only if audit.md already existed when doctor started (cold-safe —
|
||
// see auditExists above). A pristine project gets the stdout report and no
|
||
// file side effects; an initialized project records HEALTH_CHECKED as before.
|
||
if (auditExists) {
|
||
appendAuditEvent(projectDir, "HEALTH_CHECKED", {
|
||
Request: `/aidlc --doctor`,
|
||
Details: `${passed} passed, ${failed} failed`,
|
||
});
|
||
}
|
||
|
||
return { checks: reportResults, passed, warnings, failed };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// init (scaffold 0.2) — bootstrap state/audit files + scaffold aidlc-docs/
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Agent knowledge metadata (display name + example files) is now derived
|
||
// from `.claude/agents/*.md` frontmatter via loadAgents() in lib.ts.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Deterministic workspace scanner
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface SubmoduleEntry {
|
||
name: string;
|
||
path: string; // as written in .gitmodules (validated relative)
|
||
url: string; // "" when absent
|
||
initialized: boolean; // existsSync(join(projectDir, path, ".git"))
|
||
}
|
||
|
||
interface ScanResult {
|
||
projectType: string; // "Greenfield" | "Brownfield"
|
||
languages: string; // e.g. "TypeScript, JavaScript"
|
||
frameworks: string; // e.g. "React, Vite"
|
||
buildSystem: string; // e.g. "npm (package.json)"
|
||
// Comma-joined workspace-relative directory path(s) the nested-project
|
||
// fallback classified Brownfield from. Absent when the root itself decided
|
||
// the verdict (the common case). Surfaced only in the WORKSPACE_SCANNED audit
|
||
// event and the `detect --json` payload, never in the state file.
|
||
nestedRoot?: string;
|
||
submodules: SubmoduleEntry[]; // [] when no .gitmodules / none parseable
|
||
}
|
||
|
||
// The remedy naming the git command that fetches uninitialized submodules.
|
||
// Shared by every warning surface so the wording never drifts.
|
||
const SUBMODULE_INIT_REMEDY = "git submodule update --init --recursive";
|
||
|
||
// Enumerate submodule paths for a warning string: at most 5, then "(+N more)".
|
||
// Returns the bare comma-joined list (no parens) so each surface wraps it as
|
||
// it needs. Caps the enumerated set to keep audit/stdout lines bounded.
|
||
function enumerateSubmodulePaths(entries: SubmoduleEntry[]): string {
|
||
const paths = entries.map((e) => e.path);
|
||
if (paths.length <= 5) return paths.join(", ");
|
||
return `${paths.slice(0, 5).join(", ")} (+${paths.length - 5} more)`;
|
||
}
|
||
|
||
const LANG_BY_EXT: Record<string, string> = {
|
||
".ts": "TypeScript",
|
||
".tsx": "TypeScript",
|
||
".js": "JavaScript",
|
||
".jsx": "JavaScript",
|
||
".mjs": "JavaScript",
|
||
".cjs": "JavaScript",
|
||
".py": "Python",
|
||
".java": "Java",
|
||
".kt": "Kotlin",
|
||
".go": "Go",
|
||
".rs": "Rust",
|
||
".rb": "Ruby",
|
||
".cs": "C#",
|
||
".cpp": "C++",
|
||
".c": "C",
|
||
".h": "C",
|
||
".hpp": "C++",
|
||
".swift": "Swift",
|
||
".php": "PHP",
|
||
};
|
||
|
||
const SCAN_SOURCE_DIRS = ["src", "app", "lib", "pages", "components", "tests"];
|
||
// Set view for the sweep-side skip in scanSignals: the depth-6 recurse there
|
||
// is the SOLE counter for these dirs, so the file sweep must never enter them.
|
||
const SCAN_SOURCE_DIR_SET: ReadonlySet<string> = new Set(SCAN_SOURCE_DIRS);
|
||
const SCAN_EXCLUDE = new Set([
|
||
".claude",
|
||
".kiro",
|
||
".codex",
|
||
".opencode",
|
||
".aidlc",
|
||
".cursor",
|
||
"aidlc-docs",
|
||
"node_modules",
|
||
".git",
|
||
"dist",
|
||
"build",
|
||
".next",
|
||
"target",
|
||
"vendor",
|
||
]);
|
||
|
||
// Package/build manifests that mark a directory as an application (not just
|
||
// scaffolding). Shared by the root scan and the nested-project fallback.
|
||
const SOURCE_MANIFESTS = [
|
||
"requirements.txt",
|
||
"pyproject.toml",
|
||
"setup.py",
|
||
"Cargo.toml",
|
||
"go.mod",
|
||
"pom.xml",
|
||
"build.gradle",
|
||
"build.gradle.kts",
|
||
"composer.json",
|
||
"Gemfile",
|
||
];
|
||
|
||
// Directory names the nested-project fallback never descends into at any
|
||
// container level: they commonly hold sample/snippet/boilerplate code that is
|
||
// not the project's own source. The harness/VCS/build dirs in SCAN_EXCLUDE and
|
||
// SCAN_SOURCE_DIRS are skipped separately. Lowercased for a case-insensitive
|
||
// match.
|
||
const NESTED_SCAN_EXCLUDE = new Set([
|
||
"aidlc",
|
||
"docs",
|
||
"doc",
|
||
"examples",
|
||
"example",
|
||
"samples",
|
||
"sample",
|
||
"demos",
|
||
"demo",
|
||
"reference",
|
||
"testdata",
|
||
"fixtures",
|
||
"templates",
|
||
"scripts",
|
||
]);
|
||
|
||
const NESTED_SCAN_MAX_DEPTH = 3;
|
||
const SCAN_EXCLUDE_LOWER = new Set(
|
||
[...SCAN_EXCLUDE].map((entry) => entry.toLowerCase())
|
||
);
|
||
|
||
function skipNestedScanDir(entry: string): boolean {
|
||
const lower = entry.toLowerCase();
|
||
return (
|
||
entry.startsWith(".") ||
|
||
SCAN_EXCLUDE_LOWER.has(lower) ||
|
||
NESTED_SCAN_EXCLUDE.has(lower) ||
|
||
SCAN_SOURCE_DIR_SET.has(entry)
|
||
);
|
||
}
|
||
|
||
// skipDirs: directory names to skip at THIS level only (not propagated into
|
||
// the recursion); the caller counts those dirs through a separate deeper call.
|
||
function countFilesByLang(
|
||
dir: string,
|
||
counts: Record<string, number>,
|
||
maxDepth: number,
|
||
skipDirs?: ReadonlySet<string>
|
||
): void {
|
||
if (maxDepth < 0) return;
|
||
let entries: string[];
|
||
try {
|
||
entries = readdirSync(dir);
|
||
} catch {
|
||
return;
|
||
}
|
||
for (const entry of entries) {
|
||
if (SCAN_EXCLUDE.has(entry)) continue;
|
||
const full = join(dir, entry);
|
||
let st: import("node:fs").Stats;
|
||
try {
|
||
st = lstatSync(full);
|
||
} catch {
|
||
continue;
|
||
}
|
||
// Don't follow symlinks — cycle protection.
|
||
if (st.isSymbolicLink()) continue;
|
||
if (st.isDirectory()) {
|
||
if (skipDirs?.has(entry)) continue;
|
||
countFilesByLang(full, counts, maxDepth - 1);
|
||
} else if (st.isFile()) {
|
||
const dot = entry.lastIndexOf(".");
|
||
if (dot > 0) {
|
||
const ext = entry.slice(dot).toLowerCase();
|
||
const lang = LANG_BY_EXT[ext];
|
||
if (lang) counts[lang] = (counts[lang] || 0) + 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function detectFrameworks(topEntries: Set<string>, projectDir: string): string[] {
|
||
const fws: string[] = [];
|
||
const has = (name: string) => topEntries.has(name);
|
||
|
||
if (["next.config.js", "next.config.ts", "next.config.mjs", "next.config.cjs"].some(has))
|
||
fws.push("Next.js");
|
||
if (["vite.config.js", "vite.config.ts", "vite.config.mjs"].some(has))
|
||
fws.push("Vite");
|
||
if (has("angular.json")) fws.push("Angular");
|
||
if (["nuxt.config.js", "nuxt.config.ts"].some(has)) fws.push("Nuxt");
|
||
if (has("remix.config.js")) fws.push("Remix");
|
||
if (has("gatsby-config.js")) fws.push("Gatsby");
|
||
if (["astro.config.mjs", "astro.config.js", "astro.config.ts"].some(has))
|
||
fws.push("Astro");
|
||
if (has("svelte.config.js")) fws.push("Svelte");
|
||
if (has("nest-cli.json")) fws.push("NestJS");
|
||
|
||
// React surfaces via package.json dependencies/peerDependencies
|
||
if (has("package.json")) {
|
||
try {
|
||
const raw: unknown = JSON.parse(
|
||
readFileSync(join(projectDir, "package.json"), "utf-8")
|
||
);
|
||
if (isPackageJson(raw)) {
|
||
const deps = {
|
||
...(raw.dependencies ?? {}),
|
||
...(raw.peerDependencies ?? {}),
|
||
};
|
||
if (deps.react && !fws.includes("React")) fws.push("React");
|
||
}
|
||
} catch {
|
||
// ignore parse errors
|
||
}
|
||
}
|
||
|
||
if (has("manage.py")) fws.push("Django");
|
||
|
||
if (has("Gemfile")) {
|
||
try {
|
||
const gemfile = readFileSync(join(projectDir, "Gemfile"), "utf-8");
|
||
if (/^[^#]*\brails\b/m.test(gemfile)) fws.push("Rails");
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
if (has("pom.xml")) {
|
||
try {
|
||
const pom = readFileSync(join(projectDir, "pom.xml"), "utf-8");
|
||
if (/spring-boot/.test(pom)) fws.push("Spring Boot");
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
return fws;
|
||
}
|
||
|
||
function detectBuildSystem(topEntries: Set<string>, projectDir: string): string {
|
||
if (topEntries.has("package.json")) {
|
||
if (topEntries.has("pnpm-lock.yaml")) return "pnpm (package.json)";
|
||
if (topEntries.has("yarn.lock")) return "yarn (package.json)";
|
||
if (topEntries.has("bun.lockb") || topEntries.has("bun.lock"))
|
||
return "bun (package.json)";
|
||
return "npm (package.json)";
|
||
}
|
||
if (topEntries.has("pyproject.toml")) {
|
||
try {
|
||
const pp = readFileSync(join(projectDir, "pyproject.toml"), "utf-8");
|
||
if (/\[tool\.poetry\]/.test(pp)) return "poetry (pyproject.toml)";
|
||
if (/\[tool\.uv\]/.test(pp)) return "uv (pyproject.toml)";
|
||
if (/\[tool\.hatch\]/.test(pp)) return "hatch (pyproject.toml)";
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return "python (pyproject.toml)";
|
||
}
|
||
if (topEntries.has("requirements.txt")) return "pip (requirements.txt)";
|
||
if (topEntries.has("setup.py")) return "setuptools (setup.py)";
|
||
if (topEntries.has("Cargo.toml")) return "cargo (Cargo.toml)";
|
||
if (topEntries.has("go.mod")) return "go modules (go.mod)";
|
||
if (topEntries.has("pom.xml")) return "maven (pom.xml)";
|
||
if (topEntries.has("build.gradle") || topEntries.has("build.gradle.kts"))
|
||
return "gradle (build.gradle)";
|
||
if (topEntries.has("composer.json")) return "composer (composer.json)";
|
||
if (topEntries.has("Gemfile")) return "bundler (Gemfile)";
|
||
return "Unknown";
|
||
}
|
||
|
||
function hasNonDevDeps(projectDir: string): boolean {
|
||
try {
|
||
const raw: unknown = JSON.parse(
|
||
readFileSync(join(projectDir, "package.json"), "utf-8")
|
||
);
|
||
if (!isPackageJson(raw)) return false;
|
||
const deps = raw.dependencies ?? {};
|
||
// peerDependencies declare what a consumer must provide, not what this
|
||
// project needs at runtime — exclude from the brownfield signal.
|
||
return Object.keys(deps).length > 0;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// The signal evaluation for a single directory, used for both the workspace
|
||
// root and each directory visited by the nested-project fallback. Returns the
|
||
// raw brownfield signal plus the findings so the caller can aggregate.
|
||
//
|
||
// fileScanDepth = the countFilesByLang depth for the SOURCE-FILE signal:
|
||
// - root: 0 for the top-level file sweep (files directly under dir; the
|
||
// inline top-level loop the base code ran is equivalent to
|
||
// countFilesByLang(dir, counts, 0), same SCAN_EXCLUDE filter + symlink
|
||
// skip, files only) PLUS a depth-6 recurse into each present
|
||
// SCAN_SOURCE_DIRS entry.
|
||
// - a nested container: 0, sweeping files directly under the visited
|
||
// directory. Arbitrary child containers are evaluated independently by
|
||
// the bounded walker. A present SCAN_SOURCE_DIRS entry is counted only by
|
||
// the depth-6 recurse below, so files are never counted twice.
|
||
interface DirSignals {
|
||
brownfield: boolean;
|
||
langCounts: Record<string, number>;
|
||
frameworks: string[];
|
||
buildSystem: string;
|
||
}
|
||
|
||
function scanSignals(dir: string, fileScanDepth: number): DirSignals {
|
||
let entries: string[] = [];
|
||
try {
|
||
entries = readdirSync(dir);
|
||
} catch {
|
||
// dir doesn't exist yet (caller should scaffold first)
|
||
}
|
||
const entrySet = new Set(entries.filter((e) => !SCAN_EXCLUDE.has(e)));
|
||
|
||
// Source-file count. countFilesByLang(dir, counts, 0) counts files directly
|
||
// under dir (its recursion guard returns immediately at depth -1), matching
|
||
// the base top-level file sweep. Any present known source dir is then
|
||
// recursed at the base depth cap. The sweep itself never enters a
|
||
// SCAN_SOURCE_DIRS entry, which the depth-6 recurse below counts separately.
|
||
const langCounts: Record<string, number> = {};
|
||
countFilesByLang(dir, langCounts, fileScanDepth, SCAN_SOURCE_DIR_SET);
|
||
for (const dirName of SCAN_SOURCE_DIRS) {
|
||
if (entrySet.has(dirName)) {
|
||
countFilesByLang(join(dir, dirName), langCounts, 6);
|
||
}
|
||
}
|
||
|
||
const frameworks = detectFrameworks(entrySet, dir);
|
||
const buildSystem = detectBuildSystem(entrySet, dir);
|
||
|
||
// Classification signals (mirror workspace-detection.md Step 3).
|
||
const hasSourceFiles = Object.keys(langCounts).length > 0;
|
||
const hasFrameworkConfig = frameworks.length > 0;
|
||
const hasNonDev = entrySet.has("package.json") && hasNonDevDeps(dir);
|
||
const hasOtherManifest = SOURCE_MANIFESTS.some((m) => entrySet.has(m));
|
||
const hasAppSourceDir = SCAN_SOURCE_DIRS.some((d) => entrySet.has(d));
|
||
|
||
return {
|
||
brownfield:
|
||
hasSourceFiles ||
|
||
hasFrameworkConfig ||
|
||
hasNonDev ||
|
||
hasOtherManifest ||
|
||
hasAppSourceDir,
|
||
langCounts,
|
||
frameworks,
|
||
buildSystem,
|
||
};
|
||
}
|
||
|
||
// Parse .gitmodules (ini-like) into submodule entries. Pure and exported for
|
||
// direct unit testing. Line-oriented, tolerant: malformed content degrades to
|
||
// whatever parses (total garbage yields []); it never throws. An entry with no
|
||
// path is dropped, as is any path that is absolute or escapes the project via a
|
||
// `..` segment (a caller joins path under projectDir and must not follow it out).
|
||
export function parseGitmodules(
|
||
content: string
|
||
): Array<{ name: string; path: string; url: string }> {
|
||
const entries: Array<{ name: string; path: string; url: string }> = [];
|
||
let current: { name: string; path: string; url: string } | null = null;
|
||
const finish = () => {
|
||
if (!current) return;
|
||
const p = current.path;
|
||
const isUnsafe =
|
||
p === "" ||
|
||
p.startsWith("/") ||
|
||
/^[A-Za-z]:[\\/]/.test(p) || // Windows drive-absolute
|
||
p.split(/[/\\]/).includes("..");
|
||
if (!isUnsafe) entries.push(current);
|
||
current = null;
|
||
};
|
||
for (const rawLine of content.split(/\r?\n/)) {
|
||
const line = rawLine.trim();
|
||
if (line === "" || line.startsWith("#") || line.startsWith(";")) continue;
|
||
if (line.startsWith("[")) {
|
||
finish();
|
||
const m = line.match(/^\[submodule\s+"(.+)"\]$/);
|
||
current = m ? { name: m[1], path: "", url: "" } : null;
|
||
continue;
|
||
}
|
||
if (!current) continue;
|
||
const eq = line.indexOf("=");
|
||
if (eq < 0) continue;
|
||
const key = line.slice(0, eq).trim();
|
||
const value = line.slice(eq + 1).trim();
|
||
if (key === "path") current.path = value;
|
||
else if (key === "url") current.url = value;
|
||
}
|
||
finish();
|
||
return entries;
|
||
}
|
||
|
||
// Read + parse the workspace-root .gitmodules and probe each declared path for
|
||
// initialization. A missing/unreadable file yields [] (the swallow idiom of the
|
||
// scanner neighbors). `initialized` mirrors isGitRepoDir (aidlc-lib.ts): the dir
|
||
// must exist AND hold a `.git` entry - so a missing dir, an empty dir, and a dir
|
||
// without `.git` all classify uninitialized.
|
||
function scanSubmodules(projectDir: string): SubmoduleEntry[] {
|
||
let content: string;
|
||
try {
|
||
content = readFileSync(join(projectDir, ".gitmodules"), "utf-8");
|
||
} catch {
|
||
return [];
|
||
}
|
||
return parseGitmodules(content).map((e) => ({
|
||
...e,
|
||
initialized: existsSync(join(projectDir, e.path, ".git")),
|
||
}));
|
||
}
|
||
|
||
export function detectWorkspace(projectDir: string): ScanResult {
|
||
// Root scan (depth 0 for the top-level file sweep, byte-identical to the
|
||
// base inline loop plus the SCAN_SOURCE_DIRS recurse, both inside scanSignals).
|
||
const root = scanSignals(projectDir, 0);
|
||
const langCounts = { ...root.langCounts };
|
||
const frameworks = [...root.frameworks];
|
||
let buildSystem = root.buildSystem;
|
||
let brownfield = root.brownfield;
|
||
const nestedHits: string[] = [];
|
||
|
||
// Nested-project fallback: only when the root itself shows NO brownfield
|
||
// signal. Walk candidate container directories in sorted order, bounded to
|
||
// three levels below the workspace root. Each visited directory gets the
|
||
// same nested signal evaluation; a Brownfield hit is aggregated once and is
|
||
// not descended into, preventing language counts from overlapping. Dot dirs,
|
||
// excluded names, known source dirs, symlinks, and non-dirs are never visited.
|
||
if (!brownfield) {
|
||
const walkContainers = (
|
||
parentDir: string,
|
||
parentParts: string[],
|
||
parentDepth: number
|
||
): void => {
|
||
let entries: string[];
|
||
try {
|
||
entries = readdirSync(parentDir).sort();
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
if (skipNestedScanDir(entry)) continue;
|
||
const full = join(parentDir, entry);
|
||
let st: import("node:fs").Stats;
|
||
try {
|
||
st = lstatSync(full);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (st.isSymbolicLink() || !st.isDirectory()) continue;
|
||
|
||
const parts = [...parentParts, entry];
|
||
const depth = parentDepth + 1;
|
||
const sub = scanSignals(full, 0);
|
||
if (sub.brownfield) {
|
||
brownfield = true;
|
||
nestedHits.push(parts.join("/"));
|
||
for (const [lang, n] of Object.entries(sub.langCounts)) {
|
||
langCounts[lang] = (langCounts[lang] || 0) + n;
|
||
}
|
||
for (const fw of sub.frameworks) {
|
||
if (!frameworks.includes(fw)) frameworks.push(fw);
|
||
}
|
||
if (buildSystem === "Unknown") buildSystem = sub.buildSystem;
|
||
continue;
|
||
}
|
||
|
||
if (depth < NESTED_SCAN_MAX_DEPTH) {
|
||
walkContainers(full, parts, depth);
|
||
}
|
||
}
|
||
};
|
||
|
||
walkContainers(projectDir, [], 0);
|
||
}
|
||
|
||
// Language list: primary = highest count; secondary = >= 20% of primary count.
|
||
const sortedLangs = Object.entries(langCounts).sort((a, b) => b[1] - a[1]);
|
||
let languages: string;
|
||
if (sortedLangs.length === 0) {
|
||
languages = "Unknown";
|
||
} else {
|
||
const primary = sortedLangs[0][0];
|
||
const primaryCount = sortedLangs[0][1];
|
||
const threshold = Math.max(1, Math.floor(primaryCount * 0.2));
|
||
const extras = sortedLangs
|
||
.slice(1)
|
||
.filter(([, c]) => c >= threshold)
|
||
.map(([l]) => l);
|
||
languages = [primary, ...extras].join(", ");
|
||
}
|
||
|
||
// Repo metadata: a .gitmodules with >= 1 valid submodule path declares code,
|
||
// even when the submodule dirs are empty/uninitialized. Languages stay AS
|
||
// SCANNED (Unknown is truthful until the submodules are fetched). A root
|
||
// signal: folded in after the nested fallback so nested aggregation (and
|
||
// nestedRoot attribution) still runs when submodules are the only signal.
|
||
const submodules = scanSubmodules(projectDir);
|
||
if (submodules.length > 0) brownfield = true;
|
||
|
||
const result: ScanResult = {
|
||
projectType: brownfield ? "Brownfield" : "Greenfield",
|
||
languages,
|
||
frameworks: frameworks.length > 0 ? frameworks.join(", ") : "Unknown",
|
||
buildSystem,
|
||
submodules,
|
||
};
|
||
if (nestedHits.length > 0) result.nestedRoot = nestedHits.join(", ");
|
||
return result;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// intent-create (0.1-0.3) — deterministic: mint intent + scan + state-init
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Deferred `git rm` of a migrated flat tree. migrateFlatLayout MOVED the data
|
||
// (staged copy → per-intent record) and left the original aidlc-docs/ in place
|
||
// for this untrack step (it never rmSync's the source). Best-effort: a non-git
|
||
// project, or a tree git doesn't track, is a clean no-op — `git rm -r --cached`
|
||
// untracks without touching the working tree, then we remove the now-moved
|
||
// directory from disk. Resolved decision (3): migration git-rm's the tracked
|
||
// flat aidlc-docs/ post-move.
|
||
function gitRmFlatTree(projectDir: string, flatTree: string): void {
|
||
try {
|
||
if (!existsSync(flatTree)) return;
|
||
// Untrack (cached only — the data already moved). Ignore failure (non-git
|
||
// project, or already untracked) — the rmSync below still tidies disk.
|
||
Bun.spawnSync(["git", "-C", projectDir, "rm", "-r", "--cached", "--quiet", "--", flatTree], {
|
||
stdout: "ignore",
|
||
stderr: "ignore",
|
||
});
|
||
// Remove the moved-from directory from the working tree (the data lives in
|
||
// the per-intent record now; this is the empty husk).
|
||
rmSync(flatTree, { recursive: true, force: true });
|
||
} catch {
|
||
// best-effort untrack; the migration itself already succeeded
|
||
}
|
||
}
|
||
|
||
// The phases a scope actually runs: those holding at least one EXECUTE stage.
|
||
// This is the SINGLE derivation behind two decisions that must never disagree:
|
||
// which per-phase dirs a new record gets (ensureWorkspaceDirs) and which phases
|
||
// report PHASE_SKIPPED at creation. Both read the compiled scope grid via
|
||
// stagesInScope, so the folders on disk and the audit trail always tell the same
|
||
// story, with no LLM input in the path. A phase whose stage set is empty under
|
||
// the enabled bundle (plugin selection can empty one) has nothing to write and
|
||
// is likewise out.
|
||
function phasesWithExecuteStages(scope: string): Set<string> {
|
||
const stages = stagesInScope(scope);
|
||
return new Set(
|
||
PHASES.filter((phase) =>
|
||
stages.some((s) => s.phase === phase && s.action === "EXECUTE")
|
||
)
|
||
);
|
||
}
|
||
|
||
// Ensure the dirs a workflow writes into exist. Idempotent ensure-exists (SEED
|
||
// ships the shell). Creates the active intent's record dir plus a per-phase
|
||
// artifact dir for each phase the SCOPE RUNS, plus the SPACE-level CodeKB
|
||
// parent and domain knowledge dir; all skipped if already present. The active
|
||
// intent cursor must be set before this runs.
|
||
//
|
||
// Scope-excluded phases get NO folder: an empty `operation/` in a bugfix record
|
||
// reads as work that was planned and skipped, when that phase was never in the
|
||
// plan. Nothing depends on the folder pre-existing: a stage artifact is written
|
||
// by the agent's own file tool, which creates its parent chain on first write,
|
||
// and every deterministic reader of a phase dir guards on existence. This only
|
||
// ever creates: an older record that already carries all five keeps them.
|
||
function ensureWorkspaceDirs(
|
||
projectDir: string,
|
||
scope: string,
|
||
intent: string,
|
||
space: string,
|
||
): void {
|
||
const record = docsDir(projectDir, intent, space);
|
||
mkdirSync(record, { recursive: true });
|
||
// Lazy per-phase artifact dirs, in-scope phases only (stages write reports here).
|
||
for (const phase of phasesWithExecuteStages(scope)) {
|
||
mkdirSync(join(record, phase), { recursive: true });
|
||
}
|
||
// verification/ is scope-independent: sensor and gate verification can land
|
||
// for any phase, so every record gets it.
|
||
mkdirSync(join(record, "verification"), { recursive: true });
|
||
// The shared CodeKB parent is safe to inspect before any repository has been
|
||
// analyzed. Per-repo stores remain lazy and appear only when RE writes them.
|
||
mkdirSync(dirname(codekbDir(projectDir, "_", space)), { recursive: true });
|
||
// SPACE-level domain knowledge dir (NOT per-intent): vision §"Spaces" makes
|
||
// knowledge a sibling of memory/codekb/intents under spaces/<space>/, so team
|
||
// domain knowledge accumulates across every intent in the space rather than
|
||
// being trapped in one intent's record. Free-form, empty at bootstrap. The
|
||
// engine's per-agent METHODOLOGY knowledge ships separately under
|
||
// <harness>/knowledge/ (untouched). Lazy ensure-exists — never SEED.
|
||
mkdirSync(knowledgeDir(projectDir, space), { recursive: true });
|
||
// Engine-only-install self-heal: recover an ENGINE-ONLY install. Normally the
|
||
// workspace shell (aidlc/spaces/default/memory/) ships as a SIBLING of the
|
||
// engine dir (the packager's emitMemory → MEMORY_DST), so a complete dist/
|
||
// copy already carries it and the lines below leave it untouched. But a user
|
||
// who copies ONLY the harness engine dir (e.g. dist/kiro/.kiro/) and NOT the
|
||
// sibling aidlc/ shell lands with NO default-space method tree → doctor's
|
||
// "workspace shell ready" check fails and the rule resolver loads zero rules.
|
||
// To recover, seed the default-space memory tree from the copy the packager
|
||
// bundled INSIDE the engine at tools/data/memory-seed/ (frameworkMemorySeedDir,
|
||
// mirroring the tools/data/templates pattern) — but ONLY if the default tree is
|
||
// ABSENT. The existsSync guard makes this strictly idempotent: a normal install
|
||
// that copied aidlc/ already has the dir, so the seed never fires and the
|
||
// committed default tree never churns (preserving the "default tree never
|
||
// churns" invariant). This is a deliberate, GUARDED exception to the
|
||
// "never SEED" rule the rest of this function follows.
|
||
const defaultMemory = memoryDirFor(projectDir, DEFAULT_SPACE);
|
||
if (!existsSync(defaultMemory)) {
|
||
const seed = frameworkMemorySeedDir();
|
||
if (existsSync(seed)) cpSync(seed, defaultMemory, { recursive: true });
|
||
}
|
||
// Align the harness-native includes with the active space at bootstrap (first
|
||
// /aidlc). A no-op when they already point there (the common default-cursor
|
||
// case) — so this never dirties a single-team committed tree; it self-heals a
|
||
// tree whose cursor and includes drifted out of sync.
|
||
repointHarnessIncludes(projectDir, activeSpace(projectDir));
|
||
}
|
||
|
||
function waitAtIntentCreateChangeControlSnapshotBarrier(): void {
|
||
const barrier =
|
||
process.env.AIDLC_TEST_INTENT_CREATE_CHANGE_CONTROL_BARRIER?.trim();
|
||
if (!barrier) return;
|
||
writeFileSync(`${barrier}.snapshotted`, "snapshotted\n", "utf-8");
|
||
const waitCell = new Int32Array(new SharedArrayBuffer(4));
|
||
const deadline = Date.now() + 30_000;
|
||
while (!existsSync(`${barrier}.release`)) {
|
||
if (Date.now() >= deadline) {
|
||
throw new Error(
|
||
"timed out waiting at the intent-create Change Control snapshot barrier",
|
||
);
|
||
}
|
||
Atomics.wait(waitCell, 0, 0, 10);
|
||
}
|
||
}
|
||
|
||
// intent-create - the deterministic mutation behind the engine's creation
|
||
// directive (the engine NAMES the move read-only; this tool performs it).
|
||
// Creates the FIRST intent in the active space on a fresh workspace, OR a new
|
||
// intent for new work alongside an active one. Crash-safe + concurrent-safe:
|
||
// the WHOLE transaction (migration probe, intent mint, registry append,
|
||
// active-intent cursor, state-build, audit emits) runs inside ONE withAuditLock
|
||
// on the WORKSPACE sentinel bucket — every intents.json mutation takes that
|
||
// bucket (invariant 2), so two concurrent first-runs are serialized and BOTH
|
||
// creation attempts land distinct uuids/dirs/rows with no lost update.
|
||
//
|
||
// The directory-tree copy + knowledge READMEs that the old `--init` shipped are
|
||
// gone: the workspace shell (spaces/default/memory, native includes) ships in
|
||
// dist/ (SEED), and lazy workspace dirs are ensure-exists at creation or first
|
||
// use. What stays is the scope→stage state-build that routes
|
||
// the workflow to its first post-init stage — relocated here, now writing into
|
||
// the CREATED intent's record (the active-intent cursor set first makes the
|
||
// default-resolving state/audit helpers resolve there).
|
||
function handleIntentCreate(projectDir: string, flags: Record<string, string>): void {
|
||
// Creation mutates the registry and active cursor. Refuse an invocation that
|
||
// carries no meaningful scope or description instead of minting a default
|
||
// record from an accidental bare command.
|
||
if (!INTENT_CREATE_DESCRIPTIVE_FLAGS.some((name) => flags[name])) {
|
||
die(
|
||
"intent-create refused: no --scope, --arguments, or --label given. Creation " +
|
||
"is a mutation and a bare invocation mints a garbage default-scope " +
|
||
"intent. Start work via `/aidlc \"<what to build>\"` (the engine names " +
|
||
"the create move for you) or `/aidlc-init [--scope <name>] <description>`; " +
|
||
"to invoke this tool directly, pass at least `--scope <name>` (and " +
|
||
"ideally `--arguments \"<description>\" --label \"<2-3 word essence>\"`).",
|
||
);
|
||
}
|
||
|
||
// Default when --scope is omitted: AWS_AIDLC_DEFAULT_SCOPE overrides, then
|
||
// the framework's single hard-coded fallback (DEFAULT_SCOPE, "classic");
|
||
// selection-aware so a plugin-only install (where the core default is
|
||
// deselected) resolves to its nominated freeform default instead of
|
||
// crashing with "Unknown scope".
|
||
const scope = flags.scope || envDefaultScope() ||
|
||
resolveDefaultScope(DEFAULT_SCOPE);
|
||
if (!validScopes().has(scope)) {
|
||
die(
|
||
`Unknown scope: "${scope}". Valid scopes: ${[...validScopes()].join(", ")}.`
|
||
);
|
||
}
|
||
|
||
if (flags.arguments !== undefined) {
|
||
const description = authoritativeProjectDescription(flags.arguments);
|
||
if (description.error) {
|
||
die(
|
||
`intent-create refused: ${description.error}. Use exact, non-nested ` +
|
||
"<document>...</document> markers and clarify the request before retrying.",
|
||
);
|
||
}
|
||
if (
|
||
description.pastedDocumentPresent &&
|
||
description.description.length === 0
|
||
) {
|
||
die(
|
||
"intent-create refused: pasted document content has no authoritative user " +
|
||
"directions outside <document>...</document>. State what to do with the " +
|
||
"document before retrying.",
|
||
);
|
||
}
|
||
}
|
||
|
||
const depthOverride = flags.depth;
|
||
if (depthOverride && !VALID_DEPTHS[depthOverride.toLowerCase()]) {
|
||
die(`Unknown depth: "${depthOverride}". Valid depths: minimal, standard, comprehensive.`);
|
||
}
|
||
|
||
const testStrategyOverride = flags["test-strategy"];
|
||
if (testStrategyOverride && !VALID_TEST_STRATEGIES[testStrategyOverride.toLowerCase()]) {
|
||
die(`Unknown test strategy: "${testStrategyOverride}". Valid: minimal, standard, comprehensive.`);
|
||
}
|
||
const reviewOverride = parseReviewOverride(flags.review);
|
||
if (flags["change-control"] !== undefined && parseChangeControl(flags["change-control"]) === null) {
|
||
die(
|
||
`Unknown Change Control value: "${flags["change-control"]}". Valid: ${CHANGE_CONTROL_VALUES.join(", ")}.`,
|
||
);
|
||
}
|
||
// The creation target. An explicit --space is the one selector creation takes:
|
||
// the intent is created under that space, that space's memory layers govern
|
||
// its Change Control, and the refusal rows land under that space (main seeds
|
||
// errorSelection from the same flag). Without the flag the session binding or
|
||
// the active-space cursor decides, as before. A space that does not exist is
|
||
// refused: creating a space is a separate, deliberate move.
|
||
if (flags.space !== undefined) {
|
||
const spaces = listSpaces(projectDir);
|
||
if (!spaces.some((s) => s.name === flags.space)) {
|
||
die(
|
||
`Unknown space "${flags.space}". Existing: ${spaces.map((s) => s.name).join(", ")}. ` +
|
||
"intent-create only creates in an existing space; create the space first " +
|
||
"(/aidlc space create <name>, or legacy /aidlc space-create <name>).",
|
||
);
|
||
}
|
||
}
|
||
const initialSelection = resolveWorkflowSelection(projectDir, { space: flags.space });
|
||
|
||
// Preflight the target space's Change Control memory BEFORE any mutation. The
|
||
// state build re-reads it under the lock to write the state line; checking
|
||
// here means a refused creation creates nothing: no record dir, no registry
|
||
// row, no cursor move, no audit rows.
|
||
const requestedChangeControl = parseChangeControl(flags["change-control"]);
|
||
let preflightMemoryStrict: ChangeControlMemoryDeclaration | null;
|
||
try {
|
||
preflightMemoryStrict =
|
||
memoryChangeControlDeclarations(projectDir, { space: initialSelection.space })
|
||
.find((declaration) => declaration.value === "strict") ?? null;
|
||
} catch (e) {
|
||
die(errorMessage(e));
|
||
}
|
||
if (preflightMemoryStrict !== null && requestedChangeControl === "relaxed") {
|
||
die(changeControlMemoryStrictRefusal(preflightMemoryStrict));
|
||
}
|
||
// A flat aidlc-docs/ layout is migrated into the DEFAULT space by the first
|
||
// creation (below, under the lock). An explicit other space cannot be honored
|
||
// on that same run, so refuse instead of silently creating somewhere else.
|
||
if (
|
||
flags.space !== undefined &&
|
||
flags.space !== DEFAULT_SPACE &&
|
||
needsFlatMigration(projectDir)
|
||
) {
|
||
die(
|
||
"intent-create refused: this project still has the flat aidlc-docs/ layout, " +
|
||
`which the first creation migrates into the "${DEFAULT_SPACE}" space. Run ` +
|
||
"intent-create once without --space to migrate it, then create in " +
|
||
`"${flags.space}".`,
|
||
);
|
||
}
|
||
|
||
// Resolve the repo set the intent touches (P7 multi-repo): an explicit
|
||
// `--repos a,b` wins; absent it, sibling auto-discovery scans the workspace
|
||
// root's immediate children for a `.git`. An empty result (legacy single-repo /
|
||
// fresh greenfield) records no repos row — the lone repo is inferred on the
|
||
// construction path. Validated up front so a bad name fails before any mutation.
|
||
let repos: string[];
|
||
try {
|
||
repos = resolveIntentRepoSet(projectDir, flags.repos);
|
||
} catch (e) {
|
||
die(errorMessage(e));
|
||
}
|
||
|
||
// The whole mutation runs under the WORKSPACE lock so a concurrent first-run
|
||
// is serialized - both creation attempts append distinct rows to intents.json without a
|
||
// lost update. The migration probe + the registry append are the reads/writes
|
||
// the hazard box demands be in ONE critical section on the sentinel bucket.
|
||
withAuditLock(projectDir, () => {
|
||
// (1) MIGRATION WIRING. A pre-workspace project still at the flat aidlc-docs/
|
||
// layout is migrated ONCE here (idempotent + crash-safe; no-op on a fresh
|
||
// SEED shell or an already-migrated project). migrateFlatLayout MOVES the
|
||
// existing flat state INTO a per-intent record (mints the intent, sets the
|
||
// cursor + registry row), so when it fires the migrated state is AUTHORITATIVE
|
||
// — we do NOT mint a second intent and do NOT rebuild state on top (that
|
||
// would clobber the moved workflow). We git-rm the moved flat tree and emit a
|
||
// migration acknowledgement, then return. The deferred `git rm` untracks the
|
||
// data that MOVED (the source is never rmSync'd; best-effort — a non-git
|
||
// project skips it).
|
||
const migration = migrateFlatLayout(projectDir);
|
||
if (migration) {
|
||
if (initialSelection.sessionId) {
|
||
writeSessionBinding(
|
||
projectDir,
|
||
initialSelection.sessionId,
|
||
DEFAULT_SPACE,
|
||
migration.intentDirName,
|
||
);
|
||
}
|
||
gitRmFlatTree(projectDir, migration.movedFrom);
|
||
const migratedState = readStateFile(projectDir);
|
||
const reviewUpdate = applyReviewOverride(
|
||
migratedState,
|
||
reviewOverride,
|
||
);
|
||
if (reviewUpdate.changed) {
|
||
writeStateFile(projectDir, reviewUpdate.content);
|
||
}
|
||
// The migrated record carries its prior state + audit history. Record that
|
||
// the workspace was migrated into this intent (lands in the migrated
|
||
// intent's audit shard — the cursor points there now). No state rebuild.
|
||
appendAuditEvent(projectDir, "WORKSPACE_INITIALISED", {
|
||
Request: `/aidlc ${flags.arguments || scope}`,
|
||
Scope: scope,
|
||
Details: `Migrated flat aidlc-docs/ into ${migration.intentDirName}`,
|
||
...(reviewOverride !== undefined
|
||
? {
|
||
"Review Override":
|
||
reviewUpdate.storedReview || "adversarial (stage defaults)",
|
||
}
|
||
: {}),
|
||
});
|
||
if (reviewUpdate.changed) {
|
||
appendAuditEvent(projectDir, "REVIEW_CLASS_CHANGED", {
|
||
"Old Override": reviewUpdate.oldReview || "none set",
|
||
"New Override":
|
||
reviewUpdate.storedReview || "cleared (stage defaults apply)",
|
||
});
|
||
}
|
||
process.stdout.write(
|
||
`Migrated flat workspace into intent: ${migration.intentDirName} (space: ${DEFAULT_SPACE})\n`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// (2) MINT THE INTENT. SPIKE (date-prefix): the dir name is `<YYMMDD>-<label>`.
|
||
// TWO seams, by the three-concerns split:
|
||
// • KNOWLEDGE→LLM: the conductor passes a short 2-3 word essence via --label
|
||
// ("simple calc"). This is the dir-name label — the readable, condensed half
|
||
// no deterministic tool can produce from a long sentence.
|
||
// • DETERMINISM→TOOL: --label is slugified (cap 24), the date prefix + collision
|
||
// counter are appended, the dirName is stored in the registry row.
|
||
// Fallback chain so a NON-LLM caller (direct tool invocation, scripts, or a
|
||
// conductor that omits --label) still creates a sane name: --label, else the
|
||
// freeform --arguments (truncated — may cut mid-phrase, the pre-LLM behaviour),
|
||
// else the scope token. The full --arguments text still flows to the audit
|
||
// Request + state Project fields below (verbose prose belongs there, not the dir).
|
||
const description = flags.arguments?.trim();
|
||
const label = flags.label?.trim();
|
||
const slugSource = label || description || scope;
|
||
const slug = slugify(slugSource, 24);
|
||
// "help" is grammar (`intent help` prints help), so an intent slugged
|
||
// "help" would be unswitchable by name. createIntent throws on it too
|
||
// (library backstop); dying here keeps the clean JSON error shape.
|
||
if (RESERVED_RECORD_NAMES.has(slug)) {
|
||
die(
|
||
`"${slug}" is a reserved name and cannot be an intent label. Pick a label that describes the work.`
|
||
);
|
||
}
|
||
const space = initialSelection.space;
|
||
// The preflight above ran outside the lock; a memory edit could land in
|
||
// between. Re-read under the lock, still BEFORE the mint, so the refusal
|
||
// that reaches the human is always a creation that did nothing. This locked
|
||
// read is the creation's policy snapshot; state construction receives the
|
||
// resulting value and cannot refuse after the mint because memory changed.
|
||
const lockedMemoryStrict =
|
||
memoryChangeControlDeclarations(projectDir, { space })
|
||
.find((declaration) => declaration.value === "strict") ?? null;
|
||
if (lockedMemoryStrict !== null && requestedChangeControl === "relaxed") {
|
||
die(changeControlMemoryStrictRefusal(lockedMemoryStrict));
|
||
}
|
||
const lockedScopeDef = loadScopeMapping()[scope];
|
||
if (!lockedScopeDef) die(`Unknown scope: ${scope}`);
|
||
const effectiveChangeControl =
|
||
lockedMemoryStrict !== null
|
||
? formatChangeControl("strict", `${lockedMemoryStrict.layer}.md`)
|
||
: requestedChangeControl !== null
|
||
? formatChangeControl(requestedChangeControl, "you")
|
||
: formatChangeControl(
|
||
lockedScopeDef.changeControl ?? "strict",
|
||
`scope ${scope}`,
|
||
);
|
||
waitAtIntentCreateChangeControlSnapshotBarrier();
|
||
const created = createIntent(
|
||
projectDir,
|
||
slug,
|
||
space,
|
||
scope,
|
||
repos,
|
||
initialSelection.sessionId ?? undefined,
|
||
);
|
||
|
||
const ts = isoTimestamp();
|
||
|
||
// ---- Audit bootstrap + creation events (relocated from the old --init) ----
|
||
//
|
||
// Every write from here on names the CREATED record explicitly. The
|
||
// default-resolving helpers follow the session binding or the active-space
|
||
// cursor, and an explicit --space is neither: without the selection they
|
||
// would land the new workflow's rows and state in the active space's intent.
|
||
|
||
// audit.md: header-only bootstrap if absent. WORKFLOW_STARTED is the creation
|
||
// event; SESSION_STARTED is owned by the SessionStart hook.
|
||
const auditPath = auditFilePath(projectDir, created.dirName, created.space);
|
||
if (!existsSync(auditPath)) {
|
||
mkdirSync(dirname(auditPath), { recursive: true });
|
||
writeFileSync(auditPath, `# AI-DLC Audit Log\n`, "utf-8");
|
||
}
|
||
|
||
// WORKFLOW_STARTED — mandatory first event of any new workflow. Captures the
|
||
// creation timestamp so "when did this feature begin?" is answerable from the
|
||
// audit alone. Lands in the created intent's audit (relocated from --init).
|
||
appendAuditEvent(projectDir, "WORKFLOW_STARTED", {
|
||
Scope: scope,
|
||
Request: `/aidlc ${flags.arguments || scope}`,
|
||
...sourceBaselineAuditFields(
|
||
projectDir,
|
||
"code-generation",
|
||
created.dirName,
|
||
created.space,
|
||
),
|
||
...(reviewOverride !== undefined
|
||
? {
|
||
"Review Override":
|
||
storedReviewOverride(reviewOverride) || "adversarial (stage defaults)",
|
||
}
|
||
: {}),
|
||
// Record the intent's repo span at creation (P7). Omitted when no repos were
|
||
// captured (legacy single-repo / fresh greenfield: the lone repo is inferred).
|
||
...(repos.length > 0 ? { Repos: repos.join(", ") } : {}),
|
||
}, created.dirName, created.space);
|
||
|
||
// PHASE_STARTED for the Init phase — Init always runs. Other phases emit
|
||
// PHASE_STARTED at their boundary (via aidlc-state.ts advance) or
|
||
// PHASE_SKIPPED right now if the scope excludes them.
|
||
const initStageCount = stagesInScope(scope).filter(
|
||
(s) => s.phase === "initialization" && s.action === "EXECUTE"
|
||
).length;
|
||
appendAuditEvent(projectDir, "PHASE_STARTED", {
|
||
Phase: "initialization",
|
||
"Stage count": String(initStageCount),
|
||
Scope: scope,
|
||
}, created.dirName, created.space);
|
||
|
||
// PHASE_SKIPPED — one per phase the scope excludes entirely (no EXECUTE
|
||
// stages in that phase). Captures the scope decision at workflow creation so
|
||
// you don't have to derive it later by diffing the stage list. Shares
|
||
// phasesWithExecuteStages with the folder creation below, so a phase that
|
||
// reports skipped here is exactly a phase that gets no folder.
|
||
const runningPhases = phasesWithExecuteStages(scope);
|
||
for (const phase of PHASES) {
|
||
if (phase === "initialization") continue;
|
||
const inPhase = stagesInScope(scope).filter((s) => s.phase === phase);
|
||
if (!runningPhases.has(phase) && inPhase.length > 0) {
|
||
appendAuditEvent(projectDir, "PHASE_SKIPPED", {
|
||
Phase: phase,
|
||
Scope: scope,
|
||
Reason: `scope ${scope} excludes ${phase}`,
|
||
}, created.dirName, created.space);
|
||
}
|
||
}
|
||
|
||
appendAuditEvent(projectDir, "STAGE_STARTED", {
|
||
Stage: "workspace-scaffold",
|
||
Agent: "orchestrator",
|
||
}, created.dirName, created.space);
|
||
|
||
// ---- Ensure-exists record dirs (lazy; SEED ships the shell) ----
|
||
// The shipped shell already carries spaces/default/memory + native includes.
|
||
// Intent creation only ensures the dirs this workflow will write into: an artifact dir
|
||
// per IN-SCOPE phase (a scope-excluded phase gets none), verification/, and
|
||
// the space-level knowledge/ dir. All idempotent: skip any dir that already
|
||
// exists, and never remove one.
|
||
ensureWorkspaceDirs(projectDir, scope, created.dirName, created.space);
|
||
|
||
const phaseDirDetail = `${runningPhases.size} in-scope phase dirs + verification/ + space-level knowledge/ ensured`;
|
||
appendAuditEvent(projectDir, "WORKSPACE_SCAFFOLDED", {
|
||
Request: `/aidlc ${flags.arguments || scope}`,
|
||
Details: `${phaseDirDetail} (shell shipped by SEED)`,
|
||
}, created.dirName, created.space);
|
||
appendAuditEvent(projectDir, "STAGE_COMPLETED", {
|
||
Stage: "workspace-scaffold",
|
||
Details: phaseDirDetail,
|
||
}, created.dirName, created.space);
|
||
|
||
handleIntentCreateStateBuild(
|
||
projectDir,
|
||
flags,
|
||
scope,
|
||
ts,
|
||
reviewOverride,
|
||
created.dirName,
|
||
created.space,
|
||
effectiveChangeControl,
|
||
);
|
||
}, undefined, undefined, WORKSPACE_MUTATION_LOCK_RETRIES);
|
||
}
|
||
|
||
// The scope→stage state-build half of creation: the workspace detection + state
|
||
// file authoring + routing audit emits the old --init ran after scaffolding.
|
||
// Split out only so handleIntentCreate's lock body stays readable; it is called
|
||
// from inside that lock (every write here resolves the created intent's record).
|
||
function handleIntentCreateStateBuild(
|
||
projectDir: string,
|
||
flags: Record<string, string>,
|
||
scope: string,
|
||
ts: string,
|
||
reviewOverride: ReviewOverride | undefined,
|
||
createdDir: string,
|
||
createdSpace: string,
|
||
effectiveChangeControl: string,
|
||
): void {
|
||
const depthOverride = flags.depth;
|
||
const testStrategyOverride = flags["test-strategy"];
|
||
// ---- Workspace detection (stage 0.2) ----
|
||
|
||
appendAuditEvent(projectDir, "STAGE_STARTED", {
|
||
Stage: "workspace-detection",
|
||
Agent: "orchestrator",
|
||
}, createdDir, createdSpace);
|
||
|
||
const scan = detectWorkspace(projectDir);
|
||
const uninitSubmodules = scan.submodules.filter((s) => !s.initialized);
|
||
const submoduleRemedy =
|
||
uninitSubmodules.length > 0
|
||
? `${uninitSubmodules.length} uninitialized submodule path(s) (${enumerateSubmodulePaths(uninitSubmodules)}) - run '${SUBMODULE_INIT_REMEDY}' to fetch them`
|
||
: "";
|
||
|
||
appendAuditEvent(projectDir, "WORKSPACE_SCANNED", {
|
||
"Project Type": scan.projectType,
|
||
Languages: scan.languages,
|
||
Frameworks: scan.frameworks,
|
||
"Build System": scan.buildSystem,
|
||
...(scan.nestedRoot ? { "Nested Root": scan.nestedRoot } : {}),
|
||
...(scan.submodules.length > 0
|
||
? {
|
||
Submodules: `${scan.submodules.length} declared, ${uninitSubmodules.length} uninitialized`,
|
||
}
|
||
: {}),
|
||
Details:
|
||
uninitSubmodules.length > 0
|
||
? `Deterministic rule-based scan; ${submoduleRemedy}`
|
||
: "Deterministic rule-based scan",
|
||
}, createdDir, createdSpace);
|
||
appendAuditEvent(projectDir, "STAGE_COMPLETED", {
|
||
Stage: "workspace-detection",
|
||
Details: `Classified ${scan.projectType}; languages=${scan.languages}; frameworks=${scan.frameworks}`,
|
||
}, createdDir, createdSpace);
|
||
|
||
// ---- State init (stage 0.3) ----
|
||
|
||
appendAuditEvent(projectDir, "STAGE_STARTED", {
|
||
Stage: "state-init",
|
||
Agent: "orchestrator",
|
||
}, createdDir, createdSpace);
|
||
|
||
const graph = loadStageGraph();
|
||
const scopeMapping = loadScopeMapping();
|
||
const scopeDef = scopeMapping[scope];
|
||
if (!scopeDef) die(`Unknown scope: ${scope}`);
|
||
const effectiveDepth = depthOverride
|
||
? VALID_DEPTHS[depthOverride.toLowerCase()]
|
||
: scopeDef.depth;
|
||
const effectiveTestStrategy = testStrategyOverride
|
||
? VALID_TEST_STRATEGIES[testStrategyOverride.toLowerCase()]
|
||
: (scopeDef.testStrategy ?? effectiveDepth);
|
||
// Compute stages to execute/skip
|
||
const executeStages: string[] = [];
|
||
const skipStages: string[] = [];
|
||
for (const stage of graph) {
|
||
const action = scopeDef.stages[stage.slug] || "SKIP";
|
||
if (action === "EXECUTE") {
|
||
executeStages.push(stage.number);
|
||
} else {
|
||
skipStages.push(`${stage.number} (${stage.slug})`);
|
||
}
|
||
}
|
||
|
||
// For greenfield, reverse-engineering becomes SKIP
|
||
const adjustedMapping = { ...scopeDef.stages };
|
||
if (scan.projectType.toLowerCase() === "greenfield") {
|
||
if (adjustedMapping["reverse-engineering"] === "EXECUTE") {
|
||
adjustedMapping["reverse-engineering"] = "SKIP";
|
||
const reStage = graph.find((s) => s.slug === "reverse-engineering");
|
||
if (reStage) {
|
||
const idx = executeStages.indexOf(reStage.number);
|
||
if (idx >= 0) executeStages.splice(idx, 1);
|
||
skipStages.push(`${reStage.number} (reverse-engineering — greenfield)`);
|
||
}
|
||
// Advisory: the incremental scopes presume existing code, so a greenfield
|
||
// scan is a likely misread (source nested past the bounded fallback, or a
|
||
// wrong scope). We do NOT override routing (an empty workspace genuinely
|
||
// has nothing to reverse-engineer); we point the user at the fix.
|
||
if (["bugfix", "refactor", "security-patch"].includes(scope)) {
|
||
process.stderr.write(
|
||
`Note: scope "${scope}" usually targets existing code, but the workspace scanned as Greenfield ` +
|
||
`so Reverse Engineering will be skipped. If this project has a codebase the scanner missed, ` +
|
||
`edit "Project Type" to Brownfield in the intent's aidlc-state.md, or move the source so it is ` +
|
||
`detected (top-level or within three container levels), then re-run.\n`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build stage progress checkboxes
|
||
let stageProgress = "";
|
||
const phaseMap: Record<string, typeof graph> = {};
|
||
for (const stage of graph) {
|
||
if (!phaseMap[stage.phase]) phaseMap[stage.phase] = [];
|
||
phaseMap[stage.phase].push(stage);
|
||
}
|
||
|
||
const phaseHeaders: Record<string, string> = {
|
||
initialization: "INITIALIZATION PHASE",
|
||
ideation: "IDEATION PHASE",
|
||
inception: "INCEPTION PHASE",
|
||
construction: "CONSTRUCTION PHASE",
|
||
operation: "OPERATION PHASE",
|
||
};
|
||
|
||
for (const phase of PHASES) {
|
||
const stages = phaseMap[phase] || [];
|
||
stageProgress += `\n### ${phaseHeaders[phase]}\n`;
|
||
if (phase === "construction") {
|
||
stageProgress += "Per unit: [TBD]\n";
|
||
}
|
||
for (const stage of stages) {
|
||
const action =
|
||
adjustedMapping[stage.slug] || scopeDef.stages[stage.slug] || "SKIP";
|
||
const isInit = phase === "initialization";
|
||
const marker = isInit ? "[x]" : "[ ]";
|
||
const suffix = action === "EXECUTE" ? "EXECUTE" : `SKIP`;
|
||
stageProgress += `- ${marker} ${stage.slug} — ${suffix}\n`;
|
||
}
|
||
}
|
||
|
||
const firstPostInit = determineFirstPostInitStage(adjustedMapping, graph);
|
||
stageProgress = stageProgress.replace(
|
||
`- [ ] ${firstPostInit}`,
|
||
`- [-] ${firstPostInit}`
|
||
);
|
||
|
||
const totalInScope = executeStages.length;
|
||
const completedInit = graph.filter((s) => s.phase === "initialization").length;
|
||
|
||
const firstPostInitEntry = graph.find((s) => s.slug === firstPostInit);
|
||
const firstPostInitPhase = firstPostInitEntry
|
||
? firstPostInitEntry.phase.toUpperCase()
|
||
: "IDEATION";
|
||
const firstPostInitAgent = firstPostInitEntry
|
||
? firstPostInitEntry.lead_agent
|
||
: "aidlc-product-agent";
|
||
|
||
const nextAfterFirst = nextInScopeStage(firstPostInit, scope);
|
||
const nextStageName = nextAfterFirst ? nextAfterFirst.slug : "none";
|
||
|
||
const rawProjectDesc = flags.arguments || "[Project description]";
|
||
const descriptionAuthority = authoritativeProjectDescription(rawProjectDesc);
|
||
const previewSource = descriptionAuthority.error
|
||
? "[Pasted document boundary needs clarification]"
|
||
: descriptionAuthority.pastedDocumentPresent
|
||
? descriptionAuthority.description || "[Pasted document provided]"
|
||
: rawProjectDesc;
|
||
const projectDesc = hasUnsafeSingleLineCharacter(previewSource)
|
||
? Array.from(previewSource, (char) => {
|
||
const codePoint = char.codePointAt(0) ?? 0;
|
||
return codePoint <= 0x1f ||
|
||
codePoint === 0x7f ||
|
||
codePoint === 0x2028 ||
|
||
codePoint === 0x2029
|
||
? " "
|
||
: char;
|
||
})
|
||
.join("")
|
||
.replace(/ {2,}/g, " ")
|
||
.trim() || "[Project description]"
|
||
: previewSource;
|
||
|
||
// Phase Progress - per-phase status. Creation completes every initialization
|
||
// stage ([x]) and hands off to the first post-init stage ([-]), emitting the
|
||
// PHASE_COMPLETED/VERIFIED/STARTED trio for that boundary below - so the
|
||
// seed mirrors it: Initialization is Verified and the first post-init
|
||
// stage's phase is Active. Later phases are Skipped if the adjusted scope
|
||
// mapping has zero EXECUTE stages for them, otherwise Pending; advance /
|
||
// finalize / complete-workflow / jump flip the rows at each subsequent
|
||
// boundary (aidlc-state.ts, aidlc-jump.ts).
|
||
const phaseStatus = (phase: string): string => {
|
||
if (phase === "initialization") return "Verified";
|
||
if (firstPostInitEntry && phase === firstPostInitEntry.phase) return "Active";
|
||
const stagesInPhase = graph.filter((s) => s.phase === phase);
|
||
const hasExecute = stagesInPhase.some(
|
||
(s) => (adjustedMapping[s.slug] || scopeDef.stages[s.slug] || "SKIP") === "EXECUTE"
|
||
);
|
||
return hasExecute ? "Pending" : "Skipped";
|
||
};
|
||
const phaseProgressLines = [
|
||
`- **Initialization**: ${phaseStatus("initialization")}`,
|
||
`- **Ideation**: ${phaseStatus("ideation")}`,
|
||
`- **Inception**: ${phaseStatus("inception")}`,
|
||
`- **Construction**: ${phaseStatus("construction")}`,
|
||
`- **Operation**: ${phaseStatus("operation")}`,
|
||
].join("\n");
|
||
|
||
const stateContent = `# AI-DLC State Tracking
|
||
|
||
## Project Information
|
||
- **Project**: ${projectDesc}
|
||
- **Project Description Source**: ${PROJECT_DESCRIPTION_FILE}
|
||
- **Project Type**: ${scan.projectType}
|
||
- **Scope**: ${scope}
|
||
- **Start Date**: ${ts}
|
||
- **State Version**: ${CURRENT_STATE_VERSION}
|
||
- **Active Agent**: ${firstPostInitAgent}
|
||
- **Worktree Path**:
|
||
- **Bolt Refs**:
|
||
- **Practices Affirmed Timestamp**:
|
||
|
||
## Scope Configuration
|
||
- **Stages to Execute**: ${executeStages.join(", ")}
|
||
- **Stages to Skip**: ${skipStages.length > 0 ? skipStages.join(", ") : "none"}
|
||
- **Depth**: ${effectiveDepth}
|
||
- **Test Strategy**: ${effectiveTestStrategy}
|
||
- **Review Override**: ${reviewOverride === undefined ? "" : storedReviewOverride(reviewOverride)}
|
||
- **Change Control**: ${effectiveChangeControl}
|
||
|
||
## Workspace State
|
||
- **Project Root**: .
|
||
- **Languages**: ${scan.languages}
|
||
- **Frameworks**: ${scan.frameworks}
|
||
- **Build System**: ${scan.buildSystem}
|
||
|
||
## Execution Plan Summary
|
||
- **Total Stages**: ${totalInScope}
|
||
- **Completed**: ${completedInit}
|
||
- **In Progress**: ${firstPostInit}
|
||
|
||
## Runtime State
|
||
- **Revision Count**: 0
|
||
|
||
## Phase Progress
|
||
<!-- Status values: Pending, Active, Verified, Skipped -->
|
||
|
||
${phaseProgressLines}
|
||
|
||
## Stage Progress
|
||
<!-- Checkbox states: [ ] not started, [-] in progress, [?] awaiting approval (gate open), [R] revising (user rejected gate), [x] completed, [S] skipped via --stage/--phase jump -->
|
||
${stageProgress}
|
||
## Current Status
|
||
- **Lifecycle Phase**: ${firstPostInitPhase}
|
||
- **Current Stage**: ${firstPostInit}
|
||
- **Next Stage**: ${nextStageName}
|
||
- **Status**: Running
|
||
- **Last Updated**: ${ts}
|
||
|
||
## Session Resume Point
|
||
- **Last Completed Stage**: state-init
|
||
- **Next Action**: Execute ${firstPostInit}
|
||
- **Pending Artifacts**: none
|
||
`;
|
||
|
||
writeFileAtomic(
|
||
projectDescriptionFilePath(projectDir, createdDir, createdSpace),
|
||
`${JSON.stringify(rawProjectDesc)}\n`,
|
||
);
|
||
writeStateFile(projectDir, stateContent, createdDir, createdSpace);
|
||
|
||
appendAuditEvent(projectDir, "WORKSPACE_INITIALISED", {
|
||
Request: `/aidlc ${flags.arguments || scope}`,
|
||
"Project Type": scan.projectType,
|
||
Scope: scope,
|
||
Languages: scan.languages,
|
||
Frameworks: scan.frameworks,
|
||
"Build System": scan.buildSystem,
|
||
Details: `${totalInScope} stages in scope, routing to ${firstPostInit}`,
|
||
}, createdDir, createdSpace);
|
||
appendAuditEvent(projectDir, "STAGE_COMPLETED", {
|
||
Stage: "state-init",
|
||
Details: `State initialized: ${scope} scope, ${totalInScope} stages, routing to ${firstPostInit}`,
|
||
}, createdDir, createdSpace);
|
||
|
||
// Phase hand-off: initialization → first post-init phase. The state file
|
||
// advertises Current Stage = first post-init, so the audit must reflect
|
||
// the same transition (PHASE_COMPLETED + PHASE_VERIFIED + PHASE_STARTED +
|
||
// STAGE_STARTED) to keep the two streams coherent. Without these, the first
|
||
// subsequent `advance` call would appear to jump from workspace-scaffold
|
||
// directly into a fresh phase.
|
||
if (firstPostInitEntry && firstPostInitEntry.phase !== "initialization") {
|
||
appendAuditEvent(projectDir, "PHASE_COMPLETED", {
|
||
"From phase": "initialization",
|
||
"To phase": firstPostInitEntry.phase,
|
||
"Stages completed": String(completedInit),
|
||
}, createdDir, createdSpace);
|
||
appendAuditEvent(projectDir, "PHASE_VERIFIED", {
|
||
"Phase boundary": `initialization → ${firstPostInitEntry.phase}`,
|
||
}, createdDir, createdSpace);
|
||
appendAuditEvent(projectDir, "PHASE_STARTED", {
|
||
Phase: firstPostInitEntry.phase,
|
||
Scope: scope,
|
||
}, createdDir, createdSpace);
|
||
appendAuditEvent(projectDir, "STAGE_STARTED", {
|
||
Stage: firstPostInit,
|
||
Agent: firstPostInitAgent,
|
||
}, createdDir, createdSpace);
|
||
}
|
||
|
||
// Combined stdout summary (intent created + state-build). The state file and
|
||
// every row above name the created record explicitly.
|
||
const submoduleWarningLine =
|
||
uninitSubmodules.length > 0
|
||
? `Warning: ${uninitSubmodules.length} uninitialized git submodule path(s) (${enumerateSubmodulePaths(uninitSubmodules)}) - run '${SUBMODULE_INIT_REMEDY}' before proceeding so reverse-engineering can read the code.\n`
|
||
: "";
|
||
process.stdout.write(
|
||
`Intent created: ${createdDir} (space: ${createdSpace})
|
||
State initialized: ${scope} scope, ${totalInScope} stages, ${effectiveDepth} depth
|
||
Project type: ${scan.projectType}
|
||
Languages: ${scan.languages}
|
||
Frameworks: ${scan.frameworks}
|
||
Build System: ${scan.buildSystem}
|
||
${submoduleWarningLine}First post-init stage: ${firstPostInit} (${firstPostInitPhase})
|
||
`
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// state-init / init - transition aliases
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function handleInitTransition(): void {
|
||
die(INIT_TRANSITION_MESSAGE);
|
||
}
|
||
|
||
function handleStateInit(_projectDir: string, _flags: Record<string, string>): void {
|
||
die(
|
||
"state-init is merged into intent-create. Just describe what you want to build (/aidlc \"build the auth service\") and the workflow record is created for you."
|
||
);
|
||
}
|
||
|
||
function handleUpgrade(): void {
|
||
die(UPGRADE_UNAVAILABLE_MESSAGE);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// intent / space — the verb families + the deterministic query layer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Print an intent listing (the query layer's human OR --json mode). Both modes
|
||
// read the SAME listSpaces/listIntents source so they never diverge. --json
|
||
// shape: {active, spaces:[...], intents:[{uuid,slug,status,repos}]} — consumed
|
||
// by the creation gate, resume-rebind, and statusline; human text is the bare
|
||
// `/aidlc intent` rendering. Pure read.
|
||
function printIntentListing(
|
||
projectDir: string,
|
||
asJson: boolean,
|
||
): void {
|
||
const selection = resolveWorkflowSelection(projectDir);
|
||
const space = selection.space;
|
||
const intents = listIntents(projectDir, space, selection.intent);
|
||
const active = intents.find((i) => i.active);
|
||
if (asJson) {
|
||
process.stdout.write(
|
||
`${JSON.stringify({
|
||
active: active ? active.dirName : null,
|
||
space,
|
||
intents: intents.map((i) => ({
|
||
uuid: i.uuid,
|
||
slug: i.slug,
|
||
status: i.status,
|
||
repos: i.repos ?? [],
|
||
dirName: i.dirName,
|
||
active: i.active,
|
||
})),
|
||
})}\n`
|
||
);
|
||
return;
|
||
}
|
||
if (intents.length === 0) {
|
||
process.stdout.write(
|
||
`No intents in space "${space}" yet. Start one by describing what to build: /aidlc "build the auth service"\n`
|
||
);
|
||
return;
|
||
}
|
||
let out = `Intents in space "${space}":\n`;
|
||
for (const i of intents) {
|
||
const marker = i.active ? "*" : " ";
|
||
out += `${marker} ${i.dirName ?? i.slug} [${i.status}]\n`;
|
||
}
|
||
if (!active) {
|
||
out += `\n(no active intent - switch with /aidlc intent <name>)\n`;
|
||
}
|
||
process.stdout.write(out);
|
||
}
|
||
|
||
// Print a space listing (human OR --json). --json shape:
|
||
// {active, spaces:[{name,active}]}. Pure read.
|
||
function printSpaceListing(
|
||
projectDir: string,
|
||
asJson: boolean,
|
||
): void {
|
||
const selection = resolveWorkflowSelection(projectDir);
|
||
const spaces = listSpaces(projectDir, selection.space);
|
||
const active = spaces.find((s) => s.active);
|
||
if (asJson) {
|
||
process.stdout.write(
|
||
`${JSON.stringify({
|
||
active: active ? active.name : DEFAULT_SPACE,
|
||
spaces: spaces.map((s) => ({ name: s.name, active: s.active })),
|
||
})}\n`
|
||
);
|
||
return;
|
||
}
|
||
let out = `Spaces:\n`;
|
||
for (const s of spaces) {
|
||
out += `${s.active ? "*" : " "} ${s.name}\n`;
|
||
}
|
||
process.stdout.write(out);
|
||
}
|
||
|
||
// `/aidlc intent` (list) · `/aidlc intent <name>` (switch the active-intent
|
||
// cursor). Switching an intent is a PURE cursor write (an intent has no native
|
||
// include — only a space does). The <name> matches a record dir name exactly,
|
||
// or a slug (when unambiguous within the space). --json on the bare list emits
|
||
// the structured query shape.
|
||
function handleIntent(
|
||
projectDir: string,
|
||
positional: string[],
|
||
flags: Record<string, string>,
|
||
): void {
|
||
const asJson = flags.json === "true";
|
||
const verbOrTarget = positional[1];
|
||
if (verbOrTarget === "list") {
|
||
printIntentListing(projectDir, asJson);
|
||
return;
|
||
}
|
||
if (verbOrTarget === "create") {
|
||
handleIntentCreate(projectDir, flags);
|
||
return;
|
||
}
|
||
const target = verbOrTarget === "switch" ? positional[2] : verbOrTarget;
|
||
if (verbOrTarget === "switch" && !target) {
|
||
die("Usage: aidlc-utility intent switch <name>");
|
||
}
|
||
if (!target) {
|
||
printIntentListing(projectDir, asJson);
|
||
return;
|
||
}
|
||
// `intent help`/`-h` is a help request, not a switch to a record named
|
||
// "help" ("help" is a reserved record name, so no real record is shadowed).
|
||
// The engine routes it to help before it ever reaches this tool; this arm is
|
||
// the backstop for a direct invocation, so a confused caller gets the help
|
||
// text instead of an "Unknown intent" error that reads like an invitation to
|
||
// start new work.
|
||
if (target === "help" || target === "-h") {
|
||
handleHelp();
|
||
return;
|
||
}
|
||
const selection = resolveWorkflowSelection(projectDir);
|
||
const space = selection.space;
|
||
const intents = listIntents(projectDir, space, selection.intent);
|
||
// Exact record-dir match first; then a unique slug match.
|
||
let match = intents.find((i) => i.dirName === target);
|
||
if (!match) {
|
||
const bySlug = intents.filter((i) => i.slug === target && i.dirName !== null);
|
||
if (bySlug.length === 1) match = bySlug[0];
|
||
else if (bySlug.length > 1) {
|
||
die(
|
||
`Ambiguous intent "${target}" in space "${space}" (${bySlug.length} match). Use the full record-dir name: ${bySlug.map((i) => i.dirName).join(", ")}.`
|
||
);
|
||
}
|
||
}
|
||
if (!match || match.dirName === null) {
|
||
// Deliberately NOT "describe what to build to start a new one": a conductor
|
||
// recovering from a failed switch read that as an instruction and created an
|
||
// unwanted intent. Point at the read-only listing only; starting new work
|
||
// stays a separate, human-confirmed move.
|
||
die(
|
||
`Unknown intent "${target}" in space "${space}". This command only switches between existing intents - run /aidlc intent to list them. Do not start a new workflow to recover from this error.`
|
||
);
|
||
}
|
||
setActiveIntentCursor(projectDir, match.dirName, space);
|
||
// Re-stamp the LIVE conversation's session→intent record to the switched-to
|
||
// intent. WHY: the resume-rebind stamp (session-start hook) is keyed by
|
||
// session_id, which this tool never sees; only the hook does. Without this, a
|
||
// deliberate in-conversation `/aidlc intent <slug>` switch leaves the session
|
||
// stamped at the OLD intent, so resuming THIS same conversation fires a FALSE
|
||
// rebind nag ("was working X, switch back?"). The hook records the live session
|
||
// in `.current-session` on every fire (it owns session-id capture); we read
|
||
// that marker here and re-stamp deterministically. Self-switch: the marker
|
||
// names THIS session → its stamp follows the cursor → no false nag. Foreign
|
||
// drift (a DIFFERENT session moved the cursor): the marker names that OTHER
|
||
// session → its stamp moves, not ours → a genuine resume of our session still
|
||
// offers the rebind. writeSessionIntentUuid no-ops on a blank uuid, so an
|
||
// orphan (registry-less) record is fail-safe. Best-effort throughout.
|
||
const sid =
|
||
selection.sessionId ??
|
||
readCurrentSessionId(projectDir);
|
||
if (sid) {
|
||
writeSessionBinding(projectDir, sid, space, match.dirName);
|
||
clearSessionRebindOffer(projectDir, sid);
|
||
if (match.uuid) writeSessionIntentUuid(projectDir, sid, match.uuid);
|
||
}
|
||
process.stdout.write(`Active intent -> ${match.dirName} (space: ${space})\n`);
|
||
}
|
||
|
||
// `/aidlc space` (list) · `/aidlc space <name>` (switch the active-space
|
||
// cursor). Switching a space does TWO per-user writes: move the gitignored
|
||
// active-space cursor, then SURGICALLY repoint the harness-native rule includes
|
||
// in place so the next turn loads the switched space's method (the ambient
|
||
// channel — Claude @-stub / Kiro resources glob / Codex AIDLC_RULES_DIR). Both
|
||
// are per-user: the cursor is gitignored, and the include re-point is a no-op at
|
||
// `default` (so a single-team user never dirties the committed tree). Switching
|
||
// to a non-existent space errors (use space-create). --json on the bare list
|
||
// emits the structured shape.
|
||
function handleSpace(projectDir: string, positional: string[], flags: Record<string, string>): void {
|
||
const asJson = flags.json === "true";
|
||
const verbOrTarget = positional[1];
|
||
if (verbOrTarget === "list") {
|
||
printSpaceListing(projectDir, asJson);
|
||
return;
|
||
}
|
||
if (verbOrTarget === "create") {
|
||
handleSpaceCreate(projectDir, ["space-create", positional[2] ?? ""], flags);
|
||
return;
|
||
}
|
||
const raw = verbOrTarget === "switch" ? positional[2] : verbOrTarget;
|
||
if (verbOrTarget === "switch" && !raw) {
|
||
die("Usage: aidlc-utility space switch <name>");
|
||
}
|
||
if (!raw) {
|
||
printSpaceListing(projectDir, asJson);
|
||
return;
|
||
}
|
||
// `space help`/`-h` is a help request, not a switch to a space named "help"
|
||
// - same backstop as handleIntent (the engine routes it to help upstream,
|
||
// and "help" is a reserved space name).
|
||
if (raw === "help" || raw === "-h") {
|
||
handleHelp();
|
||
return;
|
||
}
|
||
// Spaces are STORED under their slug (handleSpaceCreate writes slugify(raw)),
|
||
// so slugify the switch target before lookup AND before the cursor write —
|
||
// otherwise `/aidlc space "My Space"` (stored as my-space) would miss.
|
||
const target = slugify(raw);
|
||
const spaces = listSpaces(projectDir);
|
||
if (!spaces.some((s) => s.name === target)) {
|
||
die(
|
||
`Unknown space "${target}". Existing: ${spaces.map((s) => s.name).join(", ")}. This command only switches between existing spaces. Do not create a space to recover from this error - creating one is a separate, deliberate move (/aidlc space create <name>, or legacy /aidlc space-create <name>).`
|
||
);
|
||
}
|
||
const selection = resolveWorkflowSelection(projectDir);
|
||
setActiveSpaceCursor(projectDir, target);
|
||
const sessionId = selection.sessionId ?? readCurrentSessionId(projectDir);
|
||
if (sessionId) {
|
||
const targetIntent = activeIntent(projectDir, target);
|
||
writeSessionBinding(projectDir, sessionId, target, targetIntent);
|
||
clearSessionRebindOffer(projectDir, sessionId);
|
||
if (targetIntent) {
|
||
const uuid = listIntents(projectDir, target).find(
|
||
(entry) => entry.dirName === targetIntent,
|
||
)?.uuid;
|
||
if (uuid) writeSessionIntentUuid(projectDir, sessionId, uuid);
|
||
} else {
|
||
clearSessionIntentUuid(projectDir, sessionId);
|
||
}
|
||
}
|
||
// Re-point the harness-native includes at the switched space so the NEXT turn
|
||
// loads its method into ambient context (the cursor alone only moves AIDLC's
|
||
// own resolver; the CLI-native include is the ambient channel). Surgical
|
||
// in-place rewrite of the pointer segment only — preserves all engine wiring.
|
||
const repointed = repointHarnessIncludes(projectDir, target);
|
||
process.stdout.write(`Active space -> ${target}\n`);
|
||
if (repointed.length > 0) {
|
||
process.stdout.write(` repointed ${repointed.length} harness include(s) -> ${target}\n`);
|
||
}
|
||
}
|
||
|
||
// `aidlc-utility.ts codekb-path [--repo <name>] [--json]` — read-only. Prints the
|
||
// deterministic space-level per-repo codekb directory (forward-slash, workspace-
|
||
// relative) the reverse-engineering stage writes its 9 artifacts into. The repo
|
||
// is the caller-supplied --repo, else the engine-resolved codekbRepoName (the
|
||
// lone recorded repo, or basename(projectDir) when none is recorded). No mkdir,
|
||
// no state read, no audit — mirrors the intent/space read-only query arms.
|
||
function handleCodekbPath(projectDir: string, flags: Record<string, string>): void {
|
||
const asJson = flags.json === "true";
|
||
const selection = resolveWorkflowSelection(projectDir);
|
||
const space = selection.space;
|
||
const repo = flags.repo && flags.repo.length > 0
|
||
? flags.repo
|
||
: codekbRepoName(projectDir, space, selection.intent ?? undefined);
|
||
const dir = relativeCodekbDir(projectDir, repo, space);
|
||
if (asJson) {
|
||
process.stdout.write(`${JSON.stringify({ space, repo, dir })}\n`);
|
||
return;
|
||
}
|
||
process.stdout.write(`${dir}/\n`);
|
||
}
|
||
|
||
// `aidlc-utility.ts document-input` - read-only. Reads one selected path from
|
||
// the active record's fixed DOCUMENT_INPUT_REQUEST_FILE, so customer-controlled
|
||
// filename bytes never enter a shell command. Resolves that path from the
|
||
// project root and refuses search/fallback, symlinks, non-regular files,
|
||
// out-of-project targets, binary input, and content beyond the same
|
||
// 200k-character delivery cap used by DocumentKB. Successful output carries
|
||
// DocumentKB's path/content trust notices in the same JSON object as the bytes
|
||
// they govern. No mkdir, state write, or audit event.
|
||
function handleProjectDescription(projectDir: string): void {
|
||
const recordRoot = dirname(stateFilePath(projectDir));
|
||
process.stdout.write(
|
||
`${JSON.stringify(readProjectDescriptionAuthority(recordRoot))}\n`,
|
||
);
|
||
}
|
||
|
||
async function handleDocumentInput(projectDir: string): Promise<void> {
|
||
const {
|
||
detectMimeType,
|
||
EXTRACT_OUTPUT_CHAR_CAP,
|
||
readDocumentBytes,
|
||
resolveContainedFile,
|
||
UNTRUSTED_CONTENT_NOTICE,
|
||
UNTRUSTED_PATH_NOTICE,
|
||
} = await import("./aidlc-knowledge.ts");
|
||
const documentInputByteCap = EXTRACT_OUTPUT_CHAR_CAP * 4;
|
||
// The transport file carries ONE path line, so it gets a path-sized cap, not
|
||
// the document cap. Without an explicit bound the whole file is allocated and
|
||
// UTF-8 decoded BEFORE the one-line check, so a sparse multi-megabyte
|
||
// .aidlc-document-input-path kills the process with an out-of-memory error
|
||
// before any validation runs. 4096 bytes covers PATH_MAX on every supported
|
||
// platform, plus the trailing newline.
|
||
const requestFileByteCap = 4096;
|
||
|
||
const refuse = (message: string): never =>
|
||
die(`${UNTRUSTED_PATH_NOTICE} ${message}`);
|
||
const requestFile = documentInputRequestFilePath(projectDir);
|
||
const requested = (() => {
|
||
let raw: string;
|
||
try {
|
||
raw = new TextDecoder("utf-8", { fatal: true }).decode(
|
||
readDocumentBytes(
|
||
requestFile,
|
||
DOCUMENT_INPUT_REQUEST_FILE,
|
||
undefined,
|
||
requestFileByteCap,
|
||
),
|
||
);
|
||
} catch (error) {
|
||
return refuse(
|
||
`cannot read ${DOCUMENT_INPUT_REQUEST_FILE}: ${errorMessage(error)}. ` +
|
||
"Write one exact path to that active-record file with the native file-write tool.",
|
||
);
|
||
}
|
||
const value = raw.replace(/\r?\n$/, "");
|
||
if (value === "" || /[\r\n]/.test(value)) {
|
||
return refuse(
|
||
`${DOCUMENT_INPUT_REQUEST_FILE} must contain exactly one non-empty path line.`,
|
||
);
|
||
}
|
||
return value;
|
||
})();
|
||
|
||
const projectRoot = (() => {
|
||
try {
|
||
return realpathSync(projectDir);
|
||
} catch (error) {
|
||
return refuse(`cannot resolve the project root: ${errorMessage(error)}`);
|
||
}
|
||
})();
|
||
|
||
const requestedAbs = isAbsolute(requested)
|
||
? resolve(requested)
|
||
: resolve(projectRoot, requested);
|
||
const rel = relative(projectRoot, requestedAbs);
|
||
if (
|
||
rel === "" ||
|
||
rel === ".." ||
|
||
rel.startsWith(`..${sep}`) ||
|
||
isAbsolute(rel)
|
||
) {
|
||
refuse(
|
||
`document path must resolve to a file inside the project root: ${JSON.stringify(requested)}`,
|
||
);
|
||
}
|
||
const portablePath = rel.split(sep).join("/");
|
||
|
||
const { absPath, bytes } = (() => {
|
||
try {
|
||
const resolved = resolveContainedFile(projectRoot, portablePath);
|
||
return {
|
||
absPath: resolved.absPath,
|
||
bytes: readDocumentBytes(
|
||
resolved.absPath,
|
||
`document input ${JSON.stringify(portablePath)}`,
|
||
undefined,
|
||
documentInputByteCap,
|
||
resolved.identity,
|
||
),
|
||
};
|
||
} catch (error) {
|
||
return refuse(
|
||
`cannot read ${JSON.stringify(portablePath)} directly: ${errorMessage(error)} ` +
|
||
"The path is resolved from the project root and filenames are not searched " +
|
||
"recursively. Provide one accessible regular file inside the project, or use DocumentKB.",
|
||
);
|
||
}
|
||
})();
|
||
|
||
const mime = detectMimeType(absPath, bytes);
|
||
if (mime !== "text/plain" && mime !== "text/markdown") {
|
||
refuse(
|
||
`${JSON.stringify(portablePath)} is ${mime}, not direct UTF-8 text or Markdown. ` +
|
||
"Place it under aidlc/spaces/<space>/knowledge/documents/, run " +
|
||
"`/aidlc knowledge onboard <path>`, then read it with `/aidlc knowledge show <id>`.",
|
||
);
|
||
}
|
||
|
||
const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||
if (content.length > EXTRACT_OUTPUT_CHAR_CAP) {
|
||
refuse(
|
||
`${JSON.stringify(portablePath)} contains ${content.length} characters; direct input is ` +
|
||
`limited to ${EXTRACT_OUTPUT_CHAR_CAP}. Use DocumentKB so extraction and truncation ` +
|
||
"are explicit.",
|
||
);
|
||
}
|
||
|
||
process.stdout.write(
|
||
`${JSON.stringify({
|
||
path_notice: UNTRUSTED_PATH_NOTICE,
|
||
content_notice: UNTRUSTED_CONTENT_NOTICE,
|
||
path: portablePath,
|
||
bytes: bytes.length,
|
||
content_trust: "untrusted",
|
||
content_handling: "data-not-instructions",
|
||
content,
|
||
})}\n`,
|
||
);
|
||
}
|
||
|
||
const CODEKB_ARTIFACT_FILES = [
|
||
"api-documentation.md",
|
||
"architecture.md",
|
||
"business-overview.md",
|
||
"code-quality-assessment.md",
|
||
"code-structure.md",
|
||
"component-inventory.md",
|
||
"dependencies.md",
|
||
"reverse-engineering-timestamp.md",
|
||
"technology-stack.md",
|
||
] as const;
|
||
|
||
function resolveCodekbRepo(
|
||
projectDir: string,
|
||
flags: Record<string, string>,
|
||
): { space: string; repo: string; repoDir: string; storeDir: string; excludes: string[] } {
|
||
const selection = resolveWorkflowSelection(projectDir);
|
||
const space = selection.space;
|
||
const repo = flags.repo && flags.repo.length > 0
|
||
? flags.repo
|
||
: codekbRepoName(projectDir, space, selection.intent ?? undefined);
|
||
if (!isValidRepoName(repo)) {
|
||
die(`Invalid --repo "${repo}": a repo name must be one path segment.`);
|
||
}
|
||
const siblingDir = join(projectDir, repo);
|
||
const sourceDir =
|
||
existsSync(siblingDir) && statSync(siblingDir).isDirectory()
|
||
? siblingDir
|
||
: projectDir;
|
||
return {
|
||
space,
|
||
repo,
|
||
repoDir: sourceDir,
|
||
storeDir: codekbDir(projectDir, repo, space),
|
||
excludes: sourceDir === projectDir ? ["aidlc"] : [],
|
||
};
|
||
}
|
||
|
||
function codekbPaths(flags: Record<string, string>, command: string): string[] {
|
||
const paths = (flags.paths ?? "")
|
||
.split(",")
|
||
.map((path) => path.trim())
|
||
.filter((path) => path !== "");
|
||
if (paths.length === 0) {
|
||
die(`${command}: pass --paths <comma-separated repo-relative paths>`);
|
||
}
|
||
return [...new Set(paths)];
|
||
}
|
||
|
||
function codekbLockIntent(repo: string): string {
|
||
return `__codekb__${createHash("sha256").update(repo).digest("hex").slice(0, 16)}`;
|
||
}
|
||
|
||
function codekbTransactionRoot(
|
||
projectDir: string,
|
||
space: string,
|
||
repo: string,
|
||
): string {
|
||
return join(
|
||
projectDir,
|
||
"aidlc",
|
||
"spaces",
|
||
space,
|
||
"intents",
|
||
".aidlc-codekb-transactions",
|
||
repo,
|
||
);
|
||
}
|
||
|
||
function trustedCodekbRecoveryPath(
|
||
projectReal: string,
|
||
relativePath: string,
|
||
): string {
|
||
try {
|
||
return assertNoSymlinkInChainOrThrow(projectReal, relativePath);
|
||
} catch (error) {
|
||
throw new Error(
|
||
`refusing CodeKB recovery through an unsafe project path: ${errorMessage(error)}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function recoverCodekbTransactions(
|
||
projectDir: string,
|
||
space: string,
|
||
repo: string,
|
||
): void {
|
||
const projectReal = realpathSync(projectDir);
|
||
const rootRelative = relative(
|
||
projectReal,
|
||
codekbTransactionRoot(projectReal, space, repo),
|
||
);
|
||
const storeRelative = join("aidlc", "spaces", space, "codekb", repo);
|
||
const root = trustedCodekbRecoveryPath(projectReal, rootRelative);
|
||
const storeDir = trustedCodekbRecoveryPath(projectReal, storeRelative);
|
||
if (!existsSync(root)) return;
|
||
const rootStat = lstatSync(root);
|
||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
||
throw new Error(`refusing CodeKB recovery from non-directory transaction root: ${root}`);
|
||
}
|
||
for (const name of readdirSync(root).sort()) {
|
||
const txnRelative = join(rootRelative, name);
|
||
const txn = trustedCodekbRecoveryPath(projectReal, txnRelative);
|
||
const txnStat = lstatSync(txn);
|
||
if (!txnStat.isDirectory() || txnStat.isSymbolicLink()) {
|
||
throw new Error(`refusing CodeKB recovery from non-directory transaction: ${txn}`);
|
||
}
|
||
const backupRelative = join(txnRelative, "backup");
|
||
const backup = trustedCodekbRecoveryPath(projectReal, backupRelative);
|
||
if (!existsSync(storeDir) && existsSync(backup)) {
|
||
const backupStat = lstatSync(backup);
|
||
if (!backupStat.isDirectory() || backupStat.isSymbolicLink()) {
|
||
throw new Error(`refusing CodeKB recovery from non-directory backup: ${backup}`);
|
||
}
|
||
const checkedStore = trustedCodekbRecoveryPath(projectReal, storeRelative);
|
||
const checkedBackup = trustedCodekbRecoveryPath(projectReal, backupRelative);
|
||
mkdirSync(dirname(checkedStore), { recursive: true });
|
||
renameSync(checkedBackup, checkedStore);
|
||
}
|
||
rmSync(
|
||
trustedCodekbRecoveryPath(projectReal, txnRelative),
|
||
{ recursive: true, force: true },
|
||
);
|
||
}
|
||
rmSync(
|
||
trustedCodekbRecoveryPath(projectReal, rootRelative),
|
||
{ recursive: true, force: true },
|
||
);
|
||
}
|
||
|
||
function withCodekbLock<T>(
|
||
projectDir: string,
|
||
space: string,
|
||
repo: string,
|
||
fn: () => T extends Promise<unknown> ? never : T,
|
||
): T extends Promise<unknown> ? never : T {
|
||
return withAuditLock(
|
||
projectDir,
|
||
fn,
|
||
codekbLockIntent(repo),
|
||
space,
|
||
);
|
||
}
|
||
|
||
// Snapshot the two generations a scan is built from. The stage takes this
|
||
// immediately before scanning and passes both tokens to codekb-publish.
|
||
function handleCodekbSnapshot(
|
||
projectDir: string,
|
||
flags: Record<string, string>,
|
||
): void {
|
||
const { space, repo, repoDir, storeDir, excludes } =
|
||
resolveCodekbRepo(projectDir, flags);
|
||
const paths = codekbPaths(flags, "codekb-snapshot");
|
||
const snapshot = withCodekbLock(projectDir, space, repo, () => {
|
||
recoverCodekbTransactions(projectDir, space, repo);
|
||
const sourceFingerprint = codekbSourceFingerprint(repoDir, paths, excludes);
|
||
if (sourceFingerprint === null) {
|
||
die(`codekb-snapshot: cannot fingerprint source paths: ${paths.join(", ")}`);
|
||
}
|
||
return {
|
||
repo,
|
||
store: `${relativeCodekbDir(projectDir, repo, space)}/`,
|
||
paths,
|
||
store_generation: codekbStoreGeneration(storeDir),
|
||
source_fingerprint: sourceFingerprint,
|
||
};
|
||
});
|
||
if (flags.json === "true") {
|
||
process.stdout.write(`${JSON.stringify(snapshot)}\n`);
|
||
return;
|
||
}
|
||
process.stdout.write(
|
||
`STORE_GENERATION ${snapshot.store_generation}\n` +
|
||
`SOURCE_FINGERPRINT ${snapshot.source_fingerprint}\n` +
|
||
`SOURCE_PATHS ${snapshot.paths.join(",")}\n`,
|
||
);
|
||
}
|
||
|
||
function readCodekbCandidate(
|
||
projectDir: string,
|
||
stagedFlag: string | undefined,
|
||
): {
|
||
files: Map<string, Buffer>;
|
||
scope: Extract<ReturnType<typeof parseReScope>, { ok: true }>["scope"];
|
||
} {
|
||
if (!stagedFlag) {
|
||
die("codekb-publish: pass --staged <directory-containing-all-nine-artifacts>");
|
||
}
|
||
const stagedPath = resolve(projectDir, stagedFlag);
|
||
const rel = relative(projectDir, stagedPath);
|
||
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||
die("codekb-publish: --staged must resolve inside the project directory");
|
||
}
|
||
let stagedStat: ReturnType<typeof lstatSync>;
|
||
try {
|
||
stagedStat = lstatSync(stagedPath);
|
||
} catch {
|
||
die(`codekb-publish: staged directory not found: ${stagedFlag}`);
|
||
}
|
||
if (!stagedStat.isDirectory() || stagedStat.isSymbolicLink()) {
|
||
die("codekb-publish: --staged must be a real directory, not a symlink");
|
||
}
|
||
const projectReal = realpathSync(projectDir);
|
||
const stagedDir = realpathSync(stagedPath);
|
||
const realRel = relative(projectReal, stagedDir);
|
||
if (
|
||
realRel === "" ||
|
||
realRel === ".." ||
|
||
realRel.startsWith(`..${sep}`) ||
|
||
isAbsolute(realRel)
|
||
) {
|
||
die("codekb-publish: --staged must not escape the project through a symlinked ancestor");
|
||
}
|
||
const entries = readdirSync(stagedDir).sort();
|
||
const required = [...CODEKB_ARTIFACT_FILES].sort();
|
||
if (JSON.stringify(entries) !== JSON.stringify(required)) {
|
||
die(
|
||
`codekb-publish: staged directory must contain exactly the nine CodeKB artifacts; ` +
|
||
`found: ${entries.join(", ") || "(empty)"}`,
|
||
);
|
||
}
|
||
const files = new Map<string, Buffer>();
|
||
for (const name of required) {
|
||
const path = join(stagedDir, name);
|
||
const stat = lstatSync(path);
|
||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||
die(`codekb-publish: staged artifact must be a regular file: ${name}`);
|
||
}
|
||
files.set(name, readFileSync(path));
|
||
}
|
||
const parsed = parseReScope(
|
||
files.get("reverse-engineering-timestamp.md")?.toString("utf-8") ?? "",
|
||
);
|
||
if (!parsed.ok) {
|
||
die(
|
||
`codekb-publish: staged reverse-engineering-timestamp.md has an invalid ` +
|
||
`Scope of Analysis block (${parsed.reason}: ${parsed.detail})`,
|
||
);
|
||
}
|
||
return { files, scope: parsed.scope };
|
||
}
|
||
|
||
function handleCodekbPublish(
|
||
projectDir: string,
|
||
flags: Record<string, string>,
|
||
): void {
|
||
const { space, repo, repoDir, storeDir, excludes } =
|
||
resolveCodekbRepo(projectDir, flags);
|
||
const expectedStore = flags["expect-store"];
|
||
const expectedSource = flags["expect-source"];
|
||
if (!expectedStore || !expectedSource) {
|
||
die(
|
||
"codekb-publish: pass --expect-store <generation> and --expect-source <fingerprint> from codekb-snapshot",
|
||
);
|
||
}
|
||
const sourcePaths = codekbPaths(flags, "codekb-publish");
|
||
const candidate = readCodekbCandidate(projectDir, flags.staged);
|
||
for (const path of candidate.scope.analyzedPaths) {
|
||
if (!sourcePaths.includes("./") && !scopePathCovered(sourcePaths, path)) {
|
||
die(
|
||
`codekb-publish: snapshot paths do not cover candidate analyzed path "${path}"; ` +
|
||
`take a fresh codekb-snapshot over the complete candidate scope`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const result = withCodekbLock(projectDir, space, repo, () => {
|
||
recoverCodekbTransactions(projectDir, space, repo);
|
||
const currentStore = codekbStoreGeneration(storeDir);
|
||
if (currentStore !== expectedStore) {
|
||
die(
|
||
`CODEKB_STORE_CHANGED: expected ${expectedStore}, found ${currentStore}. ` +
|
||
`Re-read the current store, re-merge the staged scan, take a fresh snapshot, and retry.`,
|
||
);
|
||
}
|
||
const currentSource = codekbSourceFingerprint(repoDir, sourcePaths, excludes);
|
||
if (currentSource === null || currentSource !== expectedSource) {
|
||
die(
|
||
`CODEKB_SOURCE_CHANGED: expected ${expectedSource}, found ${currentSource ?? "unavailable"}. ` +
|
||
`Re-scan the affected source, re-synthesize all nine artifacts, take a fresh snapshot, and retry.`,
|
||
);
|
||
}
|
||
const currentCandidateFingerprint = codekbScopeFingerprint(
|
||
repoDir,
|
||
candidate.scope.analyzedPaths,
|
||
excludes,
|
||
);
|
||
if (
|
||
candidate.scope.fingerprint !== currentCandidateFingerprint &&
|
||
!(candidate.scope.fingerprint === null && currentCandidateFingerprint === null)
|
||
) {
|
||
die(
|
||
`CODEKB_CANDIDATE_STALE: staged fingerprint ` +
|
||
`${candidate.scope.fingerprint ?? "unknown"} does not match the current source ` +
|
||
`${currentCandidateFingerprint ?? "unknown"}. Re-mint the timestamp and retry.`,
|
||
);
|
||
}
|
||
|
||
const txn = join(
|
||
codekbTransactionRoot(projectDir, space, repo),
|
||
`${process.pid}-${randomUUID()}`,
|
||
);
|
||
const next = join(txn, "next");
|
||
const backup = join(txn, "backup");
|
||
mkdirSync(next, { recursive: true });
|
||
try {
|
||
for (const [name, bytes] of candidate.files) {
|
||
writeFileSync(join(next, name), bytes);
|
||
}
|
||
mkdirSync(dirname(storeDir), { recursive: true });
|
||
const hadStore = existsSync(storeDir);
|
||
if (hadStore) renameSync(storeDir, backup);
|
||
try {
|
||
renameSync(next, storeDir);
|
||
} catch (error) {
|
||
if (hadStore && existsSync(backup) && !existsSync(storeDir)) {
|
||
renameSync(backup, storeDir);
|
||
}
|
||
throw error;
|
||
}
|
||
rmSync(backup, { recursive: true, force: true });
|
||
return {
|
||
repo,
|
||
published: `${relativeCodekbDir(projectDir, repo, space)}/`,
|
||
generation: codekbStoreGeneration(storeDir),
|
||
};
|
||
} finally {
|
||
rmSync(txn, { recursive: true, force: true });
|
||
}
|
||
});
|
||
process.stdout.write(
|
||
flags.json === "true"
|
||
? `${JSON.stringify(result)}\n`
|
||
: `PUBLISHED ${result.published} ${result.generation}\n`,
|
||
);
|
||
}
|
||
|
||
// `aidlc-utility.ts codekb-scope-diff [--repo <name>] [--compare <timestamp.md>]
|
||
// [--json]` - read-only. The deterministic half of the reverse-engineering
|
||
// rerun guard (the store is shared space-level knowledge; compare mode reports
|
||
// which paths/components are no longer claimed as verified deep coverage).
|
||
//
|
||
// Status mode (default): parse the STORE's reverse-engineering-timestamp.md
|
||
// scope block and recompute the content fingerprint over its analyzed paths.
|
||
// NO_STORE no store timestamp - first scan, nothing to guard
|
||
// CURRENT fingerprint matches - the store's deep knowledge is exact
|
||
// STALE analyzed paths changed since the store was built
|
||
// UNVERIFIED scope parsed but no/uncomputable fingerprint (non-git)
|
||
// UNKNOWN_SCOPE block absent (legacy store) or malformed
|
||
//
|
||
// Compare mode (--compare <incoming timestamp.md>): does the incoming run's
|
||
// analyzed scope cover the store's? COVERS, or NARROWER + the exact paths and
|
||
// components an overwrite would discard.
|
||
//
|
||
// Mint mode (--mint --paths <a,b,...>): print the content fingerprint over
|
||
// the given repo-relative paths - the value the architect writes into the
|
||
// scope block's `fingerprint:` line at synthesis time. Prints `unknown` when
|
||
// not computable (non-git or invalid pathspec), which the block records
|
||
// verbatim.
|
||
//
|
||
// Always exits 0 with the verdict in the output (read-only query - mirrors
|
||
// codekb-path; refusals are for lifecycle verbs). No mkdir, no state write,
|
||
// no audit.
|
||
function handleCodekbScopeDiff(projectDir: string, flags: Record<string, string>): void {
|
||
const asJson = flags.json === "true";
|
||
const selection = resolveWorkflowSelection(projectDir);
|
||
const space = selection.space;
|
||
const repo = flags.repo && flags.repo.length > 0
|
||
? flags.repo
|
||
: codekbRepoName(projectDir, space, selection.intent ?? undefined);
|
||
const storeDir = relativeCodekbDir(projectDir, repo, space);
|
||
const storePath = join(projectDir, ...storeDir.split("/"), "reverse-engineering-timestamp.md");
|
||
|
||
// The repo's source root: the sibling dir `<workspace>/<repo>/` when it
|
||
// exists (the multi-repo layout reverse-engineering.md Step 1 scans), else
|
||
// the workspace root itself (the lone-repo case, where codekbRepoName is
|
||
// basename(projectDir)).
|
||
const siblingDir = join(projectDir, repo);
|
||
const repoDir = existsSync(siblingDir) && statSync(siblingDir).isDirectory() ? siblingDir : projectDir;
|
||
// In the lone-repo layout the framework-owned aidlc workspace tree lives
|
||
// under the repository root. Exclude it from full-root fingerprints so
|
||
// writing the scope draft, codekb, audit, or state cannot stale its own hash.
|
||
const fingerprintExcludes = repoDir === projectDir ? ["aidlc"] : [];
|
||
|
||
if (flags.mint === "true") {
|
||
const paths = (flags.paths ?? "")
|
||
.split(",")
|
||
.map((p) => p.trim())
|
||
.filter((p) => p !== "");
|
||
if (paths.length === 0) {
|
||
die("codekb-scope-diff --mint: pass --paths <comma-separated repo-relative paths>");
|
||
}
|
||
const fp = codekbScopeFingerprint(repoDir, paths, fingerprintExcludes) ?? "unknown";
|
||
if (asJson) process.stdout.write(`${JSON.stringify({ repo, fingerprint: fp, paths })}\n`);
|
||
else process.stdout.write(`${fp}\n`);
|
||
return;
|
||
}
|
||
|
||
const emit = (payload: Record<string, unknown>, human: string): void => {
|
||
if (asJson) process.stdout.write(`${JSON.stringify({ repo, store: `${storeDir}/`, ...payload })}\n`);
|
||
else process.stdout.write(`${human}\n`);
|
||
};
|
||
|
||
if (!existsSync(storePath)) {
|
||
emit(
|
||
{ verdict: "NO_STORE" },
|
||
`NO_STORE: no reverse-engineering-timestamp.md at ${storeDir}/ - first scan, nothing to compare.`,
|
||
);
|
||
return;
|
||
}
|
||
const parsed = parseReScope(readFileSync(storePath, "utf-8"));
|
||
if (!parsed.ok) {
|
||
emit(
|
||
{ verdict: "UNKNOWN_SCOPE", reason: parsed.reason, detail: parsed.detail },
|
||
`UNKNOWN_SCOPE (${parsed.reason}): ${parsed.detail}. The store predates scope tracking. A focused merge may retain its prose, but prior paths and components are not claimed as verified coverage until rescanned.`,
|
||
);
|
||
return;
|
||
}
|
||
const store = parsed.scope;
|
||
|
||
if (flags.compare !== undefined) {
|
||
const incomingPath = flags.compare;
|
||
if (!incomingPath || !existsSync(incomingPath)) {
|
||
die(`codekb-scope-diff --compare: file not found: ${incomingPath || "(missing path)"}`);
|
||
}
|
||
const incomingParsed = parseReScope(readFileSync(incomingPath, "utf-8"));
|
||
if (!incomingParsed.ok) {
|
||
emit(
|
||
{ verdict: "UNKNOWN_SCOPE", reason: incomingParsed.reason, detail: `incoming: ${incomingParsed.detail}` },
|
||
`UNKNOWN_SCOPE (incoming ${incomingParsed.reason}): ${incomingParsed.detail}.`,
|
||
);
|
||
return;
|
||
}
|
||
const incoming = incomingParsed.scope;
|
||
const fullScopeDowngrade = store.kind === "full" && incoming.kind !== "full";
|
||
const discardedPaths =
|
||
incoming.kind === "full"
|
||
? []
|
||
: fullScopeDowngrade
|
||
? [...store.analyzedPaths]
|
||
: store.analyzedPaths.filter((p) => !scopePathCovered(incoming.analyzedPaths, p));
|
||
const discardedComponents =
|
||
incoming.kind === "full"
|
||
? []
|
||
: store.analyzedComponents.filter((c) => !incoming.analyzedComponents.includes(c));
|
||
const narrower = discardedPaths.length > 0 || discardedComponents.length > 0;
|
||
const payload = {
|
||
verdict: narrower ? "NARROWER" : "COVERS",
|
||
store_intent: store.intent,
|
||
incoming_intent: incoming.intent,
|
||
discarded_paths: discardedPaths,
|
||
discarded_components: discardedComponents,
|
||
};
|
||
if (narrower) {
|
||
emit(
|
||
payload,
|
||
`NARROWER: the incoming scope no longer claims verified deep coverage for:\n` +
|
||
discardedPaths.map((p) => ` - ${p}`).join("\n") +
|
||
(discardedComponents.length > 0
|
||
? `\n components: ${discardedComponents.join(", ")}`
|
||
: "") +
|
||
`\n(store intent: ${store.intent || "unrecorded"}; incoming intent: ${incoming.intent || "unrecorded"})`,
|
||
);
|
||
} else {
|
||
emit(payload, `COVERS: the incoming scan covers everything the store analyzed.`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Status mode.
|
||
const currentFingerprint =
|
||
store.analyzedPaths.length > 0
|
||
? codekbScopeFingerprint(repoDir, store.analyzedPaths, fingerprintExcludes)
|
||
: null;
|
||
const scopeLines = store.analyzedPaths.map((p) => ` - ${p}`).join("\n");
|
||
if (store.fingerprint === null || currentFingerprint === null) {
|
||
emit(
|
||
{
|
||
verdict: "UNVERIFIED",
|
||
store_intent: store.intent,
|
||
kind: store.kind,
|
||
analyzed_paths: store.analyzedPaths,
|
||
detail: store.fingerprint === null ? "store has no fingerprint" : "fingerprint not computable here",
|
||
},
|
||
`UNVERIFIED: the store (intent: ${store.intent || "unrecorded"}) analyzed:\n${scopeLines}\n` +
|
||
`but ${store.fingerprint === null ? "recorded no fingerprint" : "the current tree's fingerprint cannot be computed"} - freshness unknown.`,
|
||
);
|
||
return;
|
||
}
|
||
const current = store.fingerprint === currentFingerprint;
|
||
emit(
|
||
{
|
||
verdict: current ? "CURRENT" : "STALE",
|
||
store_intent: store.intent,
|
||
kind: store.kind,
|
||
analyzed_paths: store.analyzedPaths,
|
||
store_fingerprint: store.fingerprint,
|
||
current_fingerprint: currentFingerprint,
|
||
},
|
||
current
|
||
? `CURRENT: the analyzed paths are unchanged since the store was built (intent: ${store.intent || "unrecorded"}, coverage: ${store.kind}):\n${scopeLines}`
|
||
: `STALE: the analyzed paths have changed since the store was built (intent: ${store.intent || "unrecorded"}):\n${scopeLines}`,
|
||
);
|
||
}
|
||
|
||
// `detect [--json]` - read-only. Runs the workspace scan (detectWorkspace) on
|
||
// the bare project dir - it needs no aidlc/ workspace; it scans the app root -
|
||
// and prints projectType (Greenfield/Brownfield), languages, frameworks, and
|
||
// buildSystem. ALSO prints the resolved scope-registry paths (scopesDir +
|
||
// scopeGridPath): those are module-relative to the installed tool, which a
|
||
// prose agent cannot derive itself, so the composer agent is TOLD where the
|
||
// runtime reads scope data (and therefore where an authored scope must land).
|
||
// Writes nothing, no audit, no mkdir - mirrors codekb-path's read-only shape.
|
||
function handleDetect(projectDir: string, flags: Record<string, string>): void {
|
||
const scan = detectWorkspace(projectDir);
|
||
const payload = {
|
||
projectType: scan.projectType,
|
||
languages: scan.languages,
|
||
frameworks: scan.frameworks,
|
||
buildSystem: scan.buildSystem,
|
||
...(scan.nestedRoot ? { nestedRoot: scan.nestedRoot } : {}),
|
||
submodules: scan.submodules,
|
||
scopesDir: scopesDir(),
|
||
scopeGridPath: scopeGridPath(),
|
||
scopes: [...validScopes()],
|
||
};
|
||
if (flags.json === "true") {
|
||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||
return;
|
||
}
|
||
const uninitCount = scan.submodules.filter((s) => !s.initialized).length;
|
||
const submoduleLine =
|
||
scan.submodules.length > 0
|
||
? `Submodules: ${scan.submodules.length} declared, ${uninitCount} uninitialized\n`
|
||
: "";
|
||
process.stdout.write(
|
||
`Project type: ${payload.projectType}\n` +
|
||
`Languages: ${payload.languages}\n` +
|
||
`Frameworks: ${payload.frameworks}\n` +
|
||
`Build system: ${payload.buildSystem}\n` +
|
||
(scan.nestedRoot ? `Nested root: ${scan.nestedRoot}\n` : "") +
|
||
submoduleLine +
|
||
`Scopes dir: ${payload.scopesDir}\n` +
|
||
`Scope grid: ${payload.scopeGridPath}\n` +
|
||
`Valid scopes: ${payload.scopes.join(", ")}\n`,
|
||
);
|
||
}
|
||
|
||
// `/aidlc space create <name>` (legacy `/aidlc space-create <name>`) - seed a NEW space's memory. org.md is copied
|
||
// from spaces/default/memory/org.md (the always-present SEED baseline), plus
|
||
// fresh empty team.md/project.md/phases stubs + the templates/ floor. A new team
|
||
// starts at the framework baseline and earns its OWN practices — it does NOT
|
||
// inherit another space's learnings. (A new INTENT, by contrast, seeds nothing:
|
||
// it reads its space's live memory — handled in createIntent.)
|
||
function handleSpaceCreate(projectDir: string, positional: string[], _flags: Record<string, string>): void {
|
||
const raw = positional[1];
|
||
if (!raw) die("Usage: aidlc-utility space-create <name>");
|
||
// A help-shaped arg is a help request, not a name. Checked BEFORE slugify:
|
||
// slugify("-h") is "h", which is not a reserved name, so the guard below
|
||
// would let it through and a junk space would be created.
|
||
if (raw === "-h" || raw === "help") {
|
||
die("Did you mean /aidlc --help? To create a space, pass a name: /aidlc space-create <name>.");
|
||
}
|
||
const name = slugify(raw);
|
||
// "help" is grammar (`space help` prints help), so a space with that slug
|
||
// would be unswitchable by name - refuse it here, the creation chokepoint.
|
||
if (RESERVED_RECORD_NAMES.has(name)) {
|
||
die(
|
||
`"${name}" is a reserved name and cannot be a space name. Pick a name that describes the team.`
|
||
);
|
||
}
|
||
const dest = join(spacesRoot(projectDir), name);
|
||
if (existsSync(dest)) die(`Space "${name}" already exists at ${dest}.`);
|
||
|
||
const memoryDest = join(dest, "memory");
|
||
mkdirSync(memoryDest, { recursive: true });
|
||
mkdirSync(join(memoryDest, "phases"), { recursive: true });
|
||
mkdirSync(join(memoryDest, "templates"), { recursive: true });
|
||
mkdirSync(join(dest, "intents"), { recursive: true });
|
||
// #5 — a new space gets the FULL space shape so it matches default's
|
||
// committed layout (vision §11.2 "identical shape"): the space-level codekb/
|
||
// and knowledge/ siblings of memory/intents. Built as bare parents — the
|
||
// per-repo codekb/<repo>/ subdir is authored later by RE/codekb-path (no repo
|
||
// is recorded at create time, so codekbDir() can't be called here), and
|
||
// knowledge/ is free-form/empty at bootstrap. .gitkeep floors so the empty
|
||
// dirs track (codekb output is COMMITTED, so the floor is not gitignored).
|
||
mkdirSync(join(dest, "codekb"), { recursive: true });
|
||
mkdirSync(knowledgeDir(projectDir, name), { recursive: true });
|
||
|
||
// Copy the org.md baseline from the default space (the always-present SEED
|
||
// shell). If absent (a malformed shell), fall back to an empty stub rather
|
||
// than dying — the resolver tolerates an empty/absent rules dir.
|
||
const orgSrc = join(spacesRoot(projectDir), DEFAULT_SPACE, "memory", "org.md");
|
||
const orgDest = join(memoryDest, "org.md");
|
||
if (existsSync(orgSrc)) {
|
||
writeFileSync(orgDest, readFileSync(orgSrc, "utf-8"), "utf-8");
|
||
} else {
|
||
writeFileSync(orgDest, "# Organization defaults\n", "utf-8");
|
||
}
|
||
// Fresh empty team/project stubs (a new team earns its own practices).
|
||
if (!existsSync(join(memoryDest, "team.md"))) {
|
||
writeFileSync(join(memoryDest, "team.md"), "# Team practices\n", "utf-8");
|
||
}
|
||
if (!existsSync(join(memoryDest, "project.md"))) {
|
||
writeFileSync(join(memoryDest, "project.md"), "# Project overrides\n", "utf-8");
|
||
}
|
||
// templates/ floor marker so the empty dir is tracked (mirrors SEED's floor).
|
||
const floor = join(memoryDest, "templates", ".gitkeep");
|
||
if (!existsSync(floor)) writeFileSync(floor, "", "utf-8");
|
||
// codekb/ + knowledge/ floors so the empty siblings track (both committed).
|
||
const codekbFloor = join(dest, "codekb", ".gitkeep");
|
||
if (!existsSync(codekbFloor)) writeFileSync(codekbFloor, "", "utf-8");
|
||
const knowledgeFloor = join(knowledgeDir(projectDir, name), ".gitkeep");
|
||
if (!existsSync(knowledgeFloor)) writeFileSync(knowledgeFloor, "", "utf-8");
|
||
|
||
process.stdout.write(
|
||
`Space created: ${name}\n memory/org.md (copied from default), team.md, project.md, phases/, templates/, codekb/, knowledge/\nSwitch to it with /aidlc space ${name}.\n`
|
||
);
|
||
}
|
||
|
||
|
||
// Caller is responsible for applying any scope- or project-type-specific
|
||
// downgrades (e.g., reverse-engineering SKIP for greenfield) to the mapping
|
||
// before calling this helper. Walks post-init stages and returns the slug of
|
||
// the first EXECUTE entry.
|
||
function determineFirstPostInitStage(
|
||
adjustedMapping: Record<string, string>,
|
||
graph: StageEntry[]
|
||
): string {
|
||
for (const stage of graph) {
|
||
if (stage.phase === "initialization") continue;
|
||
const action = adjustedMapping[stage.slug] || "SKIP";
|
||
if (action === "EXECUTE") {
|
||
return stage.slug;
|
||
}
|
||
}
|
||
return "intent-capture"; // fallback
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// scope-change — atomically change scope on an existing workflow
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function handleScopeChange(projectDir: string, flags: Record<string, string>): void {
|
||
const newScope = flags.scope;
|
||
if (!newScope) die("--scope is required for scope-change");
|
||
|
||
const depthOverride = flags.depth;
|
||
if (depthOverride && !VALID_DEPTHS[depthOverride.toLowerCase()]) {
|
||
die(`Unknown depth: "${depthOverride}". Valid depths: minimal, standard, comprehensive.`);
|
||
}
|
||
|
||
const testStrategyOverride = flags["test-strategy"];
|
||
if (testStrategyOverride && !VALID_TEST_STRATEGIES[testStrategyOverride.toLowerCase()]) {
|
||
die(`Unknown test strategy: "${testStrategyOverride}". Valid: minimal, standard, comprehensive.`);
|
||
}
|
||
const reviewOverride = parseReviewOverride(flags.review);
|
||
|
||
const selection = resolveWorkflowSelection(projectDir, {
|
||
intent: flags.intent,
|
||
space: flags.space,
|
||
});
|
||
const intent = selection.intent ?? undefined;
|
||
const space = selection.space;
|
||
const sp = stateFilePath(projectDir, intent, space);
|
||
if (!existsSync(sp)) die("No state file found. Start a workflow first by describing what to build (/aidlc \"build the auth service\").");
|
||
|
||
const scopeMapping = loadScopeMapping();
|
||
const newScopeDef = scopeMapping[newScope];
|
||
if (!newScopeDef) die(`Unknown scope: ${newScope}. Valid scopes: ${Object.keys(scopeMapping).join(", ")}`);
|
||
|
||
const contentBefore = readStateFile(projectDir, intent, space);
|
||
const before = resolveChangeControl(projectDir, contentBefore, {
|
||
selection: { intent, space },
|
||
});
|
||
let content = contentBefore;
|
||
// AUTONOMY GUARD (the recompose guard's twin, same rationale): scope-change
|
||
// flips stage EXECUTE/SKIP suffixes exactly like recompose does, so an
|
||
// unattended autonomous Construction run must not have its plan re-shaped
|
||
// through this verb either - there is no human at the gate to approve the
|
||
// new shape. Guard placed before the same-scope early exit (fail-fast, the
|
||
// recompose posture): under autonomy even a no-op call is refused, so the
|
||
// conductor learns the rule on first contact rather than on the first
|
||
// differing scope. Uses the exported isAutonomousMode predicate per its
|
||
// contract (new gate sites use the helper; only pre-existing open-coded
|
||
// sites are grandfathered), so this site cannot drift from the others.
|
||
if (isAutonomousMode(content)) {
|
||
die(
|
||
"Cannot change scope while Construction is running unattended (Construction Autonomy Mode " +
|
||
"is autonomous). Changing the plan needs someone to approve it, and nobody is being asked " +
|
||
"right now. Either switch back to stopping for approval at each Bolt " +
|
||
"(aidlc-bolt set-autonomy --mode gated) or wait for the current build to finish, then change scope.",
|
||
);
|
||
}
|
||
const oldScope = getField(content, "Scope");
|
||
if (!oldScope) die("Cannot read current Scope from state file.");
|
||
|
||
if (oldScope === newScope) {
|
||
if (depthOverride || testStrategyOverride || reviewOverride !== undefined) {
|
||
handleConfigChange(projectDir, flags);
|
||
return;
|
||
}
|
||
process.stdout.write(`Scope is already ${newScope}\n`);
|
||
return;
|
||
}
|
||
|
||
const graph = loadStageGraph();
|
||
const projectType = getField(content, "Project Type") || "Greenfield";
|
||
|
||
// Compute adjusted mapping (greenfield reverse-engineering adjustment)
|
||
const adjustedMapping = { ...newScopeDef.stages };
|
||
if (projectType.toLowerCase() === "greenfield") {
|
||
if (adjustedMapping["reverse-engineering"] === "EXECUTE") {
|
||
adjustedMapping["reverse-engineering"] = "SKIP";
|
||
}
|
||
}
|
||
|
||
// Compute new execute/skip lists
|
||
const executeStages: string[] = [];
|
||
const skipStages: string[] = [];
|
||
for (const stage of graph) {
|
||
const action = adjustedMapping[stage.slug] || "SKIP";
|
||
if (action === "EXECUTE") {
|
||
executeStages.push(stage.number);
|
||
} else {
|
||
let reason = stage.slug;
|
||
if (stage.slug === "reverse-engineering" && projectType.toLowerCase() === "greenfield" &&
|
||
newScopeDef.stages["reverse-engineering"] === "EXECUTE") {
|
||
reason += " — greenfield";
|
||
}
|
||
skipStages.push(`${stage.number} (${reason})`);
|
||
}
|
||
}
|
||
|
||
// Parse existing checkboxes to preserve states
|
||
const existingCheckboxes = parseCheckboxes(content);
|
||
const existingMap = new Map(existingCheckboxes.map(c => [c.slug, c]));
|
||
|
||
// Rebuild Stage Progress section
|
||
const phaseMap: Record<string, typeof graph> = {};
|
||
for (const stage of graph) {
|
||
if (!phaseMap[stage.phase]) phaseMap[stage.phase] = [];
|
||
phaseMap[stage.phase].push(stage);
|
||
}
|
||
|
||
const phaseHeaders: Record<string, string> = {
|
||
initialization: "INITIALIZATION PHASE",
|
||
ideation: "IDEATION PHASE",
|
||
inception: "INCEPTION PHASE",
|
||
construction: "CONSTRUCTION PHASE",
|
||
operation: "OPERATION PHASE",
|
||
};
|
||
|
||
let newStageProgress = "";
|
||
for (const phase of PHASES) {
|
||
const stages = phaseMap[phase] || [];
|
||
newStageProgress += `\n### ${phaseHeaders[phase]}\n`;
|
||
if (phase === "construction") {
|
||
// Preserve existing "Per unit:" line
|
||
const perUnitMatch = content.match(/^Per unit:.*$/m);
|
||
if (perUnitMatch) {
|
||
newStageProgress += `${perUnitMatch[0]}\n`;
|
||
}
|
||
}
|
||
for (const stage of stages) {
|
||
const action = adjustedMapping[stage.slug] || "SKIP";
|
||
const existing = existingMap.get(stage.slug);
|
||
// Preserve existing checkbox state, default to [ ] if not found
|
||
const marker = existing
|
||
? `[${existing.state === "completed" ? "x" : existing.state === "in-progress" ? "-" : existing.state === "skipped" ? "S" : " "}]`
|
||
: "[ ]";
|
||
const suffix = action === "EXECUTE" ? "EXECUTE" : "SKIP";
|
||
newStageProgress += `- ${marker} ${stage.slug} \u2014 ${suffix}\n`;
|
||
}
|
||
}
|
||
|
||
// Replace Stage Progress section in content
|
||
const stageProgressRegex = /## Stage Progress\n<!-- [^\n]* -->\n([\s\S]*?)(?=\n## (?!Stage Progress))/;
|
||
const stageProgressHeader = "## Stage Progress\n<!-- Checkbox states: [ ] not started, [-] in progress, [x] completed, [S] skipped via --stage/--phase jump -->\n";
|
||
content = content.replace(stageProgressRegex, stageProgressHeader + newStageProgress);
|
||
|
||
// Update fields
|
||
content = setField(content, "Scope", newScope);
|
||
content = setField(content, "Stages to Execute", executeStages.join(", "));
|
||
content = setField(content, "Stages to Skip", skipStages.length > 0 ? skipStages.join(", ") : "none");
|
||
const effectiveDepth = depthOverride
|
||
? VALID_DEPTHS[depthOverride.toLowerCase()]
|
||
: newScopeDef.depth;
|
||
content = setField(content, "Depth", effectiveDepth);
|
||
const effectiveTestStrategy = testStrategyOverride
|
||
? VALID_TEST_STRATEGIES[testStrategyOverride.toLowerCase()]
|
||
: (newScopeDef.testStrategy ?? effectiveDepth);
|
||
content = setField(content, "Test Strategy", effectiveTestStrategy);
|
||
// A Change Control line the scope supplied follows the new scope; a value
|
||
// the human set, a legacy intent with no line, or a memory override stays.
|
||
const scopeSuppliedChangeControl = before.intent?.source.startsWith("scope ") === true;
|
||
const scopeChangeControl = newScopeDef.changeControl ?? "strict";
|
||
if (scopeSuppliedChangeControl) {
|
||
content = setField(
|
||
content,
|
||
CHANGE_CONTROL_FIELD,
|
||
formatChangeControl(scopeChangeControl, `scope ${newScope}`),
|
||
);
|
||
}
|
||
const reviewUpdate = applyReviewOverride(content, reviewOverride);
|
||
content = reviewUpdate.content;
|
||
content = setField(content, "Total Stages", String(executeStages.length));
|
||
|
||
// Recount completed based on actual [x] count of in-scope EXECUTE stages
|
||
const updatedCheckboxes = parseCheckboxes(content);
|
||
const executeSlugs = new Set(
|
||
graph.filter(s => (adjustedMapping[s.slug] || "SKIP") === "EXECUTE").map(s => s.slug)
|
||
);
|
||
const completedCount = updatedCheckboxes.filter(
|
||
c => c.state === "completed" && executeSlugs.has(c.slug)
|
||
).length;
|
||
content = setField(content, "Completed", String(completedCount));
|
||
|
||
// Re-derive the not-yet-reached Phase Progress rows against the new plan:
|
||
// a phase the new scope leaves without EXECUTE stages reads Skipped, one it
|
||
// (re-)includes reads Pending. Verified/Active rows record history the
|
||
// scope change does not rewrite (checkbox states are preserved above for
|
||
// the same reason), so they are left untouched.
|
||
for (const phase of PHASES) {
|
||
const label = phase.charAt(0).toUpperCase() + phase.slice(1);
|
||
const row = getField(content, label);
|
||
if (row !== "Pending" && row !== "Skipped") continue;
|
||
const hasExecute = graph.some(
|
||
(s) => s.phase === phase && (adjustedMapping[s.slug] || "SKIP") === "EXECUTE"
|
||
);
|
||
content = setPhaseProgress(content, phase, hasExecute ? "Pending" : "Skipped");
|
||
}
|
||
|
||
// Update Last Updated timestamp
|
||
content = setField(content, "Last Updated", isoTimestamp());
|
||
|
||
const after = resolveChangeControl(projectDir, content, {
|
||
selection: { intent, space },
|
||
});
|
||
// Build every audit row before mutation. The batch lands in one append
|
||
// before the state write under the same lock, so append failure leaves both untouched.
|
||
const oldScopeDef = scopeMapping[oldScope];
|
||
const oldExecuteCount = oldScopeDef
|
||
? graph.filter(s => (oldScopeDef.stages[s.slug] || "SKIP") === "EXECUTE").length
|
||
: 0;
|
||
const stageDelta = executeStages.length - oldExecuteCount;
|
||
const deltaStr = stageDelta >= 0 ? `+${stageDelta}` : String(stageDelta);
|
||
|
||
// Ceremony preview for the switch: gate count from the effective grid (the
|
||
// reverse-engineering greenfield adjustment already applied) so the same
|
||
// disclosure exists on a scope switch as on the cold-start confirm.
|
||
const gates = gridCostSummary(
|
||
adjustedMapping as Record<string, "EXECUTE" | "SKIP">,
|
||
).gates;
|
||
const auditEntries: AuditEntryInput[] = [{
|
||
eventType: "SCOPE_CHANGED",
|
||
fields: {
|
||
"Old Scope": oldScope,
|
||
"New Scope": newScope,
|
||
"Stage Count Delta": deltaStr,
|
||
"Stages in Scope": String(executeStages.length),
|
||
"Approval Gates": String(gates),
|
||
Depth: effectiveDepth,
|
||
},
|
||
}];
|
||
if (reviewUpdate.changed) {
|
||
auditEntries.push({
|
||
eventType: "REVIEW_CLASS_CHANGED",
|
||
fields: {
|
||
"Old Override": reviewUpdate.oldReview || "none set",
|
||
"New Override": reviewUpdate.storedReview || "cleared (stage defaults apply)",
|
||
},
|
||
});
|
||
}
|
||
if (before.value !== after.value) {
|
||
auditEntries.push({
|
||
eventType: "CHANGE_CONTROL_SET",
|
||
fields: {
|
||
"Old Value": before.value,
|
||
"New Value": after.value,
|
||
Source: `scope ${newScope}`,
|
||
},
|
||
});
|
||
}
|
||
withAuditLock(projectDir, () => {
|
||
try {
|
||
if (before.value !== after.value) assertChangeControlLedgerWritable();
|
||
appendAuditEntries(auditEntries, projectDir, intent, space);
|
||
} catch (error) {
|
||
throw new Error(`Cannot record the scope change: ${errorMessage(error)}`);
|
||
}
|
||
writeStateFile(projectDir, content, intent, space);
|
||
}, intent, space);
|
||
|
||
process.stdout.write(
|
||
`Scope changed: ${oldScope} -> ${newScope}
|
||
Stages in scope: ${executeStages.length} (${deltaStr})
|
||
Approval gates: ${gates}
|
||
Depth: ${effectiveDepth}
|
||
${reviewOverride === undefined
|
||
? ""
|
||
: `Review override: ${reviewUpdate.storedReview || "adversarial (stage defaults)"}\n`}Completed: ${completedCount}/${executeStages.length}
|
||
`
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// recompose - flip a PENDING stage's plan suffix on the live state file
|
||
// (the adaptive composer's in-flight write). `--skip <slugs>` drops stages
|
||
// from the plan; `--add <slugs>` promotes them back (comma-separated). The
|
||
// whole mutation runs under withAuditLock; validation is STRICT (a starved
|
||
// required input rejects, not advises); the derived state fields are rebuilt
|
||
// the way scope-change rebuilds them; a RECOMPOSED audit event lands with the
|
||
// flip lists. A run that never calls recompose is byte-identical to before -
|
||
// the verb is inert when unused.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function handleRecompose(projectDir: string, flags: Record<string, string>): void {
|
||
const skipList = (flags.skip ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||
const addList = (flags.add ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||
if (skipList.length === 0 && addList.length === 0) {
|
||
die("Usage: recompose [--skip <slug,...>] [--add <slug,...>] - name at least one flip.");
|
||
}
|
||
const overlap = skipList.filter((s) => addList.includes(s));
|
||
if (overlap.length > 0) {
|
||
die(`Cannot both --skip and --add the same stage: ${overlap.join(", ")}.`);
|
||
}
|
||
|
||
const sp = stateFilePath(projectDir, flags.intent, flags.space);
|
||
if (!existsSync(sp)) {
|
||
die("No state file found. recompose re-shapes a RUNNING workflow; start one first.");
|
||
}
|
||
|
||
withAuditLock(projectDir, () => {
|
||
let content = readStateFile(projectDir, flags.intent, flags.space);
|
||
// AUTONOMY GUARD (mirrors the park guard's shape in aidlc-state.ts): an
|
||
// unattended autonomous Construction run has no human at the gate, so a
|
||
// conductor that drifts into "improving the plan" must not flip pending
|
||
// stages on its own. The SKILL.md prose says plan-reshape never runs under
|
||
// autonomous Construction on any harness; this is the deterministic anchor
|
||
// that enforcement was missing (the strict validator catches starvation and
|
||
// anchor moves, but not the absence of a human). Refuse outright; a
|
||
// legitimate unattended-recompose story, if one ever arrives, comes as an
|
||
// explicit flag, not the default.
|
||
if (getField(content, "Construction Autonomy Mode")?.trim() === "autonomous") {
|
||
die(
|
||
"Cannot change the plan while Construction is running unattended (Construction Autonomy " +
|
||
"Mode is autonomous). Changing the plan needs someone to approve it, and nobody is being " +
|
||
"asked right now. Either switch back to stopping for approval at each Bolt " +
|
||
"(aidlc-bolt set-autonomy --mode gated) or wait for the current build to finish, then recompose.",
|
||
);
|
||
}
|
||
// Only a RUNNING workflow has a live plan to re-shape. A Completed (or
|
||
// Parked/terminated) state file is a terminal record: flipping its rows
|
||
// would grow Total Stages under a summary computed at completion and
|
||
// leave no cursor to ever reach the added stage — a corrupted record,
|
||
// not a plan change. (With no cursor, the behind-cursor guard below is
|
||
// also inert, so this check is the only thing standing between recompose
|
||
// and a finished workflow.)
|
||
const wfStatus = getField(content, "Status") || "";
|
||
if (wfStatus !== "Running") {
|
||
die(
|
||
`Cannot recompose: workflow Status is "${wfStatus || "unknown"}", not Running. ` +
|
||
"Recompose re-shapes a LIVE plan; for finished work start a new workflow instead.",
|
||
);
|
||
}
|
||
const scope = getField(content, "Scope");
|
||
if (!scope) die("Cannot read current Scope from state file.");
|
||
const scopeDef = loadScopeMapping()[scope];
|
||
if (!scopeDef) die(`Unknown scope in state file: ${scope}.`);
|
||
|
||
const graph = loadStageGraph();
|
||
const knownSlugs = new Set(graph.map((s) => s.slug));
|
||
const checkboxes = parseCheckboxes(content);
|
||
const checkboxMap = new Map(checkboxes.map((c) => [c.slug, c.state]));
|
||
const suffixes = parseStateStageSuffixes(content);
|
||
const currentSlug = getField(content, "Current Stage") || "";
|
||
const currentIdx = graph.findIndex((s) => s.slug === currentSlug);
|
||
|
||
// The effective pre-flip plan (suffix override wins over the grid).
|
||
const effective = (slug: string): "EXECUTE" | "SKIP" => {
|
||
const v = suffixes.get(slug) ?? scopeDef.stages[slug];
|
||
return v === "EXECUTE" ? "EXECUTE" : "SKIP";
|
||
};
|
||
|
||
// --- Per-flip guards: pending-only, ahead-of-cursor, skeleton-gate ------
|
||
const reject = (slug: string, why: string): never =>
|
||
die(`Cannot recompose "${slug}": ${why}`);
|
||
|
||
for (const slug of [...skipList, ...addList]) {
|
||
if (!knownSlugs.has(slug)) {
|
||
reject(slug, "not a compiled stage.");
|
||
}
|
||
const state = checkboxMap.get(slug);
|
||
if (state === "completed" || state === "in-progress" || state === "skipped" ||
|
||
state === "awaiting-approval" || state === "revising") {
|
||
reject(slug, `its checkbox is not pending ([${state}]). Only a PENDING stage's plan can be re-shaped; completed/in-progress/skipped stages are frozen.`);
|
||
}
|
||
const idx = graph.findIndex((s) => s.slug === slug);
|
||
if (currentIdx !== -1 && idx !== -1 && idx <= currentIdx) {
|
||
reject(slug, `it is at or behind the current stage ("${currentSlug}"). In-flight recompose only reaches forward; re-running the past is out of scope.`);
|
||
}
|
||
}
|
||
|
||
// The walking-skeleton gate derivation keys off the FIRST construction
|
||
// EXECUTE stage (static). A flip that MOVES that anchor - skipping the
|
||
// current anchor, or adding a construction stage AHEAD of it - would
|
||
// silently relocate Bolt 1 and the skeleton stance round-trip. Compare
|
||
// the anchor before and after the proposed flips and reject any move
|
||
// (the cheapest sound answer; a suffix-aware gate derivation is a larger
|
||
// change this verb must not smuggle in).
|
||
const anchorOf = (plan: (slug: string) => "EXECUTE" | "SKIP"): string | undefined =>
|
||
graph.find((s) => s.phase === "construction" && plan(s.slug) === "EXECUTE")?.slug;
|
||
const anchorBefore = anchorOf(effective);
|
||
const anchorAfter = anchorOf((slug) => {
|
||
if (skipList.includes(slug)) return "SKIP";
|
||
if (addList.includes(slug)) return "EXECUTE";
|
||
return effective(slug);
|
||
});
|
||
if (anchorBefore !== anchorAfter) {
|
||
const mover =
|
||
anchorBefore && skipList.includes(anchorBefore) ? anchorBefore : (anchorAfter ?? anchorBefore ?? "construction");
|
||
reject(
|
||
mover,
|
||
`the flip moves the first EXECUTE stage of Construction (the walking-skeleton gate anchor) from "${anchorBefore ?? "none"}" to "${anchorAfter ?? "none"}". The skeleton gate must stay anchored; jump or change scope instead.`,
|
||
);
|
||
}
|
||
|
||
// --- Build the proposed effective grid and validate STRICT --------------
|
||
// Strictness is a DIFF against the pre-flip baseline: a stock scope may be
|
||
// CREATED with structural advisories (e.g. bugfix's code-generation consumes
|
||
// unit-of-work from the skipped units-generation - the scope author owns
|
||
// that upstream work), and those must not veto an unrelated flip. What the
|
||
// recompose validator hard-rejects is NEW starvation the flips introduce:
|
||
// any strict error present post-flip that was absent pre-flip.
|
||
const baseGrid: Record<string, string> = {};
|
||
for (const s of graph) baseGrid[s.slug] = effective(s.slug);
|
||
const proposed: Record<string, string> = { ...baseGrid };
|
||
for (const slug of skipList) proposed[slug] = "SKIP";
|
||
for (const slug of addList) proposed[slug] = "EXECUTE";
|
||
// Stages already completed [x] satisfy their consumers even if the plan
|
||
// now skips them - mark them EXECUTE for the dependency walk (in BOTH
|
||
// grids) so a flip after a producer already ran is not falsely starved.
|
||
for (const c of checkboxes) {
|
||
if (c.state === "completed") {
|
||
baseGrid[c.slug] = "EXECUTE";
|
||
proposed[c.slug] = "EXECUTE";
|
||
}
|
||
}
|
||
const projectType = (getField(content, "Project Type") || "").toLowerCase();
|
||
const pt = projectType === "brownfield" || projectType === "greenfield"
|
||
? (projectType as "brownfield" | "greenfield")
|
||
: undefined;
|
||
const label = `recomposed ${scope}`;
|
||
const baseErrors = new Set(
|
||
validateGrid(baseGrid, { strict: true, projectType: pt, label }).errors,
|
||
);
|
||
const validation = validateGrid(proposed, {
|
||
strict: true,
|
||
projectType: pt,
|
||
label,
|
||
});
|
||
const newErrors = validation.errors.filter((e) => !baseErrors.has(e));
|
||
if (newErrors.length > 0) {
|
||
die(
|
||
`Recompose rejected by the strict validator:\n${newErrors.map((e) => ` - ${e}`).join("\n")}`,
|
||
);
|
||
}
|
||
|
||
// --- Apply the suffix flips ---------------------------------------------
|
||
for (const slug of skipList) content = setStageSuffix(content, slug, "SKIP");
|
||
for (const slug of addList) content = setStageSuffix(content, slug, "EXECUTE");
|
||
|
||
// --- Rebuild the derived fields against the EFFECTIVE plan --------------
|
||
// (the scope-change set: Stages to Execute / to Skip / Total / Completed).
|
||
const postSuffixes = parseStateStageSuffixes(content);
|
||
const eff = (slug: string): "EXECUTE" | "SKIP" => {
|
||
const v = postSuffixes.get(slug) ?? scopeDef.stages[slug];
|
||
return v === "EXECUTE" ? "EXECUTE" : "SKIP";
|
||
};
|
||
// The Stages to Skip row carries creation/scope-change annotations (entry
|
||
// shape "<number> (<slug>)", e.g. "2.1 (reverse-engineering — greenfield)")
|
||
// that a bare-slug rebuild would destroy. Preserve each existing entry
|
||
// VERBATIM, in its existing position, when its stage is still skipped;
|
||
// drop entries whose stage was promoted; append newly-skipped stages in
|
||
// graph order, rendered the way scope-change renders them. A skip+add
|
||
// round trip therefore leaves the row byte-identical.
|
||
const priorSkipRow = getField(content, "Stages to Skip") || "";
|
||
const priorTokens =
|
||
priorSkipRow.trim() === "" || priorSkipRow.trim() === "none"
|
||
? []
|
||
: priorSkipRow.split(", ");
|
||
const slugOfSkipToken = (token: string): string => {
|
||
const m = /^\S+ \((.+)\)$/.exec(token);
|
||
const inner = m ? m[1] : token;
|
||
return inner.split(" — ")[0];
|
||
};
|
||
const executeStages: string[] = [];
|
||
const skipStages: string[] = [];
|
||
const preservedSlugs = new Set<string>();
|
||
for (const token of priorTokens) {
|
||
const slug = slugOfSkipToken(token);
|
||
if (knownSlugs.has(slug) && eff(slug) === "SKIP") {
|
||
skipStages.push(token);
|
||
preservedSlugs.add(slug);
|
||
}
|
||
}
|
||
for (const s of graph) {
|
||
if (eff(s.slug) === "EXECUTE") executeStages.push(s.number);
|
||
else if (!preservedSlugs.has(s.slug)) skipStages.push(`${s.number} (${s.slug})`);
|
||
}
|
||
content = setField(content, "Stages to Execute", executeStages.join(", "));
|
||
content = setField(content, "Stages to Skip", skipStages.length > 0 ? skipStages.join(", ") : "none");
|
||
content = setField(content, "Total Stages", String(executeStages.length));
|
||
const completedCount = parseCheckboxes(content).filter(
|
||
(c) => c.state === "completed" && eff(c.slug) === "EXECUTE",
|
||
).length;
|
||
content = setField(content, "Completed", String(completedCount));
|
||
// Re-derive not-yet-reached Phase Progress rows against the effective
|
||
// plan (scope-change's twin): a flip can empty a phase of EXECUTE stages
|
||
// (-> Skipped) or give a Skipped phase its first (-> Pending).
|
||
// Verified/Active rows are history and stay untouched.
|
||
for (const phase of PHASES) {
|
||
const phaseLabel = phase.charAt(0).toUpperCase() + phase.slice(1);
|
||
const row = getField(content, phaseLabel);
|
||
if (row !== "Pending" && row !== "Skipped") continue;
|
||
const hasExecute = graph.some(
|
||
(s) => s.phase === phase && eff(s.slug) === "EXECUTE",
|
||
);
|
||
content = setPhaseProgress(content, phase, hasExecute ? "Pending" : "Skipped");
|
||
}
|
||
// The Next Stage projection over the recomposed plan (override-aware).
|
||
if (currentSlug) {
|
||
const next = nextInScopeStage(currentSlug, scope, content);
|
||
content = setField(content, "Next Stage", next ? next.slug : "none");
|
||
}
|
||
content = setField(content, "Last Updated", isoTimestamp());
|
||
|
||
writeStateFile(projectDir, content, flags.intent, flags.space);
|
||
|
||
appendAuditEvent(projectDir, "RECOMPOSED", {
|
||
Scope: scope,
|
||
"Stages skipped": skipList.length > 0 ? skipList.join(", ") : "none",
|
||
"Stages added": addList.length > 0 ? addList.join(", ") : "none",
|
||
"Stages in Scope": String(executeStages.length),
|
||
});
|
||
|
||
process.stdout.write(
|
||
`Recomposed: ${skipList.length} skipped (${skipList.join(", ") || "none"}), ` +
|
||
`${addList.length} added (${addList.join(", ") || "none"})\n` +
|
||
`Stages in scope: ${executeStages.length}\n` +
|
||
`Completed: ${completedCount}/${executeStages.length}\n`,
|
||
);
|
||
}, undefined, undefined, WORKSPACE_MUTATION_LOCK_RETRIES);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// config get/list/set - read or update active workflow config
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function configFieldForKey(key: string): "Depth" | "Test Strategy" | "Review Override" | null {
|
||
if (key === "depth") return "Depth";
|
||
if (key === "test-strategy") return "Test Strategy";
|
||
if (key === "review") return "Review Override";
|
||
return null;
|
||
}
|
||
|
||
function readConfigField(projectDir: string, flags: Record<string, string>, field: "Depth" | "Test Strategy" | "Review Override"): string {
|
||
const sp = stateFilePath(projectDir, flags.intent, flags.space);
|
||
if (!existsSync(sp)) die(NO_STATE_FILE_MESSAGE);
|
||
const content = readStateFile(projectDir, flags.intent, flags.space);
|
||
return getField(content, field) || "";
|
||
}
|
||
|
||
function handleConfigGet(projectDir: string, positional: string[], flags: Record<string, string>): void {
|
||
const key = positional[1] ?? "";
|
||
const field = configFieldForKey(key);
|
||
if (!field) die(`Unknown config key: "${key}". Valid keys: ${CONFIG_KEYS.join(", ")}.`);
|
||
process.stdout.write(`${readConfigField(projectDir, flags, field)}\n`);
|
||
}
|
||
|
||
function handleConfigList(projectDir: string, flags: Record<string, string>): void {
|
||
const depth = readConfigField(projectDir, flags, "Depth");
|
||
const testStrategy = readConfigField(projectDir, flags, "Test Strategy");
|
||
const review = readConfigField(projectDir, flags, "Review Override");
|
||
if (flags.json === "true") {
|
||
process.stdout.write(`${JSON.stringify({ depth, "test-strategy": testStrategy, review })}\n`);
|
||
return;
|
||
}
|
||
process.stdout.write(`depth: ${depth}\ntest-strategy: ${testStrategy}\nreview: ${review}\n`);
|
||
}
|
||
|
||
function handleConfigChange(projectDir: string, flags: Record<string, string>): void {
|
||
const rawDepth = flags.depth;
|
||
const rawStrategy = flags["test-strategy"];
|
||
const rawReview = flags.review;
|
||
|
||
if (!rawDepth && !rawStrategy && !rawReview) {
|
||
die("config-change requires --depth, --test-strategy, and/or --review");
|
||
}
|
||
|
||
let newDepth: string | undefined;
|
||
if (rawDepth) {
|
||
newDepth = VALID_DEPTHS[rawDepth.toLowerCase()];
|
||
if (!newDepth) die(`Unknown depth: "${rawDepth}". Valid depths: minimal, standard, comprehensive.`);
|
||
}
|
||
|
||
let newStrategy: string | undefined;
|
||
if (rawStrategy) {
|
||
newStrategy = VALID_TEST_STRATEGIES[rawStrategy.toLowerCase()];
|
||
if (!newStrategy) die(`Unknown test strategy: "${rawStrategy}". Valid: minimal, standard, comprehensive.`);
|
||
}
|
||
|
||
// --review sets the per-run Review Override (a CEILING on the effective
|
||
// review class, low-wins against stage declaration and scope review_cap).
|
||
const newReview = parseReviewOverride(rawReview);
|
||
|
||
const sp = stateFilePath(projectDir, flags.intent, flags.space);
|
||
if (!existsSync(sp)) die(NO_STATE_FILE_MESSAGE);
|
||
|
||
let content = readStateFile(projectDir, flags.intent, flags.space);
|
||
const oldDepth = getField(content, "Depth");
|
||
const oldStrategy = getField(content, "Test Strategy");
|
||
|
||
// Inline existence checks (instead of caching to a boolean) so TS narrows
|
||
// newDepth / newStrategy at each use site — avoids non-null assertions.
|
||
if (newDepth !== undefined && newDepth !== oldDepth) {
|
||
content = setField(content, "Depth", newDepth);
|
||
}
|
||
if (newStrategy !== undefined && newStrategy !== oldStrategy) {
|
||
content = setField(content, "Test Strategy", newStrategy);
|
||
}
|
||
const reviewUpdate = applyReviewOverride(content, newReview);
|
||
content = reviewUpdate.content;
|
||
const { oldReview, storedReview } = reviewUpdate;
|
||
const depthChanging = newDepth !== undefined && newDepth !== oldDepth;
|
||
const strategyChanging =
|
||
newStrategy !== undefined && newStrategy !== oldStrategy;
|
||
const reviewChanging = reviewUpdate.changed;
|
||
if (depthChanging || strategyChanging || reviewChanging) {
|
||
content = setField(content, "Last Updated", isoTimestamp());
|
||
writeStateFile(projectDir, content, flags.intent, flags.space);
|
||
}
|
||
|
||
if (newDepth !== undefined && newDepth !== oldDepth) {
|
||
appendAuditEvent(projectDir, "DEPTH_CHANGED", {
|
||
"Old Depth": oldDepth || "unknown",
|
||
"New Depth": newDepth,
|
||
});
|
||
}
|
||
if (newStrategy !== undefined && newStrategy !== oldStrategy) {
|
||
appendAuditEvent(projectDir, "TEST_STRATEGY_CHANGED", {
|
||
"Old Strategy": oldStrategy || "unknown",
|
||
"New Strategy": newStrategy,
|
||
});
|
||
}
|
||
if (reviewChanging) {
|
||
appendAuditEvent(projectDir, "REVIEW_CLASS_CHANGED", {
|
||
"Old Override": oldReview || "none set",
|
||
"New Override": storedReview || "cleared (stage defaults apply)",
|
||
});
|
||
}
|
||
|
||
if (newDepth !== undefined) {
|
||
process.stdout.write(
|
||
depthChanging
|
||
? `Depth changed: ${oldDepth} -> ${newDepth}\n`
|
||
: `Depth is already ${newDepth}\n`
|
||
);
|
||
}
|
||
if (newStrategy !== undefined) {
|
||
process.stdout.write(
|
||
strategyChanging
|
||
? `Test strategy changed: ${oldStrategy} -> ${newStrategy}\n`
|
||
: `Test strategy is already ${newStrategy}\n`
|
||
);
|
||
}
|
||
if (newReview !== undefined) {
|
||
const display = storedReview === "" ? "adversarial (stage defaults)" : storedReview;
|
||
process.stdout.write(
|
||
reviewChanging
|
||
? `Review override changed: ${oldReview || "none"} -> ${display}\n`
|
||
: `Review override is already ${display}\n`
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// change-control <strict|relaxed> - rewrite the intent's Change Control line
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// The one write path for the per-intent value: the `/aidlc --change-control`
|
||
// flag and the plain-chat request both land here. A memory layer that declares
|
||
// strict refuses the flip and names its file; the value is then not the
|
||
// human's to change from chat. The rewritten line carries `(set by you)` so
|
||
// `--status` can say where the value came from; the ledger gets one
|
||
// CHANGE_CONTROL_SET row per real change.
|
||
|
||
function handleChangeControl(
|
||
projectDir: string,
|
||
positional: string[],
|
||
flags: Record<string, string>,
|
||
): void {
|
||
const raw = positional[1];
|
||
const requested = parseChangeControl(raw);
|
||
if (raw === undefined || requested === null) {
|
||
die(
|
||
`change-control requires exactly one of: ${CHANGE_CONTROL_VALUES.join(", ")}` +
|
||
(raw === undefined ? "." : ` (received "${raw}").`),
|
||
);
|
||
}
|
||
const selection = resolveWorkflowSelection(projectDir, {
|
||
intent: flags.intent,
|
||
space: flags.space,
|
||
});
|
||
const intent = selection.intent ?? undefined;
|
||
const space = selection.space;
|
||
const sp = stateFilePath(projectDir, intent, space);
|
||
if (!existsSync(sp)) die(NO_STATE_FILE_MESSAGE);
|
||
let content = readStateFile(projectDir, intent, space);
|
||
const resolution = resolveChangeControl(projectDir, content, {
|
||
tolerateInvalidState: true,
|
||
selection: { intent, space },
|
||
});
|
||
if (resolution.memoryStrict !== null && requested !== "strict") {
|
||
die(changeControlMemoryStrictRefusal(resolution.memoryStrict));
|
||
}
|
||
const previous = getField(content, CHANGE_CONTROL_FIELD);
|
||
const line = formatChangeControl(requested, "you");
|
||
if (previous === line) {
|
||
process.stdout.write(`Change Control is already ${line}\n`);
|
||
return;
|
||
}
|
||
if (previous === null) {
|
||
const beforeInsert = content;
|
||
for (const anchor of ["Review Override", "Test Strategy", "Scope"]) {
|
||
content = content.replace(
|
||
new RegExp(`^(- \\*\\*${anchor}\\*\\*:[^\\n]*)$`, "m"),
|
||
`$1\n- **${CHANGE_CONTROL_FIELD}**:`,
|
||
);
|
||
if (content !== beforeInsert) break;
|
||
}
|
||
if (content === beforeInsert) {
|
||
content = `${content.trimEnd()}\n- **${CHANGE_CONTROL_FIELD}**:\n`;
|
||
}
|
||
}
|
||
content = setField(content, CHANGE_CONTROL_FIELD, line);
|
||
content = setField(content, "Last Updated", isoTimestamp());
|
||
// Audit first, then the state write, like every other state-mutating verb:
|
||
// a ledger that cannot take the row leaves the line untouched.
|
||
const oldAuditValue = resolution.intent === null && resolution.rawStateValue !== null
|
||
? resolution.rawStateValue
|
||
: resolution.value;
|
||
recordChangeControlSet(projectDir, oldAuditValue, requested, "you", { intent, space });
|
||
writeStateFile(projectDir, content, intent, space);
|
||
const oldDisplay = resolution.intent === null && resolution.rawStateValue !== null
|
||
? resolution.rawStateValue
|
||
: formatChangeControl(resolution.value, resolution.source);
|
||
process.stdout.write(`Change Control changed: ${oldDisplay} to ${line}\n`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// set-status — atomically update statusline fields at stage start
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function setStatus(
|
||
projectDir: string,
|
||
flags: Record<string, string>,
|
||
): { phase: string; stage: string; agent: string } {
|
||
const sp = stateFilePath(projectDir, flags.intent, flags.space);
|
||
if (!existsSync(sp)) throw new Error(NO_STATE_FILE_MESSAGE);
|
||
|
||
const stage = flags.stage;
|
||
if (!stage) throw new Error("--stage is required for set-status");
|
||
|
||
const entry = findStageBySlug(stage);
|
||
if (!entry) throw new Error(`Unknown stage: ${stage}`);
|
||
|
||
const phase = (flags.phase || entry.phase).toUpperCase();
|
||
const agent = flags.agent || entry.lead_agent;
|
||
|
||
const previousContent = readStateFile(projectDir, flags.intent, flags.space);
|
||
const currentStage = (getField(previousContent, "Current Stage") ?? "").trim();
|
||
const activeDirective = readActiveDirectiveMarker(projectDir, previousContent);
|
||
const preserveUnitMajorCursor =
|
||
getField(previousContent, "Construction Iteration")?.trim() === "unit-major" &&
|
||
phase === "CONSTRUCTION" &&
|
||
activeDirective?.stage === stage &&
|
||
activeDirective.unit !== undefined &&
|
||
currentStage.length > 0 &&
|
||
currentStage !== stage;
|
||
let content = previousContent;
|
||
content = setField(content, "Lifecycle Phase", phase);
|
||
content = setField(content, "Active Agent", agent);
|
||
content = setField(content, "Status", "Running");
|
||
content = setField(content, "Last Updated", isoTimestamp());
|
||
if (!preserveUnitMajorCursor) {
|
||
content = setField(content, "Current Stage", stage);
|
||
content = setField(content, "In Progress", stage);
|
||
content = setCheckbox(content, stage, "in-progress");
|
||
}
|
||
writeStateFile(projectDir, content, flags.intent, flags.space);
|
||
try {
|
||
refreshActiveDirectiveMarker(projectDir, stage, previousContent, content);
|
||
} catch (e) {
|
||
recordHookDrop(projectDir, "active-directive", errorMessage(e));
|
||
}
|
||
|
||
return { phase, stage, agent };
|
||
}
|
||
|
||
function handleSetStatus(projectDir: string, flags: Record<string, string>): void {
|
||
if (
|
||
process.env.AIDLC_STATUSLINE_OWNER !== `statusline:${process.ppid}`
|
||
) {
|
||
die(
|
||
"Direct aidlc-utility set-status is blocked: status synchronization is owned by the sync-workflow-state hook.",
|
||
);
|
||
}
|
||
try {
|
||
const result = setStatus(projectDir, flags);
|
||
process.stdout.write(`${JSON.stringify({ updated: true, ...result })}\n`);
|
||
} catch (error) {
|
||
die(errorMessage(error));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Scope inference from freeform text
|
||
//
|
||
// The keyword sets live in each scope's `.claude/scopes/aidlc-<name>.md`
|
||
// frontmatter `keywords` field; this
|
||
// helper resolves the scope using word-boundary matching (so "debug"
|
||
// does not match "bug"),
|
||
// alphabetical iteration over scopes (so first-match-wins is
|
||
// deterministic), and a ">5 word" heuristic that requires an affirmative
|
||
// high-specificity keyword. Generic or negated mentions in long descriptions
|
||
// fall back to the selection-aware default scope.
|
||
//
|
||
// Exported for t67 unit tests; not a stable public API.
|
||
|
||
export interface InferResult {
|
||
scope: string;
|
||
source: "keyword" | "freeform";
|
||
matches: Array<{ scope: string; keyword: string }>;
|
||
}
|
||
|
||
// These core-owned keywords can identify a scope in a long description
|
||
// (issue #1072). Generic words still defer to the word-count heuristic.
|
||
// Plugin vocabularies remain owned by their plugins; declaring specificity
|
||
// in scope frontmatter is a separate follow-up.
|
||
const HIGH_SPECIFICITY_KEYWORDS = new Set<string>([
|
||
"refactor",
|
||
"mvp",
|
||
"minimum viable",
|
||
"poc",
|
||
"proof of concept",
|
||
"cve",
|
||
]);
|
||
|
||
function isNegatedScopeKeyword(text: string, index: number): boolean {
|
||
// Keep this local to the occurrence: "refactor without changing behavior"
|
||
// is affirmative, and a new clause can request a different scope. This is
|
||
// a conservative lexical guard, not a general natural-language parser.
|
||
const prefix = text
|
||
.slice(0, index)
|
||
.split(/[.!?;:\n]|\b(?:but|however|instead)\b/)
|
||
.pop() ?? "";
|
||
const normalized = prefix.replace(/\bnot\s+(?:only|just|merely)\b/g, "");
|
||
return /\b(?:no|not|never|without|avoid(?:ing)?|skip(?:ping)?|exclud(?:e|ing)|[a-z]+n['’]t)\b(?:[\s"'“”‘’()-]+\w+){0,4}[\s"'“”‘’()-]*$/.test(normalized);
|
||
}
|
||
|
||
export function inferScopeFromText(input: string): InferResult {
|
||
const text = input.toLowerCase();
|
||
const trimmed = input.trim();
|
||
const wordCount = trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
|
||
const mapping = loadScopeMapping();
|
||
const allMatches: Array<{ scope: string; keyword: string }> = [];
|
||
let specificMatch: { scope: string; keyword: string } | undefined;
|
||
|
||
// Iterate in alphabetical order for determinism (not JSON insertion
|
||
// order). validScopes() already returns a sorted set. Multi-word
|
||
// keywords like "proof of concept" allow any whitespace run between
|
||
// tokens, so "proof of concept" (double-spaced) still matches.
|
||
for (const scope of [...validScopes()]) {
|
||
const keywords = mapping[scope]?.keywords ?? [];
|
||
let firstMatch: { scope: string; keyword: string } | undefined;
|
||
for (const kw of keywords) {
|
||
const normalized = kw.toLowerCase().trim().replace(/\s+/g, " ");
|
||
const tokens = normalized.split(" ").map(escapeRegex);
|
||
const re = new RegExp(`\\b${tokens.join("\\s+")}\\b`, "gi");
|
||
for (const match of text.matchAll(re)) {
|
||
firstMatch ??= { scope, keyword: kw };
|
||
if (
|
||
wordCount > 5 &&
|
||
specificMatch === undefined &&
|
||
HIGH_SPECIFICITY_KEYWORDS.has(normalized) &&
|
||
!isNegatedScopeKeyword(text, match.index)
|
||
) {
|
||
specificMatch = { scope, keyword: kw };
|
||
}
|
||
}
|
||
}
|
||
// Preserve one diagnostic match per scope and short-input precedence,
|
||
// while checking every keyword for the long-input exemption.
|
||
if (firstMatch) allMatches.push(firstMatch);
|
||
}
|
||
|
||
// No matches at all → default (freeform).
|
||
if (allMatches.length === 0) {
|
||
return {
|
||
scope: selectionAwareDefaultScope().scope,
|
||
source: "freeform",
|
||
matches: allMatches,
|
||
};
|
||
}
|
||
|
||
// Long descriptions need an affirmative high-specificity match.
|
||
if (wordCount > 5 && specificMatch === undefined) {
|
||
return {
|
||
scope: selectionAwareDefaultScope().scope,
|
||
source: "freeform",
|
||
matches: allMatches,
|
||
};
|
||
}
|
||
|
||
// First alphabetical match wins (deterministic across calls). In long
|
||
// prose a high-specificity match takes precedence over an alphabetically
|
||
// earlier incidental low-specificity one.
|
||
const winner =
|
||
wordCount > 5 && specificMatch !== undefined ? specificMatch : allMatches[0];
|
||
return {
|
||
scope: winner.scope,
|
||
source: "keyword",
|
||
matches: allMatches,
|
||
};
|
||
}
|
||
|
||
/** Doctor uses this for keyword-overlap detection. */
|
||
export function findScopeByKeyword(kw: string): string[] {
|
||
const mapping = loadScopeMapping();
|
||
const hits: string[] = [];
|
||
for (const scope of [...validScopes()]) {
|
||
if (
|
||
(mapping[scope]?.keywords ?? []).some(
|
||
(k) => k.toLowerCase() === kw.toLowerCase()
|
||
)
|
||
) {
|
||
hits.push(scope);
|
||
}
|
||
}
|
||
return hits;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// scope-table - compiled summary of the scope grid for SKILL.md
|
||
//
|
||
// Emits a Markdown table delimited by BEGIN/END HTML comments. SKILL.md
|
||
// has a matching region that is regenerated via this tool. --check mode
|
||
// byte-compares the current SKILL.md region against the rendered output
|
||
// and exits 1 on drift. Mirrors aidlc-graph.ts compile / compile --check.
|
||
//
|
||
// AIDLC_SKILL_MD_PATH env-seam lets t67 sandbox --check against a
|
||
// fixture SKILL.md (so drift tests never mutate the real file).
|
||
|
||
const SCOPE_TABLE_BEGIN =
|
||
`<!-- BEGIN: compiled scope grid via \`${aidlcDispatcherInvocation("gen scope-table")}\` - do NOT hand-edit -->`;
|
||
const SCOPE_TABLE_END =
|
||
"<!-- END: compiled scope grid -->";
|
||
|
||
/** Exported for t67 unit tests. */
|
||
export function renderScopeTable(): string {
|
||
const mapping = loadScopeMapping();
|
||
const scopes = [...validScopes()]; // alphabetical
|
||
const lines = [
|
||
"| Scope | Depth | TestStrategy | EXECUTE / Total |",
|
||
"|----------------|---------------|--------------|-----------------|",
|
||
];
|
||
for (const name of scopes) {
|
||
const def = mapping[name];
|
||
const stages = def.stages;
|
||
const total = Object.keys(stages).length;
|
||
const execute = Object.values(stages).filter((v) => v === "EXECUTE").length;
|
||
const depth = def.depth;
|
||
const ts = def.testStrategy ?? "(default)";
|
||
lines.push(
|
||
`| ${name.padEnd(14)} | ${depth.padEnd(13)} | ${ts.padEnd(12)} | ${`${execute} / ${total}`.padEnd(15)} |`
|
||
);
|
||
}
|
||
return lines.join("\n");
|
||
}
|
||
|
||
/** Canonical byte-shape: BEGIN\n\n<table>\n\nEND. */
|
||
export function canonicalScopeTableRegion(table: string): string {
|
||
return `${SCOPE_TABLE_BEGIN}\n\n${table}\n\n${SCOPE_TABLE_END}`;
|
||
}
|
||
|
||
function skillMdPath(): string {
|
||
if (process.env.AIDLC_SKILL_MD_PATH) return process.env.AIDLC_SKILL_MD_PATH;
|
||
const harnessSkill = resolveSkillsPath(["aidlc", "SKILL.md"]);
|
||
if (existsSync(harnessSkill)) return harnessSkill;
|
||
const agentsSkill = join(
|
||
dirname(resolveHarnessPath([])),
|
||
".agents",
|
||
"skills",
|
||
"aidlc",
|
||
"SKILL.md",
|
||
);
|
||
if (existsSync(agentsSkill)) return agentsSkill;
|
||
return harnessSkill;
|
||
}
|
||
|
||
function checkGeneratedTableRegion(
|
||
verb: string,
|
||
beginMarker: string,
|
||
endMarker: string,
|
||
renderRegion: () => string,
|
||
): void {
|
||
const skillPath = skillMdPath();
|
||
let skillRaw: string;
|
||
try {
|
||
skillRaw = readFileSync(skillPath, "utf-8");
|
||
} catch (err) {
|
||
console.error(
|
||
`SKILL.md not readable at ${skillPath}: ${errorMessage(err)}`
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Normalize line endings before comparison so Windows CRLF files
|
||
// (core.autocrlf=true) don't false-positive as drifted.
|
||
skillRaw = skillRaw.replace(/\r\n/g, "\n");
|
||
|
||
let located: GeneratedRegionLocation;
|
||
try {
|
||
located = findGeneratedRegion(skillRaw, beginMarker, endMarker, verb, skillPath);
|
||
} catch (err) {
|
||
console.error(errorMessage(err));
|
||
process.exit(1);
|
||
}
|
||
|
||
const currentRegion = skillRaw.substring(located.beginIdx, located.regionEndIdx);
|
||
const expectedRegion = renderRegion();
|
||
|
||
if (currentRegion === expectedRegion) {
|
||
return; // exit 0 silent
|
||
}
|
||
|
||
console.error(
|
||
`SKILL.md ${verb} region is out of date. Refresh it from \`${aidlcDispatcherInvocation(`gen ${verb}`)}\`.`
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
function handleScopeTable(
|
||
_projectDir: string,
|
||
_flags: Record<string, string>,
|
||
rawArgs: string[]
|
||
): void {
|
||
const check = rawArgs.includes("--check");
|
||
const expectedRegion = canonicalScopeTableRegion(renderScopeTable());
|
||
|
||
if (!check) {
|
||
process.stdout.write(`${expectedRegion}\n`);
|
||
return;
|
||
}
|
||
|
||
checkGeneratedTableRegion(
|
||
"scope-table",
|
||
SCOPE_TABLE_BEGIN,
|
||
SCOPE_TABLE_END,
|
||
() => expectedRegion,
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// stage-table — compiled summary of the stage graph for SKILL.md
|
||
//
|
||
// Emits a Markdown table delimited by BEGIN/END HTML comments. SKILL.md
|
||
// has a matching region that is regenerated via this tool. --check mode
|
||
// byte-compares the current SKILL.md region against the rendered output
|
||
// and exits 1 on drift. Mirrors scope-table above.
|
||
//
|
||
// AIDLC_SKILL_MD_PATH env-seam lets tests sandbox --check against a
|
||
// fixture SKILL.md (so drift tests never mutate the real file).
|
||
|
||
const STAGE_TABLE_BEGIN =
|
||
`<!-- BEGIN: compiled stage graph via \`${aidlcDispatcherInvocation("gen stage-table")}\` - do NOT hand-edit -->`;
|
||
const STAGE_TABLE_END =
|
||
"<!-- END: compiled stage graph -->";
|
||
|
||
function displayPhase(phase: string): string {
|
||
return phase.charAt(0).toUpperCase() + phase.slice(1);
|
||
}
|
||
|
||
function displayLeadAgent(agent: string): string {
|
||
return agent === "orchestrator" ? "(orchestrator)" : agent;
|
||
}
|
||
|
||
function displaySupportAgents(agents: string[] | undefined): string {
|
||
return Array.isArray(agents) && agents.length > 0 ? agents.join(", ") : "—";
|
||
}
|
||
|
||
/** Exported for t32 integration tests. */
|
||
export function renderStageTable(): string {
|
||
const lines = [
|
||
"| Slug | # | Stage | Phase | Execution | Lead Agent | Support Agents | Mode |",
|
||
"|------|---|-------|-------|-----------|------------|----------------|------|",
|
||
];
|
||
for (const stage of loadStageGraph()) {
|
||
lines.push(
|
||
`| ${stage.slug} | ${stage.number} | ${stage.name} | ${displayPhase(stage.phase)} | ${stage.execution} | ${displayLeadAgent(stage.lead_agent)} | ${displaySupportAgents(stage.support_agents)} | ${stage.mode} |`
|
||
);
|
||
}
|
||
return lines.join("\n");
|
||
}
|
||
|
||
/** Canonical byte-shape: BEGIN\n\n<table>\n\nEND. */
|
||
export function canonicalStageTableRegion(table: string): string {
|
||
return `${STAGE_TABLE_BEGIN}\n\n${table}\n\n${STAGE_TABLE_END}`;
|
||
}
|
||
|
||
function handleStageTable(
|
||
_projectDir: string,
|
||
_flags: Record<string, string>,
|
||
rawArgs: string[]
|
||
): void {
|
||
const check = rawArgs.includes("--check");
|
||
const expectedRegion = canonicalStageTableRegion(renderStageTable());
|
||
|
||
if (!check) {
|
||
process.stdout.write(`${expectedRegion}\n`);
|
||
return;
|
||
}
|
||
|
||
checkGeneratedTableRegion(
|
||
"stage-table",
|
||
STAGE_TABLE_BEGIN,
|
||
STAGE_TABLE_END,
|
||
() => expectedRegion,
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// detect-scope — record a scope-detection event
|
||
//
|
||
// Two modes:
|
||
// 1. Explicit: `--scope <scope> --input <text> [--source ...]`.
|
||
// Recorded unchanged.
|
||
// 2. Inference: `--from-text --input <text>`.
|
||
// Resolves the scope via inferScopeFromText and emits SCOPE_DETECTED
|
||
// with Source=keyword (match) or Source=freeform (default fallback).
|
||
//
|
||
// Passing both `--scope` and `--from-text` is an error — they are
|
||
// mutually exclusive modes. Missing both is also an error.
|
||
|
||
const VALID_SCOPE_SOURCES: ReadonlySet<string> = new Set([
|
||
"freeform",
|
||
"keyword",
|
||
"env",
|
||
"cli",
|
||
]);
|
||
|
||
function handleDetectScope(
|
||
projectDir: string,
|
||
flags: Record<string, string>
|
||
): void {
|
||
const fromText = flags["from-text"] !== undefined;
|
||
const explicitScope = flags.scope;
|
||
|
||
if (fromText && explicitScope) {
|
||
die(
|
||
"Cannot combine --from-text and --scope. Use one or the other."
|
||
);
|
||
}
|
||
if (!fromText && !explicitScope) {
|
||
die(
|
||
"Missing --scope <scope> (or pass --from-text to infer from --input)."
|
||
);
|
||
}
|
||
|
||
// --input requirement differs by mode:
|
||
// --scope mode: --input is required (audit event needs original text).
|
||
// --from-text mode: --input may be empty string — inferScopeFromText
|
||
// returns `feature` as the documented default. Missing --input
|
||
// entirely is still an error; an empty string is fine.
|
||
const input = flags.input;
|
||
if (input === undefined) {
|
||
die("Missing --input <original-text>");
|
||
}
|
||
if (!fromText && input === "") {
|
||
die("--input cannot be empty under --scope mode.");
|
||
}
|
||
|
||
let scope: string;
|
||
let source: string;
|
||
let matchedKeywords: string[] = [];
|
||
|
||
if (fromText) {
|
||
const result = inferScopeFromText(input);
|
||
scope = result.scope;
|
||
source = result.source;
|
||
matchedKeywords = result.matches.map((m) => m.keyword);
|
||
} else {
|
||
scope = explicitScope;
|
||
source = flags.source || "freeform";
|
||
if (!VALID_SCOPE_SOURCES.has(source)) {
|
||
die(
|
||
`Unknown source: "${source}". Valid: ${[...VALID_SCOPE_SOURCES].join(", ")}.`
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!validScopes().has(scope)) {
|
||
die(
|
||
`Unknown scope: "${scope}". Valid scopes: ${[...validScopes()].join(", ")}.`
|
||
);
|
||
}
|
||
|
||
const auditFields: Record<string, string> = {
|
||
"Detected scope": scope,
|
||
"Input text": input,
|
||
Source: source,
|
||
};
|
||
if (matchedKeywords.length > 0) {
|
||
auditFields["Matched keywords"] = matchedKeywords.join(", ");
|
||
}
|
||
appendAuditEvent(projectDir, "SCOPE_DETECTED", auditFields);
|
||
|
||
process.stdout.write(
|
||
`${JSON.stringify({
|
||
emitted: "SCOPE_DETECTED",
|
||
scope,
|
||
source,
|
||
matches: matchedKeywords,
|
||
})}\n`
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// resolve-env-scope — validate AWS_AIDLC_DEFAULT_SCOPE and emit its value
|
||
//
|
||
// The orchestrator's step 0 in SKILL.md calls this to resolve the env default
|
||
// deterministically. Behavior:
|
||
// - Env unset or empty: exit 0, no output. The orchestrator takes the
|
||
// non-env path (CLI flag, keyword detection, or hard-coded fallback).
|
||
// - Env set to a valid scope: exit 0, print `scope=<value>` to stdout.
|
||
// The orchestrator synthesizes `--scope <value>` into $ARGUMENTS.
|
||
// - Env names an installed but disabled scope: resolve the selection-aware
|
||
// default. This preserves plugin-only installs whose existing config names
|
||
// a deselected core scope such as `feature`.
|
||
// - Env names an unknown scope: exit 1 with the canonical error. Explicit
|
||
// typos never enter the internal default-fallback path.
|
||
//
|
||
// Centralising validation here (instead of leaving it to LLM prose) guarantees
|
||
// the error message shape and guarantees invalid env never reaches scope-change
|
||
// / state-init.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function handleResolveEnvScope(): void {
|
||
const envScope = (resolveProjectFlag("AWS_AIDLC_DEFAULT_SCOPE") || "").trim();
|
||
if (envScope === "") {
|
||
return; // unset — no output, exit 0
|
||
}
|
||
if (!validScopes().has(envScope)) {
|
||
if (loadScopeMetadataAll()[envScope] === undefined) {
|
||
die(
|
||
`Invalid AWS_AIDLC_DEFAULT_SCOPE "${envScope}". Valid scopes: ${[...validScopes()].join(", ")}.`
|
||
);
|
||
}
|
||
const fallback = selectionAwareDefaultScope(envScope);
|
||
if (!fallback.error && validScopes().has(fallback.scope)) {
|
||
if (fallback.note) {
|
||
process.stderr.write(
|
||
`AWS_AIDLC_DEFAULT_SCOPE="${envScope}" is not an enabled scope; using ${fallback.scope} (sole enabled plugin's first scope)\n`,
|
||
);
|
||
}
|
||
process.stdout.write(`scope=${fallback.scope}\n`);
|
||
return;
|
||
}
|
||
die(
|
||
`Invalid AWS_AIDLC_DEFAULT_SCOPE "${envScope}". Valid scopes: ${[...validScopes()].join(", ")}.`
|
||
);
|
||
}
|
||
process.stdout.write(`scope=${envScope}\n`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CLI entry point
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function main(argv: string[]): Promise<void> {
|
||
const rawArgs = argv;
|
||
errorArgs = [...rawArgs];
|
||
const { positional, flags, bareFlags, blankFlags } = parseArgs(rawArgs);
|
||
const subcommand = positional[0];
|
||
if (
|
||
(subcommand === "intent-create" || subcommand === "init") &&
|
||
(flags.help === "true" || rawArgs.includes("-h"))
|
||
) {
|
||
process.stdout.write(
|
||
"Usage: aidlc-utility intent-create --scope <scope> " +
|
||
'[--arguments "<description>"] [--label "<short label>"] ' +
|
||
"[--depth <level>] [--test-strategy <level>] [--review <class>] [--change-control <value>] [--repos <name,...>] " +
|
||
"[--space <name>] [--project-dir <path>]\n",
|
||
);
|
||
return;
|
||
}
|
||
const isIntentCreate =
|
||
subcommand === "intent-create" ||
|
||
subcommand === "init" ||
|
||
(subcommand === "intent" && positional[1] === "create");
|
||
const missingValueFlags = new Set([...bareFlags, ...blankFlags]);
|
||
errorProjectDirArg = missingValueFlags.has("project-dir")
|
||
? undefined
|
||
: flags["project-dir"];
|
||
errorSelection = {
|
||
intent: missingValueFlags.has("intent") ? undefined : flags.intent,
|
||
space: missingValueFlags.has("space") ? undefined : flags.space,
|
||
};
|
||
if (isIntentCreate) {
|
||
validateIntentCreateFlagValues(flags, missingValueFlags);
|
||
}
|
||
const projectDir = resolveProjectDir(flags["project-dir"]);
|
||
|
||
switch (subcommand) {
|
||
case "help":
|
||
handleHelp();
|
||
break;
|
||
case "version":
|
||
handleVersion();
|
||
break;
|
||
case "status":
|
||
handleStatus(projectDir, flags);
|
||
break;
|
||
case "claim":
|
||
unitMain([
|
||
"claim",
|
||
...rawArgs.slice(1),
|
||
"--project-dir",
|
||
projectDir,
|
||
]);
|
||
break;
|
||
case "release":
|
||
unitMain([
|
||
"release",
|
||
...rawArgs.slice(1),
|
||
"--project-dir",
|
||
projectDir,
|
||
]);
|
||
break;
|
||
case "participate":
|
||
unitMain(["participate", "--project-dir", projectDir]);
|
||
break;
|
||
case "doctor":
|
||
await (await import("./aidlc-doctor.ts")).main(rawArgs);
|
||
break;
|
||
case "intent-create":
|
||
handleIntentCreate(projectDir, flags);
|
||
break;
|
||
case "intent":
|
||
handleIntent(projectDir, positional, flags);
|
||
break;
|
||
case "space":
|
||
handleSpace(projectDir, positional, flags);
|
||
break;
|
||
case "space-create":
|
||
handleSpaceCreate(projectDir, positional, flags);
|
||
break;
|
||
// codekb-path — read-only query verb. Prints the deterministic
|
||
// space-level per-repo codekb dir the RE stage writes into. Mirrors the
|
||
// read-only intent/space query arms: no mutation, no audit, no mkdir.
|
||
case "codekb-path":
|
||
handleCodekbPath(projectDir, flags);
|
||
break;
|
||
// project-description - read-only exact-description authority boundary.
|
||
// Marked records must load the JSON sidecar; only unmarked legacy records
|
||
// fall back to the state preview.
|
||
case "project-description":
|
||
handleProjectDescription(projectDir);
|
||
break;
|
||
// document-input - read-only direct-document boundary used by Intent Capture
|
||
// and Requirements Analysis. One exact path in, one trust-marked JSON object
|
||
// out; no search, mutation, or audit.
|
||
case "document-input":
|
||
await handleDocumentInput(projectDir);
|
||
break;
|
||
case "codekb-snapshot":
|
||
handleCodekbSnapshot(projectDir, flags);
|
||
break;
|
||
case "codekb-publish":
|
||
handleCodekbPublish(projectDir, flags);
|
||
break;
|
||
// codekb-scope-diff - read-only query verb. Compares the codekb store's
|
||
// recorded scope of analysis against the live tree (status) or an
|
||
// incoming run's timestamp (--compare). The RE stage's rerun guard.
|
||
case "codekb-scope-diff":
|
||
handleCodekbScopeDiff(projectDir, flags);
|
||
break;
|
||
// detect - read-only query verb. Prints the workspace scan
|
||
// (greenfield/brownfield, languages) + the resolved scope-registry paths so
|
||
// the composer agent is told where scope data lives. No mutation, no audit.
|
||
case "detect":
|
||
handleDetect(projectDir, flags);
|
||
break;
|
||
case "select-plugins":
|
||
handleSelectPlugins(projectDir, positional);
|
||
break;
|
||
case "plugin-list":
|
||
handlePluginList(flags);
|
||
break;
|
||
case "plugin-sync":
|
||
await handlePluginSync(projectDir);
|
||
break;
|
||
case "plugin-validate":
|
||
handlePluginValidate(positional, flags);
|
||
break;
|
||
case "plugin-build":
|
||
handlePluginBuild(positional, flags, missingValueFlags);
|
||
break;
|
||
// init / state-init are transition-only and intentionally absent from help.
|
||
// Stale init callers get a loud error for this release; workflow start is
|
||
// still intent-create through the orchestrator.
|
||
case "init":
|
||
handleInitTransition();
|
||
break;
|
||
case "state-init":
|
||
handleStateInit(projectDir, flags);
|
||
break;
|
||
case "upgrade":
|
||
handleUpgrade();
|
||
break;
|
||
case "scope-change":
|
||
handleScopeChange(projectDir, flags);
|
||
break;
|
||
// recompose - the adaptive composer's in-flight write: flip PENDING
|
||
// stages' plan suffixes (--skip/--add) under the audit lock, strict-
|
||
// validated, derived fields rebuilt, RECOMPOSED audited.
|
||
case "recompose":
|
||
handleRecompose(projectDir, flags);
|
||
break;
|
||
case "config-change":
|
||
handleConfigChange(projectDir, flags);
|
||
break;
|
||
case "config-get":
|
||
handleConfigGet(projectDir, positional, flags);
|
||
break;
|
||
case "config-list":
|
||
handleConfigList(projectDir, flags);
|
||
break;
|
||
case "set-status":
|
||
handleSetStatus(projectDir, flags);
|
||
break;
|
||
case "detect-scope":
|
||
handleDetectScope(projectDir, flags);
|
||
break;
|
||
case "change-control":
|
||
handleChangeControl(projectDir, positional, flags);
|
||
break;
|
||
case "resolve-env-scope":
|
||
handleResolveEnvScope();
|
||
break;
|
||
case "scope-table":
|
||
handleScopeTable(projectDir, flags, rawArgs);
|
||
break;
|
||
case "stage-table":
|
||
handleStageTable(projectDir, flags, rawArgs);
|
||
break;
|
||
default:
|
||
// `intent-birth` was renamed to `intent-create`; point the old name at
|
||
// the new one rather than burying it in the verb list.
|
||
if (subcommand === "intent-birth") {
|
||
die(
|
||
"`intent-birth` was renamed to `intent-create`. Run the same command with " +
|
||
"`intent-create` instead (flags are unchanged)."
|
||
);
|
||
}
|
||
die(
|
||
`Unknown command "${subcommand}". Run \`aidlc-utility help\` for what this tool can do.\n\n` +
|
||
"Available commands: help, version, status, doctor, intent-create, intent, space, " +
|
||
"space-create, codekb-path, codekb-snapshot, codekb-publish, project-description, document-input, codekb-scope-diff, detect, select-plugins, plugin-list, plugin-sync, plugin-validate, plugin-build, " +
|
||
"recompose, scope-change, config-change, config-get, config-list, set-status, " +
|
||
"detect-scope, resolve-env-scope, scope-table, stage-table, upgrade\n" +
|
||
"Common options: [--project-dir <path>] [--scope <scope>] [--json]"
|
||
);
|
||
}
|
||
}
|
||
|
||
if (import.meta.main) {
|
||
void main(process.argv.slice(2)).catch((error) => {
|
||
die(errorMessage(error));
|
||
});
|
||
}
|