@mstar-harness/engine 3.4.0 → 3.5.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.
package/dist/audit.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/audit.ts
2
- import { execFileSync } from "node:child_process";
3
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, rmdirSync as rmdirSync2, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
4
- import { basename as basename2, join as join4, resolve as resolve4, sep as sep2 } from "node:path";
2
+ import { execFileSync as execFileSync2 } from "node:child_process";
3
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync5, rmdirSync as rmdirSync2, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
4
+ import { basename as basename3, join as join7, resolve as resolve7, sep as sep2 } from "node:path";
5
5
 
6
6
  // src/core.ts
7
7
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
@@ -92,7 +92,16 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
92
92
  }
93
93
  }
94
94
 
95
+ // src/path.ts
96
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync4, realpathSync as realpathSync2, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
97
+ import { execFileSync } from "node:child_process";
98
+ import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute4, join as join6, relative as relative2, resolve as resolve6 } from "node:path";
99
+
95
100
  // src/mstarc.ts
101
+ import { readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
102
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve3 } from "node:path";
103
+ var MSTARC_FILE = ".mstarc";
104
+ var MSTARC_SECTION = "config";
96
105
  var MSTARC_HARNESS_DIR_KEY = "harness_dir";
97
106
  var MSTARC_PLAN_DIR_KEY = "plan_dir";
98
107
  var MSTARC_SDD_DIR_KEY = "sdd_dir";
@@ -113,10 +122,205 @@ var CONFIG_KEYS = {
113
122
  [MSTARC_PROJECT_DIR_KEY]: "projectDir",
114
123
  [MSTARC_ENFORCEMENT_KEY]: "enforcement"
115
124
  };
