@sabaiway/agent-workflow-kit 1.15.2 → 1.17.0

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.
@@ -20,8 +20,16 @@ export const EXPECTED_MEMORY_NAME = 'agent-workflow-memory';
20
20
  // The assets a memory candidate must carry, AND their required type. A partial install (manifest +
21
21
  // SKILL.md only) is missing these → invalid → fallback. Checking the type (not just existence)
22
22
  // rejects a wrong-shaped install (e.g. a file where a dir is expected) BEFORE any project write.
23
+ //
24
+ // `references/templates/orchestration.json` (Step 2.4) is a SURGICAL gate: a memory too old to ship
25
+ // the orchestration-config template (pre-1.2.0, e.g. v1.0.0) can't seed `docs/ai/orchestration.json`,
26
+ // so it must NOT be delegate-classified — the kit then falls back to its OWN bundled substrate, which
27
+ // DOES seed orchestration.json (Mode: upgrade step 3). This closes the stale-memory trap that the
28
+ // read-only family-registry note (MEMORY_ORCH_TEMPLATE_REL) only INFORMS about — the gate ACTS. The
29
+ // two key on the same asset; a cross-tool parity test pins them in lockstep.
23
30
  export const REQUIRED_MEMORY_ASSETS = [
24
31
  { path: 'references/templates', type: 'dir' },
32
+ { path: 'references/templates/orchestration.json', type: 'file' },
25
33
  { path: 'references/contracts.md', type: 'file' },
26
34
  { path: 'references/scripts', type: 'dir' },
27
35
  { path: 'scripts/stamp-takeover.mjs', type: 'file' },
@@ -16,14 +16,29 @@
16
16
  // Pure, dependency-injectable (fs/env/home/validator are deps), dependency-free, Node >= 18. No
17
17
  // side effects on import (the isDirectRun idiom) — tests import the helpers with nothing run.
18
18
 
19
- import { existsSync, statSync, readFileSync } from 'node:fs';
19
+ import { existsSync, statSync, readFileSync, lstatSync } from 'node:fs';
20
20
  import { join, resolve } from 'node:path';
21
21
  import { pathToFileURL } from 'node:url';
22
22
  import os from 'node:os';
23
- import { resolveDir } from './detect-backends.mjs';
23
+ import { resolveDir, detectBackends, findOnPath } from './detect-backends.mjs';
24
24
  import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
25
- import { START_MARKER, excludePath } from './hide-footprint.mjs';
25
+ import { START_MARKER, excludePath, inferVisibility } from './hide-footprint.mjs';
26
26
  import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
27
+ import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
28
+ import { loadConfig } from './procedures.mjs';
29
+ // The deployment-lineage head + the shared settings readers are reused from velocity-profile so the
30
+ // `--json` envelope/settings-survey has ONE implementation each, never a drifting copy:
31
+ // EXPECTED_WORKFLOW_VERSION — the head literal (drift-guarded vs memory LINEAGE_HEAD)
32
+ // readSettingsFile / resolveEffectiveMode — the loud `.claude/settings.*` reader + mode precedence
33
+ // (No import cycle: velocity-profile / recipes / procedures import only node builtins + siblings, none
34
+ // import family-registry, and none has a side effect on import — every CLI is isDirectRun-guarded.)
35
+ import {
36
+ EXPECTED_WORKFLOW_VERSION,
37
+ SETTINGS_FILE,
38
+ SETTINGS_LOCAL_FILE,
39
+ readSettingsFile,
40
+ resolveEffectiveMode,
41
+ } from './velocity-profile.mjs';
27
42
 
28
43
  // ── manifestState values (the detect-backends precedence, generalized to any member kind) ──────────
29
44
  export const NOT_INSTALLED = 'not-installed';
@@ -93,14 +108,14 @@ export const FAMILY_MEMBERS = [
93
108
  export const isGlobalSkill = (member) => member.kind !== undefined; // every member is a global skill today
94
109
 
95
110
  // ── pure probes ──────────────────────────────────────────────────────────────────
96
- // Wrapped marker probe → 'present' (a regular file) | 'absent' (ENOENT / not a file) | 'unknown' (a
97
- // non-ENOENT fs error, e.g. EACCES). 'unknown' is NOT collapsed to 'absent': a permission error must
98
- // surface, never be masked as not-installed (no silent failure) and uninstall then leaves it alone.
111
+ // Wrapped marker probe → 'present' (a regular file) | 'absent' (ENOENT / not a regular file) |
112
+ // 'unknown' (a non-ENOENT fs error, e.g. EACCES). STAT-FIRST on purpose: `existsSync()` SWALLOWS an
113
+ // EACCES into a bare `false`, which would mask a permission error as 'absent' (a silent failure);
114
+ // `statSync` THROWS the EACCES so it surfaces as 'unknown'. 'unknown' is never collapsed to 'absent' —
115
+ // classifyMember reports it (uninstall then leaves the dir alone) and the memory caveat skips it.
99
116
  const probeMarker = (path, deps = {}) => {
100
- const exists = deps.exists ?? existsSync;
101
117
  const stat = deps.stat ?? statSync;
102
118
  try {
103
- if (!exists(path)) return 'absent';
104
119
  return stat(path).isFile() ? 'present' : 'absent';
105
120
  } catch (err) {
106
121
  return err && err.code === 'ENOENT' ? 'absent' : 'unknown';
@@ -158,6 +173,17 @@ const ENGINE_FRAGMENT_CAVEATS = [
158
173
  { rel: PROCEDURES_FRAGMENT_REL, caveat: 'engine present but does not ship the activity-procedures canon (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
159
174
  ];
160
175
 
176
+ // The orchestration-config TEMPLATE a current memory ships (added in memory 1.2.0; absent in older
177
+ // installs such as v1.0.0). It is the SAME asset Step 2.4 adds to delegation.mjs's
178
+ // REQUIRED_MEMORY_ASSETS: the read-only note here INFORMS, the delegation gate ACTS. Step 2.4 also
179
+ // adds a parity drift-guard tying this path to that required-asset set, so the note and the gate can
180
+ // never key on different files (until then they are kept in lockstep by review).
181
+ export const MEMORY_ORCH_TEMPLATE_REL = 'references/templates/orchestration.json';
182
+ // Worded as an honest OBSERVATION, not a diagnosis (absence can mean old OR incomplete), and it makes
183
+ // NO claim about an orchestration.json seeding outcome (that depends on delegate-vs-fallback).
184
+ const MEMORY_BEHIND_NOTE =
185
+ "the memory installed here doesn't include the current orchestration template — refresh it with `npx @sabaiway/agent-workflow-memory@latest init`, then restart the session.";
186
+
161
187
  export const surveyFamily = (deps = {}) =>
162
188
  FAMILY_MEMBERS.map((member) => {
163
189
  const row = classifyMember(member, deps);
@@ -173,6 +199,14 @@ export const surveyFamily = (deps = {}) =>
173
199
  const caveats = ENGINE_FRAGMENT_CAVEATS.filter((f) => !fragmentUsable(f.rel)).map((f) => f.caveat);
174
200
  if (caveats.length) row.caveats = caveats;
175
201
  }
202
+ // Memory offline caveat (Step 2.2): a distinct probe — the orchestration TEMPLATE file's existence.
203
+ // Only attach when it is provably ABSENT (a non-ENOENT probe error → 'unknown' → skip, never a
204
+ // false "missing" claim). Mirrors the engine-caveat SHAPE; keyed on the Step-2.4 required asset.
205
+ if (row.kind === 'memory-substrate' && row.manifestState === OK && row.skillDir) {
206
+ if (probeMarker(join(row.skillDir, MEMORY_ORCH_TEMPLATE_REL), deps) === 'absent') {
207
+ row.caveats = [...(row.caveats ?? []), MEMORY_BEHIND_NOTE];
208
+ }
209
+ }
176
210
  return row;
177
211
  });
178
212
 
@@ -234,13 +268,41 @@ export const surveyProject = (projectDir, deps = {}) => {
234
268
  // ── report ───────────────────────────────────────────────────────────────────────
235
269
  const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
236
270
 
237
- export const formatStatus = (family, project = null) => {
271
+ // The human (non-JSON) settings render. A dev view — the agent consumes `--json` + renders in plain
272
+ // language (Mode: status). Each area shows its `error` field loudly when one fired.
273
+ const formatSettings = (s) => {
274
+ const out = ['', 'settings'];
275
+ if (s.recipes?.error) out.push(` ${pad('recipes', 14)}error: ${s.recipes.error}`);
276
+ else {
277
+ const parts = [];
278
+ for (const [act, slots] of Object.entries(s.recipes?.activities ?? {})) {
279
+ for (const [slot, r] of Object.entries(slots)) parts.push(`${act}.${slot}=${r.recipe}`);
280
+ }
281
+ out.push(` ${pad('recipes', 14)}${parts.join(' · ') || '—'}`);
282
+ if (s.recipes?.detectError) out.push(` ${pad('', 14)}↳ couldn't check backends (${s.recipes.detectError}); recipes floored at solo`);
283
+ }
284
+ if (s.attribution?.error) out.push(` ${pad('attribution', 14)}error: ${s.attribution.error}`);
285
+ else out.push(` ${pad('attribution', 14)}includeCoAuthoredBy effective=${String(s.attribution?.effective)}`);
286
+ if (s.velocity?.error) out.push(` ${pad('velocity', 14)}error: ${s.velocity.error}`);
287
+ else out.push(` ${pad('velocity', 14)}defaultMode=${String(s.velocity?.defaultMode)} · allow project/local=${s.velocity?.allowEntries?.project}/${s.velocity?.allowEntries?.local}`);
288
+ return out;
289
+ };
290
+
291
+ export const formatStatus = (family, project = null, extras = {}) => {
238
292
  const lines = ['agent-workflow family — installed skills (skill axis)', ''];
239
293
  for (const m of family) {
240
294
  const ver = m.version ? `v${m.version}` : '—';
241
295
  lines.push(` ${pad(m.name, 26)}[${pad(m.manifestState, 16)}] ${pad(ver, 10)} ${m.kind}`);
242
296
  for (const c of m.caveats ?? []) lines.push(` ↳ ${c}`);
243
297
  }
298
+ if (extras.bridges) {
299
+ const WRAP_MARK = { present: '✓', missing: '✗', unknown: '?' };
300
+ lines.push('', 'execution backends (host)', '');
301
+ for (const b of extras.bridges) {
302
+ const w = b.wrappers.map((x) => `${x.cmd} ${WRAP_MARK[x.state] ?? '?'}`).join(', ') || '—';
303
+ lines.push(` ${pad(b.display, 20)}${pad(b.readiness, 18)}wrappers: ${w}`);
304
+ }
305
+ }
244
306
  if (project) {
245
307
  lines.push('', `project deployment (${project.dir})`, '');
246
308
  if (!project.deployed) {
@@ -250,16 +312,197 @@ export const formatStatus = (family, project = null) => {
250
312
  lines.push(` ${pad(s.file, 26)}${s.version ?? '—'}`);
251
313
  }
252
314
  lines.push(` ${pad('docs/ai present', 26)}${project.docsAiPresent ? 'yes' : 'no'}`);
253
- lines.push(` ${pad('hidden-mode fence', 26)}${project.hiddenFence ? 'present' : 'absent'}`);
315
+ if (extras.visibility) {
316
+ lines.push(` ${pad('visibility', 26)}${extras.visibility.error ? `error: ${extras.visibility.error}` : extras.visibility.state}`);
317
+ } else {
318
+ lines.push(` ${pad('hidden-mode fence', 26)}${project.hiddenFence ? 'present' : 'absent'}`);
319
+ }
254
320
  }
321
+ if (extras.settings) lines.push(...formatSettings(extras.settings));
255
322
  }
256
323
  return lines.join('\n');
257
324
  };
258
325
 
326
+ // ── the no-leak --json envelope ──────────────────────────────────────────────────
327
+ // A machine-readable view with USER-SAFE field names only — NEVER the internal manifestState /
328
+ // hiddenFence terms or the raw stamp FILENAMES. The render (SKILL.md version block + Mode: status)
329
+ // consumes THIS, never the human table verbatim. An envelope-shape test pins its shape so later phases
330
+ // (the settings/visibility block) can't silently break the Phase-2 version consumer.
331
+
332
+ // internal manifestState → a STABLE, user-safe token (SKILL.md owns the value→plain-language phrasing).
333
+ // Deliberately NOT the internal literals (foreign/stub/…): those must never leak past this boundary.
334
+ const STATE_PUBLIC = {
335
+ [OK]: 'installed',
336
+ [NOT_INSTALLED]: 'absent',
337
+ [FOREIGN]: 'other-tool',
338
+ [STUB]: 'placeholder',
339
+ [INVALID_MANIFEST]: 'invalid',
340
+ [UNSUPPORTED_SCHEMA]: 'unsupported',
341
+ [UNKNOWN]: 'uncheckable',
342
+ };
343
+
344
+ // Short, user-facing labels for the version block (kit · memory · engine · codex-bridge · …), so the
345
+ // render is deterministic and the agent never invents a label.
346
+ export const DISPLAY_NAMES = {
347
+ 'agent-workflow-kit': 'kit',
348
+ 'agent-workflow-memory': 'memory',
349
+ 'agent-workflow-engine': 'engine',
350
+ 'codex-cli-bridge': 'codex-bridge',
351
+ 'antigravity-cli-bridge': 'antigravity-bridge',
352
+ };
353
+ const displayOf = (name) => DISPLAY_NAMES[name] ?? name;
354
+
355
+ // ── the settings survey (Phase 3) — read-only, honest, localized-on-error ──────────
356
+ // Each sub-survey returns a small user-safe object OR a single `{ error }` field (a localized message,
357
+ // never a crash): a malformed/unreadable file in ONE area must not break the rest of `status`. The
358
+ // readers are REUSED (one implementation each): loadConfig (procedures.mjs), readSettingsFile /
359
+ // resolveEffectiveMode (velocity-profile.mjs), resolveActivityRecipe (recipes.mjs), inferVisibility
360
+ // (hide-footprint.mjs). Engine-free throughout (the effective-recipe view never reads the engine).
361
+
362
+ // A localized, user-safe error string. The reused readers already build cwd-relative `path: reason`
363
+ // messages (loadConfig / velocity), so this just normalizes to a string — never a raw stack/abs path.
364
+ const localizeError = (err) => (err && err.message ? String(err.message) : String(err));
365
+
366
+ const hasOwn = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k);
367
+
368
+ // The backend detector is a SECONDARY input to the survey — a corrupt bridge must never break the
369
+ // read-only view, but the failure must NOT be silent (Hard Constraint). Run it defensively: a throw →
370
+ // { detection: [], error: <localized> } so callers floor gracefully (recipes → solo, bridges →
371
+ // 'unknown') AND can surface the concrete reason. Returns a value so callers stay `const`.
372
+ const detectSafe = (deps) => {
373
+ try {
374
+ return { detection: (deps.detect ?? detectBackends)(deps), error: null };
375
+ } catch (err) {
376
+ return { detection: [], error: localizeError(err) };
377
+ }
378
+ };
379
+
380
+ // visibility: the THREE honest states from inferVisibility (NOT the single hiddenFence bit) → user-safe
381
+ // words. Never the internal "hidden fence" / marker terms. A git/probe error → a localized error field.
382
+ const VISIBILITY_PUBLIC = { visible: 'visible', hidden: 'hidden', ambiguous: 'unclear' };
383
+ export const surveyVisibility = (dir, deps = {}) => {
384
+ try {
385
+ const vis = inferVisibility(deps, resolve(dir));
386
+ return { state: VISIBILITY_PUBLIC[vis.visibility] ?? 'unclear' };
387
+ } catch (err) {
388
+ return { error: localizeError(err) };
389
+ }
390
+ };
391
+
392
+ // orchestration recipes: the EFFECTIVE recipe per slot (config · default · effective), engine-free —
393
+ // shared loadConfig + resolveActivityRecipe + the read-only backend detector. A malformed config → a
394
+ // localized error field; a detection failure floors at solo (a corrupt bridge must not break the view).
395
+ export const surveyRecipes = (dir, deps = {}) => {
396
+ // A detector failure floors recipes at solo (mirrors procedures) but is surfaced as `detectError`, so
397
+ // the render says "couldn't check backends" instead of letting a real solo-default look identical.
398
+ const { detection, error: detectError } = detectSafe(deps);
399
+ try {
400
+ const { config, source } = loadConfig(resolve(dir), deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
401
+ const activities = {};
402
+ for (const [activity, def] of Object.entries(ACTIVITIES)) {
403
+ activities[activity] = {};
404
+ for (const slot of Object.keys(def.slots)) {
405
+ const r = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot });
406
+ activities[activity][slot] = { recipe: r.recipe, source: r.source, degradedFrom: r.degradedFrom ?? null };
407
+ }
408
+ }
409
+ return { configSource: source, activities, ...(detectError ? { detectError } : {}) };
410
+ } catch (err) {
411
+ return { error: localizeError(err) };
412
+ }
413
+ };
414
+
415
+ // attribution: includeCoAuthoredBy across .claude/settings.json + settings.local.json as
416
+ // project · local override · effective (local WINS only when it actually sets the key — presence-based,
417
+ // mirroring resolveEffectiveMode). Reuses the loud readSettingsFile; the precedence read is new code.
418
+ export const surveyAttribution = (dir, deps = {}) => {
419
+ try {
420
+ const d = resolve(dir);
421
+ const project = readSettingsFile(join(d, SETTINGS_FILE), { ...deps, cwd: d });
422
+ const local = readSettingsFile(join(d, SETTINGS_LOCAL_FILE), { ...deps, cwd: d });
423
+ const projVal = project.present && hasOwn(project.data, 'includeCoAuthoredBy') ? project.data.includeCoAuthoredBy : null;
424
+ const localHasKey = local.present && hasOwn(local.data, 'includeCoAuthoredBy');
425
+ const localVal = localHasKey ? local.data.includeCoAuthoredBy : null;
426
+ return { project: projVal, local: localVal, effective: localHasKey ? localVal : projVal };
427
+ } catch (err) {
428
+ return { error: localizeError(err) };
429
+ }
430
+ };
431
+
432
+ // velocity: the effective permissions.defaultMode (local > project, via resolveEffectiveMode) + the
433
+ // per-source count of allowlist entries — a read-only view of what the velocity profile may have seeded.
434
+ export const surveyVelocity = (dir, deps = {}) => {
435
+ try {
436
+ const d = resolve(dir);
437
+ const project = readSettingsFile(join(d, SETTINGS_FILE), { ...deps, cwd: d });
438
+ const local = readSettingsFile(join(d, SETTINGS_LOCAL_FILE), { ...deps, cwd: d });
439
+ const { effectiveMode } = resolveEffectiveMode(project.data, local.data);
440
+ const allowOf = (s) => (s.present && Array.isArray(s.data?.permissions?.allow) ? s.data.permissions.allow.length : 0);
441
+ return { defaultMode: effectiveMode ?? null, allowEntries: { project: allowOf(project), local: allowOf(local) } };
442
+ } catch (err) {
443
+ return { error: localizeError(err) };
444
+ }
445
+ };
446
+
447
+ // the project-scoped settings survey (needs a project dir). Each area is independently localized-on-error.
448
+ export const surveySettings = (dir, deps = {}) => ({
449
+ recipes: surveyRecipes(dir, deps),
450
+ attribution: surveyAttribution(dir, deps),
451
+ velocity: surveyVelocity(dir, deps),
452
+ });
453
+
454
+ // bridges: HOST-scoped (no project needed). Wrapper command NAMES come from FAMILY_MEMBERS[].wrapperCmds
455
+ // (static, always present), their PATH-presence is probed DIRECTLY via findOnPath over those names (NOT
456
+ // detect-backends' wrappers[], which is [] when the bridge isn't ok — the onboarding case), and the
457
+ // readiness summary comes from the detector. NO default-model claim (a negative drift-guard asserts it).
458
+ export const surveyBridges = (deps = {}) => {
459
+ const { detection } = detectSafe(deps); // a detector failure → every readiness reads 'unknown' (honest)
460
+ const probe = deps.findOnPath ?? findOnPath;
461
+ return FAMILY_MEMBERS.filter((m) => m.kind === 'execution-backend').map((m) => {
462
+ const det = detection.find((d) => d.name === m.name);
463
+ // Preserve findOnPath's THREE-state result (present | missing | unknown): an `unknown` (EACCES,
464
+ // "cannot confirm") must stay distinct from a real `missing` — never flattened to a false boolean.
465
+ const wrappers = m.wrapperCmds.map((cmd) => ({ cmd, state: probe(cmd, deps).state }));
466
+ return { member: m.name, display: displayOf(m.name), readiness: det?.readiness ?? 'unknown', wrappers };
467
+ });
468
+ };
469
+
470
+ export const buildEnvelope = (family, project = null, extras = {}) => {
471
+ const installed = family.map((m) => {
472
+ const entry = {
473
+ member: m.name,
474
+ display: displayOf(m.name),
475
+ version: m.version ?? null,
476
+ state: STATE_PUBLIC[m.manifestState] ?? STATE_PUBLIC[UNKNOWN],
477
+ };
478
+ if (m.caveats?.length) entry.notes = m.caveats; // plain-language observations (Steps 2.2 / engine)
479
+ return entry;
480
+ });
481
+ const envelope = { deploymentHead: EXPECTED_WORKFLOW_VERSION, installed };
482
+ if (extras.bridges) envelope.bridges = extras.bridges; // HOST-scoped (no project needed)
483
+ if (project) {
484
+ envelope.project = {
485
+ dir: project.dir,
486
+ deployed: project.deployed,
487
+ docsAi: project.docsAiPresent,
488
+ // member + display + version only — never the internal stamp FILENAME (s.file).
489
+ deployStamps: project.stamps.map((s) => ({ member: s.name, display: displayOf(s.name), version: s.version ?? null })),
490
+ };
491
+ // project-scoped Phase-3 additions (visibility from inferVisibility, not the hiddenFence bit).
492
+ if (extras.visibility) envelope.project.visibility = extras.visibility;
493
+ if (extras.settings) envelope.project.settings = extras.settings;
494
+ }
495
+ return envelope;
496
+ };
497
+
259
498
  // ── CLI ────────────────────────────────────────────────────────────────────────
260
499
  const parseArgs = (argv) => {
261
500
  const dirFlag = argv.indexOf('--dir');
262
- return { help: argv.includes('--help') || argv.includes('-h'), dir: dirFlag >= 0 ? argv[dirFlag + 1] : undefined };
501
+ return {
502
+ help: argv.includes('--help') || argv.includes('-h'),
503
+ json: argv.includes('--json'),
504
+ dir: dirFlag >= 0 ? argv[dirFlag + 1] : undefined,
505
+ };
263
506
  };
264
507
 
265
508
  const main = (argv) => {
@@ -268,14 +511,21 @@ const main = (argv) => {
268
511
  console.log(`family-registry — read-only view of the agent-workflow family.
269
512
 
270
513
  Usage:
271
- node family-registry.mjs [--dir <project>] # skill axis always; deploy axis when --dir is given
514
+ node family-registry.mjs [--dir <project>] [--json]
515
+ # skill axis always; deploy axis when --dir is given; --json = the no-leak machine envelope
272
516
 
273
517
  Detection only — never writes, never commits, never runs a subscription CLI.`);
274
518
  return;
275
519
  }
276
520
  const family = surveyFamily();
521
+ const bridges = surveyBridges();
277
522
  const project = args.dir ? surveyProject(args.dir) : null;
278
- console.log(formatStatus(family, project));
523
+ const extras = { bridges };
524
+ if (args.dir) {
525
+ extras.visibility = surveyVisibility(args.dir);
526
+ extras.settings = surveySettings(args.dir);
527
+ }
528
+ console.log(args.json ? JSON.stringify(buildEnvelope(family, project, extras), null, 2) : formatStatus(family, project, extras));
279
529
  };
280
530
 
281
531
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -141,7 +141,9 @@ const validateConfig = (config) => {
141
141
  // Load + validate the config from <cwd>/docs/ai/orchestration.json. Absent FILE → computed defaults
142
142
  // (NOT an error): { config: null, source: 'none' }. Malformed JSON / schema-invalid / unreadable →
143
143
  // loud `path: reason` (exit 1). The resolver receives the parsed+validated object (§2.2 IO/resolver split).
144
- const loadConfig = (cwd, readFile = readFileSync, lstat = lstatSync) => {
144
+ // Exported so the read-only status settings-survey (family-registry.mjs) reuses ONE config reader —
145
+ // same strict-JSON + loud-on-malformed contract, no second drifting implementation (Plan 3.1).
146
+ export const loadConfig = (cwd, readFile = readFileSync, lstat = lstatSync) => {
145
147
  const full = join(cwd, CONFIG_REL);
146
148
  // Distinguish a TRULY-absent config (no entry at all → computed defaults) from a present-but-
147
149
  // unreadable one (a directory, a DANGLING SYMLINK, a permission error → loud exit 1). lstat does NOT