@delorenj/pjangler 1.5.53 → 1.5.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -457,9 +457,44 @@ var init_notebookSettingsValidation = __esm({
457
457
  });
458
458
 
459
459
  // src/project/executionBinding.ts
460
+ function executionReadiness(manifest) {
461
+ const execution = manifest.execution;
462
+ if (!execution || execution.mode === "legacy") return [];
463
+ const errors = [];
464
+ if (!["shadow", "managed"].includes(execution.mode)) errors.push("execution.mode must be legacy, shadow or managed");
465
+ if (!manifest.project_id) errors.push("canonical project_id missing");
466
+ if (manifest.ticket_provider?.type !== "plane" || !manifest.ticket_provider.workspace || !manifest.ticket_provider.board_id) errors.push("exact Plane binding missing");
467
+ if (execution.policy_version !== 2 || !execution.skill_version) errors.push("execution policy or skill pin missing");
468
+ for (const lane of EXECUTION_LANES) if (!execution.states?.[lane]) errors.push(`lane binding missing: ${lane}`);
469
+ for (const pin of ["pilot_bundle_sha256", "momo_bundle_sha256"]) if (!/^[a-f0-9]{64}$/.test(execution[pin] ?? "")) errors.push(`${pin} missing`);
470
+ if (!execution.working_label) errors.push("working label binding missing");
471
+ if (!execution.actors?.[execution.pm_actor] || execution.actors[execution.pm_actor].role !== "pm") errors.push("PM actor enrollment missing");
472
+ if (!execution.actors?.[execution.controller_actor] || execution.actors[execution.controller_actor].role !== "operator") errors.push("controller repair actor enrollment missing");
473
+ const ids = /* @__PURE__ */ new Set();
474
+ for (const [name, actor] of Object.entries(execution.actors ?? {})) {
475
+ if (!actor.native_user_id || !actor.key_ref?.startsWith("op://") || !actor.runtime_id) errors.push(`actor ${name}: native identity, op reference and runtime required`);
476
+ if (ids.has(actor.native_user_id)) errors.push(`actor ${name}: duplicate native identity`);
477
+ ids.add(actor.native_user_id);
478
+ if (!["pm", "operator", "reviewer", "interactive"].includes(actor.role)) errors.push(`actor ${name}: invalid role`);
479
+ if (name === execution.pm_actor && (!Array.isArray(actor.runtime?.planner_argv) || !actor.runtime.planner_argv.length || actor.runtime.planner_argv.some((x) => typeof x !== "string" || !x || x.includes("\0")))) errors.push("PM planner argv missing");
480
+ if (actor.runtime?.adapter !== "systemd" || !/^[a-zA-Z0-9_-]+$/.test(actor.runtime?.unit_prefix ?? "")) errors.push(`actor ${name}: supervised runtime missing`);
481
+ }
482
+ if (execution.legacy_writers_fenced !== true) errors.push("legacy writers not fenced");
483
+ return errors;
484
+ }
485
+ function validateExecutionBinding(manifest) {
486
+ if (!manifest.execution) return;
487
+ if (typeof manifest.execution !== "object" || Array.isArray(manifest.execution)) throw new Error("execution must be an object");
488
+ if (manifest.execution.mode === "managed") {
489
+ const errors = executionReadiness(manifest);
490
+ if (errors.length) throw new Error(`Managed execution not ready: ${errors.join("; ")}`);
491
+ } else if (!["legacy", "shadow"].includes(manifest.execution.mode)) throw new Error("invalid execution mode");
492
+ }
493
+ var EXECUTION_LANES;
460
494
  var init_executionBinding = __esm({
461
495
  "src/project/executionBinding.ts"() {
462
496
  "use strict";
497
+ EXECUTION_LANES = ["Backlog", "Needs Re-evaluation", "Todo", "In Progress", "E2E Testing & QA", "Ready for Documentation", "Done", "Needs Attention", "Cancelled"];
463
498
  }
464
499
  });
465
500
 
@@ -472,6 +507,46 @@ function normalizeProjectId2(value) {
472
507
  throw new RegistryError(error instanceof Error ? error.message : String(error));
473
508
  }
474
509
  }
510
+ function object(value) {
511
+ return !!value && typeof value === "object" && !Array.isArray(value);
512
+ }
513
+ function validateManifestShape(manifest, path) {
514
+ validateExecutionBinding(manifest);
515
+ const fail2 = (field2, shape) => {
516
+ throw new RegistryError(`Manifest ${path}: ${field2} must be ${shape}`);
517
+ };
518
+ const strings = (value, names, prefix = "") => {
519
+ for (const name of names) if (value[name] !== void 0 && typeof value[name] !== "string") fail2(`${prefix}${name}`, "a string");
520
+ };
521
+ strings(manifest, ["project_name", "name", "project_description", "description", "status", "repo_path", "created_at", "updated_at"]);
522
+ for (const field2 of ["ticket_provider", "agents", "template", "notebook", "automation"]) if (manifest[field2] !== void 0 && !object(manifest[field2])) fail2(field2, "an object");
523
+ if (manifest.source_artifacts !== void 0 && !Array.isArray(manifest.source_artifacts)) fail2("source_artifacts", "an array");
524
+ for (const [index, artifact] of (manifest.source_artifacts ?? []).entries()) {
525
+ if (!object(artifact)) fail2(`source_artifacts.${index}`, "an object");
526
+ strings(artifact, ["kind", "path", "package_name"], `source_artifacts.${index}.`);
527
+ }
528
+ if (manifest.ticket_provider) strings(manifest.ticket_provider, ["type", "workspace", "identifier", "identifier_source", "identifier_fetched_at", "board_id", "board_confirmed_at", "board_url", "state", "scope_label"], "ticket_provider.");
529
+ if (manifest.ticket_provider?.scope_default !== void 0 && typeof manifest.ticket_provider.scope_default !== "boolean") fail2("ticket_provider.scope_default", "a boolean");
530
+ for (const [name, agent] of Object.entries(manifest.agents ?? {})) {
531
+ if (!object(agent)) fail2(`agents.${name}`, "an object");
532
+ strings(agent, ["role", "role_dir", "provisioning_state"], `agents.${name}.`);
533
+ }
534
+ if (manifest.template?.commonproject !== void 0) {
535
+ if (!object(manifest.template.commonproject)) fail2("template.commonproject", "an object");
536
+ const template = manifest.template.commonproject;
537
+ if (template.enabled !== void 0 && typeof template.enabled !== "boolean") fail2("template.commonproject.enabled", "boolean");
538
+ strings(template, ["primary_language", "project_type"], "template.commonproject.");
539
+ }
540
+ for (const field2 of ["binding", "policy"]) if (manifest.notebook?.[field2] !== void 0 && !object(manifest.notebook[field2])) fail2(`notebook.${field2}`, "an object");
541
+ if (manifest.notebook?.binding) strings(manifest.notebook.binding, ["provider", "state", "notebook_name", "blocked_reason", "notebook_id", "overview_note_id", "created_at", "updated_at"], "notebook.binding.");
542
+ if (manifest.notebook?.policy) {
543
+ try {
544
+ validateGlobalNotebookConfig({ defaults: manifest.notebook.policy });
545
+ } catch (error) {
546
+ throw new RegistryError(`Manifest ${path}: invalid notebook policy: ${String(error)}`);
547
+ }
548
+ }
549
+ }
475
550
  function recordFromManifest(manifest, path) {
476
551
  const id = normalizeProjectId2(manifest.project_id ?? manifest.project_slug);
477
552
  const { project_slug: _old, project_name: _name, project_description: _description, notebook, ...rest } = manifest;
@@ -1641,12 +1716,40 @@ function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1641
1716
  const hint = `${searched} Add project-specific roots with ${PROJECT_SOURCE_SKILL_ROOTS_ENV}.`;
1642
1717
  throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
1643
1718
  }