125
+ function parseMstarc(text) {
126
+ let section = null;
127
+ const out = {};
128
+ for (const raw of text.split(/\r?\n/)) {
129
+ const line = raw.trim();
130
+ if (line === "" || line.startsWith("#") || line.startsWith(";"))
131
+ continue;
132
+ const header = /^\[([^\]]+)\]$/.exec(line);
133
+ if (header !== null) {
134
+ section = header[1].trim();
135
+ continue;
136
+ }
137
+ if (section !== MSTARC_SECTION)
138
+ continue;
139
+ const eq = line.indexOf("=");
140
+ if (eq === -1)
141
+ continue;
142
+ const field = CONFIG_KEYS[line.slice(0, eq).trim()];
143
+ if (field === undefined)
144
+ continue;
145
+ const value = line.slice(eq + 1).trim();
146
+ if (value === "")
147
+ continue;
148
+ if (field === "enforcement" && value !== "hard" && value !== "soft")
149
+ continue;
150
+ out[field] = value;
151
+ }
152
+ return out;
153
+ }
154
+ function isFile(file) {
155
+ try {
156
+ return statSync2(file).isFile();
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+ function findMstarc(startDir, boundary) {
162
+ let dir = resolve3(startDir);
163
+ const bound = resolve3(boundary);
164
+ for (;; ) {
165
+ if (!isAtOrBelow(dir, bound))
166
+ return null;
167
+ const candidate = join3(dir, MSTARC_FILE);
168
+ if (isFile(candidate))
169
+ return candidate;
170
+ if (dir === bound)
171
+ return null;
172
+ const parent = dirname3(dir);
173
+ if (parent === dir)
174
+ return null;
175
+ dir = parent;
176
+ }
177
+ }
178
+ function loadMstarc(startDir, boundary) {
179
+ const file = findMstarc(startDir, boundary);
180
+ if (file === null)
181
+ return null;
182
+ return { file, dir: dirname3(file), config: parseMstarc(readFileSync2(file, "utf8")) };
183
+ }
184
+ function isAtOrBelow(dir, root) {
185
+ const rel = relative(root, dir);
186
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
187
+ }
188
+
189
+ // src/store.ts
190
+ import { existsSync as existsSync2, readdirSync, unlinkSync as unlinkSync3 } from "node:fs";
191
+ import { isAbsolute as isAbsolute3, join as join4, resolve as resolve4 } from "node:path";
192
+ var PLAN_SHAPED_KEY_RE = /^[0-9]{8}-[a-z0-9-]+$/;
193
+ function resolveArtifactPath(harnessRoot, ref) {
194
+ const { kind, key } = ref;
195
+ if (kind === "json") {
196
+ if (!isAbsolute3(key)) {
197
+ throw new Error(`ArtifactStore json key must be an absolute path — got ${JSON.stringify(key)}`);
198
+ }
199
+ if (key.split(/[\\/]+/).includes("..")) {
200
+ throw new Error(`ArtifactStore json key must not contain ".." segments — got ${JSON.stringify(key)}`);
201
+ }
202
+ return key;
203
+ }
204
+ assertSafePathComponent(key, "ArtifactStore key");
205
+ if (kind === "status") {
206
+ if (key !== "root") {
207
+ throw new Error(`ArtifactStore status key must be "root" — got ${JSON.stringify(key)}`);
208
+ }
209
+ return join4(harnessRoot, "status.json");
210
+ }
211
+ if (kind === "snapshot") {
212
+ return join4(resolveWorkflowDir(harnessRoot, { harnessDir: harnessRoot }), key, "snapshot.json");
213
+ }
214
+ if (kind === "residuals") {
215
+ return join4(resolveProjectDir(harnessRoot, { harnessDir: harnessRoot }), key, "residuals.json");
216
+ }
217
+ if (PLAN_SHAPED_KEY_RE.test(key)) {
218
+ return join4(harnessRoot, "sdd", key, "review", "report.json");
219
+ }
220
+ return join4(harnessRoot, "sdd", "_reviews", `${key}.json`);
221
+ }
222
+ function listDirNames(dir) {
223
+ return readDirEntries(dir).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
224
+ }
225
+ function listJsonKeys(dir) {
226
+ return readDirEntries(dir).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name.slice(0, -".json".length));
227
+ }
228
+ function readDirEntries(dir) {
229
+ try {
230
+ return readdirSync(dir, { withFileTypes: true });
231
+ } catch (error) {
232
+ const code = error.code;
233
+ if (code === "ENOENT" || code === "ENOTDIR")
234
+ return [];
235
+ throw error;
236
+ }
237
+ }
238
+ function tryResolveGetPath(root, kind, key) {
239
+ try {
240
+ return resolveArtifactPath(root, { kind, key });
241
+ } catch {
242
+ return;
243
+ }
244
+ }
245
+ function createFsStore(harnessRoot) {
246
+ const root = resolve4(harnessRoot);
247
+ return {
248
+ root,
249
+ async put(doc) {
250
+ if (doc.schema !== undefined) {
251
+ throw new Error("FsStore does not persist schema ids — omit --schema or inject a store module that persists it");
252
+ }
253
+ writeJson(resolveArtifactPath(root, doc), doc.payload);
254
+ },
255
+ async get(ref) {
256
+ const filePath = resolveArtifactPath(root, ref);
257
+ if (!existsSync2(filePath))
258
+ return;
259
+ return readJson(filePath);
260
+ },
261
+ async delete(ref) {
262
+ const filePath = resolveArtifactPath(root, ref);
263
+ if (existsSync2(filePath))
264
+ unlinkSync3(filePath);
265
+ },
266
+ async list(kind) {
267
+ if (kind === "json") {
268
+ throw new Error("ArtifactStore json keys are absolute paths and cannot be listed");
269
+ }
270
+ const keys = [];
271
+ if (kind === "status") {
272
+ if (existsSync2(resolveArtifactPath(root, { kind, key: "root" })))
273
+ keys.push("root");
274
+ } else if (kind === "snapshot" || kind === "residuals") {
275
+ const baseDir = kind === "snapshot" ? resolveWorkflowDir(root, { harnessDir: root }) : resolveProjectDir(root, { harnessDir: root });
276
+ for (const name of listDirNames(baseDir)) {
277
+ const getPath = tryResolveGetPath(root, kind, name);
278
+ if (getPath !== undefined && existsSync2(getPath))
279
+ keys.push(name);
280
+ }
281
+ } else {
282
+ const sddDir = join4(root, "sdd");
283
+ for (const key of listJsonKeys(join4(sddDir, "_reviews"))) {
284
+ if (!PLAN_SHAPED_KEY_RE.test(key) && tryResolveGetPath(root, kind, key) !== undefined) {
285
+ keys.push(key);
286
+ }
287
+ }
288
+ for (const name of listDirNames(sddDir)) {
289
+ if (PLAN_SHAPED_KEY_RE.test(name)) {
290
+ const getPath = tryResolveGetPath(root, kind, name);
291
+ if (getPath !== undefined && existsSync2(getPath))
292
+ keys.push(name);
293
+ }
294
+ }
295
+ }
296
+ return keys.sort().map((key) => ({ kind, key }));
297
+ }
298
+ };
299
+ }
300
+ var injectedStore;
301
+ function getArtifactStore() {
302
+ if (injectedStore !== undefined)
303
+ return injectedStore;
304
+ const root = resolveHarnessDir(process.cwd());
305
+ if (root === null) {
306
+ throw new Error(`harness dir not found from ${resolve4(process.cwd())} — cannot create the default FsStore (run \`mstar harness scaffold\`, pass opts.harnessDir, or set MSTAR_HARNESS_DIR)`);
307
+ }
308
+ return createFsStore(root);
309
+ }
310
+ function assertFsStorePath(store, ref, expectedPath) {
311
+ const root = store.root;
312
+ if (typeof root !== "string")
313
+ return;
314
+ const storePath = resolveArtifactPath(root, ref);
315
+ const expected = resolve4(expectedPath);
316
+ if (storePath !== expected) {
317
+ throw new Error(`routed writer path mismatch: the active FsStore resolves ${ref.kind}/${JSON.stringify(ref.key)} to ${JSON.stringify(storePath)} but the caller's target is ${JSON.stringify(expected)} — call setArtifactStore(createFsStore(<root>)) first when the write target differs from the active store's root`);
318
+ }
319
+ }
116
320
 
117
321
  // src/status.ts
118
- import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, realpathSync } from "node:fs";
119
- import { dirname as dirname3, join as join3, resolve as resolve3, sep } from "node:path";
322
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, realpathSync } from "node:fs";
323
+ import { dirname as dirname4, join as join5, resolve as resolve5, sep } from "node:path";
120
324
 
