@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 +326 -46
- package/dist/dispatch.d.ts +37 -18
- package/dist/engine.js +539 -203
- package/dist/index.d.ts +4 -2
- package/dist/project.d.ts +12 -5
- package/dist/prreview.d.ts +65 -0
- package/dist/status.d.ts +13 -1
- package/dist/store.d.ts +76 -0
- package/dist/store.test.d.ts +1 -0
- package/dist/workflow.d.ts +9 -1
- package/package.json +1 -1
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
|
|
4
|
-
import { basename as
|
|
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
|
|
119
|
-
import { dirname as
|
|
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 =
|
|
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 =
|
|
252
|
-
const snapshotPath =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
916
|
+
text = readFileSync5(file, "utf8");
|
|
637
917
|
} catch {
|
|
638
918
|
unreadableFiles++;
|
|
639
919
|
continue;
|
|
640
920
|
}
|
|
641
|
-
const base =
|
|
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 =
|
|
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) =>
|
|
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(
|
|
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(
|
|
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 =
|
|
1003
|
+
const workflowsDir = join7(repoRoot, ".github", "workflows");
|
|
724
1004
|
let wfEntries = [];
|
|
725
1005
|
try {
|
|
726
|
-
wfEntries =
|
|
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 =
|
|
1013
|
+
const wfPath = join7(workflowsDir, entry.name);
|
|
734
1014
|
const relPath = `.github/workflows/${entry.name}`;
|
|
735
1015
|
let text;
|
|
736
1016
|
try {
|
|
737
|
-
text =
|
|
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 =
|
|
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
|
-
|
|
890
|
-
const existingReadme =
|
|
891
|
-
const carried =
|
|
892
|
-
const existing =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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 ??
|
|
1246
|
+
const workflowId = options.workflowId ?? basename3(resolve7(outDir));
|
|
967
1247
|
assertSafePathComponent(workflowId, "workflow id");
|
|
968
|
-
const harnessDir =
|
|
969
|
-
const statusPath =
|
|
970
|
-
const workflowDir =
|
|
971
|
-
const snapshotPath =
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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 ${
|
|
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 =
|
|
1333
|
+
const readmePath = join7(outDir, "README.md");
|
|
1054
1334
|
let text;
|
|
1055
1335
|
try {
|
|
1056
|
-
text =
|
|
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 =
|
|
1362
|
+
const resolved = resolve7(outDir);
|
|
1083
1363
|
const parts = resolved.split(sep2);
|
|
1084
1364
|
const plansIdx = parts.lastIndexOf("plans");
|
|
1085
1365
|
if (plansIdx >= 0) {
|
package/dist/dispatch.d.ts
CHANGED
|
@@ -172,13 +172,24 @@ export declare function assertTriIdentity(reviewerRoles: readonly string[]): Gat
|
|
|
172
172
|
*/
|
|
173
173
|
export type ComposeDispatchGateOptions = {
|
|
174
174
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
-
|
|
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 —
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* `
|
|
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
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* dispatch
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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;
|