1719
+ function looksLikePath(value) {
1720
+ return /^(?:\/|~\/|\.{1,2}\/)/.test(value);
1721
+ }
1722
+ function normalizeLegacyManifest(manifest) {
1723
+ const artifacts = manifest.source_artifacts;
1724
+ if (!Array.isArray(artifacts)) return { manifest, notes: [] };
1725
+ const notes = [];
1726
+ const normalized = artifacts.map((artifact, index) => {
1727
+ if (typeof artifact !== "string" || !looksLikePath(artifact)) return artifact;
1728
+ notes.push(`.project.json source_artifacts.${index}: bare path normalized to {"kind": "${LEGACY_SOURCE_ARTIFACT_KIND}", "path": "${artifact}"}`);
1729
+ return { kind: LEGACY_SOURCE_ARTIFACT_KIND, path: artifact };
1730
+ });
1731
+ return notes.length ? { manifest: { ...manifest, source_artifacts: normalized }, notes } : { manifest, notes };
1732
+ }
1644
1733
  function planProjectInit(input) {
1645
1734
  if (!input.name.trim()) throw new Error("Project name is required");
1646
1735
  const targetDir = resolve11(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
1647
1736
  const localManifestPath = join18(targetDir, ".project.json");
1648
1737
  const localManifestContent = existsSync11(localManifestPath) ? readFileSync17(localManifestPath, "utf8") : void 0;
1649
- const localManifest = localManifestContent === void 0 ? void 0 : JSON.parse(localManifestContent);
1738
+ let localManifest;
1739
+ const manifestNotes = [];
1740
+ if (localManifestContent !== void 0) {
1741
+ let parsed;
1742
+ try {
1743
+ parsed = JSON.parse(localManifestContent);
1744
+ } catch {
1745
+ throw new Error(`Malformed JSON: ${localManifestPath}`);
1746
+ }
1747
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Expected a JSON object: ${localManifestPath}`);
1748
+ const normalized = normalizeLegacyManifest(parsed);
1749
+ localManifest = normalized.manifest;
1750
+ manifestNotes.push(...normalized.notes);
1751
+ validateManifestShape(localManifest, localManifestPath);
1752
+ }
1650
1753
  const localId = localManifest?.project_id ?? localManifest?.project_slug;
1651
1754
  const slug = normalizeProjectId(input.projectSlug ?? localId ?? slugifyProjectName(input.name));
1652
1755
  if (localId && normalizeProjectId(localId) !== slug) throw new Error(`Project ID ${slug} conflicts with authoritative manifest ${localId}`);
@@ -1741,6 +1844,7 @@ function planProjectInit(input) {
1741
1844
  validateNoDuplicateProject(registry, project, overwrite);
1742
1845
  const pjanglerRoot = resolve11(input.pjanglerRoot ?? resolvePjanglerRoot());
1743
1846
  const manifest = projectManifestFromRegistryProject(project);
1847
+ validateManifestShape(manifest, localManifestPath);
1744
1848
  const apply = input.apply ?? false;
1745
1849
  const live = input.live ?? false;
1746
1850
  const provisionTicketBoard = input.provisionTicketBoard ?? true;
@@ -1796,7 +1900,7 @@ function planProjectInit(input) {
1796
1900
  project,
1797
1901
  manifest,
1798
1902
  actions,
1799
- ...input.boardUrl !== void 0 ? { warnings: [BOARD_URL_DEPRECATION_WARNING] } : {}
1903
+ ...input.boardUrl !== void 0 || manifestNotes.length ? { warnings: [...input.boardUrl !== void 0 ? [BOARD_URL_DEPRECATION_WARNING] : [], ...manifestNotes] } : {}
1800
1904
  };
1801
1905
  }
1802
1906
  function linkTicketProviderBoard(plan, action, boardId, identifier, now = /* @__PURE__ */ new Date()) {
@@ -2264,7 +2368,7 @@ function expandHome(path) {
2264
2368
  function isRecord3(value) {
2265
2369
  return typeof value === "object" && value !== null && !Array.isArray(value);
2266
2370
  }
2267
- var PROJECT_SOURCE_SKILL_ROOTS_ENV, TICKET_PROVIDER_ADAPTERS_ENV, PROJECT_REGISTRY_SCHEMA_VERSION, DEFAULT_NEW_PROJECT_STATUS, PROJECT_TYPES, PROJECT_TYPE_LANGUAGES, PROJECT_TYPE_SENTINELS, BOARD_URL_DEPRECATION_WARNING, PROJECT_IDENTIFIER_SOURCES, IDENTIFIER_ASSIGNING_PROVIDERS, TICKET_PROVIDER_STATES, IDENTIFIER_REPAIR_COMMAND, DEFAULT_SOURCE_SKILL_ROOTS, PROJECT_REGISTRY_OWNED_KEYS, PROJECT_NOTEBOOK_OWNED_KEYS, TICKET_PROVIDER_OWNED_KEYS, GLOBAL_NOTEBOOK_OWNED_KEYS, GLOBAL_NOTEBOOK_AUTH_OWNED_KEYS, GLOBAL_NOTEBOOK_DEFAULTS_OWNED_KEYS, GLOBAL_NOTEBOOK_LIMITS_OWNED_KEYS, GLOBAL_NOTEBOOK_SUMMARIZER_OWNED_KEYS, SAFE_PATH_SEGMENT;
2371
+ var PROJECT_SOURCE_SKILL_ROOTS_ENV, TICKET_PROVIDER_ADAPTERS_ENV, PROJECT_REGISTRY_SCHEMA_VERSION, DEFAULT_NEW_PROJECT_STATUS, PROJECT_TYPES, PROJECT_TYPE_LANGUAGES, PROJECT_TYPE_SENTINELS, BOARD_URL_DEPRECATION_WARNING, PROJECT_IDENTIFIER_SOURCES, IDENTIFIER_ASSIGNING_PROVIDERS, TICKET_PROVIDER_STATES, IDENTIFIER_REPAIR_COMMAND, DEFAULT_SOURCE_SKILL_ROOTS, PROJECT_REGISTRY_OWNED_KEYS, PROJECT_NOTEBOOK_OWNED_KEYS, TICKET_PROVIDER_OWNED_KEYS, GLOBAL_NOTEBOOK_OWNED_KEYS, GLOBAL_NOTEBOOK_AUTH_OWNED_KEYS, GLOBAL_NOTEBOOK_DEFAULTS_OWNED_KEYS, GLOBAL_NOTEBOOK_LIMITS_OWNED_KEYS, GLOBAL_NOTEBOOK_SUMMARIZER_OWNED_KEYS, SAFE_PATH_SEGMENT, LEGACY_SOURCE_ARTIFACT_KIND;
2268
2372
  var init_project = __esm({
2269
2373
  "src/project/index.ts"() {
2270
2374
  "use strict";
@@ -2320,6 +2424,7 @@ var init_project = __esm({
2320
2424
  GLOBAL_NOTEBOOK_LIMITS_OWNED_KEYS = Object.keys(DEFAULT_NOTEBOOK_LIMITS);
2321
2425
  GLOBAL_NOTEBOOK_SUMMARIZER_OWNED_KEYS = ["executable", "args"];
2322
2426
  SAFE_PATH_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
2427
+ LEGACY_SOURCE_ARTIFACT_KIND = "reference";
2323
2428
  }
2324
2429
  });
2325
2430
 
@@ -6353,6 +6458,10 @@ function backupToSnapshot(backup) {
6353
6458
  }
6354
6459
  return snapshot;
6355
6460
  }
6461
+ function removeEntry(full, stat) {
6462
+ if (stat.isDirectory() && !stat.isSymbolicLink()) rmSync(full, { recursive: true, force: true });
6463
+ else unlinkSync(full);
6464
+ }
6356
6465
  function restoreSingleEntry(root, rel, entry) {
6357
6466
  const full = rel === "." ? root : join3(root, rel);
6358
6467
  let currentStat;
@@ -6363,7 +6472,7 @@ function restoreSingleEntry(root, rel, entry) {
6363
6472
  }
6364
6473
  if (entry.kind === "dir") {
6365
6474
  if (currentStat && (!currentStat.isDirectory() || currentStat.isSymbolicLink())) {
6366
- rmSync(full, { recursive: true, force: true });
6475
+ removeEntry(full, currentStat);
6367
6476
  currentStat = void 0;
6368
6477
  }
6369
6478
  if (!currentStat) mkdirSync2(full, { recursive: true });
@@ -6374,7 +6483,7 @@ function restoreSingleEntry(root, rel, entry) {
6374
6483
  if (entry.kind === "link") {
6375
6484
  if (currentStat) {
6376
6485
  if (currentStat.isSymbolicLink() && readlinkSync2(full) === entry.target) return;
6377
- rmSync(full, { recursive: true, force: true });
6486
+ removeEntry(full, currentStat);
6378
6487
  }
6379
6488
  symlinkSync(entry.target, full);
6380
6489
  return;
@@ -6383,7 +6492,7 @@ function restoreSingleEntry(root, rel, entry) {
6383
6492
  if ((currentStat.mode & 4095) === entry.mode && readFileSync2(full).equals(entry.bytes)) return;
6384
6493
  unlinkSync(full);
6385
6494
  } else if (currentStat) {
6386
- rmSync(full, { recursive: true, force: true });
6495
+ removeEntry(full, currentStat);
6387
6496
  }
6388
6497
  writeFileSync2(full, entry.bytes);
6389
6498
  chmodSync(full, entry.mode);
@@ -6992,10 +7101,10 @@ function openDisplacedStash(projectRoot) {
6992
7101
  index.push({ rel, kind: "link", target });
6993
7102
  count++;
6994
7103
  } else if (bytes) {
6995
- const object = createHash4("sha256").update(bytes).digest("hex");
6996
- const objectPath = join5(open(), "objects", object);
7104
+ const object2 = createHash4("sha256").update(bytes).digest("hex");
7105
+ const objectPath = join5(open(), "objects", object2);
6997
7106
  if (!existsSync3(objectPath)) writeFileSync3(objectPath, bytes);
6998
- index.push({ rel, kind: "file", mode: stat.mode & 4095, object });
7107
+ index.push({ rel, kind: "file", mode: stat.mode & 4095, object: object2 });
6999
7108
  count++;
7000
7109
  } else if (stat.isDirectory()) {
7001
7110
  open();
@@ -9033,9 +9142,13 @@ async function predictRootCollisions(ctx, incoming) {
9033
9142
  async function auditProjectSkills(ctx) {
9034
9143
  const result2 = await inspectStatus(skillCoreOptions(ctx));
9035
9144
  const details = skillDiagnostics(result2.findings);
9145
+ let findings = result2.findings.filter((finding2) => finding2.severity !== "info").length;
9036
9146
  details.push(...(result2.data?.changes ?? []).map((change) => `${change.action}: ${change.path}`));
9147
+ const pendingChanges = (result2.data?.changes ?? []).length;
9037
9148
  const plan = planSkillRoots(ctx.repoRoot);
9038
- details.push(...skillRootsSummary(plan));
9149
+ const rootLines = skillRootsSummary(plan);
9150
+ details.push(...rootLines);
9151
+ findings += rootLines.length;
9039
9152
  const conflict = activationConflict(ctx, result2.findings);
9040
9153
  let conflictResolvable = false;
9041
9154
  if (conflict?.kind === "alias") conflictResolvable = plan.clean;
@@ -9043,6 +9156,7 @@ async function auditProjectSkills(ctx) {
9043
9156
  const predictions = await predictRootCollisions(ctx, []);
9044
9157
  conflictResolvable = predictions.length > 0 && predictions.every(({ decision }) => decision.action !== "block");
9045
9158
  for (const { decision } of predictions) {
9159
+ if (decision.action === "block") findings += 1;
9046
9160
  details.push(decision.action === "block" ? `blocked: ${decision.detail}` : `resolvable root collision: ${decision.detail.replace(/^replaced/, "replace")}`);
9047
9161
  }
9048
9162
  }
@@ -9051,7 +9165,10 @@ async function auditProjectSkills(ctx) {
9051
9165
  const installerOnly = errors.length > 0 && await installerRefusalsResolvable(ctx, errors, handoff);
9052
9166
  if (installerOnly) details.push("resolvable: every refusal is at a skill the bmad-method installer provides; migrate excludes the inherited selection and releases the replaced links");
9053
9167
  if (handoff.skipped) details.push(`installer skill handoff unavailable: ${handoff.skipped}`);
9054
- if (handoff.restored.length) details.push(...handoff.restored.map((name) => `stale exclusion: ${name} was excluded for the bmad-method installer's copy, which is gone; migrate restores the inherited catalog skill`));
9168
+ if (handoff.restored.length) {
9169
+ findings += handoff.restored.length;
9170
+ details.push(...handoff.restored.map((name) => `stale exclusion: ${name} was excluded for the bmad-method installer's copy, which is gone; migrate restores the inherited catalog skill`));
9171
+ }
9055
9172
  if (result2.exit === 2 || result2.exit === 3) details.push(migrationGuidance(ctx));
9056
9173
  const changes = result2.data?.changes ?? [];
9057
9174
  const receiptOnly = result2.exit === 6 && changes.length > 0 && changes.every((change) => change.action === "write-receipt") && !result2.findings.some((finding2) => finding2.severity === "error");
@@ -9063,6 +9180,8 @@ async function auditProjectSkills(ctx) {
9063
9180
  status: result2.exit === 0 ? staleHandoff ? "warn" : "pass" : result2.exit === 4 || receiptOnly ? "warn" : "fail",
9064
9181
  summary: result2.exit === 0 ? "Skillex declaration and activation are in parity" : receiptOnly ? "Skillex activation is in parity; its receipt predates the current catalog commit" : `Skillex inspection returned exit ${result2.exit}`,
9065
9182
  details,
9183
+ findings,
9184
+ pendingChanges,
9066
9185
  fixable: result2.exit === 0 || result2.exit === 6 || conflictResolvable || installerOnly || staleHandoff || result2.findings.some((finding2) => finding2.code === "E_NO_PROJECT_MANIFEST")
9067
9186
  };
9068
9187
  }
@@ -12104,28 +12223,54 @@ function consolidatedBmadConfig(repoRoot) {
12104
12223
  if (!existsSync8(path)) return { status: "absent" };
12105
12224
  let parsed;
12106
12225
  try {
12107
- parsed = YAML4.parse(readText(path));
12226
+ parsed = YAML4.parse(readText(path), { uniqueKeys: false });
12108
12227
  } catch (error) {
12109
- return { status: "unusable", reason: `could not be parsed: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}` };
12228
+ const reason = error instanceof Error ? error.message.split("\n")[0].replace(/:\s*$/, "") : String(error);
12229
+ return { status: "unusable", reason: `could not be parsed: ${reason}` };
12110
12230
  }
12111
12231
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
12112
12232
  return { status: "unusable", reason: "is not a YAML mapping" };
12113
12233
  }
12114
12234
  return { status: "mapping", sections: new Set(Object.keys(parsed)) };
12115
12235
  }
12236
+ var BMAD_SCAFFOLD_REMEDY = "pj migrate bmad.scaffold";
12237
+ function gitIgnoresPath(repoRoot, rel) {
12238
+ return spawnSync5("git", ["check-ignore", "-q", "--", rel], { cwd: repoRoot, encoding: "utf8", timeout: 5e3 }).status === 0;
12239
+ }
12240
+ function bmadManifestGapDetail(repoRoot) {
12241
+ const path = "_bmad/_config/manifest.yaml";
12242
+ const bmad = join12(repoRoot, "_bmad");
12243
+ if (!existsSync8(bmad)) return path;
12244
+ const present = (...segments) => {
12245
+ try {
12246
+ lstatSync10(join12(bmad, ...segments));
12247
+ return true;
12248
+ } catch {
12249
+ return false;
12250
+ }
12251
+ };
12252
+ const record3 = `${path} (the installer's install record)`;
12253
+ const remedy = `Restore it with: ${BMAD_SCAFFOLD_REMEDY}`;
12254
+ if (!present("_config") && !present("core") && existsSync8(join12(bmad, "config.yaml"))) {
12255
+ return `${record3}. BMB setup's \`--also-remove _config\` cleanup is the likely cause: it deletes all of _bmad/_config. ${remedy}`;
12256
+ }
12257
+ if (gitIgnoresPath(repoRoot, path)) return `${record3} is git-ignored in this checkout, so a fresh clone or worktree lacks it. ${remedy}`;
12258
+ return `${record3} is missing. ${remedy}`;
12259
+ }
12116
12260
  function bmadSentinelGaps(repoRoot, modules = selectedBmadModules(repoRoot)) {
12117
12261
  const bmad = join12(repoRoot, "_bmad");
12118
12262
  const gaps = [];
12119
12263
  const absent = (...segments) => !existsSync8(join12(bmad, ...segments));
12264
+ const installed = existsSync8(bmad);
12120
12265
  if (absent("config.toml")) gaps.push({ path: "config.toml", detail: "_bmad/config.toml" });
12121
12266
  for (const script of BMAD_RUNTIME_SCRIPT_SENTINELS) {
12122
- if (absent("scripts", script)) gaps.push({ path: join12("scripts", script), detail: `_bmad/scripts/${script}` });
12267
+ if (!absent("scripts", script)) continue;
12268
+ const rel = `_bmad/scripts/${script}`;
12269
+ const detail = !installed ? rel : gitIgnoresPath(repoRoot, rel) ? `${rel} (git-ignored in this checkout, so a fresh clone or worktree lacks it; restore it with: ${BMAD_SCAFFOLD_REMEDY})` : `${rel} (restore it with: ${BMAD_SCAFFOLD_REMEDY})`;
12270
+ gaps.push({ path: join12("scripts", script), detail });
12123
12271
  }
12124
12272
  if (absent("_config", "manifest.yaml")) {
12125
- gaps.push({
12126
- path: join12("_config", "manifest.yaml"),
12127
- detail: existsSync8(bmad) ? "_bmad/_config/manifest.yaml (the installer's install record). BMB setup's `--also-remove _config` cleanup is the likely cause: it deletes all of _bmad/_config. Restore it with: pj migrate bmad.scaffold" : "_bmad/_config/manifest.yaml"
12128
- });
12273
+ gaps.push({ path: join12("_config", "manifest.yaml"), detail: bmadManifestGapDetail(repoRoot) });
12129
12274
  }
12130
12275
  let consolidated;
12131
12276
  for (const module of modules) {
@@ -13281,11 +13426,14 @@ function createAgentHooksChecks() {
13281
13426
  const unsafe = retired.filter((path) => lstatIfPresent(path)?.isDirectory());
13282
13427
  const details = [...finding2.details, ...wiring, ...registryBlocked, ...retired.map((path) => unsafe.includes(path) ? `Inspect and preserve retired script directory: ${path}` : `${path} is a retired skill writer`)];
13283
13428
  const miseDrift = mise !== null && upsertLinkAgentfilesBlock(mise, ctx) !== mise;
13429
+ const { findings: auditFindings, pendingChanges, ...audited } = finding2;
13430
+ const problems = auditFindings + wiring.length + registryBlocked.length + retired.length;
13431
+ const summary = problems ? `${problems} Skillex finding(s)${pendingChanges ? `, ${pendingChanges} pending change(s)` : ""}` : pendingChanges && audited.status !== "pass" ? `${pendingChanges} pending Skillex change(s)` : audited.summary;
13284
13432
  return {
13285
- ...finding2,
13286
- status: wiring.length || retired.length || registryBlocked.length ? "fail" : finding2.status,
13287
- fixable: unsafe.length ? false : finding2.fixable || wiring.length > 0 || retired.length > 0 || miseDrift,
13288
- summary: details.length ? `${details.length} Skillex finding(s)` : finding2.summary,
13433
+ ...audited,
13434
+ status: wiring.length || retired.length || registryBlocked.length ? "fail" : audited.status,
13435
+ fixable: unsafe.length ? false : audited.fixable || wiring.length > 0 || retired.length > 0 || miseDrift,
13436
+ summary,
13289
13437
  details
13290
13438
  };
13291
13439
  },
@@ -13858,24 +14006,52 @@ function withInstallerSkillHandoff(check) {
13858
14006
  }
13859
14007
  };
13860
14008
  }
13861
- function withScaffoldPostcondition(check) {
14009
+ function withScaffoldScriptRestore(check) {
13862
14010
  if (check.id !== "bmad.scaffold") return check;
13863
14011
  return {
13864
14012
  ...check,
13865
14013
  migrate: async (ctx, finding2) => {
13866
- const result2 = await check.migrate(ctx, finding2);
13867
- if (ctx.dryRun || result2.status !== "applied" && result2.status !== "noop") return result2;
13868
- const after = await check.audit(ctx);
13869
- if (after.status === "pass" || after.status === "skip") return result2;
13870
- return {
13871
- ...result2,
13872
- status: "partial",
13873
- summary: after.summary,
13874
- details: [...result2.details, `still failing after the install: ${after.summary}`, ...after.details]
13875
- };
14014
+ const attempt = restoreScaffoldScripts(ctx, finding2);
14015
+ if (attempt.result) return attempt.result;
14016
+ const viaInstaller = await check.migrate(ctx, finding2);
14017
+ return attempt.note ? { ...viaInstaller, details: [attempt.note, ...viaInstaller.details] } : viaInstaller;
13876
14018
  }
13877
14019
  };
13878
14020
  }
14021
+ function restoreScaffoldScripts(ctx, finding2) {
14022
+ const manifest = manifestBmadModules(ctx.repoRoot);
14023
+ if (manifest.status === "invalid") return {};
14024
+ const modules = manifest.status === "valid" ? manifest.modules : configuredBmadModuleSelection(ctx.repoRoot)?.modules ?? [...DEFAULT_BMAD_MODULES];
14025
+ const issues = bmadScaffoldIssues(ctx.repoRoot, modules);
14026
+ const scriptGap = (path) => BMAD_RUNTIME_SCRIPT_SENTINELS.some((script) => path === join12("scripts", script));
14027
+ if (!issues.gaps.length || issues.nameIssues.details.length || !issues.gaps.every((gap) => scriptGap(gap.path))) return {};
14028
+ const state = bmadSetupScriptsState(ctx.repoRoot);
14029
+ if (state.kind === "present") return {};
14030
+ if (state.kind !== "missing") {
14031
+ return { note: `targeted _bmad/scripts restore not possible, so this takes the installer path: ${state.kind === "decline" ? state.summary : state.error}` };
14032
+ }
14033
+ const source = `bmad-method@${BMAD_INSTALLER_VERSION} (${state.pkg.root})`;
14034
+ if (ctx.dryRun) {
14035
+ return { result: {
14036
+ id: finding2.id,
14037
+ title: finding2.title,
14038
+ status: "applied",
14039
+ summary: "Would restore the missing _bmad/scripts from the verified pinned bmad-method package (no reinstall)",
14040
+ changedFiles: state.files,
14041
+ details: [source]
14042
+ } };
14043
+ }
14044
+ const applied = applyBmadSetupScripts(ctx.repoRoot, state.pkg, BMAD_INSTALLER_VERSION);
14045
+ if (applied.error) return { note: `targeted _bmad/scripts restore failed, so this takes the installer path: ${applied.error}` };
14046
+ return { result: {
14047
+ id: finding2.id,
14048
+ title: finding2.title,
14049
+ status: applied.files.length ? "applied" : "noop",
14050
+ summary: applied.files.length ? "Restored the missing _bmad/scripts from the verified pinned bmad-method package (no reinstall)" : "No changes required",
14051
+ changedFiles: applied.files,
14052
+ details: [source]
14053
+ } };
14054
+ }
13879
14055
  function createBmadChecks() {
13880
14056
  return [
13881
14057
  {
@@ -14403,7 +14579,7 @@ function createBmadChecks() {
14403
14579
  };
14404
14580
  }
14405
14581
  }
14406
- ].map(withInstallerSkillHandoff).map(withScaffoldPostcondition);
14582
+ ].map(withInstallerSkillHandoff).map(withScaffoldScriptRestore);
14407
14583
  }
