@delorenj/pjangler 1.4.4 → 1.4.6

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.
@@ -192,7 +192,7 @@ var init_types = __esm({
192
192
  });
193
193
 
194
194
  // src/utils/tree-diff.ts
195
- import { createHash as createHash3 } from "node:crypto";
195
+ import { createHash as createHash4 } from "node:crypto";
196
196
  import { existsSync as existsSync4, lstatSync as lstatSync4, readFileSync as readFileSync5, readdirSync as readdirSync3, readlinkSync as readlinkSync2 } from "node:fs";
197
197
  import { join as join6, relative as relative3 } from "node:path";
198
198
  function snapshotTree(root, current = root, snapshot = /* @__PURE__ */ new Map()) {
@@ -203,7 +203,7 @@ function snapshotTree(root, current = root, snapshot = /* @__PURE__ */ new Map()
203
203
  if (stat.isSymbolicLink()) {
204
204
  snapshot.set(rel, `link:${readlinkSync2(current)}`);
205
205
  } else if (stat.isFile()) {
206
- snapshot.set(rel, `file:${createHash3("sha256").update(readFileSync5(current)).digest("hex")}:${stat.mode & 511}`);
206
+ snapshot.set(rel, `file:${createHash4("sha256").update(readFileSync5(current)).digest("hex")}:${stat.mode & 511}`);
207
207
  } else if (stat.isDirectory()) {
208
208
  snapshot.set(rel, `dir:${stat.mode & 511}`);
209
209
  for (const name of readdirSync3(current)) snapshotTree(root, join6(current, name), snapshot);
@@ -222,7 +222,7 @@ var init_tree_diff = __esm({
222
222
  });
223
223
 
224
224
  // src/lifecycle/preflight.ts
225
- import { createHash as createHash4 } from "node:crypto";
225
+ import { createHash as createHash5 } from "node:crypto";
226
226
  import {
227
227
  accessSync,
228
228
  constants as constants2,
@@ -254,7 +254,7 @@ function firstExecutableOnPath(env2) {
254
254
  return void 0;
255
255
  }
256
256
  function sha2562(path) {
257
- return createHash4("sha256").update(readFileSync6(path)).digest("base64url");
257
+ return createHash5("sha256").update(readFileSync6(path)).digest("base64url");
258
258
  }
259
259
  function fingerprint(path) {
260
260
  const absolute = resolve4(path);
@@ -2529,7 +2529,7 @@ var init_config = __esm({
2529
2529
  });
2530
2530
 
2531
2531
  // src/notebook/notes.ts
2532
- import { createHash as createHash5, randomUUID as randomUUID2 } from "node:crypto";
2532
+ import { createHash as createHash6, randomUUID as randomUUID2 } from "node:crypto";
2533
2533
  function canonicalJson(value) {
2534
2534
  if (value === void 0 || typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") throw new NotebookError("INVALID_INPUT", "Canonical JSON value is not JSON-compatible");
2535
2535
  if (value === null || typeof value !== "object") {
@@ -2542,7 +2542,7 @@ function canonicalJson(value) {
2542
2542
  return `{${Object.keys(record).filter((key) => record[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
2543
2543
  }
2544
2544
  function sha256Hex(value) {
2545
- return createHash5("sha256").update(value).digest("hex");
2545
+ return createHash6("sha256").update(value).digest("hex");
2546
2546
  }
2547
2547
  function base64UrlEncode(value) {
2548
2548
  return Buffer.from(value, "utf8").toString("base64url");
@@ -2799,7 +2799,7 @@ var init_remote_mutation_schema = __esm({
2799
2799
  });
2800
2800
 
2801
2801
  // src/notebook/state.ts
2802
- import { createHash as createHash6, randomUUID as randomUUID3 } from "node:crypto";
2802
+ import { createHash as createHash7, randomUUID as randomUUID3 } from "node:crypto";
2803
2803
  import {
2804
2804
  closeSync as closeSync4,
2805
2805
  constants as constants3,
@@ -5098,7 +5098,7 @@ var init_capture = __esm({
5098
5098
 
5099
5099
  // src/mcp-server.ts
5100
5100
  import { existsSync as existsSync27, statSync as statSync8 } from "node:fs";
5101
- import { basename as basename9, dirname as dirname17, join as join35, resolve as resolve21 } from "node:path";
5101
+ import { basename as basename9, dirname as dirname18, join as join36, resolve as resolve22 } from "node:path";
5102
5102
  import { fileURLToPath as fileURLToPath9 } from "node:url";
5103
5103
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5104
5104
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -5307,7 +5307,7 @@ import { existsSync as existsSync2, lstatSync as lstatSync2, mkdirSync as mkdirS
5307
5307
  import { basename as basename2, dirname as dirname2, join as join3, relative as relative2, resolve as resolve3 } from "node:path";
5308
5308
  import { fileURLToPath, pathToFileURL } from "node:url";
5309
5309
  import { homedir as homedir2 } from "node:os";
5310
- import { createHash as createHash2 } from "node:crypto";
5310
+ import { createHash as createHash3 } from "node:crypto";
5311
5311
  import { spawnSync } from "node:child_process";
5312
5312
  import YAML from "yaml";
5313
5313
 
@@ -5323,9 +5323,149 @@ var SUPPORTED_CLIS = Object.freeze([
5323
5323
  var SUPPORTED_BMAD_TOOLS = SUPPORTED_CLIS.map((cli) => cli.bmadTool);
5324
5324
  var SUPPORTED_CLI_ROOTS = SUPPORTED_CLIS.map((cli) => cli.projectRoot);
5325
5325
 
5326
+ // src/scaffold/compare.ts
5327
+ import { createHash } from "node:crypto";
5328
+ var PLACEHOLDER = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/gu;
5329
+ var JINJA_DELIMITER = /\{\{|\{%|\{#/u;
5330
+ function renderTemplate(source, inputs) {
5331
+ const stripped = source.replace(PLACEHOLDER, "");
5332
+ if (JINJA_DELIMITER.test(stripped)) {
5333
+ return { ok: false, reason: "render-unsupported", detail: "render-unsupported: template uses Jinja constructs beyond simple substitution" };
5334
+ }
5335
+ const undeclared = /* @__PURE__ */ new Set();
5336
+ const missing = /* @__PURE__ */ new Set();
5337
+ const text2 = source.replace(PLACEHOLDER, (whole, name) => {
5338
+ if (!Object.prototype.hasOwnProperty.call(inputs, name)) {
5339
+ undeclared.add(name);
5340
+ return whole;
5341
+ }
5342
+ const value = inputs[name];
5343
+ if (value === null || value === void 0) {
5344
+ missing.add(name);
5345
+ return whole;
5346
+ }
5347
+ return value;
5348
+ });
5349
+ if (undeclared.size > 0) {
5350
+ return { ok: false, reason: "render-unsupported", detail: `render-unsupported: undeclared placeholder ${[...undeclared].sort().join(", ")}` };
5351
+ }
5352
+ if (missing.size > 0) {
5353
+ return { ok: false, reason: "input-missing", detail: `input-missing: ${[...missing].sort().join(", ")}` };
5354
+ }
5355
+ return { ok: true, text: text2 };
5356
+ }
5357
+ function blobId(bytes) {
5358
+ return createHash("sha1").update(`blob ${bytes.byteLength}\0`, "latin1").update(bytes).digest("hex");
5359
+ }
5360
+ function digestPrefix(id) {
5361
+ return id.slice(0, 12);
5362
+ }
5363
+ function globToRegExp(glob) {
5364
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/gu, "\\$&").replace(/\*/gu, "[^/]*").replace(/\?/gu, "[^/]");
5365
+ return new RegExp(`^${escaped}$`, "u");
5366
+ }
5367
+ function matchesExcluded(path, patterns) {
5368
+ const segments = path.split("/").filter((segment) => segment !== "");
5369
+ for (const pattern of patterns) {
5370
+ if (pattern === "" || pattern === "/") continue;
5371
+ const directory = pattern.endsWith("/");
5372
+ const glob = directory ? pattern.slice(0, -1) : pattern;
5373
+ if (glob === "") continue;
5374
+ if (glob.includes("/")) {
5375
+ const expression2 = globToRegExp(glob.replace(/^\/+/u, ""));
5376
+ if (directory ? segments.some((_, index) => index < segments.length - 1 && expression2.test(segments.slice(0, index + 1).join("/"))) : expression2.test(segments.join("/"))) return pattern;
5377
+ continue;
5378
+ }
5379
+ const expression = globToRegExp(glob);
5380
+ const candidates = directory ? segments.slice(0, -1) : segments;
5381
+ if (candidates.some((segment) => expression.test(segment))) return pattern;
5382
+ }
5383
+ return null;
5384
+ }
5385
+ function groupFor(path, groups) {
5386
+ let best = null;
5387
+ let bestLength = -1;
5388
+ for (const leaf of Object.keys(groups).sort()) {
5389
+ const rolePath = groups[leaf];
5390
+ const matches = rolePath.endsWith("/") ? path.startsWith(rolePath) : path === rolePath;
5391
+ if (!matches) continue;
5392
+ const length = rolePath.endsWith("/") ? rolePath.length : rolePath.length + 1;
5393
+ if (length > bestLength) {
5394
+ best = leaf;
5395
+ bestLength = length;
5396
+ }
5397
+ }
5398
+ return best;
5399
+ }
5400
+ function typeWord(asset) {
5401
+ return asset.type;
5402
+ }
5403
+ function modeWord(executable) {
5404
+ return executable ? "100755" : "100644";
5405
+ }
5406
+ function compareAssets(desired, observe, options) {
5407
+ const out = [];
5408
+ const compareModes = options.modes !== false;
5409
+ const push = (asset, seen, kind, desiredWord, observedWord, detail = null) => {
5410
+ out.push({ path: asset.path, kind, desired: desiredWord, observed: observedWord, detail, wip: seen.wip });
5411
+ };
5412
+ for (const asset of desired) {
5413
+ const seen = observe(asset);
5414
+ if (asset.incomplete) {
5415
+ push(asset, seen, "incomplete", null, seen.present ? seen.type : "absent", asset.incomplete.detail);
5416
+ continue;
5417
+ }
5418
+ if (!seen.present) {
5419
+ push(asset, seen, "missing", typeWord(asset), "absent");
5420
+ continue;
5421
+ }
5422
+ if (seen.type === "symlink" && seen.unsafeSymlink) {
5423
+ push(asset, seen, "unsafe-symlink", typeWord(asset), "symlink");
5424
+ continue;
5425
+ }
5426
+ if (seen.type !== asset.type) {
5427
+ push(asset, seen, "wrong-type", typeWord(asset), seen.type ?? "unknown");
5428
+ continue;
5429
+ }
5430
+ if (compareModes && asset.type === "file" && seen.executable !== asset.executable) {
5431
+ push(asset, seen, "wrong-mode", modeWord(asset.executable), modeWord(seen.executable));
5432
+ }
5433
+ if (asset.presenceOnly || asset.blobId === null) continue;
5434
+ if (seen.unreadable !== null || seen.blobId === null) {
5435
+ push(asset, seen, "incomplete", digestPrefix(asset.blobId), null, `unreadable: ${seen.unreadable ?? "unread"}`);
5436
+ continue;
5437
+ }
5438
+ if (seen.blobId === asset.blobId) continue;
5439
+ const kind = options.inLineage(seen.blobId, asset.path) ? "stale-content" : "locally-modified";
5440
+ push(asset, seen, kind, digestPrefix(asset.blobId), digestPrefix(seen.blobId));
5441
+ }
5442
+ return out.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0);
5443
+ }
5444
+ function classifyExtras(input) {
5445
+ const unexpected = [];
5446
+ let foreignTracked = 0;
5447
+ const runtimePrefix = `${input.runtimeDir.replace(/\/+$/u, "")}/`;
5448
+ for (const path of input.trackedPaths) {
5449
+ if (input.ownedPaths.has(path)) continue;
5450
+ if (path.startsWith(runtimePrefix)) {
5451
+ foreignTracked += 1;
5452
+ continue;
5453
+ }
5454
+ const group = groupFor(path, input.groups);
5455
+ if (group === null) {
5456
+ foreignTracked += 1;
5457
+ continue;
5458
+ }
5459
+ const excluded = matchesExcluded(path, input.excludedPatterns);
5460
+ unexpected.push({ path, group, detail: excluded === null ? null : `matches excluded pattern ${excluded}` });
5461
+ }
5462
+ unexpected.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
5463
+ return { unexpected, foreignTracked };
5464
+ }
5465
+
5326
5466
  // src/parity/pack.ts
5327
5467
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync } from "node:fs";
5328
- import { createHash } from "node:crypto";
5468
+ import { createHash as createHash2 } from "node:crypto";
5329
5469
  import { basename, join as join2, relative, resolve as resolve2, sep } from "node:path";
5330
5470
  var PackUnavailableError = class extends Error {
5331
5471
  constructor(message) {
@@ -5335,7 +5475,7 @@ var PackUnavailableError = class extends Error {
5335
5475
  };
5336
5476
  var CANONICAL_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
5337
5477
  function sha256(content) {
5338
- return createHash("sha256").update(content).digest("hex");
5478
+ return createHash2("sha256").update(content).digest("hex");
5339
5479
  }
5340
5480
  function readRegularFile(path) {
5341
5481
  const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -6647,7 +6787,7 @@ function availableSkillsRegistryRoots(ctx) {
6647
6787
  );
6648
6788
  }
6649
6789
  function digestSkillEntry(root) {
6650
- const hash = createHash2("sha256");
6790
+ const hash = createHash3("sha256");
6651
6791
  try {
6652
6792
  const stat = lstatSync2(root);
6653
6793
  if (stat.isSymbolicLink()) return null;
@@ -7712,8 +7852,72 @@ ${roleSpecific}
7712
7852
  Your memory is stored locally at \`./runtime/memories/\`. Use durable memory deliberately and keep \`memories/MEMORY.md\` current.
7713
7853
  `;
7714
7854
  }
7855
+ function renderScaffoldAsset(templateRoleDir, jinjaRel, inputs) {
7856
+ const result2 = renderTemplate(readText(join3(templateRoleDir, jinjaRel)), inputs);
7857
+ if (!result2.ok) throw new Error(`${jinjaRel}: ${result2.detail}`);
7858
+ return result2.text;
7859
+ }
7715
7860
  function renderHermesWrapper(role, templateRoleDir) {
7716
- return readText(join3(templateRoleDir, "hermes.jinja")).replace(/\{\{\s*agent_id\s*\}\}/g, role.agentId);
7861
+ return renderScaffoldAsset(templateRoleDir, "hermes.jinja", { agent_id: role.agentId });
7862
+ }
7863
+ function sentinelPromptInputs(role) {
7864
+ return {
7865
+ agent_id: role.agentId,
7866
+ role: role.role,
7867
+ target_repo: role.repo,
7868
+ display_name: role.displayName || role.agentId,
7869
+ ticket_provider: role.ticketProviderName || "plane"
7870
+ };
7871
+ }
7872
+ function observeScaffoldAsset(path) {
7873
+ const seen = { present: false, type: null, executable: false, blobId: null, unsafeSymlink: false, unreadable: null, wip: false };
7874
+ try {
7875
+ const stat = lstatSync2(path);
7876
+ seen.present = true;
7877
+ if (stat.isSymbolicLink()) {
7878
+ seen.type = "symlink";
7879
+ seen.blobId = blobId(Buffer.from(readlinkSync(path), "utf8"));
7880
+ } else if (stat.isDirectory()) {
7881
+ seen.type = "directory";
7882
+ } else if (stat.isFile()) {
7883
+ seen.type = "file";
7884
+ seen.executable = (stat.mode & 73) !== 0;
7885
+ try {
7886
+ seen.blobId = blobId(readFileSync2(path));
7887
+ } catch {
7888
+ seen.unreadable = "unreadable";
7889
+ }
7890
+ } else {
7891
+ seen.type = "other";
7892
+ }
7893
+ } catch {
7894
+ }
7895
+ return seen;
7896
+ }
7897
+ function scaffoldDesiredForRule(role, templateRoleDir, managedScripts) {
7898
+ const desired = [];
7899
+ const asset = (path, blob, incomplete = null, presenceOnly = false) => {
7900
+ desired.push({ path, type: "file", executable: false, blobId: blob, presenceOnly, incomplete });
7901
+ };
7902
+ for (const rel of ["role.yaml", "SOUL.md", ".runtime-scaffold/README.md"]) asset(rel, null, null, true);
7903
+ const rendered = (path, jinjaRel, inputs) => {
7904
+ const source = join3(templateRoleDir, jinjaRel);
7905
+ if (!existsSync2(source)) {
7906
+ asset(path, null, { reason: "render-unsupported", detail: `render-unsupported: ${jinjaRel} is absent from the template` });
7907
+ return;
7908
+ }
7909
+ const result2 = renderTemplate(readFileSync2(source).toString("utf8"), inputs);
7910
+ if (!result2.ok) {
7911
+ asset(path, null, { reason: result2.reason, detail: result2.detail });
7912
+ return;
7913
+ }
7914
+ asset(path, blobId(Buffer.from(result2.text, "utf8")));
7915
+ };
7916
+ rendered("hermes", "hermes.jinja", { agent_id: role.agentId });
7917
+ rendered(".gitignore", ".gitignore.jinja", { role: role.role });
7918
+ for (const rel of managedScripts) asset(`.scripts/${rel}`, blobId(readFileSync2(join3(templateRoleDir, ".scripts", rel))));
7919
+ rendered(".scripts/sentinel.prompt.md", ".scripts/sentinel.prompt.md.jinja", sentinelPromptInputs(role));
7920
+ return desired;
7717
7921
  }
7718
7922
  function templateFiles(sourceDir, current = sourceDir) {
7719
7923
  if (!existsSync2(current)) return [];
@@ -7767,7 +7971,7 @@ function managedHermesScaffoldRoles(ctx) {
7767
7971
  return { roles, blockers };
7768
7972
  }
7769
7973
  function renderSentinelPrompt(role, templateRoleDir) {
7770
- return readText(join3(templateRoleDir, ".scripts", "sentinel.prompt.md.jinja")).replace(/\{\{\s*agent_id\s*\}\}/g, role.agentId).replace(/\{\{\s*role\s*\}\}/g, role.role).replace(/\{\{\s*target_repo\s*\}\}/g, role.repo).replace(/\{\{\s*display_name\s*\}\}/g, role.displayName || role.agentId).replace(/\{\{\s*ticket_provider\s*\}\}/g, role.ticketProviderName || "plane");
7974
+ return renderScaffoldAsset(templateRoleDir, join3(".scripts", "sentinel.prompt.md.jinja"), sentinelPromptInputs(role));
7771
7975
  }
7772
7976
  function copyMissingRecursive(sourceDir, targetDir, changedFiles, dryRun, skip) {
7773
7977
  if (!existsSync2(sourceDir)) return;
@@ -8495,7 +8699,7 @@ function ownedRegistryEntries(registry, repoRoot) {
8495
8699
  }
8496
8700
  return owned;
8497
8701
  }
8498
- function unprovisionedRoleAgents(registry, repoRoot, canonical) {
8702
+ function unprovisionedRoleAgents(registry, repoRoot, canonical2) {
8499
8703
  const blockers = /* @__PURE__ */ new Map();
8500
8704
  const record = (agentId, roleDir, source) => {
8501
8705
  const current = blockers.get(agentId) ?? { roleDir, sources: /* @__PURE__ */ new Set() };
@@ -8504,12 +8708,12 @@ function unprovisionedRoleAgents(registry, repoRoot, canonical) {
8504
8708
  blockers.set(agentId, current);
8505
8709
  };
8506
8710
  for (const [agentId, entry] of ownedRegistryEntries(registry, repoRoot)) {
8507
- if (canonical.has(agentId)) continue;
8711
+ if (canonical2.has(agentId)) continue;
8508
8712
  const roleDir = String(entry.role_dir ?? "");
8509
8713
  if (declaredRoleIsUnprovisioned(repoRoot, roleDir)) record(agentId, roleDir, "registry");
8510
8714
  }
8511
8715
  for (const [agentId, entry] of declaredAgentEntries(repoRoot)) {
8512
- if (canonical.has(agentId)) continue;
8716
+ if (canonical2.has(agentId)) continue;
8513
8717
  const configured = String(entry.role_dir ?? "");
8514
8718
  const roleDir = configured ? resolve3(repoRoot, configured) : "";
8515
8719
  if (declaredRoleIsUnprovisioned(repoRoot, configured)) record(agentId, roleDir, ".project.json");
@@ -8760,7 +8964,7 @@ function unsupportedRootAttestation(repoRoot, rootName) {
8760
8964
  for (const rel of walked.files) {
8761
8965
  const expectedHash = inventory.files.get(rel);
8762
8966
  if (!expectedHash) return { safe: false, reason: `${rootName}/${rel} is outside the BMAD generated inventory` };
8763
- const actualHash = createHash2("sha256").update(readFileSync2(join3(root, rel))).digest("hex");
8967
+ const actualHash = createHash3("sha256").update(readFileSync2(join3(root, rel))).digest("hex");
8764
8968
  if (actualHash !== expectedHash) return { safe: false, reason: `${rootName}/${rel} was locally modified after generation` };
8765
8969
  }
8766
8970
  return { safe: true, reason: `${walked.files.length} file(s) match BMAD installer inventory and hashes` };
@@ -9307,15 +9511,15 @@ function createProjectJsonChecks() {
9307
9511
  const droppedDetails = [];
9308
9512
  const path = join3(ctx.repoRoot, ".project.json");
9309
9513
  const existing = readProjectJson(ctx) ?? {};
9310
- const canonical = canonicalProjectJson(ctx);
9514
+ const canonical2 = canonicalProjectJson(ctx);
9311
9515
  const preservedDetails = [];
9312
- for (const agentId of canonical.unprovisioned) {
9516
+ for (const agentId of canonical2.unprovisioned) {
9313
9517
  const roleDir = existing.agents?.[agentId]?.role_dir;
9314
9518
  preservedDetails.push(
9315
9519
  `preserved unprovisioned declared agent: ${agentId}${typeof roleDir === "string" && roleDir ? ` (${roleDir} has no role.yaml)` : " (no role_dir)"}; provision or restore the role, do not delete its declaration`
9316
9520
  );
9317
9521
  }
9318
- for (const agentId of canonical.dropped) {
9522
+ for (const agentId of canonical2.dropped) {
9319
9523
  const entry = existing.agents?.[agentId];
9320
9524
  const entryRecord = typeof entry === "object" && entry !== null ? entry : void 0;
9321
9525
  const declared = {
@@ -9328,7 +9532,7 @@ function createProjectJsonChecks() {
9328
9532
  const reason = validation.details.join("; ") || "invalid";
9329
9533
  droppedDetails.push(`dropped invalid declared agent: ${agentId} (${reason})`);
9330
9534
  }
9331
- const { dropped: _dropped, unprovisioned: _unprovisioned, ...canonicalJson2 } = canonical;
9535
+ const { dropped: _dropped, unprovisioned: _unprovisioned, ...canonicalJson2 } = canonical2;
9332
9536
  const merged = { ...existing, ...canonicalJson2 };
9333
9537
  const expected = `${JSON.stringify(merged, null, 2)}
9334
9538
  `;
@@ -10026,26 +10230,19 @@ function createHermesChecks() {
10026
10230
  const managedScripts = templateFiles(join3(templateRoleDir, ".scripts")).filter((rel) => rel !== "sentinel.prompt.md.jinja");
10027
10231
  for (const role of selection.roles) {
10028
10232
  const prefix = role.agentId || role.role;
10029
- for (const rel of ["role.yaml", "SOUL.md", ".runtime-scaffold/README.md", "runtime/memories/MEMORY.md"]) {
10030
- if (!existsSync2(join3(role.roleDir, rel))) details.push(`${prefix}: missing ${relative2(ctx.repoRoot, join3(role.roleDir, rel))}`);
10031
- }
10032
- const wrapper = join3(role.roleDir, "hermes");
10033
- const expectedWrapper = renderHermesWrapper(role, templateRoleDir);
10034
- if (!existsSync2(wrapper)) details.push(`${prefix}: missing ${relative2(ctx.repoRoot, wrapper)}`);
10035
- else if (safeReadText(wrapper) !== expectedWrapper) details.push(`${prefix}: stale ${relative2(ctx.repoRoot, wrapper)}`);
10036
- const expectedIgnore = readText(join3(templateRoleDir, ".gitignore.jinja")).replace(/\{\{\s*role\s*\}\}/g, role.role);
10037
- const ignorePath = join3(role.roleDir, ".gitignore");
10038
- if (!existsSync2(ignorePath)) details.push(`${prefix}: missing ${relative2(ctx.repoRoot, ignorePath)}`);
10039
- else if (safeReadText(ignorePath) !== expectedIgnore) details.push(`${prefix}: stale ${relative2(ctx.repoRoot, ignorePath)}`);
10040
- for (const rel of managedScripts) {
10041
- const source = join3(templateRoleDir, ".scripts", rel);
10042
- const target = join3(role.roleDir, ".scripts", rel);
10043
- if (!existsSync2(target)) details.push(`${prefix}: missing ${relative2(ctx.repoRoot, target)}`);
10044
- else if (safeReadText(target) !== readText(source)) details.push(`${prefix}: stale ${relative2(ctx.repoRoot, target)}`);
10233
+ const memory = join3(role.roleDir, "runtime", "memories", "MEMORY.md");
10234
+ if (!existsSync2(memory)) details.push(`${prefix}: missing ${relative2(ctx.repoRoot, memory)}`);
10235
+ const desired = scaffoldDesiredForRule(role, templateRoleDir, managedScripts);
10236
+ const findings = compareAssets(
10237
+ desired,
10238
+ (asset) => observeScaffoldAsset(join3(role.roleDir, ...asset.path.split("/"))),
10239
+ { inLineage: () => true, modes: false }
10240
+ );
10241
+ for (const finding2 of findings) {
10242
+ const word = finding2.kind === "stale-content" ? "stale" : finding2.kind;
10243
+ const shown = relative2(ctx.repoRoot, join3(role.roleDir, ...finding2.path.split("/")));
10244
+ details.push(`${prefix}: ${word} ${shown}${finding2.detail ? ` (${finding2.detail})` : ""}`);
10045
10245
  }
10046
- const promptPath = join3(role.roleDir, ".scripts", "sentinel.prompt.md");
10047
- if (!existsSync2(promptPath)) details.push(`${prefix}: missing ${relative2(ctx.repoRoot, promptPath)}`);
10048
- else if (safeReadText(promptPath) !== renderSentinelPrompt(role, templateRoleDir)) details.push(`${prefix}: stale ${relative2(ctx.repoRoot, promptPath)}`);
10049
10246
  if (hasRuntimeSubmoduleMapping(ctx.repoRoot, role)) details.push(`${prefix}: .gitmodules contains retired ${role.role} runtime submodule mapping`);
10050
10247
  if (!profileMetaInheritsDefault(join3(role.roleDir, "runtime", "profile.yaml"))) details.push(`${prefix}: runtime/profile.yaml missing inherited default config metadata`);
10051
10248
  const registry = safeReadText(registryPath(ctx.homeDir));
@@ -10648,9 +10845,9 @@ function createHermesChecks() {
10648
10845
  }
10649
10846
  return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "warn", summary: `registry unreadable at ${registryPath2}`, details: [], fixable: false };
10650
10847
  }
10651
- const canonical = new Set(roles.map((role) => role.agentId).filter(Boolean));
10848
+ const canonical2 = new Set(roles.map((role) => role.agentId).filter(Boolean));
10652
10849
  const owned = ownedRegistryEntries(registry, ctx.repoRoot);
10653
- const unprovisioned = unprovisionedRoleAgents(registry, ctx.repoRoot, canonical);
10850
+ const unprovisioned = unprovisionedRoleAgents(registry, ctx.repoRoot, canonical2);
10654
10851
  if (unprovisioned.length) {
10655
10852
  return {
10656
10853
  id: "hermes.registry-parity",
@@ -10663,16 +10860,16 @@ function createHermesChecks() {
10663
10860
  fixable: false
10664
10861
  };
10665
10862
  }
10666
- if (canonical.size === 0) {
10863
+ if (canonical2.size === 0) {
10667
10864
  return { id: "hermes.registry-parity", title: "Fleet registry matches .project.json (no duplicate or stale agents)", status: "skip", summary: "No Hermes roles, declarations, or registry entries present", details: [], fixable: false };
10668
10865
  }
10669
10866
  for (const [agentId, entry] of owned) {
10670
10867
  const roleDir = String(entry?.role_dir ?? "");
10671
- if (!canonical.has(agentId)) {
10672
- details.push(`stale/duplicate registry agent "${agentId}" for ${roleDir} (role.yaml declares ${[...canonical].join(", ")})`);
10868
+ if (!canonical2.has(agentId)) {
10869
+ details.push(`stale/duplicate registry agent "${agentId}" for ${roleDir} (role.yaml declares ${[...canonical2].join(", ")})`);
10673
10870
  }
10674
10871
  }
10675
- for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical.has(id))) {
10872
+ for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical2.has(id))) {
10676
10873
  details.push(`.project.json declares agent "${extra}" that no role.yaml claims`);
10677
10874
  }
10678
10875
  for (const role of roles) {
@@ -10761,8 +10958,8 @@ function createHermesChecks() {
10761
10958
  return { id: finding2.id, title: finding2.title, status: "blocked", summary: "registry is not valid YAML", changedFiles, details };
10762
10959
  }
10763
10960
  const agents = doc?.agents ?? {};
10764
- const canonical = new Set(roles.map((role) => role.agentId).filter(Boolean));
10765
- const unprovisioned = unprovisionedRoleAgents(agents, ctx.repoRoot, canonical);
10961
+ const canonical2 = new Set(roles.map((role) => role.agentId).filter(Boolean));
10962
+ const unprovisioned = unprovisionedRoleAgents(agents, ctx.repoRoot, canonical2);
10766
10963
  if (unprovisioned.length) {
10767
10964
  return {
10768
10965
  id: finding2.id,
@@ -10777,7 +10974,7 @@ function createHermesChecks() {
10777
10974
  }
10778
10975
  const fleetBin = fleetBinPath(ctx);
10779
10976
  let dirty = false;
10780
- if (canonical.size === 0) {
10977
+ if (canonical2.size === 0) {
10781
10978
  for (const [agentId] of ownedRegistryEntries(agents, ctx.repoRoot)) {
10782
10979
  details.push(`blocked: "${agentId}" has no role.yaml; provision the role instead of pruning the registry`);
10783
10980
  }
@@ -10839,7 +11036,7 @@ function createHermesChecks() {
10839
11036
  }
10840
11037
  }
10841
11038
  for (const [agentId, entry] of ownedRegistryEntries(agents, ctx.repoRoot)) {
10842
- if (canonical.size > 0 && !canonical.has(agentId)) {
11039
+ if (canonical2.size > 0 && !canonical2.has(agentId)) {
10843
11040
  details.push(`drop stale/duplicate registry agent "${agentId}"`);
10844
11041
  delete agents[agentId];
10845
11042
  dropDeclaredAgent(ctx, agentId, changedFiles, details);
@@ -10859,8 +11056,8 @@ function createHermesChecks() {
10859
11056
  dirty = true;
10860
11057
  }
10861
11058
  }
10862
- if (canonical.size > 0) {
10863
- for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical.has(id))) {
11059
+ if (canonical2.size > 0) {
11060
+ for (const extra of declaredAgentIds(ctx.repoRoot).filter((id) => !canonical2.has(id))) {
10864
11061
  dropDeclaredAgent(ctx, extra, changedFiles, details);
10865
11062
  }
10866
11063
  }
@@ -13351,7 +13548,7 @@ init_notes();
13351
13548
  init_state();
13352
13549
  init_types();
13353
13550
  import { spawn, spawnSync as spawnSync11 } from "node:child_process";
13354
- import { createHash as createHash7, randomUUID as randomUUID5 } from "node:crypto";
13551
+ import { createHash as createHash8, randomUUID as randomUUID5 } from "node:crypto";
13355
13552
  import { chmodSync as chmodSync4, closeSync as closeSync6, constants as constants5, copyFileSync as copyFileSync3, existsSync as existsSync17, fstatSync as fstatSync4, fsyncSync as fsyncSync4, lstatSync as lstatSync11, mkdirSync as mkdirSync7, openSync as openSync6, readFileSync as readFileSync17, readSync as readSync3, readdirSync as readdirSync8, realpathSync as realpathSync8, renameSync as renameSync6, rmSync as rmSync4, symlinkSync as symlinkSync2, unlinkSync as unlinkSync5, writeFileSync as writeFileSync10 } from "node:fs";
13356
13553
  import { basename as basename8, dirname as dirname11, isAbsolute as isAbsolute4, join as join23, parse as parse3, relative as relative11, resolve as resolve12, sep as sep6 } from "node:path";
13357
13554
  import { fileURLToPath as fileURLToPath5 } from "node:url";
@@ -13408,7 +13605,7 @@ function enumerateSkillPayload(source) {
13408
13605
  if (entry.isDirectory()) walk(path);
13409
13606
  else if (entry.isFile()) {
13410
13607
  const expectedMode = rel.endsWith(".sh") || rel.startsWith("scripts/") ? "0755" : "0644";
13411
- result2.push({ path: rel, sha256: createHash7("sha256").update(readFileSync17(path)).digest("hex"), mode: expectedMode });
13608
+ result2.push({ path: rel, sha256: createHash8("sha256").update(readFileSync17(path)).digest("hex"), mode: expectedMode });
13412
13609
  } else throw new NotebookError("CONFLICT", "Project Notebook skill export contains a non-regular entry");
13413
13610
  }
13414
13611
  };
@@ -13430,7 +13627,7 @@ function parsePackedManifest(source) {
13430
13627
  const path = join23(source, ...entry.path.split("/"));
13431
13628
  const stat = lstatSync11(path);
13432
13629
  if (!stat.isFile() || stat.isSymbolicLink()) throw new NotebookError("CONFLICT", "Project Notebook skill export contains a non-regular entry");
13433
- const actual = createHash7("sha256").update(readFileSync17(path)).digest("hex");
13630
+ const actual = createHash8("sha256").update(readFileSync17(path)).digest("hex");
13434
13631
  if (actual !== entry.sha256) throw new NotebookError("CONFLICT", `Project Notebook skill digest mismatch: ${entry.path}`);
13435
13632
  const actualMode = stat.mode & 511;
13436
13633
  const executable = entry.mode === "0755";
@@ -13451,7 +13648,7 @@ function expectedPackedSkill() {
13451
13648
  assertOwnedSkillTree(candidate);
13452
13649
  const manifest = parsePackedManifest(candidate);
13453
13650
  if (!manifest) continue;
13454
- const digest = createHash7("sha256").update(JSON.stringify(manifest)).digest("hex");
13651
+ const digest = createHash8("sha256").update(JSON.stringify(manifest)).digest("hex");
13455
13652
  return { source: candidate, manifest, digest };
13456
13653
  }
13457
13654
  throw new NotebookError("NOT_CONFIGURED", "PJ\xE1ngler package has no verified Project Notebook skill export");
@@ -13519,10 +13716,10 @@ function resolveProjectNotebookSkillSource(env2 = process.env) {
13519
13716
  return explicit;
13520
13717
  }
13521
13718
  if (env2.PJ_SKILLS_REGISTRY_ROOT) {
13522
- const canonical = resolve12(env2.PJ_SKILLS_REGISTRY_ROOT, "all-skills", "project-notebook");
13523
- if (existsSync17(join23(canonical, "SKILL.md"))) {
13524
- verifyProjectNotebookSkillExport(canonical);
13525
- return canonical;
13719
+ const canonical2 = resolve12(env2.PJ_SKILLS_REGISTRY_ROOT, "all-skills", "project-notebook");
13720
+ if (existsSync17(join23(canonical2, "SKILL.md"))) {
13721
+ verifyProjectNotebookSkillExport(canonical2);
13722
+ return canonical2;
13526
13723
  }
13527
13724
  }
13528
13725
  try {
@@ -13590,7 +13787,7 @@ function installPackagedProjectNotebookSkill(input = {}) {
13590
13787
  const source = input.source ?? resolveProjectNotebookSkillSource(env2);
13591
13788
  if (!source) throw new NotebookError("NOT_CONFIGURED", "Project Notebook skill source is unavailable");
13592
13789
  const manifest = verifyProjectNotebookSkillExport(source);
13593
- const digest = createHash7("sha256").update(JSON.stringify(manifest)).digest("hex");
13790
+ const digest = createHash8("sha256").update(JSON.stringify(manifest)).digest("hex");
13594
13791
  const home = env2.HOME;
13595
13792
  if (!home || !resolve12(home).startsWith("/")) throw new NotebookError("NOT_CONFIGURED", "A trusted HOME is required to install the Project Notebook skill");
13596
13793
  const skillsRoot = join23(home, ".agents", "skills");
@@ -13745,7 +13942,7 @@ function repairProjectNotebookSkillProjection(input = {}) {
13745
13942
  if (!existsSync17(path)) return true;
13746
13943
  const stat = lstatSync11(path);
13747
13944
  if (!stat.isFile()) return true;
13748
- if (createHash7("sha256").update(readFileSync17(path)).digest("hex") !== entry.sha256) return true;
13945
+ if (createHash8("sha256").update(readFileSync17(path)).digest("hex") !== entry.sha256) return true;
13749
13946
  const mode = stat.mode & 511;
13750
13947
  const executable = entry.mode === "0755";
13751
13948
  return executable && (mode & 64) === 0 || !executable && (mode & 73) !== 0;
@@ -14996,8 +15193,8 @@ var ProjectRecipe = class extends Recipe {
14996
15193
  } else try {
14997
15194
  rmSync5(targetDir, { recursive: true, force: true });
14998
15195
  const insideTarget = (path) => {
14999
- const relative13 = relativePath(resolve14(targetDir), resolve14(path));
15000
- return relative13 === "" || !relative13.startsWith("..") && !isAbsolute6(relative13);
15196
+ const relative14 = relativePath(resolve14(targetDir), resolve14(path));
15197
+ return relative14 === "" || !relative14.startsWith("..") && !isAbsolute6(relative14);
15001
15198
  };
15002
15199
  const orphaned = [...new Set(changedFiles.filter((path) => !insideTarget(path)))].sort();
15003
15200
  changedFiles.length = 0;
@@ -15438,15 +15635,15 @@ var WireMiseAgentHooks = class _WireMiseAgentHooks extends Command {
15438
15635
  const enterRe = /(enter\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
15439
15636
  if (enterRe.test(content)) {
15440
15637
  content = content.replace(enterRe, (_m, head, close) => {
15441
- const sep7 = /[,[]\s*$/.test(head) ? "" : ",";
15442
- return `${head}${sep7}
15638
+ const sep8 = /[,[]\s*$/.test(head) ? "" : ",";
15639
+ return `${head}${sep8}
15443
15640
  ${enterAdds}${close}`;
15444
15641
  });
15445
15642
  const leaveRe = /(leave\s*=\s*\[[\s\S]*?)(\n[ \t]*\])/;
15446
15643
  if (leaveRe.test(content)) {
15447
15644
  content = content.replace(leaveRe, (_m, head, close) => {
15448
- const sep7 = /[,[]\s*$/.test(head) ? "" : ",";
15449
- return `${head}${sep7}
15645
+ const sep8 = /[,[]\s*$/.test(head) ? "" : ",";
15646
+ return `${head}${sep8}
15450
15647
  "${cr}/.agents/hooks/sync.py --uninstall --quiet",${close}`;
15451
15648
  });
15452
15649
  } else {
@@ -16570,7 +16767,7 @@ init_preflight();
16570
16767
  import { z } from "zod";
16571
16768
 
16572
16769
  // src/fleet/inventory.ts
16573
- import { createHash as createHash8 } from "node:crypto";
16770
+ import { createHash as createHash9 } from "node:crypto";
16574
16771
  import { existsSync as existsSync24, lstatSync as lstatSync13, readFileSync as readFileSync23, readlinkSync as readlinkSync3, statSync as statSync6 } from "node:fs";
16575
16772
  import { homedir as homedir12 } from "node:os";
16576
16773
  import { isAbsolute as isAbsolute7, join as join32, posix, relative as relative12, resolve as resolve18 } from "node:path";
@@ -16583,7 +16780,7 @@ import { fileURLToPath as fileURLToPath8 } from "node:url";
16583
16780
  import YAML8 from "yaml";
16584
16781
 
16585
16782
  // src/fleet/types.ts
16586
- var FLEET_SUPPORTED_SCHEMA_VERSIONS = { min: 1, max: 2 };
16783
+ var FLEET_SUPPORTED_SCHEMA_VERSIONS = { min: 1, max: 3 };
16587
16784
  var FLEET_SCHEMA_VERSION = 1;
16588
16785
  var FLEET_CONTRACT_ROOT_KEYS = [
16589
16786
  "schema_version",
@@ -16595,20 +16792,34 @@ var FLEET_CONTRACT_ROOT_KEYS = [
16595
16792
  "service_model",
16596
16793
  "activation",
16597
16794
  "retired",
16598
- "health_policy"
16795
+ "health_policy",
16796
+ "scaffold_manifest"
16599
16797
  ];
16600
- var FLEET_CONTRACT_OPTIONAL_ROOT_KEYS = ["health_policy"];
16798
+ var FLEET_CONTRACT_OPTIONAL_ROOT_KEYS = ["health_policy", "scaffold_manifest"];
16601
16799
  var FLEET_HEALTH_POLICY_KEYS = [
16602
16800
  "required_domains",
16603
16801
  "deferred_capabilities",
16604
16802
  "allowed_warnings",
16605
16803
  "allowed_skips",
16606
- "freshness"
16804
+ "freshness",
16805
+ "agent_exceptions"
16607
16806
  ];
16608
16807
  var FLEET_HEALTH_POLICY_DEFERRED_KEYS = ["domain", "capability", "reason", "owner_story"];
16609
16808
  var FLEET_HEALTH_POLICY_WARNING_KEYS = ["rule_id", "reason", "owner"];
16610
16809
  var FLEET_HEALTH_POLICY_SKIP_KEYS = ["domain", "rule_id", "reason"];
16611
16810
  var FLEET_HEALTH_POLICY_FRESHNESS_KEYS = ["field", "max_age_days", "applies_to"];
16811
+ var FLEET_HEALTH_POLICY_AGENT_EXCEPTION_KEYS = ["domain", "agent_id", "reason", "owner"];
16812
+ var FLEET_SCAFFOLD_MANIFEST_KEYS = [
16813
+ "template_submodule",
16814
+ "template_subdirectory",
16815
+ "render_suffix",
16816
+ "render_inputs",
16817
+ "groups",
16818
+ "presence_only",
16819
+ "excluded_patterns",
16820
+ "runtime_dir"
16821
+ ];
16822
+ var FLEET_SCAFFOLD_PRESENCE_ONLY_KEYS = ["path", "reason"];
16612
16823
  var FLEET_COMPATIBILITY_KEYS = ["min_schema_version", "max_schema_version"];
16613
16824
  var FLEET_AUTHORITY_KEYS = ["owner", "store", "store_env", "writable_fields", "read_only", "notes"];
16614
16825
  var FLEET_PROJECTION_KEYS = ["field", "source", "target", "direction", "writable_by"];
@@ -16703,6 +16914,9 @@ var FLEET_STATUS_MAX_OBSERVATIONS_PER_AGENT = 200;
16703
16914
  var FLEET_STATUS_MAX_FINDINGS = 2e3;
16704
16915
  var FLEET_STATUS_MAX_DETAILS = 20;
16705
16916
  var FLEET_STATUS_AUDIT_CONCURRENCY = 4;
16917
+ var FLEET_STATUS_SCAFFOLD_CONCURRENCY = 4;
16918
+ var FLEET_STATUS_MAX_ITEMS = 100;
16919
+ var FLEET_STATUS_MAX_WIP_OVERLAP = 20;
16706
16920
  var FLEET_STATUS_EVIDENCE = ["direct", "declared", "derived", "absent"];
16707
16921
  var FLEET_STATUS_FRESHNESS_PRECEDENCE = [
16708
16922
  "stale",
@@ -16851,7 +17065,8 @@ function validateFleetContract(document) {
16851
17065
  () => validateAuthorityConflicts(policy),
16852
17066
  () => sortDiagnostics(validateClassifications(policy)),
16853
17067
  () => sortDiagnostics(validateRetiredModes(policy)),
16854
- () => sortDiagnostics(validateHealthPolicy(policy))
17068
+ () => sortDiagnostics(validateHealthPolicy(policy)),
17069
+ () => sortDiagnostics(validateScaffoldManifest(policy))
16855
17070
  ];
16856
17071
  for (const stage of stages) {
16857
17072
  const findings = stage();
@@ -17445,6 +17660,141 @@ function validateHealthPolicy(policy) {
17445
17660
  });
17446
17661
  }
17447
17662
  }
17663
+ const exceptions = block.agent_exceptions;
17664
+ if (exceptions !== void 0) {
17665
+ if (!Array.isArray(exceptions)) fail("health_policy.agent_exceptions", "agent_exceptions must be a list");
17666
+ else {
17667
+ const seen = /* @__PURE__ */ new Set();
17668
+ exceptions.forEach((entry, index) => {
17669
+ const at = `health_policy.agent_exceptions[${index}]`;
17670
+ if (!isRecord4(entry)) {
17671
+ fail(at, "agent exception must be a mapping");
17672
+ return;
17673
+ }
17674
+ for (const key2 of Object.keys(entry)) {
17675
+ if (!FLEET_HEALTH_POLICY_AGENT_EXCEPTION_KEYS.includes(key2)) fail(`${at}.${key2}`, "unknown agent_exceptions key");
17676
+ }
17677
+ requireDomain(entry.domain, `${at}.domain`);
17678
+ if (typeof entry.agent_id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(entry.agent_id)) {
17679
+ fail(`${at}.agent_id`, "agent_id must be a registry key: letters, digits, dots, underscores or hyphens");
17680
+ }
17681
+ for (const key2 of ["reason", "owner"]) {
17682
+ const value = entry[key2];
17683
+ if (typeof value !== "string" || value.trim().length === 0) fail(`${at}.${key2}`, `${key2} must be a non-empty string`);
17684
+ }
17685
+ const key = `${String(entry.domain)}\0${String(entry.agent_id)}`;
17686
+ if (seen.has(key)) fail(at, "duplicate agent exception: the same domain and agent_id is already ruled on");
17687
+ seen.add(key);
17688
+ });
17689
+ }
17690
+ }
17691
+ return findings;
17692
+ }
17693
+ var SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
17694
+ var RENDER_INPUT_NAME = /^[a-z_][a-z0-9_]*$/u;
17695
+ function relativeInside(value) {
17696
+ if (typeof value !== "string" || value === "" || value.startsWith("/") || value.includes("\\")) return false;
17697
+ const segments = value.replace(/\/+$/u, "").split("/");
17698
+ return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
17699
+ }
17700
+ function validateScaffoldManifest(policy) {
17701
+ const block = policy.scaffold_manifest;
17702
+ if (block === void 0) return [];
17703
+ const findings = [];
17704
+ const fail = (path, message) => {
17705
+ findings.push({ code: "INVALID_INPUT", path, message });
17706
+ };
17707
+ if (!isRecord4(block)) {
17708
+ fail("scaffold_manifest", "scaffold_manifest must be a mapping");
17709
+ return findings;
17710
+ }
17711
+ for (const key of Object.keys(block)) {
17712
+ if (!FLEET_SCAFFOLD_MANIFEST_KEYS.includes(key)) fail(`scaffold_manifest.${key}`, "unknown scaffold_manifest key");
17713
+ }
17714
+ for (const key of ["template_submodule", "template_subdirectory"]) {
17715
+ if (!relativeInside(block[key]) || block[key].endsWith("/")) fail(`scaffold_manifest.${key}`, `${key} must be a relative path inside the repository`);
17716
+ }
17717
+ const suffix = block.render_suffix;
17718
+ if (typeof suffix !== "string" || !/^\.[A-Za-z0-9]+$/u.test(suffix)) fail("scaffold_manifest.render_suffix", "render_suffix must be a dotted extension such as .jinja");
17719
+ const runtimeDir = block.runtime_dir;
17720
+ if (typeof runtimeDir !== "string" || !SAFE_SEGMENT.test(runtimeDir)) fail("scaffold_manifest.runtime_dir", "runtime_dir must be one safe path segment");
17721
+ const declaredFields = /* @__PURE__ */ new Set();
17722
+ const authorities = policy.authorities;
17723
+ if (isRecord4(authorities)) {
17724
+ for (const entry of Object.values(authorities)) {
17725
+ if (!isRecord4(entry) || !Array.isArray(entry.writable_fields)) continue;
17726
+ for (const field3 of entry.writable_fields) if (typeof field3 === "string") declaredFields.add(field3);
17727
+ }
17728
+ }
17729
+ const inputs = block.render_inputs;
17730
+ if (!isRecord4(inputs)) fail("scaffold_manifest.render_inputs", "render_inputs must be a mapping of placeholder name to field path");
17731
+ else {
17732
+ for (const [name, field3] of Object.entries(inputs)) {
17733
+ const at = `scaffold_manifest.render_inputs.${name}`;
17734
+ if (!RENDER_INPUT_NAME.test(name)) fail(at, "a render input name must be a lower-case identifier");
17735
+ if (typeof field3 !== "string" || !FIELD_PATH.test(field3)) {
17736
+ fail(at, "must be a dotted field path");
17737
+ continue;
17738
+ }
17739
+ const resolvable = field3 === "agents.{agent_id}" || (field3.startsWith("agents.{agent_id}.") || field3.startsWith("projects.{slug}.")) && declaredFields.has(field3);
17740
+ if (!resolvable) fail(at, `${field3} is not agents.{agent_id} or a declared agents.{agent_id}.* / projects.{slug}.* writable field`);
17741
+ }
17742
+ }
17743
+ const groups = block.groups;
17744
+ const groupTable = {};
17745
+ if (!isRecord4(groups) || Object.keys(groups).length === 0) fail("scaffold_manifest.groups", "groups must be a non-empty mapping of declared leaf to role-relative path");
17746
+ else {
17747
+ const seenPaths = /* @__PURE__ */ new Map();
17748
+ for (const [leaf, rolePath] of Object.entries(groups)) {
17749
+ const at = `scaffold_manifest.groups.${leaf}`;
17750
+ if (!declaredFields.has(leaf)) fail(at, `${leaf} is not declared writable by any authority`);
17751
+ if (!relativeInside(rolePath)) {
17752
+ fail(at, "must be a role-relative path; a trailing / owns a directory");
17753
+ continue;
17754
+ }
17755
+ const previous = seenPaths.get(rolePath);
17756
+ if (previous !== void 0) fail(at, `duplicate group path ${rolePath}, already owned by ${previous}`);
17757
+ else seenPaths.set(rolePath, leaf);
17758
+ groupTable[leaf] = rolePath;
17759
+ }
17760
+ }
17761
+ const presence = block.presence_only;
17762
+ if (presence !== void 0) {
17763
+ if (!Array.isArray(presence)) fail("scaffold_manifest.presence_only", "presence_only must be a list");
17764
+ else {
17765
+ const seen = /* @__PURE__ */ new Set();
17766
+ presence.forEach((entry, index) => {
17767
+ const at = `scaffold_manifest.presence_only[${index}]`;
17768
+ if (!isRecord4(entry)) {
17769
+ fail(at, "presence_only entry must be a mapping");
17770
+ return;
17771
+ }
17772
+ for (const key of Object.keys(entry)) {
17773
+ if (!FLEET_SCAFFOLD_PRESENCE_ONLY_KEYS.includes(key)) fail(`${at}.${key}`, "unknown presence_only key");
17774
+ }
17775
+ const path = entry.path;
17776
+ if (!relativeInside(path) || path.endsWith("/")) fail(`${at}.path`, "path must be a role-relative file path");
17777
+ else if (Object.keys(groupTable).length > 0 && groupFor(path, groupTable) === null) fail(`${at}.path`, `${path} resolves to no declared group`);
17778
+ else if (seen.has(path)) fail(`${at}.path`, `duplicate presence_only path ${path}`);
17779
+ else seen.add(path);
17780
+ if (typeof entry.reason !== "string" || entry.reason.trim().length === 0) fail(`${at}.reason`, "reason must be a non-empty string");
17781
+ });
17782
+ }
17783
+ }
17784
+ const excluded = block.excluded_patterns;
17785
+ if (excluded !== void 0) {
17786
+ if (!Array.isArray(excluded)) fail("scaffold_manifest.excluded_patterns", "excluded_patterns must be a list");
17787
+ else {
17788
+ excluded.forEach((pattern, index) => {
17789
+ const at = `scaffold_manifest.excluded_patterns[${index}]`;
17790
+ if (typeof pattern !== "string" || pattern.trim() === "" || pattern === "/" || pattern.startsWith("/")) {
17791
+ fail(at, "an excluded pattern must be a non-empty segment glob");
17792
+ return;
17793
+ }
17794
+ if (/^\*+\/?$/u.test(pattern) || pattern.includes("**")) fail(at, "an excluded pattern may not match every segment");
17795
+ });
17796
+ }
17797
+ }
17448
17798
  return findings;
17449
17799
  }
17450
17800
 
@@ -17459,7 +17809,7 @@ function list(value) {
17459
17809
  function readHealthPolicy(contract) {
17460
17810
  const block = contract.health_policy;
17461
17811
  if (!block) {
17462
- return { declared: false, requiredDomains: /* @__PURE__ */ new Set(), deferred: [], warnings: [], skips: [], freshness: [] };
17812
+ return { declared: false, requiredDomains: /* @__PURE__ */ new Set(), deferred: [], warnings: [], skips: [], freshness: [], agentExceptions: [] };
17463
17813
  }
17464
17814
  return {
17465
17815
  declared: true,
@@ -17467,7 +17817,8 @@ function readHealthPolicy(contract) {
17467
17817
  deferred: list(block.deferred_capabilities).map((entry, index) => ({ path: `health_policy.deferred_capabilities[${index}]`, entry })),
17468
17818
  warnings: list(block.allowed_warnings).map((entry, index) => ({ path: `health_policy.allowed_warnings[${index}]`, entry })),
17469
17819
  skips: list(block.allowed_skips).map((entry, index) => ({ path: `health_policy.allowed_skips[${index}]`, entry })),
17470
- freshness: list(block.freshness).map((entry, index) => ({ path: `health_policy.freshness[${index}]`, entry }))
17820
+ freshness: list(block.freshness).map((entry, index) => ({ path: `health_policy.freshness[${index}]`, entry })),
17821
+ agentExceptions: list(block.agent_exceptions).map((entry, index) => ({ path: `health_policy.agent_exceptions[${index}]`, entry }))
17471
17822
  };
17472
17823
  }
17473
17824
  function evaluateFreshness(field3, isoValue, referenceMs, policy) {
@@ -17489,6 +17840,8 @@ var SOURCE_EVIDENCE = Object.freeze({
17489
17840
  "fleet-inventory": "direct",
17490
17841
  "fleet-provenance": "direct",
17491
17842
  "recipe-audit": "direct",
17843
+ // The scaffold observer reads git objects and role directories itself.
17844
+ "fleet-scaffold": "direct",
17492
17845
  "declared-gap": "absent"
17493
17846
  });
17494
17847
  var UNREAD_STATES = /* @__PURE__ */ new Set(["unobserved", "unsupported", "error"]);
@@ -17505,9 +17858,10 @@ function resolveJustification(input, policy) {
17505
17858
  }
17506
17859
  }
17507
17860
  if (input.exceptionId !== null) {
17861
+ const policyPath = input.exceptionPolicy ?? `classifications.intentionally_unmanaged.entries.${bounded2(input.exceptionId, 128)}`;
17508
17862
  return {
17509
17863
  kind: "exception",
17510
- policy: `classifications.intentionally_unmanaged.entries.${bounded2(input.exceptionId, 128)}`,
17864
+ policy: bounded2(policyPath),
17511
17865
  reason: bounded2(input.exceptionReason ?? "a managed exception the contract records for exactly these participants"),
17512
17866
  owner: null
17513
17867
  };
@@ -17996,18 +18350,27 @@ async function probe(ctx, argv, cwd) {
17996
18350
  const { outcome, value } = await runBoundedChild(ctx, command, args, { cwd });
17997
18351
  return { outcome, value };
17998
18352
  }
18353
+ async function probeRaw(ctx, argv, cwd, input) {
18354
+ const [command, ...args] = argv;
18355
+ if (!command) return { outcome: "failed", value: null };
18356
+ const { outcome, bytes } = await runBoundedChild(ctx, command, args, { cwd, input, raw: true });
18357
+ return { outcome, value: outcome === "ok" ? bytes : null };
18358
+ }
17999
18359
  function captureSelf(ctx, entry, args, cwd, env2, timeoutMs) {
18000
18360
  return runBoundedChild(ctx, process.execPath, [entry, ...args], { cwd, env: env2, timeoutMs, keepStdoutOnFailure: true });
18001
18361
  }
18002
18362
  function runBoundedChild(ctx, command, args, options = {}) {
18003
- const { cwd, env: env2, keepStdoutOnFailure = false, timeoutMs } = options;
18363
+ const { cwd, env: env2, keepStdoutOnFailure = false, timeoutMs, input, raw = false } = options;
18004
18364
  const budget = Math.min(timeoutMs ?? ctx.probeTimeoutMs, remainingMs(ctx));
18005
18365
  return new Promise((settle) => {
18006
18366
  let child;
18007
18367
  try {
18008
18368
  child = spawn3(command, [...args], {
18009
18369
  cwd,
18010
- stdio: ["ignore", "pipe", "ignore"],
18370
+ // stdin is `ignore` unless a caller has bytes to feed it: a child that
18371
+ // could wait on a terminal it never gets is a child the timeout has to
18372
+ // kill, and nothing here should need killing to finish.
18373
+ stdio: [input === void 0 ? "ignore" : "pipe", "pipe", "ignore"],
18011
18374
  // Its own process GROUP, so `kill` below can reach the whole tree.
18012
18375
  // Measured: a shim that is `#!/bin/sh ... sleep 60` puts the sleep under
18013
18376
  // the sh. SIGKILLing the sh alone leaves the sleep holding the write end
@@ -18023,12 +18386,13 @@ function runBoundedChild(ctx, command, args, options = {}) {
18023
18386
  env: env2 ?? probeEnv()
18024
18387
  });
18025
18388
  } catch {
18026
- settle({ outcome: "failed", value: null, code: null, overflow: false });
18389
+ settle({ outcome: "failed", value: null, code: null, overflow: false, bytes: null });
18027
18390
  return;
18028
18391
  }
18029
- let out = "";
18392
+ const chunks = [];
18030
18393
  let size = 0;
18031
18394
  let settled = false;
18395
+ const collected = () => Buffer.concat(chunks);
18032
18396
  const finish = (result2) => {
18033
18397
  if (settled) return;
18034
18398
  settled = true;
@@ -18038,8 +18402,21 @@ function runBoundedChild(ctx, command, args, options = {}) {
18038
18402
  child.stdout?.destroy();
18039
18403
  } catch {
18040
18404
  }
18405
+ try {
18406
+ child.stdin?.destroy();
18407
+ } catch {
18408
+ }
18041
18409
  child.unref();
18042
- settle(result2);
18410
+ const bytes = result2.keep ? collected() : null;
18411
+ settle({
18412
+ outcome: result2.outcome,
18413
+ code: result2.code,
18414
+ overflow: result2.overflow,
18415
+ bytes,
18416
+ // The TEXT value keeps its historical shape for every existing caller:
18417
+ // decoded as utf8 and trimmed. A raw caller reads `bytes` instead.
18418
+ value: bytes === null || raw ? null : bytes.toString("utf8").trim()
18419
+ });
18043
18420
  };
18044
18421
  const kill = () => {
18045
18422
  try {
@@ -18053,31 +18430,40 @@ function runBoundedChild(ctx, command, args, options = {}) {
18053
18430
  };
18054
18431
  const timer = setTimeout(() => {
18055
18432
  kill();
18056
- finish({ outcome: "timeout", value: null, code: null, overflow: false });
18433
+ finish({ outcome: "timeout", code: null, overflow: false, keep: false });
18057
18434
  }, budget);
18058
18435
  timer.unref?.();
18059
18436
  const onAbort = () => {
18060
18437
  kill();
18061
- finish({ outcome: "cancelled", value: null, code: null, overflow: false });
18438
+ finish({ outcome: "cancelled", code: null, overflow: false, keep: false });
18062
18439
  };
18063
18440
  ctx.signal.addEventListener("abort", onAbort, { once: true });
18064
- child.stdout.setEncoding("utf8");
18065
- child.stdout.on("data", (chunk) => {
18066
- size += chunk.length;
18441
+ const stdout = child.stdout;
18442
+ if (!stdout) {
18443
+ finish({ outcome: "failed", code: null, overflow: false, keep: false });
18444
+ return;
18445
+ }
18446
+ stdout.on("data", (chunk) => {
18447
+ size += chunk.byteLength;
18067
18448
  if (size > PROBE_MAX_BYTES) {
18068
18449
  kill();
18069
- finish({ outcome: "failed", value: null, code: null, overflow: true });
18450
+ finish({ outcome: "failed", code: null, overflow: true, keep: false });
18070
18451
  return;
18071
18452
  }
18072
- out += chunk;
18453
+ chunks.push(chunk);
18073
18454
  });
18074
- child.on("error", () => finish({ outcome: "failed", value: null, code: null, overflow: false }));
18455
+ if (child.stdin) {
18456
+ child.stdin.on("error", () => {
18457
+ });
18458
+ child.stdin.end(input);
18459
+ }
18460
+ child.on("error", () => finish({ outcome: "failed", code: null, overflow: false, keep: false }));
18075
18461
  child.on("close", (code) => {
18076
18462
  if (code !== 0) {
18077
- finish({ outcome: "failed", value: keepStdoutOnFailure ? out.trim() : null, code, overflow: false });
18463
+ finish({ outcome: "failed", code, overflow: false, keep: keepStdoutOnFailure });
18078
18464
  return;
18079
18465
  }
18080
- finish({ outcome: "ok", value: out.trim(), code, overflow: false });
18466
+ finish({ outcome: "ok", code, overflow: false, keep: true });
18081
18467
  });
18082
18468
  });
18083
18469
  }
@@ -18702,7 +19088,7 @@ function normalizePathValue(value) {
18702
19088
  }
18703
19089
  function conflictGroupId(fieldPath, value) {
18704
19090
  const normalized = value.normalize("NFC").trim();
18705
- return `conflict:${fieldPath}:${createHash8("sha256").update(normalized, "utf8").digest("hex").slice(0, 12)}`;
19091
+ return `conflict:${fieldPath}:${createHash9("sha256").update(normalized, "utf8").digest("hex").slice(0, 12)}`;
18706
19092
  }
18707
19093
  var DIMENSIONS = {
18708
19094
  agentId: { key: "agent-id", field: "agents.{agent_id}", kind: "agent" },
@@ -19169,7 +19555,7 @@ function collectFleetInventory(options = {}) {
19169
19555
  }
19170
19556
 
19171
19557
  // src/fleet/provenance.ts
19172
- import { createHash as createHash9 } from "node:crypto";
19558
+ import { createHash as createHash10 } from "node:crypto";
19173
19559
  import { existsSync as existsSync25, lstatSync as lstatSync14, readFileSync as readFileSync24, realpathSync as realpathSync9, statSync as statSync7 } from "node:fs";
19174
19560
  import { homedir as homedir13 } from "node:os";
19175
19561
  import { dirname as dirname16, isAbsolute as isAbsolute8, join as join33, resolve as resolve19 } from "node:path";
@@ -19191,7 +19577,6 @@ var SOURCE_TEMPLATE_SUBMODULE = "hermes-agent-template-submodule";
19191
19577
  var SOURCE_AGENT_REGISTRY = "hermes-agent-registry";
19192
19578
  var SOURCE_AGENT_CHECKOUT = "hermes-agent-checkout";
19193
19579
  var SOURCE_PROVENANCE_POLICY = "pjangler-fleet-provenance";
19194
- var SOURCE_ROLE_SCAFFOLD = "hermes-agent-role-scaffold";
19195
19580
  var SOURCE_PROFILE_TREE = "hermes-profile-tree";
19196
19581
  var SHELL_REFERENCE = /\$\{?[A-Za-z_]/u;
19197
19582
  function isRecord7(value) {
@@ -19338,14 +19723,14 @@ async function probeCheckout(ctx, declared, home) {
19338
19723
  reason: toplevel.outcome === "timeout" ? "probe-timeout" : toplevel.outcome === "cancelled" ? "probe-cancelled" : "probe-failed"
19339
19724
  };
19340
19725
  }
19341
- const canonical = (path) => {
19726
+ const canonical2 = (path) => {
19342
19727
  try {
19343
19728
  return realpathSync9(path);
19344
19729
  } catch {
19345
19730
  return resolve19(path);
19346
19731
  }
19347
19732
  };
19348
- if (canonical(toplevel.value) !== canonical(expanded)) {
19733
+ if (canonical2(toplevel.value) !== canonical2(expanded)) {
19349
19734
  return { ...base, classification, outcome: "skipped", reason: "not-a-repository-root" };
19350
19735
  }
19351
19736
  const [remote, head, status] = await Promise.all([
@@ -19615,23 +20000,7 @@ function addAgentRecordFacts(ctx, subject) {
19615
20000
  });
19616
20001
  }
19617
20002
  function addUnsupportedFacts(ctx, subject, profileTemplate) {
19618
- const { agentId, roleDir, profileName } = subject;
19619
- const scaffoldEvidence = roleDir === null ? absent(SOURCE_ROLE_SCAFFOLD, "missing") : (() => {
19620
- const dir = expandHome3(roleDir, ctx.home);
19621
- const view = classifyPath(dir, { directory: true });
19622
- const manifest = join33(dir, "role.yaml");
19623
- const state = view.classification === "ok" && existsSync25(manifest) ? "role.yaml present" : `role directory is ${view.classification}`;
19624
- return present(state, SOURCE_ROLE_SCAFFOLD, { classification: view.classification });
19625
- })();
19626
- addFact(ctx, {
19627
- id: "scaffold.template_ref",
19628
- scope: "agent",
19629
- agent_id: agentId,
19630
- field: "scaffold",
19631
- desired: absent(SOURCE_ROLE_SCAFFOLD, "unsupported"),
19632
- observed: scaffoldEvidence,
19633
- detail: "a deployed role scaffold records no template ref -- it renders no .copier-answers.yml and the repo-root one is CommonProject's, with no _commit -- so no comparison is possible and none is invented"
19634
- });
20003
+ const { agentId, profileName } = subject;
19635
20004
  const namedProfile = profileName !== null && /^[A-Za-z0-9._-]+$/u.test(profileName) && profileName !== "." && profileName !== ".." ? profileName : null;
19636
20005
  const profilePath = namedProfile && profileTemplate ? profileTemplate.replaceAll("{profile_name}", namedProfile) : null;
19637
20006
  const generated = profilePath ? join33(profilePath, "config.yaml") : null;
@@ -19651,7 +20020,7 @@ function profileDigest(path) {
19651
20020
  if (stat.isSymbolicLink()) return present("symlink", SOURCE_PROFILE_TREE, { classification: "symlink" });
19652
20021
  if (!stat.isFile()) return present(`not-a-file`, SOURCE_PROFILE_TREE, { classification: "not-a-directory" });
19653
20022
  if (stat.size > PROFILE_CONFIG_MAX_BYTES) return absent(SOURCE_PROFILE_TREE, "unobserved", { classification: "unreadable" });
19654
- return present(`sha256:${createHash9("sha256").update(readFileSync24(path)).digest("hex")}`, SOURCE_PROFILE_TREE, { classification: "ok" });
20023
+ return present(`sha256:${createHash10("sha256").update(readFileSync24(path)).digest("hex")}`, SOURCE_PROFILE_TREE, { classification: "ok" });
19655
20024
  } catch (error) {
19656
20025
  const code = error.code;
19657
20026
  return absent(SOURCE_PROFILE_TREE, "missing", { classification: code === "ENOENT" || code === "ENOTDIR" ? "absent" : "unreadable" });
@@ -19809,9 +20178,9 @@ async function collectFleetProvenance(options) {
19809
20178
  remainingMs(runContext);
19810
20179
  const observations = await mapBounded(targets, PROBE_CONCURRENCY, async ([, declared]) => probeCheckout(runContext, declared, home));
19811
20180
  const byDeclared = /* @__PURE__ */ new Map();
19812
- targets.forEach(([canonical, declared], index) => {
20181
+ targets.forEach(([canonical2, declared], index) => {
19813
20182
  const observation3 = observations[index];
19814
- byDeclared.set(canonical, observation3);
20183
+ byDeclared.set(canonical2, observation3);
19815
20184
  addProbe(ctx, {
19816
20185
  id: `checkout:${observation3.target}`,
19817
20186
  kind: "checkout",
@@ -19929,10 +20298,545 @@ async function collectFleetProvenance(options) {
19929
20298
  }
19930
20299
 
19931
20300
  // src/fleet/status.ts
19932
- import { createHash as createHash10 } from "node:crypto";
19933
- import { existsSync as existsSync26, readFileSync as readFileSync25 } from "node:fs";
20301
+ import { createHash as createHash11 } from "node:crypto";
20302
+ import { existsSync as existsSync26, readFileSync as readFileSync26 } from "node:fs";
19934
20303
  import { homedir as homedir14 } from "node:os";
19935
- import { isAbsolute as isAbsolute9, join as join34, resolve as resolve20 } from "node:path";
20304
+ import { isAbsolute as isAbsolute10, join as join35, resolve as resolve21 } from "node:path";
20305
+
20306
+ // src/fleet/scaffold.ts
20307
+ import { lstatSync as lstatSync15, readFileSync as readFileSync25, readlinkSync as readlinkSync4, realpathSync as realpathSync10 } from "node:fs";
20308
+ import { dirname as dirname17, isAbsolute as isAbsolute9, join as join34, relative as relative13, resolve as resolve20, sep as sep7 } from "node:path";
20309
+ var SCAFFOLD_MAX_ASSET_BYTES = 4 * 1024 * 1024;
20310
+ var SCAFFOLD_MAX_ROLE_ENTRIES = 5e3;
20311
+ var SCAFFOLD_LINEAGE_DEPTH = 12;
20312
+ var SCAFFOLD_PROBE_KIND = "scaffold";
20313
+ function gitArgv2(path, args) {
20314
+ return ["git", "--no-optional-locks", "-C", path, ...args];
20315
+ }
20316
+ function canonical(path) {
20317
+ try {
20318
+ return realpathSync10(path);
20319
+ } catch {
20320
+ return resolve20(path);
20321
+ }
20322
+ }
20323
+ function within(root, candidate) {
20324
+ return candidate === root || candidate.startsWith(`${root}${sep7}`);
20325
+ }
20326
+ function parseBatch(output, keys) {
20327
+ const out = /* @__PURE__ */ new Map();
20328
+ let offset = 0;
20329
+ for (const key of keys) {
20330
+ const newline = output.indexOf(10, offset);
20331
+ if (newline === -1) {
20332
+ out.set(key, null);
20333
+ continue;
20334
+ }
20335
+ const header = output.subarray(offset, newline).toString("utf8");
20336
+ offset = newline + 1;
20337
+ const parts = header.split(" ");
20338
+ if (parts.length < 3 || parts[1] === "missing" || parts[1] === "ambiguous") {
20339
+ out.set(key, null);
20340
+ continue;
20341
+ }
20342
+ const size = Number(parts[2]);
20343
+ if (!Number.isSafeInteger(size) || size < 0) {
20344
+ out.set(key, null);
20345
+ continue;
20346
+ }
20347
+ out.set(key, output.subarray(offset, offset + size));
20348
+ offset += size + 1;
20349
+ }
20350
+ return out;
20351
+ }
20352
+ async function inspectSource(ctx) {
20353
+ const { manifest } = ctx;
20354
+ const submodule = join34(ctx.pjanglerRoot, manifest.template_submodule);
20355
+ const target = ctx.shown(submodule);
20356
+ const empty = (integrity, detail, gitlink2, outcome = "failed") => ({
20357
+ record: { gitlink: gitlink2, integrity, detail, assets: 0 },
20358
+ submodule,
20359
+ assets: [],
20360
+ renderSources: /* @__PURE__ */ new Map(),
20361
+ probe: { id: `${SCAFFOLD_PROBE_KIND}:${target}`, kind: SCAFFOLD_PROBE_KIND, target, outcome, reason: integrity }
20362
+ });
20363
+ const unobserved = (step, outcome, gitlink2) => empty("source-unobserved", `the ${step} probe ${outcome === "timeout" ? "timed out" : "failed"} before the template source could be read`, gitlink2, outcome);
20364
+ const committed = await probe(ctx.run, gitArgv2(ctx.pjanglerRoot, ["ls-tree", "HEAD", "--", manifest.template_submodule]));
20365
+ throwIfCancelled(ctx.run);
20366
+ if (committed.outcome === "timeout") return unobserved("ls-tree HEAD", "timeout", null);
20367
+ const head = /^160000\s+commit\s+([0-9a-f]{40})\t/u.exec(committed.value ?? "");
20368
+ if (!head) {
20369
+ return empty("gitlink-missing", `HEAD of the package root records no gitlink for ${manifest.template_submodule}; the template has no committed pin to compare against`, null);
20370
+ }
20371
+ const gitlink = head[1];
20372
+ const staged = await probe(ctx.run, gitArgv2(ctx.pjanglerRoot, ["ls-files", "--stage", "--", manifest.template_submodule]));
20373
+ throwIfCancelled(ctx.run);
20374
+ if (staged.outcome === "timeout") return unobserved("ls-files --stage", "timeout", gitlink);
20375
+ const index = /^160000\s+([0-9a-f]{40})\s/u.exec(staged.value ?? "");
20376
+ if (!index || index[1] !== gitlink) {
20377
+ return empty("gitlink-unstable", `the index pins ${manifest.template_submodule} at ${index ? index[1].slice(0, 12) : "nothing"} while HEAD pins ${gitlink.slice(0, 12)}; a staged, uncommitted pin is not a version anything can be compared against`, gitlink);
20378
+ }
20379
+ const toplevel = await probe(ctx.run, gitArgv2(submodule, ["rev-parse", "--show-toplevel"]));
20380
+ throwIfCancelled(ctx.run);
20381
+ if (toplevel.outcome === "timeout") return unobserved("rev-parse --show-toplevel", "timeout", gitlink);
20382
+ if (toplevel.outcome !== "ok" || !toplevel.value || canonical(toplevel.value) !== canonical(submodule)) {
20383
+ return empty("source-uninitialized", `${manifest.template_submodule} is not an initialized submodule checkout; run git submodule update to make the pinned template readable`, gitlink);
20384
+ }
20385
+ const object = await probe(ctx.run, gitArgv2(submodule, ["cat-file", "-e", `${gitlink}^{commit}`]));
20386
+ throwIfCancelled(ctx.run);
20387
+ if (object.outcome === "timeout") return unobserved("cat-file -e", "timeout", gitlink);
20388
+ if (object.outcome !== "ok") {
20389
+ return empty("source-missing-object", `the committed gitlink ${gitlink.slice(0, 12)} names a commit the submodule's object database does not hold; fetch the template before comparing against it`, gitlink);
20390
+ }
20391
+ const worktreeHead = await probe(ctx.run, gitArgv2(submodule, ["rev-parse", "HEAD"]));
20392
+ throwIfCancelled(ctx.run);
20393
+ if (worktreeHead.outcome === "timeout") return unobserved("rev-parse HEAD", "timeout", gitlink);
20394
+ if (worktreeHead.outcome !== "ok" || worktreeHead.value !== gitlink) {
20395
+ return empty("source-mismatched", `the template worktree is at ${worktreeHead.value ? worktreeHead.value.slice(0, 12) : "no commit"} while the parent commits ${gitlink.slice(0, 12)}; the checkout is not canonical`, gitlink);
20396
+ }
20397
+ const dirty = await probe(ctx.run, gitArgv2(submodule, ["status", "--porcelain", "--untracked-files=no"]));
20398
+ throwIfCancelled(ctx.run);
20399
+ if (dirty.outcome === "timeout") return unobserved("status --porcelain", "timeout", gitlink);
20400
+ if (dirty.outcome !== "ok") return unobserved("status --porcelain", "failed", gitlink);
20401
+ if ((dirty.value ?? "") !== "") {
20402
+ return empty("source-dirty", `the template worktree carries modified tracked files; desired bytes are read from the committed gitlink ${gitlink.slice(0, 12)} and never from a dirty checkout, and the checkout must be clean before it is canonical`, gitlink);
20403
+ }
20404
+ const tree = await probeRaw(ctx.run, gitArgv2(submodule, ["ls-tree", "-r", "-z", gitlink, "--", manifest.template_subdirectory]));
20405
+ throwIfCancelled(ctx.run);
20406
+ if (tree.outcome === "timeout") return unobserved("ls-tree -r", "timeout", gitlink);
20407
+ if (tree.outcome !== "ok" || tree.value === null) return unobserved("ls-tree -r", "failed", gitlink);
20408
+ const prefix = `${manifest.template_subdirectory.replace(/\/+$/u, "")}/`;
20409
+ const assets = [];
20410
+ const entries = tree.value.toString("utf8").split("\0").filter((entry) => entry !== "");
20411
+ for (const entry of entries) {
20412
+ const match = /^(\d{6}) (\w+) ([0-9a-f]{40})\t(.+)$/su.exec(entry);
20413
+ if (!match) continue;
20414
+ const [, mode, type, sha, templatePath] = match;
20415
+ if (!templatePath.startsWith(prefix)) continue;
20416
+ const raw = templatePath.slice(prefix.length);
20417
+ if (type !== "blob") {
20418
+ return empty("source-contaminated", `the tree at ${gitlink.slice(0, 12)} carries a ${type} at ${raw}; a template renders blobs only`, gitlink);
20419
+ }
20420
+ const excluded = matchesExcluded(raw, manifest.excluded_patterns);
20421
+ if (excluded !== null) {
20422
+ return empty("source-contaminated", `the tree at ${gitlink.slice(0, 12)} carries ${raw}, which matches the excluded pattern ${excluded}; a contaminated template cannot be the desired state`, gitlink);
20423
+ }
20424
+ const rendered = raw.endsWith(manifest.render_suffix);
20425
+ const rolePath = rendered ? raw.slice(0, -manifest.render_suffix.length) : raw;
20426
+ if (rolePath === "" || rolePath.endsWith("/")) {
20427
+ return empty("source-contaminated", `the tree at ${gitlink.slice(0, 12)} renders an unnamed asset from ${raw}`, gitlink);
20428
+ }
20429
+ const group = groupFor(rolePath, manifest.groups);
20430
+ if (group === null) {
20431
+ return empty(`manifest-uncovered:${rolePath}`, `the tree at ${gitlink.slice(0, 12)} renders ${rolePath}, which no scaffold_manifest.groups entry owns; declare a group for it before it can be compared`, gitlink);
20432
+ }
20433
+ assets.push({
20434
+ templatePath,
20435
+ rolePath,
20436
+ sha,
20437
+ mode,
20438
+ type: mode === "120000" ? "symlink" : "file",
20439
+ executable: mode === "100755",
20440
+ rendered,
20441
+ group,
20442
+ presenceOnly: manifest.presence_only.some((item) => item.path === rolePath)
20443
+ });
20444
+ }
20445
+ if (assets.length === 0) {
20446
+ return empty("source-empty", `the tree at ${gitlink.slice(0, 12)} renders nothing under ${manifest.template_subdirectory}; an empty template is not a desired state`, gitlink);
20447
+ }
20448
+ assets.sort((a, b) => a.rolePath < b.rolePath ? -1 : a.rolePath > b.rolePath ? 1 : 0);
20449
+ const renderSources = /* @__PURE__ */ new Map();
20450
+ const wanted = assets.filter((asset) => asset.rendered && !asset.presenceOnly && asset.type === "file");
20451
+ if (wanted.length > 0) {
20452
+ const ids = [...new Set(wanted.map((asset) => asset.sha))];
20453
+ const batch = await probeRaw(ctx.run, gitArgv2(submodule, ["cat-file", "--batch"]), void 0, `${ids.join("\n")}
20454
+ `);
20455
+ throwIfCancelled(ctx.run);
20456
+ if (batch.outcome === "timeout") return unobserved("cat-file --batch", "timeout", gitlink);
20457
+ if (batch.outcome !== "ok" || batch.value === null) return unobserved("cat-file --batch", "failed", gitlink);
20458
+ const blobs = parseBatch(batch.value, ids);
20459
+ for (const asset of wanted) {
20460
+ const bytes = blobs.get(asset.sha) ?? null;
20461
+ if (bytes === null) {
20462
+ return empty("source-missing-object", `blob ${asset.sha.slice(0, 12)} for ${asset.rolePath} is absent from the submodule's object database`, gitlink);
20463
+ }
20464
+ const text2 = bytes.toString("utf8");
20465
+ renderSources.set(asset.templatePath, Buffer.from(text2, "utf8").equals(bytes) ? { text: text2 } : { unsupported: "render-unsupported: template source is not UTF-8 text" });
20466
+ }
20467
+ }
20468
+ return {
20469
+ record: { gitlink, integrity: "ok", detail: `${assets.length} asset(s) rendered by the template at the committed gitlink ${gitlink.slice(0, 12)}`, assets: assets.length },
20470
+ submodule,
20471
+ assets,
20472
+ renderSources,
20473
+ probe: { id: `${SCAFFOLD_PROBE_KIND}:${target}`, kind: SCAFFOLD_PROBE_KIND, target, outcome: "ok", reason: null }
20474
+ };
20475
+ }
20476
+ function desiredFor(source, input) {
20477
+ return source.assets.map((asset) => {
20478
+ const base = {
20479
+ path: asset.rolePath,
20480
+ type: asset.type,
20481
+ executable: asset.executable,
20482
+ blobId: asset.sha,
20483
+ presenceOnly: asset.presenceOnly,
20484
+ incomplete: null
20485
+ };
20486
+ if (asset.presenceOnly) return { ...base, blobId: null };
20487
+ if (!asset.rendered || asset.type !== "file") return base;
20488
+ const rendered = source.renderSources.get(asset.templatePath);
20489
+ if (!rendered) return { ...base, blobId: null, incomplete: { reason: "render-unsupported", detail: "render-unsupported: source not read" } };
20490
+ if ("unsupported" in rendered) return { ...base, blobId: null, incomplete: { reason: "render-unsupported", detail: rendered.unsupported } };
20491
+ const result2 = renderTemplate(rendered.text, input.inputs);
20492
+ if (!result2.ok) return { ...base, blobId: null, incomplete: { reason: result2.reason, detail: result2.detail } };
20493
+ return { ...base, blobId: blobId(Buffer.from(result2.text, "utf8")) };
20494
+ });
20495
+ }
20496
+ function absent2() {
20497
+ return { present: false, type: null, executable: false, blobId: null, unsafeSymlink: false, unreadable: null, wip: false };
20498
+ }
20499
+ async function scanAgent(ctx, source, input) {
20500
+ const scan = {
20501
+ input,
20502
+ roleDir: null,
20503
+ roleDirSource: input.roleDir !== null ? "registry" : "default",
20504
+ error: null,
20505
+ top: null,
20506
+ observed: /* @__PURE__ */ new Map(),
20507
+ tracked: [],
20508
+ wipPaths: /* @__PURE__ */ new Set(),
20509
+ wipPreserved: 0,
20510
+ ignoredEntries: 0,
20511
+ probe: { id: "", kind: SCAFFOLD_PROBE_KIND, target: "", outcome: "skipped", reason: null }
20512
+ };
20513
+ const fail = (error, outcome = "skipped") => {
20514
+ scan.error = error;
20515
+ scan.probe = { ...scan.probe, outcome, reason: error };
20516
+ return scan;
20517
+ };
20518
+ const setTarget = (path) => {
20519
+ const shown = ctx.shown(path);
20520
+ scan.probe = { ...scan.probe, id: `${SCAFFOLD_PROBE_KIND}:${shown}`, target: shown };
20521
+ };
20522
+ if (input.projectPath === null) {
20523
+ setTarget(input.agentId);
20524
+ return fail("repository-unreadable:no-project-path");
20525
+ }
20526
+ setTarget(input.projectPath);
20527
+ const toplevel = await probe(ctx.run, gitArgv2(input.projectPath, ["rev-parse", "--show-toplevel"]));
20528
+ throwIfCancelled(ctx.run);
20529
+ if (toplevel.outcome === "timeout") return fail("probe-timeout", "timeout");
20530
+ if (toplevel.outcome !== "ok" || !toplevel.value) return fail("repository-unreadable:not-a-repository", "failed");
20531
+ const top = canonical(toplevel.value);
20532
+ if (top !== canonical(input.projectPath)) return fail("repository-unreadable:not-a-repository-root");
20533
+ scan.top = top;
20534
+ let roleDir;
20535
+ if (input.roleDir !== null) {
20536
+ roleDir = isAbsolute9(input.roleDir) ? resolve20(input.roleDir) : resolve20(input.projectPath, input.roleDir);
20537
+ } else if (input.role !== null && input.role !== "" && input.role !== "." && input.role !== ".." && !input.role.includes("/")) {
20538
+ roleDir = join34(input.projectPath, "agents", "hermes", input.role);
20539
+ } else {
20540
+ return fail("role-unresolved");
20541
+ }
20542
+ scan.roleDir = roleDir;
20543
+ setTarget(roleDir);
20544
+ let roleRel;
20545
+ let roleReal;
20546
+ try {
20547
+ const stat = lstatSync15(roleDir);
20548
+ if (stat.isSymbolicLink()) return fail("role-dir-symlink");
20549
+ if (!stat.isDirectory()) return fail("role-dir-not-a-directory");
20550
+ roleReal = canonical(roleDir);
20551
+ if (roleReal === top) return fail("role-dir-is-repository-root");
20552
+ if (!within(top, roleReal)) return fail("role-dir-outside-project");
20553
+ roleRel = relative13(top, roleReal);
20554
+ } catch {
20555
+ const lexical = resolve20(roleDir);
20556
+ const project = resolve20(input.projectPath);
20557
+ if (lexical === project || lexical === top) return fail("role-dir-is-repository-root");
20558
+ if (within(project, lexical)) roleRel = relative13(project, lexical);
20559
+ else if (within(top, lexical)) roleRel = relative13(top, lexical);
20560
+ else return fail("role-dir-outside-project");
20561
+ roleReal = lexical;
20562
+ }
20563
+ const rolePosix = roleRel.split(sep7).join("/");
20564
+ const pathspec = `:(literal)${rolePosix}`;
20565
+ const tracked = await probeRaw(ctx.run, gitArgv2(top, ["ls-files", "-z", "--", pathspec]));
20566
+ throwIfCancelled(ctx.run);
20567
+ if (tracked.outcome === "timeout") return fail("probe-timeout", "timeout");
20568
+ if (tracked.outcome !== "ok" || tracked.value === null) return fail("repository-unreadable:probe-failed", "failed");
20569
+ const status = await probeRaw(ctx.run, gitArgv2(top, ["status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=all", "--", pathspec]));
20570
+ throwIfCancelled(ctx.run);
20571
+ if (status.outcome === "timeout") return fail("probe-timeout", "timeout");
20572
+ if (status.outcome !== "ok" || status.value === null) return fail("repository-unreadable:probe-failed", "failed");
20573
+ const toRole = (path) => path.startsWith(`${rolePosix}/`) ? path.slice(rolePosix.length + 1) : null;
20574
+ const trackedEntries = tracked.value.toString("utf8").split("\0").filter((entry) => entry !== "");
20575
+ const statusEntries = status.value.toString("utf8").split("\0");
20576
+ if (trackedEntries.length + statusEntries.length > SCAFFOLD_MAX_ROLE_ENTRIES) return fail("role-entries-exceeded");
20577
+ for (const entry of trackedEntries) {
20578
+ const rolePath = toRole(entry);
20579
+ if (rolePath !== null) scan.tracked.push(rolePath);
20580
+ }
20581
+ scan.tracked.sort();
20582
+ for (let index = 0; index < statusEntries.length; index += 1) {
20583
+ const entry = statusEntries[index];
20584
+ if (entry.length < 4) continue;
20585
+ const xy = entry.slice(0, 2);
20586
+ const path = entry.slice(3);
20587
+ if (xy[0] === "R" || xy[0] === "C") index += 1;
20588
+ const rolePath = toRole(path.replace(/\/$/u, ""));
20589
+ if (rolePath === null && !path.startsWith(`${rolePosix}/`)) continue;
20590
+ if (xy === "!!") {
20591
+ scan.ignoredEntries += 1;
20592
+ continue;
20593
+ }
20594
+ scan.wipPreserved += 1;
20595
+ if (xy !== "??" && rolePath !== null) scan.wipPaths.add(rolePath);
20596
+ }
20597
+ for (const asset of source.assets) {
20598
+ const full = join34(roleReal, ...asset.rolePath.split("/"));
20599
+ const seen = absent2();
20600
+ seen.wip = scan.wipPaths.has(asset.rolePath);
20601
+ try {
20602
+ const stat = lstatSync15(full);
20603
+ seen.present = true;
20604
+ if (stat.isSymbolicLink()) {
20605
+ seen.type = "symlink";
20606
+ const link = readlinkSync4(full);
20607
+ let resolvedTarget = null;
20608
+ try {
20609
+ resolvedTarget = realpathSync10(resolve20(dirname17(full), link));
20610
+ } catch {
20611
+ resolvedTarget = null;
20612
+ }
20613
+ seen.unsafeSymlink = isAbsolute9(link) || resolvedTarget !== null && !within(top, resolvedTarget);
20614
+ seen.blobId = blobId(Buffer.from(link, "utf8"));
20615
+ } else if (stat.isDirectory()) {
20616
+ seen.type = "directory";
20617
+ } else if (stat.isFile()) {
20618
+ seen.type = "file";
20619
+ seen.executable = (stat.mode & 73) !== 0;
20620
+ if (stat.size > SCAFFOLD_MAX_ASSET_BYTES) seen.unreadable = "too-large";
20621
+ else {
20622
+ try {
20623
+ seen.blobId = blobId(readFileSync25(full));
20624
+ } catch {
20625
+ seen.unreadable = "unreadable";
20626
+ }
20627
+ }
20628
+ } else {
20629
+ seen.type = "other";
20630
+ }
20631
+ } catch {
20632
+ }
20633
+ scan.observed.set(asset.rolePath, seen);
20634
+ }
20635
+ scan.probe = { ...scan.probe, outcome: "ok", reason: null };
20636
+ return scan;
20637
+ }
20638
+ async function resolveLineage(ctx, source, scans, desiredByAgent) {
20639
+ const lineage = { verbatim: /* @__PURE__ */ new Set(), rendered: /* @__PURE__ */ new Map(), unobserved: false };
20640
+ const verbatimIds = /* @__PURE__ */ new Set();
20641
+ const renderedNeeded = /* @__PURE__ */ new Map();
20642
+ const assetByPath = new Map(source.assets.map((asset) => [asset.rolePath, asset]));
20643
+ scans.forEach((scan, index) => {
20644
+ if (scan.error !== null) return;
20645
+ for (const desired of desiredByAgent[index] ?? []) {
20646
+ if (desired.presenceOnly || desired.blobId === null || desired.type !== "file") continue;
20647
+ const seen = scan.observed.get(desired.path);
20648
+ if (!seen || !seen.present || seen.type !== "file" || seen.blobId === null || seen.blobId === desired.blobId) continue;
20649
+ const asset = assetByPath.get(desired.path);
20650
+ if (!asset) continue;
20651
+ if (asset.rendered) {
20652
+ const set = renderedNeeded.get(asset.templatePath) ?? /* @__PURE__ */ new Set();
20653
+ set.add(index);
20654
+ renderedNeeded.set(asset.templatePath, set);
20655
+ } else {
20656
+ verbatimIds.add(seen.blobId);
20657
+ }
20658
+ }
20659
+ });
20660
+ if (verbatimIds.size > 0) {
20661
+ const ids = [...verbatimIds].sort();
20662
+ const check = await probeRaw(ctx.run, gitArgv2(source.submodule, ["cat-file", "--batch-check"]), void 0, `${ids.join("\n")}
20663
+ `);
20664
+ throwIfCancelled(ctx.run);
20665
+ if (check.outcome !== "ok" || check.value === null) lineage.unobserved = true;
20666
+ else {
20667
+ for (const line of check.value.toString("utf8").split("\n")) {
20668
+ const match = /^([0-9a-f]{40}) (\w+)/u.exec(line);
20669
+ if (match && match[2] !== "missing") lineage.verbatim.add(match[1]);
20670
+ }
20671
+ }
20672
+ }
20673
+ if (renderedNeeded.size > 0) {
20674
+ const gitlink = source.record.gitlink;
20675
+ const keys = [];
20676
+ const versions = /* @__PURE__ */ new Map();
20677
+ for (const templatePath of [...renderedNeeded.keys()].sort()) {
20678
+ const log3 = await probe(ctx.run, gitArgv2(source.submodule, ["log", "--format=%H", "-n", String(SCAFFOLD_LINEAGE_DEPTH), gitlink, "--", templatePath]));
20679
+ throwIfCancelled(ctx.run);
20680
+ if (log3.outcome !== "ok") {
20681
+ lineage.unobserved = true;
20682
+ continue;
20683
+ }
20684
+ const commits = (log3.value ?? "").split("\n").map((line) => line.trim()).filter((line) => /^[0-9a-f]{40}$/u.test(line));
20685
+ const perPath = commits.map((commit) => `${commit}:${templatePath}`);
20686
+ versions.set(templatePath, perPath);
20687
+ keys.push(...perPath);
20688
+ }
20689
+ if (keys.length > 0) {
20690
+ const batch = await probeRaw(ctx.run, gitArgv2(source.submodule, ["cat-file", "--batch"]), void 0, `${keys.join("\n")}
20691
+ `);
20692
+ throwIfCancelled(ctx.run);
20693
+ if (batch.outcome !== "ok" || batch.value === null) lineage.unobserved = true;
20694
+ else {
20695
+ const blobs = parseBatch(batch.value, keys);
20696
+ for (const [templatePath, agentIndexes] of renderedNeeded) {
20697
+ const asset = source.assets.find((item) => item.templatePath === templatePath);
20698
+ if (!asset) continue;
20699
+ const sources = (versions.get(templatePath) ?? []).map((key) => blobs.get(key) ?? null).filter((bytes) => bytes !== null).map((bytes) => bytes.toString("utf8"));
20700
+ for (const index of agentIndexes) {
20701
+ const scan = scans[index];
20702
+ const rendered = /* @__PURE__ */ new Set();
20703
+ for (const text2 of sources) {
20704
+ const result2 = renderTemplate(text2, scan.input.inputs);
20705
+ if (result2.ok) rendered.add(blobId(Buffer.from(result2.text, "utf8")));
20706
+ }
20707
+ const perAgent = lineage.rendered.get(index) ?? /* @__PURE__ */ new Map();
20708
+ perAgent.set(asset.rolePath, rendered);
20709
+ lineage.rendered.set(index, perAgent);
20710
+ }
20711
+ }
20712
+ }
20713
+ }
20714
+ }
20715
+ return lineage;
20716
+ }
20717
+ function erroredGroups(manifest, assets, detail) {
20718
+ return Object.keys(manifest.groups).sort().map((group) => {
20719
+ const rolePath = manifest.groups[group];
20720
+ const owned = assets.filter((asset) => asset.group === group).length;
20721
+ return {
20722
+ group,
20723
+ role_path: rolePath,
20724
+ state: "error",
20725
+ owned,
20726
+ matching: 0,
20727
+ drifted: 0,
20728
+ incomplete: owned,
20729
+ unexpected: 0,
20730
+ items: [{ path: rolePath, kind: "incomplete", desired: null, observed: null, detail, wip: false }]
20731
+ };
20732
+ });
20733
+ }
20734
+ function emptyAgentResult(input, groups, error, roleDir, roleDirSource) {
20735
+ const owned = groups.reduce((total, group) => total + group.owned, 0);
20736
+ return {
20737
+ agentId: input.agentId,
20738
+ roleDir,
20739
+ roleDirSource,
20740
+ error,
20741
+ groups,
20742
+ assets: { owned, compared: 0, matching: 0, drifted: 0, incomplete: owned, unexpected_owned: 0 },
20743
+ wipOverlap: [],
20744
+ wipPreserved: 0,
20745
+ foreignTracked: 0,
20746
+ ignoredEntries: 0
20747
+ };
20748
+ }
20749
+ async function collectScaffoldParity(ctx) {
20750
+ throwIfCancelled(ctx.run);
20751
+ const source = await inspectSource(ctx);
20752
+ const probes = [source.probe];
20753
+ const manifest = ctx.manifest;
20754
+ if (source.record.integrity !== "ok") {
20755
+ const detail = `source:${source.record.integrity}`;
20756
+ const agents2 = ctx.agents.map((input) => {
20757
+ const roleDir = input.roleDir !== null ? isAbsolute9(input.roleDir) ? resolve20(input.roleDir) : input.projectPath === null ? null : resolve20(input.projectPath, input.roleDir) : input.projectPath !== null && input.role ? join34(input.projectPath, "agents", "hermes", input.role) : null;
20758
+ const target = ctx.shown(roleDir ?? input.projectPath ?? input.agentId);
20759
+ probes.push({ id: `${SCAFFOLD_PROBE_KIND}:${target}`, kind: SCAFFOLD_PROBE_KIND, target, outcome: "skipped", reason: "source-unreadable" });
20760
+ return emptyAgentResult(input, erroredGroups(manifest, [], detail), detail, roleDir, input.roleDir !== null ? "registry" : "default");
20761
+ });
20762
+ return { source: source.record, agents: agents2, probes };
20763
+ }
20764
+ const scans = await mapBounded(ctx.agents, FLEET_STATUS_SCAFFOLD_CONCURRENCY, (input) => scanAgent(ctx, source, input));
20765
+ const desiredByAgent = ctx.agents.map((input) => desiredFor(source, input));
20766
+ const lineage = await resolveLineage(ctx, source, scans, desiredByAgent);
20767
+ const ownedPaths = new Set(source.assets.map((asset) => asset.rolePath));
20768
+ const agents = scans.map((scan, index) => {
20769
+ probes.push(scan.probe);
20770
+ if (scan.error !== null) {
20771
+ return emptyAgentResult(scan.input, erroredGroups(manifest, source.assets, scan.error), scan.error, scan.roleDir, scan.roleDirSource);
20772
+ }
20773
+ const desired = desiredByAgent[index] ?? [];
20774
+ const renderedLineage = lineage.rendered.get(index) ?? /* @__PURE__ */ new Map();
20775
+ let findings = compareAssets(desired, (asset) => scan.observed.get(asset.path) ?? absent2(), {
20776
+ inLineage: (id, path) => lineage.verbatim.has(id) || (renderedLineage.get(path)?.has(id) ?? false)
20777
+ });
20778
+ if (lineage.unobserved) {
20779
+ findings = findings.map((finding2) => finding2.kind === "stale-content" || finding2.kind === "locally-modified" ? { ...finding2, kind: "incomplete", detail: "lineage-unobserved" } : finding2);
20780
+ }
20781
+ const extras = classifyExtras({
20782
+ trackedPaths: scan.tracked,
20783
+ ownedPaths,
20784
+ groups: manifest.groups,
20785
+ runtimeDir: manifest.runtime_dir,
20786
+ excludedPatterns: manifest.excluded_patterns
20787
+ });
20788
+ const groups = Object.keys(manifest.groups).sort().map((group) => {
20789
+ const rolePath = manifest.groups[group];
20790
+ const ownedHere = source.assets.filter((asset) => asset.group === group);
20791
+ const ownedSet = new Set(ownedHere.map((asset) => asset.rolePath));
20792
+ const items = findings.filter((finding2) => ownedSet.has(finding2.path));
20793
+ for (const extra of extras.unexpected) {
20794
+ if (extra.group !== group) continue;
20795
+ items.push({ path: extra.path, kind: "unexpected-owned", desired: null, observed: "tracked", detail: extra.detail, wip: scan.wipPaths.has(extra.path) });
20796
+ }
20797
+ items.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0);
20798
+ const incompletePaths = new Set(items.filter((item) => item.kind === "incomplete").map((item) => item.path));
20799
+ const driftedPaths = new Set(items.filter((item) => item.kind !== "incomplete" && item.kind !== "unexpected-owned" && !incompletePaths.has(item.path)).map((item) => item.path));
20800
+ const unexpected = items.filter((item) => item.kind === "unexpected-owned").length;
20801
+ const owned = ownedHere.length;
20802
+ return {
20803
+ group,
20804
+ role_path: rolePath,
20805
+ state: incompletePaths.size > 0 ? "error" : driftedPaths.size > 0 || unexpected > 0 ? "fail" : "pass",
20806
+ owned,
20807
+ matching: owned - incompletePaths.size - driftedPaths.size,
20808
+ drifted: driftedPaths.size,
20809
+ incomplete: incompletePaths.size,
20810
+ unexpected,
20811
+ items
20812
+ };
20813
+ });
20814
+ const totals = groups.reduce((sum, group) => ({
20815
+ owned: sum.owned + group.owned,
20816
+ compared: sum.compared + group.owned - group.incomplete,
20817
+ matching: sum.matching + group.matching,
20818
+ drifted: sum.drifted + group.drifted,
20819
+ incomplete: sum.incomplete + group.incomplete,
20820
+ unexpected_owned: sum.unexpected_owned + group.unexpected
20821
+ }), { owned: 0, compared: 0, matching: 0, drifted: 0, incomplete: 0, unexpected_owned: 0 });
20822
+ const wipOverlap = [...new Set(groups.flatMap((group) => group.items).filter((item) => item.wip).map((item) => item.path))].sort();
20823
+ return {
20824
+ agentId: scan.input.agentId,
20825
+ roleDir: scan.roleDir,
20826
+ roleDirSource: scan.roleDirSource,
20827
+ error: null,
20828
+ groups,
20829
+ assets: totals,
20830
+ wipOverlap,
20831
+ wipPreserved: scan.wipPreserved,
20832
+ foreignTracked: extras.foreignTracked,
20833
+ ignoredEntries: scan.ignoredEntries
20834
+ };
20835
+ });
20836
+ return { source: source.record, agents, probes };
20837
+ }
20838
+
20839
+ // src/fleet/status.ts
19936
20840
  init_project();
19937
20841
  var RULE_DOMAIN = Object.freeze({
19938
20842
  // Tracked assets the CommonProject / Hermes template owns in a repository.
@@ -20025,6 +20929,7 @@ var DOMAIN_AUTHORITY_BLOCK = Object.freeze({
20025
20929
  var CAPABILITY_SYSTEMD = "unit_topology";
20026
20930
  var CAPABILITY_LIVE_PROCESS = "process_attribution";
20027
20931
  var CAPABILITY_BLOODBANK_LIVENESS = "routing_liveness";
20932
+ var CAPABILITY_SCAFFOLD_MANIFEST = "scaffold.manifest";
20028
20933
  var FINDING_SEVERITY = Object.freeze({
20029
20934
  error: "critical",
20030
20935
  warn: "medium",
@@ -20049,6 +20954,16 @@ var SOURCE_REGISTRY = "fleet-inventory";
20049
20954
  var SOURCE_PROVENANCE = "fleet-provenance";
20050
20955
  var SOURCE_AUDIT = "recipe-audit";
20051
20956
  var SOURCE_DECLARED_GAP = "declared-gap";
20957
+ var SOURCE_SCAFFOLD = "fleet-scaffold";
20958
+ var SCAFFOLD_SOURCE_RULE_ID = "scaffold.source";
20959
+ var RULE_COVERED_CONTENT_GROUPS = /* @__PURE__ */ new Set([
20960
+ "scaffold.hermes",
20961
+ "scaffold.gitignore",
20962
+ "scaffold.scripts",
20963
+ "scaffold.sentinel.prompt.md"
20964
+ ]);
20965
+ var RULE_COVERED_PRESENCE_PATHS = /* @__PURE__ */ new Set(["role.yaml", "SOUL.md", ".runtime-scaffold/README.md"]);
20966
+ var RULE_COVERED_KINDS = /* @__PURE__ */ new Set(["missing", "stale-content", "locally-modified"]);
20052
20967
  var FLEET_STATUS_AUDIT_TIMEOUT_MS = 2e4;
20053
20968
  var CLI_ENTRY_ENV = "PJ_FLEET_CLI_ENTRY";
20054
20969
  var AUDIT_CHILD_ENV_KEYS = [
@@ -20087,7 +21002,7 @@ function nonEmptyString3(value) {
20087
21002
  }
20088
21003
  function expandHome4(path, home) {
20089
21004
  if (path === "~") return home;
20090
- if (path.startsWith("~/")) return join34(home, path.slice(2));
21005
+ if (path.startsWith("~/")) return join35(home, path.slice(2));
20091
21006
  return path;
20092
21007
  }
20093
21008
  function shownPath3(path) {
@@ -20095,7 +21010,7 @@ function shownPath3(path) {
20095
21010
  }
20096
21011
  function statusFindingId(scope, agentId, domain, ruleId, field3, source) {
20097
21012
  const key = [scope, agentId ?? "", domain, ruleId ?? "", field3, source].join("\0");
20098
- return createHash10("sha256").update(key, "utf8").digest("hex").slice(0, 12);
21013
+ return createHash11("sha256").update(key, "utf8").digest("hex").slice(0, 12);
20099
21014
  }
20100
21015
  function retrievalFor(agentId, domain, live) {
20101
21016
  const parts = ["pjangler fleet status"];
@@ -20264,12 +21179,14 @@ function observation2(ctx, input) {
20264
21179
  fixable: input.fixable ?? null,
20265
21180
  exceptionId: input.exceptionId ?? null,
20266
21181
  exceptionReason: input.exceptionReason ?? null,
21182
+ exceptionPolicy: input.exceptionPolicy ?? null,
20267
21183
  freshness,
20268
21184
  repo: input.agentId === null ? null : ctx.repoFor(input.agentId),
20269
21185
  retrieval,
20270
21186
  activationField: ctx.activationField,
20271
21187
  activationOwner: ctx.activationOwner
20272
21188
  }, ctx.policy);
21189
+ const items = input.items && input.items.length > 0 ? { items: [...input.items] } : {};
20273
21190
  return {
20274
21191
  domain: input.domain,
20275
21192
  agent_id: input.agentId,
@@ -20298,7 +21215,8 @@ function observation2(ctx, input) {
20298
21215
  desired: bounded2(redactHome(input.desired ?? DOMAIN_DESIRED[input.domain])),
20299
21216
  next_action: classification.next_action,
20300
21217
  next_action_class: classification.next_action_class,
20301
- justification: classification.justification
21218
+ justification: classification.justification,
21219
+ ...items
20302
21220
  };
20303
21221
  }
20304
21222
  function observeFromInventory(ctx, row, domains) {
@@ -20538,21 +21456,134 @@ function observeFromProvenance(ctx, facts, agentId, domains) {
20538
21456
  if (!domains.has(domain)) continue;
20539
21457
  out.push(factObservation(ctx, fact, domain, agentId));
20540
21458
  }
20541
- for (const domain of ["template_scaffold", "release_provenance"]) {
21459
+ for (const domain of ["release_provenance"]) {
20542
21460
  if (!domains.has(domain) || (byDomain.get(domain) ?? 0) > 0) continue;
20543
21461
  out.push(observation2(ctx, {
20544
21462
  domain,
20545
21463
  agentId,
20546
21464
  state: "unobserved",
20547
21465
  field: DOMAIN_FIELD[domain],
20548
- summary: `the provenance core reported no ${domain === "template_scaffold" ? "scaffold" : "release"} fact for this agent`,
21466
+ summary: "the provenance core reported no release fact for this agent",
20549
21467
  source: SOURCE_PROVENANCE,
20550
21468
  observed: "no fact",
20551
- desired: `at least one ${domain === "template_scaffold" ? "scaffold" : "release"} provenance fact for this agent`
21469
+ desired: "at least one release provenance fact for this agent"
20552
21470
  }));
20553
21471
  }
20554
21472
  return out;
20555
21473
  }
21474
+ function observeFromScaffold(ctx, input) {
21475
+ const { agentId, result: result2 } = input;
21476
+ if (!input.manifestDeclared) {
21477
+ return {
21478
+ observations: [observation2(ctx, {
21479
+ domain: "template_scaffold",
21480
+ agentId,
21481
+ state: "unsupported",
21482
+ field: DOMAIN_FIELD.template_scaffold,
21483
+ summary: "the fleet contract declares no scaffold_manifest, so no role directory can be compared against the pinned template",
21484
+ details: ["declare scaffold_manifest in the fleet contract (schema 3) to make this domain observable"],
21485
+ source: SOURCE_SCAFFOLD,
21486
+ capability: CAPABILITY_SCAFFOLD_MANIFEST,
21487
+ observed: "not observed",
21488
+ desired: "every owned asset compared against the template at the committed gitlink"
21489
+ })],
21490
+ summary: null
21491
+ };
21492
+ }
21493
+ if (result2 === void 0) {
21494
+ return {
21495
+ observations: [observation2(ctx, {
21496
+ domain: "template_scaffold",
21497
+ agentId,
21498
+ state: "unobserved",
21499
+ field: DOMAIN_FIELD.template_scaffold,
21500
+ summary: "the scaffold observer produced no result for this agent",
21501
+ source: SOURCE_SCAFFOLD,
21502
+ observed: "no result",
21503
+ desired: "eight group observations for this agent"
21504
+ })],
21505
+ summary: null
21506
+ };
21507
+ }
21508
+ const short = input.gitlink === null ? null : input.gitlink.slice(0, 12);
21509
+ const desiredSuffix = short === null ? "at the committed gitlink" : `at gitlink ${short}`;
21510
+ const observations = [];
21511
+ for (const group of result2.groups) {
21512
+ const items = group.items.map((item) => ({
21513
+ path: bounded2(item.path),
21514
+ kind: item.kind,
21515
+ desired: item.desired === null ? null : bounded2(item.desired, 64),
21516
+ observed: item.observed === null ? null : bounded2(item.observed, 64),
21517
+ detail: item.detail === null ? null : bounded2(item.detail),
21518
+ wip: item.wip
21519
+ }));
21520
+ const kept = items.slice(0, FLEET_STATUS_MAX_ITEMS);
21521
+ if (items.length > kept.length) {
21522
+ input.notes.push(
21523
+ `agents.${agentId}.observations[${group.group}].items: ${items.length - kept.length} of ${items.length} items dropped; the counts on this observation and on agents[].scaffold.assets are computed over every item before the cap`
21524
+ );
21525
+ }
21526
+ const details = kept.map((item) => {
21527
+ const pair = item.desired !== null || item.observed !== null ? ` ${item.desired ?? "-"} -> ${item.observed ?? "-"}` : "";
21528
+ return `${item.kind} ${item.path}${pair}${item.detail ? ` (${item.detail})` : ""}${item.wip ? " [wip]" : ""}`;
21529
+ });
21530
+ const summary = group.state === "pass" ? `${group.owned} owned asset(s) in ${group.role_path} match the template ${desiredSuffix}` : group.state === "fail" ? `${group.drifted} drifted, ${group.unexpected} unexpected and ${group.incomplete} undecided asset(s) in ${group.role_path}` : `${group.incomplete} asset(s) in ${group.role_path} could not be compared`;
21531
+ const excepted = group.state === "fail" && input.exception !== null;
21532
+ observations.push(observation2(ctx, {
21533
+ domain: "template_scaffold",
21534
+ agentId,
21535
+ state: group.state,
21536
+ field: group.group,
21537
+ ruleId: null,
21538
+ ruleScope: "project",
21539
+ fixable: false,
21540
+ source: SOURCE_SCAFFOLD,
21541
+ summary,
21542
+ details,
21543
+ observed: `${group.matching}/${group.owned} assets match`,
21544
+ desired: `every asset in ${group.group} ${desiredSuffix}`,
21545
+ items: kept,
21546
+ exceptionId: excepted ? agentId : null,
21547
+ exceptionReason: excepted ? input.exception.reason : null,
21548
+ exceptionPolicy: excepted ? input.exception.path : null
21549
+ }));
21550
+ }
21551
+ const overlap = result2.wipOverlap.slice(0, FLEET_STATUS_MAX_WIP_OVERLAP).map((path) => bounded2(path));
21552
+ if (result2.wipOverlap.length > overlap.length) {
21553
+ input.notes.push(`agents.${agentId}.scaffold.wip_overlap: ${result2.wipOverlap.length - overlap.length} of ${result2.wipOverlap.length} paths dropped`);
21554
+ }
21555
+ return {
21556
+ observations,
21557
+ summary: {
21558
+ source_gitlink: input.gitlink,
21559
+ role_dir: result2.roleDir === null ? null : shownPath3(result2.roleDir),
21560
+ role_dir_source: result2.roleDirSource,
21561
+ assets: { ...result2.assets },
21562
+ wip_overlap: overlap,
21563
+ wip_preserved: result2.wipPreserved,
21564
+ foreign_tracked: result2.foreignTracked,
21565
+ ignored_entries: result2.ignoredEntries
21566
+ }
21567
+ };
21568
+ }
21569
+ function scaffoldRuleVerdict(rule) {
21570
+ if (rule === void 0) return null;
21571
+ if (rule.status === "pass") return "pass";
21572
+ if (rule.status !== "fail") return null;
21573
+ const details = Array.isArray(rule.details) ? rule.details.filter((line) => typeof line === "string") : [];
21574
+ return details.some((line) => /: (?:missing|stale) (?!.*\/runtime\/)/u.test(line)) ? "fail" : null;
21575
+ }
21576
+ function scaffoldObserverDrift(result2) {
21577
+ if (result2 === void 0 || result2.error !== null) return null;
21578
+ if (result2.groups.some((group) => group.state === "error" && (RULE_COVERED_CONTENT_GROUPS.has(group.group) || group.owned > 0))) return null;
21579
+ for (const group of result2.groups) {
21580
+ for (const item of group.items) {
21581
+ if (item.kind === "missing" && RULE_COVERED_PRESENCE_PATHS.has(item.path)) return true;
21582
+ if (RULE_COVERED_CONTENT_GROUPS.has(group.group) && RULE_COVERED_KINDS.has(item.kind)) return true;
21583
+ }
21584
+ }
21585
+ return false;
21586
+ }
20556
21587
  function factObservation(ctx, fact, domain, agentId) {
20557
21588
  return observation2(ctx, {
20558
21589
  domain,
@@ -20614,8 +21645,8 @@ function agentLifecycle(row, own, domains) {
20614
21645
  }
20615
21646
  function resolveAuditCli(env2 = process.env) {
20616
21647
  const override = nonEmptyString3(env2[CLI_ENTRY_ENV]);
20617
- const entry = override ?? join34(resolvePjanglerRoot(), "dist", "index.js");
20618
- if (!isAbsolute9(entry)) return null;
21648
+ const entry = override ?? join35(resolvePjanglerRoot(), "dist", "index.js");
21649
+ if (!isAbsolute10(entry)) return null;
20619
21650
  return existsSync26(entry) ? entry : null;
20620
21651
  }
20621
21652
  function auditChildEnv(base = process.env) {
@@ -20686,10 +21717,10 @@ async function collectFleetStatus(options) {
20686
21717
  let baselineText = null;
20687
21718
  let baselineShown = "";
20688
21719
  if (baselinePath !== null) {
20689
- const resolved = resolve20(expandHome4(baselinePath, home));
21720
+ const resolved = resolve21(expandHome4(baselinePath, home));
20690
21721
  baselineShown = shownPath3(resolved);
20691
21722
  try {
20692
- baselineText = readFileSync25(resolved, "utf8");
21723
+ baselineText = readFileSync26(resolved, "utf8");
20693
21724
  } catch {
20694
21725
  throw new FleetError(
20695
21726
  "INVALID_INPUT",
@@ -20732,15 +21763,22 @@ async function collectFleetStatus(options) {
20732
21763
  const agentRawById = /* @__PURE__ */ new Map();
20733
21764
  for (const entry of agentRaw.entries) if (!agentRawById.has(entry.key)) agentRawById.set(entry.key, entry.value);
20734
21765
  const repoByAgent = /* @__PURE__ */ new Map();
21766
+ const roleDirByAgent = /* @__PURE__ */ new Map();
21767
+ const rawRowByAgent = /* @__PURE__ */ new Map();
20735
21768
  for (const entry of agentRaw.entries) {
20736
- if (!selectedAgents.has(entry.key)) continue;
21769
+ if (!selectedAgents.has(entry.key) || rawRowByAgent.has(entry.key)) continue;
20737
21770
  const raw = isRecord8(entry.value) ? entry.value : {};
20738
21771
  const declared = nonEmptyString3(raw.project_path);
20739
- if (declared !== null) repoByAgent.set(entry.key, resolve20(expandHome4(declared, home)));
21772
+ if (declared !== null) repoByAgent.set(entry.key, resolve21(expandHome4(declared, home)));
21773
+ const declaredRoleDir = nonEmptyString3(raw.role_dir);
21774
+ roleDirByAgent.set(entry.key, declaredRoleDir === null ? null : expandHome4(declaredRoleDir, home));
21775
+ rawRowByAgent.set(entry.key, raw);
20740
21776
  }
20741
21777
  const policy = readHealthPolicy(contract);
21778
+ const manifest = domainSet.has("template_scaffold") ? contract.scaffold_manifest ?? null : null;
20742
21779
  const projectRawBySlug = /* @__PURE__ */ new Map();
20743
- if (policy.freshness.some(({ entry }) => entry.field.startsWith("projects."))) {
21780
+ const manifestNeedsProjects = manifest !== null && Object.values(manifest.render_inputs).some((field3) => field3.startsWith("projects."));
21781
+ if (manifestNeedsProjects || policy.freshness.some(({ entry }) => entry.field.startsWith("projects."))) {
20744
21782
  const projectRaw = readProjectRegistryRaw(stores.projects.inspectedPath);
20745
21783
  for (const entry of projectRaw.entries) {
20746
21784
  if (!projectRawBySlug.has(entry.key)) projectRawBySlug.set(entry.key, entry.value);
@@ -20852,6 +21890,66 @@ async function collectFleetStatus(options) {
20852
21890
  provenanceFacts = provenance.facts;
20853
21891
  ctx.probes.push(...provenance.probes);
20854
21892
  }
21893
+ const scalarAt = (root, segments) => {
21894
+ let node = root;
21895
+ for (const segment of segments) {
21896
+ if (!isRecord8(node)) return null;
21897
+ node = node[segment];
21898
+ }
21899
+ if (typeof node === "string") return nonEmptyString3(node);
21900
+ if (typeof node === "number" || typeof node === "boolean") return String(node);
21901
+ return null;
21902
+ };
21903
+ const renderInputsFor = (agentId) => {
21904
+ const inputs = {};
21905
+ if (manifest === null) return inputs;
21906
+ const rawRow = rawRowByAgent.get(agentId);
21907
+ const slug = rowsById.get(agentId)?.project_id.value ?? null;
21908
+ const project = slug === null ? void 0 : projectRawBySlug.get(slug);
21909
+ for (const [name, field3] of Object.entries(manifest.render_inputs)) {
21910
+ const [root, key, ...rest] = field3.split(".");
21911
+ if (field3 === "agents.{agent_id}") inputs[name] = agentId;
21912
+ else if (root === "agents" && key === "{agent_id}") inputs[name] = scalarAt(rawRow, rest);
21913
+ else if (root === "projects" && key === "{slug}") inputs[name] = scalarAt(project, rest);
21914
+ else inputs[name] = null;
21915
+ }
21916
+ return inputs;
21917
+ };
21918
+ const scaffoldExceptionFor = (agentId) => {
21919
+ const ruling = policy.agentExceptions.find(({ entry }) => entry.domain === "template_scaffold" && entry.agent_id === agentId);
21920
+ return ruling ? { path: ruling.path, reason: ruling.entry.reason } : null;
21921
+ };
21922
+ let scaffoldParity = null;
21923
+ const scaffoldByAgent = /* @__PURE__ */ new Map();
21924
+ if (manifest !== null) {
21925
+ throwIfCancelled(runContext);
21926
+ scaffoldParity = await collectScaffoldParity({
21927
+ run: runContext,
21928
+ pjanglerRoot: resolvePjanglerRoot(),
21929
+ manifest,
21930
+ agents: agentIds.map((agentId) => ({
21931
+ agentId,
21932
+ projectPath: repoByAgent.get(agentId) ?? null,
21933
+ roleDir: roleDirByAgent.get(agentId) ?? null,
21934
+ role: nonEmptyString3(rawRowByAgent.get(agentId)?.role),
21935
+ inputs: renderInputsFor(agentId)
21936
+ })),
21937
+ shown: shownPath3
21938
+ });
21939
+ ctx.probes.push(...scaffoldParity.probes);
21940
+ for (const result2 of scaffoldParity.agents) scaffoldByAgent.set(result2.agentId, result2);
21941
+ }
21942
+ const scaffoldCounts = {
21943
+ total_registered: inventory.totals.registered_agents,
21944
+ selected: agentIds.length,
21945
+ applicable: 0,
21946
+ passing: 0,
21947
+ drifted: 0,
21948
+ incomplete: 0,
21949
+ exception_authorized: 0,
21950
+ unobserved: 0
21951
+ };
21952
+ const ruleAgreement = { compared: 0, agree: 0, disagree: 0, not_compared: 0 };
20855
21953
  const auditFedSelected = domains.filter((domain) => AUDIT_PER_AGENT_DOMAINS.has(domain));
20856
21954
  const wantsAudit = live && auditFedSelected.length > 0;
20857
21955
  const auditByAgent = /* @__PURE__ */ new Map();
@@ -20933,6 +22031,57 @@ async function collectFleetStatus(options) {
20933
22031
  }
20934
22032
  }
20935
22033
  const hostByRule = /* @__PURE__ */ new Map();
22034
+ if (scaffoldParity !== null) {
22035
+ const state = scaffoldParity.source.integrity === "ok" ? "pass" : "error";
22036
+ const field3 = DOMAIN_FIELD.template_scaffold;
22037
+ const hostRetrieval = retrievalFor(null, "template_scaffold", live);
22038
+ const sourceClassification = classifyObservation({
22039
+ domain: "template_scaffold",
22040
+ state,
22041
+ field: field3,
22042
+ ruleId: SCAFFOLD_SOURCE_RULE_ID,
22043
+ ruleScope: "host",
22044
+ source: SOURCE_SCAFFOLD,
22045
+ capability: null,
22046
+ evidence: null,
22047
+ fixable: false,
22048
+ exceptionId: null,
22049
+ exceptionReason: null,
22050
+ freshness: "not_applicable",
22051
+ repo: null,
22052
+ retrieval: hostRetrieval,
22053
+ activationField: ctx.activationField,
22054
+ activationOwner: ctx.activationOwner
22055
+ }, ctx.policy);
22056
+ const sourceSummary = bounded2(redactHome(scaffoldParity.source.detail));
22057
+ hostByRule.set(SCAFFOLD_SOURCE_RULE_ID, {
22058
+ finding: {
22059
+ rule_id: SCAFFOLD_SOURCE_RULE_ID,
22060
+ owner: ctx.domainOwner("template_scaffold"),
22061
+ domain: "template_scaffold",
22062
+ state,
22063
+ summary: sourceSummary,
22064
+ details: boundedDetails([
22065
+ `integrity ${scaffoldParity.source.integrity}`,
22066
+ `gitlink ${scaffoldParity.source.gitlink ?? "none recorded"}`,
22067
+ `${scaffoldParity.source.assets} asset(s) in the manifest`
22068
+ ]),
22069
+ finding_id: statusFindingId("host", null, "template_scaffold", SCAFFOLD_SOURCE_RULE_ID, field3, SOURCE_SCAFFOLD),
22070
+ retrieval: hostRetrieval,
22071
+ applicability: sourceClassification.applicability,
22072
+ evidence: sourceClassification.evidence,
22073
+ freshness: sourceClassification.freshness,
22074
+ severity: sourceClassification.severity,
22075
+ repair: sourceClassification.repair,
22076
+ observed: bounded2(scaffoldParity.source.integrity),
22077
+ desired: "the committed gitlink stable, its object present, the worktree at it with no tracked change, and every rendered path owned by a declared group",
22078
+ next_action: sourceClassification.next_action,
22079
+ next_action_class: sourceClassification.next_action_class,
22080
+ justification: sourceClassification.justification
22081
+ },
22082
+ states: /* @__PURE__ */ new Map([[state, 1]])
22083
+ });
22084
+ }
20936
22085
  const agentRecords = [];
20937
22086
  let totalObservations = 0;
20938
22087
  let emittedObservations = 0;
@@ -20949,8 +22098,34 @@ async function collectFleetStatus(options) {
20949
22098
  const own = [];
20950
22099
  own.push(...observeFromInventory(ctx, row, domainSet));
20951
22100
  if (needsProvenance) own.push(...observeFromProvenance(ctx, provenanceFacts, agentId, domainSet));
22101
+ const agentNotes = [];
22102
+ let scaffoldSummary = null;
22103
+ const scaffoldResult = scaffoldByAgent.get(agentId);
22104
+ const scaffoldObserved = manifest !== null && scaffoldResult !== void 0;
22105
+ if (domainSet.has("template_scaffold")) {
22106
+ const scaffold2 = observeFromScaffold(ctx, {
22107
+ agentId,
22108
+ result: scaffoldResult,
22109
+ manifestDeclared: manifest !== null,
22110
+ gitlink: scaffoldParity?.source.gitlink ?? null,
22111
+ exception: scaffoldExceptionFor(agentId),
22112
+ notes: agentNotes
22113
+ });
22114
+ own.push(...scaffold2.observations);
22115
+ scaffoldSummary = scaffold2.summary;
22116
+ if (!scaffoldObserved) scaffoldCounts.unobserved += 1;
22117
+ else {
22118
+ scaffoldCounts.applicable += 1;
22119
+ if (scaffoldResult.groups.some((group) => group.state === "error")) scaffoldCounts.incomplete += 1;
22120
+ else if (scaffoldResult.groups.some((group) => group.state === "fail")) {
22121
+ if (scaffoldExceptionFor(agentId) !== null) scaffoldCounts.exception_authorized += 1;
22122
+ else scaffoldCounts.drifted += 1;
22123
+ } else scaffoldCounts.passing += 1;
22124
+ }
22125
+ }
20952
22126
  const audit = auditByAgent.get(agentId);
20953
22127
  if (!live) {
22128
+ if (scaffoldObserved) ruleAgreement.not_compared += 1;
20954
22129
  for (const domain of auditFedSelected) {
20955
22130
  own.push(observation2(ctx, {
20956
22131
  domain,
@@ -20965,6 +22140,7 @@ async function collectFleetStatus(options) {
20965
22140
  }));
20966
22141
  }
20967
22142
  } else if (auditEntry === null || audit === void 0 || audit.outcome !== "ok") {
22143
+ if (scaffoldObserved) ruleAgreement.not_compared += 1;
20968
22144
  const failed = audit !== void 0 && audit.outcome === "failed";
20969
22145
  const state = failed ? "error" : "unobserved";
20970
22146
  const reason = auditEntry === null ? "audit-cli-unavailable" : audit === void 0 ? "no-project-path-recorded" : audit.reason ?? audit.outcome;
@@ -21117,6 +22293,29 @@ async function collectFleetStatus(options) {
21117
22293
  fixable: typeof rule.fixable === "boolean" ? rule.fixable : null
21118
22294
  }));
21119
22295
  }
22296
+ if (scaffoldObserved) {
22297
+ const ruleVerdict = scaffoldRuleVerdict((audit.rules ?? []).find((rule) => nonEmptyString3(rule.id) === "hermes.pm-scaffold"));
22298
+ const observerDrift = scaffoldObserverDrift(scaffoldResult);
22299
+ if (ruleVerdict === null || observerDrift === null) ruleAgreement.not_compared += 1;
22300
+ else {
22301
+ ruleAgreement.compared += 1;
22302
+ if (ruleVerdict === "fail" === observerDrift) ruleAgreement.agree += 1;
22303
+ else {
22304
+ ruleAgreement.disagree += 1;
22305
+ addFinding3(ctx, {
22306
+ code: "scaffold-rule-disagreement",
22307
+ domain: "template_scaffold",
22308
+ field: DOMAIN_FIELD.template_scaffold,
22309
+ agent_id: agentId,
22310
+ source: ctx.domainOwner("template_scaffold"),
22311
+ severity: "error",
22312
+ statusSeverity: "high",
22313
+ gating: true,
22314
+ detail: `the recipe rule hermes.pm-scaffold reports ${ruleVerdict} while the scaffold observer finds ${observerDrift ? "drift" : "no drift"} over the assets both compare (hermes, .gitignore, .scripts/** for content; role.yaml, SOUL.md and .runtime-scaffold/README.md for presence); both readings are kept and neither is resolved silently`
22315
+ });
22316
+ }
22317
+ }
22318
+ }
21120
22319
  }
21121
22320
  own.sort((a, b) => a.domain < b.domain ? -1 : a.domain > b.domain ? 1 : (a.rule_id ?? "") < (b.rule_id ?? "") ? -1 : (a.rule_id ?? "") > (b.rule_id ?? "") ? 1 : a.field < b.field ? -1 : a.field > b.field ? 1 : a.finding_id < b.finding_id ? -1 : a.finding_id > b.finding_id ? 1 : 0);
21122
22321
  totalObservations += own.length;
@@ -21134,6 +22333,7 @@ async function collectFleetStatus(options) {
21134
22333
  );
21135
22334
  members[memberClass] += 1;
21136
22335
  if (agentRecords.length >= FLEET_STATUS_MAX_AGENTS) continue;
22336
+ truncated.push(...agentNotes);
21137
22337
  let kept = own;
21138
22338
  let retrieval = retrievalFor(agentId, domains.length === 1 ? domains[0] : null, live);
21139
22339
  if (clipped) {
@@ -21154,7 +22354,8 @@ async function collectFleetStatus(options) {
21154
22354
  truncated: clipped,
21155
22355
  retrieval,
21156
22356
  lifecycle,
21157
- member_class: memberClass
22357
+ member_class: memberClass,
22358
+ scaffold: scaffoldSummary
21158
22359
  });
21159
22360
  }
21160
22361
  const fleetObservations = [];
@@ -21243,6 +22444,18 @@ async function collectFleetStatus(options) {
21243
22444
  policy,
21244
22445
  domainStates: new Map(domainRollups.map((rollup) => [rollup.domain, rollup.state]))
21245
22446
  });
22447
+ const scaffold = !domainSet.has("template_scaffold") ? null : {
22448
+ source: scaffoldParity === null ? { gitlink: null, integrity: "manifest-undeclared", detail: "the fleet contract declares no scaffold_manifest, so no role directory was compared" } : {
22449
+ gitlink: scaffoldParity.source.gitlink,
22450
+ integrity: bounded2(scaffoldParity.source.integrity),
22451
+ detail: bounded2(redactHome(scaffoldParity.source.detail))
22452
+ },
22453
+ agents: {
22454
+ ...scaffoldCounts,
22455
+ unobserved: scaffoldCounts.unobserved + (scaffoldCounts.total_registered - scaffoldCounts.selected)
22456
+ },
22457
+ rule_agreement: { ...ruleAgreement }
22458
+ };
21246
22459
  return {
21247
22460
  contract_path: shownPath3(contractPath),
21248
22461
  contract_version: contract.contract_version,
@@ -21255,6 +22468,7 @@ async function collectFleetStatus(options) {
21255
22468
  findings,
21256
22469
  probes,
21257
22470
  transitions,
22471
+ scaffold,
21258
22472
  truncated
21259
22473
  };
21260
22474
  }
@@ -21439,7 +22653,7 @@ function validateExternalEffectConsent(input, options) {
21439
22653
  return selected;
21440
22654
  }
21441
22655
  function resolveTargetDir(targetDir) {
21442
- const dir = resolve21(targetDir ?? process.cwd());
22656
+ const dir = resolve22(targetDir ?? process.cwd());
21443
22657
  if (!existsSync27(dir)) {
21444
22658
  throw new Error(`Target directory does not exist: ${dir}`);
21445
22659
  }
@@ -21449,12 +22663,12 @@ function resolveTargetDir(targetDir) {
21449
22663
  return dir;
21450
22664
  }
21451
22665
  function resolvePjanglerRoot3() {
21452
- let dir = dirname17(fileURLToPath9(import.meta.url));
21453
- while (dir !== dirname17(dir)) {
21454
- if (existsSync27(join35(dir, "package.json")) && existsSync27(join35(dir, "templates", "commonproject", "copier.yml"))) {
22666
+ let dir = dirname18(fileURLToPath9(import.meta.url));
22667
+ while (dir !== dirname18(dir)) {
22668
+ if (existsSync27(join36(dir, "package.json")) && existsSync27(join36(dir, "templates", "commonproject", "copier.yml"))) {
21455
22669
  return dir;
21456
22670
  }
21457
- dir = dirname17(dir);
22671
+ dir = dirname18(dir);
21458
22672
  }
21459
22673
  throw new Error("Unable to resolve pjangler root");
21460
22674
  }
@@ -21548,7 +22762,7 @@ function projectPreflightFailure(plan, errors, audit) {
21548
22762
  };
21549
22763
  }
21550
22764
  function preflightExistingHermesScaffold(targetDir) {
21551
- if (!existsSync27(join35(targetDir, "agents", "hermes"))) return void 0;
22765
+ if (!existsSync27(join36(targetDir, "agents", "hermes"))) return void 0;
21552
22766
  const owner = recipeRegistry.ownerOf("hermes.pm-scaffold");
21553
22767
  if (!owner) return "Hermes lifecycle owner is unavailable";
21554
22768
  const finding2 = owner.check.audit(lifecycleContext(targetDir, true));
@@ -21807,12 +23021,12 @@ server.registerTool(
21807
23021
  );
21808
23022
  const pjanglerRoot = resolvePjanglerRoot3();
21809
23023
  const projectSlug = validateSafePathSegment(input.projectSlug ?? slugify(input.projectName), "Project slug");
21810
- const explicitTargetDir = input.targetDir ? resolve21(input.targetDir) : void 0;
21811
- const parentDir = resolve21(input.parentDir ?? (explicitTargetDir ? dirname17(explicitTargetDir) : process.cwd()));
23024
+ const explicitTargetDir = input.targetDir ? resolve22(input.targetDir) : void 0;
23025
+ const parentDir = resolve22(input.parentDir ?? (explicitTargetDir ? dirname18(explicitTargetDir) : process.cwd()));
21812
23026
  if (!existsSync27(parentDir) || !statSync8(parentDir).isDirectory()) throw new Error(`Parent directory does not exist: ${parentDir}`);
21813
23027
  const targetDir = resolveContainedPath(
21814
23028
  parentDir,
21815
- explicitTargetDir ?? join35(parentDir, projectSlug),
23029
+ explicitTargetDir ?? join36(parentDir, projectSlug),
21816
23030
  "Bootstrap target"
21817
23031
  );
21818
23032
  const overwrite = input.overwrite ?? input.force ?? false;
@@ -21959,7 +23173,7 @@ server.registerTool(
21959
23173
  registryPath: input.registryPath,
21960
23174
  force: input.force ?? false,
21961
23175
  overwrite: input.force ?? false,
21962
- scaffold: !(input.targetDir && existsSync27(join35(resolve21(input.targetDir), ".git")))
23176
+ scaffold: !(input.targetDir && existsSync27(join36(resolve22(input.targetDir), ".git")))
21963
23177
  });
21964
23178
  if (!input.apply) return asText(publicCompositeProjectResponse(publicProjectPlan(plan), plan));
21965
23179
  const preflight = preflightProjectApply(plan, resolvePjanglerRoot3());