@mstar-harness/engine 3.2.6 → 3.3.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.d.ts +15 -1
- package/dist/audit.js +55 -34
- package/dist/engine.js +1366 -1300
- package/dist/index.d.ts +1 -1
- package/dist/path.d.ts +31 -5
- package/package.json +1 -1
package/dist/engine.js
CHANGED
|
@@ -170,327 +170,365 @@ function isAtOrBelow(dir, root) {
|
|
|
170
170
|
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
171
171
|
}
|
|
172
172
|
// src/path.ts
|
|
173
|
-
import { existsSync as
|
|
173
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync6, realpathSync as realpathSync2, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
174
174
|
import { execFileSync } from "node:child_process";
|
|
175
|
-
import { basename as basename2, dirname as
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
175
|
+
import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute3, join as join8, relative as relative2, resolve as resolve5 } from "node:path";
|
|
176
|
+
|
|
177
|
+
// src/project.ts
|
|
178
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, readdirSync as readdirSync3 } from "node:fs";
|
|
179
|
+
import { join as join7 } from "node:path";
|
|
180
|
+
|
|
181
|
+
// src/iteration.ts
|
|
182
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
|
|
183
|
+
import { join as join3 } from "node:path";
|
|
184
|
+
var COMPASS_STATUSES = ["active", "locked", "completed"];
|
|
185
|
+
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
186
|
+
var PLAN_STATUS_DONE = "Done";
|
|
187
|
+
var COMPASS_FILE = "delivery-compass.md";
|
|
188
|
+
var INDEX_README = "README.md";
|
|
189
|
+
var INDEX_HEADER = "| Iteration | Path | Description | Status |";
|
|
190
|
+
function typeName(value) {
|
|
191
|
+
if (value === null)
|
|
192
|
+
return "null";
|
|
193
|
+
if (Array.isArray(value))
|
|
194
|
+
return "array";
|
|
195
|
+
return typeof value;
|
|
196
|
+
}
|
|
197
|
+
function validateCompassShape(doc) {
|
|
198
|
+
const issues = [];
|
|
199
|
+
const expectString = (key, opts = {}) => {
|
|
200
|
+
const value = doc[key];
|
|
201
|
+
if (typeof value !== "string") {
|
|
202
|
+
issues.push({ path: [key], message: `expected string, received ${typeName(value)}` });
|
|
203
|
+
return;
|
|
192
204
|
}
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
205
|
+
if (opts.min !== undefined && value.length < opts.min) {
|
|
206
|
+
issues.push({ path: [key], message: `string must contain at least ${opts.min} character(s)` });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (opts.regex !== undefined && !opts.regex.test(value)) {
|
|
210
|
+
issues.push({ path: [key], message: `string must match ${opts.regex}` });
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
expectString("iteration_id", { min: 1 });
|
|
214
|
+
expectString("start_date", { regex: DATE_RE });
|
|
215
|
+
const status = doc.status;
|
|
216
|
+
if (typeof status !== "string" || !COMPASS_STATUSES.includes(status)) {
|
|
217
|
+
issues.push({
|
|
218
|
+
path: ["status"],
|
|
219
|
+
message: `expected one of ${COMPASS_STATUSES.map((s) => `'${s}'`).join(" | ")}, received ${typeName(status)}`
|
|
220
|
+
});
|
|
199
221
|
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
222
|
+
expectString("iteration_base_branch", { min: 1 });
|
|
223
|
+
expectString("target_branch", { min: 1 });
|
|
224
|
+
const plans = doc.plans;
|
|
225
|
+
if (plans !== undefined) {
|
|
226
|
+
if (!Array.isArray(plans)) {
|
|
227
|
+
issues.push({ path: ["plans"], message: `expected array, received ${typeName(plans)}` });
|
|
228
|
+
} else {
|
|
229
|
+
plans.forEach((entry, index) => {
|
|
230
|
+
if (typeof entry !== "string") {
|
|
231
|
+
issues.push({ path: ["plans", index], message: `expected string, received ${typeName(entry)}` });
|
|
232
|
+
} else if (entry.length < 1) {
|
|
233
|
+
issues.push({ path: ["plans", index], message: "string must contain at least 1 character(s)" });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
214
236
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
}
|
|
238
|
+
const end_date = doc.end_date;
|
|
239
|
+
if (end_date !== undefined) {
|
|
240
|
+
if (typeof end_date !== "string") {
|
|
241
|
+
issues.push({ path: ["end_date"], message: `expected string, received ${typeName(end_date)}` });
|
|
242
|
+
} else if (!DATE_RE.test(end_date)) {
|
|
243
|
+
issues.push({ path: ["end_date"], message: `string must match ${DATE_RE}` });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (issues.length > 0)
|
|
247
|
+
return { ok: false, issues };
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
data: {
|
|
251
|
+
iteration_id: doc.iteration_id,
|
|
252
|
+
start_date: doc.start_date,
|
|
253
|
+
status,
|
|
254
|
+
iteration_base_branch: doc.iteration_base_branch,
|
|
255
|
+
target_branch: doc.target_branch,
|
|
256
|
+
...plans !== undefined ? { plans } : {},
|
|
257
|
+
...end_date !== undefined ? { end_date } : {}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
218
260
|
}
|
|
219
|
-
function
|
|
220
|
-
|
|
221
|
-
return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
|
|
261
|
+
function violation(severity, code, message, fix) {
|
|
262
|
+
return { ok: false, severity, code, message, fix };
|
|
222
263
|
}
|
|
223
|
-
function
|
|
224
|
-
|
|
225
|
-
const rc = loadMstarc(dir, dirname3(dir));
|
|
226
|
-
const declared = rc?.config[key];
|
|
227
|
-
return declared ? resolve3(rc.dir, declared) : null;
|
|
264
|
+
function isPlainObject(value) {
|
|
265
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
228
266
|
}
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
267
|
+
function validateCompassFrontmatter(doc) {
|
|
268
|
+
if (!isPlainObject(doc)) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
violations: [
|
|
272
|
+
violation("medium", "COMPASS_INVALID_FIELD", "Compass frontmatter must be a YAML object with iteration_id / start_date / status / iteration_base_branch / target_branch (template: mstar-iteration §1.3)", "Fix the frontmatter of {ITERATION_DIR}/<iteration-id>/delivery-compass.md")
|
|
273
|
+
]
|
|
274
|
+
};
|
|
235
275
|
}
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
for (const candidate of candidates) {
|
|
246
|
-
if (isDirectory(candidate) && hasFiles(candidate))
|
|
247
|
-
return candidate;
|
|
276
|
+
const parsed = validateCompassShape(doc);
|
|
277
|
+
if (!parsed.ok) {
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
violations: parsed.issues.map((issue) => {
|
|
281
|
+
const field = issue.path.join(".") || "(root)";
|
|
282
|
+
return violation("medium", "COMPASS_INVALID_FIELD", `Compass frontmatter field '${field}' is invalid: ${issue.message}`, `Fix '${field}' in {ITERATION_DIR}/<iteration-id>/delivery-compass.md frontmatter (template: mstar-iteration §1.3)`);
|
|
283
|
+
})
|
|
284
|
+
};
|
|
248
285
|
}
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
286
|
+
const violations = [];
|
|
287
|
+
const { status, end_date } = parsed.data;
|
|
288
|
+
if (status === "completed" && end_date === undefined) {
|
|
289
|
+
violations.push(violation("high", "COMPASS_END_DATE_REQUIRED", "Compass frontmatter status is 'completed' but end_date is missing — end_date is required at iteration-close (mstar-iteration §3.4, template Fields guide)", "Add `end_date: YYYY-MM-DD` to the frontmatter"));
|
|
290
|
+
}
|
|
291
|
+
if (status !== "completed" && end_date !== undefined) {
|
|
292
|
+
violations.push(violation("medium", "COMPASS_END_DATE_NOT_ALLOWED", `Compass frontmatter sets end_date while status is '${status}' — end_date is only written at iteration-close (mstar-iteration §3.4)`, "Remove end_date until iteration-close"));
|
|
293
|
+
}
|
|
294
|
+
return { ok: violations.length === 0, violations };
|
|
253
295
|
}
|
|
254
|
-
function
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const dir = resolve3(harnessDir);
|
|
259
|
-
const name = basename2(dir);
|
|
260
|
-
if (name === ".plans" || name === "plans")
|
|
261
|
-
return dir;
|
|
262
|
-
return join3(dir, "plans");
|
|
296
|
+
function registeredPlanIds(compassDoc) {
|
|
297
|
+
if (!Array.isArray(compassDoc.plans))
|
|
298
|
+
return [];
|
|
299
|
+
return compassDoc.plans.filter((plan) => typeof plan === "string" && plan.length > 0);
|
|
263
300
|
}
|
|
264
|
-
function
|
|
265
|
-
if (
|
|
266
|
-
|
|
301
|
+
function findPlanRow(snapshotDoc, planId) {
|
|
302
|
+
if (!Array.isArray(snapshotDoc.plans))
|
|
303
|
+
return null;
|
|
304
|
+
for (const row of snapshotDoc.plans) {
|
|
305
|
+
if (!isPlainObject(row))
|
|
306
|
+
continue;
|
|
307
|
+
const rowId = typeof row.id === "string" ? row.id : typeof row.plan_id === "string" ? row.plan_id : null;
|
|
308
|
+
if (rowId === planId)
|
|
309
|
+
return row;
|
|
267
310
|
}
|
|
311
|
+
return null;
|
|
268
312
|
}
|
|
269
|
-
function
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
313
|
+
function entryPlansAllDone(snapshotDoc, registered) {
|
|
314
|
+
const violations = [];
|
|
315
|
+
if (registered.length === 0) {
|
|
316
|
+
violations.push(violation("medium", "COMPASS_NO_PLANS", "Compass frontmatter registers no plans — the all-plans-Done transition cannot be verified (mstar-iteration §1.3 / Phase transition gates)", "List the iteration's plan ids in the compass frontmatter `plans`"));
|
|
317
|
+
return violations;
|
|
318
|
+
}
|
|
319
|
+
for (const planId of registered) {
|
|
320
|
+
const row = findPlanRow(snapshotDoc, planId);
|
|
321
|
+
if (row === null) {
|
|
322
|
+
violations.push(violation("high", "PLAN_NOT_IN_STATUS", `Plan '${planId}' is registered in the compass frontmatter but has no row in the workflow snapshot plans[] (mstar-iteration §3.1 entry item 1)`, "Add the plan row to {HARNESS_DIR}/workflows/<id>/snapshot.json"));
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (row.status !== PLAN_STATUS_DONE) {
|
|
326
|
+
violations.push(violation("high", "PLAN_NOT_DONE", `Plan '${planId}' status is ${JSON.stringify(row.status)} in the workflow snapshot — all compass-registered plans must be 'Done' before iteration-close (mstar-iteration §3.1 entry item 1)`));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return violations;
|
|
281
330
|
}
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
if (declared !== null)
|
|
285
|
-
return declared;
|
|
286
|
-
return join3(resolve3(harnessDir), "knowledge");
|
|
331
|
+
function entryFrontmatterComplete(compassDoc) {
|
|
332
|
+
return validateCompassFrontmatter(compassDoc).violations;
|
|
287
333
|
}
|
|
288
|
-
function
|
|
289
|
-
const
|
|
290
|
-
if (
|
|
291
|
-
|
|
334
|
+
function exitFrontmatterClosed(compassDoc) {
|
|
335
|
+
const violations = [];
|
|
336
|
+
if (compassDoc.status !== "completed") {
|
|
337
|
+
violations.push(violation("high", "EXIT_STATUS_NOT_COMPLETED", `Compass frontmatter status must be 'completed' at close exit — current: ${JSON.stringify(compassDoc.status)} (mstar-iteration §3.4 / §3.5 exit item 4)`));
|
|
292
338
|
}
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return
|
|
339
|
+
const endDate = compassDoc.end_date;
|
|
340
|
+
if (typeof endDate !== "string" || !DATE_RE.test(endDate)) {
|
|
341
|
+
violations.push(violation("high", "EXIT_END_DATE_REQUIRED", "Compass frontmatter end_date (YYYY-MM-DD) is required when closing (mstar-iteration §3.4 / §3.5 exit item 4)"));
|
|
342
|
+
}
|
|
343
|
+
return violations;
|
|
298
344
|
}
|
|
299
|
-
function
|
|
300
|
-
|
|
345
|
+
function exitBranchCheck(opts) {
|
|
346
|
+
const violations = [];
|
|
347
|
+
const { currentBranch, specIntegrationBranch } = opts;
|
|
348
|
+
if (currentBranch === undefined || specIntegrationBranch === undefined) {
|
|
349
|
+
violations.push(violation("medium", "EXIT_BRANCH_UNVERIFIABLE", "Cannot verify the current branch is spec_integration_branch — missing currentBranch / specIntegrationBranch probe inputs (mstar-iteration §3.5 exit item 5)"));
|
|
350
|
+
} else if (currentBranch !== specIntegrationBranch) {
|
|
351
|
+
violations.push(violation("high", "EXIT_BRANCH_MISMATCH", `Current branch '${currentBranch}' is not the spec_integration_branch '${specIntegrationBranch}' (mstar-iteration §3.5 exit item 5)`));
|
|
352
|
+
}
|
|
353
|
+
return violations;
|
|
301
354
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const statusPath = join3(harnessDir, "status.json");
|
|
313
|
-
if (Object.keys(readJson(statusPath)).length === 0)
|
|
314
|
-
writeJson(statusPath, EMPTY_STATUS_TEMPLATE);
|
|
315
|
-
return harnessDir;
|
|
355
|
+
function exitPrBaseCheck(compassDoc, opts) {
|
|
356
|
+
const violations = [];
|
|
357
|
+
const target = compassDoc.target_branch;
|
|
358
|
+
const { prBaseBranch } = opts;
|
|
359
|
+
if (prBaseBranch === undefined) {
|
|
360
|
+
violations.push(violation("medium", "EXIT_PR_BASE_UNVERIFIABLE", "Cannot verify the PR base — missing prBaseBranch probe input (mstar-iteration §3.5 exit item 6)"));
|
|
361
|
+
} else if (typeof target !== "string" || prBaseBranch !== target) {
|
|
362
|
+
violations.push(violation("high", "EXIT_PR_BASE_MISMATCH", `PR base '${prBaseBranch}' must equal the compass target_branch '${String(target)}' — not an undocumented branch (mstar-iteration §3.5 exit item 6)`));
|
|
363
|
+
}
|
|
364
|
+
return violations;
|
|
316
365
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
function emitGitignoreSnippet(kind) {
|
|
343
|
-
if (kind === "agents")
|
|
344
|
-
return GITIGNORE_SNIPPET_AGENTS;
|
|
345
|
-
if (kind === "mstar")
|
|
346
|
-
return GITIGNORE_SNIPPET;
|
|
347
|
-
return `${GITIGNORE_SNIPPET}${GITIGNORE_SNIPPET_AGENTS}`;
|
|
348
|
-
}
|
|
349
|
-
function validateGitignore(root) {
|
|
350
|
-
const gitignorePath = join3(resolve3(root), ".gitignore");
|
|
351
|
-
const kind = detectHarnessKind(resolveHarnessDir(root));
|
|
352
|
-
let content;
|
|
353
|
-
try {
|
|
354
|
-
content = readFileSync3(gitignorePath, "utf8");
|
|
355
|
-
} catch {
|
|
356
|
-
return {
|
|
357
|
-
ok: false,
|
|
358
|
-
severity: "medium",
|
|
359
|
-
code: "gitignore.missing",
|
|
360
|
-
message: `no .gitignore found at ${gitignorePath}`,
|
|
361
|
-
fix: `append the canonical snippet (emitGitignoreSnippet(${kind ? `"${kind}"` : ""})) to ${gitignorePath}`
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
const lines = new Set(content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0));
|
|
365
|
-
const mstarMissing = GITIGNORE_PROCESS_ENTRIES.filter((entry) => !lines.has(entry));
|
|
366
|
-
const agentsMissing = GITIGNORE_PROCESS_ENTRIES_AGENTS.filter((entry) => !lines.has(entry));
|
|
367
|
-
let missing;
|
|
368
|
-
let label;
|
|
369
|
-
if (kind === "agents") {
|
|
370
|
-
missing = agentsMissing;
|
|
371
|
-
label = ".agents/ set";
|
|
372
|
-
} else if (kind === "mstar") {
|
|
373
|
-
missing = mstarMissing;
|
|
374
|
-
label = ".mstar/ set";
|
|
375
|
-
} else {
|
|
376
|
-
label = "either .mstar/ or .agents/ set";
|
|
377
|
-
missing = mstarMissing.length === 0 || agentsMissing.length === 0 ? [] : mstarMissing.length <= agentsMissing.length ? mstarMissing : agentsMissing;
|
|
378
|
-
}
|
|
379
|
-
if (missing.length > 0) {
|
|
380
|
-
return {
|
|
381
|
-
ok: false,
|
|
382
|
-
severity: "medium",
|
|
383
|
-
code: "gitignore.missing-entries",
|
|
384
|
-
message: `.gitignore at ${gitignorePath} is missing canonical harness ignore entries (${label}): ${missing.join(", ")}`,
|
|
385
|
-
fix: `append the canonical snippet (emitGitignoreSnippet(${kind ? `"${kind}"` : ""})) to ${gitignorePath}`
|
|
386
|
-
};
|
|
387
|
-
}
|
|
366
|
+
function evaluatePhaseGate(snapshotDoc, compassDoc, opts = {}) {
|
|
367
|
+
const registered = registeredPlanIds(compassDoc);
|
|
368
|
+
const entryViolations = [
|
|
369
|
+
...entryPlansAllDone(snapshotDoc, registered),
|
|
370
|
+
...entryFrontmatterComplete(compassDoc)
|
|
371
|
+
];
|
|
372
|
+
const exitViolations = [
|
|
373
|
+
...exitFrontmatterClosed(compassDoc),
|
|
374
|
+
...exitBranchCheck(opts),
|
|
375
|
+
...exitPrBaseCheck(compassDoc, opts)
|
|
376
|
+
];
|
|
377
|
+
const allPlansDone = registered.length > 0 && registered.every((planId) => {
|
|
378
|
+
const row = findPlanRow(snapshotDoc, planId);
|
|
379
|
+
return row !== null && row.status === PLAN_STATUS_DONE;
|
|
380
|
+
});
|
|
381
|
+
const entry = { ok: entryViolations.length === 0, violations: entryViolations };
|
|
382
|
+
const exit = { ok: exitViolations.length === 0, violations: exitViolations };
|
|
383
|
+
let transition;
|
|
384
|
+
if (!allPlansDone)
|
|
385
|
+
transition = "phase-2-execute";
|
|
386
|
+
else if (entry.ok && exit.ok)
|
|
387
|
+
transition = "phase-4-pr-delivery";
|
|
388
|
+
else
|
|
389
|
+
transition = "phase-3-close";
|
|
390
|
+
const gateBlocking = allPlansDone ? [...entryViolations, ...exitViolations] : [];
|
|
388
391
|
return {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
392
|
+
transition,
|
|
393
|
+
allPlansDone,
|
|
394
|
+
entry,
|
|
395
|
+
exit,
|
|
396
|
+
ok: gateBlocking.length === 0,
|
|
397
|
+
violations: gateBlocking
|
|
393
398
|
};
|
|
394
399
|
}
|
|
395
|
-
function
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
return
|
|
400
|
+
function pushCadenceProbe(ciRunning, reviewWaveActive) {
|
|
401
|
+
const violations = [];
|
|
402
|
+
if (ciRunning) {
|
|
403
|
+
violations.push(violation("high", "PUSH_BLOCKED_CI", "CI checks are still queued/in_progress on the current head — do not push until the wave completes (mstar-iteration §5.1a push gate 1)", "Wait for CI to settle, then push once with the whole local batch"));
|
|
404
|
+
}
|
|
405
|
+
if (reviewWaveActive) {
|
|
406
|
+
violations.push(violation("high", "PUSH_BLOCKED_REVIEW_WAVE", "An AI/bot review wave is still running on the current head — do not push until it settles (mstar-iteration §5.1a push gate 2)", "Wait for the review wave, then push once"));
|
|
407
|
+
}
|
|
408
|
+
return { ok: violations.length === 0, violations };
|
|
404
409
|
}
|
|
405
|
-
function
|
|
406
|
-
|
|
407
|
-
if (!harnessDir) {
|
|
410
|
+
function assertIndexRowObligations(iterationsDir) {
|
|
411
|
+
if (!existsSync2(iterationsDir)) {
|
|
408
412
|
return {
|
|
409
413
|
ok: false,
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
fix: "initialize the harness (scaffoldHarness) so plans land in {PLAN_DIR}"
|
|
414
|
+
violations: [
|
|
415
|
+
violation("high", "INDEX_ITERATIONS_DIR_MISSING", `{ITERATION_DIR} '${iterationsDir}' does not exist (mstar-iteration §1.4)`, "Create the iterations directory (path.resolveIterationDir)")
|
|
416
|
+
]
|
|
414
417
|
};
|
|
415
418
|
}
|
|
416
|
-
const
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
if (!inside) {
|
|
419
|
+
const iterationIds = readdirSync(iterationsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).filter((entry) => existsSync2(join3(iterationsDir, entry.name, COMPASS_FILE))).map((entry) => entry.name).sort();
|
|
420
|
+
const readmePath = join3(iterationsDir, INDEX_README);
|
|
421
|
+
if (!existsSync2(readmePath)) {
|
|
420
422
|
return {
|
|
421
423
|
ok: false,
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
fix: `write the plan under ${planDir}`
|
|
424
|
+
violations: [
|
|
425
|
+
violation("high", "INDEX_README_MISSING", `{ITERATION_DIR}/README.md does not exist — one row per iteration is required (mstar-iteration §1.4)`, `Create {ITERATION_DIR}/README.md with the header '${INDEX_HEADER}' and one row per iteration`)
|
|
426
|
+
]
|
|
426
427
|
};
|
|
427
428
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const canonicalRel = relative2(canonicalPlanDir, canonicalPlan);
|
|
433
|
-
const canonicalInside = canonicalRel === "" || !canonicalRel.startsWith("..") && !isAbsolute2(canonicalRel);
|
|
434
|
-
if (!canonicalInside) {
|
|
435
|
-
return {
|
|
436
|
-
ok: false,
|
|
437
|
-
severity: "high",
|
|
438
|
-
code: "plan-path.symlink-escape",
|
|
439
|
-
message: `plan file ${planAbs} resolves to ${canonicalPlan}, outside {PLAN_DIR} (${canonicalPlanDir})`,
|
|
440
|
-
fix: `write the plan under ${planDir}`
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
} catch {}
|
|
429
|
+
const violations = [];
|
|
430
|
+
const lines = readFileSync3(readmePath, "utf8").split(/\r?\n/);
|
|
431
|
+
if (!lines.some((line) => line.includes(INDEX_HEADER))) {
|
|
432
|
+
violations.push(violation("medium", "INDEX_HEADER_MISSING", `{ITERATION_DIR}/README.md lacks the table header '${INDEX_HEADER}' (mstar-iteration §1.4)`, "Add the header row on first creation"));
|
|
444
433
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
434
|
+
const indexed = new Set;
|
|
435
|
+
for (const line of lines) {
|
|
436
|
+
const match = line.match(/^\s*\|\s*`([^`]+)`\s*\|/);
|
|
437
|
+
if (match)
|
|
438
|
+
indexed.add(match[1].trim());
|
|
439
|
+
}
|
|
440
|
+
for (const id of iterationIds) {
|
|
441
|
+
if (!indexed.has(id)) {
|
|
442
|
+
violations.push(violation("medium", "INDEX_ROW_MISSING", `Iteration '${id}' has a delivery-compass.md but no index row in {ITERATION_DIR}/README.md — one row per iteration (mstar-iteration §1.4)`, `Add | \`${id}\` | [\`${id}/\`](${id}/) | <description> | <status> |`));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { ok: violations.length === 0, violations };
|
|
451
446
|
}
|
|
452
|
-
function
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
447
|
+
function parseCompassFrontmatter(filePath) {
|
|
448
|
+
return parseCompassFrontmatterText(readFileSync3(filePath, "utf8"), filePath);
|
|
449
|
+
}
|
|
450
|
+
function parseCompassFrontmatterText(content, filePath) {
|
|
451
|
+
const lines = content.split(/\r?\n/);
|
|
452
|
+
if (lines[0]?.trim() !== "---") {
|
|
453
|
+
throw new Error(`no YAML frontmatter fence in ${filePath} (expected first line "---")`);
|
|
457
454
|
}
|
|
455
|
+
const end = lines.indexOf("---", 1);
|
|
456
|
+
if (end === -1) {
|
|
457
|
+
throw new Error(`unterminated YAML frontmatter in ${filePath} (no closing "---")`);
|
|
458
|
+
}
|
|
459
|
+
const doc = {};
|
|
460
|
+
let listKey = null;
|
|
461
|
+
for (let i = 1;i < end; i += 1) {
|
|
462
|
+
const line = lines[i] ?? "";
|
|
463
|
+
if (!line.trim() || line.trim().startsWith("#"))
|
|
464
|
+
continue;
|
|
465
|
+
if (listKey !== null && /^\s*-\s+/.test(line)) {
|
|
466
|
+
const item = line.replace(/^\s*-\s+/, "").trim().replace(/^["']|["']$/g, "");
|
|
467
|
+
if (!Array.isArray(doc[listKey]))
|
|
468
|
+
doc[listKey] = [];
|
|
469
|
+
doc[listKey].push(item);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
listKey = null;
|
|
473
|
+
const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
474
|
+
if (!kv) {
|
|
475
|
+
throw new Error(`unsupported frontmatter line in ${filePath}: ${JSON.stringify(line)}`);
|
|
476
|
+
}
|
|
477
|
+
const value = kv[2].trim();
|
|
478
|
+
doc[kv[1]] = value === "" ? null : /^\[.*\]$/.test(value) ? parseFlowArray(value, filePath) : value.replace(/^["']|["']$/g, "");
|
|
479
|
+
listKey = value === "" ? kv[1] : null;
|
|
480
|
+
}
|
|
481
|
+
return doc;
|
|
458
482
|
}
|
|
459
|
-
function
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
483
|
+
function parseFlowArray(raw, filePath) {
|
|
484
|
+
const inner = raw.slice(1, -1);
|
|
485
|
+
if (/[[\]]/.test(inner)) {
|
|
486
|
+
throw new Error(`nested flow-style array in ${filePath}: ${JSON.stringify(raw)} — only flat scalar items are supported (e.g. [a, b])`);
|
|
487
|
+
}
|
|
488
|
+
let quote = null;
|
|
489
|
+
for (const ch of inner) {
|
|
490
|
+
if (ch === '"' || ch === "'") {
|
|
491
|
+
if (quote === null)
|
|
492
|
+
quote = ch;
|
|
493
|
+
else if (quote === ch)
|
|
494
|
+
quote = null;
|
|
495
|
+
} else if (ch === "," && quote !== null) {
|
|
496
|
+
throw new Error(`ambiguous flow-style array in ${filePath}: ${JSON.stringify(raw)} — quoted item containing comma cannot be split unambiguously (flat scalar items only)`);
|
|
468
497
|
}
|
|
469
|
-
return false;
|
|
470
|
-
} catch {
|
|
471
|
-
return false;
|
|
472
498
|
}
|
|
499
|
+
if (quote !== null) {
|
|
500
|
+
throw new Error(`unterminated ${quote} quote in flow-style array in ${filePath}: ${JSON.stringify(raw)}`);
|
|
501
|
+
}
|
|
502
|
+
const items = [];
|
|
503
|
+
for (const part of inner.split(",")) {
|
|
504
|
+
const item = part.trim().replace(/^["']|["']$/g, "");
|
|
505
|
+
if (item === "")
|
|
506
|
+
continue;
|
|
507
|
+
items.push(item);
|
|
508
|
+
}
|
|
509
|
+
return items;
|
|
473
510
|
}
|
|
511
|
+
|
|
474
512
|
// src/status.ts
|
|
475
|
-
import { existsSync as existsSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync
|
|
476
|
-
import { dirname as
|
|
513
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync } from "node:fs";
|
|
514
|
+
import { dirname as dirname4, join as join6, resolve as resolve4, sep } from "node:path";
|
|
477
515
|
|
|
478
516
|
// src/lease.ts
|
|
479
|
-
import { mkdirSync as
|
|
480
|
-
import { dirname as
|
|
517
|
+
import { mkdirSync as mkdirSync2, rmdirSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
518
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, resolve as resolve3 } from "node:path";
|
|
481
519
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
482
520
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
483
|
-
function
|
|
521
|
+
function isPlainObject2(value) {
|
|
484
522
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
485
523
|
}
|
|
486
|
-
function
|
|
524
|
+
function violation2(severity, code, message, fix) {
|
|
487
525
|
return { ok: false, severity, code, message, fix };
|
|
488
526
|
}
|
|
489
527
|
function validateNonEmptyString(violations, value, field, missingCode, invalidCode) {
|
|
490
528
|
if (value === undefined) {
|
|
491
|
-
violations.push(
|
|
529
|
+
violations.push(violation2("high", missingCode, `missing required field: ${field}`));
|
|
492
530
|
} else if (typeof value !== "string" || value.trim() === "") {
|
|
493
|
-
violations.push(
|
|
531
|
+
violations.push(violation2("medium", invalidCode, `${field} must be a non-empty string`));
|
|
494
532
|
}
|
|
495
533
|
}
|
|
496
534
|
var DATE_PART = String.raw`\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])`;
|
|
@@ -501,66 +539,66 @@ function isValidClaimedAt(value) {
|
|
|
501
539
|
}
|
|
502
540
|
function validateExecutionLease(lease) {
|
|
503
541
|
const violations = [];
|
|
504
|
-
if (!
|
|
542
|
+
if (!isPlainObject2(lease)) {
|
|
505
543
|
return {
|
|
506
544
|
ok: false,
|
|
507
545
|
violations: [
|
|
508
|
-
|
|
546
|
+
violation2("high", "lease.execution-lease.invalid", "execution_lease must be an object — null and tombstone objects are invalid; writers delete the key on release")
|
|
509
547
|
]
|
|
510
548
|
};
|
|
511
549
|
}
|
|
512
550
|
validateNonEmptyString(violations, lease.holder, "holder", "lease.execution-lease.missing-holder", "lease.execution-lease.invalid-holder");
|
|
513
551
|
if (lease.claimed_at === undefined) {
|
|
514
|
-
violations.push(
|
|
552
|
+
violations.push(violation2("high", "lease.execution-lease.missing-claimed-at", "missing required field: claimed_at"));
|
|
515
553
|
} else if (!isValidClaimedAt(lease.claimed_at)) {
|
|
516
|
-
violations.push(
|
|
554
|
+
violations.push(violation2("medium", "lease.execution-lease.invalid-claimed-at", "claimed_at must be an RFC 3339 UTC timestamp with explicit Z (e.g. 2026-07-22T02:30:00Z) or a YYYY-MM-DD date"));
|
|
517
555
|
}
|
|
518
556
|
if (lease.worktree_path === undefined) {
|
|
519
|
-
violations.push(
|
|
557
|
+
violations.push(violation2("high", "lease.execution-lease.missing-worktree-path", "missing required field: worktree_path"));
|
|
520
558
|
} else if (typeof lease.worktree_path !== "string" || lease.worktree_path.trim() === "") {
|
|
521
|
-
violations.push(
|
|
522
|
-
} else if (!
|
|
523
|
-
violations.push(
|
|
559
|
+
violations.push(violation2("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be a non-empty string"));
|
|
560
|
+
} else if (!isAbsolute2(lease.worktree_path)) {
|
|
561
|
+
violations.push(violation2("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be an absolute path — it identifies the dedicated feature-worktree root (and MUST differ from metadata.control_worktree_path)"));
|
|
524
562
|
}
|
|
525
563
|
validateNonEmptyString(violations, lease.working_branch, "working_branch", "lease.execution-lease.missing-working-branch", "lease.execution-lease.invalid-working-branch");
|
|
526
564
|
if (lease.session_label !== undefined && typeof lease.session_label !== "string") {
|
|
527
|
-
violations.push(
|
|
565
|
+
violations.push(violation2("medium", "lease.execution-lease.invalid-session-label", "session_label must be a string (display only — never used for ownership comparison)"));
|
|
528
566
|
}
|
|
529
567
|
return { ok: violations.length === 0, violations };
|
|
530
568
|
}
|
|
531
569
|
function validateIntegrationMergeLease(lease) {
|
|
532
570
|
const violations = [];
|
|
533
|
-
if (!
|
|
571
|
+
if (!isPlainObject2(lease)) {
|
|
534
572
|
return {
|
|
535
573
|
ok: false,
|
|
536
574
|
violations: [
|
|
537
|
-
|
|
575
|
+
violation2("high", "lease.merge-lease.invalid", "integration_merge_lease must be an object — absent means unclaimed; null and tombstone objects are invalid; writers delete the key on release")
|
|
538
576
|
]
|
|
539
577
|
};
|
|
540
578
|
}
|
|
541
579
|
validateNonEmptyString(violations, lease.holder, "holder", "lease.merge-lease.missing-holder", "lease.merge-lease.invalid-holder");
|
|
542
580
|
if (lease.claimed_at === undefined) {
|
|
543
|
-
violations.push(
|
|
581
|
+
violations.push(violation2("high", "lease.merge-lease.missing-claimed-at", "missing required field: claimed_at"));
|
|
544
582
|
} else if (!isValidClaimedAt(lease.claimed_at)) {
|
|
545
|
-
violations.push(
|
|
583
|
+
violations.push(violation2("medium", "lease.merge-lease.invalid-claimed-at", "claimed_at must be an RFC 3339 UTC timestamp with explicit Z (e.g. 2026-07-22T04:00:00Z) or a YYYY-MM-DD date"));
|
|
546
584
|
}
|
|
547
585
|
validateNonEmptyString(violations, lease.plan_id, "plan_id", "lease.merge-lease.missing-plan-id", "lease.merge-lease.invalid-plan-id");
|
|
548
586
|
validateNonEmptyString(violations, lease.source_branch, "source_branch", "lease.merge-lease.missing-source-branch", "lease.merge-lease.invalid-source-branch");
|
|
549
587
|
validateNonEmptyString(violations, lease.target_branch, "target_branch", "lease.merge-lease.missing-target-branch", "lease.merge-lease.invalid-target-branch");
|
|
550
588
|
if (lease.session_label !== undefined && typeof lease.session_label !== "string") {
|
|
551
|
-
violations.push(
|
|
589
|
+
violations.push(violation2("medium", "lease.merge-lease.invalid-session-label", "session_label must be a string (display only — never used for ownership comparison)"));
|
|
552
590
|
}
|
|
553
591
|
return { ok: violations.length === 0, violations };
|
|
554
592
|
}
|
|
555
593
|
function claimLease(row, holder, fields) {
|
|
556
594
|
const lease = row.execution_lease;
|
|
557
595
|
if (lease !== undefined) {
|
|
558
|
-
if (!
|
|
596
|
+
if (!isPlainObject2(lease)) {
|
|
559
597
|
return {
|
|
560
598
|
ok: false,
|
|
561
599
|
row,
|
|
562
600
|
violations: [
|
|
563
|
-
|
|
601
|
+
violation2("high", "lease.claim.tombstone", "execution_lease must be an object — null and tombstone objects are invalid; resolve the corrupt state before claiming")
|
|
564
602
|
]
|
|
565
603
|
};
|
|
566
604
|
}
|
|
@@ -569,7 +607,7 @@ function claimLease(row, holder, fields) {
|
|
|
569
607
|
ok: false,
|
|
570
608
|
row,
|
|
571
609
|
violations: [
|
|
572
|
-
|
|
610
|
+
violation2("high", "lease.claim.other-holder", `execution_lease held by ${JSON.stringify(lease.holder)} — no timestamp makes it stealable; Blocked unless the current-turn user explicitly overrides (then audit plans[].notes)`)
|
|
573
611
|
]
|
|
574
612
|
};
|
|
575
613
|
}
|
|
@@ -578,7 +616,7 @@ function claimLease(row, holder, fields) {
|
|
|
578
616
|
ok: false,
|
|
579
617
|
row,
|
|
580
618
|
violations: [
|
|
581
|
-
|
|
619
|
+
violation2("high", "lease.claim.verify-held-lease", `same holder but lease ${lease.worktree_path} @ ${lease.working_branch} does not match the Assignment ${fields.worktree_path} @ ${fields.working_branch} — verify-held-lease failed`)
|
|
582
620
|
]
|
|
583
621
|
};
|
|
584
622
|
}
|
|
@@ -589,7 +627,7 @@ function claimLease(row, holder, fields) {
|
|
|
589
627
|
ok: false,
|
|
590
628
|
row,
|
|
591
629
|
violations: [
|
|
592
|
-
|
|
630
|
+
violation2("high", "lease.claim.orphan", "plan is InProgress without an execution_lease — orphan: STOP, no writable dispatch until recovery (status-and-residuals.md § Orphan recovery); do not invent a lease")
|
|
593
631
|
]
|
|
594
632
|
};
|
|
595
633
|
}
|
|
@@ -598,7 +636,7 @@ function claimLease(row, holder, fields) {
|
|
|
598
636
|
ok: false,
|
|
599
637
|
row,
|
|
600
638
|
violations: [
|
|
601
|
-
|
|
639
|
+
violation2("high", "lease.claim.status", `claim requires status Todo or Blocked (got ${JSON.stringify(row.status)}) — claim-before-InProgress contract`)
|
|
602
640
|
]
|
|
603
641
|
};
|
|
604
642
|
}
|
|
@@ -619,12 +657,12 @@ function releaseLease(row, holder) {
|
|
|
619
657
|
if (row.execution_lease === undefined) {
|
|
620
658
|
return { ok: true, row, outcome: "released", violations: [] };
|
|
621
659
|
}
|
|
622
|
-
if (!
|
|
660
|
+
if (!isPlainObject2(row.execution_lease)) {
|
|
623
661
|
return {
|
|
624
662
|
ok: false,
|
|
625
663
|
row,
|
|
626
664
|
violations: [
|
|
627
|
-
|
|
665
|
+
violation2("high", "lease.release.tombstone", "execution_lease must be an object — null and tombstone objects are invalid; resolve the corrupt state before releasing")
|
|
628
666
|
]
|
|
629
667
|
};
|
|
630
668
|
}
|
|
@@ -633,7 +671,7 @@ function releaseLease(row, holder) {
|
|
|
633
671
|
ok: false,
|
|
634
672
|
row,
|
|
635
673
|
violations: [
|
|
636
|
-
|
|
674
|
+
violation2("high", "lease.release.other-holder", `execution_lease held by ${JSON.stringify(row.execution_lease.holder)} — release requires the same-session holder; a different holder must Blocked (no timestamp makes it stealable)`)
|
|
637
675
|
]
|
|
638
676
|
};
|
|
639
677
|
}
|
|
@@ -641,10 +679,10 @@ function releaseLease(row, holder) {
|
|
|
641
679
|
return { ok: true, row: rest, outcome: "released", violations: [] };
|
|
642
680
|
}
|
|
643
681
|
function sameHolderResume(lease, holder) {
|
|
644
|
-
return
|
|
682
|
+
return isPlainObject2(lease) && lease.holder === holder;
|
|
645
683
|
}
|
|
646
684
|
function canSteal(lease, holder, opts = {}) {
|
|
647
|
-
if (!
|
|
685
|
+
if (!isPlainObject2(lease) || lease.holder === holder)
|
|
648
686
|
return false;
|
|
649
687
|
return opts.userOverride === true;
|
|
650
688
|
}
|
|
@@ -659,14 +697,14 @@ function verifyPlanExecutionLease(row, planId) {
|
|
|
659
697
|
return {
|
|
660
698
|
ok: false,
|
|
661
699
|
violations: [
|
|
662
|
-
|
|
700
|
+
violation2("high", "lease.verify.orphan", "plan is InProgress without an execution_lease — orphan: STOP, no writable dispatch until recovery (status-and-residuals.md § Orphan recovery)")
|
|
663
701
|
]
|
|
664
702
|
};
|
|
665
703
|
}
|
|
666
704
|
return {
|
|
667
705
|
ok: false,
|
|
668
706
|
violations: [
|
|
669
|
-
|
|
707
|
+
violation2("high", "lease.verify.missing", `plan ${planId} has no execution_lease at the SSOT location plans[].execution_lease`)
|
|
670
708
|
]
|
|
671
709
|
};
|
|
672
710
|
}
|
|
@@ -678,7 +716,7 @@ var STATUS_WRITE_LOCKDIR = ".status-write.lockdir";
|
|
|
678
716
|
var LOCKDIR_HOLDER_PID = "holder.pid";
|
|
679
717
|
var heldLockDirs = new AsyncLocalStorage;
|
|
680
718
|
async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
681
|
-
const lockDir = join4(
|
|
719
|
+
const lockDir = join4(dirname3(resolve3(statusPath)), STATUS_WRITE_LOCKDIR);
|
|
682
720
|
const held = heldLockDirs.getStore();
|
|
683
721
|
if (held !== undefined && held.has(lockDir)) {
|
|
684
722
|
throw new Error(`${lockDir} is already held by this process in this async context — withStatusWriteLock is not reentrant; a nested acquisition on the same status.json is a bug`);
|
|
@@ -689,8 +727,8 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
|
689
727
|
let acquired = null;
|
|
690
728
|
for (;; ) {
|
|
691
729
|
try {
|
|
692
|
-
|
|
693
|
-
const st =
|
|
730
|
+
mkdirSync2(lockDir);
|
|
731
|
+
const st = statSync2(lockDir);
|
|
694
732
|
acquired = { dev: st.dev, ino: st.ino };
|
|
695
733
|
break;
|
|
696
734
|
} catch (error) {
|
|
@@ -712,7 +750,7 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
|
712
750
|
} finally {
|
|
713
751
|
owns.delete(lockDir);
|
|
714
752
|
try {
|
|
715
|
-
const current =
|
|
753
|
+
const current = statSync2(lockDir);
|
|
716
754
|
if (acquired !== null && current.dev === acquired.dev && current.ino === acquired.ino) {
|
|
717
755
|
try {
|
|
718
756
|
unlinkSync2(join4(lockDir, LOCKDIR_HOLDER_PID));
|
|
@@ -730,7 +768,7 @@ var REQUIRED_FIELDS = [
|
|
|
730
768
|
{ key: "delegation", label: "Delegation", code: "delegation" },
|
|
731
769
|
{ key: "taskCategory", label: "Task category", code: "task-category" }
|
|
732
770
|
];
|
|
733
|
-
function
|
|
771
|
+
function violation3(severity, code, message, fix) {
|
|
734
772
|
return { ok: false, severity, code, message, fix };
|
|
735
773
|
}
|
|
736
774
|
function parseAssignmentFields(assignmentText) {
|
|
@@ -780,11 +818,11 @@ function parseEnforcementFlag(text) {
|
|
|
780
818
|
}
|
|
781
819
|
function requireField(violations, value, label, code) {
|
|
782
820
|
if (value === undefined) {
|
|
783
|
-
const v =
|
|
821
|
+
const v = violation3("high", `assignment.field.missing-${code}`, `missing required Assignment field: ${label}`, `add "**${label}**: <value>" to the Assignment`);
|
|
784
822
|
v.aliases = [`assignment.presence.missing-${code}`];
|
|
785
823
|
violations.push(v);
|
|
786
824
|
} else if (value === "") {
|
|
787
|
-
const v =
|
|
825
|
+
const v = violation3("high", `assignment.field.invalid-${code}`, `${label} must be non-empty`, `fill in "**${label}**: <value>"`);
|
|
788
826
|
v.aliases = [`assignment.presence.missing-${code}`];
|
|
789
827
|
violations.push(v);
|
|
790
828
|
}
|
|
@@ -843,20 +881,20 @@ function validateAssignmentFields(assignmentText, opts = {}) {
|
|
|
843
881
|
const formCount = Number(workingPresent) + Number(policyPresent);
|
|
844
882
|
const forms = parseAssignmentBranchForms(assignmentText);
|
|
845
883
|
if (formCount === 0) {
|
|
846
|
-
violations.push(
|
|
884
|
+
violations.push(violation3("high", "assignment.field.branch-missing", "writable assignment must contain exactly one branch form", `add exactly one of: ${BRANCH_FORMS_HINT}`));
|
|
847
885
|
} else if (formCount > 1) {
|
|
848
|
-
violations.push(
|
|
886
|
+
violations.push(violation3("high", "assignment.field.branch-multiple", `writable assignment contains ${formCount} branch forms (Working branch + Branch policy) — exactly one required`, `keep exactly one of: ${BRANCH_FORMS_HINT}`));
|
|
849
887
|
} else if (workingPresent) {
|
|
850
888
|
const create = forms.createForm;
|
|
851
889
|
if (create !== undefined && (create.base === undefined || create.base.trim() === "" || create.name.trim() === "")) {
|
|
852
|
-
violations.push(
|
|
890
|
+
violations.push(violation3("high", "assignment.field.branch-missing-base", `create-form Working branch is incomplete: "${fields.workingBranch}" (expected "create <new-branch> from <base>")`, "write both the new branch name and the ancestor branch after `from` (main / existing feature branch / remote-tracking branch / `current`)"));
|
|
853
891
|
}
|
|
854
892
|
} else if (policyPresent) {
|
|
855
893
|
const direct = forms.directOn;
|
|
856
894
|
if (direct === undefined) {
|
|
857
|
-
violations.push(
|
|
895
|
+
violations.push(violation3("high", "assignment.field.branch-policy-missing-branch", `unparseable Branch policy: "${fields.branchPolicy}" (expected "direct on <branch> — <reason>")`, "start the field with `direct on <branch>`"));
|
|
858
896
|
} else if (direct.reason === "") {
|
|
859
|
-
violations.push(
|
|
897
|
+
violations.push(violation3("high", "assignment.field.branch-policy-missing-reason", `Branch policy "direct on ${direct.branch}" is missing the reason`, 'append "— <reason>" after the branch name'));
|
|
860
898
|
}
|
|
861
899
|
}
|
|
862
900
|
}
|
|
@@ -867,7 +905,7 @@ function assertDefaultBranchProtected(branch, opts = {}) {
|
|
|
867
905
|
const violations = [];
|
|
868
906
|
const normalized = branch.trim();
|
|
869
907
|
if (normalized !== "" && defaultBranches.includes(normalized) && opts.directOnException !== true) {
|
|
870
|
-
violations.push(
|
|
908
|
+
violations.push(violation3("high", "dispatch.default-branch.protected", `writable work on default protected branch "${normalized}" requires an explicit direct-on exception`, `add "Branch policy: direct on ${normalized} — <reason>" to the Assignment, or use a feature branch`));
|
|
871
909
|
}
|
|
872
910
|
return { ok: violations.length === 0, violations };
|
|
873
911
|
}
|
|
@@ -876,7 +914,7 @@ function executionModeToN(executionMode, opts = {}) {
|
|
|
876
914
|
const mode = executionMode.trim().toLowerCase().split(/\s+/)[0] ?? "";
|
|
877
915
|
let n;
|
|
878
916
|
if (mode === "") {
|
|
879
|
-
violations.push(
|
|
917
|
+
violations.push(violation3("high", "dispatch.execution-mode.missing", "missing required Assignment field: Execution mode", 'add "**Execution mode**: sdd | inline | targeted"'));
|
|
880
918
|
} else if (mode === "sdd") {
|
|
881
919
|
n = 3;
|
|
882
920
|
} else if (mode === "inline") {
|
|
@@ -884,14 +922,14 @@ function executionModeToN(executionMode, opts = {}) {
|
|
|
884
922
|
} else if (mode === "targeted") {
|
|
885
923
|
const seats = [...new Set((opts.seats ?? []).map((s) => s.trim()).filter((s) => s !== ""))];
|
|
886
924
|
if (seats.length === 0) {
|
|
887
|
-
violations.push(
|
|
925
|
+
violations.push(violation3("high", "dispatch.execution-mode.missing-seats", 'execution mode "targeted" requires listed reviewer seats', 'add "QC re-review: targeted — reviewers: <role-id>, …" to the Assignment and pass the seats'));
|
|
888
926
|
} else if (seats.length > 3) {
|
|
889
|
-
violations.push(
|
|
927
|
+
violations.push(violation3("high", "dispatch.execution-mode.too-many-seats", `execution mode "targeted" lists ${seats.length} reviewer seats — at most 3 (targeted re-review seats are the tri seats, N = 1–3)`, "list at most three reviewer seats for the targeted re-review"));
|
|
890
928
|
} else {
|
|
891
929
|
n = seats.length;
|
|
892
930
|
}
|
|
893
931
|
} else {
|
|
894
|
-
violations.push(
|
|
932
|
+
violations.push(violation3("high", "dispatch.execution-mode.unknown", `unknown execution mode "${executionMode.trim()}" (expected sdd | inline | targeted)`, "fix the Execution mode field"));
|
|
895
933
|
}
|
|
896
934
|
return n === undefined ? { ok: false, violations } : { ok: true, violations, n };
|
|
897
935
|
}
|
|
@@ -905,7 +943,7 @@ function assertTriIdentity(reviewerRoles) {
|
|
|
905
943
|
return {
|
|
906
944
|
ok: false,
|
|
907
945
|
violations: [
|
|
908
|
-
|
|
946
|
+
violation3("high", "dispatch.tri-identity.invalid", `tri-review initial wave must be exactly qc-specialist / qc-specialist-2 / qc-specialist-3, got: ${got}`, "dispatch qc-specialist, qc-specialist-2 and qc-specialist-3 for the initial wave")
|
|
909
947
|
]
|
|
910
948
|
};
|
|
911
949
|
}
|
|
@@ -951,7 +989,7 @@ function antiRecursionPrecheck(subagentType, executeAs) {
|
|
|
951
989
|
return {
|
|
952
990
|
ok: false,
|
|
953
991
|
violations: [
|
|
954
|
-
|
|
992
|
+
violation3("critical", "dispatch.anti-recursion.empty-binding", `empty host role binding — the host cannot report which agent is calling, so anti-recursion cannot be proven (a dispatch could silently recurse)`, "set the host role-binding field (omp task entry `agent` / opencode `subagent` / cursor `subagent_type` / dsh `dispatchBinding`) before dispatching")
|
|
955
993
|
]
|
|
956
994
|
};
|
|
957
995
|
}
|
|
@@ -959,7 +997,7 @@ function antiRecursionPrecheck(subagentType, executeAs) {
|
|
|
959
997
|
return {
|
|
960
998
|
ok: false,
|
|
961
999
|
violations: [
|
|
962
|
-
|
|
1000
|
+
violation3("critical", "dispatch.anti-recursion.self-type", `recursive dispatch refused: role binding "${subagentType}" equals Execute as "${executeAs}" (leaf executors must not re-invoke their own role)`, "complete the work in this session, or return Blocked to project-manager")
|
|
963
1001
|
]
|
|
964
1002
|
};
|
|
965
1003
|
}
|
|
@@ -967,51 +1005,51 @@ function antiRecursionPrecheck(subagentType, executeAs) {
|
|
|
967
1005
|
}
|
|
968
1006
|
|
|
969
1007
|
// src/workflow.ts
|
|
970
|
-
import { mkdirSync as
|
|
1008
|
+
import { mkdirSync as mkdirSync3 } from "node:fs";
|
|
971
1009
|
import { join as join5 } from "node:path";
|
|
972
1010
|
var WORKFLOW_SNAPSHOT_FILE = "snapshot.json";
|
|
973
1011
|
var WORKFLOW_LIFECYCLE_STATUSES = ["running", "paused", "completed", "failed", "stopped"];
|
|
974
1012
|
var WORKFLOW_TERMINAL_STATUSES = ["completed", "failed", "stopped"];
|
|
975
1013
|
var WORKFLOW_LIFECYCLE_TYPES = ["plan", "iteration"];
|
|
976
|
-
function
|
|
1014
|
+
function isPlainObject3(value) {
|
|
977
1015
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
978
1016
|
}
|
|
979
|
-
function
|
|
1017
|
+
function violation4(severity, code, message, fix) {
|
|
980
1018
|
return { ok: false, severity, code, message, fix };
|
|
981
1019
|
}
|
|
982
1020
|
function validateNonEmptyString2(violations, value, field, missingCode, invalidCode) {
|
|
983
1021
|
if (value === undefined) {
|
|
984
|
-
violations.push(
|
|
1022
|
+
violations.push(violation4("high", missingCode, `missing required field: ${field}`));
|
|
985
1023
|
} else if (typeof value !== "string" || value.trim() === "") {
|
|
986
|
-
violations.push(
|
|
1024
|
+
violations.push(violation4("medium", invalidCode, `${field} must be a non-empty string`));
|
|
987
1025
|
}
|
|
988
1026
|
}
|
|
989
1027
|
function validateWorkflowSnapshot(doc) {
|
|
990
1028
|
const violations = [];
|
|
991
|
-
if (!
|
|
1029
|
+
if (!isPlainObject3(doc)) {
|
|
992
1030
|
return {
|
|
993
1031
|
ok: false,
|
|
994
|
-
violations: [
|
|
1032
|
+
violations: [violation4("high", "workflow.snapshot.invalid", "workflow snapshot must be an object")]
|
|
995
1033
|
};
|
|
996
1034
|
}
|
|
997
1035
|
if (doc.schema_version === undefined) {
|
|
998
|
-
violations.push(
|
|
1036
|
+
violations.push(violation4("high", "workflow.snapshot.missing-schema-version", "missing required field: schema_version"));
|
|
999
1037
|
} else if (doc.schema_version !== 1) {
|
|
1000
|
-
violations.push(
|
|
1038
|
+
violations.push(violation4("high", "workflow.snapshot.invalid-schema-version", `schema_version must be 1 — got ${JSON.stringify(doc.schema_version)} (version is reserved for the root file discriminator)`));
|
|
1001
1039
|
}
|
|
1002
1040
|
if (doc.version !== undefined) {
|
|
1003
|
-
violations.push(
|
|
1041
|
+
violations.push(violation4("medium", "workflow.snapshot.reserved-version", `top-level version is reserved for the root status.json discriminator — snapshots use schema_version; remove the version key (got ${JSON.stringify(doc.version)})`, "remove the version key from the snapshot"));
|
|
1004
1042
|
}
|
|
1005
1043
|
validateNonEmptyString2(violations, doc.id, "id", "workflow.snapshot.missing-id", "workflow.snapshot.invalid-id");
|
|
1006
1044
|
if (doc.type === undefined) {
|
|
1007
|
-
violations.push(
|
|
1045
|
+
violations.push(violation4("high", "workflow.snapshot.missing-type", "missing required field: type"));
|
|
1008
1046
|
} else if (typeof doc.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(doc.type)) {
|
|
1009
|
-
violations.push(
|
|
1047
|
+
violations.push(violation4("medium", "workflow.snapshot.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(doc.type)}`));
|
|
1010
1048
|
}
|
|
1011
1049
|
if (doc.status === undefined) {
|
|
1012
|
-
violations.push(
|
|
1050
|
+
violations.push(violation4("high", "workflow.snapshot.missing-status", "missing required field: status"));
|
|
1013
1051
|
} else if (typeof doc.status !== "string" || !WORKFLOW_LIFECYCLE_STATUSES.includes(doc.status)) {
|
|
1014
|
-
violations.push(
|
|
1052
|
+
violations.push(violation4("medium", "workflow.snapshot.invalid-status", `status must be one of ${WORKFLOW_LIFECYCLE_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
|
|
1015
1053
|
}
|
|
1016
1054
|
validateNonEmptyString2(violations, doc.started_at, "started_at", "workflow.snapshot.missing-started-at", "workflow.snapshot.invalid-started-at");
|
|
1017
1055
|
validateNonEmptyString2(violations, doc.updated_at, "updated_at", "workflow.snapshot.missing-updated-at", "workflow.snapshot.invalid-updated-at");
|
|
@@ -1019,35 +1057,35 @@ function validateWorkflowSnapshot(doc) {
|
|
|
1019
1057
|
validateNonEmptyString2(violations, doc.ended_at, "ended_at", "workflow.snapshot.missing-ended-at", "workflow.snapshot.invalid-ended-at");
|
|
1020
1058
|
}
|
|
1021
1059
|
if (doc.phase !== undefined && typeof doc.phase !== "string") {
|
|
1022
|
-
violations.push(
|
|
1060
|
+
violations.push(violation4("medium", "workflow.snapshot.invalid-phase", "phase must be a string (free-form phase machine label)"));
|
|
1023
1061
|
}
|
|
1024
1062
|
if (doc.plans === undefined) {
|
|
1025
|
-
violations.push(
|
|
1063
|
+
violations.push(violation4("high", "workflow.snapshot.missing-plans", "missing required field: plans"));
|
|
1026
1064
|
} else if (!Array.isArray(doc.plans)) {
|
|
1027
|
-
violations.push(
|
|
1065
|
+
violations.push(violation4("high", "workflow.snapshot.invalid-plans", "plans must be an array of legacy plan rows"));
|
|
1028
1066
|
} else {
|
|
1029
1067
|
for (const row of doc.plans) {
|
|
1030
1068
|
violations.push(...validatePlanRow(row).violations);
|
|
1031
|
-
if (
|
|
1069
|
+
if (isPlainObject3(row) && row.execution_lease !== undefined) {
|
|
1032
1070
|
violations.push(...validateExecutionLease(row.execution_lease).violations);
|
|
1033
1071
|
}
|
|
1034
1072
|
}
|
|
1035
1073
|
}
|
|
1036
1074
|
if (doc.execution_policy !== undefined) {
|
|
1037
|
-
if (!
|
|
1038
|
-
violations.push(
|
|
1075
|
+
if (!isPlainObject3(doc.execution_policy)) {
|
|
1076
|
+
violations.push(violation4("medium", "workflow.snapshot.invalid-execution-policy", "execution_policy must be an object"));
|
|
1039
1077
|
}
|
|
1040
1078
|
}
|
|
1041
1079
|
if (doc.integration_merge_lease !== undefined) {
|
|
1042
1080
|
violations.push(...validateIntegrationMergeLease(doc.integration_merge_lease).violations);
|
|
1043
1081
|
}
|
|
1044
1082
|
if (doc.branch !== undefined) {
|
|
1045
|
-
if (!
|
|
1046
|
-
violations.push(
|
|
1083
|
+
if (!isPlainObject3(doc.branch)) {
|
|
1084
|
+
violations.push(violation4("medium", "workflow.snapshot.invalid-branch", "branch must be an object"));
|
|
1047
1085
|
} else {
|
|
1048
1086
|
for (const key of ["base", "integration", "target"]) {
|
|
1049
1087
|
if (doc.branch[key] !== undefined && (typeof doc.branch[key] !== "string" || doc.branch[key].trim() === "")) {
|
|
1050
|
-
violations.push(
|
|
1088
|
+
violations.push(violation4("medium", "workflow.snapshot.invalid-branch", `branch.${key} must be a non-empty string`));
|
|
1051
1089
|
}
|
|
1052
1090
|
}
|
|
1053
1091
|
}
|
|
@@ -1055,8 +1093,8 @@ function validateWorkflowSnapshot(doc) {
|
|
|
1055
1093
|
if (doc.control_worktree_path !== undefined) {
|
|
1056
1094
|
validateNonEmptyString2(violations, doc.control_worktree_path, "control_worktree_path", "workflow.snapshot.missing-control-worktree-path", "workflow.snapshot.invalid-control-worktree-path");
|
|
1057
1095
|
}
|
|
1058
|
-
if (doc.legacy_metadata !== undefined && !
|
|
1059
|
-
violations.push(
|
|
1096
|
+
if (doc.legacy_metadata !== undefined && !isPlainObject3(doc.legacy_metadata)) {
|
|
1097
|
+
violations.push(violation4("medium", "workflow.snapshot.invalid-legacy-metadata", "legacy_metadata must be an object"));
|
|
1060
1098
|
}
|
|
1061
1099
|
if (doc.compass_ref !== undefined) {
|
|
1062
1100
|
validateNonEmptyString2(violations, doc.compass_ref, "compass_ref", "workflow.snapshot.missing-compass-ref", "workflow.snapshot.invalid-compass-ref");
|
|
@@ -1064,17 +1102,17 @@ function validateWorkflowSnapshot(doc) {
|
|
|
1064
1102
|
const terminal = typeof doc.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(doc.status);
|
|
1065
1103
|
if (terminal) {
|
|
1066
1104
|
if (doc.ended_at === undefined) {
|
|
1067
|
-
violations.push(
|
|
1105
|
+
violations.push(violation4("high", "workflow.snapshot.missing-ended-at", `terminal status ${JSON.stringify(doc.status)} requires ended_at — a terminal snapshot must record when the lifecycle ended`));
|
|
1068
1106
|
}
|
|
1069
1107
|
if (Array.isArray(doc.plans)) {
|
|
1070
1108
|
for (const row of doc.plans) {
|
|
1071
|
-
if (
|
|
1072
|
-
violations.push(
|
|
1109
|
+
if (isPlainObject3(row) && row.execution_lease !== undefined) {
|
|
1110
|
+
violations.push(violation4("high", "workflow.snapshot.terminal-dangling-execution-lease", `terminal snapshot must not carry a row execution_lease (dangling lease) — release every lease before the lifecycle ends`));
|
|
1073
1111
|
}
|
|
1074
1112
|
}
|
|
1075
1113
|
}
|
|
1076
1114
|
if (doc.integration_merge_lease !== undefined) {
|
|
1077
|
-
violations.push(
|
|
1115
|
+
violations.push(violation4("high", "workflow.snapshot.terminal-dangling-merge-lease", "terminal snapshot must not carry integration_merge_lease (dangling lease) — release the merge lease before the lifecycle ends"));
|
|
1078
1116
|
}
|
|
1079
1117
|
}
|
|
1080
1118
|
return { ok: violations.length === 0, violations };
|
|
@@ -1086,21 +1124,21 @@ async function writeWorkflowSnapshot(snapshot, dir) {
|
|
|
1086
1124
|
throw new Error(`refusing to write invalid workflow snapshot: ${detail}`);
|
|
1087
1125
|
}
|
|
1088
1126
|
const snapshotPath = join5(dir, WORKFLOW_SNAPSHOT_FILE);
|
|
1089
|
-
|
|
1127
|
+
mkdirSync3(dir, { recursive: true });
|
|
1090
1128
|
await withStatusWriteLock(snapshotPath, () => {
|
|
1091
1129
|
writeJson(snapshotPath, snapshot);
|
|
1092
1130
|
});
|
|
1093
1131
|
}
|
|
1094
1132
|
|
|
1095
1133
|
// src/status.ts
|
|
1096
|
-
var
|
|
1134
|
+
var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
|
|
1097
1135
|
var PLAN_STATUSES = ["Todo", "InProgress", "InReview", "Blocked", "Done"];
|
|
1098
1136
|
var RESIDUAL_DECISIONS = ["defer", "accept", "risk-accepted"];
|
|
1099
1137
|
var RESIDUAL_LIFECYCLES = ["open", "resolved", "waived", "superseded", "duplicate"];
|
|
1100
|
-
function
|
|
1138
|
+
function isPlainObject4(value) {
|
|
1101
1139
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1102
1140
|
}
|
|
1103
|
-
function
|
|
1141
|
+
function violation5(severity, code, message, fix) {
|
|
1104
1142
|
return { ok: false, severity, code, message, fix };
|
|
1105
1143
|
}
|
|
1106
1144
|
function todayString() {
|
|
@@ -1123,19 +1161,19 @@ function isOpenResidual(entry) {
|
|
|
1123
1161
|
}
|
|
1124
1162
|
function validateNonEmptyString3(violations, value, field, missingCode, invalidCode) {
|
|
1125
1163
|
if (value === undefined) {
|
|
1126
|
-
violations.push(
|
|
1164
|
+
violations.push(violation5("high", missingCode, `missing required field: ${field}`));
|
|
1127
1165
|
} else if (typeof value !== "string" || value.trim() === "") {
|
|
1128
|
-
violations.push(
|
|
1166
|
+
violations.push(violation5("medium", invalidCode, `${field} must be a non-empty string`));
|
|
1129
1167
|
}
|
|
1130
1168
|
}
|
|
1131
1169
|
function validatePlanRow(row) {
|
|
1132
1170
|
const violations = [];
|
|
1133
|
-
if (!
|
|
1134
|
-
return { ok: false, violations: [
|
|
1171
|
+
if (!isPlainObject4(row)) {
|
|
1172
|
+
return { ok: false, violations: [violation5("high", "status.plan-row.invalid", "plan row must be an object")] };
|
|
1135
1173
|
}
|
|
1136
1174
|
const { id, plan_id: planId, title, file, status, metadata, execution_lease } = row;
|
|
1137
1175
|
if (id === undefined && planId === undefined) {
|
|
1138
|
-
violations.push(
|
|
1176
|
+
violations.push(violation5("high", "status.plan-row.missing-id", "missing required field: id (or legacy plan_id)"));
|
|
1139
1177
|
} else {
|
|
1140
1178
|
if (id !== undefined) {
|
|
1141
1179
|
validateNonEmptyString3(violations, id, "id", "status.plan-row.missing-id", "status.plan-row.invalid-id");
|
|
@@ -1144,31 +1182,31 @@ function validatePlanRow(row) {
|
|
|
1144
1182
|
validateNonEmptyString3(violations, planId, "plan_id", "status.plan-row.missing-plan-id", "status.plan-row.invalid-plan-id");
|
|
1145
1183
|
}
|
|
1146
1184
|
if (id !== undefined && planId !== undefined && id !== planId) {
|
|
1147
|
-
violations.push(
|
|
1185
|
+
violations.push(violation5("medium", "status.plan-row.dual-id", "row has both id and plan_id with different values — write one canonical key (prefer id)"));
|
|
1148
1186
|
}
|
|
1149
1187
|
}
|
|
1150
1188
|
validateNonEmptyString3(violations, title, "title", "status.plan-row.missing-title", "status.plan-row.invalid-title");
|
|
1151
1189
|
validateNonEmptyString3(violations, file, "file", "status.plan-row.missing-file", "status.plan-row.invalid-file");
|
|
1152
1190
|
if (status === undefined) {
|
|
1153
|
-
violations.push(
|
|
1191
|
+
violations.push(violation5("high", "status.plan-row.missing-status", "missing required field: status"));
|
|
1154
1192
|
} else if (typeof status !== "string" || !PLAN_STATUSES.includes(status)) {
|
|
1155
|
-
violations.push(
|
|
1193
|
+
violations.push(violation5("medium", "status.plan-row.invalid-status", `status must be one of ${PLAN_STATUSES.join(" | ")} — got ${JSON.stringify(status)}`));
|
|
1156
1194
|
}
|
|
1157
|
-
if (metadata !== undefined && !
|
|
1158
|
-
violations.push(
|
|
1195
|
+
if (metadata !== undefined && !isPlainObject4(metadata)) {
|
|
1196
|
+
violations.push(violation5("medium", "status.plan-row.invalid-metadata", "metadata must be an object"));
|
|
1159
1197
|
}
|
|
1160
|
-
if (execution_lease !== undefined && !
|
|
1161
|
-
violations.push(
|
|
1198
|
+
if (execution_lease !== undefined && !isPlainObject4(execution_lease)) {
|
|
1199
|
+
violations.push(violation5("medium", "status.plan-row.invalid-execution-lease", "execution_lease must be an object"));
|
|
1162
1200
|
}
|
|
1163
1201
|
if (status === "Done" && execution_lease !== undefined) {
|
|
1164
|
-
violations.push(
|
|
1202
|
+
violations.push(violation5("medium", "status.plan-row.done-with-lease", 'plan status Done must not carry an execution_lease — the Done authority deletes the lease in the same complete-file update as status: "Done" (status-and-residuals.md § Hold, release, and override)', 'delete plans[].execution_lease in the same update that sets status: "Done"'));
|
|
1165
1203
|
}
|
|
1166
1204
|
return { ok: violations.length === 0, violations };
|
|
1167
1205
|
}
|
|
1168
1206
|
function validateResidual(entry) {
|
|
1169
1207
|
const violations = [];
|
|
1170
|
-
if (!
|
|
1171
|
-
return { ok: false, violations: [
|
|
1208
|
+
if (!isPlainObject4(entry)) {
|
|
1209
|
+
return { ok: false, violations: [violation5("high", "status.residual.invalid", "residual entry must be an object")] };
|
|
1172
1210
|
}
|
|
1173
1211
|
const { id, title, severity, source, scope, decision, owner, target, tracking, detail_doc, lifecycle, closed_at } = entry;
|
|
1174
1212
|
validateNonEmptyString3(violations, id, "id", "status.residual.missing-id", "status.residual.invalid-id");
|
|
@@ -1177,42 +1215,42 @@ function validateResidual(entry) {
|
|
|
1177
1215
|
validateNonEmptyString3(violations, scope, "scope", "status.residual.missing-scope", "status.residual.invalid-scope");
|
|
1178
1216
|
validateNonEmptyString3(violations, owner, "owner", "status.residual.missing-owner", "status.residual.invalid-owner");
|
|
1179
1217
|
if (severity === undefined) {
|
|
1180
|
-
violations.push(
|
|
1218
|
+
violations.push(violation5("high", "status.residual.missing-severity", "missing required field: severity"));
|
|
1181
1219
|
} else if (typeof severity !== "string" || !SEVERITY_ORDER.includes(severity) && severity !== "warning") {
|
|
1182
|
-
violations.push(
|
|
1220
|
+
violations.push(violation5("medium", "status.residual.invalid-severity", `severity must be one of ${SEVERITY_ORDER.join(" | ")} — got ${JSON.stringify(severity)}`));
|
|
1183
1221
|
} else if (severity === "warning") {
|
|
1184
|
-
violations.push(
|
|
1222
|
+
violations.push(violation5("low", "status.residual.legacy-warning", `severity "warning" is legacy — forbidden on new entries; read paths normalize it to "low"`, `use "low" (normalizeSeverity maps 'warning' → 'low')`));
|
|
1185
1223
|
}
|
|
1186
1224
|
if (decision === undefined) {
|
|
1187
|
-
violations.push(
|
|
1225
|
+
violations.push(violation5("high", "status.residual.missing-decision", "missing required field: decision"));
|
|
1188
1226
|
} else if (typeof decision !== "string" || !RESIDUAL_DECISIONS.includes(decision)) {
|
|
1189
|
-
violations.push(
|
|
1227
|
+
violations.push(violation5("medium", "status.residual.invalid-decision", `decision must be one of ${RESIDUAL_DECISIONS.join(" | ")} — got ${JSON.stringify(decision)}`));
|
|
1190
1228
|
}
|
|
1191
1229
|
if (target === undefined) {
|
|
1192
|
-
violations.push(
|
|
1230
|
+
violations.push(violation5("high", "status.residual.missing-target", "missing required field: target"));
|
|
1193
1231
|
} else if (typeof target !== "string" && target !== null) {
|
|
1194
|
-
violations.push(
|
|
1232
|
+
violations.push(violation5("medium", "status.residual.invalid-target", "target must be a string or null"));
|
|
1195
1233
|
}
|
|
1196
1234
|
if (tracking === undefined) {
|
|
1197
|
-
violations.push(
|
|
1235
|
+
violations.push(violation5("high", "status.residual.missing-tracking", "missing required field: tracking"));
|
|
1198
1236
|
} else if (typeof tracking !== "string" && tracking !== null) {
|
|
1199
|
-
violations.push(
|
|
1237
|
+
violations.push(violation5("medium", "status.residual.invalid-tracking", "tracking must be a string or null"));
|
|
1200
1238
|
}
|
|
1201
1239
|
if (detail_doc !== undefined && typeof detail_doc !== "string" && detail_doc !== null) {
|
|
1202
|
-
violations.push(
|
|
1240
|
+
violations.push(violation5("medium", "status.residual.invalid-detail-doc", "detail_doc must be a string or null"));
|
|
1203
1241
|
}
|
|
1204
|
-
if (closed_at !== undefined && (typeof closed_at !== "string" || !
|
|
1205
|
-
violations.push(
|
|
1242
|
+
if (closed_at !== undefined && (typeof closed_at !== "string" || !DATE_RE2.test(closed_at))) {
|
|
1243
|
+
violations.push(violation5("medium", "status.residual.invalid-closed-at", "closed_at must be YYYY-MM-DD"));
|
|
1206
1244
|
}
|
|
1207
1245
|
if (lifecycle !== undefined) {
|
|
1208
1246
|
if (typeof lifecycle !== "string" || !RESIDUAL_LIFECYCLES.includes(lifecycle)) {
|
|
1209
|
-
violations.push(
|
|
1247
|
+
violations.push(violation5("medium", "status.residual.invalid-lifecycle", `lifecycle must be one of ${RESIDUAL_LIFECYCLES.join(" | ")} — got ${JSON.stringify(lifecycle)}`));
|
|
1210
1248
|
} else if (lifecycle !== "open") {
|
|
1211
1249
|
if (closed_at === undefined) {
|
|
1212
|
-
violations.push(
|
|
1250
|
+
violations.push(violation5("high", "status.residual.closed-missing-closed-at", `lifecycle "${lifecycle}" requires closed_at (YYYY-MM-DD)`, 'set closed_at (e.g. "2026-08-08")'));
|
|
1213
1251
|
}
|
|
1214
1252
|
if (entry.closure_note === undefined) {
|
|
1215
|
-
violations.push(
|
|
1253
|
+
violations.push(violation5("medium", "status.residual.closed-missing-closure-note", `lifecycle "${lifecycle}" requires closure_note (what changed; how verified)`, "add closure_note explaining the close"));
|
|
1216
1254
|
}
|
|
1217
1255
|
}
|
|
1218
1256
|
}
|
|
@@ -1227,25 +1265,25 @@ function isHarnessRelativePath(dir) {
|
|
|
1227
1265
|
}
|
|
1228
1266
|
function validateWorkflowEntry(entry) {
|
|
1229
1267
|
const violations = [];
|
|
1230
|
-
if (!
|
|
1268
|
+
if (!isPlainObject4(entry)) {
|
|
1231
1269
|
return {
|
|
1232
1270
|
ok: false,
|
|
1233
|
-
violations: [
|
|
1271
|
+
violations: [violation5("high", "status.workflow.invalid", "workflow entry must be an object")]
|
|
1234
1272
|
};
|
|
1235
1273
|
}
|
|
1236
1274
|
validateNonEmptyString3(violations, entry.id, "id", "status.workflow.missing-id", "status.workflow.invalid-id");
|
|
1237
1275
|
if (entry.type === undefined) {
|
|
1238
|
-
violations.push(
|
|
1276
|
+
violations.push(violation5("high", "status.workflow.missing-type", "missing required field: type"));
|
|
1239
1277
|
} else if (typeof entry.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(entry.type)) {
|
|
1240
|
-
violations.push(
|
|
1278
|
+
violations.push(violation5("medium", "status.workflow.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(entry.type)}`));
|
|
1241
1279
|
}
|
|
1242
1280
|
validateNonEmptyString3(violations, entry.started_at, "started_at", "status.workflow.missing-started-at", "status.workflow.invalid-started-at");
|
|
1243
1281
|
if (entry.dir === undefined) {
|
|
1244
|
-
violations.push(
|
|
1282
|
+
violations.push(violation5("high", "status.workflow.missing-dir", "missing required field: dir"));
|
|
1245
1283
|
} else if (typeof entry.dir !== "string" || entry.dir.trim() === "") {
|
|
1246
|
-
violations.push(
|
|
1284
|
+
violations.push(violation5("medium", "status.workflow.invalid-dir", "dir must be a non-empty string"));
|
|
1247
1285
|
} else if (!isHarnessRelativePath(entry.dir)) {
|
|
1248
|
-
violations.push(
|
|
1286
|
+
violations.push(violation5("medium", "status.workflow.invalid-dir", `dir must be a harness-relative path (no absolute paths, no ".." segments) — got ${JSON.stringify(entry.dir)}`));
|
|
1249
1287
|
}
|
|
1250
1288
|
return { ok: violations.length === 0, violations };
|
|
1251
1289
|
}
|
|
@@ -1255,24 +1293,24 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
1255
1293
|
if (typeof docOrPath === "string") {
|
|
1256
1294
|
try {
|
|
1257
1295
|
doc = readJson(docOrPath);
|
|
1258
|
-
harnessDir =
|
|
1296
|
+
harnessDir = dirname4(resolve4(docOrPath));
|
|
1259
1297
|
} catch (error) {
|
|
1260
1298
|
return {
|
|
1261
1299
|
ok: false,
|
|
1262
|
-
violations: [
|
|
1300
|
+
violations: [violation5("high", "status.invalid-json", error.message)]
|
|
1263
1301
|
};
|
|
1264
1302
|
}
|
|
1265
1303
|
} else {
|
|
1266
1304
|
doc = docOrPath;
|
|
1267
1305
|
}
|
|
1268
|
-
if (!
|
|
1269
|
-
return { ok: false, violations: [
|
|
1306
|
+
if (!isPlainObject4(doc)) {
|
|
1307
|
+
return { ok: false, violations: [violation5("high", "status.invalid-doc", "status document must be an object")] };
|
|
1270
1308
|
}
|
|
1271
1309
|
if (doc.version !== 2) {
|
|
1272
1310
|
return {
|
|
1273
1311
|
ok: false,
|
|
1274
1312
|
violations: [
|
|
1275
|
-
|
|
1313
|
+
violation5("high", "status.migration-required", `status.json schema version 2 required — got ${JSON.stringify(doc.version)} (v1 or unknown version); run \`mstar migrate\` to convert the tree`, "run `mstar migrate`")
|
|
1276
1314
|
]
|
|
1277
1315
|
};
|
|
1278
1316
|
}
|
|
@@ -1280,7 +1318,7 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
1280
1318
|
return {
|
|
1281
1319
|
ok: false,
|
|
1282
1320
|
violations: [
|
|
1283
|
-
|
|
1321
|
+
violation5("high", "status.migration-required", "v1-shaped status.json (root plans[]) is not a v2 document — run `mstar migrate` to convert the tree", "run `mstar migrate`")
|
|
1284
1322
|
]
|
|
1285
1323
|
};
|
|
1286
1324
|
}
|
|
@@ -1288,27 +1326,27 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
1288
1326
|
return {
|
|
1289
1327
|
ok: false,
|
|
1290
1328
|
violations: [
|
|
1291
|
-
|
|
1329
|
+
violation5("high", "status.migration-required", "v1-shaped status.json (root residual_findings) is not a v2 document — run `mstar migrate` to convert the tree", "run `mstar migrate`")
|
|
1292
1330
|
]
|
|
1293
1331
|
};
|
|
1294
1332
|
}
|
|
1295
1333
|
const violations = [];
|
|
1296
1334
|
if (doc.updated_at === undefined) {
|
|
1297
|
-
violations.push(
|
|
1298
|
-
} else if (typeof doc.updated_at !== "string" || !
|
|
1299
|
-
violations.push(
|
|
1335
|
+
violations.push(violation5("high", "status.missing-updated-at", "missing required field: updated_at"));
|
|
1336
|
+
} else if (typeof doc.updated_at !== "string" || !DATE_RE2.test(doc.updated_at)) {
|
|
1337
|
+
violations.push(violation5("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
|
|
1300
1338
|
}
|
|
1301
1339
|
if (doc.workflows === undefined) {
|
|
1302
|
-
violations.push(
|
|
1340
|
+
violations.push(violation5("high", "status.missing-workflows", "missing required field: workflows"));
|
|
1303
1341
|
} else if (!Array.isArray(doc.workflows)) {
|
|
1304
|
-
violations.push(
|
|
1342
|
+
violations.push(violation5("high", "status.invalid-workflows", "workflows must be an array"));
|
|
1305
1343
|
} else {
|
|
1306
1344
|
const seen = new Set;
|
|
1307
1345
|
for (const entry of doc.workflows) {
|
|
1308
1346
|
violations.push(...validateWorkflowEntry(entry).violations);
|
|
1309
|
-
if (
|
|
1347
|
+
if (isPlainObject4(entry) && typeof entry.id === "string") {
|
|
1310
1348
|
if (seen.has(entry.id)) {
|
|
1311
|
-
violations.push(
|
|
1349
|
+
violations.push(violation5("medium", "status.workflow.duplicate-id", `duplicate workflow id in workflows[]: ${JSON.stringify(entry.id)}`));
|
|
1312
1350
|
}
|
|
1313
1351
|
seen.add(entry.id);
|
|
1314
1352
|
}
|
|
@@ -1317,40 +1355,40 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
1317
1355
|
if (harnessDir !== undefined && Array.isArray(doc.workflows)) {
|
|
1318
1356
|
let realHarnessDir = null;
|
|
1319
1357
|
try {
|
|
1320
|
-
realHarnessDir =
|
|
1358
|
+
realHarnessDir = realpathSync(harnessDir);
|
|
1321
1359
|
} catch {}
|
|
1322
1360
|
for (const entry of doc.workflows) {
|
|
1323
|
-
if (!
|
|
1361
|
+
if (!isPlainObject4(entry) || typeof entry.dir !== "string")
|
|
1324
1362
|
continue;
|
|
1325
1363
|
const relSnapshot = join6(entry.dir, WORKFLOW_SNAPSHOT_FILE);
|
|
1326
1364
|
const snapshotPath = join6(harnessDir, relSnapshot);
|
|
1327
1365
|
const label = typeof entry.id === "string" ? entry.id : relSnapshot;
|
|
1328
1366
|
let physical;
|
|
1329
1367
|
try {
|
|
1330
|
-
physical =
|
|
1368
|
+
physical = realpathSync(snapshotPath);
|
|
1331
1369
|
} catch {
|
|
1332
|
-
violations.push(
|
|
1370
|
+
violations.push(violation5("high", "status.workflow.snapshot-missing", `workflows[] lists ${JSON.stringify(label)} but its snapshot does not exist at ${JSON.stringify(relSnapshot)} — the root holds active lifecycles only; unregister the id when its snapshot is removed`));
|
|
1333
1371
|
continue;
|
|
1334
1372
|
}
|
|
1335
1373
|
if (realHarnessDir !== null && physical !== realHarnessDir && !physical.startsWith(`${realHarnessDir}${sep}`)) {
|
|
1336
|
-
violations.push(
|
|
1374
|
+
violations.push(violation5("high", "status.workflow.snapshot-outside-harness", `workflows[] lists ${JSON.stringify(label)} but its snapshot resolves outside the harness dir (${JSON.stringify(physical)}) — symlinked snapshot paths are rejected; the snapshot must physically live under ${JSON.stringify(harnessDir)}`));
|
|
1337
1375
|
continue;
|
|
1338
1376
|
}
|
|
1339
1377
|
let snapshot;
|
|
1340
1378
|
try {
|
|
1341
1379
|
snapshot = readJson(snapshotPath);
|
|
1342
1380
|
} catch (error) {
|
|
1343
|
-
violations.push(
|
|
1381
|
+
violations.push(violation5("high", "status.workflow.snapshot-invalid", `snapshot at ${JSON.stringify(relSnapshot)} is not valid JSON: ${error.message}`));
|
|
1344
1382
|
continue;
|
|
1345
1383
|
}
|
|
1346
1384
|
if (typeof snapshot.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(snapshot.status)) {
|
|
1347
|
-
violations.push(
|
|
1385
|
+
violations.push(violation5("high", "status.workflow.terminal-listed", `workflows[] lists ${JSON.stringify(label)} whose snapshot status is terminal (${snapshot.status}) — removal-at-terminal: terminal writers unregister AFTER the snapshot write`));
|
|
1348
1386
|
}
|
|
1349
1387
|
if (typeof entry.type === "string" && typeof snapshot.type === "string" && entry.type !== snapshot.type) {
|
|
1350
|
-
violations.push(
|
|
1388
|
+
violations.push(violation5("medium", "status.workflow.mismatched-type", `workflows[] entry ${JSON.stringify(label)} type ${JSON.stringify(entry.type)} does not match its snapshot type ${JSON.stringify(snapshot.type)} — the root entry mirrors the snapshot; align them`));
|
|
1351
1389
|
}
|
|
1352
1390
|
if (typeof entry.started_at === "string" && typeof snapshot.started_at === "string" && entry.started_at !== snapshot.started_at) {
|
|
1353
|
-
violations.push(
|
|
1391
|
+
violations.push(violation5("medium", "status.workflow.mismatched-started-at", `workflows[] entry ${JSON.stringify(label)} started_at ${JSON.stringify(entry.started_at)} does not match its snapshot started_at ${JSON.stringify(snapshot.started_at)} — workflow ${JSON.stringify(label)} collided with another writer (e.g. a concurrent/re-run \`audit promote\` with the same workflow id rewrote the snapshot); the root entry mirrors the snapshot — align them or remove the colliding workflow`));
|
|
1354
1392
|
}
|
|
1355
1393
|
}
|
|
1356
1394
|
}
|
|
@@ -1358,7 +1396,7 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
1358
1396
|
}
|
|
1359
1397
|
var validateStatus = validateStatusV2;
|
|
1360
1398
|
function registerWorkflowEntryLocked(statusPath, entry) {
|
|
1361
|
-
const harnessDir =
|
|
1399
|
+
const harnessDir = dirname4(statusPath);
|
|
1362
1400
|
const current = readJson(statusPath);
|
|
1363
1401
|
const fresh = Object.keys(current).length === 0;
|
|
1364
1402
|
const doc = fresh ? { version: 2, updated_at: todayString(), workflows: [] } : current;
|
|
@@ -1384,15 +1422,15 @@ async function registerWorkflow(root, entry) {
|
|
|
1384
1422
|
if (!entryGate.ok) {
|
|
1385
1423
|
throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
|
|
1386
1424
|
}
|
|
1387
|
-
const statusPath =
|
|
1425
|
+
const statusPath = resolve4(root);
|
|
1388
1426
|
return withStatusWriteLock(statusPath, () => registerWorkflowEntryLocked(statusPath, entry));
|
|
1389
1427
|
}
|
|
1390
1428
|
async function unregisterWorkflow(root, id) {
|
|
1391
1429
|
if (typeof id !== "string" || id.trim() === "") {
|
|
1392
1430
|
throw new Error("refusing to unregister workflow: id must be a non-empty string");
|
|
1393
1431
|
}
|
|
1394
|
-
const statusPath =
|
|
1395
|
-
const harnessDir =
|
|
1432
|
+
const statusPath = resolve4(root);
|
|
1433
|
+
const harnessDir = dirname4(statusPath);
|
|
1396
1434
|
return withStatusWriteLock(statusPath, () => {
|
|
1397
1435
|
const current = readJson(statusPath);
|
|
1398
1436
|
if (Object.keys(current).length === 0) {
|
|
@@ -1449,8 +1487,8 @@ function resolveCompassEnforcement(harnessDir) {
|
|
|
1449
1487
|
return { hard: false, source: "none" };
|
|
1450
1488
|
}
|
|
1451
1489
|
function resolveMstarcEnforcement(harnessDir) {
|
|
1452
|
-
const dir =
|
|
1453
|
-
const rc = loadMstarc(dir,
|
|
1490
|
+
const dir = resolve4(harnessDir);
|
|
1491
|
+
const rc = loadMstarc(dir, dirname4(dir));
|
|
1454
1492
|
const value = rc?.config.enforcement;
|
|
1455
1493
|
if (value === "hard")
|
|
1456
1494
|
return { hard: true, source: "mstarc" };
|
|
@@ -1464,1025 +1502,1030 @@ function resolveRepoEnforcement(harnessDir) {
|
|
|
1464
1502
|
return rc;
|
|
1465
1503
|
return resolveCompassEnforcement(harnessDir);
|
|
1466
1504
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
var
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
return
|
|
1505
|
+
|
|
1506
|
+
// src/project.ts
|
|
1507
|
+
var PROJECT_ROADMAP_FILE = "roadmap.md";
|
|
1508
|
+
var PROJECT_REFERENCES_DIR = "references";
|
|
1509
|
+
var PROJECT_REGISTER_FILE = "residuals.json";
|
|
1510
|
+
var _DEFAULT_PROJECT = "_default";
|
|
1511
|
+
var ROADMAP_STATUSES = ["active", "paused", "completed"];
|
|
1512
|
+
var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
|
|
1513
|
+
var ROLLUP_FIELDS = ["total_open", "by_severity", "by_target", "by_plan"];
|
|
1514
|
+
function isPlainObject5(value) {
|
|
1515
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1478
1516
|
}
|
|
1479
|
-
function
|
|
1517
|
+
function violation6(severity, code, message, fix) {
|
|
1480
1518
|
return { ok: false, severity, code, message, fix };
|
|
1481
1519
|
}
|
|
1482
|
-
function
|
|
1483
|
-
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
if (precomputed !== undefined)
|
|
1488
|
-
return { branch: precomputed };
|
|
1489
|
-
const timeout = opts.timeoutMs ?? probeTimeoutMs();
|
|
1490
|
-
try {
|
|
1491
|
-
const stdout = execFileSync2(opts.gitPath ?? "git", ["-C", worktreePath, "branch", "--show-current"], {
|
|
1492
|
-
encoding: "utf8",
|
|
1493
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1494
|
-
timeout
|
|
1495
|
-
});
|
|
1496
|
-
const branch = stdout.trim();
|
|
1497
|
-
if (branch === "")
|
|
1498
|
-
return { error: `no branch checked out (detached HEAD?) at "${worktreePath}"` };
|
|
1499
|
-
return { branch };
|
|
1500
|
-
} catch (err) {
|
|
1501
|
-
const e = err;
|
|
1502
|
-
if (e.killed === true || e.signal !== undefined) {
|
|
1503
|
-
return { error: `git probe timed out after ${timeout}ms (killed by ${e.signal ?? "SIGTERM"})` };
|
|
1504
|
-
}
|
|
1505
|
-
const detail = (e.stderr !== undefined ? e.stderr.toString().trim() : "") || e.message || "git probe failed";
|
|
1506
|
-
return { error: detail };
|
|
1520
|
+
function validateNonEmptyString4(violations, value, field, missingCode, invalidCode) {
|
|
1521
|
+
if (value === undefined) {
|
|
1522
|
+
violations.push(violation6("high", missingCode, `missing required field: ${field}`));
|
|
1523
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
1524
|
+
violations.push(violation6("medium", invalidCode, `${field} must be a non-empty string`));
|
|
1507
1525
|
}
|
|
1508
1526
|
}
|
|
1509
|
-
function
|
|
1527
|
+
function validateRoadmap(filePath) {
|
|
1510
1528
|
const violations = [];
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1529
|
+
let content;
|
|
1530
|
+
try {
|
|
1531
|
+
content = readFileSync5(filePath, "utf8");
|
|
1532
|
+
} catch {
|
|
1533
|
+
return {
|
|
1534
|
+
ok: false,
|
|
1535
|
+
violations: [violation6("high", "project.roadmap.unreadable", `cannot read roadmap file: ${filePath}`)],
|
|
1536
|
+
warnings: []
|
|
1537
|
+
};
|
|
1514
1538
|
}
|
|
1515
|
-
|
|
1516
|
-
|
|
1539
|
+
let doc;
|
|
1540
|
+
try {
|
|
1541
|
+
doc = parseCompassFrontmatterText(content, filePath);
|
|
1542
|
+
} catch (err) {
|
|
1543
|
+
const message = err instanceof Error ? err.message : `invalid roadmap frontmatter in ${filePath}`;
|
|
1544
|
+
return { ok: false, violations: [violation6("high", "project.roadmap.invalid-frontmatter", message)], warnings: [] };
|
|
1517
1545
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1546
|
+
validateNonEmptyString4(violations, doc.project_id, "project_id", "project.roadmap.missing-project-id", "project.roadmap.invalid-project-id");
|
|
1547
|
+
validateNonEmptyString4(violations, doc.title, "title", "project.roadmap.missing-title", "project.roadmap.invalid-title");
|
|
1548
|
+
if (doc.status === undefined) {
|
|
1549
|
+
violations.push(violation6("high", "project.roadmap.missing-status", "missing required field: status"));
|
|
1550
|
+
} else if (typeof doc.status !== "string" || !ROADMAP_STATUSES.includes(doc.status)) {
|
|
1551
|
+
violations.push(violation6("medium", "project.roadmap.invalid-status", `status must be one of ${ROADMAP_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
|
|
1520
1552
|
}
|
|
1521
|
-
if (
|
|
1522
|
-
violations.push(
|
|
1553
|
+
if (doc.created_at === undefined) {
|
|
1554
|
+
violations.push(violation6("high", "project.roadmap.missing-created-at", "missing required field: created_at"));
|
|
1555
|
+
} else if (typeof doc.created_at !== "string" || !DATE_RE3.test(doc.created_at)) {
|
|
1556
|
+
violations.push(violation6("medium", "project.roadmap.invalid-created-at", "created_at must be YYYY-MM-DD"));
|
|
1523
1557
|
}
|
|
1524
|
-
if (
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1558
|
+
if (doc.milestones !== undefined && doc.milestones !== null) {
|
|
1559
|
+
if (!Array.isArray(doc.milestones)) {
|
|
1560
|
+
violations.push(violation6("medium", "project.roadmap.invalid-milestones", "milestones must be a list of milestone names"));
|
|
1561
|
+
} else {
|
|
1562
|
+
for (const item of doc.milestones) {
|
|
1563
|
+
if (typeof item !== "string" || item.trim() === "") {
|
|
1564
|
+
violations.push(violation6("medium", "project.roadmap.invalid-milestones", "milestones items must be non-empty strings"));
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1532
1568
|
}
|
|
1533
1569
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
const violations = [];
|
|
1538
|
-
const tracks = input.tracks ?? [];
|
|
1539
|
-
const seenPaths = new Set;
|
|
1540
|
-
if (tracks.length < 1) {
|
|
1541
|
-
violations.push(violation5("high", "worktree.l2.no-tracks", "no parallel writable tracks — the L2 pre-dispatch checklist requires at least one track with an absolute worktreePath and Working branch", "pass each track's absolute Worktree path and PM-approved Working branch"));
|
|
1542
|
-
}
|
|
1543
|
-
tracks.forEach((track, index) => {
|
|
1544
|
-
if (track.worktreePath.trim() === "" || track.workingBranch.trim() === "") {
|
|
1545
|
-
violations.push(violation5("high", "worktree.l2.track-invalid", `track ${index + 1} is missing worktreePath and/or workingBranch`, "fill both fields for every track"));
|
|
1546
|
-
return;
|
|
1547
|
-
}
|
|
1548
|
-
if (!isAbsolute4(track.worktreePath)) {
|
|
1549
|
-
violations.push(violation5("high", "worktree.l2.track-path-relative", `track ${index + 1} worktreePath "${track.worktreePath}" is not an absolute path — L2 tracks MUST use absolute worktree checkout paths (consistent with the lease validator's absolute worktree_path enforcement)`, `use an absolute path for track ${index + 1} (e.g. /Users/<you>/worktrees/<branch>)`));
|
|
1550
|
-
return;
|
|
1551
|
-
}
|
|
1552
|
-
const normalized = resolve6(track.worktreePath);
|
|
1553
|
-
if (seenPaths.has(normalized)) {
|
|
1554
|
-
violations.push(violation5("high", "worktree.l2.track-path-collision", `duplicate worktreePath "${track.worktreePath}" across parallel tracks — L2 parallel-writable isolation requires a distinct absolute Worktree path per track (N parallel invokes ≠ isolation)`, "give every parallel track its own git worktree checkout"));
|
|
1555
|
-
return;
|
|
1556
|
-
}
|
|
1557
|
-
seenPaths.add(normalized);
|
|
1558
|
-
if (!existsSync4(track.worktreePath)) {
|
|
1559
|
-
violations.push(violation5("high", "worktree.l2.track-missing", `track worktree directory "${track.worktreePath}" does not exist`, `create it before dispatch: git worktree add ${track.worktreePath} ${track.workingBranch}`));
|
|
1560
|
-
return;
|
|
1561
|
-
}
|
|
1562
|
-
const probe = probeBranch(track.worktreePath, opts);
|
|
1563
|
-
if ("error" in probe) {
|
|
1564
|
-
violations.push(violation5("high", "worktree.l2.branch-probe-failed", `cannot probe branch at "${track.worktreePath}": ${probe.error}`, "verify the path is a git worktree checkout on its Working branch (not detached)"));
|
|
1565
|
-
} else if (probe.branch !== track.workingBranch) {
|
|
1566
|
-
violations.push(violation5("high", "worktree.l2.branch-mismatch", `track worktree "${track.worktreePath}" is on branch "${probe.branch}", expected Working branch "${track.workingBranch}"`, `checkout ${track.workingBranch} in that worktree`));
|
|
1570
|
+
if (doc.residuals_ref !== undefined && doc.residuals_ref !== null) {
|
|
1571
|
+
if (typeof doc.residuals_ref !== "string" || doc.residuals_ref.trim() === "") {
|
|
1572
|
+
violations.push(violation6("medium", "project.roadmap.invalid-residuals-ref", "residuals_ref must be a non-empty string"));
|
|
1567
1573
|
}
|
|
1568
|
-
});
|
|
1569
|
-
return gate(violations);
|
|
1570
|
-
}
|
|
1571
|
-
function assertControlVsFeaturePath(controlWorktreePath, featureWorktreePath) {
|
|
1572
|
-
const violations = [];
|
|
1573
|
-
const samePath = controlWorktreePath === "" && featureWorktreePath === "" || controlWorktreePath !== "" && featureWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(featureWorktreePath);
|
|
1574
|
-
if (samePath) {
|
|
1575
|
-
violations.push(violation5("critical", "worktree.control-feature.same", `control worktree path equals feature/lease worktree path "${controlWorktreePath}" — execution_lease.worktree_path MUST differ from metadata.control_worktree_path`, "use a distinct feature worktree for the plan's product edits"));
|
|
1576
1574
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
violations.push(violation5("high", "worktree.branch-probe-failed", `cannot probe branch at "${worktreePath}": ${probe.error}`, "verify the path is a git worktree checkout on the expected branch (not detached)"));
|
|
1584
|
-
} else if (probe.branch !== expectedBranch) {
|
|
1585
|
-
violations.push(violation5("high", "worktree.branch-mismatch", `worktree "${worktreePath}" is on branch "${probe.branch}", expected "${expectedBranch}" (Assignment Working branch)`, `checkout ${expectedBranch} in that worktree`));
|
|
1575
|
+
const warnings = [];
|
|
1576
|
+
const fenceEnd = linesIndexOfClosingFence(content);
|
|
1577
|
+
const body = content.split(/\r?\n/).slice(fenceEnd + 1).join(`
|
|
1578
|
+
`);
|
|
1579
|
+
if (!/^##\s+Direction\s*$/m.test(body)) {
|
|
1580
|
+
warnings.push(violation6("low", "project.roadmap.body.missing-direction", "roadmap body has no `## Direction` section (documented body convention) — state the project direction there"));
|
|
1586
1581
|
}
|
|
1587
|
-
|
|
1582
|
+
if (!/^\s*[-*]\s+\[[xX ]\]/m.test(body)) {
|
|
1583
|
+
warnings.push(violation6("low", "project.roadmap.body.no-goal-items", "roadmap body has no goal-item task list (documented body convention) — list goals as `- [ ]` / `- [x]` markdown task items"));
|
|
1584
|
+
}
|
|
1585
|
+
return { ok: violations.length === 0, violations, warnings };
|
|
1588
1586
|
}
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
];
|
|
1594
|
-
function assertQcAlignment(assignments) {
|
|
1587
|
+
function linesIndexOfClosingFence(content) {
|
|
1588
|
+
return content.split(/\r?\n/).indexOf("---", 1);
|
|
1589
|
+
}
|
|
1590
|
+
function validateProjectRegister(doc) {
|
|
1595
1591
|
const violations = [];
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1592
|
+
if (!isPlainObject5(doc)) {
|
|
1593
|
+
return {
|
|
1594
|
+
ok: false,
|
|
1595
|
+
violations: [violation6("high", "project.register.invalid", "project register must be an object")]
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
if (doc.entries === undefined) {
|
|
1599
|
+
violations.push(violation6("high", "project.register.missing-entries", "missing required field: entries"));
|
|
1600
|
+
} else if (!isPlainObject5(doc.entries)) {
|
|
1601
|
+
violations.push(violation6("high", "project.register.invalid-entries", "entries must be an object keyed by plan id"));
|
|
1602
|
+
} else {
|
|
1603
|
+
for (const [key, entries] of Object.entries(doc.entries)) {
|
|
1604
|
+
if (key.trim() === "") {
|
|
1605
|
+
violations.push(violation6("medium", "project.register.invalid-key", "entries keys must be non-empty plan ids"));
|
|
1606
|
+
}
|
|
1607
|
+
if (!Array.isArray(entries)) {
|
|
1608
|
+
violations.push(violation6("high", "project.register.invalid-entry-list", `entries[${JSON.stringify(key)}] must be an array of residual entries (one entry per residual; v1 multi-finding semantics)`));
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
for (const entry of entries) {
|
|
1612
|
+
violations.push(...validateResidual(entry).violations);
|
|
1613
|
+
if (!isPlainObject5(entry))
|
|
1614
|
+
continue;
|
|
1615
|
+
validateNonEmptyString4(violations, entry.source_plan, "source_plan", "project.register.missing-source-plan", "project.register.invalid-source-plan");
|
|
1616
|
+
if (entry.registered_at === undefined) {
|
|
1617
|
+
violations.push(violation6("high", "project.register.missing-registered-at", "missing required field: registered_at"));
|
|
1618
|
+
} else if (typeof entry.registered_at !== "string" || !DATE_RE3.test(entry.registered_at)) {
|
|
1619
|
+
violations.push(violation6("medium", "project.register.invalid-registered-at", "registered_at must be YYYY-MM-DD"));
|
|
1620
|
+
}
|
|
1621
|
+
if (entry.lifecycle_id !== undefined && (typeof entry.lifecycle_id !== "string" || entry.lifecycle_id.trim() === "")) {
|
|
1622
|
+
violations.push(violation6("medium", "project.register.invalid-lifecycle-id", "lifecycle_id must be a non-empty string"));
|
|
1623
|
+
}
|
|
1624
|
+
if (typeof entry.source_plan === "string" && entry.source_plan.trim() !== "" && entry.source_plan !== key) {
|
|
1625
|
+
violations.push(violation6("medium", "project.register.mismatched-source-plan", `source_plan ${JSON.stringify(entry.source_plan)} does not match the entries key ${JSON.stringify(key)} — entries are keyed by plan id`));
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1601
1628
|
}
|
|
1602
1629
|
}
|
|
1603
|
-
return
|
|
1630
|
+
return { ok: violations.length === 0, violations };
|
|
1604
1631
|
}
|
|
1605
|
-
function
|
|
1632
|
+
function findingsCleanupGate(register, planId, opts) {
|
|
1633
|
+
const mode = opts?.mode ?? "allow-residual";
|
|
1606
1634
|
const violations = [];
|
|
1607
|
-
const
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
violations.push(violation5("high", "qc.alignment.snapshot-missing", `review head not provided for assignment ${index + 1} (plan_id "${a.planId}") — cannot confirm the single review snapshot precondition`, "precompute and pass the review HEAD (full SHA) for every assignment"));
|
|
1611
|
-
}
|
|
1612
|
-
});
|
|
1613
|
-
const distinct = [...new Set(list.map((a) => a.head ?? "").filter((h) => h.trim() !== ""))];
|
|
1614
|
-
if (distinct.length > 1) {
|
|
1615
|
-
violations.push(violation5("high", "qc.alignment.single-snapshot", `assignments cover ${distinct.length} different review heads (${distinct.join(", ")}) — all reviewable commits must sit on ONE Working branch HEAD before QC tri + QA`, "merge the parallel tracks to a single Working branch HEAD, then re-derive the heads"));
|
|
1635
|
+
const entries = isPlainObject5(register.entries) ? register.entries[planId] : undefined;
|
|
1636
|
+
if (entries === undefined) {
|
|
1637
|
+
return { ok: true, violations };
|
|
1616
1638
|
}
|
|
1617
|
-
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
1621
|
-
import { mkdirSync as mkdirSync5, readdirSync as readdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync3, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1622
|
-
import { basename as basename3, dirname as dirname6, isAbsolute as isAbsolute5, join as join7, resolve as resolve7 } from "node:path";
|
|
1623
|
-
class SddScriptError extends Error {
|
|
1624
|
-
exitCode;
|
|
1625
|
-
constructor(message, exitCode) {
|
|
1626
|
-
super(message);
|
|
1627
|
-
this.name = "SddScriptError";
|
|
1628
|
-
this.exitCode = exitCode;
|
|
1639
|
+
if (!Array.isArray(entries)) {
|
|
1640
|
+
violations.push(violation6("high", "project.register.invalid-entry-list", `entries[${JSON.stringify(planId)}] must be an array of residual entries (one entry per residual; v1 multi-finding semantics)`));
|
|
1641
|
+
return { ok: false, violations };
|
|
1629
1642
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
try {
|
|
1633
|
-
return statSync4(dir).isDirectory();
|
|
1634
|
-
} catch {
|
|
1635
|
-
return false;
|
|
1643
|
+
if (entries.length === 0) {
|
|
1644
|
+
return { ok: true, violations };
|
|
1636
1645
|
}
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1646
|
+
for (const entry of entries) {
|
|
1647
|
+
if (!isOpenResidual(entry))
|
|
1648
|
+
continue;
|
|
1649
|
+
const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
|
|
1650
|
+
const label = `R#${id}`;
|
|
1651
|
+
if (mode === "zero-residual") {
|
|
1652
|
+
if (entry.severity === "nit") {
|
|
1653
|
+
violations.push(violation6("medium", "findings.zero-residual-nit", `${label}: style-only nits must be fixed in-session or dropped — never left open under zero-residual`));
|
|
1654
|
+
} else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
|
|
1655
|
+
violations.push(violation6("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
|
|
1656
|
+
} else if (entry.decision === "defer") {
|
|
1657
|
+
if (typeof entry.target !== "string" || entry.target.trim() === "") {
|
|
1658
|
+
violations.push(violation6("medium", "findings.zero-residual-defer-no-target", `${label}: blocker-defer requires a target (next iteration/milestone) under zero-residual`));
|
|
1659
|
+
}
|
|
1660
|
+
} else {
|
|
1661
|
+
violations.push(violation6("medium", "findings.zero-residual-open-fixable", `${label}: fixable finding must not remain open under zero-residual — fix now or convert to a blocker-defer`));
|
|
1662
|
+
}
|
|
1663
|
+
} else if (normalizeSeverity(entry.severity) === "critical") {
|
|
1664
|
+
violations.push(violation6("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
|
|
1665
|
+
}
|
|
1643
1666
|
}
|
|
1667
|
+
return { ok: violations.length === 0, violations };
|
|
1644
1668
|
}
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
encoding: "utf8",
|
|
1651
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1652
|
-
maxBuffer: GIT_CAPTURE_MAX_BYTES
|
|
1653
|
-
}).trim();
|
|
1654
|
-
} catch {
|
|
1655
|
-
return null;
|
|
1669
|
+
function groupCount(values) {
|
|
1670
|
+
const counts = new Map;
|
|
1671
|
+
for (const value of values) {
|
|
1672
|
+
const key = typeof value === "string" ? value : String(value);
|
|
1673
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1656
1674
|
}
|
|
1675
|
+
return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
1657
1676
|
}
|
|
1658
|
-
function
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
if (isFile2(join7(root, ".agents", "status.json")))
|
|
1662
|
-
return join7(root, ".agents");
|
|
1663
|
-
if (hasWorkflowSnapshot(join7(root, ".mstar")))
|
|
1664
|
-
return join7(root, ".mstar");
|
|
1665
|
-
if (hasWorkflowSnapshot(join7(root, ".agents")))
|
|
1666
|
-
return join7(root, ".agents");
|
|
1667
|
-
return null;
|
|
1668
|
-
}
|
|
1669
|
-
function hasWorkflowSnapshot(harnessDir) {
|
|
1670
|
-
let workflowsDir;
|
|
1677
|
+
function techDebtRollup(projectDir) {
|
|
1678
|
+
const items = [];
|
|
1679
|
+
let entries;
|
|
1671
1680
|
try {
|
|
1672
|
-
|
|
1681
|
+
entries = readdirSync3(projectDir, { withFileTypes: true });
|
|
1673
1682
|
} catch {
|
|
1674
|
-
|
|
1683
|
+
entries = [];
|
|
1675
1684
|
}
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1685
|
+
for (const project of entries) {
|
|
1686
|
+
if (!project.isDirectory())
|
|
1687
|
+
continue;
|
|
1688
|
+
const registerPath = join7(projectDir, project.name, PROJECT_REGISTER_FILE);
|
|
1689
|
+
if (!existsSync4(registerPath))
|
|
1690
|
+
continue;
|
|
1691
|
+
let register;
|
|
1692
|
+
try {
|
|
1693
|
+
register = readJson(registerPath);
|
|
1694
|
+
} catch {
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
if (!isPlainObject5(register) || !isPlainObject5(register.entries))
|
|
1698
|
+
continue;
|
|
1699
|
+
for (const [plan, planEntries] of Object.entries(register.entries)) {
|
|
1700
|
+
if (!Array.isArray(planEntries))
|
|
1701
|
+
continue;
|
|
1702
|
+
for (const entry of planEntries) {
|
|
1703
|
+
if (!isPlainObject5(entry) || !isOpenResidual(entry))
|
|
1704
|
+
continue;
|
|
1705
|
+
items.push({ plan, entry });
|
|
1706
|
+
}
|
|
1682
1707
|
}
|
|
1683
|
-
} catch {
|
|
1684
|
-
return false;
|
|
1685
1708
|
}
|
|
1686
|
-
|
|
1709
|
+
const bySeverity = {};
|
|
1710
|
+
for (const severity of SEVERITY_ORDER) {
|
|
1711
|
+
bySeverity[severity] = items.filter(({ entry }) => normalizeSeverity(entry.severity) === severity).length;
|
|
1712
|
+
}
|
|
1713
|
+
const computed = {
|
|
1714
|
+
total_open: items.length,
|
|
1715
|
+
by_severity: bySeverity,
|
|
1716
|
+
by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
|
|
1717
|
+
by_plan: groupCount(items.map(({ plan }) => plan))
|
|
1718
|
+
};
|
|
1719
|
+
const stored = null;
|
|
1720
|
+
const checks = ROLLUP_FIELDS.map((field) => ({ field, status: "DRIFT" }));
|
|
1721
|
+
const overall = "DRIFT";
|
|
1722
|
+
return { computed, stored, checks, overall };
|
|
1687
1723
|
}
|
|
1688
|
-
function
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1691
|
-
if (gitDirRaw === null || commonRaw === null)
|
|
1692
|
-
return false;
|
|
1693
|
-
const gitDir = isAbsolute5(gitDirRaw) ? gitDirRaw : join7(root, gitDirRaw);
|
|
1694
|
-
const common = isAbsolute5(commonRaw) ? commonRaw : join7(root, commonRaw);
|
|
1695
|
-
if (gitDir.includes("/.git/worktrees/") || gitDir.includes("/worktrees/"))
|
|
1696
|
-
return true;
|
|
1724
|
+
function listProjectReferenceFiles(projectDir) {
|
|
1725
|
+
const root = join7(projectDir, PROJECT_REFERENCES_DIR);
|
|
1726
|
+
let entries;
|
|
1697
1727
|
try {
|
|
1698
|
-
|
|
1699
|
-
const cmAbs = realpathSync3(common);
|
|
1700
|
-
return join7(gdParent, basename3(gitDir)) !== cmAbs && gitDir !== cmAbs;
|
|
1728
|
+
entries = readdirSync3(root, { withFileTypes: true });
|
|
1701
1729
|
} catch {
|
|
1702
|
-
return
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
|
-
function sddWorkspace(planId, opts = {}) {
|
|
1706
|
-
if (!planId) {
|
|
1707
|
-
throw new SddScriptError(`usage: mstar sdd workspace PLAN_ID [CONTROL_ROOT]
|
|
1708
|
-
` + " Set MSTAR_CONTROL_ROOT=<control_worktree_path> when running from a feature worktree.", 2);
|
|
1709
|
-
}
|
|
1710
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
1711
|
-
const controlRoot = opts.controlRoot ?? (process.env.MSTAR_CONTROL_ROOT || undefined);
|
|
1712
|
-
let root;
|
|
1713
|
-
if (controlRoot) {
|
|
1714
|
-
if (!isDirectory2(controlRoot)) {
|
|
1715
|
-
throw new SddScriptError(`mstar sdd workspace: CONTROL_ROOT / MSTAR_CONTROL_ROOT is not a directory: ${controlRoot}`, 1);
|
|
1716
|
-
}
|
|
1717
|
-
root = realpathSync3(controlRoot);
|
|
1718
|
-
} else {
|
|
1719
|
-
const topLevel = gitOut(cwd, ["rev-parse", "--show-toplevel"]);
|
|
1720
|
-
root = realpathSync3(topLevel ?? cwd);
|
|
1721
|
-
}
|
|
1722
|
-
if (!controlRoot && isLinkedWorktree(root)) {
|
|
1723
|
-
throw new SddScriptError(`mstar sdd workspace: linked worktree at ${root} has no {HARNESS_DIR}/status.json (default gitignore).
|
|
1724
|
-
` + ` Refusing to create a second SDD tree under the feature checkout.
|
|
1725
|
-
` + ` Re-run with MSTAR_CONTROL_ROOT=<control_worktree_path> or: mstar sdd workspace ${planId} <control_worktree_path>
|
|
1726
|
-
` + ` See mstar-branch-worktree «Harness path SSOT under default gitignore».`, 1);
|
|
1730
|
+
return [];
|
|
1727
1731
|
}
|
|
1728
|
-
const
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
harnessDir = join7(root, ".agents");
|
|
1745
|
-
} else {
|
|
1746
|
-
harnessDir = join7(root, ".mstar");
|
|
1732
|
+
const files = [];
|
|
1733
|
+
for (const entry of entries) {
|
|
1734
|
+
if (entry.name === PROJECT_ROADMAP_FILE || entry.name === PROJECT_REGISTER_FILE)
|
|
1735
|
+
continue;
|
|
1736
|
+
if (entry.isFile()) {
|
|
1737
|
+
files.push(entry.name);
|
|
1738
|
+
} else if (entry.isDirectory()) {
|
|
1739
|
+
let nested;
|
|
1740
|
+
try {
|
|
1741
|
+
nested = readdirSync3(join7(root, entry.name), { withFileTypes: true });
|
|
1742
|
+
} catch {
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
for (const child of nested) {
|
|
1746
|
+
if (child.isFile())
|
|
1747
|
+
files.push(`${entry.name}/${child.name}`);
|
|
1747
1748
|
}
|
|
1748
1749
|
}
|
|
1749
1750
|
}
|
|
1750
|
-
|
|
1751
|
-
mkdirSync5(sddDir, { recursive: true });
|
|
1752
|
-
writeFileSync3(join7(sddDir, ".gitignore"), `*
|
|
1753
|
-
`);
|
|
1754
|
-
return realpathSync3(sddDir);
|
|
1751
|
+
return files.sort();
|
|
1755
1752
|
}
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1753
|
+
|
|
1754
|
+
// src/path.ts
|
|
1755
|
+
function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
|
|
1756
|
+
const start = resolve5(startDir);
|
|
1757
|
+
const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
|
|
1758
|
+
if (explicit)
|
|
1759
|
+
return resolve5(start, explicit);
|
|
1760
|
+
const boundary = resolve5(start, opts.workspaceRoot ?? defaultWorkspaceRoot(start));
|
|
1761
|
+
const rc = loadMstarc(start, boundary);
|
|
1762
|
+
if (rc !== null && rc.config.harnessDir)
|
|
1763
|
+
return resolve5(rc.dir, rc.config.harnessDir);
|
|
1764
|
+
let dir = start;
|
|
1765
|
+
for (;; ) {
|
|
1766
|
+
if (!isAtOrBelow2(dir, boundary))
|
|
1767
|
+
return null;
|
|
1768
|
+
for (const candidate of [join8(dir, ".mstar"), join8(dir, ".agents"), join8(dir, ".plans"), join8(dir, "plans")]) {
|
|
1769
|
+
if (isDirectory(candidate))
|
|
1770
|
+
return candidate;
|
|
1773
1771
|
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
`);
|
|
1781
|
-
const headingRe = /^#+[ \t]+Task[ \t]+[0-9]+/;
|
|
1782
|
-
const targetRe = new RegExp(`^#+[ ]+Task[ ]+${taskN}([^0-9]|$)`);
|
|
1783
|
-
let infence = false;
|
|
1784
|
-
let intask = false;
|
|
1785
|
-
const printed = [];
|
|
1786
|
-
for (const line of records) {
|
|
1787
|
-
if (/^```/.test(line))
|
|
1788
|
-
infence = !infence;
|
|
1789
|
-
if (!infence && headingRe.test(line))
|
|
1790
|
-
intask = targetRe.test(line);
|
|
1791
|
-
if (intask)
|
|
1792
|
-
printed.push(line);
|
|
1793
|
-
}
|
|
1794
|
-
const output = printed.length > 0 ? `${printed.join(`
|
|
1795
|
-
`)}
|
|
1796
|
-
` : "";
|
|
1797
|
-
writeFileSync3(out, output);
|
|
1798
|
-
if (printed.length === 0) {
|
|
1799
|
-
throw new SddScriptError(`task ${taskN} not found in ${planFile} (no heading matching Task ${taskN})`, 3);
|
|
1772
|
+
if (dir === boundary)
|
|
1773
|
+
return null;
|
|
1774
|
+
const parent = dirname5(dir);
|
|
1775
|
+
if (parent === dir)
|
|
1776
|
+
return null;
|
|
1777
|
+
dir = parent;
|
|
1800
1778
|
}
|
|
1801
|
-
return out;
|
|
1802
1779
|
}
|
|
1803
|
-
function
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
verifyRef(head, "HEAD");
|
|
1817
|
-
let out;
|
|
1818
|
-
if (outFile) {
|
|
1819
|
-
out = outFile;
|
|
1820
|
-
} else {
|
|
1821
|
-
const sddDir = opts.sddDir ?? process.env.SDD_DIR;
|
|
1822
|
-
if (!sddDir) {
|
|
1823
|
-
throw new SddScriptError("mstar sdd review-package: set SDD_DIR or pass OUTFILE", 2);
|
|
1780
|
+
function defaultWorkspaceRoot(startDir) {
|
|
1781
|
+
try {
|
|
1782
|
+
const cdup = execFileSync("git", ["rev-parse", "--show-cdup"], {
|
|
1783
|
+
cwd: startDir,
|
|
1784
|
+
encoding: "utf8",
|
|
1785
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1786
|
+
}).trim();
|
|
1787
|
+
if (!cdup)
|
|
1788
|
+
return startDir;
|
|
1789
|
+
let boundary = startDir;
|
|
1790
|
+
for (const segment of cdup.split(/[\\/]/)) {
|
|
1791
|
+
if (segment && segment !== ".")
|
|
1792
|
+
boundary = dirname5(boundary);
|
|
1824
1793
|
}
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1794
|
+
return resolve5(boundary);
|
|
1795
|
+
} catch {}
|
|
1796
|
+
return startDir;
|
|
1797
|
+
}
|
|
1798
|
+
function isAtOrBelow2(dir, root) {
|
|
1799
|
+
const rel = relative2(root, dir);
|
|
1800
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
|
|
1801
|
+
}
|
|
1802
|
+
function mstarcDirOverride(harnessDir, key) {
|
|
1803
|
+
const dir = resolve5(harnessDir);
|
|
1804
|
+
const rc = loadMstarc(dir, dirname5(dir));
|
|
1805
|
+
const declared = rc?.config[key];
|
|
1806
|
+
return declared ? resolve5(rc.dir, declared) : null;
|
|
1807
|
+
}
|
|
1808
|
+
function resolveSpecsDir(harnessDir, opts = {}) {
|
|
1809
|
+
const declared = mstarcDirOverride(harnessDir, "specsDir");
|
|
1810
|
+
if (declared !== null) {
|
|
1811
|
+
if (opts.create !== false)
|
|
1812
|
+
mkdirSync4(declared, { recursive: true });
|
|
1813
|
+
return declared;
|
|
1829
1814
|
}
|
|
1830
|
-
const
|
|
1831
|
-
const
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
## Files changed
|
|
1839
|
-
`),
|
|
1840
|
-
run(["diff", "--stat", `${base}..${head}`]),
|
|
1841
|
-
Buffer.from(`
|
|
1842
|
-
## Diff
|
|
1843
|
-
`),
|
|
1844
|
-
run(["diff", "-U10", `${base}..${head}`])
|
|
1815
|
+
const harness = resolve5(harnessDir);
|
|
1816
|
+
const repoRoot = dirname5(harness);
|
|
1817
|
+
const candidates = [
|
|
1818
|
+
join8(harness, "specs"),
|
|
1819
|
+
join8(repoRoot, "docs", "specs"),
|
|
1820
|
+
join8(repoRoot, "specs"),
|
|
1821
|
+
join8(harness, "designs"),
|
|
1822
|
+
join8(repoRoot, "designs")
|
|
1845
1823
|
];
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
function assertBaseSha(ref, opts = {}) {
|
|
1850
|
-
if (typeof ref !== "string" || !/^[0-9a-f]{4,40}$/i.test(ref)) {
|
|
1851
|
-
throw new SddScriptError(`assertBaseSha: BASE must be a commit SHA (full or prefix); got ${JSON.stringify(ref)}. ` + "Never use HEAD~1 as review BASE (multi-commit tasks truncate).", 2);
|
|
1824
|
+
for (const candidate of candidates) {
|
|
1825
|
+
if (isDirectory(candidate) && hasFiles(candidate))
|
|
1826
|
+
return candidate;
|
|
1852
1827
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1828
|
+
const fallback = join8(harness, "specs");
|
|
1829
|
+
if (opts.create !== false)
|
|
1830
|
+
mkdirSync4(fallback, { recursive: true });
|
|
1831
|
+
return fallback;
|
|
1832
|
+
}
|
|
1833
|
+
function resolvePlanDir(harnessDir) {
|
|
1834
|
+
const declared = mstarcDirOverride(harnessDir, "planDir");
|
|
1835
|
+
if (declared !== null)
|
|
1836
|
+
return declared;
|
|
1837
|
+
const dir = resolve5(harnessDir);
|
|
1838
|
+
const name = basename2(dir);
|
|
1839
|
+
if (name === ".plans" || name === "plans")
|
|
1840
|
+
return dir;
|
|
1841
|
+
return join8(dir, "plans");
|
|
1842
|
+
}
|
|
1843
|
+
function assertSafePathComponent(value, what) {
|
|
1844
|
+
if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
1845
|
+
throw new Error(`${what} must be a single safe path component ([A-Za-z0-9._-]+; not "", ".", "..", or containing "/" or "\\") — got ${JSON.stringify(value)}`);
|
|
1860
1846
|
}
|
|
1861
1847
|
}
|
|
1862
|
-
function
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1848
|
+
function resolveSddDir(harnessDir, planId) {
|
|
1849
|
+
assertSafePathComponent(planId, "planId");
|
|
1850
|
+
const base = resolve5(harnessDir);
|
|
1851
|
+
const declared = mstarcDirOverride(base, "sddDir");
|
|
1852
|
+
const sddBase = declared !== null ? declared : join8(base, "sdd");
|
|
1853
|
+
return join8(sddBase, planId);
|
|
1854
|
+
}
|
|
1855
|
+
function resolveIterationDir(harnessDir) {
|
|
1856
|
+
const declared = mstarcDirOverride(harnessDir, "iterationDir");
|
|
1857
|
+
if (declared !== null)
|
|
1858
|
+
return declared;
|
|
1859
|
+
return join8(resolve5(harnessDir), "iterations");
|
|
1860
|
+
}
|
|
1861
|
+
function resolveKnowledgeDir(harnessDir) {
|
|
1862
|
+
const declared = mstarcDirOverride(harnessDir, "knowledgeDir");
|
|
1863
|
+
if (declared !== null)
|
|
1864
|
+
return declared;
|
|
1865
|
+
return join8(resolve5(harnessDir), "knowledge");
|
|
1866
|
+
}
|
|
1867
|
+
function resolveHarnessSubdir(startDir, opts, key, fallback) {
|
|
1868
|
+
const harness = resolveHarnessDir(startDir, opts);
|
|
1869
|
+
if (harness === null) {
|
|
1870
|
+
throw new Error(`harness dir not found from ${resolve5(startDir)} — cannot resolve the ${fallback} dir (run \`mstar harness scaffold\`, pass opts.harnessDir, or set MSTAR_HARNESS_DIR)`);
|
|
1868
1871
|
}
|
|
1872
|
+
const declared = mstarcDirOverride(harness, key);
|
|
1873
|
+
return declared !== null ? declared : join8(resolve5(harness), fallback);
|
|
1869
1874
|
}
|
|
1870
|
-
function
|
|
1875
|
+
function resolveWorkflowDir(startDir = process.cwd(), opts = {}) {
|
|
1876
|
+
return resolveHarnessSubdir(startDir, opts, "workflowDir", "workflows");
|
|
1877
|
+
}
|
|
1878
|
+
function resolveProjectDir(startDir = process.cwd(), opts = {}) {
|
|
1879
|
+
return resolveHarnessSubdir(startDir, opts, "projectDir", "projects");
|
|
1880
|
+
}
|
|
1881
|
+
var EMPTY_STATUS_TEMPLATE = {
|
|
1882
|
+
version: 2,
|
|
1883
|
+
updated_at: "1970-01-01",
|
|
1884
|
+
workflows: []
|
|
1885
|
+
};
|
|
1886
|
+
var SCAFFOLD_DIRS = ["plans", "iterations", "knowledge", "specs", "sdd"];
|
|
1887
|
+
function resolveScaffoldDirs(root) {
|
|
1888
|
+
const start = resolve5(root);
|
|
1889
|
+
const boundary = resolve5(start, defaultWorkspaceRoot(start));
|
|
1890
|
+
const rc = loadMstarc(start, boundary);
|
|
1891
|
+
const explicit = process.env.MSTAR_HARNESS_DIR;
|
|
1892
|
+
const harnessDir = explicit ? resolve5(start, explicit) : rc !== null && rc.config.harnessDir ? resolve5(rc.dir, rc.config.harnessDir) : join8(start, ".mstar");
|
|
1893
|
+
const declaredProjectDir = mstarcDirOverride(harnessDir, "projectDir");
|
|
1894
|
+
const projectDir = declaredProjectDir !== null ? declaredProjectDir : join8(harnessDir, "projects");
|
|
1895
|
+
return { harnessDir, projectDir };
|
|
1896
|
+
}
|
|
1897
|
+
var ROADMAP_TEMPLATE = `---
|
|
1898
|
+
project_id: _default
|
|
1899
|
+
title: Default Project
|
|
1900
|
+
status: active
|
|
1901
|
+
created_at: {created_at}
|
|
1902
|
+
---
|
|
1903
|
+
|
|
1904
|
+
# Roadmap
|
|
1905
|
+
|
|
1906
|
+
## Direction
|
|
1907
|
+
|
|
1908
|
+
State the project direction here.
|
|
1909
|
+
`;
|
|
1910
|
+
var EMPTY_REGISTER_TEMPLATE = {
|
|
1911
|
+
entries: {}
|
|
1912
|
+
};
|
|
1913
|
+
function scaffoldHarness(root) {
|
|
1914
|
+
const { harnessDir, projectDir } = resolveScaffoldDirs(root);
|
|
1915
|
+
for (const dir of SCAFFOLD_DIRS)
|
|
1916
|
+
mkdirSync4(join8(harnessDir, dir), { recursive: true });
|
|
1917
|
+
const statusPath = join8(harnessDir, "status.json");
|
|
1918
|
+
if (Object.keys(readJson(statusPath)).length === 0)
|
|
1919
|
+
writeJson(statusPath, EMPTY_STATUS_TEMPLATE);
|
|
1920
|
+
const defaultProjectDir = join8(projectDir, _DEFAULT_PROJECT);
|
|
1921
|
+
mkdirSync4(defaultProjectDir, { recursive: true });
|
|
1922
|
+
const roadmapPath = join8(defaultProjectDir, PROJECT_ROADMAP_FILE);
|
|
1923
|
+
if (!existsSync5(roadmapPath)) {
|
|
1924
|
+
const created = new Date().toISOString().slice(0, 10);
|
|
1925
|
+
writeFileSync3(roadmapPath, ROADMAP_TEMPLATE.replace("{created_at}", created), "utf8");
|
|
1926
|
+
}
|
|
1927
|
+
const registerPath = join8(defaultProjectDir, PROJECT_REGISTER_FILE);
|
|
1928
|
+
if (Object.keys(readJson(registerPath)).length === 0)
|
|
1929
|
+
writeJson(registerPath, EMPTY_REGISTER_TEMPLATE);
|
|
1930
|
+
return harnessDir;
|
|
1931
|
+
}
|
|
1932
|
+
var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
|
|
1933
|
+
# Principle: process stays local; results are shared with the team.
|
|
1934
|
+
# Default-ignore everything under .mstar/, then re-include the tracked results.
|
|
1935
|
+
.mstar/**
|
|
1936
|
+
!.mstar/AGENTS.md
|
|
1937
|
+
!.mstar/knowledge/
|
|
1938
|
+
!.mstar/knowledge/**
|
|
1939
|
+
!.mstar/specs/
|
|
1940
|
+
!.mstar/specs/**
|
|
1941
|
+
# .mstarc — repo-local harness config (may declare [config] harness_dir=<name>)
|
|
1942
|
+
.mstarc
|
|
1943
|
+
`;
|
|
1944
|
+
var GITIGNORE_SNIPPET_AGENTS = `# Morning Star harness (.agents/) — legacy
|
|
1945
|
+
# Default-ignore everything under .agents/, then re-include the tracked results.
|
|
1946
|
+
.agents/**
|
|
1947
|
+
!.agents/AGENTS.md
|
|
1948
|
+
!.agents/knowledge/
|
|
1949
|
+
!.agents/knowledge/**
|
|
1950
|
+
!.agents/specs/
|
|
1951
|
+
!.agents/specs/**
|
|
1952
|
+
`;
|
|
1953
|
+
var GITIGNORE_PROCESS_ENTRIES = GITIGNORE_SNIPPET.split(`
|
|
1954
|
+
`).filter((line) => line.startsWith(".mstar/") || line.startsWith("!.mstar/")).map((line) => line.trim());
|
|
1955
|
+
var GITIGNORE_PROCESS_ENTRIES_AGENTS = GITIGNORE_SNIPPET_AGENTS.split(`
|
|
1956
|
+
`).filter((line) => line.startsWith(".agents/") || line.startsWith("!.agents/")).map((line) => line.trim());
|
|
1957
|
+
function emitGitignoreSnippet(kind) {
|
|
1958
|
+
if (kind === "agents")
|
|
1959
|
+
return GITIGNORE_SNIPPET_AGENTS;
|
|
1960
|
+
if (kind === "mstar")
|
|
1961
|
+
return GITIGNORE_SNIPPET;
|
|
1962
|
+
return `${GITIGNORE_SNIPPET}${GITIGNORE_SNIPPET_AGENTS}`;
|
|
1963
|
+
}
|
|
1964
|
+
function validateGitignore(root) {
|
|
1965
|
+
const gitignorePath = join8(resolve5(root), ".gitignore");
|
|
1966
|
+
const kind = detectHarnessKind(resolveHarnessDir(root));
|
|
1871
1967
|
let content;
|
|
1872
1968
|
try {
|
|
1873
|
-
content =
|
|
1969
|
+
content = readFileSync6(gitignorePath, "utf8");
|
|
1874
1970
|
} catch {
|
|
1875
|
-
return [];
|
|
1876
|
-
}
|
|
1877
|
-
return content.split(`
|
|
1878
|
-
`).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
1879
|
-
}
|
|
1880
|
-
function implementerSessionStickyRules(input) {
|
|
1881
|
-
const { session, nextTask, microBatchTasks = 1 } = input;
|
|
1882
|
-
if (session.session_mode !== "sticky") {
|
|
1883
|
-
return { resume: false, reason: `session_mode is '${session.session_mode}'; sticky resume requires 'sticky'` };
|
|
1884
|
-
}
|
|
1885
|
-
if (typeof session.host_agent_id !== "string" || session.host_agent_id.length === 0) {
|
|
1886
|
-
return {
|
|
1887
|
-
resume: false,
|
|
1888
|
-
reason: "host_agent_id is missing from implementer-session.json; fall back to fresh for this task " + "(mstar-sdd SKILL.md red flag: resume implementer without host_agent_id)"
|
|
1889
|
-
};
|
|
1890
|
-
}
|
|
1891
|
-
if (nextTask <= session.last_task) {
|
|
1892
|
-
return {
|
|
1893
|
-
resume: false,
|
|
1894
|
-
reason: `nextTask ${nextTask} <= last_task ${session.last_task}; task already completed in this session`
|
|
1895
|
-
};
|
|
1896
|
-
}
|
|
1897
|
-
if (microBatchTasks < 1 || microBatchTasks > 3) {
|
|
1898
1971
|
return {
|
|
1899
|
-
|
|
1900
|
-
|
|
1972
|
+
ok: false,
|
|
1973
|
+
severity: "medium",
|
|
1974
|
+
code: "gitignore.missing",
|
|
1975
|
+
message: `no .gitignore found at ${gitignorePath}`,
|
|
1976
|
+
fix: `append the canonical snippet (emitGitignoreSnippet(${kind ? `"${kind}"` : ""})) to ${gitignorePath}`
|
|
1901
1977
|
};
|
|
1902
1978
|
}
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
if (Array.isArray(value))
|
|
1918
|
-
return "array";
|
|
1919
|
-
return typeof value;
|
|
1920
|
-
}
|
|
1921
|
-
function validateCompassShape(doc) {
|
|
1922
|
-
const issues = [];
|
|
1923
|
-
const expectString = (key, opts = {}) => {
|
|
1924
|
-
const value = doc[key];
|
|
1925
|
-
if (typeof value !== "string") {
|
|
1926
|
-
issues.push({ path: [key], message: `expected string, received ${typeName(value)}` });
|
|
1927
|
-
return;
|
|
1928
|
-
}
|
|
1929
|
-
if (opts.min !== undefined && value.length < opts.min) {
|
|
1930
|
-
issues.push({ path: [key], message: `string must contain at least ${opts.min} character(s)` });
|
|
1931
|
-
return;
|
|
1932
|
-
}
|
|
1933
|
-
if (opts.regex !== undefined && !opts.regex.test(value)) {
|
|
1934
|
-
issues.push({ path: [key], message: `string must match ${opts.regex}` });
|
|
1935
|
-
}
|
|
1936
|
-
};
|
|
1937
|
-
expectString("iteration_id", { min: 1 });
|
|
1938
|
-
expectString("start_date", { regex: DATE_RE2 });
|
|
1939
|
-
const status = doc.status;
|
|
1940
|
-
if (typeof status !== "string" || !COMPASS_STATUSES.includes(status)) {
|
|
1941
|
-
issues.push({
|
|
1942
|
-
path: ["status"],
|
|
1943
|
-
message: `expected one of ${COMPASS_STATUSES.map((s) => `'${s}'`).join(" | ")}, received ${typeName(status)}`
|
|
1944
|
-
});
|
|
1945
|
-
}
|
|
1946
|
-
expectString("iteration_base_branch", { min: 1 });
|
|
1947
|
-
expectString("target_branch", { min: 1 });
|
|
1948
|
-
const plans = doc.plans;
|
|
1949
|
-
if (plans !== undefined) {
|
|
1950
|
-
if (!Array.isArray(plans)) {
|
|
1951
|
-
issues.push({ path: ["plans"], message: `expected array, received ${typeName(plans)}` });
|
|
1952
|
-
} else {
|
|
1953
|
-
plans.forEach((entry, index) => {
|
|
1954
|
-
if (typeof entry !== "string") {
|
|
1955
|
-
issues.push({ path: ["plans", index], message: `expected string, received ${typeName(entry)}` });
|
|
1956
|
-
} else if (entry.length < 1) {
|
|
1957
|
-
issues.push({ path: ["plans", index], message: "string must contain at least 1 character(s)" });
|
|
1958
|
-
}
|
|
1959
|
-
});
|
|
1960
|
-
}
|
|
1979
|
+
const lines = new Set(content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0));
|
|
1980
|
+
const mstarMissing = GITIGNORE_PROCESS_ENTRIES.filter((entry) => !lines.has(entry));
|
|
1981
|
+
const agentsMissing = GITIGNORE_PROCESS_ENTRIES_AGENTS.filter((entry) => !lines.has(entry));
|
|
1982
|
+
let missing;
|
|
1983
|
+
let label;
|
|
1984
|
+
if (kind === "agents") {
|
|
1985
|
+
missing = agentsMissing;
|
|
1986
|
+
label = ".agents/ set";
|
|
1987
|
+
} else if (kind === "mstar") {
|
|
1988
|
+
missing = mstarMissing;
|
|
1989
|
+
label = ".mstar/ set";
|
|
1990
|
+
} else {
|
|
1991
|
+
label = "either .mstar/ or .agents/ set";
|
|
1992
|
+
missing = mstarMissing.length === 0 || agentsMissing.length === 0 ? [] : mstarMissing.length <= agentsMissing.length ? mstarMissing : agentsMissing;
|
|
1961
1993
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1994
|
+
if (missing.length > 0) {
|
|
1995
|
+
return {
|
|
1996
|
+
ok: false,
|
|
1997
|
+
severity: "medium",
|
|
1998
|
+
code: "gitignore.missing-entries",
|
|
1999
|
+
message: `.gitignore at ${gitignorePath} is missing canonical harness ignore entries (${label}): ${missing.join(", ")}`,
|
|
2000
|
+
fix: `append the canonical snippet (emitGitignoreSnippet(${kind ? `"${kind}"` : ""})) to ${gitignorePath}`
|
|
2001
|
+
};
|
|
1969
2002
|
}
|
|
1970
|
-
if (issues.length > 0)
|
|
1971
|
-
return { ok: false, issues };
|
|
1972
2003
|
return {
|
|
1973
2004
|
ok: true,
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
status,
|
|
1978
|
-
iteration_base_branch: doc.iteration_base_branch,
|
|
1979
|
-
target_branch: doc.target_branch,
|
|
1980
|
-
...plans !== undefined ? { plans } : {},
|
|
1981
|
-
...end_date !== undefined ? { end_date } : {}
|
|
1982
|
-
}
|
|
2005
|
+
severity: "low",
|
|
2006
|
+
code: "gitignore.ok",
|
|
2007
|
+
message: `.gitignore at ${gitignorePath} contains a complete canonical harness ignore set — default-ignore + tracked re-includes (${label})`
|
|
1983
2008
|
};
|
|
1984
2009
|
}
|
|
1985
|
-
function
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2010
|
+
function detectHarnessKind(harnessDir) {
|
|
2011
|
+
if (!harnessDir)
|
|
2012
|
+
return null;
|
|
2013
|
+
const name = basename2(resolve5(harnessDir));
|
|
2014
|
+
if (name === ".mstar")
|
|
2015
|
+
return "mstar";
|
|
2016
|
+
if (name === ".agents")
|
|
2017
|
+
return "agents";
|
|
2018
|
+
return null;
|
|
1990
2019
|
}
|
|
1991
|
-
function
|
|
1992
|
-
|
|
2020
|
+
function assertPlanWritingPath(planPath, harnessDir) {
|
|
2021
|
+
const planAbs = resolve5(planPath);
|
|
2022
|
+
if (!harnessDir) {
|
|
1993
2023
|
return {
|
|
1994
2024
|
ok: false,
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
2025
|
+
severity: "high",
|
|
2026
|
+
code: "plan-path.no-harness",
|
|
2027
|
+
message: `persistent plan tracking is not enabled — cannot place plan ${planAbs} under {PLAN_DIR}`,
|
|
2028
|
+
fix: "initialize the harness (scaffoldHarness) so plans land in {PLAN_DIR}"
|
|
1998
2029
|
};
|
|
1999
2030
|
}
|
|
2000
|
-
const
|
|
2001
|
-
|
|
2031
|
+
const planDir = resolvePlanDir(harnessDir);
|
|
2032
|
+
const rel = relative2(planDir, planAbs);
|
|
2033
|
+
const inside = rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
|
|
2034
|
+
if (!inside) {
|
|
2002
2035
|
return {
|
|
2003
2036
|
ok: false,
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
}
|
|
2037
|
+
severity: "high",
|
|
2038
|
+
code: "plan-path.outside-plan-dir",
|
|
2039
|
+
message: `plan file ${planAbs} is outside {PLAN_DIR} (${planDir})`,
|
|
2040
|
+
fix: `write the plan under ${planDir}`
|
|
2008
2041
|
};
|
|
2009
2042
|
}
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2043
|
+
if (existsSync5(planAbs)) {
|
|
2044
|
+
try {
|
|
2045
|
+
const canonicalPlan = realpathSync2(planAbs);
|
|
2046
|
+
const canonicalPlanDir = existsSync5(planDir) ? realpathSync2(planDir) : resolve5(planDir);
|
|
2047
|
+
const canonicalRel = relative2(canonicalPlanDir, canonicalPlan);
|
|
2048
|
+
const canonicalInside = canonicalRel === "" || !canonicalRel.startsWith("..") && !isAbsolute3(canonicalRel);
|
|
2049
|
+
if (!canonicalInside) {
|
|
2050
|
+
return {
|
|
2051
|
+
ok: false,
|
|
2052
|
+
severity: "high",
|
|
2053
|
+
code: "plan-path.symlink-escape",
|
|
2054
|
+
message: `plan file ${planAbs} resolves to ${canonicalPlan}, outside {PLAN_DIR} (${canonicalPlanDir})`,
|
|
2055
|
+
fix: `write the plan under ${planDir}`
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
} catch {}
|
|
2017
2059
|
}
|
|
2018
|
-
return {
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2060
|
+
return {
|
|
2061
|
+
ok: true,
|
|
2062
|
+
severity: "low",
|
|
2063
|
+
code: "plan-path.ok",
|
|
2064
|
+
message: `plan file ${planAbs} lives under {PLAN_DIR} (${planDir})`
|
|
2065
|
+
};
|
|
2024
2066
|
}
|
|
2025
|
-
function
|
|
2026
|
-
|
|
2027
|
-
return
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
continue;
|
|
2031
|
-
const rowId = typeof row.id === "string" ? row.id : typeof row.plan_id === "string" ? row.plan_id : null;
|
|
2032
|
-
if (rowId === planId)
|
|
2033
|
-
return row;
|
|
2067
|
+
function isDirectory(dir) {
|
|
2068
|
+
try {
|
|
2069
|
+
return statSync3(dir).isDirectory();
|
|
2070
|
+
} catch {
|
|
2071
|
+
return false;
|
|
2034
2072
|
}
|
|
2035
|
-
return null;
|
|
2036
2073
|
}
|
|
2037
|
-
function
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
violations.push(violation6("high", "PLAN_NOT_IN_STATUS", `Plan '${planId}' is registered in the compass frontmatter but has no row in the workflow snapshot plans[] (mstar-iteration §3.1 entry item 1)`, "Add the plan row to {HARNESS_DIR}/workflows/<id>/snapshot.json"));
|
|
2047
|
-
continue;
|
|
2048
|
-
}
|
|
2049
|
-
if (row.status !== PLAN_STATUS_DONE) {
|
|
2050
|
-
violations.push(violation6("high", "PLAN_NOT_DONE", `Plan '${planId}' status is ${JSON.stringify(row.status)} in the workflow snapshot — all compass-registered plans must be 'Done' before iteration-close (mstar-iteration §3.1 entry item 1)`));
|
|
2074
|
+
function hasFiles(dir) {
|
|
2075
|
+
try {
|
|
2076
|
+
for (const entry of readdirSync4(dir, { withFileTypes: true })) {
|
|
2077
|
+
if (entry.isDirectory()) {
|
|
2078
|
+
if (hasFiles(join8(dir, entry.name)))
|
|
2079
|
+
return true;
|
|
2080
|
+
} else if (entry.isFile()) {
|
|
2081
|
+
return true;
|
|
2082
|
+
}
|
|
2051
2083
|
}
|
|
2084
|
+
return false;
|
|
2085
|
+
} catch {
|
|
2086
|
+
return false;
|
|
2052
2087
|
}
|
|
2053
|
-
return violations;
|
|
2054
2088
|
}
|
|
2055
|
-
|
|
2056
|
-
|
|
2089
|
+
// src/worktree.ts
|
|
2090
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2091
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
2092
|
+
import { isAbsolute as isAbsolute4, resolve as resolve6 } from "node:path";
|
|
2093
|
+
var DEFAULT_PROBE_TIMEOUT_MS = 1e4;
|
|
2094
|
+
function probeTimeoutMs() {
|
|
2095
|
+
const raw = process.env.MSTAR_GIT_PROBE_TIMEOUT_MS;
|
|
2096
|
+
if (raw === undefined || raw.trim() === "")
|
|
2097
|
+
return DEFAULT_PROBE_TIMEOUT_MS;
|
|
2098
|
+
const parsed = Number(raw);
|
|
2099
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PROBE_TIMEOUT_MS;
|
|
2057
2100
|
}
|
|
2058
|
-
function
|
|
2059
|
-
|
|
2060
|
-
if (compassDoc.status !== "completed") {
|
|
2061
|
-
violations.push(violation6("high", "EXIT_STATUS_NOT_COMPLETED", `Compass frontmatter status must be 'completed' at close exit — current: ${JSON.stringify(compassDoc.status)} (mstar-iteration §3.4 / §3.5 exit item 4)`));
|
|
2062
|
-
}
|
|
2063
|
-
const endDate = compassDoc.end_date;
|
|
2064
|
-
if (typeof endDate !== "string" || !DATE_RE2.test(endDate)) {
|
|
2065
|
-
violations.push(violation6("high", "EXIT_END_DATE_REQUIRED", "Compass frontmatter end_date (YYYY-MM-DD) is required when closing (mstar-iteration §3.4 / §3.5 exit item 4)"));
|
|
2066
|
-
}
|
|
2067
|
-
return violations;
|
|
2101
|
+
function violation7(severity, code, message, fix) {
|
|
2102
|
+
return { ok: false, severity, code, message, fix };
|
|
2068
2103
|
}
|
|
2069
|
-
function
|
|
2070
|
-
|
|
2071
|
-
const { currentBranch, specIntegrationBranch } = opts;
|
|
2072
|
-
if (currentBranch === undefined || specIntegrationBranch === undefined) {
|
|
2073
|
-
violations.push(violation6("medium", "EXIT_BRANCH_UNVERIFIABLE", "Cannot verify the current branch is spec_integration_branch — missing currentBranch / specIntegrationBranch probe inputs (mstar-iteration §3.5 exit item 5)"));
|
|
2074
|
-
} else if (currentBranch !== specIntegrationBranch) {
|
|
2075
|
-
violations.push(violation6("high", "EXIT_BRANCH_MISMATCH", `Current branch '${currentBranch}' is not the spec_integration_branch '${specIntegrationBranch}' (mstar-iteration §3.5 exit item 5)`));
|
|
2076
|
-
}
|
|
2077
|
-
return violations;
|
|
2104
|
+
function gate(violations) {
|
|
2105
|
+
return { ok: violations.length === 0, violations };
|
|
2078
2106
|
}
|
|
2079
|
-
function
|
|
2080
|
-
const
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2107
|
+
function probeBranch(worktreePath, opts) {
|
|
2108
|
+
const precomputed = opts.branchOf?.(worktreePath);
|
|
2109
|
+
if (precomputed !== undefined)
|
|
2110
|
+
return { branch: precomputed };
|
|
2111
|
+
const timeout = opts.timeoutMs ?? probeTimeoutMs();
|
|
2112
|
+
try {
|
|
2113
|
+
const stdout = execFileSync2(opts.gitPath ?? "git", ["-C", worktreePath, "branch", "--show-current"], {
|
|
2114
|
+
encoding: "utf8",
|
|
2115
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2116
|
+
timeout
|
|
2117
|
+
});
|
|
2118
|
+
const branch = stdout.trim();
|
|
2119
|
+
if (branch === "")
|
|
2120
|
+
return { error: `no branch checked out (detached HEAD?) at "${worktreePath}"` };
|
|
2121
|
+
return { branch };
|
|
2122
|
+
} catch (err) {
|
|
2123
|
+
const e = err;
|
|
2124
|
+
if (e.killed === true || e.signal !== undefined) {
|
|
2125
|
+
return { error: `git probe timed out after ${timeout}ms (killed by ${e.signal ?? "SIGTERM"})` };
|
|
2126
|
+
}
|
|
2127
|
+
const detail = (e.stderr !== undefined ? e.stderr.toString().trim() : "") || e.message || "git probe failed";
|
|
2128
|
+
return { error: detail };
|
|
2087
2129
|
}
|
|
2088
|
-
return violations;
|
|
2089
|
-
}
|
|
2090
|
-
function evaluatePhaseGate(snapshotDoc, compassDoc, opts = {}) {
|
|
2091
|
-
const registered = registeredPlanIds(compassDoc);
|
|
2092
|
-
const entryViolations = [
|
|
2093
|
-
...entryPlansAllDone(snapshotDoc, registered),
|
|
2094
|
-
...entryFrontmatterComplete(compassDoc)
|
|
2095
|
-
];
|
|
2096
|
-
const exitViolations = [
|
|
2097
|
-
...exitFrontmatterClosed(compassDoc),
|
|
2098
|
-
...exitBranchCheck(opts),
|
|
2099
|
-
...exitPrBaseCheck(compassDoc, opts)
|
|
2100
|
-
];
|
|
2101
|
-
const allPlansDone = registered.length > 0 && registered.every((planId) => {
|
|
2102
|
-
const row = findPlanRow(snapshotDoc, planId);
|
|
2103
|
-
return row !== null && row.status === PLAN_STATUS_DONE;
|
|
2104
|
-
});
|
|
2105
|
-
const entry = { ok: entryViolations.length === 0, violations: entryViolations };
|
|
2106
|
-
const exit = { ok: exitViolations.length === 0, violations: exitViolations };
|
|
2107
|
-
let transition;
|
|
2108
|
-
if (!allPlansDone)
|
|
2109
|
-
transition = "phase-2-execute";
|
|
2110
|
-
else if (entry.ok && exit.ok)
|
|
2111
|
-
transition = "phase-4-pr-delivery";
|
|
2112
|
-
else
|
|
2113
|
-
transition = "phase-3-close";
|
|
2114
|
-
const gateBlocking = allPlansDone ? [...entryViolations, ...exitViolations] : [];
|
|
2115
|
-
return {
|
|
2116
|
-
transition,
|
|
2117
|
-
allPlansDone,
|
|
2118
|
-
entry,
|
|
2119
|
-
exit,
|
|
2120
|
-
ok: gateBlocking.length === 0,
|
|
2121
|
-
violations: gateBlocking
|
|
2122
|
-
};
|
|
2123
2130
|
}
|
|
2124
|
-
function
|
|
2131
|
+
function l1PreDispatchCheck(input, opts = {}) {
|
|
2125
2132
|
const violations = [];
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
if (reviewWaveActive) {
|
|
2130
|
-
violations.push(violation6("high", "PUSH_BLOCKED_REVIEW_WAVE", "An AI/bot review wave is still running on the current head — do not push until it settles (mstar-iteration §5.1a push gate 2)", "Wait for the review wave, then push once"));
|
|
2131
|
-
}
|
|
2132
|
-
return { ok: violations.length === 0, violations };
|
|
2133
|
-
}
|
|
2134
|
-
function assertIndexRowObligations(iterationsDir) {
|
|
2135
|
-
if (!existsSync5(iterationsDir)) {
|
|
2136
|
-
return {
|
|
2137
|
-
ok: false,
|
|
2138
|
-
violations: [
|
|
2139
|
-
violation6("high", "INDEX_ITERATIONS_DIR_MISSING", `{ITERATION_DIR} '${iterationsDir}' does not exist (mstar-iteration §1.4)`, "Create the iterations directory (path.resolveIterationDir)")
|
|
2140
|
-
]
|
|
2141
|
-
};
|
|
2133
|
+
const { controlWorktreePath, leaseWorktreePath, leaseWorkingBranch, planId } = input;
|
|
2134
|
+
if (controlWorktreePath.trim() === "") {
|
|
2135
|
+
violations.push(violation7("high", "worktree.l1.control-missing", "metadata.control_worktree_path is not recorded — the L1 control worktree (integration-branch checkout) must be recorded in status.json before writable dispatch", "record the control worktree path in status.json metadata.control_worktree_path"));
|
|
2142
2136
|
}
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
if (!existsSync5(readmePath)) {
|
|
2146
|
-
return {
|
|
2147
|
-
ok: false,
|
|
2148
|
-
violations: [
|
|
2149
|
-
violation6("high", "INDEX_README_MISSING", `{ITERATION_DIR}/README.md does not exist — one row per iteration is required (mstar-iteration §1.4)`, `Create {ITERATION_DIR}/README.md with the header '${INDEX_HEADER}' and one row per iteration`)
|
|
2150
|
-
]
|
|
2151
|
-
};
|
|
2137
|
+
if (leaseWorktreePath.trim() === "") {
|
|
2138
|
+
violations.push(violation7("high", "worktree.l1.lease-missing", `execution_lease.worktree_path is empty for plan "${planId}" — no verified execution_lease to dispatch against`, "claim the execution_lease with an absolute feature worktree path before dispatch"));
|
|
2152
2139
|
}
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
if (!lines.some((line) => line.includes(INDEX_HEADER))) {
|
|
2156
|
-
violations.push(violation6("medium", "INDEX_HEADER_MISSING", `{ITERATION_DIR}/README.md lacks the table header '${INDEX_HEADER}' (mstar-iteration §1.4)`, "Add the header row on first creation"));
|
|
2140
|
+
if (leaseWorkingBranch.trim() === "") {
|
|
2141
|
+
violations.push(violation7("high", "worktree.l1.lease-branch-missing", `execution_lease.working_branch is empty for plan "${planId}"`, "record the lease working_branch before dispatch"));
|
|
2157
2142
|
}
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
const match = line.match(/^\s*\|\s*`([^`]+)`\s*\|/);
|
|
2161
|
-
if (match)
|
|
2162
|
-
indexed.add(match[1].trim());
|
|
2143
|
+
if (controlWorktreePath !== "" && leaseWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(leaseWorktreePath)) {
|
|
2144
|
+
violations.push(violation7("critical", "worktree.l1.lease-equals-control", `execution_lease.worktree_path "${leaseWorktreePath}" equals metadata.control_worktree_path — the feature worktree MUST differ from the control worktree (L1 isolation; product edits never land in the control checkout)`, "use a distinct feature worktree for the plan (git worktree add <path> <branch>) and update the lease"));
|
|
2163
2145
|
}
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2146
|
+
if (leaseWorktreePath !== "" && !existsSync6(leaseWorktreePath)) {
|
|
2147
|
+
violations.push(violation7("high", "worktree.l1.feature-missing", `feature worktree directory "${leaseWorktreePath}" does not exist for plan "${planId}"`, `create it before dispatch: git worktree add ${leaseWorktreePath} <working-branch>`));
|
|
2148
|
+
} else if (leaseWorktreePath !== "" && leaseWorkingBranch !== "") {
|
|
2149
|
+
const probe = probeBranch(leaseWorktreePath, opts);
|
|
2150
|
+
if ("error" in probe) {
|
|
2151
|
+
violations.push(violation7("high", "worktree.l1.branch-probe-failed", `cannot probe branch at "${leaseWorktreePath}" for plan "${planId}": ${probe.error}`, "verify the path is a git worktree checkout on the lease working branch (not detached)"));
|
|
2152
|
+
} else if (probe.branch !== leaseWorkingBranch) {
|
|
2153
|
+
violations.push(violation7("high", "worktree.l1.branch-mismatch", `feature worktree "${leaseWorktreePath}" is on branch "${probe.branch}", expected execution_lease.working_branch "${leaseWorkingBranch}" (plan "${planId}")`, `checkout ${leaseWorkingBranch} in the feature worktree`));
|
|
2167
2154
|
}
|
|
2168
2155
|
}
|
|
2169
|
-
return
|
|
2170
|
-
}
|
|
2171
|
-
function parseCompassFrontmatter(filePath) {
|
|
2172
|
-
return parseCompassFrontmatterText(readFileSync6(filePath, "utf8"), filePath);
|
|
2156
|
+
return gate(violations);
|
|
2173
2157
|
}
|
|
2174
|
-
function
|
|
2175
|
-
const
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
if (end === -1) {
|
|
2181
|
-
throw new Error(`unterminated YAML frontmatter in ${filePath} (no closing "---")`);
|
|
2158
|
+
function l2PreDispatchCheck(input, opts = {}) {
|
|
2159
|
+
const violations = [];
|
|
2160
|
+
const tracks = input.tracks ?? [];
|
|
2161
|
+
const seenPaths = new Set;
|
|
2162
|
+
if (tracks.length < 1) {
|
|
2163
|
+
violations.push(violation7("high", "worktree.l2.no-tracks", "no parallel writable tracks — the L2 pre-dispatch checklist requires at least one track with an absolute worktreePath and Working branch", "pass each track's absolute Worktree path and PM-approved Working branch"));
|
|
2182
2164
|
}
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
if (!line.trim() || line.trim().startsWith("#"))
|
|
2188
|
-
continue;
|
|
2189
|
-
if (listKey !== null && /^\s*-\s+/.test(line)) {
|
|
2190
|
-
const item = line.replace(/^\s*-\s+/, "").trim().replace(/^["']|["']$/g, "");
|
|
2191
|
-
if (!Array.isArray(doc[listKey]))
|
|
2192
|
-
doc[listKey] = [];
|
|
2193
|
-
doc[listKey].push(item);
|
|
2194
|
-
continue;
|
|
2165
|
+
tracks.forEach((track, index) => {
|
|
2166
|
+
if (track.worktreePath.trim() === "" || track.workingBranch.trim() === "") {
|
|
2167
|
+
violations.push(violation7("high", "worktree.l2.track-invalid", `track ${index + 1} is missing worktreePath and/or workingBranch`, "fill both fields for every track"));
|
|
2168
|
+
return;
|
|
2195
2169
|
}
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
throw new Error(`unsupported frontmatter line in ${filePath}: ${JSON.stringify(line)}`);
|
|
2170
|
+
if (!isAbsolute4(track.worktreePath)) {
|
|
2171
|
+
violations.push(violation7("high", "worktree.l2.track-path-relative", `track ${index + 1} worktreePath "${track.worktreePath}" is not an absolute path — L2 tracks MUST use absolute worktree checkout paths (consistent with the lease validator's absolute worktree_path enforcement)`, `use an absolute path for track ${index + 1} (e.g. /Users/<you>/worktrees/<branch>)`));
|
|
2172
|
+
return;
|
|
2200
2173
|
}
|
|
2201
|
-
const
|
|
2202
|
-
|
|
2203
|
-
|
|
2174
|
+
const normalized = resolve6(track.worktreePath);
|
|
2175
|
+
if (seenPaths.has(normalized)) {
|
|
2176
|
+
violations.push(violation7("high", "worktree.l2.track-path-collision", `duplicate worktreePath "${track.worktreePath}" across parallel tracks — L2 parallel-writable isolation requires a distinct absolute Worktree path per track (N parallel invokes ≠ isolation)`, "give every parallel track its own git worktree checkout"));
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
seenPaths.add(normalized);
|
|
2180
|
+
if (!existsSync6(track.worktreePath)) {
|
|
2181
|
+
violations.push(violation7("high", "worktree.l2.track-missing", `track worktree directory "${track.worktreePath}" does not exist`, `create it before dispatch: git worktree add ${track.worktreePath} ${track.workingBranch}`));
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
const probe = probeBranch(track.worktreePath, opts);
|
|
2185
|
+
if ("error" in probe) {
|
|
2186
|
+
violations.push(violation7("high", "worktree.l2.branch-probe-failed", `cannot probe branch at "${track.worktreePath}": ${probe.error}`, "verify the path is a git worktree checkout on its Working branch (not detached)"));
|
|
2187
|
+
} else if (probe.branch !== track.workingBranch) {
|
|
2188
|
+
violations.push(violation7("high", "worktree.l2.branch-mismatch", `track worktree "${track.worktreePath}" is on branch "${probe.branch}", expected Working branch "${track.workingBranch}"`, `checkout ${track.workingBranch} in that worktree`));
|
|
2189
|
+
}
|
|
2190
|
+
});
|
|
2191
|
+
return gate(violations);
|
|
2192
|
+
}
|
|
2193
|
+
function assertControlVsFeaturePath(controlWorktreePath, featureWorktreePath) {
|
|
2194
|
+
const violations = [];
|
|
2195
|
+
const samePath = controlWorktreePath === "" && featureWorktreePath === "" || controlWorktreePath !== "" && featureWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(featureWorktreePath);
|
|
2196
|
+
if (samePath) {
|
|
2197
|
+
violations.push(violation7("critical", "worktree.control-feature.same", `control worktree path equals feature/lease worktree path "${controlWorktreePath}" — execution_lease.worktree_path MUST differ from metadata.control_worktree_path`, "use a distinct feature worktree for the plan's product edits"));
|
|
2204
2198
|
}
|
|
2205
|
-
return
|
|
2199
|
+
return gate(violations);
|
|
2206
2200
|
}
|
|
2207
|
-
function
|
|
2208
|
-
const
|
|
2209
|
-
|
|
2210
|
-
|
|
2201
|
+
function assertBranchAlignment(worktreePath, expectedBranch, opts = {}) {
|
|
2202
|
+
const violations = [];
|
|
2203
|
+
const probe = probeBranch(worktreePath, opts);
|
|
2204
|
+
if ("error" in probe) {
|
|
2205
|
+
violations.push(violation7("high", "worktree.branch-probe-failed", `cannot probe branch at "${worktreePath}": ${probe.error}`, "verify the path is a git worktree checkout on the expected branch (not detached)"));
|
|
2206
|
+
} else if (probe.branch !== expectedBranch) {
|
|
2207
|
+
violations.push(violation7("high", "worktree.branch-mismatch", `worktree "${worktreePath}" is on branch "${probe.branch}", expected "${expectedBranch}" (Assignment Working branch)`, `checkout ${expectedBranch} in that worktree`));
|
|
2211
2208
|
}
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2209
|
+
return gate(violations);
|
|
2210
|
+
}
|
|
2211
|
+
var QC_ALIGNMENT_FIELDS = [
|
|
2212
|
+
{ key: "planId", label: "plan_id" },
|
|
2213
|
+
{ key: "reviewRange", label: "Review range" },
|
|
2214
|
+
{ key: "diffBasis", label: "Diff basis" }
|
|
2215
|
+
];
|
|
2216
|
+
function assertQcAlignment(assignments) {
|
|
2217
|
+
const violations = [];
|
|
2218
|
+
const list = assignments ?? [];
|
|
2219
|
+
for (const { key, label } of QC_ALIGNMENT_FIELDS) {
|
|
2220
|
+
const distinct = [...new Set(list.map((a) => a[key]))];
|
|
2221
|
+
if (distinct.length > 1) {
|
|
2222
|
+
violations.push(violation7("high", "qc.alignment.mismatch", `QC/QA alignment field "${label}" is not byte-identical across ${list.length} assignments: ${distinct.map((v) => `"${v}"`).join(" vs ")}`, `copy the same ${label} value verbatim into every QC tri and QA Assignment`));
|
|
2221
2223
|
}
|
|
2222
2224
|
}
|
|
2223
|
-
|
|
2224
|
-
throw new Error(`unterminated ${quote} quote in flow-style array in ${filePath}: ${JSON.stringify(raw)}`);
|
|
2225
|
-
}
|
|
2226
|
-
const items = [];
|
|
2227
|
-
for (const part of inner.split(",")) {
|
|
2228
|
-
const item = part.trim().replace(/^["']|["']$/g, "");
|
|
2229
|
-
if (item === "")
|
|
2230
|
-
continue;
|
|
2231
|
-
items.push(item);
|
|
2232
|
-
}
|
|
2233
|
-
return items;
|
|
2234
|
-
}
|
|
2235
|
-
// src/project.ts
|
|
2236
|
-
import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync as readdirSync5 } from "node:fs";
|
|
2237
|
-
import { join as join9 } from "node:path";
|
|
2238
|
-
var PROJECT_ROADMAP_FILE = "roadmap.md";
|
|
2239
|
-
var PROJECT_REFERENCES_DIR = "references";
|
|
2240
|
-
var PROJECT_REGISTER_FILE = "residuals.json";
|
|
2241
|
-
var _DEFAULT_PROJECT = "_default";
|
|
2242
|
-
var ROADMAP_STATUSES = ["active", "paused", "completed"];
|
|
2243
|
-
var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
|
|
2244
|
-
var ROLLUP_FIELDS = ["total_open", "by_severity", "by_target", "by_plan"];
|
|
2245
|
-
function isPlainObject5(value) {
|
|
2246
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2225
|
+
return gate(violations);
|
|
2247
2226
|
}
|
|
2248
|
-
function
|
|
2249
|
-
|
|
2227
|
+
function singleReviewSnapshot(assignments) {
|
|
2228
|
+
const violations = [];
|
|
2229
|
+
const list = assignments ?? [];
|
|
2230
|
+
list.forEach((a, index) => {
|
|
2231
|
+
if ((a.head ?? "").trim() === "") {
|
|
2232
|
+
violations.push(violation7("high", "qc.alignment.snapshot-missing", `review head not provided for assignment ${index + 1} (plan_id "${a.planId}") — cannot confirm the single review snapshot precondition`, "precompute and pass the review HEAD (full SHA) for every assignment"));
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
const distinct = [...new Set(list.map((a) => a.head ?? "").filter((h) => h.trim() !== ""))];
|
|
2236
|
+
if (distinct.length > 1) {
|
|
2237
|
+
violations.push(violation7("high", "qc.alignment.single-snapshot", `assignments cover ${distinct.length} different review heads (${distinct.join(", ")}) — all reviewable commits must sit on ONE Working branch HEAD before QC tri + QA`, "merge the parallel tracks to a single Working branch HEAD, then re-derive the heads"));
|
|
2238
|
+
}
|
|
2239
|
+
return gate(violations);
|
|
2250
2240
|
}
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2241
|
+
// src/sdd.ts
|
|
2242
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2243
|
+
import { mkdirSync as mkdirSync5, readdirSync as readdirSync5, readFileSync as readFileSync7, realpathSync as realpathSync3, statSync as statSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2244
|
+
import { basename as basename3, dirname as dirname6, isAbsolute as isAbsolute5, join as join9, resolve as resolve7 } from "node:path";
|
|
2245
|
+
class SddScriptError extends Error {
|
|
2246
|
+
exitCode;
|
|
2247
|
+
constructor(message, exitCode) {
|
|
2248
|
+
super(message);
|
|
2249
|
+
this.name = "SddScriptError";
|
|
2250
|
+
this.exitCode = exitCode;
|
|
2256
2251
|
}
|
|
2257
2252
|
}
|
|
2258
|
-
function
|
|
2259
|
-
const violations = [];
|
|
2260
|
-
let content;
|
|
2253
|
+
function isDirectory2(dir) {
|
|
2261
2254
|
try {
|
|
2262
|
-
|
|
2255
|
+
return statSync4(dir).isDirectory();
|
|
2263
2256
|
} catch {
|
|
2264
|
-
return
|
|
2265
|
-
ok: false,
|
|
2266
|
-
violations: [violation7("high", "project.roadmap.unreadable", `cannot read roadmap file: ${filePath}`)],
|
|
2267
|
-
warnings: []
|
|
2268
|
-
};
|
|
2257
|
+
return false;
|
|
2269
2258
|
}
|
|
2270
|
-
|
|
2259
|
+
}
|
|
2260
|
+
function isFile2(file) {
|
|
2271
2261
|
try {
|
|
2272
|
-
|
|
2273
|
-
} catch
|
|
2274
|
-
|
|
2275
|
-
return { ok: false, violations: [violation7("high", "project.roadmap.invalid-frontmatter", message)], warnings: [] };
|
|
2276
|
-
}
|
|
2277
|
-
validateNonEmptyString4(violations, doc.project_id, "project_id", "project.roadmap.missing-project-id", "project.roadmap.invalid-project-id");
|
|
2278
|
-
validateNonEmptyString4(violations, doc.title, "title", "project.roadmap.missing-title", "project.roadmap.invalid-title");
|
|
2279
|
-
if (doc.status === undefined) {
|
|
2280
|
-
violations.push(violation7("high", "project.roadmap.missing-status", "missing required field: status"));
|
|
2281
|
-
} else if (typeof doc.status !== "string" || !ROADMAP_STATUSES.includes(doc.status)) {
|
|
2282
|
-
violations.push(violation7("medium", "project.roadmap.invalid-status", `status must be one of ${ROADMAP_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
|
|
2283
|
-
}
|
|
2284
|
-
if (doc.created_at === undefined) {
|
|
2285
|
-
violations.push(violation7("high", "project.roadmap.missing-created-at", "missing required field: created_at"));
|
|
2286
|
-
} else if (typeof doc.created_at !== "string" || !DATE_RE3.test(doc.created_at)) {
|
|
2287
|
-
violations.push(violation7("medium", "project.roadmap.invalid-created-at", "created_at must be YYYY-MM-DD"));
|
|
2288
|
-
}
|
|
2289
|
-
if (doc.milestones !== undefined && doc.milestones !== null) {
|
|
2290
|
-
if (!Array.isArray(doc.milestones)) {
|
|
2291
|
-
violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones must be a list of milestone names"));
|
|
2292
|
-
} else {
|
|
2293
|
-
for (const item of doc.milestones) {
|
|
2294
|
-
if (typeof item !== "string" || item.trim() === "") {
|
|
2295
|
-
violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones items must be non-empty strings"));
|
|
2296
|
-
break;
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
}
|
|
2301
|
-
if (doc.residuals_ref !== undefined && doc.residuals_ref !== null) {
|
|
2302
|
-
if (typeof doc.residuals_ref !== "string" || doc.residuals_ref.trim() === "") {
|
|
2303
|
-
violations.push(violation7("medium", "project.roadmap.invalid-residuals-ref", "residuals_ref must be a non-empty string"));
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
const warnings = [];
|
|
2307
|
-
const fenceEnd = linesIndexOfClosingFence(content);
|
|
2308
|
-
const body = content.split(/\r?\n/).slice(fenceEnd + 1).join(`
|
|
2309
|
-
`);
|
|
2310
|
-
if (!/^##\s+Direction\s*$/m.test(body)) {
|
|
2311
|
-
warnings.push(violation7("low", "project.roadmap.body.missing-direction", "roadmap body has no `## Direction` section (documented body convention) — state the project direction there"));
|
|
2262
|
+
return statSync4(file).isFile();
|
|
2263
|
+
} catch {
|
|
2264
|
+
return false;
|
|
2312
2265
|
}
|
|
2313
|
-
|
|
2314
|
-
|
|
2266
|
+
}
|
|
2267
|
+
var GIT_CAPTURE_MAX_BYTES = 64 * 1024 * 1024;
|
|
2268
|
+
function gitOut(cwd, args) {
|
|
2269
|
+
try {
|
|
2270
|
+
return execFileSync3("git", args, {
|
|
2271
|
+
cwd,
|
|
2272
|
+
encoding: "utf8",
|
|
2273
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2274
|
+
maxBuffer: GIT_CAPTURE_MAX_BYTES
|
|
2275
|
+
}).trim();
|
|
2276
|
+
} catch {
|
|
2277
|
+
return null;
|
|
2315
2278
|
}
|
|
2316
|
-
return { ok: violations.length === 0, violations, warnings };
|
|
2317
2279
|
}
|
|
2318
|
-
function
|
|
2319
|
-
|
|
2280
|
+
function probeHarnessWithStatus(root) {
|
|
2281
|
+
if (isFile2(join9(root, ".mstar", "status.json")))
|
|
2282
|
+
return join9(root, ".mstar");
|
|
2283
|
+
if (isFile2(join9(root, ".agents", "status.json")))
|
|
2284
|
+
return join9(root, ".agents");
|
|
2285
|
+
if (hasWorkflowSnapshot(join9(root, ".mstar")))
|
|
2286
|
+
return join9(root, ".mstar");
|
|
2287
|
+
if (hasWorkflowSnapshot(join9(root, ".agents")))
|
|
2288
|
+
return join9(root, ".agents");
|
|
2289
|
+
return null;
|
|
2320
2290
|
}
|
|
2321
|
-
function
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
};
|
|
2291
|
+
function hasWorkflowSnapshot(harnessDir) {
|
|
2292
|
+
let workflowsDir;
|
|
2293
|
+
try {
|
|
2294
|
+
workflowsDir = resolveWorkflowDir(harnessDir, { harnessDir });
|
|
2295
|
+
} catch {
|
|
2296
|
+
workflowsDir = join9(harnessDir, "workflows");
|
|
2328
2297
|
}
|
|
2329
|
-
if (
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
if (key.trim() === "") {
|
|
2336
|
-
violations.push(violation7("medium", "project.register.invalid-key", "entries keys must be non-empty plan ids"));
|
|
2337
|
-
}
|
|
2338
|
-
if (!Array.isArray(entries)) {
|
|
2339
|
-
violations.push(violation7("high", "project.register.invalid-entry-list", `entries[${JSON.stringify(key)}] must be an array of residual entries (one entry per residual; v1 multi-finding semantics)`));
|
|
2340
|
-
continue;
|
|
2341
|
-
}
|
|
2342
|
-
for (const entry of entries) {
|
|
2343
|
-
violations.push(...validateResidual(entry).violations);
|
|
2344
|
-
if (!isPlainObject5(entry))
|
|
2345
|
-
continue;
|
|
2346
|
-
validateNonEmptyString4(violations, entry.source_plan, "source_plan", "project.register.missing-source-plan", "project.register.invalid-source-plan");
|
|
2347
|
-
if (entry.registered_at === undefined) {
|
|
2348
|
-
violations.push(violation7("high", "project.register.missing-registered-at", "missing required field: registered_at"));
|
|
2349
|
-
} else if (typeof entry.registered_at !== "string" || !DATE_RE3.test(entry.registered_at)) {
|
|
2350
|
-
violations.push(violation7("medium", "project.register.invalid-registered-at", "registered_at must be YYYY-MM-DD"));
|
|
2351
|
-
}
|
|
2352
|
-
if (entry.lifecycle_id !== undefined && (typeof entry.lifecycle_id !== "string" || entry.lifecycle_id.trim() === "")) {
|
|
2353
|
-
violations.push(violation7("medium", "project.register.invalid-lifecycle-id", "lifecycle_id must be a non-empty string"));
|
|
2354
|
-
}
|
|
2355
|
-
if (typeof entry.source_plan === "string" && entry.source_plan.trim() !== "" && entry.source_plan !== key) {
|
|
2356
|
-
violations.push(violation7("medium", "project.register.mismatched-source-plan", `source_plan ${JSON.stringify(entry.source_plan)} does not match the entries key ${JSON.stringify(key)} — entries are keyed by plan id`));
|
|
2357
|
-
}
|
|
2358
|
-
}
|
|
2298
|
+
if (!isDirectory2(workflowsDir))
|
|
2299
|
+
return false;
|
|
2300
|
+
try {
|
|
2301
|
+
for (const entry of readdirSync5(workflowsDir, { withFileTypes: true })) {
|
|
2302
|
+
if (entry.isDirectory() && isFile2(join9(workflowsDir, entry.name, "snapshot.json")))
|
|
2303
|
+
return true;
|
|
2359
2304
|
}
|
|
2305
|
+
} catch {
|
|
2306
|
+
return false;
|
|
2307
|
+
}
|
|
2308
|
+
return false;
|
|
2309
|
+
}
|
|
2310
|
+
function isLinkedWorktree(root) {
|
|
2311
|
+
const gitDirRaw = gitOut(root, ["rev-parse", "--git-dir"]);
|
|
2312
|
+
const commonRaw = gitOut(root, ["rev-parse", "--git-common-dir"]);
|
|
2313
|
+
if (gitDirRaw === null || commonRaw === null)
|
|
2314
|
+
return false;
|
|
2315
|
+
const gitDir = isAbsolute5(gitDirRaw) ? gitDirRaw : join9(root, gitDirRaw);
|
|
2316
|
+
const common = isAbsolute5(commonRaw) ? commonRaw : join9(root, commonRaw);
|
|
2317
|
+
if (gitDir.includes("/.git/worktrees/") || gitDir.includes("/worktrees/"))
|
|
2318
|
+
return true;
|
|
2319
|
+
try {
|
|
2320
|
+
const gdParent = realpathSync3(dirname6(gitDir));
|
|
2321
|
+
const cmAbs = realpathSync3(common);
|
|
2322
|
+
return join9(gdParent, basename3(gitDir)) !== cmAbs && gitDir !== cmAbs;
|
|
2323
|
+
} catch {
|
|
2324
|
+
return false;
|
|
2360
2325
|
}
|
|
2361
|
-
return { ok: violations.length === 0, violations };
|
|
2362
2326
|
}
|
|
2363
|
-
function
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
if (entries === undefined) {
|
|
2368
|
-
return { ok: true, violations };
|
|
2327
|
+
function sddWorkspace(planId, opts = {}) {
|
|
2328
|
+
if (!planId) {
|
|
2329
|
+
throw new SddScriptError(`usage: mstar sdd workspace PLAN_ID [CONTROL_ROOT]
|
|
2330
|
+
` + " Set MSTAR_CONTROL_ROOT=<control_worktree_path> when running from a feature worktree.", 2);
|
|
2369
2331
|
}
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2332
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2333
|
+
const controlRoot = opts.controlRoot ?? (process.env.MSTAR_CONTROL_ROOT || undefined);
|
|
2334
|
+
let root;
|
|
2335
|
+
if (controlRoot) {
|
|
2336
|
+
if (!isDirectory2(controlRoot)) {
|
|
2337
|
+
throw new SddScriptError(`mstar sdd workspace: CONTROL_ROOT / MSTAR_CONTROL_ROOT is not a directory: ${controlRoot}`, 1);
|
|
2338
|
+
}
|
|
2339
|
+
root = realpathSync3(controlRoot);
|
|
2340
|
+
} else {
|
|
2341
|
+
const topLevel = gitOut(cwd, ["rev-parse", "--show-toplevel"]);
|
|
2342
|
+
root = realpathSync3(topLevel ?? cwd);
|
|
2373
2343
|
}
|
|
2374
|
-
if (
|
|
2375
|
-
|
|
2344
|
+
if (!controlRoot && isLinkedWorktree(root)) {
|
|
2345
|
+
throw new SddScriptError(`mstar sdd workspace: linked worktree at ${root} has no {HARNESS_DIR}/status.json (default gitignore).
|
|
2346
|
+
` + ` Refusing to create a second SDD tree under the feature checkout.
|
|
2347
|
+
` + ` Re-run with MSTAR_CONTROL_ROOT=<control_worktree_path> or: mstar sdd workspace ${planId} <control_worktree_path>
|
|
2348
|
+
` + ` See mstar-branch-worktree «Harness path SSOT under default gitignore».`, 1);
|
|
2376
2349
|
}
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2350
|
+
const harnessOverride = opts.harnessDir ?? (process.env.MSTAR_HARNESS_DIR || undefined);
|
|
2351
|
+
let harnessDir;
|
|
2352
|
+
if (harnessOverride) {
|
|
2353
|
+
harnessDir = resolve7(root, harnessOverride);
|
|
2354
|
+
} else {
|
|
2355
|
+
const rc = findMstarc(root, root);
|
|
2356
|
+
const rcHarnessDir = rc !== null ? parseMstarc(readFileSync7(rc, "utf8")).harnessDir : undefined;
|
|
2357
|
+
if (rcHarnessDir) {
|
|
2358
|
+
harnessDir = resolve7(rc !== null ? dirname6(rc) : root, rcHarnessDir);
|
|
2359
|
+
} else {
|
|
2360
|
+
const probed = probeHarnessWithStatus(root);
|
|
2361
|
+
if (probed) {
|
|
2362
|
+
harnessDir = probed;
|
|
2363
|
+
} else if (isDirectory2(join9(root, ".mstar"))) {
|
|
2364
|
+
harnessDir = join9(root, ".mstar");
|
|
2365
|
+
} else if (isDirectory2(join9(root, ".agents"))) {
|
|
2366
|
+
harnessDir = join9(root, ".agents");
|
|
2391
2367
|
} else {
|
|
2392
|
-
|
|
2368
|
+
harnessDir = join9(root, ".mstar");
|
|
2393
2369
|
}
|
|
2394
|
-
} else if (normalizeSeverity(entry.severity) === "critical") {
|
|
2395
|
-
violations.push(violation7("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
|
|
2396
2370
|
}
|
|
2397
2371
|
}
|
|
2398
|
-
|
|
2372
|
+
const sddDir = resolveSddDir(harnessDir, planId);
|
|
2373
|
+
mkdirSync5(sddDir, { recursive: true });
|
|
2374
|
+
writeFileSync4(join9(sddDir, ".gitignore"), `*
|
|
2375
|
+
`);
|
|
2376
|
+
return realpathSync3(sddDir);
|
|
2399
2377
|
}
|
|
2400
|
-
function
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
const key = typeof value === "string" ? value : String(value);
|
|
2404
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
2378
|
+
function taskBrief(planFile, taskN, outFile, opts = {}) {
|
|
2379
|
+
if (!planFile || !Number.isInteger(taskN) || taskN < 1) {
|
|
2380
|
+
throw new SddScriptError("usage: mstar sdd task-brief PLAN_FILE TASK_NUMBER [OUTFILE]", 2);
|
|
2405
2381
|
}
|
|
2406
|
-
|
|
2407
|
-
}
|
|
2408
|
-
function techDebtRollup(projectDir) {
|
|
2409
|
-
const items = [];
|
|
2410
|
-
let entries;
|
|
2382
|
+
let content;
|
|
2411
2383
|
try {
|
|
2412
|
-
|
|
2384
|
+
content = readFileSync7(planFile, "utf8");
|
|
2413
2385
|
} catch {
|
|
2414
|
-
|
|
2386
|
+
throw new SddScriptError(`no such plan file: ${planFile}`, 2);
|
|
2415
2387
|
}
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2388
|
+
let out;
|
|
2389
|
+
if (outFile) {
|
|
2390
|
+
out = outFile;
|
|
2391
|
+
} else {
|
|
2392
|
+
const sddDir = opts.sddDir ?? process.env.SDD_DIR;
|
|
2393
|
+
if (!sddDir) {
|
|
2394
|
+
throw new SddScriptError("mstar sdd task-brief: set SDD_DIR or pass OUTFILE (run mstar sdd workspace PLAN_ID first)", 2);
|
|
2395
|
+
}
|
|
2396
|
+
mkdirSync5(sddDir, { recursive: true });
|
|
2397
|
+
out = join9(sddDir, `task-${taskN}-brief.md`);
|
|
2398
|
+
}
|
|
2399
|
+
const records = content.endsWith(`
|
|
2400
|
+
`) ? content.split(`
|
|
2401
|
+
`).slice(0, -1) : content.split(`
|
|
2402
|
+
`);
|
|
2403
|
+
const headingRe = /^#+[ \t]+Task[ \t]+[0-9]+/;
|
|
2404
|
+
const targetRe = new RegExp(`^#+[ ]+Task[ ]+${taskN}([^0-9]|$)`);
|
|
2405
|
+
let infence = false;
|
|
2406
|
+
let intask = false;
|
|
2407
|
+
const printed = [];
|
|
2408
|
+
for (const line of records) {
|
|
2409
|
+
if (/^```/.test(line))
|
|
2410
|
+
infence = !infence;
|
|
2411
|
+
if (!infence && headingRe.test(line))
|
|
2412
|
+
intask = targetRe.test(line);
|
|
2413
|
+
if (intask)
|
|
2414
|
+
printed.push(line);
|
|
2415
|
+
}
|
|
2416
|
+
const output = printed.length > 0 ? `${printed.join(`
|
|
2417
|
+
`)}
|
|
2418
|
+
` : "";
|
|
2419
|
+
writeFileSync4(out, output);
|
|
2420
|
+
if (printed.length === 0) {
|
|
2421
|
+
throw new SddScriptError(`task ${taskN} not found in ${planFile} (no heading matching Task ${taskN})`, 3);
|
|
2422
|
+
}
|
|
2423
|
+
return out;
|
|
2424
|
+
}
|
|
2425
|
+
function reviewPackage(base, head, outFile, opts = {}) {
|
|
2426
|
+
if (!base || !head) {
|
|
2427
|
+
throw new SddScriptError("usage: mstar sdd review-package BASE HEAD [OUTFILE]", 2);
|
|
2428
|
+
}
|
|
2429
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2430
|
+
const verifyRef = (ref, what) => {
|
|
2423
2431
|
try {
|
|
2424
|
-
|
|
2432
|
+
execFileSync3("git", ["rev-parse", "--verify", "--quiet", ref], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
2425
2433
|
} catch {
|
|
2426
|
-
|
|
2434
|
+
throw new SddScriptError(`bad ${what}: ${ref}`, 2);
|
|
2427
2435
|
}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2436
|
+
};
|
|
2437
|
+
verifyRef(base, "BASE");
|
|
2438
|
+
verifyRef(head, "HEAD");
|
|
2439
|
+
let out;
|
|
2440
|
+
if (outFile) {
|
|
2441
|
+
out = outFile;
|
|
2442
|
+
} else {
|
|
2443
|
+
const sddDir = opts.sddDir ?? process.env.SDD_DIR;
|
|
2444
|
+
if (!sddDir) {
|
|
2445
|
+
throw new SddScriptError("mstar sdd review-package: set SDD_DIR or pass OUTFILE", 2);
|
|
2438
2446
|
}
|
|
2447
|
+
mkdirSync5(sddDir, { recursive: true });
|
|
2448
|
+
const shortBase = gitOut(cwd, ["rev-parse", "--short", base]) ?? base;
|
|
2449
|
+
const shortHead = gitOut(cwd, ["rev-parse", "--short", head]) ?? head;
|
|
2450
|
+
out = join9(sddDir, `review-${shortBase}..${shortHead}.diff`);
|
|
2439
2451
|
}
|
|
2440
|
-
const
|
|
2441
|
-
|
|
2442
|
-
|
|
2452
|
+
const run = (args) => execFileSync3("git", args, { cwd, maxBuffer: GIT_CAPTURE_MAX_BYTES });
|
|
2453
|
+
const parts = [
|
|
2454
|
+
Buffer.from(`# Review package: ${base}..${head}
|
|
2455
|
+
|
|
2456
|
+
## Commits
|
|
2457
|
+
`),
|
|
2458
|
+
run(["log", "--oneline", `${base}..${head}`]),
|
|
2459
|
+
Buffer.from(`
|
|
2460
|
+
## Files changed
|
|
2461
|
+
`),
|
|
2462
|
+
run(["diff", "--stat", `${base}..${head}`]),
|
|
2463
|
+
Buffer.from(`
|
|
2464
|
+
## Diff
|
|
2465
|
+
`),
|
|
2466
|
+
run(["diff", "-U10", `${base}..${head}`])
|
|
2467
|
+
];
|
|
2468
|
+
writeFileSync4(out, Buffer.concat(parts));
|
|
2469
|
+
return out;
|
|
2470
|
+
}
|
|
2471
|
+
function assertBaseSha(ref, opts = {}) {
|
|
2472
|
+
if (typeof ref !== "string" || !/^[0-9a-f]{4,40}$/i.test(ref)) {
|
|
2473
|
+
throw new SddScriptError(`assertBaseSha: BASE must be a commit SHA (full or prefix); got ${JSON.stringify(ref)}. ` + "Never use HEAD~1 as review BASE (multi-commit tasks truncate).", 2);
|
|
2474
|
+
}
|
|
2475
|
+
try {
|
|
2476
|
+
execFileSync3("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
|
|
2477
|
+
cwd: opts.cwd,
|
|
2478
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2479
|
+
});
|
|
2480
|
+
} catch {
|
|
2481
|
+
throw new SddScriptError(`assertBaseSha: commit not found: ${ref}`, 2);
|
|
2443
2482
|
}
|
|
2444
|
-
const computed = {
|
|
2445
|
-
total_open: items.length,
|
|
2446
|
-
by_severity: bySeverity,
|
|
2447
|
-
by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
|
|
2448
|
-
by_plan: groupCount(items.map(({ plan }) => plan))
|
|
2449
|
-
};
|
|
2450
|
-
const stored = null;
|
|
2451
|
-
const checks = ROLLUP_FIELDS.map((field) => ({ field, status: "DRIFT" }));
|
|
2452
|
-
const overall = "DRIFT";
|
|
2453
|
-
return { computed, stored, checks, overall };
|
|
2454
2483
|
}
|
|
2455
|
-
function
|
|
2456
|
-
|
|
2457
|
-
|
|
2484
|
+
function taskReportExists(sddDir, taskN) {
|
|
2485
|
+
try {
|
|
2486
|
+
const st = statSync4(join9(sddDir, `task-${taskN}-report.md`));
|
|
2487
|
+
return st.isFile() && st.size > 0;
|
|
2488
|
+
} catch {
|
|
2489
|
+
return false;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
function readProgressLedger(sddDir) {
|
|
2493
|
+
let content;
|
|
2458
2494
|
try {
|
|
2459
|
-
|
|
2495
|
+
content = readFileSync7(join9(sddDir, "progress.md"), "utf8");
|
|
2460
2496
|
} catch {
|
|
2461
2497
|
return [];
|
|
2462
2498
|
}
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
let nested;
|
|
2471
|
-
try {
|
|
2472
|
-
nested = readdirSync5(join9(root, entry.name), { withFileTypes: true });
|
|
2473
|
-
} catch {
|
|
2474
|
-
continue;
|
|
2475
|
-
}
|
|
2476
|
-
for (const child of nested) {
|
|
2477
|
-
if (child.isFile())
|
|
2478
|
-
files.push(`${entry.name}/${child.name}`);
|
|
2479
|
-
}
|
|
2480
|
-
}
|
|
2499
|
+
return content.split(`
|
|
2500
|
+
`).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
2501
|
+
}
|
|
2502
|
+
function implementerSessionStickyRules(input) {
|
|
2503
|
+
const { session, nextTask, microBatchTasks = 1 } = input;
|
|
2504
|
+
if (session.session_mode !== "sticky") {
|
|
2505
|
+
return { resume: false, reason: `session_mode is '${session.session_mode}'; sticky resume requires 'sticky'` };
|
|
2481
2506
|
}
|
|
2482
|
-
|
|
2507
|
+
if (typeof session.host_agent_id !== "string" || session.host_agent_id.length === 0) {
|
|
2508
|
+
return {
|
|
2509
|
+
resume: false,
|
|
2510
|
+
reason: "host_agent_id is missing from implementer-session.json; fall back to fresh for this task " + "(mstar-sdd SKILL.md red flag: resume implementer without host_agent_id)"
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
if (nextTask <= session.last_task) {
|
|
2514
|
+
return {
|
|
2515
|
+
resume: false,
|
|
2516
|
+
reason: `nextTask ${nextTask} <= last_task ${session.last_task}; task already completed in this session`
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
if (microBatchTasks < 1 || microBatchTasks > 3) {
|
|
2520
|
+
return {
|
|
2521
|
+
resume: false,
|
|
2522
|
+
reason: `micro-batch of ${microBatchTasks} tasks is outside 1..3 (max 3 without user override, ` + "sticky-implementer-session.md § Micro-batch fallback)"
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
return { resume: true, reason: `sticky resume OK: host_agent_id ${session.host_agent_id}, next task ${nextTask}` };
|
|
2483
2526
|
}
|
|
2484
2527
|
// src/migrate.ts
|
|
2485
|
-
import { copyFileSync, mkdirSync as mkdirSync6, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as
|
|
2528
|
+
import { copyFileSync, mkdirSync as mkdirSync6, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync5 } from "node:fs";
|
|
2486
2529
|
import { dirname as dirname7, isAbsolute as isAbsolute6, join as join10, relative as relative3, resolve as resolve8, sep as sep2 } from "node:path";
|
|
2487
2530
|
var MIGRATE_STATUS_FILE = "status.json";
|
|
2488
2531
|
var ARCHIVED_STATUS_V1_FILE = "archived/status.v1.json";
|
|
@@ -2984,7 +3027,7 @@ async function applyMigratePlan(plan) {
|
|
|
2984
3027
|
const content = notes.lines.length > 0 ? `${notes.lines.join(`
|
|
2985
3028
|
`)}
|
|
2986
3029
|
` : "";
|
|
2987
|
-
|
|
3030
|
+
writeFileSync5(filePath, content, "utf8");
|
|
2988
3031
|
}
|
|
2989
3032
|
if (plan.register !== null) {
|
|
2990
3033
|
const gate2 = validateProjectRegister(plan.register.data);
|
|
@@ -3000,7 +3043,7 @@ async function applyMigratePlan(plan) {
|
|
|
3000
3043
|
if (plan.roadmap !== null) {
|
|
3001
3044
|
const filePath = projectTargetOf(plan.roadmap.file);
|
|
3002
3045
|
mkdirSync6(dirname7(filePath), { recursive: true });
|
|
3003
|
-
|
|
3046
|
+
writeFileSync5(filePath, plan.roadmap.content, "utf8");
|
|
3004
3047
|
}
|
|
3005
3048
|
const rootGate = validateStatusV2(plan.rootV2.data, { harnessDir: plan.root });
|
|
3006
3049
|
if (!rootGate.ok) {
|
|
@@ -3422,7 +3465,7 @@ function completenessLevel(frontmatterText, checklist) {
|
|
|
3422
3465
|
return { level, items, missing, placeholders, upgradeTo, bodyUnverified };
|
|
3423
3466
|
}
|
|
3424
3467
|
// src/audit.ts
|
|
3425
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, rmdirSync as rmdirSync2, rmSync, writeFileSync as
|
|
3468
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, rmdirSync as rmdirSync2, rmSync, writeFileSync as writeFileSync6 } from "node:fs";
|
|
3426
3469
|
import { basename as basename4, join as join11, resolve as resolve9, sep as sep3 } from "node:path";
|
|
3427
3470
|
function violation9(severity, code, message, fix) {
|
|
3428
3471
|
return { ok: false, severity, code, message, fix };
|
|
@@ -3534,8 +3577,17 @@ function readPlanFileSummary(filePath) {
|
|
|
3534
3577
|
const blocks = parseStatusBlocks(text);
|
|
3535
3578
|
return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
|
|
3536
3579
|
}
|
|
3580
|
+
function extractSecurityDispositionSections(text) {
|
|
3581
|
+
const grab = (heading) => {
|
|
3582
|
+
const match = text.match(new RegExp(`(?:^|\\r?\\n)##[ \\t]+${heading}[ \\t]*\\r?\\n(?:[ \\t]*\\r?\\n)?([\\s\\S]*?)(?=\\r?\\n## |$)`, "i"));
|
|
3583
|
+
if (!match)
|
|
3584
|
+
return [];
|
|
3585
|
+
return match[1].split(/\r?\n/).map((line) => line.replace(/\r$/, "")).filter((line) => line.startsWith("- "));
|
|
3586
|
+
};
|
|
3587
|
+
return { needsVerification: grab("Needs verification"), hardeningChecked: grab("Hardening & checked notes") };
|
|
3588
|
+
}
|
|
3537
3589
|
function renderIndex(params) {
|
|
3538
|
-
const { date, repoName, repoShortSha, rows, rejected } = params;
|
|
3590
|
+
const { date, repoName, repoShortSha, rows, rejected, needsVerification, hardeningChecked } = params;
|
|
3539
3591
|
const findingsRows = rows.map((r) => `| ${r.num} | ${escapeCell(r.title)} | ${r.category} | ${escapeCell(truncate(r.impact, 80))} | ${r.effort} | ${r.risk} | ${r.confidence} | ${escapeCell(truncate(r.evidence, 80))} |`).join(`
|
|
3540
3592
|
`);
|
|
3541
3593
|
const directionRows = rows.filter((r) => r.category === "direction").map((r) => `- ${escapeCell(r.title)} — ${escapeCell(truncate(r.impact, 120))}`).join(`
|
|
@@ -3556,6 +3608,12 @@ function renderIndex(params) {
|
|
|
3556
3608
|
if (directionRows !== "") {
|
|
3557
3609
|
sections.push("", "## Direction", "", directionRows);
|
|
3558
3610
|
}
|
|
3611
|
+
if (needsVerification.length > 0) {
|
|
3612
|
+
sections.push("", "## Needs verification", "", ...needsVerification);
|
|
3613
|
+
}
|
|
3614
|
+
if (hardeningChecked.length > 0) {
|
|
3615
|
+
sections.push("", "## Hardening & checked notes", "", ...hardeningChecked);
|
|
3616
|
+
}
|
|
3559
3617
|
sections.push("", "## Execution order & status", "", "| Plan | Title | Priority | Effort | Depends on | Status |", "|------|-------|----------|--------|------------|--------|", executionRows);
|
|
3560
3618
|
if (rejectedRows !== "") {
|
|
3561
3619
|
sections.push("", "## Findings considered and rejected", "", rejectedRows);
|
|
@@ -3569,6 +3627,8 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
3569
3627
|
const date = options.date ?? new Date().toISOString().slice(0, 10);
|
|
3570
3628
|
const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
|
|
3571
3629
|
mkdirSync7(outDir, { recursive: true });
|
|
3630
|
+
const existingReadme = join11(outDir, "README.md");
|
|
3631
|
+
const carried = existsSync7(existingReadme) ? extractSecurityDispositionSections(readFileSync9(existingReadme, "utf8")) : { needsVerification: [], hardeningChecked: [] };
|
|
3572
3632
|
const existing = readdirSync7(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
|
|
3573
3633
|
let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
|
|
3574
3634
|
const written = [];
|
|
@@ -3584,7 +3644,7 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
3584
3644
|
}
|
|
3585
3645
|
usedSlugs.add(slug);
|
|
3586
3646
|
const file = `${num}-${slug}.md`;
|
|
3587
|
-
|
|
3647
|
+
writeFileSync6(join11(outDir, file), renderPlanFile(finding, plannedAt));
|
|
3588
3648
|
written.push(file);
|
|
3589
3649
|
next++;
|
|
3590
3650
|
}
|
|
@@ -3622,12 +3682,16 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
3622
3682
|
row.dependsOn = finding.dependsOn ?? "none";
|
|
3623
3683
|
}
|
|
3624
3684
|
});
|
|
3625
|
-
|
|
3685
|
+
const needsVerificationLines = options.needsVerification !== undefined ? options.needsVerification.map((nv) => `- ${escapeCell(nv.lead)}: ${escapeCell(nv.how)}${nv.evidence ? ` (${escapeCell(nv.evidence)})` : ""}`) : carried.needsVerification;
|
|
3686
|
+
const hardeningCheckedLines = options.hardeningChecked !== undefined ? options.hardeningChecked.map((hc) => `- ${hc.kind}: ${escapeCell(hc.text)}`) : carried.hardeningChecked;
|
|
3687
|
+
writeFileSync6(join11(outDir, "README.md"), renderIndex({
|
|
3626
3688
|
date,
|
|
3627
3689
|
repoName: options.repoName ?? "repo",
|
|
3628
3690
|
repoShortSha: options.repoShortSha ?? "unknown",
|
|
3629
3691
|
rows,
|
|
3630
|
-
rejected: options.rejected ?? []
|
|
3692
|
+
rejected: options.rejected ?? [],
|
|
3693
|
+
needsVerification: needsVerificationLines,
|
|
3694
|
+
hardeningChecked: hardeningCheckedLines
|
|
3631
3695
|
}));
|
|
3632
3696
|
return { outDir: resolve9(outDir), date, files: written, nextNumber: next };
|
|
3633
3697
|
}
|
|
@@ -4704,6 +4768,7 @@ export {
|
|
|
4704
4768
|
resolveSpecsDir,
|
|
4705
4769
|
resolveSkillRoot,
|
|
4706
4770
|
resolveSddDir,
|
|
4771
|
+
resolveScaffoldDirs,
|
|
4707
4772
|
resolveRepoEnforcement,
|
|
4708
4773
|
resolveProjectRoot,
|
|
4709
4774
|
resolveProjectDir,
|
|
@@ -4753,6 +4818,7 @@ export {
|
|
|
4753
4818
|
evaluatePhaseGate,
|
|
4754
4819
|
emitGitignoreSnippet,
|
|
4755
4820
|
detectHost,
|
|
4821
|
+
detectHarnessKind,
|
|
4756
4822
|
compoundRefreshScope,
|
|
4757
4823
|
composeDispatchGate,
|
|
4758
4824
|
completenessLevel,
|