14408
14584
  function createProjectMomoChecks() {
14409
14585
  return [
@@ -14614,16 +14790,17 @@ function formatAuditReport(report) {
14614
14790
  }
14615
14791
  function formatMigrationReport(report) {
14616
14792
  const idWidth = report.results.reduce((width, result2) => Math.max(width, result2.id.length), 0);
14617
- const blocked = report.results.filter((result2) => result2.status === "blocked").length;
14618
- const partial = report.results.filter((result2) => result2.status === "partial").length;
14619
- const overall = report.ok ? `${green(glyph.pass)} ${bold(report.dryRun ? "Migration preview complete" : "Migration complete")}` : blocked ? `${red(glyph.fail)} ${bold("Migration finished with blockers")}` : `${yellow(glyph.warn)} ${bold(`Migration incomplete ${glyph.dot} ${partial} rule${partial === 1 ? "" : "s"} still failing`)}`;
14793
+ const advisory = report.results.filter((result2) => result2.advisory).length;
14794
+ const blocked = report.results.filter((result2) => result2.status === "blocked" && !result2.advisory).length;
14795
+ const partial = report.results.filter((result2) => result2.status === "partial" && !result2.advisory).length;
14796
+ const overall = report.ok ? `${green(glyph.pass)} ${bold(report.dryRun ? "Migration preview complete" : "Migration complete")}${advisory ? ` ${dim(glyph.dot)} ${yellow(`${advisory} warning${advisory === 1 ? "" : "s"}`)}` : ""}` : blocked ? `${red(glyph.fail)} ${bold("Migration finished with blockers")}` : `${yellow(glyph.warn)} ${bold(`Migration incomplete ${glyph.dot} ${partial} rule${partial === 1 ? "" : "s"} still failing`)}`;
14620
14797
  const lines = [""];
14621
14798
  lines.push(` ${overall}${report.dryRun ? ` ${dim(glyph.dot)} ${yellow("dry run")}` : ""}`);
14622
14799
  lines.push(` ${dim(report.repo)}`);
14623
14800
  if (report.selectedRules.length) lines.push(` ${dim(`rules: ${report.selectedRules.join(", ")}`)}`);
14624
14801
  lines.push("");
14625
14802
  for (const result2 of report.results) {
14626
- const style = statusStyle(result2.status);
14803
+ const style = result2.advisory ? { ...statusStyle("warn"), label: `warning, ${result2.status}` } : statusStyle(result2.status);
14627
14804
  lines.push(` ${style.color(style.glyph)} ${style.color(result2.id.padEnd(idWidth))} ${result2.summary} ${dim(`[${style.label}]`)}`);
14628
14805
  for (const detail of result2.details) lines.push(` ${dim(glyph.arrow)} ${dim(detail)}`);
14629
14806
  for (const file of result2.changedFiles) lines.push(` ${green(glyph.add)} ${file}`);
@@ -14637,6 +14814,9 @@ function formatMigrationReport(report) {
14637
14814
  if (unresolved) {
14638
14815
  lines.push("");
14639
14816
  lines.push(` ${dim(`Run \`pjangler audit\` for the full detail on the ${unresolved} rule${unresolved === 1 ? "" : "s"} still failing.`)}`);
14817
+ } else if (advisory) {
14818
+ lines.push("");
14819
+ lines.push(` ${dim(`${advisory} rule${advisory === 1 ? "" : "s"} could not finish but only warn${advisory === 1 ? "s" : ""}; they do not block. \`pjangler audit\` has the detail.`)}`);
14640
14820
  }
14641
14821
  lines.push("");
14642
14822
  return lines.join("\n");
@@ -15106,6 +15286,7 @@ function ticketScope(ticket) {
15106
15286
  }
15107
15287
 
15108
15288
  // src/recipes/ProjectRecipe.ts
15289
+ init_manifestIndex();
15109
15290
  init_tree_diff();
15110
15291
 
15111
15292
  // src/recipes/NotebookRecipe.ts
@@ -17172,6 +17353,17 @@ function unsafeToRemove(targetDir) {
17172
17353
  }
17173
17354
  return null;
17174
17355
  }
17356
+ function manifestRefusal(manifestPath) {
17357
+ if (!existsSync18(manifestPath)) return void 0;
17358
+ try {
17359
+ const parsed = JSON.parse(readFileSync23(manifestPath, "utf8"));
17360
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return `Expected a JSON object: ${manifestPath}`;
17361
+ validateManifestShape(parsed, manifestPath);
17362
+ return void 0;
17363
+ } catch (error) {
17364
+ return error instanceof SyntaxError ? `Malformed JSON: ${manifestPath}` : error instanceof Error ? error.message : String(error);
17365
+ }
17366
+ }
17175
17367
  function publicAudit(report) {
17176
17368
  return {
17177
17369
  ...report,
@@ -17297,6 +17489,8 @@ var ProjectRecipe = class extends Recipe {
17297
17489
  });
17298
17490
  };
17299
17491
  let migrationReport;
17492
+ let unfinishedMigrations = [];
17493
+ const warnings = [];
17300
17494
  let audit;
17301
17495
  let notebookPlan;
17302
17496
  let notebookRecipeForCommit;
@@ -17396,20 +17590,37 @@ var ProjectRecipe = class extends Recipe {
17396
17590
  normalized.selectedRuleIds
17397
17591
  ));
17398
17592
  changedFiles.push(...migrationReport.changedFiles);
17399
- phases.push(...migrationReport.results.map((result2) => ({
17593
+ const migrationPhases = migrationReport.results.map((result2) => ({
17400
17594
  id: result2.id,
17401
17595
  status: result2.status === "applied" ? "changed" : result2.status === "noop" ? "unchanged" : result2.status === "skipped" ? "skipped" : "failed",
17402
17596
  // A `partial` failed, but its writes really happened, so they stay
17403
17597
  // accounted for rather than vanishing from the transaction record.
17404
17598
  changedFiles: result2.status === "applied" || result2.status === "partial" ? result2.changedFiles : [],
17405
17599
  message: result2.summary
17406
- })));
17407
- errors.push(...migrationReport.results.filter((result2) => result2.status === "blocked" || result2.status === "partial").map((result2) => describeFinding(result2)));
17600
+ }));
17601
+ phases.push(...migrationPhases);
17602
+ unfinishedMigrations = migrationReport.results.map((result2, index) => ({ result: result2, phase: migrationPhases[index] })).filter(({ result: result2 }) => result2.status === "blocked" || result2.status === "partial");
17408
17603
  }
17409
17604
  const eligibilityAudit = errors.length === 0 ? publicAudit(await this.registry.auditRecipes({ ...transactionContext, dryRun: true })) : void 0;
17410
17605
  audit = eligibilityAudit;
17606
+ const gating = new Set((eligibilityAudit?.rules ?? []).filter((finding2) => gatesProject(finding2)).map((finding2) => finding2.id));
17607
+ const reportedByMigration = /* @__PURE__ */ new Set();
17608
+ for (const { result: result2, phase } of unfinishedMigrations) {
17609
+ if (!eligibilityAudit || gating.has(result2.id)) {
17610
+ errors.push(describeFinding(result2));
17611
+ reportedByMigration.add(result2.id);
17612
+ } else {
17613
+ result2.advisory = true;
17614
+ phase.status = "skipped";
17615
+ phase.message = `advisory (does not block init): ${result2.summary}`;
17616
+ warnings.push(`${result2.id}: ${result2.summary}`);
17617
+ }
17618
+ }
17619
+ if (migrationReport && unfinishedMigrations.length && !unfinishedMigrations.some(({ result: result2 }) => !result2.advisory)) {
17620
+ migrationReport.ok = true;
17621
+ }
17411
17622
  if (eligibilityAudit && !eligibilityAudit.ok) {
17412
- errors.push(...eligibilityAudit.rules.filter((finding2) => gatesProject(finding2)).map((finding2) => describeFinding(finding2)));
17623
+ errors.push(...eligibilityAudit.rules.filter((finding2) => gatesProject(finding2) && !reportedByMigration.has(finding2.id)).map((finding2) => describeFinding(finding2)));
17413
17624
  }
17414
17625
  phases.push({
17415
17626
  id: "project.audit:eligibility",
@@ -17453,6 +17664,13 @@ var ProjectRecipe = class extends Recipe {
17453
17664
  }
17454
17665
  }
17455
17666
  }
17667
+ if (errors.length === 0) {
17668
+ const refusal = manifestRefusal(join25(targetDir, ".project.json"));
17669
+ if (refusal) {
17670
+ errors.push(`manifest refused before external effects: ${refusal}`);
17671
+ phases.push({ id: "project.manifest:validate", status: "failed", changedFiles: [], message: refusal });
17672
+ }
17673
+ }
17456
17674
  registryFinalizerEligible = errors.length === 0;
17457
17675
  if (errors.length === 0 && externalPlanActions.length) {
17458
17676
  const dispatching = externalPlanActions.some((action) => action.kind === "ticket-provider.create-or-link" && action.enabled);
@@ -17624,6 +17842,7 @@ var ProjectRecipe = class extends Recipe {
17624
17842
  selectedOperations: normalized.selectedOperations ?? [],
17625
17843
  selectedParityRules: normalized.selectedRuleIds ?? [],
17626
17844
  migrationReport,
17845
+ warnings,
17627
17846
  ...bmadCompanion ? { bmadCompanion } : {}
17628
17847
  };
17629
17848
  }
@@ -430,9 +430,44 @@ var init_notebookSettingsValidation = __esm({
430
430
  });
431
431
 
432
432
  // src/project/executionBinding.ts
433
+ function executionReadiness(manifest) {
434
+ const execution = manifest.execution;
435
+ if (!execution || execution.mode === "legacy") return [];
436
+ const errors = [];
437
+ if (!["shadow", "managed"].includes(execution.mode)) errors.push("execution.mode must be legacy, shadow or managed");
438
+ if (!manifest.project_id) errors.push("canonical project_id missing");
439
+ if (manifest.ticket_provider?.type !== "plane" || !manifest.ticket_provider.workspace || !manifest.ticket_provider.board_id) errors.push("exact Plane binding missing");
440
+ if (execution.policy_version !== 2 || !execution.skill_version) errors.push("execution policy or skill pin missing");
441
+ for (const lane of EXECUTION_LANES) if (!execution.states?.[lane]) errors.push(`lane binding missing: ${lane}`);
442
+ for (const pin of ["pilot_bundle_sha256", "momo_bundle_sha256"]) if (!/^[a-f0-9]{64}$/.test(execution[pin] ?? "")) errors.push(`${pin} missing`);
443
+ if (!execution.working_label) errors.push("working label binding missing");
444
+ if (!execution.actors?.[execution.pm_actor] || execution.actors[execution.pm_actor].role !== "pm") errors.push("PM actor enrollment missing");
445
+ if (!execution.actors?.[execution.controller_actor] || execution.actors[execution.controller_actor].role !== "operator") errors.push("controller repair actor enrollment missing");
446
+ const ids = /* @__PURE__ */ new Set();
447
+ for (const [name, actor] of Object.entries(execution.actors ?? {})) {
448
+ if (!actor.native_user_id || !actor.key_ref?.startsWith("op://") || !actor.runtime_id) errors.push(`actor ${name}: native identity, op reference and runtime required`);
449
+ if (ids.has(actor.native_user_id)) errors.push(`actor ${name}: duplicate native identity`);
450
+ ids.add(actor.native_user_id);
451
+ if (!["pm", "operator", "reviewer", "interactive"].includes(actor.role)) errors.push(`actor ${name}: invalid role`);
452
+ if (name === execution.pm_actor && (!Array.isArray(actor.runtime?.planner_argv) || !actor.runtime.planner_argv.length || actor.runtime.planner_argv.some((x) => typeof x !== "string" || !x || x.includes("\0")))) errors.push("PM planner argv missing");
453
+ if (actor.runtime?.adapter !== "systemd" || !/^[a-zA-Z0-9_-]+$/.test(actor.runtime?.unit_prefix ?? "")) errors.push(`actor ${name}: supervised runtime missing`);
454
+ }
455
+ if (execution.legacy_writers_fenced !== true) errors.push("legacy writers not fenced");
456
+ return errors;
457
+ }
458
+ function validateExecutionBinding(manifest) {
459
+ if (!manifest.execution) return;
460
+ if (typeof manifest.execution !== "object" || Array.isArray(manifest.execution)) throw new Error("execution must be an object");
461
+ if (manifest.execution.mode === "managed") {
462
+ const errors = executionReadiness(manifest);
463
+ if (errors.length) throw new Error(`Managed execution not ready: ${errors.join("; ")}`);
464
+ } else if (!["legacy", "shadow"].includes(manifest.execution.mode)) throw new Error("invalid execution mode");
465
+ }
466
+ var EXECUTION_LANES;
433
467
  var init_executionBinding = __esm({
434
468
  "src/project/executionBinding.ts"() {
435
469
  "use strict";
470
+ EXECUTION_LANES = ["Backlog", "Needs Re-evaluation", "Todo", "In Progress", "E2E Testing & QA", "Ready for Documentation", "Done", "Needs Attention", "Cancelled"];
436
471
  }
437
472
  });
438
473
 
@@ -445,6 +480,46 @@ function normalizeProjectId2(value) {
445
480
  throw new RegistryError(error instanceof Error ? error.message : String(error));
446
481
  }
447
482
  }
483
+ function object(value) {
484
+ return !!value && typeof value === "object" && !Array.isArray(value);
485
+ }
486
+ function validateManifestShape(manifest, path) {
487
+ validateExecutionBinding(manifest);
488
+ const fail = (field2, shape) => {
489
+ throw new RegistryError(`Manifest ${path}: ${field2} must be ${shape}`);
490
+ };
491
+ const strings = (value, names, prefix = "") => {
492
+ for (const name of names) if (value[name] !== void 0 && typeof value[name] !== "string") fail(`${prefix}${name}`, "a string");
493
+ };
494
+ strings(manifest, ["project_name", "name", "project_description", "description", "status", "repo_path", "created_at", "updated_at"]);
495
+ for (const field2 of ["ticket_provider", "agents", "template", "notebook", "automation"]) if (manifest[field2] !== void 0 && !object(manifest[field2])) fail(field2, "an object");
496
+ if (manifest.source_artifacts !== void 0 && !Array.isArray(manifest.source_artifacts)) fail("source_artifacts", "an array");
497
+ for (const [index, artifact] of (manifest.source_artifacts ?? []).entries()) {
498
+ if (!object(artifact)) fail(`source_artifacts.${index}`, "an object");
499
+ strings(artifact, ["kind", "path", "package_name"], `source_artifacts.${index}.`);
500
+ }
501
+ if (manifest.ticket_provider) strings(manifest.ticket_provider, ["type", "workspace", "identifier", "identifier_source", "identifier_fetched_at", "board_id", "board_confirmed_at", "board_url", "state", "scope_label"], "ticket_provider.");
502
+ if (manifest.ticket_provider?.scope_default !== void 0 && typeof manifest.ticket_provider.scope_default !== "boolean") fail("ticket_provider.scope_default", "a boolean");
503
+ for (const [name, agent] of Object.entries(manifest.agents ?? {})) {
504
+ if (!object(agent)) fail(`agents.${name}`, "an object");
505
+ strings(agent, ["role", "role_dir", "provisioning_state"], `agents.${name}.`);
506
+ }
507
+ if (manifest.template?.commonproject !== void 0) {
508
+ if (!object(manifest.template.commonproject)) fail("template.commonproject", "an object");
509
+ const template = manifest.template.commonproject;
510
+ if (template.enabled !== void 0 && typeof template.enabled !== "boolean") fail("template.commonproject.enabled", "boolean");
511
+ strings(template, ["primary_language", "project_type"], "template.commonproject.");
512
+ }
513
+ for (const field2 of ["binding", "policy"]) if (manifest.notebook?.[field2] !== void 0 && !object(manifest.notebook[field2])) fail(`notebook.${field2}`, "an object");
514
+ if (manifest.notebook?.binding) strings(manifest.notebook.binding, ["provider", "state", "notebook_name", "blocked_reason", "notebook_id", "overview_note_id", "created_at", "updated_at"], "notebook.binding.");
515
+ if (manifest.notebook?.policy) {
516
+ try {
517
+ validateGlobalNotebookConfig({ defaults: manifest.notebook.policy });
518
+ } catch (error) {
519
+ throw new RegistryError(`Manifest ${path}: invalid notebook policy: ${String(error)}`);
520
+ }
521
+ }
522
+ }
448
523
  function recordFromManifest(manifest, path) {
449
524
  const id = normalizeProjectId2(manifest.project_id ?? manifest.project_slug);
450
525
  const { project_slug: _old, project_name: _name, project_description: _description, notebook, ...rest } = manifest;
@@ -1512,12 +1587,40 @@ function resolveSourceSkillPath(sourceSkill, env2 = process.env) {
1512
1587
  const hint = `${searched} Add project-specific roots with ${PROJECT_SOURCE_SKILL_ROOTS_ENV}.`;
1513
1588
  throw new Error(`Source skill not found: ${sourceSkill}.${hint}`);
1514
1589
  }
1590
+ function looksLikePath(value) {
1591
+ return /^(?:\/|~\/|\.{1,2}\/)/.test(value);
1592
+ }
1593
+ function normalizeLegacyManifest(manifest) {
1594
+ const artifacts = manifest.source_artifacts;
1595
+ if (!Array.isArray(artifacts)) return { manifest, notes: [] };
1596
+ const notes = [];
1597
+ const normalized = artifacts.map((artifact, index) => {
1598
+ if (typeof artifact !== "string" || !looksLikePath(artifact)) return artifact;
1599
+ notes.push(`.project.json source_artifacts.${index}: bare path normalized to {"kind": "${LEGACY_SOURCE_ARTIFACT_KIND}", "path": "${artifact}"}`);
1600
+ return { kind: LEGACY_SOURCE_ARTIFACT_KIND, path: artifact };
1601
+ });
1602
+ return notes.length ? { manifest: { ...manifest, source_artifacts: normalized }, notes } : { manifest, notes };
1603
+ }
1515
1604
  function planProjectInit(input) {
1516
1605
  if (!input.name.trim()) throw new Error("Project name is required");
1517
1606
  const targetDir = resolve10(input.targetDir ?? defaultProjectTargetDir(input.name, input.cwd));
1518
1607
  const localManifestPath = join17(targetDir, ".project.json");
1519
1608
  const localManifestContent = existsSync11(localManifestPath) ? readFileSync15(localManifestPath, "utf8") : void 0;
1520
- const localManifest = localManifestContent === void 0 ? void 0 : JSON.parse(localManifestContent);
1609
+ let localManifest;
1610
+ const manifestNotes = [];
1611
+ if (localManifestContent !== void 0) {
1612
+ let parsed;
1613
+ try {
1614
+ parsed = JSON.parse(localManifestContent);
1615
+ } catch {
1616
+ throw new Error(`Malformed JSON: ${localManifestPath}`);
1617
+ }
1618
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Expected a JSON object: ${localManifestPath}`);
1619
+ const normalized = normalizeLegacyManifest(parsed);
1620
+ localManifest = normalized.manifest;
1621
+ manifestNotes.push(...normalized.notes);
1622
+ validateManifestShape(localManifest, localManifestPath);
1623
+ }
1521
1624
  const localId = localManifest?.project_id ?? localManifest?.project_slug;
1522
1625
  const slug = normalizeProjectId(input.projectSlug ?? localId ?? slugifyProjectName(input.name));
1523
1626
  if (localId && normalizeProjectId(localId) !== slug) throw new Error(`Project ID ${slug} conflicts with authoritative manifest ${localId}`);
@@ -1612,6 +1715,7 @@ function planProjectInit(input) {
1612
1715
  validateNoDuplicateProject(registry, project, overwrite);
1613
1716
  const pjanglerRoot = resolve10(input.pjanglerRoot ?? resolvePjanglerRoot());
1614
1717
  const manifest = projectManifestFromRegistryProject(project);
1718
+ validateManifestShape(manifest, localManifestPath);
1615
1719
  const apply = input.apply ?? false;
1616
1720
  const live = input.live ?? false;
1617
1721
  const provisionTicketBoard = input.provisionTicketBoard ?? true;
@@ -1667,7 +1771,7 @@ function planProjectInit(input) {
1667
1771
  project,
1668
1772
  manifest,
1669
1773
  actions,
1670
- ...input.boardUrl !== void 0 ? { warnings: [BOARD_URL_DEPRECATION_WARNING] } : {}
1774
+ ...input.boardUrl !== void 0 || manifestNotes.length ? { warnings: [...input.boardUrl !== void 0 ? [BOARD_URL_DEPRECATION_WARNING] : [], ...manifestNotes] } : {}
1671
1775
  };
1672
1776
  }
1673
1777
  function linkTicketProviderBoard(plan, action, boardId, identifier, now = /* @__PURE__ */ new Date()) {
@@ -2016,7 +2120,7 @@ function expandHome(path) {
2016
2120
  function isRecord2(value) {
2017
2121
  return typeof value === "object" && value !== null && !Array.isArray(value);
2018
2122
  }
2019
- var PROJECT_SOURCE_SKILL_ROOTS_ENV, TICKET_PROVIDER_ADAPTERS_ENV, PROJECT_REGISTRY_SCHEMA_VERSION, DEFAULT_NEW_PROJECT_STATUS, PROJECT_TYPES, PROJECT_TYPE_LANGUAGES, PROJECT_TYPE_SENTINELS, BOARD_URL_DEPRECATION_WARNING, PROJECT_IDENTIFIER_SOURCES, IDENTIFIER_ASSIGNING_PROVIDERS, TICKET_PROVIDER_STATES, IDENTIFIER_REPAIR_COMMAND, DEFAULT_SOURCE_SKILL_ROOTS, PROJECT_REGISTRY_OWNED_KEYS, PROJECT_NOTEBOOK_OWNED_KEYS, TICKET_PROVIDER_OWNED_KEYS, GLOBAL_NOTEBOOK_OWNED_KEYS, GLOBAL_NOTEBOOK_AUTH_OWNED_KEYS, GLOBAL_NOTEBOOK_DEFAULTS_OWNED_KEYS, GLOBAL_NOTEBOOK_LIMITS_OWNED_KEYS, GLOBAL_NOTEBOOK_SUMMARIZER_OWNED_KEYS, SAFE_PATH_SEGMENT;
2123
+ var PROJECT_SOURCE_SKILL_ROOTS_ENV, TICKET_PROVIDER_ADAPTERS_ENV, PROJECT_REGISTRY_SCHEMA_VERSION, DEFAULT_NEW_PROJECT_STATUS, PROJECT_TYPES, PROJECT_TYPE_LANGUAGES, PROJECT_TYPE_SENTINELS, BOARD_URL_DEPRECATION_WARNING, PROJECT_IDENTIFIER_SOURCES, IDENTIFIER_ASSIGNING_PROVIDERS, TICKET_PROVIDER_STATES, IDENTIFIER_REPAIR_COMMAND, DEFAULT_SOURCE_SKILL_ROOTS, PROJECT_REGISTRY_OWNED_KEYS, PROJECT_NOTEBOOK_OWNED_KEYS, TICKET_PROVIDER_OWNED_KEYS, GLOBAL_NOTEBOOK_OWNED_KEYS, GLOBAL_NOTEBOOK_AUTH_OWNED_KEYS, GLOBAL_NOTEBOOK_DEFAULTS_OWNED_KEYS, GLOBAL_NOTEBOOK_LIMITS_OWNED_KEYS, GLOBAL_NOTEBOOK_SUMMARIZER_OWNED_KEYS, SAFE_PATH_SEGMENT, LEGACY_SOURCE_ARTIFACT_KIND;
2020
2124
  var init_project = __esm({
2021
2125
  "src/project/index.ts"() {
2022
2126
  "use strict";
@@ -2072,6 +2176,7 @@ var init_project = __esm({
2072
2176
  GLOBAL_NOTEBOOK_LIMITS_OWNED_KEYS = Object.keys(DEFAULT_NOTEBOOK_LIMITS);
2073
2177
  GLOBAL_NOTEBOOK_SUMMARIZER_OWNED_KEYS = ["executable", "args"];
2074
2178
  SAFE_PATH_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
2179
+ LEGACY_SOURCE_ARTIFACT_KIND = "reference";
2075
2180
  }
2076
2181
  });
2077
2182
 
@@ -5585,6 +5690,10 @@ function backupToSnapshot(backup) {
5585
5690
  }
5586
5691
  return snapshot;
5587
5692
  }
5693
+ function removeEntry(full, stat) {
5694
+ if (stat.isDirectory() && !stat.isSymbolicLink()) rmSync(full, { recursive: true, force: true });
5695
+ else unlinkSync(full);
5696
+ }
5588
5697
  function restoreSingleEntry(root, rel, entry) {
5589
5698
  const full = rel === "." ? root : join3(root, rel);
5590
5699
  let currentStat;
@@ -5595,7 +5704,7 @@ function restoreSingleEntry(root, rel, entry) {
5595
5704
  }
5596
5705
  if (entry.kind === "dir") {
5597
5706
  if (currentStat && (!currentStat.isDirectory() || currentStat.isSymbolicLink())) {
5598
- rmSync(full, { recursive: true, force: true });
5707
+ removeEntry(full, currentStat);
5599
5708
  currentStat = void 0;
5600
5709
  }
5601
5710
  if (!currentStat) mkdirSync2(full, { recursive: true });
@@ -5606,7 +5715,7 @@ function restoreSingleEntry(root, rel, entry) {
5606
5715
  if (entry.kind === "link") {
5607
5716
  if (currentStat) {
5608
5717
  if (currentStat.isSymbolicLink() && readlinkSync2(full) === entry.target) return;
5609
- rmSync(full, { recursive: true, force: true });
5718
+ removeEntry(full, currentStat);
5610
5719
  }
5611
5720
  symlinkSync(entry.target, full);
5612
5721
  return;
@@ -5615,7 +5724,7 @@ function restoreSingleEntry(root, rel, entry) {
5615
5724
  if ((currentStat.mode & 4095) === entry.mode && readFileSync2(full).equals(entry.bytes)) return;
5616
5725
  unlinkSync(full);
5617
5726
  } else if (currentStat) {
5618
- rmSync(full, { recursive: true, force: true });
5727
+ removeEntry(full, currentStat);
5619
5728
  }
5620
5729
  writeFileSync2(full, entry.bytes);
5621
5730
  chmodSync(full, entry.mode);
@@ -6224,10 +6333,10 @@ function openDisplacedStash(projectRoot) {
6224
6333
  index.push({ rel, kind: "link", target });
6225
6334
  count++;
6226
6335
  } else if (bytes) {
6227
- const object = createHash4("sha256").update(bytes).digest("hex");
6228
- const objectPath = join5(open(), "objects", object);
6336
+ const object2 = createHash4("sha256").update(bytes).digest("hex");
6337
+ const objectPath = join5(open(), "objects", object2);
6229
6338
  if (!existsSync3(objectPath)) writeFileSync3(objectPath, bytes);
6230
- index.push({ rel, kind: "file", mode: stat.mode & 4095, object });
6339
+ index.push({ rel, kind: "file", mode: stat.mode & 4095, object: object2 });
6231
6340
  count++;
6232
6341
  } else if (stat.isDirectory()) {
6233
6342
  open();
@@ -8196,9 +8305,13 @@ async function predictRootCollisions(ctx, incoming) {
8196
8305
  async function auditProjectSkills(ctx) {
8197
8306
  const result2 = await inspectStatus(skillCoreOptions(ctx));
8198
8307
  const details = skillDiagnostics(result2.findings);
8308
+ let findings = result2.findings.filter((finding2) => finding2.severity !== "info").length;
8199
8309
  details.push(...(result2.data?.changes ?? []).map((change) => `${change.action}: ${change.path}`));
8310
+ const pendingChanges = (result2.data?.changes ?? []).length;
8200
8311
  const plan = planSkillRoots(ctx.repoRoot);
8201
- details.push(...skillRootsSummary(plan));
8312
+ const rootLines = skillRootsSummary(plan);
8313
+ details.push(...rootLines);
8314
+ findings += rootLines.length;
8202
8315
  const conflict = activationConflict(ctx, result2.findings);
8203
8316
  let conflictResolvable = false;
8204
8317
  if (conflict?.kind === "alias") conflictResolvable = plan.clean;
@@ -8206,6 +8319,7 @@ async function auditProjectSkills(ctx) {
8206
8319
  const predictions = await predictRootCollisions(ctx, []);
8207
8320
  conflictResolvable = predictions.length > 0 && predictions.every(({ decision }) => decision.action !== "block");
8208
8321
  for (const { decision } of predictions) {
8322
+ if (decision.action === "block") findings += 1;
8209
8323
  details.push(decision.action === "block" ? `blocked: ${decision.detail}` : `resolvable root collision: ${decision.detail.replace(/^replaced/, "replace")}`);
8210
8324
  }
8211
8325
  }
@@ -8214,7 +8328,10 @@ async function auditProjectSkills(ctx) {
8214
8328
  const installerOnly = errors.length > 0 && await installerRefusalsResolvable(ctx, errors, handoff);
8215
8329
  if (installerOnly) details.push("resolvable: every refusal is at a skill the bmad-method installer provides; migrate excludes the inherited selection and releases the replaced links");
8216
8330
  if (handoff.skipped) details.push(`installer skill handoff unavailable: ${handoff.skipped}`);
8217
- if (handoff.restored.length) details.push(...handoff.restored.map((name) => `stale exclusion: ${name} was excluded for the bmad-method installer's copy, which is gone; migrate restores the inherited catalog skill`));
8331
+ if (handoff.restored.length) {
8332
+ findings += handoff.restored.length;
8333
+ details.push(...handoff.restored.map((name) => `stale exclusion: ${name} was excluded for the bmad-method installer's copy, which is gone; migrate restores the inherited catalog skill`));
8334
+ }
8218
8335
  if (result2.exit === 2 || result2.exit === 3) details.push(migrationGuidance(ctx));
8219
8336
  const changes = result2.data?.changes ?? [];
8220
8337
  const receiptOnly = result2.exit === 6 && changes.length > 0 && changes.every((change) => change.action === "write-receipt") && !result2.findings.some((finding2) => finding2.severity === "error");
@@ -8226,6 +8343,8 @@ async function auditProjectSkills(ctx) {
8226
8343
  status: result2.exit === 0 ? staleHandoff ? "warn" : "pass" : result2.exit === 4 || receiptOnly ? "warn" : "fail",
8227
8344
  summary: result2.exit === 0 ? "Skillex declaration and activation are in parity" : receiptOnly ? "Skillex activation is in parity; its receipt predates the current catalog commit" : `Skillex inspection returned exit ${result2.exit}`,
8228
8345
  details,
8346
+ findings,
8347
+ pendingChanges,
8229
8348
  fixable: result2.exit === 0 || result2.exit === 6 || conflictResolvable || installerOnly || staleHandoff || result2.findings.some((finding2) => finding2.code === "E_NO_PROJECT_MANIFEST")
8230
8349
  };
8231
8350
  }
@@ -11267,28 +11386,54 @@ function consolidatedBmadConfig(repoRoot) {
11267
11386
  if (!existsSync8(path)) return { status: "absent" };
11268
11387
  let parsed;
11269
11388
  try {
11270
- parsed = YAML4.parse(readText(path));
11389
+ parsed = YAML4.parse(readText(path), { uniqueKeys: false });
11271
11390
  } catch (error) {
11272
- return { status: "unusable", reason: `could not be parsed: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}` };
11391
+ const reason = error instanceof Error ? error.message.split("\n")[0].replace(/:\s*$/, "") : String(error);
11392
+ return { status: "unusable", reason: `could not be parsed: ${reason}` };
11273
11393
  }
11274
11394
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
11275
11395
  return { status: "unusable", reason: "is not a YAML mapping" };
11276
11396
  }
11277
11397
  return { status: "mapping", sections: new Set(Object.keys(parsed)) };
11278
11398
  }
11399
+ var BMAD_SCAFFOLD_REMEDY = "pj migrate bmad.scaffold";
11400
+ function gitIgnoresPath(repoRoot, rel) {
11401
+ return spawnSync5("git", ["check-ignore", "-q", "--", rel], { cwd: repoRoot, encoding: "utf8", timeout: 5e3 }).status === 0;
11402
+ }
11403
+ function bmadManifestGapDetail(repoRoot) {
11404
+ const path = "_bmad/_config/manifest.yaml";
11405
+ const bmad = join12(repoRoot, "_bmad");
11406
+ if (!existsSync8(bmad)) return path;
11407
+ const present = (...segments) => {
11408
+ try {
11409
+ lstatSync10(join12(bmad, ...segments));
11410
+ return true;
11411
+ } catch {
11412
+ return false;
11413
+ }
11414
+ };
11415
+ const record3 = `${path} (the installer's install record)`;
11416
+ const remedy = `Restore it with: ${BMAD_SCAFFOLD_REMEDY}`;
11417
+ if (!present("_config") && !present("core") && existsSync8(join12(bmad, "config.yaml"))) {
11418
+ return `${record3}. BMB setup's \`--also-remove _config\` cleanup is the likely cause: it deletes all of _bmad/_config. ${remedy}`;
11419
+ }
11420
+ if (gitIgnoresPath(repoRoot, path)) return `${record3} is git-ignored in this checkout, so a fresh clone or worktree lacks it. ${remedy}`;
11421
+ return `${record3} is missing. ${remedy}`;
11422
+ }
11279
11423
  function bmadSentinelGaps(repoRoot, modules = selectedBmadModules(repoRoot)) {
11280
11424
  const bmad = join12(repoRoot, "_bmad");
11281
11425
  const gaps = [];
11282
11426
  const absent = (...segments) => !existsSync8(join12(bmad, ...segments));
11427
+ const installed = existsSync8(bmad);
11283
11428
  if (absent("config.toml")) gaps.push({ path: "config.toml", detail: "_bmad/config.toml" });
11284
11429
  for (const script of BMAD_RUNTIME_SCRIPT_SENTINELS) {
11285
- if (absent("scripts", script)) gaps.push({ path: join12("scripts", script), detail: `_bmad/scripts/${script}` });
11430
+ if (!absent("scripts", script)) continue;
11431
+ const rel = `_bmad/scripts/${script}`;
11432
+ const detail = !installed ? rel : gitIgnoresPath(repoRoot, rel) ? `${rel} (git-ignored in this checkout, so a fresh clone or worktree lacks it; restore it with: ${BMAD_SCAFFOLD_REMEDY})` : `${rel} (restore it with: ${BMAD_SCAFFOLD_REMEDY})`;
11433
+ gaps.push({ path: join12("scripts", script), detail });
11286
11434
  }
11287
11435
  if (absent("_config", "manifest.yaml")) {
11288
- gaps.push({
11289
- path: join12("_config", "manifest.yaml"),
11290
- detail: existsSync8(bmad) ? "_bmad/_config/manifest.yaml (the installer's install record). BMB setup's `--also-remove _config` cleanup is the likely cause: it deletes all of _bmad/_config. Restore it with: pj migrate bmad.scaffold" : "_bmad/_config/manifest.yaml"
11291
- });
11436
+ gaps.push({ path: join12("_config", "manifest.yaml"), detail: bmadManifestGapDetail(repoRoot) });
11292
11437
  }
11293
11438
  let consolidated;
11294
11439
  for (const module of modules) {
@@ -12092,11 +12237,14 @@ function createAgentHooksChecks() {
12092
12237
  const unsafe = retired.filter((path) => lstatIfPresent(path)?.isDirectory());
12093
12238
  const details = [...finding2.details, ...wiring, ...registryBlocked, ...retired.map((path) => unsafe.includes(path) ? `Inspect and preserve retired script directory: ${path}` : `${path} is a retired skill writer`)];
12094
12239
  const miseDrift = mise !== null && upsertLinkAgentfilesBlock(mise, ctx) !== mise;
12240
+ const { findings: auditFindings, pendingChanges, ...audited } = finding2;
12241
+ const problems = auditFindings + wiring.length + registryBlocked.length + retired.length;
12242
+ const summary = problems ? `${problems} Skillex finding(s)${pendingChanges ? `, ${pendingChanges} pending change(s)` : ""}` : pendingChanges && audited.status !== "pass" ? `${pendingChanges} pending Skillex change(s)` : audited.summary;
12095
12243
  return {
12096
- ...finding2,
12097
- status: wiring.length || retired.length || registryBlocked.length ? "fail" : finding2.status,
12098
- fixable: unsafe.length ? false : finding2.fixable || wiring.length > 0 || retired.length > 0 || miseDrift,
12099
- summary: details.length ? `${details.length} Skillex finding(s)` : finding2.summary,
12244
+ ...audited,
12245
+ status: wiring.length || retired.length || registryBlocked.length ? "fail" : audited.status,
12246
+ fixable: unsafe.length ? false : audited.fixable || wiring.length > 0 || retired.length > 0 || miseDrift,
12247
+ summary,
12100
12248
  details
12101
12249
  };
12102
12250
  },
@@ -12669,24 +12817,52 @@ function withInstallerSkillHandoff(check) {
12669
12817
  }
12670
12818
  };
12671
12819
  }
12672
- function withScaffoldPostcondition(check) {
12820
+ function withScaffoldScriptRestore(check) {
12673
12821
  if (check.id !== "bmad.scaffold") return check;
12674
12822
  return {
12675
12823
  ...check,
12676
12824
  migrate: async (ctx, finding2) => {
12677
- const result2 = await check.migrate(ctx, finding2);
12678
- if (ctx.dryRun || result2.status !== "applied" && result2.status !== "noop") return result2;
12679
- const after = await check.audit(ctx);
12680
- if (after.status === "pass" || after.status === "skip") return result2;
12681
- return {
12682
- ...result2,
12683
- status: "partial",
12684
- summary: after.summary,
12685
- details: [...result2.details, `still failing after the install: ${after.summary}`, ...after.details]
12686
- };
12825
+ const attempt = restoreScaffoldScripts(ctx, finding2);
12826
+ if (attempt.result) return attempt.result;
12827
+ const viaInstaller = await check.migrate(ctx, finding2);
12828
+ return attempt.note ? { ...viaInstaller, details: [attempt.note, ...viaInstaller.details] } : viaInstaller;
12687
12829
  }
12688
12830
  };
12689
12831
  }
12832
+ function restoreScaffoldScripts(ctx, finding2) {
12833
+ const manifest = manifestBmadModules(ctx.repoRoot);
12834
+ if (manifest.status === "invalid") return {};
12835
+ const modules = manifest.status === "valid" ? manifest.modules : configuredBmadModuleSelection(ctx.repoRoot)?.modules ?? [...DEFAULT_BMAD_MODULES];
12836
+ const issues = bmadScaffoldIssues(ctx.repoRoot, modules);
12837
+ const scriptGap = (path) => BMAD_RUNTIME_SCRIPT_SENTINELS.some((script) => path === join12("scripts", script));
12838
+ if (!issues.gaps.length || issues.nameIssues.details.length || !issues.gaps.every((gap) => scriptGap(gap.path))) return {};
12839
+ const state = bmadSetupScriptsState(ctx.repoRoot);
12840
+ if (state.kind === "present") return {};
12841
+ if (state.kind !== "missing") {
12842
+ return { note: `targeted _bmad/scripts restore not possible, so this takes the installer path: ${state.kind === "decline" ? state.summary : state.error}` };
12843
+ }
12844
+ const source = `bmad-method@${BMAD_INSTALLER_VERSION} (${state.pkg.root})`;
12845
+ if (ctx.dryRun) {
12846
+ return { result: {
12847
+ id: finding2.id,
12848
+ title: finding2.title,
12849
+ status: "applied",
12850
+ summary: "Would restore the missing _bmad/scripts from the verified pinned bmad-method package (no reinstall)",
12851
+ changedFiles: state.files,
12852
+ details: [source]
12853
+ } };
12854
+ }
12855
+ const applied = applyBmadSetupScripts(ctx.repoRoot, state.pkg, BMAD_INSTALLER_VERSION);
12856
+ if (applied.error) return { note: `targeted _bmad/scripts restore failed, so this takes the installer path: ${applied.error}` };
12857
+ return { result: {
12858
+ id: finding2.id,
12859
+ title: finding2.title,
12860
+ status: applied.files.length ? "applied" : "noop",
12861
+ summary: applied.files.length ? "Restored the missing _bmad/scripts from the verified pinned bmad-method package (no reinstall)" : "No changes required",
12862
+ changedFiles: applied.files,
12863
+ details: [source]
12864
+ } };
12865
+ }
12690
12866
  function createBmadChecks() {
12691
12867
  return [
12692
12868
  {
@@ -13214,7 +13390,7 @@ function createBmadChecks() {
13214
13390
  };
13215
13391
  }
13216
13392
  }
13217
- ].map(withInstallerSkillHandoff).map(withScaffoldPostcondition);
13393
+ ].map(withInstallerSkillHandoff).map(withScaffoldScriptRestore);
13218
13394
  }
13219
13395
  function createProjectMomoChecks() {
13220
13396
  return [
@@ -13837,6 +14013,7 @@ function ticketScope(ticket) {
13837
14013
  }
13838
14014
 
13839
14015
  // src/recipes/ProjectRecipe.ts
14016
+ init_manifestIndex();
13840
14017
  init_tree_diff();
13841
14018
 
13842
14019
  // src/recipes/NotebookRecipe.ts
@@ -15666,6 +15843,17 @@ function unsafeToRemove(targetDir) {
15666
15843
  }
15667
15844
  return null;
15668
15845
  }
15846
+ function manifestRefusal(manifestPath) {
15847
+ if (!existsSync18(manifestPath)) return void 0;
15848
+ try {
15849
+ const parsed = JSON.parse(readFileSync21(manifestPath, "utf8"));
15850
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return `Expected a JSON object: ${manifestPath}`;
15851
+ validateManifestShape(parsed, manifestPath);
15852
+ return void 0;
15853
+ } catch (error) {
15854
+ return error instanceof SyntaxError ? `Malformed JSON: ${manifestPath}` : error instanceof Error ? error.message : String(error);
15855
+ }
15856
+ }
15669
15857
  function publicAudit(report) {
15670
15858
  return {
15671
15859
  ...report,
@@ -15791,6 +15979,8 @@ var ProjectRecipe = class extends Recipe {
15791
15979
  });
15792
15980
  };
15793
15981
  let migrationReport;
15982
+ let unfinishedMigrations = [];
15983
+ const warnings = [];
15794
15984
  let audit;
15795
15985
  let notebookPlan;
15796
15986
  let notebookRecipeForCommit;
@@ -15890,20 +16080,37 @@ var ProjectRecipe = class extends Recipe {
15890
16080
  normalized.selectedRuleIds
15891
16081
  ));
15892
16082
  changedFiles.push(...migrationReport.changedFiles);
15893
- phases.push(...migrationReport.results.map((result2) => ({
16083
+ const migrationPhases = migrationReport.results.map((result2) => ({
15894
16084
  id: result2.id,
15895
16085
  status: result2.status === "applied" ? "changed" : result2.status === "noop" ? "unchanged" : result2.status === "skipped" ? "skipped" : "failed",
15896
16086
  // A `partial` failed, but its writes really happened, so they stay
15897
16087
  // accounted for rather than vanishing from the transaction record.
15898
16088
  changedFiles: result2.status === "applied" || result2.status === "partial" ? result2.changedFiles : [],
15899
16089
  message: result2.summary
15900
- })));
15901
- errors.push(...migrationReport.results.filter((result2) => result2.status === "blocked" || result2.status === "partial").map((result2) => describeFinding(result2)));
16090
+ }));
16091
+ phases.push(...migrationPhases);
16092
+ unfinishedMigrations = migrationReport.results.map((result2, index) => ({ result: result2, phase: migrationPhases[index] })).filter(({ result: result2 }) => result2.status === "blocked" || result2.status === "partial");
15902
16093
  }
15903
16094
  const eligibilityAudit = errors.length === 0 ? publicAudit(await this.registry.auditRecipes({ ...transactionContext, dryRun: true })) : void 0;
15904
16095
  audit = eligibilityAudit;
16096
+ const gating = new Set((eligibilityAudit?.rules ?? []).filter((finding2) => gatesProject(finding2)).map((finding2) => finding2.id));
16097
+ const reportedByMigration = /* @__PURE__ */ new Set();
16098
+ for (const { result: result2, phase } of unfinishedMigrations) {
16099
+ if (!eligibilityAudit || gating.has(result2.id)) {
16100
+ errors.push(describeFinding(result2));
16101
+ reportedByMigration.add(result2.id);
16102
+ } else {
16103
+ result2.advisory = true;
16104
+ phase.status = "skipped";
16105
+ phase.message = `advisory (does not block init): ${result2.summary}`;
16106
+ warnings.push(`${result2.id}: ${result2.summary}`);
16107
+ }
16108
+ }
16109
+ if (migrationReport && unfinishedMigrations.length && !unfinishedMigrations.some(({ result: result2 }) => !result2.advisory)) {
16110
+ migrationReport.ok = true;
16111
+ }
15905
16112
  if (eligibilityAudit && !eligibilityAudit.ok) {
15906
- errors.push(...eligibilityAudit.rules.filter((finding2) => gatesProject(finding2)).map((finding2) => describeFinding(finding2)));
16113
+ errors.push(...eligibilityAudit.rules.filter((finding2) => gatesProject(finding2) && !reportedByMigration.has(finding2.id)).map((finding2) => describeFinding(finding2)));
15907
16114
  }
15908
16115
  phases.push({
15909
16116
  id: "project.audit:eligibility",
@@ -15947,6 +16154,13 @@ var ProjectRecipe = class extends Recipe {
15947
16154
  }
15948
16155
  }
15949
16156
  }
16157
+ if (errors.length === 0) {
16158
+ const refusal = manifestRefusal(join24(targetDir, ".project.json"));
16159
+ if (refusal) {
16160
+ errors.push(`manifest refused before external effects: ${refusal}`);
16161
+ phases.push({ id: "project.manifest:validate", status: "failed", changedFiles: [], message: refusal });
16162
+ }
16163
+ }
15950
16164
  registryFinalizerEligible = errors.length === 0;
15951
16165
  if (errors.length === 0 && externalPlanActions.length) {
15952
16166
  const dispatching = externalPlanActions.some((action) => action.kind === "ticket-provider.create-or-link" && action.enabled);
@@ -16118,6 +16332,7 @@ var ProjectRecipe = class extends Recipe {
16118
16332
  selectedOperations: normalized.selectedOperations ?? [],
16119
16333
  selectedParityRules: normalized.selectedRuleIds ?? [],
16120
16334
  migrationReport,
16335
+ warnings,
16121
16336
  ...bmadCompanion ? { bmadCompanion } : {}
16122
16337
  };
16123
16338
  }
@@ -17918,6 +18133,7 @@ function projectPreflightFailure(plan, errors, audit) {
17918
18133
  plan,
17919
18134
  mode: plan.actions.some((action) => action.kind === "copier.copy.commonproject") ? "create" : "sync",
17920
18135
  audit,
18136
+ warnings: [],
17921
18137
  selectedOperations: plan.actions.map((action) => action.kind),
17922
18138
  selectedParityRules: []
17923
18139
  };
@@ -210,7 +210,7 @@ function mergeMissing(current, fallback) {
210
210
  function hash(data) {
211
211
  return createHash("sha256").update(data).digest("hex");
212
212
  }
213
- function validateManifestFields(manifest, path) {
213
+ function validateManifestShape(manifest, path) {
214
214
  validateExecutionBinding(manifest);
215
215
  const fail = (field, shape) => {
216
216
  throw new RegistryError(`Manifest ${path}: ${field} must be ${shape}`);
@@ -269,7 +269,7 @@ async function readManifest(path) {
269
269
  throw new RegistryError(`Malformed JSON: ${path}`);
270
270
  }
271
271
  if (!object(manifest)) throw new RegistryError(`Expected a JSON object: ${path}`);
272
- validateManifestFields(manifest, path);
272
+ validateManifestShape(manifest, path);
273
273
  const id = normalizeProjectId2(manifest.project_id ?? manifest.project_slug);
274
274
  if (manifest.project_id && manifest.project_slug && normalizeProjectId2(manifest.project_slug) !== id) {
275
275
  throw new RegistryError(`Conflicting project_id and project_slug: ${path}`, 409, "identity_conflict");
@@ -298,7 +298,7 @@ function recordFromManifest(manifest, path) {
298
298
  };
299
299
  }
300
300
  async function writeManifest(original, manifest) {
301
- validateManifestFields(manifest, original.path);
301
+ validateManifestShape(manifest, original.path);
302
302
  const serialized = serializedManifest(manifest);
303
303
  const temp = join(dirname(original.path), `.project.json.${randomUUID()}.tmp`);
304
304
  let committed = false;
@@ -585,7 +585,7 @@ var ManifestIndex = class {
585
585
  else throw new RegistryError(`Missing concurrency baseline for ${id}; reload the registry and retry`, 409, "stale_manifest");
586
586
  if (next.repo_path !== current.repo_path) throw new RegistryError(`Cannot move ${id} through a registry snapshot; register its canonical manifest path`, 409, "identity_conflict");
587
587
  const manifest = applyRecord(read.manifest, current, next);
588
- validateManifestFields(manifest, path);
588
+ validateManifestShape(manifest, path);
589
589
  serializedManifest(manifest);
590
590
  writes.push({ read, manifest, oldId: row?.project_id });
591
591
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delorenj/pjangler",
3
- "version": "1.5.53",
3
+ "version": "1.5.54",
4
4
  "description": "Project subsystem bootstrapper CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",