121
325
  // src/workflow.ts
122
326
  var WORKFLOW_SNAPSHOT_FILE = "snapshot.json";
@@ -181,7 +385,7 @@ function validateStatusV2(docOrPath, opts = {}) {
181
385
  if (typeof docOrPath === "string") {
182
386
  try {
183
387
  doc = readJson(docOrPath);
184
- harnessDir = dirname3(resolve3(docOrPath));
388
+ harnessDir = dirname4(resolve5(docOrPath));
185
389
  } catch (error) {
186
390
  return {
187
391
  ok: false,
@@ -248,8 +452,8 @@ function validateStatusV2(docOrPath, opts = {}) {
248
452
  for (const entry of doc.workflows) {
249
453
  if (!isPlainObject(entry) || typeof entry.dir !== "string")
250
454
  continue;
251
- const relSnapshot = join3(entry.dir, WORKFLOW_SNAPSHOT_FILE);
252
- const snapshotPath = join3(harnessDir, relSnapshot);
455
+ const relSnapshot = join5(entry.dir, WORKFLOW_SNAPSHOT_FILE);
456
+ const snapshotPath = join5(harnessDir, relSnapshot);
253
457
  const label = typeof entry.id === "string" ? entry.id : relSnapshot;
254
458
  let physical;
255
459
  try {
@@ -282,8 +486,10 @@ function validateStatusV2(docOrPath, opts = {}) {
282
486
  }
283
487
  return { ok: violations.length === 0, violations };
284
488
  }
285
- function registerWorkflowEntryLocked(statusPath, entry) {
286
- const harnessDir = dirname3(statusPath);
489
+ async function registerWorkflowEntryLocked(statusPath, entry) {
490
+ const harnessDir = dirname4(statusPath);
491
+ const store = getArtifactStore();
492
+ assertFsStorePath(store, { kind: "status", key: "root" }, statusPath);
287
493
  const current = readJson(statusPath);
288
494
  const fresh = Object.keys(current).length === 0;
289
495
  const doc = fresh ? { version: 2, updated_at: todayString(), workflows: [] } : current;
@@ -301,16 +507,83 @@ function registerWorkflowEntryLocked(statusPath, entry) {
301
507
  if (!gate.ok) {
302
508
  throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
303
509
  }
304
- writeJson(statusPath, doc);
510
+ await store.put({ kind: "status", key: "root", payload: doc });
305
511
  return doc;
306
512
  }
307
513
 
308
514
  // src/path.ts
515
+ function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
516
+ const start = resolve6(startDir);
517
+ const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
518
+ if (explicit)
519
+ return resolve6(start, explicit);
520
+ const boundary = resolve6(start, opts.workspaceRoot ?? defaultWorkspaceRoot(start));
521
+ const rc = loadMstarc(start, boundary);
522
+ if (rc !== null && rc.config.harnessDir)
523
+ return resolve6(rc.dir, rc.config.harnessDir);
524
+ let dir = start;
525
+ for (;; ) {
526
+ if (!isAtOrBelow2(dir, boundary))
527
+ return null;
528
+ for (const candidate of [join6(dir, ".mstar"), join6(dir, ".agents"), join6(dir, ".plans"), join6(dir, "plans")]) {
529
+ if (isDirectory(candidate))
530
+ return candidate;
531
+ }
532
+ if (dir === boundary)
533
+ return null;
534
+ const parent = dirname5(dir);
535
+ if (parent === dir)
536
+ return null;
537
+ dir = parent;
538
+ }
539
+ }
540
+ function defaultWorkspaceRoot(startDir) {
541
+ try {
542
+ const cdup = execFileSync("git", ["rev-parse", "--show-cdup"], {
543
+ cwd: startDir,
544
+ encoding: "utf8",
545
+ stdio: ["ignore", "pipe", "ignore"]
546
+ }).trim();
547
+ if (!cdup)
548
+ return startDir;
549
+ let boundary = startDir;
550
+ for (const segment of cdup.split(/[\\/]/)) {
551
+ if (segment && segment !== ".")
552
+ boundary = dirname5(boundary);
553
+ }
554
+ return resolve6(boundary);
555
+ } catch {}
556
+ return startDir;
557
+ }
558
+ function isAtOrBelow2(dir, root) {
559
+ const rel = relative2(root, dir);
560
+ return rel === "" || !rel.startsWith("..") && !isAbsolute4(rel);
561
+ }
562
+ function mstarcDirOverride(harnessDir, key) {
563
+ const dir = resolve6(harnessDir);
564
+ const rc = loadMstarc(dir, dirname5(dir));
565
+ const declared = rc?.config[key];
566
+ return declared ? resolve6(rc.dir, declared) : null;
567
+ }
309
568
  function assertSafePathComponent(value, what) {
310
569
  if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
311
570
  throw new Error(`${what} must be a single safe path component ([A-Za-z0-9._-]+; not "", ".", "..", or containing "/" or "\\") — got ${JSON.stringify(value)}`);
312
571
  }
313
572
  }
573
+ function resolveHarnessSubdir(startDir, opts, key, fallback) {
574
+ const harness = resolveHarnessDir(startDir, opts);
575
+ if (harness === null) {
576
+ throw new Error(`harness dir not found from ${resolve6(startDir)} — cannot resolve the ${fallback} dir (run \`mstar harness scaffold\`, pass opts.harnessDir, or set MSTAR_HARNESS_DIR)`);
577
+ }
578
+ const declared = mstarcDirOverride(harness, key);
579
+ return declared !== null ? declared : join6(resolve6(harness), fallback);
580
+ }
581
+ function resolveWorkflowDir(startDir = process.cwd(), opts = {}) {
582
+ return resolveHarnessSubdir(startDir, opts, "workflowDir", "workflows");
583
+ }
584
+ function resolveProjectDir(startDir = process.cwd(), opts = {}) {
585
+ return resolveHarnessSubdir(startDir, opts, "projectDir", "projects");
586
+ }
314
587
  var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
315
588
  # Principle: process stays local; results are shared with the team.
316
589
  # Default-ignore everything under .mstar/, then re-include the tracked results.
@@ -336,6 +609,13 @@ var GITIGNORE_PROCESS_ENTRIES = GITIGNORE_SNIPPET.split(`
336
609
  `).filter((line) => line.startsWith(".mstar/") || line.startsWith("!.mstar/")).map((line) => line.trim());
337
610
  var GITIGNORE_PROCESS_ENTRIES_AGENTS = GITIGNORE_SNIPPET_AGENTS.split(`
338
611
  `).filter((line) => line.startsWith(".agents/") || line.startsWith("!.agents/")).map((line) => line.trim());
612
+ function isDirectory(dir) {
613
+ try {
614
+ return statSync3(dir).isDirectory();
615
+ } catch {
616
+ return false;
617
+ }
618
+ }
339
619
 
340
620
  // src/audit.ts
341
621
  function violation2(severity, code, message, fix) {
@@ -633,12 +913,12 @@ function scanSecrets(files) {
633
913
  for (const file of files) {
634
914
  let text;
635
915
  try {
636
- text = readFileSync3(file, "utf8");
916
+ text = readFileSync5(file, "utf8");
637
917
  } catch {
638
918
  unreadableFiles++;
639
919
  continue;
640
920
  }
641
- const base = basename2(file);
921
+ const base = basename3(file);
642
922
  for (const entry of NEVER_COMMIT_FILENAMES) {
643
923
  if (entry.re.test(base))
644
924
  findings.push({ file, line: 1, type: entry.type });
@@ -690,21 +970,21 @@ var LOCKFILE_NAMES = [
690
970
  function rootLockfiles(root) {
691
971
  let entries;
692
972
  try {
693
- entries = readdirSync2(root, { withFileTypes: true });
973
+ entries = readdirSync4(root, { withFileTypes: true });
694
974
  } catch {
695
975
  return [];
696
976
  }
697
977
  const names = new Set(LOCKFILE_NAMES);
698
- const present = entries.filter((entry) => entry.isFile() && names.has(entry.name)).map((entry) => join4(root, entry.name));
978
+ const present = entries.filter((entry) => entry.isFile() && names.has(entry.name)).map((entry) => join7(root, entry.name));
699
979
  if (present.length === 0)
700
980
  return [];
701
981
  try {
702
- const tracked = new Set(execFileSync("git", ["ls-files", "-z", "--", "."], {
982
+ const tracked = new Set(execFileSync2("git", ["ls-files", "-z", "--", "."], {
703
983
  cwd: root,
704
984
  encoding: "utf8",
705
985
  stdio: ["ignore", "pipe", "ignore"]
706
986
  }).split("\x00").filter((f) => f !== ""));
707
- return present.filter((p) => tracked.has(basename2(p)));
987
+ return present.filter((p) => tracked.has(basename3(p)));
708
988
  } catch {
709
989
  return present;
710
990
  }
@@ -720,21 +1000,21 @@ function supplyChainChecks(repoRoot) {
720
1000
  findings.push({ kind: "lockfile-duplicate", file: lockfiles.map((f) => f.replace(`${repoRoot}/`, "")).join(", ") });
721
1001
  violations.push(violation2("medium", "audit.supply.lockfile-duplicate", `multiple lockfiles at ${repoRoot}: ${lockfiles.join(", ")}`, "keep exactly one lockfile per package manager"));
722
1002
  }
723
- const workflowsDir = join4(repoRoot, ".github", "workflows");
1003
+ const workflowsDir = join7(repoRoot, ".github", "workflows");
724
1004
  let wfEntries = [];
725
1005
  try {
726
- wfEntries = readdirSync2(workflowsDir, { withFileTypes: true });
1006
+ wfEntries = readdirSync4(workflowsDir, { withFileTypes: true });
727
1007
  } catch {
728
1008
  wfEntries = [];
729
1009
  }
730
1010
  for (const entry of wfEntries) {
731
1011
  if (!entry.isFile() || !/\.(?:ya?ml)$/.test(entry.name))
732
1012
  continue;
733
- const wfPath = join4(workflowsDir, entry.name);
1013
+ const wfPath = join7(workflowsDir, entry.name);
734
1014
  const relPath = `.github/workflows/${entry.name}`;
735
1015
  let text;
736
1016
  try {
737
- text = readFileSync3(wfPath, "utf8");
1017
+ text = readFileSync5(wfPath, "utf8");
738
1018
  } catch {
739
1019
  continue;
740
1020
  }
@@ -832,7 +1112,7 @@ function redactFinding(finding) {
832
1112
  };
833
1113
  }
834
1114
  function readPlanFileSummary(filePath) {
835
- const text = readFileSync3(filePath, "utf8");
1115
+ const text = readFileSync5(filePath, "utf8");
836
1116
  const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
837
1117
  const blocks = parseStatusBlocks(text);
838
1118
  return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
@@ -886,10 +1166,10 @@ function renderIndex(params) {
886
1166
  function scaffoldAuditPlan(outDir, findings, options = {}) {
887
1167
  const date = options.date ?? new Date().toISOString().slice(0, 10);
888
1168
  const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
889
- mkdirSync3(outDir, { recursive: true });
890
- const existingReadme = join4(outDir, "README.md");
891
- const carried = existsSync3(existingReadme) ? extractSecurityDispositionSections(readFileSync3(existingReadme, "utf8")) : { needsVerification: [], hardeningChecked: [] };
892
- const existing = readdirSync2(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
1169
+ mkdirSync4(outDir, { recursive: true });
1170
+ const existingReadme = join7(outDir, "README.md");
1171
+ const carried = existsSync5(existingReadme) ? extractSecurityDispositionSections(readFileSync5(existingReadme, "utf8")) : { needsVerification: [], hardeningChecked: [] };
1172
+ const existing = readdirSync4(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
893
1173
  let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
894
1174
  const redactedFindings = findings.map(redactFinding);
895
1175
  const written = [];
@@ -905,13 +1185,13 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
905
1185
  }
906
1186
  usedSlugs.add(slug);
907
1187
  const file = `${num}-${slug}.md`;
908
- writeFileSync3(join4(outDir, file), renderPlanFile(finding, plannedAt));
1188
+ writeFileSync4(join7(outDir, file), renderPlanFile(finding, plannedAt));
909
1189
  written.push(file);
910
1190
  next++;
911
1191
  }
912
1192
  const all = [...existing, ...written].sort();
913
1193
  const rows = all.map((file) => {
914
- const summary = readPlanFileSummary(join4(outDir, file));
1194
+ const summary = readPlanFileSummary(join7(outDir, file));
915
1195
  const fields = summary.fields;
916
1196
  return {
917
1197
  num: file.slice(0, 3),
@@ -945,7 +1225,7 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
945
1225
  });
946
1226
  const needsVerificationLines = options.needsVerification !== undefined ? options.needsVerification.map((nv) => `- ${escapeCell(redactText(nv.lead))}: ${escapeCell(redactText(nv.how))}${nv.evidence ? ` (${escapeCell(redactText(nv.evidence))})` : ""}`) : carried.needsVerification;
947
1227
  const hardeningCheckedLines = options.hardeningChecked !== undefined ? options.hardeningChecked.map((hc) => `- ${hc.kind}: ${escapeCell(redactText(hc.text))}`) : carried.hardeningChecked;
948
- writeFileSync3(join4(outDir, "README.md"), renderIndex({
1228
+ writeFileSync4(join7(outDir, "README.md"), renderIndex({
949
1229
  date,
950
1230
  repoName: options.repoName ?? "repo",
951
1231
  repoShortSha: options.repoShortSha ?? "unknown",
@@ -954,7 +1234,7 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
954
1234
  needsVerification: needsVerificationLines,
955
1235
  hardeningChecked: hardeningCheckedLines
956
1236
  }));
957
- return { outDir: resolve4(outDir), date, files: written, nextNumber: next };
1237
+ return { outDir: resolve7(outDir), date, files: written, nextNumber: next };
958
1238
  }
959
1239
  async function promoteAuditPlans(outDir, selected, options) {
960
1240
  if (selected.length === 0) {
@@ -963,19 +1243,19 @@ async function promoteAuditPlans(outDir, selected, options) {
963
1243
  if (typeof options.harnessDir !== "string" || options.harnessDir.trim() === "") {
964
1244
  throw new Error("promoteAuditPlans: options.harnessDir is required (must contain status.json + workflows/)");
965
1245
  }
966
- const workflowId = options.workflowId ?? basename2(resolve4(outDir));
1246
+ const workflowId = options.workflowId ?? basename3(resolve7(outDir));
967
1247
  assertSafePathComponent(workflowId, "workflow id");
968
- const harnessDir = resolve4(options.harnessDir);
969
- const statusPath = join4(harnessDir, "status.json");
970
- const workflowDir = join4(harnessDir, "workflows", workflowId);
971
- const snapshotPath = join4(workflowDir, WORKFLOW_SNAPSHOT_FILE);
1248
+ const harnessDir = resolve7(options.harnessDir);
1249
+ const statusPath = join7(harnessDir, "status.json");
1250
+ const workflowDir = join7(harnessDir, "workflows", workflowId);
1251
+ const snapshotPath = join7(workflowDir, WORKFLOW_SNAPSHOT_FILE);
972
1252
  const planFiles = resolveSelectedPlanFiles(outDir, selected);
973
1253
  const indexRows = readExecutionOrderIndex(outDir);
974
1254
  const plans = planFiles.map((planFile) => {
975
1255
  const stem = planFile.replace(/\.md$/, "");
976
1256
  const num = stem.slice(0, 3);
977
1257
  const indexRow = indexRows.get(num);
978
- const title = indexRow?.title ?? readPlanFileSummary(join4(outDir, planFile)).title;
1258
+ const title = indexRow?.title ?? readPlanFileSummary(join7(outDir, planFile)).title;
979
1259
  return {
980
1260
  id: stem,
981
1261
  title,
@@ -1003,18 +1283,18 @@ async function promoteAuditPlans(outDir, selected, options) {
1003
1283
  if (!entryGate.ok) {
1004
1284
  throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
1005
1285
  }
1006
- await withStatusWriteLock(statusPath, () => {
1007
- if (existsSync3(snapshotPath)) {
1286
+ await withStatusWriteLock(statusPath, async () => {
1287
+ if (existsSync5(snapshotPath)) {
1008
1288
  throw new Error(`refusing to promote audit plans: workflow ${JSON.stringify(workflowId)} already exists ` + `(snapshot at ${snapshotPath}) — re-promote would drop its registered plan rows; ` + `remove that workflow before promoting again`);
1009
1289
  }
1010
- mkdirSync3(workflowDir, { recursive: true });
1290
+ mkdirSync4(workflowDir, { recursive: true });
1011
1291
  try {
1012
1292
  writeJson(snapshotPath, snapshot);
1013
- registerWorkflowEntryLocked(statusPath, entry);
1293
+ await registerWorkflowEntryLocked(statusPath, entry);
1014
1294
  } catch (error) {
1015
1295
  rmSync(snapshotPath, { force: true });
1016
1296
  try {
1017
- if (readdirSync2(workflowDir).length === 0) {
1297
+ if (readdirSync4(workflowDir).length === 0) {
1018
1298
  rmdirSync2(workflowDir);
1019
1299
  }
1020
1300
  } catch {}
@@ -1025,7 +1305,7 @@ async function promoteAuditPlans(outDir, selected, options) {
1025
1305
  return { workflowId, snapshotPath };
1026
1306
  }
1027
1307
  function resolveSelectedPlanFiles(outDir, selected) {
1028
- const files = readdirSync2(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f)).sort();
1308
+ const files = readdirSync4(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f)).sort();
1029
1309
  const byNum = new Map;
1030
1310
  const byStem = new Map;
1031
1311
  for (const file of files) {
@@ -1040,7 +1320,7 @@ function resolveSelectedPlanFiles(outDir, selected) {
1040
1320
  for (const id of selected) {
1041
1321
  const file = byNum.get(id) ?? byStem.get(id) ?? byStem.get(id.replace(/\.md$/, ""));
1042
1322
  if (file === undefined) {
1043
- throw new Error(`promoteAuditPlans: selected plan ${JSON.stringify(id)} does not match any NNN-*.md file in ${resolve4(outDir)}`);
1323
+ throw new Error(`promoteAuditPlans: selected plan ${JSON.stringify(id)} does not match any NNN-*.md file in ${resolve7(outDir)}`);
1044
1324
  }
1045
1325
  if (!seen.has(file)) {
1046
1326
  seen.add(file);
@@ -1050,10 +1330,10 @@ function resolveSelectedPlanFiles(outDir, selected) {
1050
1330
  return resolved;
1051
1331
  }
1052
1332
  function readExecutionOrderIndex(outDir) {
1053
- const readmePath = join4(outDir, "README.md");
1333
+ const readmePath = join7(outDir, "README.md");
1054
1334
  let text;
1055
1335
  try {
1056
- text = readFileSync3(readmePath, "utf8");
1336
+ text = readFileSync5(readmePath, "utf8");
1057
1337
  } catch {
1058
1338
  return new Map;
1059
1339
  }
@@ -1079,7 +1359,7 @@ function readExecutionOrderIndex(outDir) {
1079
1359
  return rows;
1080
1360
  }
1081
1361
  function planFileRel(outDir, planFile) {
1082
- const resolved = resolve4(outDir);
1362
+ const resolved = resolve7(outDir);
1083
1363
  const parts = resolved.split(sep2);
1084
1364
  const plansIdx = parts.lastIndexOf("plans");
1085
1365
  if (plansIdx >= 0) {
@@ -172,13 +172,24 @@ export declare function assertTriIdentity(reviewerRoles: readonly string[]): Gat
172
172
  */
173
173
  export type ComposeDispatchGateOptions = {
174
174
  /**
175
- * Host role-binding field (omp task entry `agent` / opencode `subagent` /
176
- * cursor `subagent_type` / dsh `dispatchBinding`). The anti-recursion
177
- * precheck runs for EVERY Assignment-shaped text: an empty or omitted
178
- * binding fails closed (`dispatch.anti-recursion.empty-binding`) the
179
- * host cannot prove the dispatching agent is not recursing.
175
+ * Dispatching agent's OWN harness role (dsh `Config.dispatchBinding`).
176
+ * When non-empty, the anti-recursion precheck compares it against the
177
+ * Assignment's `Execute as` equality is self-recursion
178
+ * (`dispatch.anti-recursion.self-type`, critical). Leave unset on hosts
179
+ * whose tool-call event cannot report the dispatching agent's identity
180
+ * (omp/opencode/cursor): the precheck is skipped there and the NEVER red
181
+ * line stays prompt-level (mstar-dispatch-gates).
180
182
  */
181
- agent?: string;
183
+ caller?: string;
184
+ /**
185
+ * True on hosts whose contract declares the caller binding mandatory
186
+ * (dsh): an empty/missing `caller` then fails closed with
187
+ * `dispatch.anti-recursion.empty-binding` (critical) — the host could
188
+ * have declared the binding, so an absent one proves nothing and the
189
+ * dispatch must not proceed as if the NEVER red line held. Default
190
+ * `false`: the precheck is skipped entirely when `caller` is empty.
191
+ */
192
+ callerRequired?: boolean;
182
193
  /**
183
194
  * `false` for read-only roles (scout/explore) — skips the branch-form and
184
195
  * default-branch gates. Default: `true` (writable).
@@ -206,10 +217,14 @@ export type ComposeDispatchGateResult = GateResult & {
206
217
  * Assignment-shaped passes silently (`shaped: false`).
207
218
  * 2. `validateAssignmentFields` with `writable: false` when `opts.writable
208
219
  * === false` (read-only roles), else the writable default.
209
- * 3. Anti-recursion precheck — runs for every Assignment-shaped text; an
210
- * empty/omitted host binding fails closed (`dispatch.anti-recursion.
211
- * empty-binding`), a binding equal to `Execute as` is the existing
212
- * `dispatch.anti-recursion.self-type` critical.
220
+ * 3. Anti-recursion precheck — CALLER semantics (issue #156): runs only
221
+ * when the host supplies its own role binding (`caller`), or fails
222
+ * closed on an empty one when the host contract requires it
223
+ * (`callerRequired`, dsh). A caller equal to `Execute as` is the
224
+ * `dispatch.anti-recursion.self-type` critical; a required-but-empty
225
+ * caller is `dispatch.anti-recursion.empty-binding`. Target-only hosts
226
+ * (omp/opencode/cursor) skip the leg — their binding field carries the
227
+ * spawn TARGET, which equals `Execute as` on every compliant dispatch.
213
228
  * 4. Default-branch gate for writable text: the branch comes from the
214
229
  * Assignment's own branch forms (create-form name / Working branch /
215
230
  * Branch policy branch), else `$MSTAR_WORKING_BRANCH`; a well-formed
@@ -224,13 +239,17 @@ export type ComposeDispatchGateResult = GateResult & {
224
239
  export declare function composeDispatchGate(text: string, opts?: ComposeDispatchGateOptions): ComposeDispatchGateResult;
225
240
  /**
226
241
  * Anti-recursion precheck (NEVER red line, mstar-dispatch-gates § 承接方反递归
227
- * 红线): a leaf executor MUST NOT invoke a Task/subagent whose role-binding
228
- * field (`subagent_type` / `agent` / `subagent`) equals its own `Execute as`.
229
- * Comparison is case-insensitive after trim. An EMPTY binding fails closed
230
- * (`dispatch.anti-recursion.empty-binding`, critical): the host cannot
231
- * report which agent is calling, so anti-recursion cannot be proven the
232
- * dispatch must not proceed as if the NEVER red line held. An empty
233
- * `executeAs` with a set binding stays ok (field presence is
234
- * `validateAssignmentFields`' job, not this precheck).
242
+ * 红线): a leaf executor MUST NOT dispatch a Task/subagent whose target role
243
+ * (the new Assignment's `Execute as`) equals its OWN role. `subagentType`
244
+ * is therefore the DISPATCHING agent's own role binding (dsh
245
+ * `Config.dispatchBinding`) — never the spawn-target field (omp `agent` /
246
+ * opencode `subagent`), which equals `Execute as` on every compliant
247
+ * dispatch (issue #156). Comparison is case-insensitive after trim. An
248
+ * EMPTY binding fails closed (`dispatch.anti-recursion.empty-binding`,
249
+ * critical): the host cannot report which agent is calling, so
250
+ * anti-recursion cannot be proven — the dispatch must not proceed as if
251
+ * the NEVER red line held. An empty `executeAs` with a set binding stays
252
+ * ok (field presence is `validateAssignmentFields`' job, not this
253
+ * precheck).
235
254
  */
236
255
  export declare function antiRecursionPrecheck(subagentType: string, executeAs: string): GateResult;