@mstar-harness/engine 0.0.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/README.md +27 -0
- package/dist/audit.d.ts +99 -0
- package/dist/compound.d.ts +90 -0
- package/dist/core.d.ts +112 -0
- package/dist/design-md.d.ts +113 -0
- package/dist/dispatch.d.ts +205 -0
- package/dist/engine.js +3439 -0
- package/dist/host.d.ts +95 -0
- package/dist/index.d.ts +52 -0
- package/dist/iteration.d.ts +88 -0
- package/dist/lease.d.ts +193 -0
- package/dist/lint.d.ts +206 -0
- package/dist/path.d.ts +117 -0
- package/dist/roles.d.ts +87 -0
- package/dist/sdd.d.ts +123 -0
- package/dist/skill-authoring.d.ts +60 -0
- package/dist/status.d.ts +168 -0
- package/dist/worktree.d.ts +104 -0
- package/package.json +41 -0
package/dist/engine.js
ADDED
|
@@ -0,0 +1,3439 @@
|
|
|
1
|
+
// src/core.ts
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
var SEVERITY_ORDER = ["critical", "high", "medium", "low", "nit"];
|
|
7
|
+
function applyEnforcement(gate, opts) {
|
|
8
|
+
return { ...gate, hardBlocked: opts.hard && gate.violations.length > 0 };
|
|
9
|
+
}
|
|
10
|
+
function readJson(filePath) {
|
|
11
|
+
if (!existsSync(filePath))
|
|
12
|
+
return {};
|
|
13
|
+
const content = readFileSync(filePath, "utf8").trim();
|
|
14
|
+
if (!content)
|
|
15
|
+
return {};
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(content);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new Error(`Invalid JSON in ${filePath}: ${error.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function writeJson(filePath, value) {
|
|
23
|
+
const parent = dirname(filePath);
|
|
24
|
+
mkdirSync(parent, { recursive: true });
|
|
25
|
+
const tmp = join(parent, `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
|
|
28
|
+
`, "utf8");
|
|
29
|
+
renameSync(tmp, filePath);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
try {
|
|
32
|
+
unlinkSync(tmp);
|
|
33
|
+
} catch {}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function resolveProjectRoot(startDir = process.cwd()) {
|
|
38
|
+
const start = resolve(startDir);
|
|
39
|
+
let dir = start;
|
|
40
|
+
for (;; ) {
|
|
41
|
+
if (existsSync(join(dir, "package.json")) || existsSync(join(dir, "bun.lock")))
|
|
42
|
+
return dir;
|
|
43
|
+
const parent = dirname(dir);
|
|
44
|
+
if (parent === dir)
|
|
45
|
+
return start;
|
|
46
|
+
dir = parent;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function findRootPackageJson(startDir) {
|
|
50
|
+
let dir = startDir;
|
|
51
|
+
for (;; ) {
|
|
52
|
+
const candidate = resolve(dir, "package.json");
|
|
53
|
+
try {
|
|
54
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf8"));
|
|
55
|
+
if (pkg.name === "morning-star")
|
|
56
|
+
return candidate;
|
|
57
|
+
} catch {}
|
|
58
|
+
const parent = dirname(dir);
|
|
59
|
+
if (parent === dir)
|
|
60
|
+
return null;
|
|
61
|
+
dir = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function harnessVersionFrom(moduleDir) {
|
|
65
|
+
const ownManifest = join(moduleDir, "..", "package.json");
|
|
66
|
+
try {
|
|
67
|
+
const pkg = JSON.parse(readFileSync(ownManifest, "utf8"));
|
|
68
|
+
if (typeof pkg.version === "string" && pkg.version !== "")
|
|
69
|
+
return pkg.version;
|
|
70
|
+
} catch {}
|
|
71
|
+
const root = findRootPackageJson(moduleDir);
|
|
72
|
+
if (!root)
|
|
73
|
+
return "0.0.0";
|
|
74
|
+
try {
|
|
75
|
+
const pkg = JSON.parse(readFileSync(root, "utf8"));
|
|
76
|
+
return pkg.version || "0.0.0";
|
|
77
|
+
} catch {
|
|
78
|
+
return "0.0.0";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function readHarnessVersion() {
|
|
82
|
+
return harnessVersionFrom(dirname(fileURLToPath(import.meta.url)));
|
|
83
|
+
}
|
|
84
|
+
// src/path.ts
|
|
85
|
+
import { mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
|
|
86
|
+
import { basename as basename2, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
|
|
87
|
+
function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
|
|
88
|
+
const start = resolve2(startDir);
|
|
89
|
+
const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
|
|
90
|
+
if (explicit)
|
|
91
|
+
return resolve2(start, explicit);
|
|
92
|
+
let dir = start;
|
|
93
|
+
for (;; ) {
|
|
94
|
+
for (const candidate of [join2(dir, ".mstar"), join2(dir, ".agents"), join2(dir, ".plans"), join2(dir, "plans")]) {
|
|
95
|
+
if (isDirectory(candidate))
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
const parent = dirname2(dir);
|
|
99
|
+
if (parent === dir)
|
|
100
|
+
return null;
|
|
101
|
+
dir = parent;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function resolveSpecsDir(harnessDir, opts = {}) {
|
|
105
|
+
const harness = resolve2(harnessDir);
|
|
106
|
+
const repoRoot = dirname2(harness);
|
|
107
|
+
const candidates = [
|
|
108
|
+
join2(harness, "specs"),
|
|
109
|
+
join2(repoRoot, "docs", "specs"),
|
|
110
|
+
join2(repoRoot, "specs"),
|
|
111
|
+
join2(harness, "designs"),
|
|
112
|
+
join2(repoRoot, "designs")
|
|
113
|
+
];
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
if (isDirectory(candidate) && hasFiles(candidate))
|
|
116
|
+
return candidate;
|
|
117
|
+
}
|
|
118
|
+
const fallback = join2(harness, "specs");
|
|
119
|
+
if (opts.create !== false)
|
|
120
|
+
mkdirSync2(fallback, { recursive: true });
|
|
121
|
+
return fallback;
|
|
122
|
+
}
|
|
123
|
+
function resolvePlanDir(harnessDir) {
|
|
124
|
+
const dir = resolve2(harnessDir);
|
|
125
|
+
const name = basename2(dir);
|
|
126
|
+
if (name === ".plans" || name === "plans")
|
|
127
|
+
return dir;
|
|
128
|
+
return join2(dir, "plans");
|
|
129
|
+
}
|
|
130
|
+
function assertSafePathComponent(value, what) {
|
|
131
|
+
if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
132
|
+
throw new Error(`${what} must be a single safe path component ([A-Za-z0-9._-]+; not "", ".", "..", or containing "/" or "\\") — got ${JSON.stringify(value)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function resolveSddDir(harnessDir, planId) {
|
|
136
|
+
assertSafePathComponent(planId, "planId");
|
|
137
|
+
return join2(resolve2(harnessDir), "sdd", planId);
|
|
138
|
+
}
|
|
139
|
+
function resolveIterationDir(harnessDir) {
|
|
140
|
+
return join2(resolve2(harnessDir), "iterations");
|
|
141
|
+
}
|
|
142
|
+
var EMPTY_STATUS_TEMPLATE = {
|
|
143
|
+
version: 1,
|
|
144
|
+
updated_at: "1970-01-01",
|
|
145
|
+
plans: [],
|
|
146
|
+
residual_findings: {},
|
|
147
|
+
metadata: {}
|
|
148
|
+
};
|
|
149
|
+
var SCAFFOLD_DIRS = ["plans", "iterations", "knowledge", "specs", "sdd"];
|
|
150
|
+
function scaffoldHarness(root) {
|
|
151
|
+
const harnessDir = join2(resolve2(root), ".mstar");
|
|
152
|
+
for (const dir of SCAFFOLD_DIRS)
|
|
153
|
+
mkdirSync2(join2(harnessDir, dir), { recursive: true });
|
|
154
|
+
const statusPath = join2(harnessDir, "status.json");
|
|
155
|
+
if (Object.keys(readJson(statusPath)).length === 0)
|
|
156
|
+
writeJson(statusPath, EMPTY_STATUS_TEMPLATE);
|
|
157
|
+
return harnessDir;
|
|
158
|
+
}
|
|
159
|
+
var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
|
|
160
|
+
# Principle: process stays local; results are shared with the team.
|
|
161
|
+
# Ignored (process / coordination):
|
|
162
|
+
.mstar/archived/
|
|
163
|
+
.mstar/iterations/
|
|
164
|
+
.mstar/plans/
|
|
165
|
+
.mstar/sdd/
|
|
166
|
+
.mstar/notes.json
|
|
167
|
+
.mstar/status.json
|
|
168
|
+
# Tracked (results): .mstar/AGENTS.md, .mstar/knowledge/, .mstar/specs/
|
|
169
|
+
`;
|
|
170
|
+
var GITIGNORE_SNIPPET_AGENTS = `# Morning Star harness (.agents/) — legacy
|
|
171
|
+
.agents/archived/
|
|
172
|
+
.agents/iterations/
|
|
173
|
+
.agents/plans/
|
|
174
|
+
.agents/sdd/
|
|
175
|
+
.agents/notes.json
|
|
176
|
+
.agents/status.json
|
|
177
|
+
# Tracked (results): .agents/AGENTS.md, .agents/knowledge/, .agents/specs/
|
|
178
|
+
`;
|
|
179
|
+
var GITIGNORE_PROCESS_ENTRIES = GITIGNORE_SNIPPET.split(`
|
|
180
|
+
`).filter((line) => line.startsWith(".mstar/")).map((line) => line.trim());
|
|
181
|
+
var GITIGNORE_PROCESS_ENTRIES_AGENTS = GITIGNORE_SNIPPET_AGENTS.split(`
|
|
182
|
+
`).filter((line) => line.startsWith(".agents/")).map((line) => line.trim());
|
|
183
|
+
function emitGitignoreSnippet(kind) {
|
|
184
|
+
if (kind === "agents")
|
|
185
|
+
return GITIGNORE_SNIPPET_AGENTS;
|
|
186
|
+
if (kind === "mstar")
|
|
187
|
+
return GITIGNORE_SNIPPET;
|
|
188
|
+
return `${GITIGNORE_SNIPPET}${GITIGNORE_SNIPPET_AGENTS}`;
|
|
189
|
+
}
|
|
190
|
+
function validateGitignore(root) {
|
|
191
|
+
const gitignorePath = join2(resolve2(root), ".gitignore");
|
|
192
|
+
const kind = detectHarnessKind(resolveHarnessDir(root));
|
|
193
|
+
let content;
|
|
194
|
+
try {
|
|
195
|
+
content = readFileSync2(gitignorePath, "utf8");
|
|
196
|
+
} catch {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
severity: "medium",
|
|
200
|
+
code: "gitignore.missing",
|
|
201
|
+
message: `no .gitignore found at ${gitignorePath}`,
|
|
202
|
+
fix: `append the canonical snippet (emitGitignoreSnippet(${kind ? `"${kind}"` : ""})) to ${gitignorePath}`
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const lines = new Set(content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0));
|
|
206
|
+
const mstarMissing = GITIGNORE_PROCESS_ENTRIES.filter((entry) => !lines.has(entry));
|
|
207
|
+
const agentsMissing = GITIGNORE_PROCESS_ENTRIES_AGENTS.filter((entry) => !lines.has(entry));
|
|
208
|
+
let missing;
|
|
209
|
+
let label;
|
|
210
|
+
if (kind === "agents") {
|
|
211
|
+
missing = agentsMissing;
|
|
212
|
+
label = ".agents/ set";
|
|
213
|
+
} else if (kind === "mstar") {
|
|
214
|
+
missing = mstarMissing;
|
|
215
|
+
label = ".mstar/ set";
|
|
216
|
+
} else {
|
|
217
|
+
label = "either .mstar/ or .agents/ set";
|
|
218
|
+
missing = mstarMissing.length === 0 || agentsMissing.length === 0 ? [] : mstarMissing.length <= agentsMissing.length ? mstarMissing : agentsMissing;
|
|
219
|
+
}
|
|
220
|
+
if (missing.length > 0) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
severity: "medium",
|
|
224
|
+
code: "gitignore.missing-entries",
|
|
225
|
+
message: `.gitignore at ${gitignorePath} is missing canonical harness ignore entries (${label}): ${missing.join(", ")}`,
|
|
226
|
+
fix: `append the canonical snippet (emitGitignoreSnippet(${kind ? `"${kind}"` : ""})) to ${gitignorePath}`
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
severity: "low",
|
|
232
|
+
code: "gitignore.ok",
|
|
233
|
+
message: `.gitignore at ${gitignorePath} contains a complete canonical harness process-artifact ignore set (${label})`
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function detectHarnessKind(harnessDir) {
|
|
237
|
+
if (!harnessDir)
|
|
238
|
+
return null;
|
|
239
|
+
const name = basename2(resolve2(harnessDir));
|
|
240
|
+
if (name === ".mstar")
|
|
241
|
+
return "mstar";
|
|
242
|
+
if (name === ".agents")
|
|
243
|
+
return "agents";
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
function assertPlanWritingPath(planPath, harnessDir) {
|
|
247
|
+
const planAbs = resolve2(planPath);
|
|
248
|
+
if (!harnessDir) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
severity: "high",
|
|
252
|
+
code: "plan-path.no-harness",
|
|
253
|
+
message: `persistent plan tracking is not enabled — cannot place plan ${planAbs} under {PLAN_DIR}`,
|
|
254
|
+
fix: "initialize the harness (scaffoldHarness) so plans land in {PLAN_DIR}"
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const planDir = resolvePlanDir(harnessDir);
|
|
258
|
+
const rel = relative(planDir, planAbs);
|
|
259
|
+
const inside = rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
260
|
+
if (!inside) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
severity: "high",
|
|
264
|
+
code: "plan-path.outside-plan-dir",
|
|
265
|
+
message: `plan file ${planAbs} is outside {PLAN_DIR} (${planDir})`,
|
|
266
|
+
fix: `write the plan under ${planDir}`
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
severity: "low",
|
|
272
|
+
code: "plan-path.ok",
|
|
273
|
+
message: `plan file ${planAbs} lives under {PLAN_DIR} (${planDir})`
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function isDirectory(dir) {
|
|
277
|
+
try {
|
|
278
|
+
return statSync(dir).isDirectory();
|
|
279
|
+
} catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function hasFiles(dir) {
|
|
284
|
+
try {
|
|
285
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
286
|
+
if (entry.isDirectory()) {
|
|
287
|
+
if (hasFiles(join2(dir, entry.name)))
|
|
288
|
+
return true;
|
|
289
|
+
} else if (entry.isFile()) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// src/status.ts
|
|
299
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "node:fs";
|
|
300
|
+
import { join as join4, resolve as resolve4 } from "node:path";
|
|
301
|
+
|
|
302
|
+
// src/lease.ts
|
|
303
|
+
import { mkdirSync as mkdirSync3, rmdirSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
304
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, resolve as resolve3 } from "node:path";
|
|
305
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
306
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
307
|
+
function isPlainObject(value) {
|
|
308
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
309
|
+
}
|
|
310
|
+
function violation(severity, code, message, fix) {
|
|
311
|
+
return { ok: false, severity, code, message, fix };
|
|
312
|
+
}
|
|
313
|
+
function validateNonEmptyString(violations, value, field, missingCode, invalidCode) {
|
|
314
|
+
if (value === undefined) {
|
|
315
|
+
violations.push(violation("high", missingCode, `missing required field: ${field}`));
|
|
316
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
317
|
+
violations.push(violation("medium", invalidCode, `${field} must be a non-empty string`));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
var DATE_PART = String.raw`\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])`;
|
|
321
|
+
var RFC3339_Z_RE = new RegExp(String.raw`^${DATE_PART}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$`);
|
|
322
|
+
var DATE_ONLY_RE = new RegExp(String.raw`^${DATE_PART}$`);
|
|
323
|
+
function isValidClaimedAt(value) {
|
|
324
|
+
return typeof value === "string" && (RFC3339_Z_RE.test(value) || DATE_ONLY_RE.test(value));
|
|
325
|
+
}
|
|
326
|
+
function validateExecutionLease(lease) {
|
|
327
|
+
const violations = [];
|
|
328
|
+
if (!isPlainObject(lease)) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
violations: [
|
|
332
|
+
violation("high", "lease.execution-lease.invalid", "execution_lease must be an object — null and tombstone objects are invalid; writers delete the key on release")
|
|
333
|
+
]
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
validateNonEmptyString(violations, lease.holder, "holder", "lease.execution-lease.missing-holder", "lease.execution-lease.invalid-holder");
|
|
337
|
+
if (lease.claimed_at === undefined) {
|
|
338
|
+
violations.push(violation("high", "lease.execution-lease.missing-claimed-at", "missing required field: claimed_at"));
|
|
339
|
+
} else if (!isValidClaimedAt(lease.claimed_at)) {
|
|
340
|
+
violations.push(violation("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"));
|
|
341
|
+
}
|
|
342
|
+
if (lease.worktree_path === undefined) {
|
|
343
|
+
violations.push(violation("high", "lease.execution-lease.missing-worktree-path", "missing required field: worktree_path"));
|
|
344
|
+
} else if (typeof lease.worktree_path !== "string" || lease.worktree_path.trim() === "") {
|
|
345
|
+
violations.push(violation("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be a non-empty string"));
|
|
346
|
+
} else if (!isAbsolute2(lease.worktree_path)) {
|
|
347
|
+
violations.push(violation("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)"));
|
|
348
|
+
}
|
|
349
|
+
validateNonEmptyString(violations, lease.working_branch, "working_branch", "lease.execution-lease.missing-working-branch", "lease.execution-lease.invalid-working-branch");
|
|
350
|
+
if (lease.session_label !== undefined && typeof lease.session_label !== "string") {
|
|
351
|
+
violations.push(violation("medium", "lease.execution-lease.invalid-session-label", "session_label must be a string (display only — never used for ownership comparison)"));
|
|
352
|
+
}
|
|
353
|
+
return { ok: violations.length === 0, violations };
|
|
354
|
+
}
|
|
355
|
+
function validateIntegrationMergeLease(lease) {
|
|
356
|
+
const violations = [];
|
|
357
|
+
if (!isPlainObject(lease)) {
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
violations: [
|
|
361
|
+
violation("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")
|
|
362
|
+
]
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
validateNonEmptyString(violations, lease.holder, "holder", "lease.merge-lease.missing-holder", "lease.merge-lease.invalid-holder");
|
|
366
|
+
if (lease.claimed_at === undefined) {
|
|
367
|
+
violations.push(violation("high", "lease.merge-lease.missing-claimed-at", "missing required field: claimed_at"));
|
|
368
|
+
} else if (!isValidClaimedAt(lease.claimed_at)) {
|
|
369
|
+
violations.push(violation("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"));
|
|
370
|
+
}
|
|
371
|
+
validateNonEmptyString(violations, lease.plan_id, "plan_id", "lease.merge-lease.missing-plan-id", "lease.merge-lease.invalid-plan-id");
|
|
372
|
+
validateNonEmptyString(violations, lease.source_branch, "source_branch", "lease.merge-lease.missing-source-branch", "lease.merge-lease.invalid-source-branch");
|
|
373
|
+
validateNonEmptyString(violations, lease.target_branch, "target_branch", "lease.merge-lease.missing-target-branch", "lease.merge-lease.invalid-target-branch");
|
|
374
|
+
if (lease.session_label !== undefined && typeof lease.session_label !== "string") {
|
|
375
|
+
violations.push(violation("medium", "lease.merge-lease.invalid-session-label", "session_label must be a string (display only — never used for ownership comparison)"));
|
|
376
|
+
}
|
|
377
|
+
return { ok: violations.length === 0, violations };
|
|
378
|
+
}
|
|
379
|
+
function claimLease(row, holder, fields) {
|
|
380
|
+
const lease = row.execution_lease;
|
|
381
|
+
if (lease !== undefined) {
|
|
382
|
+
if (!isPlainObject(lease)) {
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
row,
|
|
386
|
+
violations: [
|
|
387
|
+
violation("high", "lease.claim.tombstone", "execution_lease must be an object — null and tombstone objects are invalid; resolve the corrupt state before claiming")
|
|
388
|
+
]
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
if (lease.holder !== holder) {
|
|
392
|
+
return {
|
|
393
|
+
ok: false,
|
|
394
|
+
row,
|
|
395
|
+
violations: [
|
|
396
|
+
violation("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)`)
|
|
397
|
+
]
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (lease.worktree_path !== fields.worktree_path || lease.working_branch !== fields.working_branch) {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
row,
|
|
404
|
+
violations: [
|
|
405
|
+
violation("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`)
|
|
406
|
+
]
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return { ok: true, row, outcome: "resumed", violations: [] };
|
|
410
|
+
}
|
|
411
|
+
if (row.status === "InProgress") {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
row,
|
|
415
|
+
violations: [
|
|
416
|
+
violation("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")
|
|
417
|
+
]
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (row.status !== "Todo" && row.status !== "Blocked") {
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
row,
|
|
424
|
+
violations: [
|
|
425
|
+
violation("high", "lease.claim.status", `claim requires status Todo or Blocked (got ${JSON.stringify(row.status)}) — claim-before-InProgress contract`)
|
|
426
|
+
]
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
const claimed = {
|
|
430
|
+
holder,
|
|
431
|
+
claimed_at: new Date().toISOString(),
|
|
432
|
+
worktree_path: fields.worktree_path,
|
|
433
|
+
working_branch: fields.working_branch,
|
|
434
|
+
...fields.session_label !== undefined ? { session_label: fields.session_label } : {}
|
|
435
|
+
};
|
|
436
|
+
const gate = validateExecutionLease(claimed);
|
|
437
|
+
if (!gate.ok) {
|
|
438
|
+
return { ok: false, row, violations: gate.violations };
|
|
439
|
+
}
|
|
440
|
+
return { ok: true, row: { ...row, status: "InProgress", execution_lease: claimed }, outcome: "claimed", violations: [] };
|
|
441
|
+
}
|
|
442
|
+
function releaseLease(row, holder) {
|
|
443
|
+
if (row.execution_lease === undefined) {
|
|
444
|
+
return { ok: true, row, outcome: "released", violations: [] };
|
|
445
|
+
}
|
|
446
|
+
if (!isPlainObject(row.execution_lease)) {
|
|
447
|
+
return {
|
|
448
|
+
ok: false,
|
|
449
|
+
row,
|
|
450
|
+
violations: [
|
|
451
|
+
violation("high", "lease.release.tombstone", "execution_lease must be an object — null and tombstone objects are invalid; resolve the corrupt state before releasing")
|
|
452
|
+
]
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (row.execution_lease.holder !== holder) {
|
|
456
|
+
return {
|
|
457
|
+
ok: false,
|
|
458
|
+
row,
|
|
459
|
+
violations: [
|
|
460
|
+
violation("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)`)
|
|
461
|
+
]
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const { execution_lease: _dropped, ...rest } = row;
|
|
465
|
+
return { ok: true, row: rest, outcome: "released", violations: [] };
|
|
466
|
+
}
|
|
467
|
+
function sameHolderResume(lease, holder) {
|
|
468
|
+
return isPlainObject(lease) && lease.holder === holder;
|
|
469
|
+
}
|
|
470
|
+
function canSteal(lease, holder, opts = {}) {
|
|
471
|
+
if (!isPlainObject(lease) || lease.holder === holder)
|
|
472
|
+
return false;
|
|
473
|
+
return opts.userOverride === true;
|
|
474
|
+
}
|
|
475
|
+
function planExecutionLeaseLocations(row) {
|
|
476
|
+
const meta = row.metadata;
|
|
477
|
+
const metadataLease = meta && typeof meta === "object" && !Array.isArray(meta) ? meta.execution_lease : undefined;
|
|
478
|
+
return { row: row.execution_lease, metadata: metadataLease };
|
|
479
|
+
}
|
|
480
|
+
function verifyPlanExecutionLease(row, planId) {
|
|
481
|
+
const { row: rowLease, metadata: metadataLease } = planExecutionLeaseLocations(row);
|
|
482
|
+
const lease = rowLease !== undefined ? rowLease : metadataLease;
|
|
483
|
+
if (lease === undefined) {
|
|
484
|
+
if (row.status === "InProgress") {
|
|
485
|
+
return {
|
|
486
|
+
ok: false,
|
|
487
|
+
violations: [
|
|
488
|
+
violation("high", "lease.verify.orphan", "plan is InProgress without an execution_lease — orphan: STOP, no writable dispatch until recovery (status-and-residuals.md § Orphan recovery)")
|
|
489
|
+
]
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
violations: [
|
|
495
|
+
violation("high", "lease.verify.missing", `plan ${planId} has no execution_lease (neither plans[].execution_lease nor legacy plans[].metadata.execution_lease)`)
|
|
496
|
+
]
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
const violations = [];
|
|
500
|
+
if (rowLease !== undefined && metadataLease !== undefined) {
|
|
501
|
+
violations.push(violation("high", "lease.verify.dual-write", "execution_lease present in BOTH plans[].execution_lease (SSOT) and plans[].metadata.execution_lease — the row-level lease wins; delete the metadata copy to remove the dual write"));
|
|
502
|
+
} else if (rowLease === undefined) {
|
|
503
|
+
violations.push(violation("high", "lease.verify.non-ssot-location", "execution_lease found only under plans[].metadata.execution_lease — the SSOT location is plans[].execution_lease; the metadata location is a legacy/hand-written read-compat fallback, not equivalent to SSOT success (migrate the lease to the plan row)"));
|
|
504
|
+
}
|
|
505
|
+
violations.push(...validateExecutionLease(lease).violations);
|
|
506
|
+
return { ok: violations.length === 0, violations, lease };
|
|
507
|
+
}
|
|
508
|
+
var STATUS_WRITE_LOCKDIR = ".status-write.lockdir";
|
|
509
|
+
var LOCKDIR_HOLDER_PID = "holder.pid";
|
|
510
|
+
var heldLockDirs = new AsyncLocalStorage;
|
|
511
|
+
async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
512
|
+
const lockDir = join3(dirname3(resolve3(statusPath)), STATUS_WRITE_LOCKDIR);
|
|
513
|
+
const held = heldLockDirs.getStore();
|
|
514
|
+
if (held !== undefined && held.has(lockDir)) {
|
|
515
|
+
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`);
|
|
516
|
+
}
|
|
517
|
+
const timeoutMs = opts.timeoutMs ?? 30000;
|
|
518
|
+
const pollMs = opts.pollMs ?? 25;
|
|
519
|
+
const deadline = Date.now() + timeoutMs;
|
|
520
|
+
let acquired = null;
|
|
521
|
+
for (;; ) {
|
|
522
|
+
try {
|
|
523
|
+
mkdirSync3(lockDir);
|
|
524
|
+
const st = statSync2(lockDir);
|
|
525
|
+
acquired = { dev: st.dev, ino: st.ino };
|
|
526
|
+
break;
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (error.code !== "EEXIST")
|
|
529
|
+
throw error;
|
|
530
|
+
if (Date.now() >= deadline) {
|
|
531
|
+
throw new Error(`${lockDir} already exists — another writer holds the status write lock; Blocked (same-host exclusive lock; status-and-residuals.md § Same-host exclusive write lock). ` + `Recovery: remove ${lockDir} if no writer is alive (holder.pid inside names the acquiring process)`);
|
|
532
|
+
}
|
|
533
|
+
await sleep(pollMs);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
writeFileSync2(join3(lockDir, LOCKDIR_HOLDER_PID), String(process.pid), "utf8");
|
|
538
|
+
} catch {}
|
|
539
|
+
const owns = held ?? new Set;
|
|
540
|
+
owns.add(lockDir);
|
|
541
|
+
try {
|
|
542
|
+
return await heldLockDirs.run(owns, fn);
|
|
543
|
+
} finally {
|
|
544
|
+
owns.delete(lockDir);
|
|
545
|
+
try {
|
|
546
|
+
const current = statSync2(lockDir);
|
|
547
|
+
if (acquired !== null && current.dev === acquired.dev && current.ino === acquired.ino) {
|
|
548
|
+
try {
|
|
549
|
+
unlinkSync2(join3(lockDir, LOCKDIR_HOLDER_PID));
|
|
550
|
+
} catch {}
|
|
551
|
+
rmdirSync(lockDir);
|
|
552
|
+
}
|
|
553
|
+
} catch {}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/dispatch.ts
|
|
558
|
+
var BRANCH_FORMS_HINT = '"Working branch: <existing>" | "Working branch: create <new> from <base>" | "Branch policy: direct on <branch> — <reason>"';
|
|
559
|
+
var REQUIRED_FIELDS = [
|
|
560
|
+
{ key: "executeAs", label: "Execute as", code: "execute-as" },
|
|
561
|
+
{ key: "delegation", label: "Delegation", code: "delegation" },
|
|
562
|
+
{ key: "taskCategory", label: "Task category", code: "task-category" }
|
|
563
|
+
];
|
|
564
|
+
function violation2(severity, code, message, fix) {
|
|
565
|
+
return { ok: false, severity, code, message, fix };
|
|
566
|
+
}
|
|
567
|
+
function parseAssignmentFields(assignmentText) {
|
|
568
|
+
const fields = {};
|
|
569
|
+
for (const line of assignmentText.split(/\r?\n/)) {
|
|
570
|
+
const match = line.match(/^[ \t]*(?:[-*][ \t]+)?\*\*\s*([^*:]+?)\s*\*\*\s*:\s*(.*)$/) ?? line.match(/^[ \t]*(?:[-*][ \t]+)?([A-Za-z][A-Za-z -]*?)\s*:\s*(.*)$/);
|
|
571
|
+
if (!match)
|
|
572
|
+
continue;
|
|
573
|
+
const label = match[1].trim();
|
|
574
|
+
const value = match[2].trim();
|
|
575
|
+
const known = REQUIRED_FIELDS.find((f) => f.label === label);
|
|
576
|
+
if (known) {
|
|
577
|
+
fields[known.key] = value;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (label === "Working branch")
|
|
581
|
+
fields.workingBranch = value;
|
|
582
|
+
else if (label === "Branch policy")
|
|
583
|
+
fields.branchPolicy = value;
|
|
584
|
+
}
|
|
585
|
+
return fields;
|
|
586
|
+
}
|
|
587
|
+
var ASSIGNMENT_ENFORCEMENT_BOLD_RE = /^[ \t]*(?:[-*][ \t]+)?\*\*\s*Enforcement\s*\*\*\s*:\s*(.*)$/m;
|
|
588
|
+
var ASSIGNMENT_ENFORCEMENT_PLAIN_RE = /^[ \t]*(?:[-*][ \t]+)?Enforcement\s*:\s*(.*)$/m;
|
|
589
|
+
var COMPASS_ENFORCEMENT_RE = /^enforcement\s*:\s*(.*)$/m;
|
|
590
|
+
function enforcementValue(raw) {
|
|
591
|
+
const value = raw.trim();
|
|
592
|
+
const unquoted = value.replace(/^(['"])(.*)\1$/, "$2");
|
|
593
|
+
return unquoted.trim().toLowerCase();
|
|
594
|
+
}
|
|
595
|
+
var ASSIGNMENT_BODY_START_RE = /^(?:#{1,6}[ \t]+Task\b|-{3,}[ \t]*$|#[ \t])/m;
|
|
596
|
+
function assignmentHeaderRegion(assignmentText) {
|
|
597
|
+
const marker = assignmentText.match(ASSIGNMENT_BODY_START_RE);
|
|
598
|
+
return marker !== null ? assignmentText.slice(0, marker.index) : assignmentText;
|
|
599
|
+
}
|
|
600
|
+
function parseEnforcementFlag(text) {
|
|
601
|
+
const bold = text.match(ASSIGNMENT_ENFORCEMENT_BOLD_RE);
|
|
602
|
+
if (bold !== null)
|
|
603
|
+
return { hard: enforcementValue(bold[1]) === "hard", source: "assignment" };
|
|
604
|
+
const plain = text.match(ASSIGNMENT_ENFORCEMENT_PLAIN_RE);
|
|
605
|
+
if (plain !== null)
|
|
606
|
+
return { hard: enforcementValue(plain[1]) === "hard", source: "assignment" };
|
|
607
|
+
const compass = text.match(COMPASS_ENFORCEMENT_RE);
|
|
608
|
+
if (compass !== null)
|
|
609
|
+
return { hard: enforcementValue(compass[1]) === "hard", source: "compass" };
|
|
610
|
+
return { hard: false, source: "none" };
|
|
611
|
+
}
|
|
612
|
+
function requireField(violations, value, label, code) {
|
|
613
|
+
if (value === undefined) {
|
|
614
|
+
const v = violation2("high", `assignment.field.missing-${code}`, `missing required Assignment field: ${label}`, `add "**${label}**: <value>" to the Assignment`);
|
|
615
|
+
v.aliases = [`assignment.presence.missing-${code}`];
|
|
616
|
+
violations.push(v);
|
|
617
|
+
} else if (value === "") {
|
|
618
|
+
const v = violation2("high", `assignment.field.invalid-${code}`, `${label} must be non-empty`, `fill in "**${label}**: <value>"`);
|
|
619
|
+
v.aliases = [`assignment.presence.missing-${code}`];
|
|
620
|
+
violations.push(v);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function parseWorkingBranchValue(value) {
|
|
624
|
+
if (value === "")
|
|
625
|
+
return {};
|
|
626
|
+
const create = value.match(/^create\s+(\S+)(?:\s+from\s+(\S+))?$/i);
|
|
627
|
+
if (create)
|
|
628
|
+
return { createForm: { name: create[1], base: create[2] } };
|
|
629
|
+
const danglingFrom = value.match(/^create\s+(\S+)\s+from$/i);
|
|
630
|
+
if (danglingFrom)
|
|
631
|
+
return { createForm: { name: danglingFrom[1], base: "" } };
|
|
632
|
+
const missingName = value.match(/^create\s+from\s+(\S+)$/i);
|
|
633
|
+
if (missingName)
|
|
634
|
+
return { createForm: { name: "", base: missingName[1] } };
|
|
635
|
+
return { workingBranch: value.split(/\s+/)[0] };
|
|
636
|
+
}
|
|
637
|
+
function parseAssignmentBranchForms(assignmentText) {
|
|
638
|
+
const fields = parseAssignmentFields(assignmentText);
|
|
639
|
+
const forms = {};
|
|
640
|
+
if (fields.workingBranch !== undefined && fields.workingBranch !== "") {
|
|
641
|
+
const parsed = parseWorkingBranchValue(fields.workingBranch);
|
|
642
|
+
if (parsed.createForm !== undefined)
|
|
643
|
+
forms.createForm = parsed.createForm;
|
|
644
|
+
else
|
|
645
|
+
forms.workingBranch = parsed.workingBranch;
|
|
646
|
+
}
|
|
647
|
+
if (fields.branchPolicy !== undefined && fields.branchPolicy !== "") {
|
|
648
|
+
const direct = fields.branchPolicy.match(/^direct\s+on\s+(\S+)/i);
|
|
649
|
+
if (direct) {
|
|
650
|
+
const strict = fields.branchPolicy.match(/^direct\s+on\s+(\S+)(?:\s*(?:[—–]|--|-)\s*(.+))?$/);
|
|
651
|
+
forms.directOn = { branch: direct[1].trim(), reason: strict ? (strict[2] ?? "").trim() : "" };
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return forms;
|
|
655
|
+
}
|
|
656
|
+
function parseBranchPolicyDirectOnBranch(assignmentText) {
|
|
657
|
+
const directOn = parseAssignmentBranchForms(assignmentText).directOn;
|
|
658
|
+
return directOn !== undefined && directOn.reason !== "" ? directOn.branch : undefined;
|
|
659
|
+
}
|
|
660
|
+
function isReadOnlyAssignmentRole(roleId) {
|
|
661
|
+
const role = roleId.trim().toLowerCase();
|
|
662
|
+
return role === "scout" || role === "explore";
|
|
663
|
+
}
|
|
664
|
+
function validateAssignmentFields(assignmentText, opts = {}) {
|
|
665
|
+
const violations = [];
|
|
666
|
+
const fields = parseAssignmentFields(assignmentText);
|
|
667
|
+
const writable = opts.writable !== false;
|
|
668
|
+
for (const { key, label, code } of REQUIRED_FIELDS) {
|
|
669
|
+
requireField(violations, fields[key], label, code);
|
|
670
|
+
}
|
|
671
|
+
if (writable) {
|
|
672
|
+
const workingPresent = fields.workingBranch !== undefined && fields.workingBranch !== "";
|
|
673
|
+
const policyPresent = fields.branchPolicy !== undefined && fields.branchPolicy !== "";
|
|
674
|
+
const formCount = Number(workingPresent) + Number(policyPresent);
|
|
675
|
+
const forms = parseAssignmentBranchForms(assignmentText);
|
|
676
|
+
if (formCount === 0) {
|
|
677
|
+
violations.push(violation2("high", "assignment.field.branch-missing", "writable assignment must contain exactly one branch form", `add exactly one of: ${BRANCH_FORMS_HINT}`));
|
|
678
|
+
} else if (formCount > 1) {
|
|
679
|
+
violations.push(violation2("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}`));
|
|
680
|
+
} else if (workingPresent) {
|
|
681
|
+
const create = forms.createForm;
|
|
682
|
+
if (create !== undefined && (create.base === undefined || create.base.trim() === "" || create.name.trim() === "")) {
|
|
683
|
+
violations.push(violation2("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`)"));
|
|
684
|
+
}
|
|
685
|
+
} else if (policyPresent) {
|
|
686
|
+
const direct = forms.directOn;
|
|
687
|
+
if (direct === undefined) {
|
|
688
|
+
violations.push(violation2("high", "assignment.field.branch-policy-missing-branch", `unparseable Branch policy: "${fields.branchPolicy}" (expected "direct on <branch> — <reason>")`, "start the field with `direct on <branch>`"));
|
|
689
|
+
} else if (direct.reason === "") {
|
|
690
|
+
violations.push(violation2("high", "assignment.field.branch-policy-missing-reason", `Branch policy "direct on ${direct.branch}" is missing the reason`, 'append "— <reason>" after the branch name'));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return { ok: violations.length === 0, violations };
|
|
695
|
+
}
|
|
696
|
+
function assertDefaultBranchProtected(branch, opts = {}) {
|
|
697
|
+
const defaultBranches = opts.defaultBranches ?? ["main", "master"];
|
|
698
|
+
const violations = [];
|
|
699
|
+
const normalized = branch.trim();
|
|
700
|
+
if (normalized !== "" && defaultBranches.includes(normalized) && opts.directOnException !== true) {
|
|
701
|
+
violations.push(violation2("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`));
|
|
702
|
+
}
|
|
703
|
+
return { ok: violations.length === 0, violations };
|
|
704
|
+
}
|
|
705
|
+
function executionModeToN(executionMode, opts = {}) {
|
|
706
|
+
const violations = [];
|
|
707
|
+
const mode = executionMode.trim().toLowerCase().split(/\s+/)[0] ?? "";
|
|
708
|
+
let n;
|
|
709
|
+
if (mode === "") {
|
|
710
|
+
violations.push(violation2("high", "dispatch.execution-mode.missing", "missing required Assignment field: Execution mode", 'add "**Execution mode**: sdd | inline | targeted"'));
|
|
711
|
+
} else if (mode === "sdd") {
|
|
712
|
+
n = 3;
|
|
713
|
+
} else if (mode === "inline") {
|
|
714
|
+
n = 1;
|
|
715
|
+
} else if (mode === "targeted") {
|
|
716
|
+
const seats = [...new Set((opts.seats ?? []).map((s) => s.trim()).filter((s) => s !== ""))];
|
|
717
|
+
if (seats.length === 0) {
|
|
718
|
+
violations.push(violation2("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'));
|
|
719
|
+
} else if (seats.length > 3) {
|
|
720
|
+
violations.push(violation2("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"));
|
|
721
|
+
} else {
|
|
722
|
+
n = seats.length;
|
|
723
|
+
}
|
|
724
|
+
} else {
|
|
725
|
+
violations.push(violation2("high", "dispatch.execution-mode.unknown", `unknown execution mode "${executionMode.trim()}" (expected sdd | inline | targeted)`, "fix the Execution mode field"));
|
|
726
|
+
}
|
|
727
|
+
return n === undefined ? { ok: false, violations } : { ok: true, violations, n };
|
|
728
|
+
}
|
|
729
|
+
function assertTriIdentity(reviewerRoles) {
|
|
730
|
+
const tri = ["qc-specialist", "qc-specialist-2", "qc-specialist-3"];
|
|
731
|
+
const roles = reviewerRoles.map((r) => r.trim().toLowerCase()).filter((r) => r !== "");
|
|
732
|
+
const valid = roles.length === tri.length && new Set(roles).size === tri.length && roles.every((r) => tri.includes(r));
|
|
733
|
+
if (valid)
|
|
734
|
+
return { ok: true, violations: [] };
|
|
735
|
+
const got = roles.length > 0 ? roles.join(", ") : "(none)";
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
violations: [
|
|
739
|
+
violation2("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")
|
|
740
|
+
]
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
function antiRecursionPrecheck(subagentType, executeAs) {
|
|
744
|
+
const binding = subagentType.trim().toLowerCase();
|
|
745
|
+
const role = executeAs.trim().toLowerCase();
|
|
746
|
+
if (binding !== "" && binding === role) {
|
|
747
|
+
return {
|
|
748
|
+
ok: false,
|
|
749
|
+
violations: [
|
|
750
|
+
violation2("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")
|
|
751
|
+
]
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
return { ok: true, violations: [] };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/status.ts
|
|
758
|
+
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
759
|
+
var PLAN_STATUSES = ["Todo", "InProgress", "InReview", "Blocked", "Done"];
|
|
760
|
+
var RESIDUAL_DECISIONS = ["defer", "accept", "risk-accepted"];
|
|
761
|
+
var RESIDUAL_LIFECYCLES = ["open", "resolved", "waived", "superseded", "duplicate"];
|
|
762
|
+
var ROLLUP_FIELDS = ["total_open", "by_severity", "by_target", "by_plan"];
|
|
763
|
+
function isPlainObject2(value) {
|
|
764
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
765
|
+
}
|
|
766
|
+
function violation3(severity, code, message, fix) {
|
|
767
|
+
return { ok: false, severity, code, message, fix };
|
|
768
|
+
}
|
|
769
|
+
function todayString() {
|
|
770
|
+
const now = new Date;
|
|
771
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
772
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
773
|
+
return `${now.getFullYear()}-${month}-${day}`;
|
|
774
|
+
}
|
|
775
|
+
function normalizeSeverity(value) {
|
|
776
|
+
if (value === "warning")
|
|
777
|
+
return "low";
|
|
778
|
+
if (value === null || value === "")
|
|
779
|
+
return "medium";
|
|
780
|
+
return value;
|
|
781
|
+
}
|
|
782
|
+
function isOpenResidual(entry) {
|
|
783
|
+
const lifecycle = entry.lifecycle;
|
|
784
|
+
const effective = lifecycle === false || lifecycle === null || lifecycle === undefined ? "open" : lifecycle;
|
|
785
|
+
return effective === "open";
|
|
786
|
+
}
|
|
787
|
+
function validateNonEmptyString2(violations, value, field, missingCode, invalidCode) {
|
|
788
|
+
if (value === undefined) {
|
|
789
|
+
violations.push(violation3("high", missingCode, `missing required field: ${field}`));
|
|
790
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
791
|
+
violations.push(violation3("medium", invalidCode, `${field} must be a non-empty string`));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function validatePlanRow(row) {
|
|
795
|
+
const violations = [];
|
|
796
|
+
if (!isPlainObject2(row)) {
|
|
797
|
+
return { ok: false, violations: [violation3("high", "status.plan-row.invalid", "plan row must be an object")] };
|
|
798
|
+
}
|
|
799
|
+
const { id, plan_id: planId, title, file, status, metadata, execution_lease } = row;
|
|
800
|
+
if (id === undefined && planId === undefined) {
|
|
801
|
+
violations.push(violation3("high", "status.plan-row.missing-id", "missing required field: id (or legacy plan_id)"));
|
|
802
|
+
} else {
|
|
803
|
+
if (id !== undefined) {
|
|
804
|
+
validateNonEmptyString2(violations, id, "id", "status.plan-row.missing-id", "status.plan-row.invalid-id");
|
|
805
|
+
}
|
|
806
|
+
if (planId !== undefined) {
|
|
807
|
+
validateNonEmptyString2(violations, planId, "plan_id", "status.plan-row.missing-plan-id", "status.plan-row.invalid-plan-id");
|
|
808
|
+
}
|
|
809
|
+
if (id !== undefined && planId !== undefined && id !== planId) {
|
|
810
|
+
violations.push(violation3("medium", "status.plan-row.dual-id", "row has both id and plan_id with different values — write one canonical key (prefer id)"));
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
validateNonEmptyString2(violations, title, "title", "status.plan-row.missing-title", "status.plan-row.invalid-title");
|
|
814
|
+
validateNonEmptyString2(violations, file, "file", "status.plan-row.missing-file", "status.plan-row.invalid-file");
|
|
815
|
+
if (status === undefined) {
|
|
816
|
+
violations.push(violation3("high", "status.plan-row.missing-status", "missing required field: status"));
|
|
817
|
+
} else if (typeof status !== "string" || !PLAN_STATUSES.includes(status)) {
|
|
818
|
+
violations.push(violation3("medium", "status.plan-row.invalid-status", `status must be one of ${PLAN_STATUSES.join(" | ")} — got ${JSON.stringify(status)}`));
|
|
819
|
+
}
|
|
820
|
+
if (metadata !== undefined && !isPlainObject2(metadata)) {
|
|
821
|
+
violations.push(violation3("medium", "status.plan-row.invalid-metadata", "metadata must be an object"));
|
|
822
|
+
}
|
|
823
|
+
if (execution_lease !== undefined && !isPlainObject2(execution_lease)) {
|
|
824
|
+
violations.push(violation3("medium", "status.plan-row.invalid-execution-lease", "execution_lease must be an object"));
|
|
825
|
+
}
|
|
826
|
+
if (status === "Done" && execution_lease !== undefined) {
|
|
827
|
+
violations.push(violation3("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"'));
|
|
828
|
+
}
|
|
829
|
+
return { ok: violations.length === 0, violations };
|
|
830
|
+
}
|
|
831
|
+
function validateResidual(entry) {
|
|
832
|
+
const violations = [];
|
|
833
|
+
if (!isPlainObject2(entry)) {
|
|
834
|
+
return { ok: false, violations: [violation3("high", "status.residual.invalid", "residual entry must be an object")] };
|
|
835
|
+
}
|
|
836
|
+
const { id, title, severity, source, scope, decision, owner, target, tracking, detail_doc, lifecycle, closed_at } = entry;
|
|
837
|
+
validateNonEmptyString2(violations, id, "id", "status.residual.missing-id", "status.residual.invalid-id");
|
|
838
|
+
validateNonEmptyString2(violations, title, "title", "status.residual.missing-title", "status.residual.invalid-title");
|
|
839
|
+
validateNonEmptyString2(violations, source, "source", "status.residual.missing-source", "status.residual.invalid-source");
|
|
840
|
+
validateNonEmptyString2(violations, scope, "scope", "status.residual.missing-scope", "status.residual.invalid-scope");
|
|
841
|
+
validateNonEmptyString2(violations, owner, "owner", "status.residual.missing-owner", "status.residual.invalid-owner");
|
|
842
|
+
if (severity === undefined) {
|
|
843
|
+
violations.push(violation3("high", "status.residual.missing-severity", "missing required field: severity"));
|
|
844
|
+
} else if (typeof severity !== "string" || !SEVERITY_ORDER.includes(severity) && severity !== "warning") {
|
|
845
|
+
violations.push(violation3("medium", "status.residual.invalid-severity", `severity must be one of ${SEVERITY_ORDER.join(" | ")} — got ${JSON.stringify(severity)}`));
|
|
846
|
+
} else if (severity === "warning") {
|
|
847
|
+
violations.push(violation3("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')`));
|
|
848
|
+
}
|
|
849
|
+
if (decision === undefined) {
|
|
850
|
+
violations.push(violation3("high", "status.residual.missing-decision", "missing required field: decision"));
|
|
851
|
+
} else if (typeof decision !== "string" || !RESIDUAL_DECISIONS.includes(decision)) {
|
|
852
|
+
violations.push(violation3("medium", "status.residual.invalid-decision", `decision must be one of ${RESIDUAL_DECISIONS.join(" | ")} — got ${JSON.stringify(decision)}`));
|
|
853
|
+
}
|
|
854
|
+
if (target === undefined) {
|
|
855
|
+
violations.push(violation3("high", "status.residual.missing-target", "missing required field: target"));
|
|
856
|
+
} else if (typeof target !== "string" && target !== null) {
|
|
857
|
+
violations.push(violation3("medium", "status.residual.invalid-target", "target must be a string or null"));
|
|
858
|
+
}
|
|
859
|
+
if (tracking === undefined) {
|
|
860
|
+
violations.push(violation3("high", "status.residual.missing-tracking", "missing required field: tracking"));
|
|
861
|
+
} else if (typeof tracking !== "string" && tracking !== null) {
|
|
862
|
+
violations.push(violation3("medium", "status.residual.invalid-tracking", "tracking must be a string or null"));
|
|
863
|
+
}
|
|
864
|
+
if (detail_doc !== undefined && typeof detail_doc !== "string" && detail_doc !== null) {
|
|
865
|
+
violations.push(violation3("medium", "status.residual.invalid-detail-doc", "detail_doc must be a string or null"));
|
|
866
|
+
}
|
|
867
|
+
if (closed_at !== undefined && (typeof closed_at !== "string" || !DATE_RE.test(closed_at))) {
|
|
868
|
+
violations.push(violation3("medium", "status.residual.invalid-closed-at", "closed_at must be YYYY-MM-DD"));
|
|
869
|
+
}
|
|
870
|
+
if (lifecycle !== undefined) {
|
|
871
|
+
if (typeof lifecycle !== "string" || !RESIDUAL_LIFECYCLES.includes(lifecycle)) {
|
|
872
|
+
violations.push(violation3("medium", "status.residual.invalid-lifecycle", `lifecycle must be one of ${RESIDUAL_LIFECYCLES.join(" | ")} — got ${JSON.stringify(lifecycle)}`));
|
|
873
|
+
} else if (lifecycle !== "open") {
|
|
874
|
+
if (closed_at === undefined) {
|
|
875
|
+
violations.push(violation3("high", "status.residual.closed-missing-closed-at", `lifecycle "${lifecycle}" requires closed_at (YYYY-MM-DD)`, 'set closed_at (e.g. "2026-08-08")'));
|
|
876
|
+
}
|
|
877
|
+
if (entry.closure_note === undefined) {
|
|
878
|
+
violations.push(violation3("medium", "status.residual.closed-missing-closure-note", `lifecycle "${lifecycle}" requires closure_note (what changed; how verified)`, "add closure_note explaining the close"));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return { ok: violations.length === 0, violations };
|
|
883
|
+
}
|
|
884
|
+
function validateStatus(docOrPath) {
|
|
885
|
+
let doc;
|
|
886
|
+
if (typeof docOrPath === "string") {
|
|
887
|
+
try {
|
|
888
|
+
doc = readJson(docOrPath);
|
|
889
|
+
} catch (error) {
|
|
890
|
+
return {
|
|
891
|
+
ok: false,
|
|
892
|
+
violations: [violation3("high", "status.invalid-json", error.message)]
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
} else {
|
|
896
|
+
doc = docOrPath;
|
|
897
|
+
}
|
|
898
|
+
const violations = [];
|
|
899
|
+
const { version, updated_at, plans, residual_findings, metadata } = doc;
|
|
900
|
+
if (version === undefined) {
|
|
901
|
+
violations.push(violation3("high", "status.missing-version", "missing required field: version"));
|
|
902
|
+
} else if (typeof version !== "number" || !Number.isInteger(version)) {
|
|
903
|
+
violations.push(violation3("high", "status.invalid-version", "version must be an integer"));
|
|
904
|
+
} else if (version !== 1) {
|
|
905
|
+
violations.push(violation3("medium", "status.unsupported-version", `unsupported status.json schema version ${version} — expected 1`));
|
|
906
|
+
}
|
|
907
|
+
if (updated_at === undefined) {
|
|
908
|
+
violations.push(violation3("high", "status.missing-updated-at", "missing required field: updated_at"));
|
|
909
|
+
} else if (typeof updated_at !== "string" || !DATE_RE.test(updated_at)) {
|
|
910
|
+
violations.push(violation3("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
|
|
911
|
+
}
|
|
912
|
+
if (plans === undefined) {
|
|
913
|
+
violations.push(violation3("high", "status.missing-plans", "missing required field: plans"));
|
|
914
|
+
} else if (!Array.isArray(plans)) {
|
|
915
|
+
violations.push(violation3("high", "status.invalid-plans", "plans must be an array"));
|
|
916
|
+
} else {
|
|
917
|
+
for (const row of plans) {
|
|
918
|
+
violations.push(...validatePlanRow(row).violations);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (residual_findings === undefined) {
|
|
922
|
+
violations.push(violation3("high", "status.missing-residual-findings", "missing required field: residual_findings (root-only canonical)"));
|
|
923
|
+
} else if (!isPlainObject2(residual_findings)) {
|
|
924
|
+
violations.push(violation3("high", "status.invalid-residual-findings", "residual_findings must be an object at root"));
|
|
925
|
+
} else {
|
|
926
|
+
for (const [planId, list] of Object.entries(residual_findings)) {
|
|
927
|
+
if (!Array.isArray(list)) {
|
|
928
|
+
violations.push(violation3("high", "status.residual.invalid-list", `residual_findings["${planId}"] must be an array`));
|
|
929
|
+
} else if (list.length === 0) {
|
|
930
|
+
violations.push(violation3("low", "status.residual.empty-key", `residual_findings["${planId}"] is empty — delete the key (no "plan-id": [])`));
|
|
931
|
+
} else {
|
|
932
|
+
for (const entry of list) {
|
|
933
|
+
violations.push(...validateResidual(entry).violations);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (metadata === undefined) {
|
|
939
|
+
violations.push(violation3("high", "status.missing-metadata", "missing required field: metadata"));
|
|
940
|
+
} else if (!isPlainObject2(metadata)) {
|
|
941
|
+
violations.push(violation3("high", "status.invalid-metadata", "metadata must be an object"));
|
|
942
|
+
} else if (Object.prototype.hasOwnProperty.call(metadata, "residual_findings")) {
|
|
943
|
+
violations.push(violation3("medium", "status.dual-write-residuals", "residual_findings must be root-only — metadata.residual_findings is legacy read-only; remove it (no dual-write)", "move entries to root residual_findings and delete metadata.residual_findings"));
|
|
944
|
+
}
|
|
945
|
+
return { ok: violations.length === 0, violations };
|
|
946
|
+
}
|
|
947
|
+
async function archiveResiduals(planId, harnessDir) {
|
|
948
|
+
const dir = harnessDir !== undefined ? resolve4(harnessDir) : resolveHarnessDir();
|
|
949
|
+
if (dir === null) {
|
|
950
|
+
throw new Error(`harness dir not found from ${process.cwd()} — pass harnessDir or set MSTAR_HARNESS_DIR`);
|
|
951
|
+
}
|
|
952
|
+
assertSafePathComponent(planId, "planId");
|
|
953
|
+
const statusPath = join4(dir, "status.json");
|
|
954
|
+
if (!existsSync2(statusPath)) {
|
|
955
|
+
throw new Error(`status file not found: ${statusPath}`);
|
|
956
|
+
}
|
|
957
|
+
return withStatusWriteLock(statusPath, () => {
|
|
958
|
+
const doc = readJson(statusPath);
|
|
959
|
+
if (!isPlainObject2(doc.residual_findings)) {
|
|
960
|
+
throw new Error(`status.json residual_findings must be an object: ${statusPath}`);
|
|
961
|
+
}
|
|
962
|
+
const open = doc.residual_findings[planId];
|
|
963
|
+
const archivePath = join4(dir, "archived", "residuals", `${planId}.json`);
|
|
964
|
+
if (!Array.isArray(open) || open.length === 0) {
|
|
965
|
+
return { planId, archived: 0, archivePath };
|
|
966
|
+
}
|
|
967
|
+
const archive = readJson(archivePath);
|
|
968
|
+
const existing = Array.isArray(archive.entries) ? archive.entries : [];
|
|
969
|
+
const existingIds = new Set(existing.map((e) => isPlainObject2(e) && typeof e.id === "string" ? e.id : undefined).filter((id) => id !== undefined));
|
|
970
|
+
const today = todayString();
|
|
971
|
+
const moved = open.filter((entry) => {
|
|
972
|
+
if (!isPlainObject2(entry) || typeof entry.id !== "string")
|
|
973
|
+
return true;
|
|
974
|
+
return !existingIds.has(entry.id);
|
|
975
|
+
}).map((entry) => ({ ...entry, archived_at: today }));
|
|
976
|
+
if (moved.length > 0) {
|
|
977
|
+
writeJson(archivePath, { plan_id: planId, schema_version: 1, entries: [...existing, ...moved] });
|
|
978
|
+
}
|
|
979
|
+
delete doc.residual_findings[planId];
|
|
980
|
+
doc.updated_at = today;
|
|
981
|
+
writeJson(statusPath, doc);
|
|
982
|
+
return { planId, archived: moved.length, archivePath };
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
function planFindingsCleanup(doc, planId) {
|
|
986
|
+
if (!Array.isArray(doc.plans))
|
|
987
|
+
return;
|
|
988
|
+
for (const row of doc.plans) {
|
|
989
|
+
if (!isPlainObject2(row))
|
|
990
|
+
continue;
|
|
991
|
+
const rowId = row.id ?? row.plan_id;
|
|
992
|
+
if (rowId !== planId)
|
|
993
|
+
continue;
|
|
994
|
+
if (!isPlainObject2(row.metadata))
|
|
995
|
+
return;
|
|
996
|
+
const mode = row.metadata.findings_cleanup;
|
|
997
|
+
if (mode === "zero-residual" || mode === "allow-residual")
|
|
998
|
+
return mode;
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
function openResidualsOf(doc, planId) {
|
|
1004
|
+
if (!isPlainObject2(doc.residual_findings))
|
|
1005
|
+
return [];
|
|
1006
|
+
const list = doc.residual_findings[planId];
|
|
1007
|
+
if (!Array.isArray(list))
|
|
1008
|
+
return [];
|
|
1009
|
+
return list.filter((entry) => isPlainObject2(entry) && isOpenResidual(entry));
|
|
1010
|
+
}
|
|
1011
|
+
function findingsCleanupGate(doc, planId, opts) {
|
|
1012
|
+
const mode = opts?.mode ?? planFindingsCleanup(doc, planId) ?? "allow-residual";
|
|
1013
|
+
const violations = [];
|
|
1014
|
+
const residuals = openResidualsOf(doc, planId);
|
|
1015
|
+
for (const entry of residuals) {
|
|
1016
|
+
const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
|
|
1017
|
+
const label = `R#${id}`;
|
|
1018
|
+
if (mode === "zero-residual") {
|
|
1019
|
+
if (entry.severity === "nit") {
|
|
1020
|
+
violations.push(violation3("medium", "findings.zero-residual-nit", `${label}: style-only nits must be fixed in-session or dropped — never left open under zero-residual`));
|
|
1021
|
+
} else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
|
|
1022
|
+
violations.push(violation3("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
|
|
1023
|
+
} else if (entry.decision === "defer") {
|
|
1024
|
+
if (typeof entry.target !== "string" || entry.target.trim() === "") {
|
|
1025
|
+
violations.push(violation3("medium", "findings.zero-residual-defer-no-target", `${label}: blocker-defer requires a target (next iteration/milestone) under zero-residual`));
|
|
1026
|
+
}
|
|
1027
|
+
} else {
|
|
1028
|
+
violations.push(violation3("medium", "findings.zero-residual-open-fixable", `${label}: fixable finding must not remain open under zero-residual — fix now or convert to a blocker-defer`));
|
|
1029
|
+
}
|
|
1030
|
+
} else if (normalizeSeverity(entry.severity) === "critical") {
|
|
1031
|
+
violations.push(violation3("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return { ok: violations.length === 0, violations };
|
|
1035
|
+
}
|
|
1036
|
+
function resolveCompassEnforcement(harnessDir) {
|
|
1037
|
+
const iterationsDir = resolveIterationDir(harnessDir);
|
|
1038
|
+
if (!existsSync2(iterationsDir))
|
|
1039
|
+
return { hard: false, source: "none" };
|
|
1040
|
+
let entries;
|
|
1041
|
+
try {
|
|
1042
|
+
entries = readdirSync2(iterationsDir, { withFileTypes: true });
|
|
1043
|
+
} catch {
|
|
1044
|
+
return { hard: false, source: "none" };
|
|
1045
|
+
}
|
|
1046
|
+
for (const entry of entries) {
|
|
1047
|
+
if (!entry.isDirectory())
|
|
1048
|
+
continue;
|
|
1049
|
+
const compassPath = join4(iterationsDir, entry.name, "delivery-compass.md");
|
|
1050
|
+
if (!existsSync2(compassPath))
|
|
1051
|
+
continue;
|
|
1052
|
+
let content;
|
|
1053
|
+
try {
|
|
1054
|
+
content = readFileSync3(compassPath, "utf8");
|
|
1055
|
+
} catch {
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
1059
|
+
const fm = frontmatter !== null ? frontmatter[1] : "";
|
|
1060
|
+
if (!/^status[ \t]*:[ \t]*(?:active|locked)[ \t]*$/m.test(fm))
|
|
1061
|
+
continue;
|
|
1062
|
+
const flag = parseEnforcementFlag(fm);
|
|
1063
|
+
if (flag.hard)
|
|
1064
|
+
return flag;
|
|
1065
|
+
}
|
|
1066
|
+
return { hard: false, source: "none" };
|
|
1067
|
+
}
|
|
1068
|
+
function groupCount(values) {
|
|
1069
|
+
const counts = new Map;
|
|
1070
|
+
for (const value of values) {
|
|
1071
|
+
const key = typeof value === "string" ? value : String(value);
|
|
1072
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1073
|
+
}
|
|
1074
|
+
return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
1075
|
+
}
|
|
1076
|
+
function techDebtRollup(docOrPath) {
|
|
1077
|
+
const doc = typeof docOrPath === "string" ? readJson(docOrPath) : docOrPath;
|
|
1078
|
+
const canonical = isPlainObject2(doc.residual_findings) ? doc.residual_findings : {};
|
|
1079
|
+
const metadata = isPlainObject2(doc.metadata) ? doc.metadata : {};
|
|
1080
|
+
const legacy = isPlainObject2(metadata.residual_findings) ? metadata.residual_findings : {};
|
|
1081
|
+
const merged = { ...canonical, ...legacy };
|
|
1082
|
+
const items = [];
|
|
1083
|
+
for (const [plan, list] of Object.entries(merged)) {
|
|
1084
|
+
if (!Array.isArray(list))
|
|
1085
|
+
continue;
|
|
1086
|
+
for (const value of list) {
|
|
1087
|
+
if (!isPlainObject2(value) || !isOpenResidual(value))
|
|
1088
|
+
continue;
|
|
1089
|
+
items.push({ plan, entry: value });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
const bySeverity = {};
|
|
1093
|
+
for (const severity of SEVERITY_ORDER) {
|
|
1094
|
+
bySeverity[severity] = items.filter(({ entry }) => normalizeSeverity(entry.severity) === severity).length;
|
|
1095
|
+
}
|
|
1096
|
+
const computed = {
|
|
1097
|
+
total_open: items.length,
|
|
1098
|
+
by_severity: bySeverity,
|
|
1099
|
+
by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
|
|
1100
|
+
by_plan: groupCount(items.map(({ plan }) => plan))
|
|
1101
|
+
};
|
|
1102
|
+
const storedRaw = metadata.tech_debt_summary ?? null;
|
|
1103
|
+
const stored = storedRaw === null ? null : storedRaw;
|
|
1104
|
+
const checks = ROLLUP_FIELDS.map((field) => {
|
|
1105
|
+
const computedField = computed[field];
|
|
1106
|
+
if (stored === null)
|
|
1107
|
+
return { field, status: "DRIFT" };
|
|
1108
|
+
const storedField = stored[field];
|
|
1109
|
+
const storedCompared = storedField === false ? null : storedField ?? null;
|
|
1110
|
+
const status = JSON.stringify(computedField) === JSON.stringify(storedCompared) ? "PASS" : "DRIFT";
|
|
1111
|
+
return { field, status };
|
|
1112
|
+
});
|
|
1113
|
+
const overall = checks.every((check) => check.status === "PASS") ? "PASS" : "DRIFT";
|
|
1114
|
+
return { computed, stored, checks, overall };
|
|
1115
|
+
}
|
|
1116
|
+
// src/worktree.ts
|
|
1117
|
+
import { execFileSync } from "node:child_process";
|
|
1118
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1119
|
+
import { isAbsolute as isAbsolute3, resolve as resolve5 } from "node:path";
|
|
1120
|
+
var DEFAULT_PROBE_TIMEOUT_MS = 1e4;
|
|
1121
|
+
function probeTimeoutMs() {
|
|
1122
|
+
const raw = process.env.MSTAR_GIT_PROBE_TIMEOUT_MS;
|
|
1123
|
+
if (raw === undefined || raw.trim() === "")
|
|
1124
|
+
return DEFAULT_PROBE_TIMEOUT_MS;
|
|
1125
|
+
const parsed = Number(raw);
|
|
1126
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PROBE_TIMEOUT_MS;
|
|
1127
|
+
}
|
|
1128
|
+
function violation4(severity, code, message, fix) {
|
|
1129
|
+
return { ok: false, severity, code, message, fix };
|
|
1130
|
+
}
|
|
1131
|
+
function gate(violations) {
|
|
1132
|
+
return { ok: violations.length === 0, violations };
|
|
1133
|
+
}
|
|
1134
|
+
function probeBranch(worktreePath, opts) {
|
|
1135
|
+
const precomputed = opts.branchOf?.(worktreePath);
|
|
1136
|
+
if (precomputed !== undefined)
|
|
1137
|
+
return { branch: precomputed };
|
|
1138
|
+
const timeout = opts.timeoutMs ?? probeTimeoutMs();
|
|
1139
|
+
try {
|
|
1140
|
+
const stdout = execFileSync(opts.gitPath ?? "git", ["-C", worktreePath, "branch", "--show-current"], {
|
|
1141
|
+
encoding: "utf8",
|
|
1142
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1143
|
+
timeout
|
|
1144
|
+
});
|
|
1145
|
+
const branch = stdout.trim();
|
|
1146
|
+
if (branch === "")
|
|
1147
|
+
return { error: `no branch checked out (detached HEAD?) at "${worktreePath}"` };
|
|
1148
|
+
return { branch };
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
const e = err;
|
|
1151
|
+
if (e.killed === true || e.signal !== undefined) {
|
|
1152
|
+
return { error: `git probe timed out after ${timeout}ms (killed by ${e.signal ?? "SIGTERM"})` };
|
|
1153
|
+
}
|
|
1154
|
+
const detail = (e.stderr !== undefined ? e.stderr.toString().trim() : "") || e.message || "git probe failed";
|
|
1155
|
+
return { error: detail };
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
function l1PreDispatchCheck(input, opts = {}) {
|
|
1159
|
+
const violations = [];
|
|
1160
|
+
const { controlWorktreePath, leaseWorktreePath, leaseWorkingBranch, planId } = input;
|
|
1161
|
+
if (controlWorktreePath.trim() === "") {
|
|
1162
|
+
violations.push(violation4("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"));
|
|
1163
|
+
}
|
|
1164
|
+
if (leaseWorktreePath.trim() === "") {
|
|
1165
|
+
violations.push(violation4("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"));
|
|
1166
|
+
}
|
|
1167
|
+
if (leaseWorkingBranch.trim() === "") {
|
|
1168
|
+
violations.push(violation4("high", "worktree.l1.lease-branch-missing", `execution_lease.working_branch is empty for plan "${planId}"`, "record the lease working_branch before dispatch"));
|
|
1169
|
+
}
|
|
1170
|
+
if (controlWorktreePath !== "" && leaseWorktreePath !== "" && resolve5(controlWorktreePath) === resolve5(leaseWorktreePath)) {
|
|
1171
|
+
violations.push(violation4("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"));
|
|
1172
|
+
}
|
|
1173
|
+
if (leaseWorktreePath !== "" && !existsSync3(leaseWorktreePath)) {
|
|
1174
|
+
violations.push(violation4("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>`));
|
|
1175
|
+
} else if (leaseWorktreePath !== "" && leaseWorkingBranch !== "") {
|
|
1176
|
+
const probe = probeBranch(leaseWorktreePath, opts);
|
|
1177
|
+
if ("error" in probe) {
|
|
1178
|
+
violations.push(violation4("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)"));
|
|
1179
|
+
} else if (probe.branch !== leaseWorkingBranch) {
|
|
1180
|
+
violations.push(violation4("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`));
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return gate(violations);
|
|
1184
|
+
}
|
|
1185
|
+
function l2PreDispatchCheck(input, opts = {}) {
|
|
1186
|
+
const violations = [];
|
|
1187
|
+
const tracks = input.tracks ?? [];
|
|
1188
|
+
const seenPaths = new Set;
|
|
1189
|
+
if (tracks.length < 1) {
|
|
1190
|
+
violations.push(violation4("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"));
|
|
1191
|
+
}
|
|
1192
|
+
tracks.forEach((track, index) => {
|
|
1193
|
+
if (track.worktreePath.trim() === "" || track.workingBranch.trim() === "") {
|
|
1194
|
+
violations.push(violation4("high", "worktree.l2.track-invalid", `track ${index + 1} is missing worktreePath and/or workingBranch`, "fill both fields for every track"));
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (!isAbsolute3(track.worktreePath)) {
|
|
1198
|
+
violations.push(violation4("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>)`));
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
const normalized = resolve5(track.worktreePath);
|
|
1202
|
+
if (seenPaths.has(normalized)) {
|
|
1203
|
+
violations.push(violation4("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"));
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
seenPaths.add(normalized);
|
|
1207
|
+
if (!existsSync3(track.worktreePath)) {
|
|
1208
|
+
violations.push(violation4("high", "worktree.l2.track-missing", `track worktree directory "${track.worktreePath}" does not exist`, `create it before dispatch: git worktree add ${track.worktreePath} ${track.workingBranch}`));
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
const probe = probeBranch(track.worktreePath, opts);
|
|
1212
|
+
if ("error" in probe) {
|
|
1213
|
+
violations.push(violation4("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)"));
|
|
1214
|
+
} else if (probe.branch !== track.workingBranch) {
|
|
1215
|
+
violations.push(violation4("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`));
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
return gate(violations);
|
|
1219
|
+
}
|
|
1220
|
+
function assertControlVsFeaturePath(controlWorktreePath, featureWorktreePath) {
|
|
1221
|
+
const violations = [];
|
|
1222
|
+
const samePath = controlWorktreePath === "" && featureWorktreePath === "" || controlWorktreePath !== "" && featureWorktreePath !== "" && resolve5(controlWorktreePath) === resolve5(featureWorktreePath);
|
|
1223
|
+
if (samePath) {
|
|
1224
|
+
violations.push(violation4("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"));
|
|
1225
|
+
}
|
|
1226
|
+
return gate(violations);
|
|
1227
|
+
}
|
|
1228
|
+
function assertBranchAlignment(worktreePath, expectedBranch, opts = {}) {
|
|
1229
|
+
const violations = [];
|
|
1230
|
+
const probe = probeBranch(worktreePath, opts);
|
|
1231
|
+
if ("error" in probe) {
|
|
1232
|
+
violations.push(violation4("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)"));
|
|
1233
|
+
} else if (probe.branch !== expectedBranch) {
|
|
1234
|
+
violations.push(violation4("high", "worktree.branch-mismatch", `worktree "${worktreePath}" is on branch "${probe.branch}", expected "${expectedBranch}" (Assignment Working branch)`, `checkout ${expectedBranch} in that worktree`));
|
|
1235
|
+
}
|
|
1236
|
+
return gate(violations);
|
|
1237
|
+
}
|
|
1238
|
+
var QC_ALIGNMENT_FIELDS = [
|
|
1239
|
+
{ key: "planId", label: "plan_id" },
|
|
1240
|
+
{ key: "reviewRange", label: "Review range" },
|
|
1241
|
+
{ key: "diffBasis", label: "Diff basis" }
|
|
1242
|
+
];
|
|
1243
|
+
function assertQcAlignment(assignments) {
|
|
1244
|
+
const violations = [];
|
|
1245
|
+
const list = assignments ?? [];
|
|
1246
|
+
for (const { key, label } of QC_ALIGNMENT_FIELDS) {
|
|
1247
|
+
const distinct = [...new Set(list.map((a) => a[key]))];
|
|
1248
|
+
if (distinct.length > 1) {
|
|
1249
|
+
violations.push(violation4("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`));
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return gate(violations);
|
|
1253
|
+
}
|
|
1254
|
+
function singleReviewSnapshot(assignments) {
|
|
1255
|
+
const violations = [];
|
|
1256
|
+
const list = assignments ?? [];
|
|
1257
|
+
list.forEach((a, index) => {
|
|
1258
|
+
if ((a.head ?? "").trim() === "") {
|
|
1259
|
+
violations.push(violation4("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"));
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
const distinct = [...new Set(list.map((a) => a.head ?? "").filter((h) => h.trim() !== ""))];
|
|
1263
|
+
if (distinct.length > 1) {
|
|
1264
|
+
violations.push(violation4("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"));
|
|
1265
|
+
}
|
|
1266
|
+
return gate(violations);
|
|
1267
|
+
}
|
|
1268
|
+
// src/sdd.ts
|
|
1269
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
1270
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, realpathSync, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1271
|
+
import { basename as basename3, dirname as dirname4, isAbsolute as isAbsolute4, join as join5, resolve as resolve6 } from "node:path";
|
|
1272
|
+
class SddScriptError extends Error {
|
|
1273
|
+
exitCode;
|
|
1274
|
+
constructor(message, exitCode) {
|
|
1275
|
+
super(message);
|
|
1276
|
+
this.name = "SddScriptError";
|
|
1277
|
+
this.exitCode = exitCode;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
function isDirectory2(dir) {
|
|
1281
|
+
try {
|
|
1282
|
+
return statSync3(dir).isDirectory();
|
|
1283
|
+
} catch {
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
function isFile(file) {
|
|
1288
|
+
try {
|
|
1289
|
+
return statSync3(file).isFile();
|
|
1290
|
+
} catch {
|
|
1291
|
+
return false;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
var GIT_CAPTURE_MAX_BYTES = 64 * 1024 * 1024;
|
|
1295
|
+
function gitOut(cwd, args) {
|
|
1296
|
+
try {
|
|
1297
|
+
return execFileSync2("git", args, {
|
|
1298
|
+
cwd,
|
|
1299
|
+
encoding: "utf8",
|
|
1300
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1301
|
+
maxBuffer: GIT_CAPTURE_MAX_BYTES
|
|
1302
|
+
}).trim();
|
|
1303
|
+
} catch {
|
|
1304
|
+
return null;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
function probeHarnessWithStatus(root) {
|
|
1308
|
+
if (isFile(join5(root, ".mstar", "status.json")))
|
|
1309
|
+
return join5(root, ".mstar");
|
|
1310
|
+
if (isFile(join5(root, ".agents", "status.json")))
|
|
1311
|
+
return join5(root, ".agents");
|
|
1312
|
+
return null;
|
|
1313
|
+
}
|
|
1314
|
+
function isLinkedWorktree(root) {
|
|
1315
|
+
const gitDirRaw = gitOut(root, ["rev-parse", "--git-dir"]);
|
|
1316
|
+
const commonRaw = gitOut(root, ["rev-parse", "--git-common-dir"]);
|
|
1317
|
+
if (gitDirRaw === null || commonRaw === null)
|
|
1318
|
+
return false;
|
|
1319
|
+
const gitDir = isAbsolute4(gitDirRaw) ? gitDirRaw : join5(root, gitDirRaw);
|
|
1320
|
+
const common = isAbsolute4(commonRaw) ? commonRaw : join5(root, commonRaw);
|
|
1321
|
+
if (gitDir.includes("/.git/worktrees/") || gitDir.includes("/worktrees/"))
|
|
1322
|
+
return true;
|
|
1323
|
+
try {
|
|
1324
|
+
const gdParent = realpathSync(dirname4(gitDir));
|
|
1325
|
+
const cmAbs = realpathSync(common);
|
|
1326
|
+
return join5(gdParent, basename3(gitDir)) !== cmAbs && gitDir !== cmAbs;
|
|
1327
|
+
} catch {
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
function sddWorkspace(planId, opts = {}) {
|
|
1332
|
+
if (!planId) {
|
|
1333
|
+
throw new SddScriptError(`usage: mstar sdd workspace PLAN_ID [CONTROL_ROOT]
|
|
1334
|
+
` + " Set MSTAR_CONTROL_ROOT=<control_worktree_path> when running from a feature worktree.", 2);
|
|
1335
|
+
}
|
|
1336
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1337
|
+
const controlRoot = opts.controlRoot ?? (process.env.MSTAR_CONTROL_ROOT || undefined);
|
|
1338
|
+
let root;
|
|
1339
|
+
if (controlRoot) {
|
|
1340
|
+
if (!isDirectory2(controlRoot)) {
|
|
1341
|
+
throw new SddScriptError(`mstar sdd workspace: CONTROL_ROOT / MSTAR_CONTROL_ROOT is not a directory: ${controlRoot}`, 1);
|
|
1342
|
+
}
|
|
1343
|
+
root = realpathSync(controlRoot);
|
|
1344
|
+
} else {
|
|
1345
|
+
const topLevel = gitOut(cwd, ["rev-parse", "--show-toplevel"]);
|
|
1346
|
+
root = realpathSync(topLevel ?? cwd);
|
|
1347
|
+
}
|
|
1348
|
+
if (!controlRoot && isLinkedWorktree(root)) {
|
|
1349
|
+
throw new SddScriptError(`mstar sdd workspace: linked worktree at ${root} has no {HARNESS_DIR}/status.json (default gitignore).
|
|
1350
|
+
` + ` Refusing to create a second SDD tree under the feature checkout.
|
|
1351
|
+
` + ` Re-run with MSTAR_CONTROL_ROOT=<control_worktree_path> or: mstar sdd workspace ${planId} <control_worktree_path>
|
|
1352
|
+
` + ` See mstar-branch-worktree «Harness path SSOT under default gitignore».`, 1);
|
|
1353
|
+
}
|
|
1354
|
+
const harnessOverride = opts.harnessDir ?? (process.env.MSTAR_HARNESS_DIR || undefined);
|
|
1355
|
+
let harnessDir;
|
|
1356
|
+
if (harnessOverride) {
|
|
1357
|
+
harnessDir = resolve6(root, harnessOverride);
|
|
1358
|
+
} else {
|
|
1359
|
+
const probed = probeHarnessWithStatus(root);
|
|
1360
|
+
if (probed) {
|
|
1361
|
+
harnessDir = probed;
|
|
1362
|
+
} else if (isDirectory2(join5(root, ".mstar"))) {
|
|
1363
|
+
harnessDir = join5(root, ".mstar");
|
|
1364
|
+
} else if (isDirectory2(join5(root, ".agents"))) {
|
|
1365
|
+
harnessDir = join5(root, ".agents");
|
|
1366
|
+
} else {
|
|
1367
|
+
harnessDir = join5(root, ".mstar");
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const sddDir = resolveSddDir(harnessDir, planId);
|
|
1371
|
+
mkdirSync4(sddDir, { recursive: true });
|
|
1372
|
+
writeFileSync3(join5(sddDir, ".gitignore"), `*
|
|
1373
|
+
`);
|
|
1374
|
+
return realpathSync(sddDir);
|
|
1375
|
+
}
|
|
1376
|
+
function taskBrief(planFile, taskN, outFile, opts = {}) {
|
|
1377
|
+
if (!planFile || !Number.isInteger(taskN) || taskN < 1) {
|
|
1378
|
+
throw new SddScriptError("usage: mstar sdd task-brief PLAN_FILE TASK_NUMBER [OUTFILE]", 2);
|
|
1379
|
+
}
|
|
1380
|
+
let content;
|
|
1381
|
+
try {
|
|
1382
|
+
content = readFileSync4(planFile, "utf8");
|
|
1383
|
+
} catch {
|
|
1384
|
+
throw new SddScriptError(`no such plan file: ${planFile}`, 2);
|
|
1385
|
+
}
|
|
1386
|
+
let out;
|
|
1387
|
+
if (outFile) {
|
|
1388
|
+
out = outFile;
|
|
1389
|
+
} else {
|
|
1390
|
+
const sddDir = opts.sddDir ?? process.env.SDD_DIR;
|
|
1391
|
+
if (!sddDir) {
|
|
1392
|
+
throw new SddScriptError("mstar sdd task-brief: set SDD_DIR or pass OUTFILE (run mstar sdd workspace PLAN_ID first)", 2);
|
|
1393
|
+
}
|
|
1394
|
+
mkdirSync4(sddDir, { recursive: true });
|
|
1395
|
+
out = join5(sddDir, `task-${taskN}-brief.md`);
|
|
1396
|
+
}
|
|
1397
|
+
const records = content.endsWith(`
|
|
1398
|
+
`) ? content.split(`
|
|
1399
|
+
`).slice(0, -1) : content.split(`
|
|
1400
|
+
`);
|
|
1401
|
+
const headingRe = /^#+[ \t]+Task[ \t]+[0-9]+/;
|
|
1402
|
+
const targetRe = new RegExp(`^#+[ ]+Task[ ]+${taskN}([^0-9]|$)`);
|
|
1403
|
+
let infence = false;
|
|
1404
|
+
let intask = false;
|
|
1405
|
+
const printed = [];
|
|
1406
|
+
for (const line of records) {
|
|
1407
|
+
if (/^```/.test(line))
|
|
1408
|
+
infence = !infence;
|
|
1409
|
+
if (!infence && headingRe.test(line))
|
|
1410
|
+
intask = targetRe.test(line);
|
|
1411
|
+
if (intask)
|
|
1412
|
+
printed.push(line);
|
|
1413
|
+
}
|
|
1414
|
+
const output = printed.length > 0 ? `${printed.join(`
|
|
1415
|
+
`)}
|
|
1416
|
+
` : "";
|
|
1417
|
+
writeFileSync3(out, output);
|
|
1418
|
+
if (printed.length === 0) {
|
|
1419
|
+
throw new SddScriptError(`task ${taskN} not found in ${planFile} (no heading matching Task ${taskN})`, 3);
|
|
1420
|
+
}
|
|
1421
|
+
return out;
|
|
1422
|
+
}
|
|
1423
|
+
function reviewPackage(base, head, outFile, opts = {}) {
|
|
1424
|
+
if (!base || !head) {
|
|
1425
|
+
throw new SddScriptError("usage: mstar sdd review-package BASE HEAD [OUTFILE]", 2);
|
|
1426
|
+
}
|
|
1427
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1428
|
+
const verifyRef = (ref, what) => {
|
|
1429
|
+
try {
|
|
1430
|
+
execFileSync2("git", ["rev-parse", "--verify", "--quiet", ref], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
1431
|
+
} catch {
|
|
1432
|
+
throw new SddScriptError(`bad ${what}: ${ref}`, 2);
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
verifyRef(base, "BASE");
|
|
1436
|
+
verifyRef(head, "HEAD");
|
|
1437
|
+
let out;
|
|
1438
|
+
if (outFile) {
|
|
1439
|
+
out = outFile;
|
|
1440
|
+
} else {
|
|
1441
|
+
const sddDir = opts.sddDir ?? process.env.SDD_DIR;
|
|
1442
|
+
if (!sddDir) {
|
|
1443
|
+
throw new SddScriptError("mstar sdd review-package: set SDD_DIR or pass OUTFILE", 2);
|
|
1444
|
+
}
|
|
1445
|
+
mkdirSync4(sddDir, { recursive: true });
|
|
1446
|
+
const shortBase = gitOut(cwd, ["rev-parse", "--short", base]) ?? base;
|
|
1447
|
+
const shortHead = gitOut(cwd, ["rev-parse", "--short", head]) ?? head;
|
|
1448
|
+
out = join5(sddDir, `review-${shortBase}..${shortHead}.diff`);
|
|
1449
|
+
}
|
|
1450
|
+
const run = (args) => execFileSync2("git", args, { cwd, maxBuffer: GIT_CAPTURE_MAX_BYTES });
|
|
1451
|
+
const parts = [
|
|
1452
|
+
Buffer.from(`# Review package: ${base}..${head}
|
|
1453
|
+
|
|
1454
|
+
## Commits
|
|
1455
|
+
`),
|
|
1456
|
+
run(["log", "--oneline", `${base}..${head}`]),
|
|
1457
|
+
Buffer.from(`
|
|
1458
|
+
## Files changed
|
|
1459
|
+
`),
|
|
1460
|
+
run(["diff", "--stat", `${base}..${head}`]),
|
|
1461
|
+
Buffer.from(`
|
|
1462
|
+
## Diff
|
|
1463
|
+
`),
|
|
1464
|
+
run(["diff", "-U10", `${base}..${head}`])
|
|
1465
|
+
];
|
|
1466
|
+
writeFileSync3(out, Buffer.concat(parts));
|
|
1467
|
+
return out;
|
|
1468
|
+
}
|
|
1469
|
+
function assertBaseSha(ref, opts = {}) {
|
|
1470
|
+
if (typeof ref !== "string" || !/^[0-9a-f]{4,40}$/i.test(ref)) {
|
|
1471
|
+
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);
|
|
1472
|
+
}
|
|
1473
|
+
try {
|
|
1474
|
+
execFileSync2("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
|
|
1475
|
+
cwd: opts.cwd,
|
|
1476
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1477
|
+
});
|
|
1478
|
+
} catch {
|
|
1479
|
+
throw new SddScriptError(`assertBaseSha: commit not found: ${ref}`, 2);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
function taskReportExists(sddDir, taskN) {
|
|
1483
|
+
try {
|
|
1484
|
+
const st = statSync3(join5(sddDir, `task-${taskN}-report.md`));
|
|
1485
|
+
return st.isFile() && st.size > 0;
|
|
1486
|
+
} catch {
|
|
1487
|
+
return false;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
function readProgressLedger(sddDir) {
|
|
1491
|
+
let content;
|
|
1492
|
+
try {
|
|
1493
|
+
content = readFileSync4(join5(sddDir, "progress.md"), "utf8");
|
|
1494
|
+
} catch {
|
|
1495
|
+
return [];
|
|
1496
|
+
}
|
|
1497
|
+
return content.split(`
|
|
1498
|
+
`).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
1499
|
+
}
|
|
1500
|
+
function implementerSessionStickyRules(input) {
|
|
1501
|
+
const { session, nextTask, microBatchTasks = 1 } = input;
|
|
1502
|
+
if (session.session_mode !== "sticky") {
|
|
1503
|
+
return { resume: false, reason: `session_mode is '${session.session_mode}'; sticky resume requires 'sticky'` };
|
|
1504
|
+
}
|
|
1505
|
+
if (typeof session.host_agent_id !== "string" || session.host_agent_id.length === 0) {
|
|
1506
|
+
return {
|
|
1507
|
+
resume: false,
|
|
1508
|
+
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)"
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
if (nextTask <= session.last_task) {
|
|
1512
|
+
return {
|
|
1513
|
+
resume: false,
|
|
1514
|
+
reason: `nextTask ${nextTask} <= last_task ${session.last_task}; task already completed in this session`
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
if (microBatchTasks < 1 || microBatchTasks > 3) {
|
|
1518
|
+
return {
|
|
1519
|
+
resume: false,
|
|
1520
|
+
reason: `micro-batch of ${microBatchTasks} tasks is outside 1..3 (max 3 without user override, ` + "sticky-implementer-session.md § Micro-batch fallback)"
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
return { resume: true, reason: `sticky resume OK: host_agent_id ${session.host_agent_id}, next task ${nextTask}` };
|
|
1524
|
+
}
|
|
1525
|
+
// src/iteration.ts
|
|
1526
|
+
import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
1527
|
+
import { join as join6 } from "node:path";
|
|
1528
|
+
var COMPASS_STATUSES = ["active", "locked", "completed"];
|
|
1529
|
+
var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
|
|
1530
|
+
var PLAN_STATUS_DONE = "Done";
|
|
1531
|
+
var COMPASS_FILE = "delivery-compass.md";
|
|
1532
|
+
var INDEX_README = "README.md";
|
|
1533
|
+
var INDEX_HEADER = "| Iteration | Path | Description | Status |";
|
|
1534
|
+
function typeName(value) {
|
|
1535
|
+
if (value === null)
|
|
1536
|
+
return "null";
|
|
1537
|
+
if (Array.isArray(value))
|
|
1538
|
+
return "array";
|
|
1539
|
+
return typeof value;
|
|
1540
|
+
}
|
|
1541
|
+
function validateCompassShape(doc) {
|
|
1542
|
+
const issues = [];
|
|
1543
|
+
const expectString = (key, opts = {}) => {
|
|
1544
|
+
const value = doc[key];
|
|
1545
|
+
if (typeof value !== "string") {
|
|
1546
|
+
issues.push({ path: [key], message: `expected string, received ${typeName(value)}` });
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
if (opts.min !== undefined && value.length < opts.min) {
|
|
1550
|
+
issues.push({ path: [key], message: `string must contain at least ${opts.min} character(s)` });
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
if (opts.regex !== undefined && !opts.regex.test(value)) {
|
|
1554
|
+
issues.push({ path: [key], message: `string must match ${opts.regex}` });
|
|
1555
|
+
}
|
|
1556
|
+
};
|
|
1557
|
+
expectString("iteration_id", { min: 1 });
|
|
1558
|
+
expectString("start_date", { regex: DATE_RE2 });
|
|
1559
|
+
const status = doc.status;
|
|
1560
|
+
if (typeof status !== "string" || !COMPASS_STATUSES.includes(status)) {
|
|
1561
|
+
issues.push({
|
|
1562
|
+
path: ["status"],
|
|
1563
|
+
message: `expected one of ${COMPASS_STATUSES.map((s) => `'${s}'`).join(" | ")}, received ${typeName(status)}`
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
expectString("iteration_base_branch", { min: 1 });
|
|
1567
|
+
expectString("target_branch", { min: 1 });
|
|
1568
|
+
const plans = doc.plans;
|
|
1569
|
+
if (plans !== undefined) {
|
|
1570
|
+
if (!Array.isArray(plans)) {
|
|
1571
|
+
issues.push({ path: ["plans"], message: `expected array, received ${typeName(plans)}` });
|
|
1572
|
+
} else {
|
|
1573
|
+
plans.forEach((entry, index) => {
|
|
1574
|
+
if (typeof entry !== "string") {
|
|
1575
|
+
issues.push({ path: ["plans", index], message: `expected string, received ${typeName(entry)}` });
|
|
1576
|
+
} else if (entry.length < 1) {
|
|
1577
|
+
issues.push({ path: ["plans", index], message: "string must contain at least 1 character(s)" });
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
const end_date = doc.end_date;
|
|
1583
|
+
if (end_date !== undefined) {
|
|
1584
|
+
if (typeof end_date !== "string") {
|
|
1585
|
+
issues.push({ path: ["end_date"], message: `expected string, received ${typeName(end_date)}` });
|
|
1586
|
+
} else if (!DATE_RE2.test(end_date)) {
|
|
1587
|
+
issues.push({ path: ["end_date"], message: `string must match ${DATE_RE2}` });
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
if (issues.length > 0)
|
|
1591
|
+
return { ok: false, issues };
|
|
1592
|
+
return {
|
|
1593
|
+
ok: true,
|
|
1594
|
+
data: {
|
|
1595
|
+
iteration_id: doc.iteration_id,
|
|
1596
|
+
start_date: doc.start_date,
|
|
1597
|
+
status,
|
|
1598
|
+
iteration_base_branch: doc.iteration_base_branch,
|
|
1599
|
+
target_branch: doc.target_branch,
|
|
1600
|
+
...plans !== undefined ? { plans } : {},
|
|
1601
|
+
...end_date !== undefined ? { end_date } : {}
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
function violation5(severity, code, message, fix) {
|
|
1606
|
+
return { ok: false, severity, code, message, fix };
|
|
1607
|
+
}
|
|
1608
|
+
function isPlainObject3(value) {
|
|
1609
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1610
|
+
}
|
|
1611
|
+
function validateCompassFrontmatter(doc) {
|
|
1612
|
+
if (!isPlainObject3(doc)) {
|
|
1613
|
+
return {
|
|
1614
|
+
ok: false,
|
|
1615
|
+
violations: [
|
|
1616
|
+
violation5("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")
|
|
1617
|
+
]
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
const parsed = validateCompassShape(doc);
|
|
1621
|
+
if (!parsed.ok) {
|
|
1622
|
+
return {
|
|
1623
|
+
ok: false,
|
|
1624
|
+
violations: parsed.issues.map((issue) => {
|
|
1625
|
+
const field = issue.path.join(".") || "(root)";
|
|
1626
|
+
return violation5("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)`);
|
|
1627
|
+
})
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
const violations = [];
|
|
1631
|
+
const { status, end_date } = parsed.data;
|
|
1632
|
+
if (status === "completed" && end_date === undefined) {
|
|
1633
|
+
violations.push(violation5("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"));
|
|
1634
|
+
}
|
|
1635
|
+
if (status !== "completed" && end_date !== undefined) {
|
|
1636
|
+
violations.push(violation5("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"));
|
|
1637
|
+
}
|
|
1638
|
+
return { ok: violations.length === 0, violations };
|
|
1639
|
+
}
|
|
1640
|
+
function registeredPlanIds(compassDoc) {
|
|
1641
|
+
if (!Array.isArray(compassDoc.plans))
|
|
1642
|
+
return [];
|
|
1643
|
+
return compassDoc.plans.filter((plan) => typeof plan === "string" && plan.length > 0);
|
|
1644
|
+
}
|
|
1645
|
+
function findPlanRow(statusDoc, planId) {
|
|
1646
|
+
if (!Array.isArray(statusDoc.plans))
|
|
1647
|
+
return null;
|
|
1648
|
+
for (const row of statusDoc.plans) {
|
|
1649
|
+
if (!isPlainObject3(row))
|
|
1650
|
+
continue;
|
|
1651
|
+
const rowId = typeof row.id === "string" ? row.id : typeof row.plan_id === "string" ? row.plan_id : null;
|
|
1652
|
+
if (rowId === planId)
|
|
1653
|
+
return row;
|
|
1654
|
+
}
|
|
1655
|
+
return null;
|
|
1656
|
+
}
|
|
1657
|
+
function entryPlansAllDone(statusDoc, registered) {
|
|
1658
|
+
const violations = [];
|
|
1659
|
+
if (registered.length === 0) {
|
|
1660
|
+
violations.push(violation5("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`"));
|
|
1661
|
+
return violations;
|
|
1662
|
+
}
|
|
1663
|
+
for (const planId of registered) {
|
|
1664
|
+
const row = findPlanRow(statusDoc, planId);
|
|
1665
|
+
if (row === null) {
|
|
1666
|
+
violations.push(violation5("high", "PLAN_NOT_IN_STATUS", `Plan '${planId}' is registered in the compass frontmatter but has no row in status.json plans[] (mstar-iteration §3.1 entry item 1)`, "Add the plan row to {HARNESS_DIR}/status.json"));
|
|
1667
|
+
continue;
|
|
1668
|
+
}
|
|
1669
|
+
if (row.status !== PLAN_STATUS_DONE) {
|
|
1670
|
+
violations.push(violation5("high", "PLAN_NOT_DONE", `Plan '${planId}' status is ${JSON.stringify(row.status)} in status.json — all compass-registered plans must be 'Done' before iteration-close (mstar-iteration §3.1 entry item 1)`));
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
return violations;
|
|
1674
|
+
}
|
|
1675
|
+
function entryResidualsOpen(statusDoc, planId) {
|
|
1676
|
+
const violations = [];
|
|
1677
|
+
const residualRoot = statusDoc.residual_findings;
|
|
1678
|
+
if (residualRoot === undefined || residualRoot === null)
|
|
1679
|
+
return violations;
|
|
1680
|
+
if (!isPlainObject3(residualRoot)) {
|
|
1681
|
+
violations.push(violation5("medium", "RESIDUAL_MALFORMED", "status.json residual_findings must be a plan-id → entries object (mstar-iteration §3.1 entry item 2)"));
|
|
1682
|
+
return violations;
|
|
1683
|
+
}
|
|
1684
|
+
const entries = residualRoot[planId];
|
|
1685
|
+
if (entries === undefined)
|
|
1686
|
+
return violations;
|
|
1687
|
+
if (!Array.isArray(entries)) {
|
|
1688
|
+
violations.push(violation5("medium", "RESIDUAL_MALFORMED", `status.json residual_findings['${planId}'] must be an array of residual entries (mstar-iteration §3.1 entry item 2)`));
|
|
1689
|
+
return violations;
|
|
1690
|
+
}
|
|
1691
|
+
const openIds = [];
|
|
1692
|
+
for (const entry of entries) {
|
|
1693
|
+
if (!isPlainObject3(entry) || !isOpenResidual(entry))
|
|
1694
|
+
continue;
|
|
1695
|
+
const isBlockerDefer = entry.decision === "defer" && typeof entry.target === "string" && entry.target.trim() !== "";
|
|
1696
|
+
if (isBlockerDefer)
|
|
1697
|
+
continue;
|
|
1698
|
+
openIds.push(typeof entry.id === "string" ? entry.id : "<unnamed>");
|
|
1699
|
+
}
|
|
1700
|
+
if (openIds.length > 0) {
|
|
1701
|
+
violations.push(violation5("high", "OPEN_RESIDUALS", `Plan '${planId}' has ${openIds.length} open residual finding(s) not exempted as blocker-defers (${openIds.join(", ")}) — residuals must be closed/archived before iteration-close; only zero-residual blocker-defers (decision: defer + target) may stay open (mstar-iteration §3.1 entry item 2)`, "Close or archive the open residuals, or convert them into blocker-defers (decision: defer + non-empty target) per mstar-plan-artifacts Findings cleanup modes"));
|
|
1702
|
+
}
|
|
1703
|
+
return violations;
|
|
1704
|
+
}
|
|
1705
|
+
function entryFrontmatterComplete(compassDoc) {
|
|
1706
|
+
return validateCompassFrontmatter(compassDoc).violations;
|
|
1707
|
+
}
|
|
1708
|
+
function exitFrontmatterClosed(compassDoc) {
|
|
1709
|
+
const violations = [];
|
|
1710
|
+
if (compassDoc.status !== "completed") {
|
|
1711
|
+
violations.push(violation5("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)`));
|
|
1712
|
+
}
|
|
1713
|
+
const endDate = compassDoc.end_date;
|
|
1714
|
+
if (typeof endDate !== "string" || !DATE_RE2.test(endDate)) {
|
|
1715
|
+
violations.push(violation5("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)"));
|
|
1716
|
+
}
|
|
1717
|
+
return violations;
|
|
1718
|
+
}
|
|
1719
|
+
function exitBranchCheck(opts) {
|
|
1720
|
+
const violations = [];
|
|
1721
|
+
const { currentBranch, specIntegrationBranch } = opts;
|
|
1722
|
+
if (currentBranch === undefined || specIntegrationBranch === undefined) {
|
|
1723
|
+
violations.push(violation5("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)"));
|
|
1724
|
+
} else if (currentBranch !== specIntegrationBranch) {
|
|
1725
|
+
violations.push(violation5("high", "EXIT_BRANCH_MISMATCH", `Current branch '${currentBranch}' is not the spec_integration_branch '${specIntegrationBranch}' (mstar-iteration §3.5 exit item 5)`));
|
|
1726
|
+
}
|
|
1727
|
+
return violations;
|
|
1728
|
+
}
|
|
1729
|
+
function exitPrBaseCheck(compassDoc, opts) {
|
|
1730
|
+
const violations = [];
|
|
1731
|
+
const target = compassDoc.target_branch;
|
|
1732
|
+
const { prBaseBranch } = opts;
|
|
1733
|
+
if (prBaseBranch === undefined) {
|
|
1734
|
+
violations.push(violation5("medium", "EXIT_PR_BASE_UNVERIFIABLE", "Cannot verify the PR base — missing prBaseBranch probe input (mstar-iteration §3.5 exit item 6)"));
|
|
1735
|
+
} else if (typeof target !== "string" || prBaseBranch !== target) {
|
|
1736
|
+
violations.push(violation5("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)`));
|
|
1737
|
+
}
|
|
1738
|
+
return violations;
|
|
1739
|
+
}
|
|
1740
|
+
function evaluatePhaseGate(statusDoc, compassDoc, opts = {}) {
|
|
1741
|
+
const registered = registeredPlanIds(compassDoc);
|
|
1742
|
+
const entryViolations = [
|
|
1743
|
+
...entryPlansAllDone(statusDoc, registered),
|
|
1744
|
+
...registered.flatMap((planId) => entryResidualsOpen(statusDoc, planId)),
|
|
1745
|
+
...entryFrontmatterComplete(compassDoc)
|
|
1746
|
+
];
|
|
1747
|
+
const exitViolations = [
|
|
1748
|
+
...exitFrontmatterClosed(compassDoc),
|
|
1749
|
+
...exitBranchCheck(opts),
|
|
1750
|
+
...exitPrBaseCheck(compassDoc, opts)
|
|
1751
|
+
];
|
|
1752
|
+
const allPlansDone = registered.length > 0 && registered.every((planId) => {
|
|
1753
|
+
const row = findPlanRow(statusDoc, planId);
|
|
1754
|
+
return row !== null && row.status === PLAN_STATUS_DONE;
|
|
1755
|
+
});
|
|
1756
|
+
const entry = { ok: entryViolations.length === 0, violations: entryViolations };
|
|
1757
|
+
const exit = { ok: exitViolations.length === 0, violations: exitViolations };
|
|
1758
|
+
let transition;
|
|
1759
|
+
if (!allPlansDone)
|
|
1760
|
+
transition = "phase-2-execute";
|
|
1761
|
+
else if (entry.ok && exit.ok)
|
|
1762
|
+
transition = "phase-4-pr-delivery";
|
|
1763
|
+
else
|
|
1764
|
+
transition = "phase-3-close";
|
|
1765
|
+
const gateBlocking = allPlansDone ? [...entryViolations, ...exitViolations] : [];
|
|
1766
|
+
return {
|
|
1767
|
+
transition,
|
|
1768
|
+
allPlansDone,
|
|
1769
|
+
entry,
|
|
1770
|
+
exit,
|
|
1771
|
+
ok: gateBlocking.length === 0,
|
|
1772
|
+
violations: gateBlocking
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
function pushCadenceProbe(ciRunning, reviewWaveActive) {
|
|
1776
|
+
const violations = [];
|
|
1777
|
+
if (ciRunning) {
|
|
1778
|
+
violations.push(violation5("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"));
|
|
1779
|
+
}
|
|
1780
|
+
if (reviewWaveActive) {
|
|
1781
|
+
violations.push(violation5("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"));
|
|
1782
|
+
}
|
|
1783
|
+
return { ok: violations.length === 0, violations };
|
|
1784
|
+
}
|
|
1785
|
+
function assertIndexRowObligations(iterationsDir) {
|
|
1786
|
+
if (!existsSync4(iterationsDir)) {
|
|
1787
|
+
return {
|
|
1788
|
+
ok: false,
|
|
1789
|
+
violations: [
|
|
1790
|
+
violation5("high", "INDEX_ITERATIONS_DIR_MISSING", `{ITERATION_DIR} '${iterationsDir}' does not exist (mstar-iteration §1.4)`, "Create the iterations directory (path.resolveIterationDir)")
|
|
1791
|
+
]
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
const iterationIds = readdirSync3(iterationsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).filter((entry) => existsSync4(join6(iterationsDir, entry.name, COMPASS_FILE))).map((entry) => entry.name).sort();
|
|
1795
|
+
const readmePath = join6(iterationsDir, INDEX_README);
|
|
1796
|
+
if (!existsSync4(readmePath)) {
|
|
1797
|
+
return {
|
|
1798
|
+
ok: false,
|
|
1799
|
+
violations: [
|
|
1800
|
+
violation5("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`)
|
|
1801
|
+
]
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
const violations = [];
|
|
1805
|
+
const lines = readFileSync5(readmePath, "utf8").split(/\r?\n/);
|
|
1806
|
+
if (!lines.some((line) => line.includes(INDEX_HEADER))) {
|
|
1807
|
+
violations.push(violation5("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"));
|
|
1808
|
+
}
|
|
1809
|
+
const indexed = new Set;
|
|
1810
|
+
for (const line of lines) {
|
|
1811
|
+
const match = line.match(/^\s*\|\s*`([^`]+)`\s*\|/);
|
|
1812
|
+
if (match)
|
|
1813
|
+
indexed.add(match[1].trim());
|
|
1814
|
+
}
|
|
1815
|
+
for (const id of iterationIds) {
|
|
1816
|
+
if (!indexed.has(id)) {
|
|
1817
|
+
violations.push(violation5("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> |`));
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
return { ok: violations.length === 0, violations };
|
|
1821
|
+
}
|
|
1822
|
+
// src/design-md.ts
|
|
1823
|
+
function violation6(severity, code, message, fix) {
|
|
1824
|
+
return { ok: false, severity, code, message, fix };
|
|
1825
|
+
}
|
|
1826
|
+
var RAW_GROUP = "__raw";
|
|
1827
|
+
var isMap = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1828
|
+
function parseScalar(raw) {
|
|
1829
|
+
const trimmed = raw.trim();
|
|
1830
|
+
const quoted = /^"([^"]*)"$/.exec(trimmed) ?? /^'([^']*)'$/.exec(trimmed);
|
|
1831
|
+
if (quoted)
|
|
1832
|
+
return quoted[1];
|
|
1833
|
+
const cut = trimmed.split(/\s+#/)[0].trim();
|
|
1834
|
+
if (/^-?\d+(?:\.\d+)?$/.test(cut))
|
|
1835
|
+
return Number(cut);
|
|
1836
|
+
if (/^(?:true|false)$/.test(cut))
|
|
1837
|
+
return cut === "true";
|
|
1838
|
+
return cut;
|
|
1839
|
+
}
|
|
1840
|
+
function parseMapBlock(lines, start, indent) {
|
|
1841
|
+
const map = {};
|
|
1842
|
+
let i = start;
|
|
1843
|
+
while (i < lines.length && lines[i].indent === indent) {
|
|
1844
|
+
const match = /^([^:]+):(.*)$/.exec(lines[i].text);
|
|
1845
|
+
if (match === null) {
|
|
1846
|
+
i++;
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
const key = parseScalar(match[1].trim()).toString();
|
|
1850
|
+
const rest = match[2].trim();
|
|
1851
|
+
if (rest === "") {
|
|
1852
|
+
const nested = i + 1 < lines.length && lines[i + 1].indent > indent;
|
|
1853
|
+
if (nested) {
|
|
1854
|
+
const child = parseBlock(lines, i + 1, lines[i + 1].indent);
|
|
1855
|
+
map[key] = child.value;
|
|
1856
|
+
i = child.next;
|
|
1857
|
+
} else {
|
|
1858
|
+
map[key] = "";
|
|
1859
|
+
i++;
|
|
1860
|
+
}
|
|
1861
|
+
} else {
|
|
1862
|
+
map[key] = parseScalar(rest);
|
|
1863
|
+
i++;
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
return { value: map, next: i };
|
|
1867
|
+
}
|
|
1868
|
+
function parseListBlock(lines, start, indent) {
|
|
1869
|
+
const list = [];
|
|
1870
|
+
let i = start;
|
|
1871
|
+
while (i < lines.length && lines[i].indent === indent && lines[i].text.startsWith("-")) {
|
|
1872
|
+
const rest = lines[i].text.slice(1).trim();
|
|
1873
|
+
const match = /^([^:]+):(.*)$/.exec(rest);
|
|
1874
|
+
if (match !== null && match[2].trim() === "" && i + 1 < lines.length && lines[i + 1].indent > indent) {
|
|
1875
|
+
const child = parseBlock(lines, i + 1, lines[i + 1].indent);
|
|
1876
|
+
list.push({ [parseScalar(match[1].trim()).toString()]: child.value });
|
|
1877
|
+
i = child.next;
|
|
1878
|
+
} else if (match !== null) {
|
|
1879
|
+
list.push({ [parseScalar(match[1].trim()).toString()]: parseScalar(match[2].trim()) });
|
|
1880
|
+
i++;
|
|
1881
|
+
} else {
|
|
1882
|
+
list.push(parseScalar(rest));
|
|
1883
|
+
i++;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
return { value: list, next: i };
|
|
1887
|
+
}
|
|
1888
|
+
function parseBlock(lines, start, indent) {
|
|
1889
|
+
if (lines[start] !== undefined && lines[start].text.startsWith("-"))
|
|
1890
|
+
return parseListBlock(lines, start, indent);
|
|
1891
|
+
return parseMapBlock(lines, start, indent);
|
|
1892
|
+
}
|
|
1893
|
+
function parseDesignFrontmatter(frontmatterText) {
|
|
1894
|
+
const body = frontmatterText.replace(/^\uFEFF/, "");
|
|
1895
|
+
const lines = body.split(/\r?\n/);
|
|
1896
|
+
if (lines.length === 0 || !lines[0].trim().startsWith("---"))
|
|
1897
|
+
return null;
|
|
1898
|
+
const inner = [];
|
|
1899
|
+
for (let i = 1;i < lines.length; i++) {
|
|
1900
|
+
const trimmed = lines[i].trim();
|
|
1901
|
+
if (trimmed === "---")
|
|
1902
|
+
break;
|
|
1903
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
1904
|
+
continue;
|
|
1905
|
+
const indent = lines[i].match(/^ */)[0].length;
|
|
1906
|
+
inner.push({ indent, text: lines[i].slice(indent) });
|
|
1907
|
+
}
|
|
1908
|
+
if (inner.length === 0)
|
|
1909
|
+
return null;
|
|
1910
|
+
const top = parseBlock(inner, 0, inner[0].indent);
|
|
1911
|
+
if (!isMap(top.value))
|
|
1912
|
+
return null;
|
|
1913
|
+
const fm = { colors: {}, typography: {}, spacing: {}, rounded: {}, components: {} };
|
|
1914
|
+
for (const [key, value] of Object.entries(top.value)) {
|
|
1915
|
+
if (key === "version" || key === "name" || key === "description") {
|
|
1916
|
+
if (typeof value === "string")
|
|
1917
|
+
fm[key] = value;
|
|
1918
|
+
} else if (key === "colors" || key === "typography" || key === "spacing" || key === "rounded" || key === "components") {
|
|
1919
|
+
fm[key] = isMap(value) ? value : { [RAW_GROUP]: value };
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
return fm;
|
|
1923
|
+
}
|
|
1924
|
+
var HEX_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
1925
|
+
var OKLCH_RE = /^oklch\([^)]*\)$/i;
|
|
1926
|
+
var PX_RE = /^-?\d+(?:\.\d+)?px$/;
|
|
1927
|
+
var PLACEHOLDER_RE = /^\[.*\]$/;
|
|
1928
|
+
var TYPOGRAPHY_PROPS = ["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing"];
|
|
1929
|
+
var REF_RE = /^\{([a-z]+)\.([^}]+)\}$/;
|
|
1930
|
+
var REF_GROUPS = ["colors", "typography", "rounded"];
|
|
1931
|
+
var isPlaceholder = (value) => PLACEHOLDER_RE.test(value);
|
|
1932
|
+
function validateDesignTokenFrontmatter(frontmatterText) {
|
|
1933
|
+
const violations = [];
|
|
1934
|
+
const fm = parseDesignFrontmatter(frontmatterText);
|
|
1935
|
+
if (fm === null) {
|
|
1936
|
+
violations.push(violation6("medium", "design-md.tokens.missing-frontmatter", "no `---` YAML frontmatter block found — DESIGN.md must open with a fenced frontmatter holding the token SSOT (design-md-spec §1.5)", "add a `---` fenced frontmatter with version, name, description, and the colors/typography/spacing/rounded groups"));
|
|
1937
|
+
return { ok: false, violations };
|
|
1938
|
+
}
|
|
1939
|
+
const groupEntries = (group) => {
|
|
1940
|
+
const value = fm[group];
|
|
1941
|
+
if (!isMap(value) || RAW_GROUP in value)
|
|
1942
|
+
return [];
|
|
1943
|
+
return Object.entries(value);
|
|
1944
|
+
};
|
|
1945
|
+
const groupIsMap = (group) => {
|
|
1946
|
+
const value = fm[group];
|
|
1947
|
+
return isMap(value) && !(RAW_GROUP in value);
|
|
1948
|
+
};
|
|
1949
|
+
for (const group of ["colors", "typography", "spacing", "rounded"]) {
|
|
1950
|
+
if (!isMap(fm[group])) {
|
|
1951
|
+
violations.push(violation6("medium", "design-md.tokens.group-not-map", `token group "${group}" must be a YAML map, not a scalar (design-md-spec §1.5)`, `rewrite \`${group}\` as a nested map`));
|
|
1952
|
+
} else if (!groupIsMap(group)) {
|
|
1953
|
+
violations.push(violation6("medium", "design-md.tokens.group-not-map", `token group "${group}" must be a YAML map, not a scalar (design-md-spec §1.5)`, `rewrite \`${group}\` as a nested map`));
|
|
1954
|
+
} else if (groupEntries(group).length === 0) {
|
|
1955
|
+
violations.push(violation6("medium", "design-md.tokens.missing-group", `missing required token group "${group}" — colors/typography/spacing/rounded are required by the frontmatter SSOT (design-md-spec §1.5)`, `add an active \`${group}:\` block with concrete token values`));
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
if (!isMap(fm.components) || !groupIsMap("components")) {
|
|
1959
|
+
violations.push(violation6("medium", "design-md.tokens.group-not-map", `token group "components" must be a YAML map, not a scalar (design-md-spec §1.5)`, `rewrite \`components\` as a nested map`));
|
|
1960
|
+
}
|
|
1961
|
+
const placeholder = (group, name, value) => violations.push(violation6("low", "design-md.tokens.placeholder", `token "${group}.${name}" uses a "[...]" template value — placeholders never count as concrete tokens (completeness-checklist § How to use item 5)`, `replace \`${value}\` with a concrete value`));
|
|
1962
|
+
for (const [name, value] of groupEntries("colors")) {
|
|
1963
|
+
if (typeof value !== "string") {
|
|
1964
|
+
violations.push(violation6("medium", "design-md.tokens.color-format", `color "${name}" must be a string value (design-md-spec §2.2)`, "quote the color value"));
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
if (isPlaceholder(value)) {
|
|
1968
|
+
placeholder("colors", name, value);
|
|
1969
|
+
} else if (!HEX_RE.test(value) && !OKLCH_RE.test(value)) {
|
|
1970
|
+
violations.push(violation6("medium", "design-md.tokens.color-format", `color "${name}" = "${value}" is not a hex (\`#rrggbb\`/\`#rrggbbaa\`) or oklch() value (design-md-spec §2.2)`, "use an sRGB hex value, optionally with a `-p3` oklch() twin"));
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
for (const [name, value] of groupEntries("typography")) {
|
|
1974
|
+
if (!isMap(value)) {
|
|
1975
|
+
violations.push(violation6("medium", "design-md.tokens.typography-shape", `typography token "${name}" must be a map of the five properties (design-md-spec §1.5)`, "give it fontFamily/fontSize/fontWeight/lineHeight/letterSpacing"));
|
|
1976
|
+
continue;
|
|
1977
|
+
}
|
|
1978
|
+
const keys = Object.keys(value);
|
|
1979
|
+
const missing = TYPOGRAPHY_PROPS.filter((p) => !keys.includes(p));
|
|
1980
|
+
const extra = keys.filter((k) => !TYPOGRAPHY_PROPS.includes(k));
|
|
1981
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
1982
|
+
violations.push(violation6("medium", "design-md.tokens.typography-shape", `typography token "${name}" must have exactly the five properties fontFamily/fontSize/fontWeight/lineHeight/letterSpacing (design-md-spec §1.5)${missing.length > 0 ? ` — missing: ${missing.join(", ")}` : ""}${extra.length > 0 ? ` — extra: ${extra.join(", ")}` : ""}`, "align the token with the five-property shape"));
|
|
1983
|
+
}
|
|
1984
|
+
for (const prop of ["fontFamily", "fontSize"]) {
|
|
1985
|
+
const v = value[prop];
|
|
1986
|
+
if (typeof v === "string" && isPlaceholder(v))
|
|
1987
|
+
placeholder("typography", name, v);
|
|
1988
|
+
else if (typeof v !== "string" || v.trim() === "") {
|
|
1989
|
+
violations.push(violation6("medium", "design-md.tokens.typography-shape", `typography token "${name}" has an empty \`${prop}\` (design-md-spec §1.5)`, `fill \`${prop}\` with a concrete value`));
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
if (groupIsMap("spacing")) {
|
|
1994
|
+
const spacing = fm.spacing;
|
|
1995
|
+
if (!Object.prototype.hasOwnProperty.call(spacing, "base")) {
|
|
1996
|
+
violations.push(violation6("medium", "design-md.tokens.spacing-base", "spacing must declare the base unit as `base` (design-md-spec §2.4)", "add `base: 4px` (or 8px) to the spacing group"));
|
|
1997
|
+
}
|
|
1998
|
+
for (const [name, value] of Object.entries(spacing)) {
|
|
1999
|
+
if (name !== "base" && name !== RAW_GROUP && !/^\d+$/.test(name)) {
|
|
2000
|
+
violations.push(violation6("medium", "design-md.tokens.spacing-key", `spacing key "${name}" must be \`base\` or a numeric multiplier (design-md-spec §1.5)`, "use numeric scale-step keys or `base`"));
|
|
2001
|
+
}
|
|
2002
|
+
if (typeof value === "string" && isPlaceholder(value)) {
|
|
2003
|
+
placeholder("spacing", name, value);
|
|
2004
|
+
} else if (typeof value !== "string" || !PX_RE.test(value)) {
|
|
2005
|
+
violations.push(violation6("medium", "design-md.tokens.spacing-format", `spacing value "${name}" = "${String(value)}" is not a px length (design-md-spec §1.5)`, "use a pixel value like `4px`"));
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
for (const [name, value] of groupEntries("rounded")) {
|
|
2010
|
+
if (typeof value === "string" && isPlaceholder(value)) {
|
|
2011
|
+
placeholder("rounded", name, value);
|
|
2012
|
+
} else if (typeof value !== "string" || !PX_RE.test(value)) {
|
|
2013
|
+
violations.push(violation6("medium", "design-md.tokens.rounded-format", `rounded value "${name}" = "${String(value)}" is not a px length (design-md-spec §1.5)`, "use a pixel value like `6px`"));
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
for (const [name, value] of groupEntries("components")) {
|
|
2017
|
+
if (!isMap(value)) {
|
|
2018
|
+
violations.push(violation6("medium", "design-md.tokens.components-shape", `component token "${name}" must be a map of properties (design-md-spec §2.8)`, "give it backgroundColor/textColor/typography/rounded/padding/height"));
|
|
2019
|
+
continue;
|
|
2020
|
+
}
|
|
2021
|
+
for (const [prop, v] of Object.entries(value)) {
|
|
2022
|
+
if (typeof v !== "string")
|
|
2023
|
+
continue;
|
|
2024
|
+
if (isPlaceholder(v)) {
|
|
2025
|
+
placeholder("components", `${name}.${prop}`, v);
|
|
2026
|
+
continue;
|
|
2027
|
+
}
|
|
2028
|
+
const ref = REF_RE.exec(v);
|
|
2029
|
+
if (ref === null)
|
|
2030
|
+
continue;
|
|
2031
|
+
const [, refGroup, refKey] = ref;
|
|
2032
|
+
const resolves = REF_GROUPS.includes(refGroup) && groupIsMap(refGroup) && Object.prototype.hasOwnProperty.call(fm[refGroup], refKey);
|
|
2033
|
+
if (!resolves) {
|
|
2034
|
+
violations.push(violation6("medium", "design-md.tokens.ref-unresolved", `component "${name}" references "${v}" which does not resolve to an active token in this frontmatter (design-md-spec §6 — {path} refs MUST trace back to a key)`, `add the referenced token or use a literal value`));
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
return { ok: violations.length === 0, violations };
|
|
2039
|
+
}
|
|
2040
|
+
var PARITY_GROUPS = ["colors", "typography", "spacing", "rounded", "components"];
|
|
2041
|
+
function assertLightDarkParity(lightFm, darkFm) {
|
|
2042
|
+
const violations = [];
|
|
2043
|
+
const light = parseDesignFrontmatter(lightFm);
|
|
2044
|
+
const dark = parseDesignFrontmatter(darkFm);
|
|
2045
|
+
if (light === null || dark === null) {
|
|
2046
|
+
violations.push(violation6("medium", "design-md.parity.missing-frontmatter", `light/dark parity needs a YAML frontmatter in both files — ${light === null ? "DESIGN.md" : "DESIGN.dark.md"} has none (design-md-spec §4 rules 1–2)`, "add the fenced frontmatter to both theme files"));
|
|
2047
|
+
return { ok: false, violations };
|
|
2048
|
+
}
|
|
2049
|
+
const activeKeys = (fm) => {
|
|
2050
|
+
const keys = new Set;
|
|
2051
|
+
for (const group of PARITY_GROUPS) {
|
|
2052
|
+
const value = fm[group];
|
|
2053
|
+
if (!isMap(value) || RAW_GROUP in value)
|
|
2054
|
+
continue;
|
|
2055
|
+
for (const key of Object.keys(value))
|
|
2056
|
+
keys.add(`${group}.${key}`);
|
|
2057
|
+
}
|
|
2058
|
+
return keys;
|
|
2059
|
+
};
|
|
2060
|
+
const lightKeys = activeKeys(light);
|
|
2061
|
+
const darkKeys = activeKeys(dark);
|
|
2062
|
+
for (const key of lightKeys) {
|
|
2063
|
+
if (!darkKeys.has(key)) {
|
|
2064
|
+
violations.push(violation6("medium", "design-md.parity.missing-dark", `token "${key}" is active in DESIGN.md but missing from DESIGN.dark.md — both files must define the same token set (design-md-spec §4 rule 3)`, "add the token to DESIGN.dark.md with a dark-appropriate value"));
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
for (const key of darkKeys) {
|
|
2068
|
+
if (!lightKeys.has(key)) {
|
|
2069
|
+
violations.push(violation6("medium", "design-md.parity.missing-light", `token "${key}" is active in DESIGN.dark.md but missing from DESIGN.md — DESIGN.md is the SSOT for token names (design-md-spec §4 rules 3–4)`, "add the token to DESIGN.md, or remove it from the dark file"));
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
return { ok: violations.length === 0, violations };
|
|
2073
|
+
}
|
|
2074
|
+
var GRAY_STEPS = ["100", "200", "300", "400", "500", "600", "700", "800", "900", "1000"];
|
|
2075
|
+
var ALPHA_STEPS = ["100", "200", "300", "400", "500", "600"];
|
|
2076
|
+
var ACCENT_SCALES = ["blue", "red", "amber", "green", "teal", "purple", "pink"];
|
|
2077
|
+
var L3_BODY_ITEM_IDS = [
|
|
2078
|
+
"dark-exists",
|
|
2079
|
+
"dark-parity",
|
|
2080
|
+
"elevation-shadows",
|
|
2081
|
+
"motion-easing",
|
|
2082
|
+
"motion-durations",
|
|
2083
|
+
"motion-reduced",
|
|
2084
|
+
"voice-content"
|
|
2085
|
+
];
|
|
2086
|
+
function isConcrete(value) {
|
|
2087
|
+
if (typeof value === "number")
|
|
2088
|
+
return true;
|
|
2089
|
+
return typeof value === "string" && value !== "" && !isPlaceholder(value);
|
|
2090
|
+
}
|
|
2091
|
+
function groupHasConcrete(fm, group, key) {
|
|
2092
|
+
if (fm === null)
|
|
2093
|
+
return false;
|
|
2094
|
+
const value = fm[group];
|
|
2095
|
+
if (!isMap(value) || RAW_GROUP in value)
|
|
2096
|
+
return false;
|
|
2097
|
+
const entry = value[key];
|
|
2098
|
+
return entry !== undefined && isConcrete(entry);
|
|
2099
|
+
}
|
|
2100
|
+
function typographyTokenComplete(fm, key) {
|
|
2101
|
+
if (fm === null)
|
|
2102
|
+
return false;
|
|
2103
|
+
const group = fm.typography;
|
|
2104
|
+
if (!isMap(group) || RAW_GROUP in group)
|
|
2105
|
+
return false;
|
|
2106
|
+
const entry = group[key];
|
|
2107
|
+
if (!isMap(entry))
|
|
2108
|
+
return false;
|
|
2109
|
+
return TYPOGRAPHY_PROPS.every((p) => Object.prototype.hasOwnProperty.call(entry, p) && isConcrete(entry[p]));
|
|
2110
|
+
}
|
|
2111
|
+
function countRoleTokens(fm, role) {
|
|
2112
|
+
if (fm === null)
|
|
2113
|
+
return 0;
|
|
2114
|
+
const group = fm.typography;
|
|
2115
|
+
if (!isMap(group) || RAW_GROUP in group)
|
|
2116
|
+
return 0;
|
|
2117
|
+
return Object.keys(group).filter((k) => k.startsWith(`${role}-`) && typographyTokenComplete(fm, k)).length;
|
|
2118
|
+
}
|
|
2119
|
+
function countNumericSpacingSteps(fm) {
|
|
2120
|
+
if (fm === null)
|
|
2121
|
+
return 0;
|
|
2122
|
+
const group = fm.spacing;
|
|
2123
|
+
if (!isMap(group) || RAW_GROUP in group)
|
|
2124
|
+
return 0;
|
|
2125
|
+
return Object.keys(group).filter((k) => k !== "base" && k !== RAW_GROUP && /^\d+$/.test(k)).length;
|
|
2126
|
+
}
|
|
2127
|
+
function hasComponent(fm, name) {
|
|
2128
|
+
if (fm === null)
|
|
2129
|
+
return false;
|
|
2130
|
+
const group = fm.components;
|
|
2131
|
+
if (!isMap(group) || RAW_GROUP in group)
|
|
2132
|
+
return false;
|
|
2133
|
+
return isMap(group[name]);
|
|
2134
|
+
}
|
|
2135
|
+
var LEVEL_RANK = { BELOW_MVP: 0, MVP: 1, Standard: 2, Production: 3 };
|
|
2136
|
+
function completenessLevel(frontmatterText, checklist) {
|
|
2137
|
+
const fm = parseDesignFrontmatter(frontmatterText);
|
|
2138
|
+
const bodyUnverified = checklist === undefined;
|
|
2139
|
+
const bodyOk = (id) => (checklist ?? []).includes(id);
|
|
2140
|
+
const items = [];
|
|
2141
|
+
const add = (id, level2, source, ok) => {
|
|
2142
|
+
items.push({ id, level: level2, ok, source });
|
|
2143
|
+
};
|
|
2144
|
+
add("fm-exists", 1, "frontmatter", fm !== null);
|
|
2145
|
+
add("version", 1, "frontmatter", fm !== null && typeof fm.version === "string" && isConcrete(fm.version));
|
|
2146
|
+
add("name-description", 1, "frontmatter", fm !== null && typeof fm.name === "string" && isConcrete(fm.name) && typeof fm.description === "string" && isConcrete(fm.description));
|
|
2147
|
+
add("colors-background", 1, "frontmatter", groupHasConcrete(fm, "colors", "background-100"));
|
|
2148
|
+
add("colors-text", 1, "frontmatter", groupHasConcrete(fm, "colors", "gray-1000") && groupHasConcrete(fm, "colors", "gray-900"));
|
|
2149
|
+
add("colors-accent", 1, "frontmatter", ACCENT_SCALES.some((a) => groupHasConcrete(fm, "colors", `${a}-700`)));
|
|
2150
|
+
add("colors-semantic", 1, "frontmatter", groupHasConcrete(fm, "colors", "red-700") && groupHasConcrete(fm, "colors", "amber-700"));
|
|
2151
|
+
add("type-copy", 1, "frontmatter", countRoleTokens(fm, "copy") >= 1);
|
|
2152
|
+
add("type-heading", 1, "frontmatter", countRoleTokens(fm, "heading") >= 1);
|
|
2153
|
+
add("spacing-scale", 1, "frontmatter", groupHasConcrete(fm, "spacing", "base") && countNumericSpacingSteps(fm) >= 5);
|
|
2154
|
+
add("rounded-sm", 1, "frontmatter", groupHasConcrete(fm, "rounded", "sm"));
|
|
2155
|
+
add("breakpoints-2", 1, "body", bodyOk("breakpoints-2"));
|
|
2156
|
+
add("colors-background-scale", 2, "frontmatter", ["background-100", "background-200", "background-300"].every((k) => groupHasConcrete(fm, "colors", k)));
|
|
2157
|
+
add("colors-gray-scale", 2, "frontmatter", GRAY_STEPS.every((s) => groupHasConcrete(fm, "colors", `gray-${s}`)));
|
|
2158
|
+
add("colors-alpha-scale", 2, "frontmatter", ALPHA_STEPS.every((s) => groupHasConcrete(fm, "colors", `gray-alpha-${s}`)));
|
|
2159
|
+
add("colors-accent-scales", 2, "frontmatter", ACCENT_SCALES.every((a) => ["700", "800", "900", "1000"].every((s) => groupHasConcrete(fm, "colors", `${a}-${s}`))));
|
|
2160
|
+
add("type-headings-3", 2, "frontmatter", countRoleTokens(fm, "heading") >= 3);
|
|
2161
|
+
add("type-label", 2, "frontmatter", countRoleTokens(fm, "label") >= 1);
|
|
2162
|
+
add("type-button", 2, "frontmatter", countRoleTokens(fm, "button") >= 1);
|
|
2163
|
+
add("spacing-full", 2, "frontmatter", countNumericSpacingSteps(fm) >= 9);
|
|
2164
|
+
add("rounded-full", 2, "frontmatter", ["sm", "md", "lg", "full"].every((k) => groupHasConcrete(fm, "rounded", k)));
|
|
2165
|
+
add("components-button", 2, "frontmatter", hasComponent(fm, "button-primary") && hasComponent(fm, "button-secondary") && hasComponent(fm, "button-small"));
|
|
2166
|
+
add("components-input", 2, "frontmatter", hasComponent(fm, "input"));
|
|
2167
|
+
add("breakpoints-4", 2, "body", bodyOk("breakpoints-4"));
|
|
2168
|
+
add("components-button-states", 2, "body", bodyOk("components-button-states"));
|
|
2169
|
+
add("components-input-states", 2, "body", bodyOk("components-input-states"));
|
|
2170
|
+
add("spacing-rhythm", 2, "body", bodyOk("spacing-rhythm"));
|
|
2171
|
+
const componentNames = [];
|
|
2172
|
+
if (fm !== null && isMap(fm.components)) {
|
|
2173
|
+
for (const key of Object.keys(fm.components)) {
|
|
2174
|
+
if (key !== RAW_GROUP && isMap(fm.components[key]))
|
|
2175
|
+
componentNames.push(key);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
const namesJoined = componentNames.join(" ");
|
|
2179
|
+
add("components-library", 3, "frontmatter", componentNames.length >= 4 && /card/i.test(namesJoined) && /modal/i.test(namesJoined) && /tooltip/i.test(namesJoined) && /menu|dropdown/i.test(namesJoined));
|
|
2180
|
+
add("dark-exists", 3, "body", bodyOk("dark-exists"));
|
|
2181
|
+
add("dark-parity", 3, "body", bodyOk("dark-parity"));
|
|
2182
|
+
add("elevation-shadows", 3, "body", bodyOk("elevation-shadows"));
|
|
2183
|
+
add("motion-easing", 3, "body", bodyOk("motion-easing"));
|
|
2184
|
+
add("motion-durations", 3, "body", bodyOk("motion-durations"));
|
|
2185
|
+
add("motion-reduced", 3, "body", bodyOk("motion-reduced"));
|
|
2186
|
+
add("voice-content", 3, "body", bodyOk("voice-content"));
|
|
2187
|
+
const participating = items.filter((it) => it.source === "frontmatter" || !bodyUnverified);
|
|
2188
|
+
const failing = (level2) => participating.filter((it) => it.level === level2 && !it.ok).map((it) => it.id);
|
|
2189
|
+
const fail1 = failing(1);
|
|
2190
|
+
const fail2 = failing(2);
|
|
2191
|
+
const fail3 = failing(3);
|
|
2192
|
+
let level;
|
|
2193
|
+
let missing;
|
|
2194
|
+
if (fail1.length > 0) {
|
|
2195
|
+
level = "BELOW_MVP";
|
|
2196
|
+
missing = fail1;
|
|
2197
|
+
} else if (fail2.length > 0) {
|
|
2198
|
+
level = "MVP";
|
|
2199
|
+
missing = fail2;
|
|
2200
|
+
} else if (fail3.length > 0) {
|
|
2201
|
+
level = "Standard";
|
|
2202
|
+
missing = fail3;
|
|
2203
|
+
} else {
|
|
2204
|
+
level = "Production";
|
|
2205
|
+
missing = [];
|
|
2206
|
+
}
|
|
2207
|
+
if (level === "Production" && bodyUnverified) {
|
|
2208
|
+
level = "Standard";
|
|
2209
|
+
missing = [...L3_BODY_ITEM_IDS];
|
|
2210
|
+
}
|
|
2211
|
+
const placeholders = [];
|
|
2212
|
+
frontmatterText.split(/\r?\n/).forEach((line, index) => {
|
|
2213
|
+
const m = /\b(LEVEL([23])_PLACEHOLDER)\b/.exec(line);
|
|
2214
|
+
if (m !== null)
|
|
2215
|
+
placeholders.push({ level: Number(m[2]), marker: m[1], line: index + 1 });
|
|
2216
|
+
});
|
|
2217
|
+
const rank = LEVEL_RANK[level];
|
|
2218
|
+
const candidate = placeholders.map((p) => p.level).filter((l) => l > rank).sort((a, b) => b - a)[0];
|
|
2219
|
+
const upgradeTo = candidate === undefined ? null : candidate;
|
|
2220
|
+
return { level, items, missing, placeholders, upgradeTo, bodyUnverified };
|
|
2221
|
+
}
|
|
2222
|
+
// src/audit.ts
|
|
2223
|
+
import { mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2224
|
+
import { join as join7, resolve as resolve7 } from "node:path";
|
|
2225
|
+
function violation7(severity, code, message, fix) {
|
|
2226
|
+
return { ok: false, severity, code, message, fix };
|
|
2227
|
+
}
|
|
2228
|
+
var AUDIT_PRIORITIES = ["P1", "P2", "P3"];
|
|
2229
|
+
var AUDIT_EFFORTS = ["XS", "S", "M", "L", "XL"];
|
|
2230
|
+
var AUDIT_RISKS = ["LOW", "MED", "HIGH"];
|
|
2231
|
+
var AUDIT_CATEGORIES = [
|
|
2232
|
+
"bug",
|
|
2233
|
+
"security",
|
|
2234
|
+
"perf",
|
|
2235
|
+
"tests",
|
|
2236
|
+
"tech-debt",
|
|
2237
|
+
"migration",
|
|
2238
|
+
"dx",
|
|
2239
|
+
"docs",
|
|
2240
|
+
"direction"
|
|
2241
|
+
];
|
|
2242
|
+
var AUDIT_STATUS_FIELDS = ["Priority", "Effort", "Risk", "Depends on", "Category", "Planned at"];
|
|
2243
|
+
function parseStatusBlocks(planText) {
|
|
2244
|
+
const blocks = [];
|
|
2245
|
+
let current = null;
|
|
2246
|
+
for (const line of planText.split(/\r?\n/)) {
|
|
2247
|
+
const trimmed = line.trim();
|
|
2248
|
+
if (trimmed === "## Status") {
|
|
2249
|
+
current = new Map;
|
|
2250
|
+
blocks.push({ fields: current });
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
if (current === null)
|
|
2254
|
+
continue;
|
|
2255
|
+
if (trimmed.startsWith("#")) {
|
|
2256
|
+
current = null;
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
const match = /^-\s*\*\*([^*]+)\*\*:\s*(.*)$/.exec(trimmed);
|
|
2260
|
+
if (match !== null)
|
|
2261
|
+
current.set(match[1].trim(), match[2].trim());
|
|
2262
|
+
}
|
|
2263
|
+
return blocks;
|
|
2264
|
+
}
|
|
2265
|
+
function validateAuditStatusBlocks(planText) {
|
|
2266
|
+
const violations = [];
|
|
2267
|
+
const blocks = parseStatusBlocks(planText);
|
|
2268
|
+
if (blocks.length === 0) {
|
|
2269
|
+
violations.push(violation7("medium", "audit.status.missing-block", "no `## Status` block found — audit plan files carry the Status block fields (mstar-audit SKILL § Plan files)", "add a `## Status` block with Priority, Effort, Risk, Depends on, Category, Planned at"));
|
|
2270
|
+
return { ok: false, violations };
|
|
2271
|
+
}
|
|
2272
|
+
blocks.forEach((block, index) => {
|
|
2273
|
+
const label = blocks.length > 1 ? ` #${index + 1}` : "";
|
|
2274
|
+
for (const field of AUDIT_STATUS_FIELDS) {
|
|
2275
|
+
if (!block.fields.has(field)) {
|
|
2276
|
+
violations.push(violation7("medium", "audit.status.missing-field", `Status block${label} missing required field "${field}" (mstar-audit SKILL § Plan files)`, `add \`- **${field}**: <value>\` to the Status block`));
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
const check = (field, pattern, code, expected) => {
|
|
2280
|
+
const value = block.fields.get(field);
|
|
2281
|
+
if (value === undefined)
|
|
2282
|
+
return;
|
|
2283
|
+
if (!pattern.test(value)) {
|
|
2284
|
+
violations.push(violation7("medium", code, `Status block${label} "${field}" = "${value}" — expected ${expected} (mstar-audit SKILL § Plan files)`, `fix \`- **${field}**:\` to one of: ${expected}`));
|
|
2285
|
+
}
|
|
2286
|
+
};
|
|
2287
|
+
check("Priority", /^P[123]$/, "audit.status.invalid-priority", "P1 | P2 | P3");
|
|
2288
|
+
check("Effort", /^(?:XS|S|M|L|XL)$/, "audit.status.invalid-effort", "XS | S | M | L | XL");
|
|
2289
|
+
check("Risk", /^(?:LOW|MED|HIGH)$/, "audit.status.invalid-risk", "LOW | MED | HIGH");
|
|
2290
|
+
check("Category", /^(?:bug|security|perf|tests|tech-debt|migration|dx|docs|direction)$/, "audit.status.invalid-category", "bug | security | perf | tests | tech-debt | migration | dx | docs | direction");
|
|
2291
|
+
check("Depends on", /^(?:none|plans\/\d{3}-[\w.*-]+\.md)$/i, "audit.status.invalid-depends-on", "none or plans/NNN-*.md");
|
|
2292
|
+
check("Planned at", /^commit \`?(?:[0-9a-f]{7,40}|unknown)\`?, \d{4}-\d{2}-\d{2}$/, "audit.status.invalid-planned-at", "commit <short SHA>, <YYYY-MM-DD> (or `commit unknown` outside a git repo)");
|
|
2293
|
+
});
|
|
2294
|
+
return { ok: violations.length === 0, violations };
|
|
2295
|
+
}
|
|
2296
|
+
var WHOLE_MATCH_PATTERNS = [
|
|
2297
|
+
{ type: "private-key", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g },
|
|
2298
|
+
{ type: "aws-access-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
2299
|
+
{ type: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
|
|
2300
|
+
{ type: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
2301
|
+
{ type: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\b/g },
|
|
2302
|
+
{ type: "api-secret-key", re: /\bsk-[A-Za-z0-9-]{20,}\b/g }
|
|
2303
|
+
];
|
|
2304
|
+
var VALUE_PATTERNS = [
|
|
2305
|
+
{
|
|
2306
|
+
typeOf: (key) => key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[_-]+/g, "-"),
|
|
2307
|
+
re: /(["']?)\b(password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|secret|token)\b(["']?)(\s*[:=]\s*)("[^"\n]{8,}"|'[^'\n]{8,}'|[A-Za-z0-9_./+\-=]{16,})/gi
|
|
2308
|
+
}
|
|
2309
|
+
];
|
|
2310
|
+
function buildLineStarts(text) {
|
|
2311
|
+
const starts = [0];
|
|
2312
|
+
for (let i = 0;i < text.length; i++) {
|
|
2313
|
+
if (text[i] === `
|
|
2314
|
+
`)
|
|
2315
|
+
starts.push(i + 1);
|
|
2316
|
+
}
|
|
2317
|
+
return starts;
|
|
2318
|
+
}
|
|
2319
|
+
function lineAt(starts, index) {
|
|
2320
|
+
let lo = 0;
|
|
2321
|
+
let hi = starts.length - 1;
|
|
2322
|
+
while (lo < hi) {
|
|
2323
|
+
const mid = lo + hi + 1 >> 1;
|
|
2324
|
+
if (starts[mid] <= index)
|
|
2325
|
+
lo = mid;
|
|
2326
|
+
else
|
|
2327
|
+
hi = mid - 1;
|
|
2328
|
+
}
|
|
2329
|
+
return lo + 1;
|
|
2330
|
+
}
|
|
2331
|
+
function redactSecrets(text, filePath) {
|
|
2332
|
+
const starts = buildLineStarts(text);
|
|
2333
|
+
const marker = (type, index) => `[REDACTED ${type}@${lineAt(starts, index)}${filePath === undefined ? "" : ` in ${filePath}`}]`;
|
|
2334
|
+
const replacements = [];
|
|
2335
|
+
const findings = [];
|
|
2336
|
+
for (const pattern of WHOLE_MATCH_PATTERNS) {
|
|
2337
|
+
for (const match of text.matchAll(pattern.re)) {
|
|
2338
|
+
if (match.index === undefined)
|
|
2339
|
+
continue;
|
|
2340
|
+
replacements.push({ index: match.index, length: match[0].length, text: marker(pattern.type, match.index) });
|
|
2341
|
+
findings.push({ line: lineAt(starts, match.index), type: pattern.type });
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
for (const pattern of VALUE_PATTERNS) {
|
|
2345
|
+
for (const match of text.matchAll(pattern.re)) {
|
|
2346
|
+
if (match.index === undefined)
|
|
2347
|
+
continue;
|
|
2348
|
+
const type = pattern.typeOf(match[2]);
|
|
2349
|
+
const replacement = `${match[1]}${match[2]}${match[3]}${match[4]}${marker(type, match.index)}`;
|
|
2350
|
+
replacements.push({ index: match.index, length: match[0].length, text: replacement });
|
|
2351
|
+
findings.push({ line: lineAt(starts, match.index), type });
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
replacements.sort((a, b) => b.index - a.index);
|
|
2355
|
+
let out = text;
|
|
2356
|
+
for (const r of replacements)
|
|
2357
|
+
out = out.slice(0, r.index) + r.text + out.slice(r.index + r.length);
|
|
2358
|
+
const deduped = new Map;
|
|
2359
|
+
for (const f of findings)
|
|
2360
|
+
deduped.set(`${f.line}:${f.type}`, f);
|
|
2361
|
+
const sorted = [...deduped.values()].sort((a, b) => a.line - b.line || a.type.localeCompare(b.type));
|
|
2362
|
+
return { text: out, findings: sorted };
|
|
2363
|
+
}
|
|
2364
|
+
function slugify(title) {
|
|
2365
|
+
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2366
|
+
}
|
|
2367
|
+
var escapeCell = (value) => value.replace(/\|/g, "\\|");
|
|
2368
|
+
var truncate = (value, max) => value.length > max ? `${value.slice(0, max)}…` : value;
|
|
2369
|
+
function renderPlanFile(finding, plannedAt) {
|
|
2370
|
+
const sections = [
|
|
2371
|
+
`# ${finding.title}`,
|
|
2372
|
+
"",
|
|
2373
|
+
"## Status",
|
|
2374
|
+
`- **Priority**: ${finding.priority}`,
|
|
2375
|
+
`- **Effort**: ${finding.effort}`,
|
|
2376
|
+
`- **Risk**: ${finding.risk}`,
|
|
2377
|
+
`- **Depends on**: ${finding.dependsOn ?? "none"}`,
|
|
2378
|
+
`- **Category**: ${finding.category}`,
|
|
2379
|
+
`- **Planned at**: commit \`${plannedAt.commit}\`, ${plannedAt.date}`,
|
|
2380
|
+
"",
|
|
2381
|
+
"## Impact",
|
|
2382
|
+
finding.impact
|
|
2383
|
+
];
|
|
2384
|
+
if (finding.evidence.length > 0) {
|
|
2385
|
+
sections.push("", "## Evidence", ...finding.evidence.map((e) => `- ${e}`));
|
|
2386
|
+
}
|
|
2387
|
+
if (finding.fixSketch !== undefined) {
|
|
2388
|
+
sections.push("", "## Fix sketch", finding.fixSketch);
|
|
2389
|
+
}
|
|
2390
|
+
if (finding.verification !== undefined) {
|
|
2391
|
+
sections.push("", "## Verification", finding.verification);
|
|
2392
|
+
}
|
|
2393
|
+
return `${sections.join(`
|
|
2394
|
+
`)}
|
|
2395
|
+
`;
|
|
2396
|
+
}
|
|
2397
|
+
function readPlanFileSummary(filePath) {
|
|
2398
|
+
const text = readFileSync6(filePath, "utf8");
|
|
2399
|
+
const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
|
|
2400
|
+
const blocks = parseStatusBlocks(text);
|
|
2401
|
+
return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
|
|
2402
|
+
}
|
|
2403
|
+
function renderIndex(params) {
|
|
2404
|
+
const { date, repoName, repoShortSha, rows, rejected } = params;
|
|
2405
|
+
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(`
|
|
2406
|
+
`);
|
|
2407
|
+
const directionRows = rows.filter((r) => r.category === "direction").map((r) => `- ${escapeCell(r.title)} — ${escapeCell(truncate(r.impact, 120))}`).join(`
|
|
2408
|
+
`);
|
|
2409
|
+
const executionRows = rows.map((r) => `| ${r.num} | ${escapeCell(r.title)} | ${r.priority} | ${r.effort} | ${r.dependsOn} | TODO |`).join(`
|
|
2410
|
+
`);
|
|
2411
|
+
const rejectedRows = rejected.map((r) => `- ${escapeCell(r.title)}: ${escapeCell(r.reason)}`).join(`
|
|
2412
|
+
`);
|
|
2413
|
+
const sections = [
|
|
2414
|
+
`# Audit Report — ${repoName} @ ${repoShortSha} (${date})`,
|
|
2415
|
+
"",
|
|
2416
|
+
"## Findings",
|
|
2417
|
+
"",
|
|
2418
|
+
"| # | Finding | Category | Impact | Effort | Risk | Confidence | Evidence |",
|
|
2419
|
+
"|---|---------|----------|--------|--------|------|------------|----------|",
|
|
2420
|
+
findingsRows
|
|
2421
|
+
];
|
|
2422
|
+
if (directionRows !== "") {
|
|
2423
|
+
sections.push("", "## Direction", "", directionRows);
|
|
2424
|
+
}
|
|
2425
|
+
sections.push("", "## Execution order & status", "", "| Plan | Title | Priority | Effort | Depends on | Status |", "|------|-------|----------|--------|------------|--------|", executionRows);
|
|
2426
|
+
if (rejectedRows !== "") {
|
|
2427
|
+
sections.push("", "## Findings considered and rejected", "", rejectedRows);
|
|
2428
|
+
}
|
|
2429
|
+
return `${sections.join(`
|
|
2430
|
+
`)}
|
|
2431
|
+
`;
|
|
2432
|
+
}
|
|
2433
|
+
function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
2434
|
+
const date = options.date ?? new Date().toISOString().slice(0, 10);
|
|
2435
|
+
const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
|
|
2436
|
+
mkdirSync5(outDir, { recursive: true });
|
|
2437
|
+
const existing = readdirSync4(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
|
|
2438
|
+
let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
|
|
2439
|
+
const written = [];
|
|
2440
|
+
const usedSlugs = new Set;
|
|
2441
|
+
for (const finding of findings) {
|
|
2442
|
+
const num = String(next).padStart(3, "0");
|
|
2443
|
+
let slug = slugify(finding.title);
|
|
2444
|
+
if (usedSlugs.has(slug)) {
|
|
2445
|
+
let n = 2;
|
|
2446
|
+
while (usedSlugs.has(`${slug}-${n}`))
|
|
2447
|
+
n++;
|
|
2448
|
+
slug = `${slug}-${n}`;
|
|
2449
|
+
}
|
|
2450
|
+
usedSlugs.add(slug);
|
|
2451
|
+
const file = `${num}-${slug}.md`;
|
|
2452
|
+
writeFileSync4(join7(outDir, file), renderPlanFile(finding, plannedAt));
|
|
2453
|
+
written.push(file);
|
|
2454
|
+
next++;
|
|
2455
|
+
}
|
|
2456
|
+
const all = [...existing, ...written].sort();
|
|
2457
|
+
const rows = all.map((file) => {
|
|
2458
|
+
const summary = readPlanFileSummary(join7(outDir, file));
|
|
2459
|
+
const fields = summary.fields;
|
|
2460
|
+
return {
|
|
2461
|
+
num: file.slice(0, 3),
|
|
2462
|
+
title: summary.title,
|
|
2463
|
+
category: fields.get("Category") ?? "—",
|
|
2464
|
+
impact: "see plan file",
|
|
2465
|
+
effort: fields.get("Effort") ?? "—",
|
|
2466
|
+
risk: fields.get("Risk") ?? "—",
|
|
2467
|
+
confidence: "—",
|
|
2468
|
+
evidence: fields.get("Evidence") ?? "—",
|
|
2469
|
+
priority: fields.get("Priority") ?? "—",
|
|
2470
|
+
dependsOn: fields.get("Depends on") ?? "—"
|
|
2471
|
+
};
|
|
2472
|
+
});
|
|
2473
|
+
const byNum = new Map(rows.map((r) => [r.num, r]));
|
|
2474
|
+
written.forEach((file, i) => {
|
|
2475
|
+
const finding = findings[i];
|
|
2476
|
+
if (finding === undefined)
|
|
2477
|
+
return;
|
|
2478
|
+
const row = byNum.get(file.slice(0, 3));
|
|
2479
|
+
if (row !== undefined) {
|
|
2480
|
+
row.category = finding.category;
|
|
2481
|
+
row.impact = finding.impact;
|
|
2482
|
+
row.effort = finding.effort;
|
|
2483
|
+
row.risk = finding.risk;
|
|
2484
|
+
row.confidence = finding.confidence;
|
|
2485
|
+
row.evidence = finding.evidence[0] ?? "";
|
|
2486
|
+
row.priority = finding.priority;
|
|
2487
|
+
row.dependsOn = finding.dependsOn ?? "none";
|
|
2488
|
+
}
|
|
2489
|
+
});
|
|
2490
|
+
writeFileSync4(join7(outDir, "README.md"), renderIndex({
|
|
2491
|
+
date,
|
|
2492
|
+
repoName: options.repoName ?? "repo",
|
|
2493
|
+
repoShortSha: options.repoShortSha ?? "unknown",
|
|
2494
|
+
rows,
|
|
2495
|
+
rejected: options.rejected ?? []
|
|
2496
|
+
}));
|
|
2497
|
+
return { outDir: resolve7(outDir), date, files: written, nextNumber: next };
|
|
2498
|
+
}
|
|
2499
|
+
// src/compound.ts
|
|
2500
|
+
import { existsSync as existsSync5, readdirSync as readdirSync5, readFileSync as readFileSync7 } from "node:fs";
|
|
2501
|
+
import { basename as basename4, isAbsolute as isAbsolute5, join as join8, relative as relative2, resolve as resolve8, sep } from "node:path";
|
|
2502
|
+
function violation8(severity, code, message, fix) {
|
|
2503
|
+
return { ok: false, severity, code, message, fix };
|
|
2504
|
+
}
|
|
2505
|
+
var KNOWLEDGE_REQUIRED_FIELDS = ["module", "date", "problem_type", "category", "severity"];
|
|
2506
|
+
var KNOWLEDGE_PROBLEM_TYPES = [
|
|
2507
|
+
"build_error",
|
|
2508
|
+
"test_failure",
|
|
2509
|
+
"runtime_error",
|
|
2510
|
+
"performance_issue",
|
|
2511
|
+
"database_issue",
|
|
2512
|
+
"security_issue",
|
|
2513
|
+
"ui_bug",
|
|
2514
|
+
"integration_issue",
|
|
2515
|
+
"logic_error",
|
|
2516
|
+
"config_error",
|
|
2517
|
+
"developer_experience",
|
|
2518
|
+
"workflow_issue",
|
|
2519
|
+
"best_practice",
|
|
2520
|
+
"documentation_gap",
|
|
2521
|
+
"architecture_pattern",
|
|
2522
|
+
"design_pattern",
|
|
2523
|
+
"tooling_decision",
|
|
2524
|
+
"convention",
|
|
2525
|
+
"api_design",
|
|
2526
|
+
"testing_pattern"
|
|
2527
|
+
];
|
|
2528
|
+
var KNOWLEDGE_BUG_PROBLEM_TYPES = [
|
|
2529
|
+
"build_error",
|
|
2530
|
+
"test_failure",
|
|
2531
|
+
"runtime_error",
|
|
2532
|
+
"performance_issue",
|
|
2533
|
+
"database_issue",
|
|
2534
|
+
"security_issue",
|
|
2535
|
+
"ui_bug",
|
|
2536
|
+
"integration_issue",
|
|
2537
|
+
"logic_error",
|
|
2538
|
+
"config_error"
|
|
2539
|
+
];
|
|
2540
|
+
var KNOWLEDGE_KNOWLEDGE_PROBLEM_TYPES = [
|
|
2541
|
+
"developer_experience",
|
|
2542
|
+
"workflow_issue",
|
|
2543
|
+
"best_practice",
|
|
2544
|
+
"documentation_gap",
|
|
2545
|
+
"architecture_pattern",
|
|
2546
|
+
"design_pattern",
|
|
2547
|
+
"tooling_decision",
|
|
2548
|
+
"convention",
|
|
2549
|
+
"api_design",
|
|
2550
|
+
"testing_pattern"
|
|
2551
|
+
];
|
|
2552
|
+
var KNOWLEDGE_SEVERITIES = ["critical", "high", "medium", "low"];
|
|
2553
|
+
var KNOWLEDGE_RESOLUTION_TYPES = [
|
|
2554
|
+
"code_fix",
|
|
2555
|
+
"migration",
|
|
2556
|
+
"config_change",
|
|
2557
|
+
"test_fix",
|
|
2558
|
+
"dependency_update",
|
|
2559
|
+
"environment_setup",
|
|
2560
|
+
"workflow_improvement",
|
|
2561
|
+
"documentation_update",
|
|
2562
|
+
"tooling_addition"
|
|
2563
|
+
];
|
|
2564
|
+
var KNOWLEDGE_CATEGORY_MAP = {
|
|
2565
|
+
build_error: "build-errors",
|
|
2566
|
+
test_failure: "test-failures",
|
|
2567
|
+
runtime_error: "runtime-errors",
|
|
2568
|
+
performance_issue: "performance-issues",
|
|
2569
|
+
database_issue: "database-issues",
|
|
2570
|
+
security_issue: "security-issues",
|
|
2571
|
+
ui_bug: "ui-bugs",
|
|
2572
|
+
integration_issue: "integration-issues",
|
|
2573
|
+
logic_error: "logic-errors",
|
|
2574
|
+
config_error: "config-errors",
|
|
2575
|
+
best_practice: "best-practices",
|
|
2576
|
+
convention: "conventions",
|
|
2577
|
+
architecture_pattern: "architecture-patterns",
|
|
2578
|
+
design_pattern: "design-patterns",
|
|
2579
|
+
tooling_decision: "tooling-decisions",
|
|
2580
|
+
testing_pattern: "testing-patterns",
|
|
2581
|
+
api_design: "api-design",
|
|
2582
|
+
workflow_issue: "workflow-patterns",
|
|
2583
|
+
developer_experience: "developer-experience",
|
|
2584
|
+
documentation_gap: "documentation"
|
|
2585
|
+
};
|
|
2586
|
+
var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
|
|
2587
|
+
var isMap2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2588
|
+
function parseScalar2(raw) {
|
|
2589
|
+
const trimmed = raw.trim();
|
|
2590
|
+
const quoted = /^"([^"]*)"$/.exec(trimmed) ?? /^'([^']*)'$/.exec(trimmed);
|
|
2591
|
+
if (quoted)
|
|
2592
|
+
return quoted[1];
|
|
2593
|
+
const cut = trimmed.split(/\s+#/)[0].trim();
|
|
2594
|
+
if (/^-?\d+(?:\.\d+)?$/.test(cut))
|
|
2595
|
+
return Number(cut);
|
|
2596
|
+
if (/^(?:true|false)$/.test(cut))
|
|
2597
|
+
return cut === "true";
|
|
2598
|
+
return cut;
|
|
2599
|
+
}
|
|
2600
|
+
function parseMapBlock2(lines, start, indent) {
|
|
2601
|
+
const map = {};
|
|
2602
|
+
let i = start;
|
|
2603
|
+
while (i < lines.length && lines[i].indent === indent) {
|
|
2604
|
+
const match = /^([^:]+):(.*)$/.exec(lines[i].text);
|
|
2605
|
+
if (match === null) {
|
|
2606
|
+
i++;
|
|
2607
|
+
continue;
|
|
2608
|
+
}
|
|
2609
|
+
const key = parseScalar2(match[1].trim()).toString();
|
|
2610
|
+
const rest = match[2].trim();
|
|
2611
|
+
if (rest === "") {
|
|
2612
|
+
const nested = i + 1 < lines.length && lines[i + 1].indent > indent;
|
|
2613
|
+
if (nested) {
|
|
2614
|
+
const child = parseBlock2(lines, i + 1, lines[i + 1].indent);
|
|
2615
|
+
map[key] = child.value;
|
|
2616
|
+
i = child.next;
|
|
2617
|
+
} else {
|
|
2618
|
+
map[key] = "";
|
|
2619
|
+
i++;
|
|
2620
|
+
}
|
|
2621
|
+
} else {
|
|
2622
|
+
map[key] = parseScalar2(rest);
|
|
2623
|
+
i++;
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
return { value: map, next: i };
|
|
2627
|
+
}
|
|
2628
|
+
function parseListBlock2(lines, start, indent) {
|
|
2629
|
+
const list = [];
|
|
2630
|
+
let i = start;
|
|
2631
|
+
while (i < lines.length && lines[i].indent === indent && lines[i].text.startsWith("-")) {
|
|
2632
|
+
const rest = lines[i].text.slice(1).trim();
|
|
2633
|
+
const match = /^([^:]+):(.*)$/.exec(rest);
|
|
2634
|
+
if (match !== null && match[2].trim() === "" && i + 1 < lines.length && lines[i + 1].indent > indent) {
|
|
2635
|
+
const child = parseBlock2(lines, i + 1, lines[i + 1].indent);
|
|
2636
|
+
list.push({ [parseScalar2(match[1].trim()).toString()]: child.value });
|
|
2637
|
+
i = child.next;
|
|
2638
|
+
} else if (match !== null) {
|
|
2639
|
+
list.push({ [parseScalar2(match[1].trim()).toString()]: parseScalar2(match[2].trim()) });
|
|
2640
|
+
i++;
|
|
2641
|
+
} else {
|
|
2642
|
+
list.push(parseScalar2(rest));
|
|
2643
|
+
i++;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
return { value: list, next: i };
|
|
2647
|
+
}
|
|
2648
|
+
function parseBlock2(lines, start, indent) {
|
|
2649
|
+
if (lines[start] !== undefined && lines[start].text.startsWith("-"))
|
|
2650
|
+
return parseListBlock2(lines, start, indent);
|
|
2651
|
+
return parseMapBlock2(lines, start, indent);
|
|
2652
|
+
}
|
|
2653
|
+
function parseYamlLite(text) {
|
|
2654
|
+
const body = text.replace(/^\uFEFF/, "");
|
|
2655
|
+
const lines = body.split(/\r?\n/);
|
|
2656
|
+
if (lines.length === 0 || !lines[0].trim().startsWith("---"))
|
|
2657
|
+
return null;
|
|
2658
|
+
const inner = [];
|
|
2659
|
+
for (let i = 1;i < lines.length; i++) {
|
|
2660
|
+
const trimmed = lines[i].trim();
|
|
2661
|
+
if (trimmed === "---")
|
|
2662
|
+
break;
|
|
2663
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
2664
|
+
continue;
|
|
2665
|
+
const indent = lines[i].match(/^ */)[0].length;
|
|
2666
|
+
inner.push({ indent, text: lines[i].slice(indent) });
|
|
2667
|
+
}
|
|
2668
|
+
if (inner.length === 0)
|
|
2669
|
+
return null;
|
|
2670
|
+
const top = parseBlock2(inner, 0, inner[0].indent);
|
|
2671
|
+
if (!isMap2(top.value))
|
|
2672
|
+
return null;
|
|
2673
|
+
return top.value;
|
|
2674
|
+
}
|
|
2675
|
+
function validateSchemaYaml(frontmatterText) {
|
|
2676
|
+
const violations = [];
|
|
2677
|
+
const doc = parseYamlLite(frontmatterText);
|
|
2678
|
+
if (doc === null) {
|
|
2679
|
+
violations.push(violation8("medium", "compound.schema.missing-frontmatter", "no `---` YAML frontmatter block found — knowledge docs must open with the schema.yaml contract (mstar-compound/references/schema.yaml)", "add the fenced frontmatter with module, date, problem_type, category, severity"));
|
|
2680
|
+
return { ok: false, violations };
|
|
2681
|
+
}
|
|
2682
|
+
const isStr = (v) => typeof v === "string";
|
|
2683
|
+
const missing = (field) => violations.push(violation8("medium", "compound.schema.missing-field", `missing required frontmatter field "${field}" (schema.yaml required_fields)`, `add \`${field}: <value>\` to the frontmatter`));
|
|
2684
|
+
for (const field of KNOWLEDGE_REQUIRED_FIELDS) {
|
|
2685
|
+
if (!(field in doc) || doc[field] === "")
|
|
2686
|
+
missing(field);
|
|
2687
|
+
}
|
|
2688
|
+
if (doc.date !== undefined && (!isStr(doc.date) || !DATE_RE3.test(doc.date))) {
|
|
2689
|
+
violations.push(violation8("medium", "compound.schema.invalid-date", `date "${String(doc.date)}" must be a YYYY-MM-DD string (schema.yaml required_fields.date)`, "use `YYYY-MM-DD`"));
|
|
2690
|
+
}
|
|
2691
|
+
const problemType = doc.problem_type;
|
|
2692
|
+
if (problemType !== undefined && !isStr(problemType)) {
|
|
2693
|
+
violations.push(violation8("medium", "compound.schema.invalid-problem-type", `problem_type "${String(problemType)}" must be a string — one of the schema.yaml enum values (bug: build_error…config_error; knowledge: developer_experience…testing_pattern)`, "pick the narrowest applicable problem_type from schema.yaml"));
|
|
2694
|
+
}
|
|
2695
|
+
const problemTypeValid = isStr(problemType) && KNOWLEDGE_PROBLEM_TYPES.includes(problemType);
|
|
2696
|
+
if (isStr(problemType) && !problemTypeValid) {
|
|
2697
|
+
violations.push(violation8("medium", "compound.schema.invalid-problem-type", `problem_type "${problemType}" is not one of the schema.yaml enum values (bug: build_error…config_error; knowledge: developer_experience…testing_pattern)`, "pick the narrowest applicable problem_type from schema.yaml"));
|
|
2698
|
+
}
|
|
2699
|
+
if (doc.severity !== undefined && !isStr(doc.severity)) {
|
|
2700
|
+
violations.push(violation8("medium", "compound.schema.invalid-severity", `severity "${String(doc.severity)}" must be a string — critical | high | medium | low (schema.yaml required_fields.severity)`, "use one of the four severity values"));
|
|
2701
|
+
}
|
|
2702
|
+
if (isStr(doc.severity) && !KNOWLEDGE_SEVERITIES.includes(doc.severity)) {
|
|
2703
|
+
violations.push(violation8("medium", "compound.schema.invalid-severity", `severity "${doc.severity}" must be critical | high | medium | low (schema.yaml required_fields.severity)`, "use one of the four severity values"));
|
|
2704
|
+
}
|
|
2705
|
+
if (problemTypeValid && isStr(doc.category)) {
|
|
2706
|
+
const expected = KNOWLEDGE_CATEGORY_MAP[problemType];
|
|
2707
|
+
if (doc.category !== expected) {
|
|
2708
|
+
violations.push(violation8("medium", "compound.schema.category-mismatch", `category "${doc.category}" does not match problem_type "${problemType}" — category-mapping.md rule 1 maps it to "${expected}"`, `set \`category: ${expected}\` (the directory name under {KNOWLEDGE_DIR})`));
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
if (problemTypeValid) {
|
|
2712
|
+
const isBug = KNOWLEDGE_BUG_PROBLEM_TYPES.includes(problemType);
|
|
2713
|
+
if (isBug) {
|
|
2714
|
+
for (const field of ["symptoms", "root_cause", "resolution_type"]) {
|
|
2715
|
+
if (!(field in doc)) {
|
|
2716
|
+
violations.push(violation8("medium", "compound.schema.missing-track-field", `bug-track doc missing required field "${field}" (schema.yaml track_rules.bug)`, `add \`${field}:\` to the frontmatter`));
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
if (doc.symptoms !== undefined && !Array.isArray(doc.symptoms)) {
|
|
2720
|
+
violations.push(violation8("medium", "compound.schema.invalid-symptoms", "bug-track `symptoms` must be a YAML list (schema.yaml track_rules.bug)", "list the observable symptoms under `symptoms:`"));
|
|
2721
|
+
}
|
|
2722
|
+
if (doc.root_cause !== undefined && !isStr(doc.root_cause)) {
|
|
2723
|
+
violations.push(violation8("medium", "compound.schema.invalid-root-cause", "bug-track `root_cause` must be a string (schema.yaml track_rules.bug)", "write the fundamental technical cause as a string"));
|
|
2724
|
+
}
|
|
2725
|
+
if (isStr(doc.resolution_type) && !KNOWLEDGE_RESOLUTION_TYPES.includes(doc.resolution_type)) {
|
|
2726
|
+
violations.push(violation8("medium", "compound.schema.invalid-resolution-type", `resolution_type "${doc.resolution_type}" is not one of the schema.yaml track_rules.bug enum values`, "use code_fix | migration | config_change | test_fix | dependency_update | environment_setup | workflow_improvement | documentation_update | tooling_addition"));
|
|
2727
|
+
}
|
|
2728
|
+
} else if (doc.applies_when !== undefined && !Array.isArray(doc.applies_when)) {
|
|
2729
|
+
violations.push(violation8("low", "compound.schema.invalid-applies-when", "knowledge-track `applies_when` must be a YAML list when present (schema.yaml track_rules.knowledge)", "list the conditions under `applies_when:`"));
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
if (doc.plan_id !== undefined && !isStr(doc.plan_id)) {
|
|
2733
|
+
violations.push(violation8("low", "compound.schema.invalid-plan-id", "optional `plan_id` must be a string (schema.yaml optional_fields.plan_id)", "reference the status.json plan id as a string"));
|
|
2734
|
+
}
|
|
2735
|
+
if (doc.tags !== undefined) {
|
|
2736
|
+
if (!Array.isArray(doc.tags)) {
|
|
2737
|
+
violations.push(violation8("low", "compound.schema.invalid-tags", "optional `tags` must be a YAML list (schema.yaml optional_fields.tags)", "list lowercase, hyphen-separated keywords"));
|
|
2738
|
+
} else if (doc.tags.length > 8) {
|
|
2739
|
+
violations.push(violation8("low", "compound.schema.tags-too-many", `tags has ${doc.tags.length} entries — max 8 (schema.yaml optional_fields.tags.max_items)`, "trim the tag list to at most 8 keywords"));
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
if (doc.last_updated !== undefined && (!isStr(doc.last_updated) || !DATE_RE3.test(doc.last_updated))) {
|
|
2743
|
+
violations.push(violation8("low", "compound.schema.invalid-last-updated", `last_updated "${String(doc.last_updated)}" must be YYYY-MM-DD (schema.yaml optional_fields.last_updated)`, "use `YYYY-MM-DD`"));
|
|
2744
|
+
}
|
|
2745
|
+
if (doc.related_components !== undefined && !Array.isArray(doc.related_components)) {
|
|
2746
|
+
violations.push(violation8("low", "compound.schema.invalid-related-components", "optional `related_components` must be a YAML list (schema.yaml optional_fields.related_components)", "list the other components involved"));
|
|
2747
|
+
}
|
|
2748
|
+
return { ok: violations.length === 0, violations };
|
|
2749
|
+
}
|
|
2750
|
+
var REF_EXT_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|md|markdown|json|jsonc|yaml|yml|toml|ini|cfg|sh|bash|zsh|py|go|rs|rb|java|kt|c|cpp|h|hpp|css|scss|sass|less|html|htm|vue|svelte|sql|graphql|env|example|gitignore|npmrc|lock|txt|svg|png|jpg|jpeg|webp|ico)$/i;
|
|
2751
|
+
var SYMBOL_REF_RE = /^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/;
|
|
2752
|
+
var SCHEME_RE = /^[a-zA-Z][\w+.-]*:\/\//;
|
|
2753
|
+
var LINE_SUFFIX_RE = /:\d+(?:-\d+)?$/;
|
|
2754
|
+
var ANCHOR_RE = /#[\w.-]+$/;
|
|
2755
|
+
var WALK_SKIP_DIRS = new Set(["node_modules", ".git", "dist"]);
|
|
2756
|
+
var MAX_WALK_FILES = 5000;
|
|
2757
|
+
function referenceExists(repoRoot, docText) {
|
|
2758
|
+
const violations = [];
|
|
2759
|
+
let checked = 0;
|
|
2760
|
+
const seen = new Set;
|
|
2761
|
+
const moduleNames = new Set;
|
|
2762
|
+
const refs = [];
|
|
2763
|
+
for (const match of docText.matchAll(/`([^`\n]+)`/g)) {
|
|
2764
|
+
const ref = match[1].trim();
|
|
2765
|
+
if (ref === "" || seen.has(ref))
|
|
2766
|
+
continue;
|
|
2767
|
+
seen.add(ref);
|
|
2768
|
+
if (SCHEME_RE.test(ref) || ref.startsWith("{") || ref.startsWith("#") || ref.includes("*") || ref.includes("?") || ref.startsWith("~") || isAbsolute5(ref)) {
|
|
2769
|
+
continue;
|
|
2770
|
+
}
|
|
2771
|
+
if (ref.includes("/") || REF_EXT_RE.test(ref)) {
|
|
2772
|
+
refs.push({ ref, isSymbol: false });
|
|
2773
|
+
} else if (SYMBOL_REF_RE.test(ref)) {
|
|
2774
|
+
const module = ref.split(".")[0];
|
|
2775
|
+
moduleNames.add(module);
|
|
2776
|
+
refs.push({ ref, isSymbol: true, module });
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
const foundModules = new Set;
|
|
2780
|
+
if (moduleNames.size > 0) {
|
|
2781
|
+
let walked = 0;
|
|
2782
|
+
const stack = [repoRoot];
|
|
2783
|
+
while (stack.length > 0 && walked < MAX_WALK_FILES) {
|
|
2784
|
+
const dir = stack.pop();
|
|
2785
|
+
let entries;
|
|
2786
|
+
try {
|
|
2787
|
+
entries = readdirSync5(dir, { withFileTypes: true });
|
|
2788
|
+
} catch {
|
|
2789
|
+
continue;
|
|
2790
|
+
}
|
|
2791
|
+
for (const entry of entries) {
|
|
2792
|
+
if (++walked > MAX_WALK_FILES)
|
|
2793
|
+
break;
|
|
2794
|
+
if (entry.isDirectory()) {
|
|
2795
|
+
if (!WALK_SKIP_DIRS.has(entry.name))
|
|
2796
|
+
stack.push(join8(dir, entry.name));
|
|
2797
|
+
} else if (!entry.isSymbolicLink()) {
|
|
2798
|
+
const base = entry.name.replace(/\.(?:ts|tsx|js|jsx|mjs|cjs)$/, "");
|
|
2799
|
+
if (moduleNames.has(base))
|
|
2800
|
+
foundModules.add(base);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
for (const { ref, isSymbol, module } of refs) {
|
|
2806
|
+
if (!isSymbol || module === undefined) {
|
|
2807
|
+
const candidate = ref.replace(LINE_SUFFIX_RE, "").replace(ANCHOR_RE, "");
|
|
2808
|
+
if (existsSync5(resolve8(repoRoot, candidate))) {
|
|
2809
|
+
checked++;
|
|
2810
|
+
} else {
|
|
2811
|
+
violations.push(violation8("medium", "compound.reference.missing-file", `referenced path \`${ref}\` does not exist under ${repoRoot} (compound-refresh Phase 2: referenced code still exists?)`, "update the doc to reference an existing path, or delete the stale reference"));
|
|
2812
|
+
}
|
|
2813
|
+
} else if (foundModules.has(module)) {
|
|
2814
|
+
checked++;
|
|
2815
|
+
} else {
|
|
2816
|
+
violations.push(violation8("low", "compound.reference.module-missing", `symbol ref \`${ref}\` — heuristic: no ${module}.ts|tsx|js|jsx|mjs|cjs module file found under ${repoRoot} (compound-refresh Phase 2)`, "verify the module file exists, or update the reference"));
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
return { ok: violations.length === 0, violations, checked };
|
|
2820
|
+
}
|
|
2821
|
+
function collectKnowledgeDocs(dir) {
|
|
2822
|
+
const docs = [];
|
|
2823
|
+
const stack = [dir];
|
|
2824
|
+
while (stack.length > 0) {
|
|
2825
|
+
const current = stack.pop();
|
|
2826
|
+
let entries;
|
|
2827
|
+
try {
|
|
2828
|
+
entries = readdirSync5(current, { withFileTypes: true });
|
|
2829
|
+
} catch {
|
|
2830
|
+
continue;
|
|
2831
|
+
}
|
|
2832
|
+
for (const entry of entries) {
|
|
2833
|
+
if (entry.isSymbolicLink())
|
|
2834
|
+
continue;
|
|
2835
|
+
const full = join8(current, entry.name);
|
|
2836
|
+
if (entry.isDirectory()) {
|
|
2837
|
+
stack.push(full);
|
|
2838
|
+
} else if (entry.name.endsWith(".md") && entry.name !== "README.md" && entry.name !== "index.md") {
|
|
2839
|
+
docs.push(relative2(dir, full).split(sep).join("/"));
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
return docs.sort();
|
|
2844
|
+
}
|
|
2845
|
+
function normalizeIndexRef(cell) {
|
|
2846
|
+
const link = /\[[^\]]*\]\(([^)]+)\)/.exec(cell);
|
|
2847
|
+
let value = link !== null ? link[1] : cell;
|
|
2848
|
+
value = value.replace(/`/g, "").replace(/^\.\//, "");
|
|
2849
|
+
if (value.startsWith("knowledge/"))
|
|
2850
|
+
value = value.slice("knowledge/".length);
|
|
2851
|
+
return value.trim();
|
|
2852
|
+
}
|
|
2853
|
+
function assertIndexRows(knowledgeDir) {
|
|
2854
|
+
const violations = [];
|
|
2855
|
+
const readmePath = join8(knowledgeDir, "README.md");
|
|
2856
|
+
if (!existsSync5(readmePath)) {
|
|
2857
|
+
violations.push(violation8("medium", "compound.index.missing-readme", `missing ${readmePath} — the knowledge index is required (mstar-compound Phase 6: every doc gets a README.md row)`, "create knowledge/README.md with a Document / Source Plan / Description / Status table"));
|
|
2858
|
+
return { ok: false, violations };
|
|
2859
|
+
}
|
|
2860
|
+
const docs = collectKnowledgeDocs(knowledgeDir);
|
|
2861
|
+
const rows = new Set;
|
|
2862
|
+
for (const line of readFileSync7(readmePath, "utf8").split(/\r?\n/)) {
|
|
2863
|
+
if (!line.trim().startsWith("|"))
|
|
2864
|
+
continue;
|
|
2865
|
+
const cells = line.split("|").map((c) => c.trim());
|
|
2866
|
+
if (cells.length < 2)
|
|
2867
|
+
continue;
|
|
2868
|
+
const normalized = normalizeIndexRef(cells[1]);
|
|
2869
|
+
if (normalized !== "")
|
|
2870
|
+
rows.add(normalized);
|
|
2871
|
+
}
|
|
2872
|
+
for (const doc of docs) {
|
|
2873
|
+
if (!rows.has(doc)) {
|
|
2874
|
+
violations.push(violation8("medium", "compound.index.missing-row", `knowledge doc "${doc}" has no row in knowledge/README.md index (mstar-compound Phase 6 index obligations)`, `add a row \`| [<title>](${doc}) | <source plan> | <description> | <status> |\` to knowledge/README.md`));
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
return { ok: violations.length === 0, violations };
|
|
2878
|
+
}
|
|
2879
|
+
function compoundRefreshScope(harnessDir, projectRoot) {
|
|
2880
|
+
return [
|
|
2881
|
+
join8(harnessDir, "knowledge"),
|
|
2882
|
+
join8(harnessDir, "knowledge", "README.md"),
|
|
2883
|
+
join8(projectRoot, "CONCEPTS.md"),
|
|
2884
|
+
join8(harnessDir, "status.json")
|
|
2885
|
+
];
|
|
2886
|
+
}
|
|
2887
|
+
function isFileLikeRoot(root) {
|
|
2888
|
+
return /^[^.]*\.[A-Za-z0-9]{1,10}$/.test(basename4(root));
|
|
2889
|
+
}
|
|
2890
|
+
function scopeGuard(path, allowedRoots) {
|
|
2891
|
+
const resolved = resolve8(path);
|
|
2892
|
+
for (const root of allowedRoots) {
|
|
2893
|
+
const r = resolve8(root);
|
|
2894
|
+
if (isFileLikeRoot(r)) {
|
|
2895
|
+
if (resolved === r)
|
|
2896
|
+
return { ok: true, violations: [] };
|
|
2897
|
+
} else if (resolved === r || resolved.startsWith(r + sep)) {
|
|
2898
|
+
return { ok: true, violations: [] };
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
return {
|
|
2902
|
+
ok: false,
|
|
2903
|
+
violations: [
|
|
2904
|
+
violation8("medium", "compound.scope.outside", `path "${path}" is outside the compound-refresh scope (allowed: ${allowedRoots.join(", ")}) — compound-refresh operates only on {HARNESS_DIR}/knowledge/**, {HARNESS_DIR}/knowledge/README.md, <repo-root>/CONCEPTS.md, {HARNESS_DIR}/status.json (mstar-compound-refresh SKILL.md § 产物与操作路径)`, "point the operation at one of the allowed paths")
|
|
2905
|
+
]
|
|
2906
|
+
};
|
|
2907
|
+
}
|
|
2908
|
+
// src/lint.ts
|
|
2909
|
+
function violation9(severity, code, message, fix) {
|
|
2910
|
+
return { ok: false, severity, code, message, fix };
|
|
2911
|
+
}
|
|
2912
|
+
var COMMENT_INTRODUCER = "(?:\\/\\/|\\/\\*|#|;|--|\\s\\*)";
|
|
2913
|
+
function findSimplifyMarkers(fileText) {
|
|
2914
|
+
const markers = [];
|
|
2915
|
+
const re = new RegExp(`${COMMENT_INTRODUCER}\\s*simplify\\s*:`, "i");
|
|
2916
|
+
const lines = fileText.split(/\r?\n/);
|
|
2917
|
+
for (let i = 0;i < lines.length; i++) {
|
|
2918
|
+
if (re.test(lines[i]))
|
|
2919
|
+
markers.push({ line: i + 1, text: lines[i].trim() });
|
|
2920
|
+
}
|
|
2921
|
+
return markers;
|
|
2922
|
+
}
|
|
2923
|
+
var REMOVAL_PATH_PATTERNS = [
|
|
2924
|
+
/status\.json/i,
|
|
2925
|
+
/R#\d+/i,
|
|
2926
|
+
/\bresiduals?\b/i,
|
|
2927
|
+
/plans?\/[\w./-]+/i,
|
|
2928
|
+
/\bplans?\s+20\d{6}[-.\w]*/i,
|
|
2929
|
+
/\b(?:tracked|recorded|logged|scheduled|listed|noted)\s+in\s+[\w./-]+/i,
|
|
2930
|
+
/removal\s+path\s*[:=]\s*["'`]?[\w./-]+/i
|
|
2931
|
+
];
|
|
2932
|
+
function findTemporaryMarkers(fileText) {
|
|
2933
|
+
const markers = [];
|
|
2934
|
+
const violations = [];
|
|
2935
|
+
const re = new RegExp(`${COMMENT_INTRODUCER}\\s*temporary\\b`, "i");
|
|
2936
|
+
const lines = fileText.split(/\r?\n/);
|
|
2937
|
+
for (let i = 0;i < lines.length; i++) {
|
|
2938
|
+
const line = lines[i];
|
|
2939
|
+
if (!re.test(line))
|
|
2940
|
+
continue;
|
|
2941
|
+
const text = line.trim();
|
|
2942
|
+
let removalPath = null;
|
|
2943
|
+
for (const pattern of REMOVAL_PATH_PATTERNS) {
|
|
2944
|
+
const match = pattern.exec(text);
|
|
2945
|
+
if (match) {
|
|
2946
|
+
removalPath = match[0];
|
|
2947
|
+
break;
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
markers.push({ line: i + 1, text, removalPath });
|
|
2951
|
+
if (removalPath === null) {
|
|
2952
|
+
violations.push(violation9("medium", "lint.temporary.no-removal-path", `temporary marker at line ${i + 1} records no removal path (plan/status artifact reference) — record one before claiming the task complete (mstar-coding-behavior § Simplification markers)`, 'add a plan/status reference to the marker, e.g. "removal tracked in status.json" or "plan 20260808-slice2 removes this"'));
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
return { ok: violations.length === 0, violations, markers };
|
|
2956
|
+
}
|
|
2957
|
+
var TEST_FILE_PATH_RE = /[\w./-]+\.(?:test|spec)\.[a-z0-9]+/i;
|
|
2958
|
+
var TEST_FILE_PHRASE_RE = /\btest files?\b/i;
|
|
2959
|
+
var COMMAND_PROMPT_RE = /^\s*[$>]\s*\S/;
|
|
2960
|
+
var RUNNER_RE = /\b(?:bun|pnpm|npm|yarn|npx|bunx)\s+(?:test|run|exec)\b|\b(?:npx|bunx)\s+[\w./-]+\b|\b(?:tsc|vitest|jest|mocha|pytest)\b|\bgo\s+test\b|\bcargo\s+test\b/i;
|
|
2961
|
+
var OUTPUT_TOKEN_RE = /[✓✔✗✘]|\b(?:PASS|FAIL)\b|\b\d+\s+(?:pass(?:es|ed)?|fail(?:s|ed|ing)?|skipped|tests?|ok)\b|\bok\s+\d+\b|\ball\s+ok\b|exit(?:ed)?\s+(?:with\s+)?(?:code\s+)?\d+/i;
|
|
2962
|
+
function assertSddTddTriple(reportText) {
|
|
2963
|
+
const violations = [];
|
|
2964
|
+
const lines = reportText.split(/\r?\n/);
|
|
2965
|
+
let hasTests = false;
|
|
2966
|
+
let hasCommand = false;
|
|
2967
|
+
let hasOutput = false;
|
|
2968
|
+
for (const line of lines) {
|
|
2969
|
+
if (!hasTests && (TEST_FILE_PATH_RE.test(line) || TEST_FILE_PHRASE_RE.test(line)))
|
|
2970
|
+
hasTests = true;
|
|
2971
|
+
if (!hasCommand && (COMMAND_PROMPT_RE.test(line) || RUNNER_RE.test(line)))
|
|
2972
|
+
hasCommand = true;
|
|
2973
|
+
if (!hasOutput && OUTPUT_TOKEN_RE.test(line))
|
|
2974
|
+
hasOutput = true;
|
|
2975
|
+
if (hasTests && hasCommand && hasOutput)
|
|
2976
|
+
break;
|
|
2977
|
+
}
|
|
2978
|
+
if (!hasTests) {
|
|
2979
|
+
violations.push(violation9("medium", "lint.sdd-tdd.missing-tests", "task report carries no test file reference — the TDD triple needs covering test file(s) (mstar-coding-behavior § Integration Notes; mstar-sdd/references/file-handoffs.md)", 'add a "Covering test file(s): <path>.test.ts" line or a `.test.<ext>` path to the report'));
|
|
2980
|
+
}
|
|
2981
|
+
if (!hasCommand) {
|
|
2982
|
+
violations.push(violation9("medium", "lint.sdd-tdd.missing-command", "task report carries no command — the TDD triple needs the exact command run (mstar-coding-behavior § Integration Notes; mstar-sdd/references/file-handoffs.md)", 'add a "Command run: `bun test <file>`" line to the report'));
|
|
2983
|
+
}
|
|
2984
|
+
if (!hasOutput) {
|
|
2985
|
+
violations.push(violation9("medium", "lint.sdd-tdd.missing-output", "task report carries no output evidence — the TDD triple needs the run output (pass/fail counts or exit code) (mstar-coding-behavior § Integration Notes; mstar-sdd/references/file-handoffs.md)", 'paste the test-run output (e.g. "12 pass / 0 fail") into the report'));
|
|
2986
|
+
}
|
|
2987
|
+
return { ok: violations.length === 0, violations };
|
|
2988
|
+
}
|
|
2989
|
+
var PLACEHOLDER_TOKEN_RE = /\b(TBDs?|TODOs?|TBAs?)\b/gi;
|
|
2990
|
+
var ELLIPSIS_RE = /\.\.\./;
|
|
2991
|
+
var NEGATION_RE = /\b(?:no|not|without|none)\b/i;
|
|
2992
|
+
function stripInlineCode(line) {
|
|
2993
|
+
return line.replace(/`[^`]*`/g, " ");
|
|
2994
|
+
}
|
|
2995
|
+
function planQualityBar(planText) {
|
|
2996
|
+
const findings = [];
|
|
2997
|
+
const violations = [];
|
|
2998
|
+
const lines = planText.split(/\r?\n/);
|
|
2999
|
+
let inFence = false;
|
|
3000
|
+
for (let i = 0;i < lines.length; i++) {
|
|
3001
|
+
const trimmed = lines[i].trim();
|
|
3002
|
+
if (/^```/.test(trimmed) || /^~~~/.test(trimmed)) {
|
|
3003
|
+
inFence = !inFence;
|
|
3004
|
+
continue;
|
|
3005
|
+
}
|
|
3006
|
+
if (inFence)
|
|
3007
|
+
continue;
|
|
3008
|
+
const stripped = stripInlineCode(lines[i]);
|
|
3009
|
+
const segmentStartBefore = (index) => Math.max(stripped.lastIndexOf("(", index - 1), stripped.lastIndexOf("[", index - 1), stripped.lastIndexOf("{", index - 1), stripped.lastIndexOf(".", index - 1), stripped.lastIndexOf(";", index - 1), stripped.lastIndexOf(",", index - 1));
|
|
3010
|
+
let token = null;
|
|
3011
|
+
for (const wordMatch of stripped.matchAll(PLACEHOLDER_TOKEN_RE)) {
|
|
3012
|
+
if (wordMatch.index === undefined)
|
|
3013
|
+
continue;
|
|
3014
|
+
const negated = NEGATION_RE.test(stripped.slice(segmentStartBefore(wordMatch.index) + 1, wordMatch.index));
|
|
3015
|
+
if (!negated) {
|
|
3016
|
+
token = wordMatch[0].replace(/s$/i, "").toUpperCase();
|
|
3017
|
+
break;
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
if (token === null && ELLIPSIS_RE.test(stripped)) {
|
|
3021
|
+
token = "...";
|
|
3022
|
+
}
|
|
3023
|
+
if (token !== null) {
|
|
3024
|
+
const text = trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
|
|
3025
|
+
findings.push({ token, line: i + 1, text });
|
|
3026
|
+
violations.push(violation9("medium", "lint.plan-quality.placeholder", `placeholder token "${token}" at line ${i + 1}: "${text}"`, "replace the placeholder with concrete content before locking the plan (mstar-plan-artifacts/references/plan-quality-bar.md; templates/plan.main.md placeholder scan)"));
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
return { ok: violations.length === 0, violations, findings };
|
|
3030
|
+
}
|
|
3031
|
+
var WORKFLOW_VERB_START_RE = /^(?:explains?|describes?|covers?|provides?|walks?|guides?|shows?|lists?|details?|demonstrates?|outlines?|teaches?|summarizes?)\b/i;
|
|
3032
|
+
var PRONOUN_RE = /\bI\b(?!\/)|\b(?:we|you|my|our|your|us)\b/gi;
|
|
3033
|
+
var DESCRIPTION_MAX_WORDS = 120;
|
|
3034
|
+
function lintSkillFrontmatter(frontmatterText) {
|
|
3035
|
+
const violations = [];
|
|
3036
|
+
const fm = parseFrontmatter(frontmatterText);
|
|
3037
|
+
if (fm === null) {
|
|
3038
|
+
violations.push(violation9("medium", "lint.frontmatter.missing", "no YAML frontmatter block found — a skill file must open with a `---` fenced frontmatter (mstar-skill-authoring § Frontmatter Contract)", "add a frontmatter block with `name` and `description` at the top of the file"));
|
|
3039
|
+
return { ok: false, violations };
|
|
3040
|
+
}
|
|
3041
|
+
const name = fm.name ?? "";
|
|
3042
|
+
if (name === "") {
|
|
3043
|
+
violations.push(violation9("medium", "lint.frontmatter.name.missing", "frontmatter `name` is missing — required (mstar-skill-authoring § Frontmatter Contract)", "add `name: <lowercase-hyphen-id>` to the frontmatter"));
|
|
3044
|
+
} else if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
|
3045
|
+
violations.push(violation9("medium", "lint.frontmatter.name.format", `frontmatter \`name\` must be lowercase-hyphen ("${name}") — e.g. example-skill (mstar-skill-authoring § Frontmatter Contract)`, "rename to a stable lowercase-hyphen id, e.g. `name: example-skill`"));
|
|
3046
|
+
}
|
|
3047
|
+
const description = fm.description ?? "";
|
|
3048
|
+
if (description === "") {
|
|
3049
|
+
violations.push(violation9("medium", "lint.frontmatter.description.missing", "frontmatter `description` is missing — the trigger contract is required (mstar-skill-authoring § Frontmatter Contract)", "add a `description:` that states when the skill loads (symptoms, context, roles, exclusions)"));
|
|
3050
|
+
} else {
|
|
3051
|
+
const stripped = description.replace(/`[^`]*`/g, " ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
|
|
3052
|
+
let pronoun = null;
|
|
3053
|
+
for (const m of stripped.matchAll(PRONOUN_RE)) {
|
|
3054
|
+
if (m[0] === "US")
|
|
3055
|
+
continue;
|
|
3056
|
+
pronoun = m;
|
|
3057
|
+
break;
|
|
3058
|
+
}
|
|
3059
|
+
if (pronoun !== null) {
|
|
3060
|
+
violations.push(violation9("low", "lint.frontmatter.description.person", `description uses first/second-person pronoun "${pronoun[0]}" — keep the trigger contract third person (mstar-skill-authoring § Frontmatter Contract)`, 'rewrite without I/we/you/my/our/your/us, e.g. "Use when the user asks …"'));
|
|
3061
|
+
}
|
|
3062
|
+
const start = description.trim().replace(/^[*_#>]+/, "").replace(/^["'`]+/, "").trim();
|
|
3063
|
+
if (WORKFLOW_VERB_START_RE.test(start)) {
|
|
3064
|
+
violations.push(violation9("low", "lint.frontmatter.description.workflow", 'description reads as a workflow summary ("Explains/Describes/Covers …") — the description is the trigger contract, not a summary of steps (mstar-skill-authoring § Frontmatter Contract)', "describe when to load the skill (symptoms, context, roles, exclusions) instead of summarizing its steps"));
|
|
3065
|
+
} else {
|
|
3066
|
+
const words = description.trim().split(/\s+/).filter(Boolean).length;
|
|
3067
|
+
if (words > DESCRIPTION_MAX_WORDS) {
|
|
3068
|
+
violations.push(violation9("low", "lint.frontmatter.description.workflow", `description is ${words} words — paragraph-length summaries bury the trigger contract (threshold ${DESCRIPTION_MAX_WORDS}, above the longest corpus description at 114 words, mstar-design-md; mstar-skill-authoring § Frontmatter Contract)`, "trim the description to a scannable trigger contract and move detail into the body"));
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
return { ok: violations.length === 0, violations };
|
|
3073
|
+
}
|
|
3074
|
+
function parseFrontmatter(text) {
|
|
3075
|
+
const body = text.replace(/^\uFEFF/, "").replace(/^\s*/, "");
|
|
3076
|
+
const lines = body.split(/\r?\n/);
|
|
3077
|
+
const fields = {};
|
|
3078
|
+
let inBlock = body.startsWith("---");
|
|
3079
|
+
for (let i = inBlock ? 1 : 0;i < lines.length; i++) {
|
|
3080
|
+
const line = lines[i];
|
|
3081
|
+
if (inBlock && line.trim() === "---")
|
|
3082
|
+
break;
|
|
3083
|
+
const keyMatch = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
|
|
3084
|
+
if (keyMatch) {
|
|
3085
|
+
fields[keyMatch[1].toLowerCase()] = keyMatch[2].trim().replace(/^["']|["']$/g, "");
|
|
3086
|
+
} else if (inBlock && fields.description !== undefined) {
|
|
3087
|
+
fields.description = `${fields.description} ${line.trim()}`.trim();
|
|
3088
|
+
} else if (!inBlock && i >= 10) {
|
|
3089
|
+
break;
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
if (Object.keys(fields).length === 0)
|
|
3093
|
+
return null;
|
|
3094
|
+
return fields;
|
|
3095
|
+
}
|
|
3096
|
+
var REQUIRED_STRATEGY_SECTIONS = [
|
|
3097
|
+
"Vision",
|
|
3098
|
+
"What we build",
|
|
3099
|
+
"What we don't build",
|
|
3100
|
+
"Guiding Principles",
|
|
3101
|
+
"Technology Direction",
|
|
3102
|
+
"Decision Log"
|
|
3103
|
+
];
|
|
3104
|
+
function lintStrategySections(docText) {
|
|
3105
|
+
const violations = [];
|
|
3106
|
+
const headings = new Set;
|
|
3107
|
+
for (const line of docText.split(/\r?\n/)) {
|
|
3108
|
+
const match = /^#{1,6}\s+(.+)$/.exec(line.trim());
|
|
3109
|
+
if (!match)
|
|
3110
|
+
continue;
|
|
3111
|
+
headings.add(match[1].replace(/[*_`]/g, "").trim().toLowerCase());
|
|
3112
|
+
}
|
|
3113
|
+
for (const required of REQUIRED_STRATEGY_SECTIONS) {
|
|
3114
|
+
if (!headings.has(required.toLowerCase())) {
|
|
3115
|
+
violations.push(violation9("medium", "lint.strategy.missing-section", `missing required section "${required}" (mstar-strategy § STRATEGY.md structure)`, "add a `## <Section>` heading; required: Vision, What we build, What we don't build, Guiding Principles, Technology Direction, Decision Log"));
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
return { ok: violations.length === 0, violations };
|
|
3119
|
+
}
|
|
3120
|
+
// src/roles.ts
|
|
3121
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
3122
|
+
import { join as join9 } from "node:path";
|
|
3123
|
+
function violation10(severity, code, message, fix) {
|
|
3124
|
+
return { ok: false, severity, code, message, fix };
|
|
3125
|
+
}
|
|
3126
|
+
var ROLE_MAPPING = [
|
|
3127
|
+
{ agentId: "project-manager", reference: "references/project-manager.md" },
|
|
3128
|
+
{ agentId: "product-manager", reference: "references/product-manager.md" },
|
|
3129
|
+
{ agentId: "architect", reference: "references/architect.md" },
|
|
3130
|
+
{ agentId: "fullstack-dev", reference: "references/fullstack-dev-shared.md" },
|
|
3131
|
+
{ agentId: "fullstack-dev-2", reference: "references/fullstack-dev-shared.md" },
|
|
3132
|
+
{ agentId: "frontend-dev", reference: "references/frontend-dev.md" },
|
|
3133
|
+
{ agentId: "qa-engineer", reference: "references/qa-engineer.md" },
|
|
3134
|
+
{ agentId: "qc-specialist", reference: "references/qc-specialist-shared.md" },
|
|
3135
|
+
{ agentId: "qc-specialist-2", reference: "references/qc-specialist-shared.md" },
|
|
3136
|
+
{ agentId: "qc-specialist-3", reference: "references/qc-specialist-shared.md" },
|
|
3137
|
+
{ agentId: "ops-engineer", reference: "references/ops-engineer.md" },
|
|
3138
|
+
{ agentId: "writing-specialist", reference: "references/writing-specialist.md" },
|
|
3139
|
+
{ agentId: "prompt-engineer", reference: "references/prompt-engineer.md" }
|
|
3140
|
+
];
|
|
3141
|
+
var SHARED_FAMILIES = [
|
|
3142
|
+
{ family: "fullstack-dev", memberIds: ["fullstack-dev", "fullstack-dev-2"] },
|
|
3143
|
+
{ family: "qc-specialist", memberIds: ["qc-specialist", "qc-specialist-2", "qc-specialist-3"] }
|
|
3144
|
+
];
|
|
3145
|
+
var DEV_TRACK_PARAMS = [
|
|
3146
|
+
{ roleId: "fullstack-dev", track: "primary" },
|
|
3147
|
+
{ roleId: "fullstack-dev-2", track: "parallel_secondary" }
|
|
3148
|
+
];
|
|
3149
|
+
var QC_REVIEWER_PARAMS = [
|
|
3150
|
+
{
|
|
3151
|
+
roleId: "qc-specialist",
|
|
3152
|
+
reviewerIndex: 1,
|
|
3153
|
+
focus: "Architecture coherence and maintainability risk",
|
|
3154
|
+
reportSuffix: "qc1"
|
|
3155
|
+
},
|
|
3156
|
+
{
|
|
3157
|
+
roleId: "qc-specialist-2",
|
|
3158
|
+
reviewerIndex: 2,
|
|
3159
|
+
focus: "Security and correctness risk",
|
|
3160
|
+
reportSuffix: "qc2"
|
|
3161
|
+
},
|
|
3162
|
+
{
|
|
3163
|
+
roleId: "qc-specialist-3",
|
|
3164
|
+
reviewerIndex: 3,
|
|
3165
|
+
focus: "Performance and reliability risk",
|
|
3166
|
+
reportSuffix: "qc3"
|
|
3167
|
+
}
|
|
3168
|
+
];
|
|
3169
|
+
function validateRoleMapping(rolesDir, options = {}) {
|
|
3170
|
+
const mapping = options.mapping ?? ROLE_MAPPING;
|
|
3171
|
+
const families = options.families ?? SHARED_FAMILIES;
|
|
3172
|
+
const devTrack = options.devTrack ?? DEV_TRACK_PARAMS;
|
|
3173
|
+
const qcReviewers = options.qcReviewers ?? QC_REVIEWER_PARAMS;
|
|
3174
|
+
const violations = [];
|
|
3175
|
+
const referenceById = new Map(mapping.map((m) => [m.agentId, m.reference]));
|
|
3176
|
+
for (const { agentId, reference } of mapping) {
|
|
3177
|
+
if (!existsSync6(join9(rolesDir, reference))) {
|
|
3178
|
+
violations.push(violation10("medium", "roles.mapping.reference.missing", `role "${agentId}" maps to ${reference} which does not exist under ${rolesDir} (mstar-roles § Role Reference Mapping)`, `create ${join9(rolesDir, reference)} or fix the mapping row`));
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
for (const { family, memberIds } of families) {
|
|
3182
|
+
const absent = memberIds.filter((id) => !referenceById.has(id));
|
|
3183
|
+
for (const id of absent) {
|
|
3184
|
+
violations.push(violation10("medium", "roles.mapping.family.member.missing", `shared family "${family}" member "${id}" is absent from the role mapping (mstar-roles § Role Reference Mapping)`, `add "${id}" to the mapping`));
|
|
3185
|
+
}
|
|
3186
|
+
if (absent.length === 0) {
|
|
3187
|
+
const refs = new Set(memberIds.map((id) => referenceById.get(id)));
|
|
3188
|
+
if (refs.size !== 1) {
|
|
3189
|
+
violations.push(violation10("medium", "roles.mapping.family.shared", `shared family "${family}" (${memberIds.join(", ")}) must resolve to ONE shared reference file — got ${[...refs].join(", ")} (mstar-roles § Maintenance Rules: "Keep shared-family roles on one shared reference file")`, `point every "${family}" member at the same references/<role>-shared.md`));
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
const tableByRole = new Map;
|
|
3194
|
+
const checkParamRoles = (rows, table) => {
|
|
3195
|
+
for (const row of rows) {
|
|
3196
|
+
const existing = tableByRole.get(row.roleId);
|
|
3197
|
+
if (existing !== undefined) {
|
|
3198
|
+
violations.push(violation10("medium", "roles.param.role.duplicate", `role "${row.roleId}" appears in both the ${existing} and ${table} parameter rows (mstar-roles § Parameter Table (SSOT))`, "remove the duplicate row"));
|
|
3199
|
+
} else {
|
|
3200
|
+
tableByRole.set(row.roleId, table);
|
|
3201
|
+
}
|
|
3202
|
+
if (!referenceById.has(row.roleId)) {
|
|
3203
|
+
violations.push(violation10("medium", "roles.param.role.missing", `${table} parameter row references unknown role "${row.roleId}" (mstar-roles § Parameter Table (SSOT))`, `add "${row.roleId}" to the role mapping or drop the row`));
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
};
|
|
3207
|
+
checkParamRoles(devTrack, "dev track");
|
|
3208
|
+
checkParamRoles(qcReviewers, "QC reviewer");
|
|
3209
|
+
for (const row of devTrack) {
|
|
3210
|
+
if (row.track !== "primary" && row.track !== "parallel_secondary") {
|
|
3211
|
+
violations.push(violation10("medium", "roles.param.track", `dev track for "${row.roleId}" is "${String(row.track)}" — must be primary or parallel_secondary (mstar-roles § Parameter Table (SSOT))`, 'set track to "primary" or "parallel_secondary"'));
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
const indices = qcReviewers.map((r) => r.reviewerIndex).sort((a, b) => a - b);
|
|
3215
|
+
const unique = new Set(indices);
|
|
3216
|
+
if (indices.length !== 3 || unique.size !== 3 || indices[0] !== 1 || indices[1] !== 2 || indices[2] !== 3) {
|
|
3217
|
+
violations.push(violation10("high", "roles.param.qc.index.set", `QC reviewer_index must be exactly {1, 2, 3} across the three qc-specialist* seats — got [${indices.join(", ")}] (mstar-roles § Parameter Table (SSOT))`, "assign reviewer_index 1/2/3 to qc-specialist / qc-specialist-2 / qc-specialist-3"));
|
|
3218
|
+
}
|
|
3219
|
+
for (const row of qcReviewers) {
|
|
3220
|
+
if (row.focus.trim() === "") {
|
|
3221
|
+
violations.push(violation10("medium", "roles.param.qc.focus.missing", `QC seat "${row.roleId}" (reviewer_index ${row.reviewerIndex}) has an empty focus (mstar-roles § Parameter Table (SSOT))`, "add the review focus"));
|
|
3222
|
+
}
|
|
3223
|
+
if (row.reportSuffix !== `qc${row.reviewerIndex}`) {
|
|
3224
|
+
violations.push(violation10("medium", "roles.param.qc.suffix", `QC seat "${row.roleId}" report_suffix "${row.reportSuffix}" must equal qc${row.reviewerIndex} — tri reports land at {SDD_DIR}/review/qc1.md…qc3.md (mstar-roles § Parameter Table (SSOT))`, `set report_suffix to qc${row.reviewerIndex}`));
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
return { ok: violations.length === 0, violations };
|
|
3228
|
+
}
|
|
3229
|
+
var LOAD_ORDER_HEADING_RE = /^#{1,6}\s+[^\r\n]*\b(?:load[\s-]*order|first\s+action)\b[^\r\n]*$/i;
|
|
3230
|
+
function extractLoadOrderSection(text) {
|
|
3231
|
+
const lines = text.split(/\r?\n/);
|
|
3232
|
+
let start = -1;
|
|
3233
|
+
let level = 0;
|
|
3234
|
+
for (let i = 0;i < lines.length; i++) {
|
|
3235
|
+
const m = /^(#{1,6})\s+/.exec(lines[i]);
|
|
3236
|
+
if (m === null)
|
|
3237
|
+
continue;
|
|
3238
|
+
if (LOAD_ORDER_HEADING_RE.test(lines[i])) {
|
|
3239
|
+
start = i;
|
|
3240
|
+
level = m[1].length;
|
|
3241
|
+
break;
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
if (start === -1)
|
|
3245
|
+
return null;
|
|
3246
|
+
const section = [lines[start]];
|
|
3247
|
+
for (let i = start + 1;i < lines.length; i++) {
|
|
3248
|
+
const m = /^(#{1,6})\s+/.exec(lines[i]);
|
|
3249
|
+
if (m !== null && m[1].length <= level)
|
|
3250
|
+
break;
|
|
3251
|
+
section.push(lines[i]);
|
|
3252
|
+
}
|
|
3253
|
+
return section.join(`
|
|
3254
|
+
`);
|
|
3255
|
+
}
|
|
3256
|
+
function lintLoadOrder(skillTexts) {
|
|
3257
|
+
const violations = [];
|
|
3258
|
+
for (const [name, text] of Object.entries(skillTexts)) {
|
|
3259
|
+
if (!name.startsWith("mstar-") || name === "mstar-harness-core")
|
|
3260
|
+
continue;
|
|
3261
|
+
const section = extractLoadOrderSection(text);
|
|
3262
|
+
if (section === null) {
|
|
3263
|
+
violations.push(violation10("medium", "roles.loadorder.section.missing", `skill "${name}" has no Load Order / First action section — every mstar-* topic skill must declare its first read (mstar-harness-core § 加载约定; mstar-roles § Load Order (Required))`, `add a "## Load Order" section naming mstar-harness-core as the first read`));
|
|
3264
|
+
continue;
|
|
3265
|
+
}
|
|
3266
|
+
if (!section.includes("mstar-harness-core")) {
|
|
3267
|
+
violations.push(violation10("medium", "roles.loadorder.core.missing", `skill "${name}" Load Order section does not declare mstar-harness-core as its first dependency (mstar-harness-core § 加载约定: 凡 mstar-*(name ≠ mstar-harness-core)假定读者已 Read 本 skill)`, "name mstar-harness-core first in the Load Order section"));
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
return { ok: violations.length === 0, violations };
|
|
3271
|
+
}
|
|
3272
|
+
// src/host.ts
|
|
3273
|
+
function detectHost(signals) {
|
|
3274
|
+
const s = new Set(signals);
|
|
3275
|
+
if (s.has("subagent_type"))
|
|
3276
|
+
return "cursor";
|
|
3277
|
+
if (s.has("question") || s.has("task_subagent"))
|
|
3278
|
+
return "opencode";
|
|
3279
|
+
if (s.has("task_agent_batch") || s.has("ask") || s.has("hub"))
|
|
3280
|
+
return "omp";
|
|
3281
|
+
if (s.has("AgentSwarm"))
|
|
3282
|
+
return "kimi";
|
|
3283
|
+
if (s.has("Agent") || s.has("AskUserQuestion") || s.has("EnterPlanMode") || s.has("TodoWrite"))
|
|
3284
|
+
return "zcode";
|
|
3285
|
+
if (s.has("plan_slash") || s.has("goal") || s.has("functions.*") || s.has("tool_search"))
|
|
3286
|
+
return "codex";
|
|
3287
|
+
return "ambiguous";
|
|
3288
|
+
}
|
|
3289
|
+
function resolveSkillRoot(host, paths) {
|
|
3290
|
+
const { skill, rel } = paths;
|
|
3291
|
+
const suffix = rel === undefined || rel === "" ? "" : `/${rel}`;
|
|
3292
|
+
switch (host) {
|
|
3293
|
+
case "omp":
|
|
3294
|
+
return `skill://${skill}${suffix}`;
|
|
3295
|
+
case "cursor":
|
|
3296
|
+
return `~/.cursor/plugins/local/morning-star-harness/skills/${skill}${suffix}`;
|
|
3297
|
+
case "codex":
|
|
3298
|
+
return `skills/${skill}${suffix}`;
|
|
3299
|
+
case "opencode":
|
|
3300
|
+
return `harness-skills/${skill}${suffix}`;
|
|
3301
|
+
case "kimi":
|
|
3302
|
+
case "zcode":
|
|
3303
|
+
return `./skills/${skill}${suffix}`;
|
|
3304
|
+
case "pi":
|
|
3305
|
+
case "dsh":
|
|
3306
|
+
return `deferred: ${host} has no plugin API in v1 — skill-root resolution lands with its adapter (roadmap §8.4)`;
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
// src/skill-authoring.ts
|
|
3310
|
+
function violation11(severity, code, message, fix) {
|
|
3311
|
+
return { ok: false, severity, code, message, fix };
|
|
3312
|
+
}
|
|
3313
|
+
var FIVE_QUESTION_SECTIONS = [
|
|
3314
|
+
{ key: "load-order", label: "Load Order", question: "when to load the skill (triggers / exclusions)" },
|
|
3315
|
+
{ key: "workflow", label: "Workflow", question: "the order of execution and key decision points" },
|
|
3316
|
+
{ key: "decision-rules", label: "Decision Rules", question: "constraints / invariants that must never be violated" },
|
|
3317
|
+
{ key: "evidence", label: "Evidence", question: "what a correct result looks like (success criteria / evidence)" },
|
|
3318
|
+
{ key: "references", label: "References", question: "additional resources to open when the main path is not enough" }
|
|
3319
|
+
];
|
|
3320
|
+
var HEADING_RE = /^#{1,6}\s+[^\r\n]+$/;
|
|
3321
|
+
function lintFiveQuestion(bodyText) {
|
|
3322
|
+
const headings = bodyText.split(/\r?\n/).filter((line) => HEADING_RE.test(line)).map((line) => line.replace(/^#{1,6}\s+/, "").trim().toLowerCase());
|
|
3323
|
+
const violations = [];
|
|
3324
|
+
for (const section of FIVE_QUESTION_SECTIONS) {
|
|
3325
|
+
const label = section.label.toLowerCase();
|
|
3326
|
+
const covered = headings.some((heading) => heading.includes(label));
|
|
3327
|
+
if (!covered) {
|
|
3328
|
+
violations.push(violation11("low", `skill-authoring.five-question.${section.key}`, `body does not answer "${section.question}" — no "${section.label}" section (mstar-skill-authoring § Body 必须回答的 5 问 / § 默认 Body 结构)`, `add a "## ${section.label}" section covering ${section.question}`));
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
return { ok: violations.length === 0, violations };
|
|
3332
|
+
}
|
|
3333
|
+
function resolveAssetPath(skillName, relPath, host) {
|
|
3334
|
+
return `skill \`${skillName}\` → ${relPath} (${resolveSkillRoot(host, { skill: skillName, rel: relPath })})`;
|
|
3335
|
+
}
|
|
3336
|
+
export {
|
|
3337
|
+
writeJson,
|
|
3338
|
+
withStatusWriteLock,
|
|
3339
|
+
verifyPlanExecutionLease,
|
|
3340
|
+
validateStatus,
|
|
3341
|
+
validateSchemaYaml,
|
|
3342
|
+
validateRoleMapping,
|
|
3343
|
+
validateResidual,
|
|
3344
|
+
validatePlanRow,
|
|
3345
|
+
validateIntegrationMergeLease,
|
|
3346
|
+
validateGitignore,
|
|
3347
|
+
validateExecutionLease,
|
|
3348
|
+
validateDesignTokenFrontmatter,
|
|
3349
|
+
validateCompassFrontmatter,
|
|
3350
|
+
validateAuditStatusBlocks,
|
|
3351
|
+
validateAssignmentFields,
|
|
3352
|
+
techDebtRollup,
|
|
3353
|
+
taskReportExists,
|
|
3354
|
+
taskBrief,
|
|
3355
|
+
singleReviewSnapshot,
|
|
3356
|
+
sddWorkspace,
|
|
3357
|
+
scopeGuard,
|
|
3358
|
+
scaffoldHarness,
|
|
3359
|
+
scaffoldAuditPlan,
|
|
3360
|
+
sameHolderResume,
|
|
3361
|
+
reviewPackage,
|
|
3362
|
+
resolveSpecsDir,
|
|
3363
|
+
resolveSkillRoot,
|
|
3364
|
+
resolveSddDir,
|
|
3365
|
+
resolveProjectRoot,
|
|
3366
|
+
resolvePlanDir,
|
|
3367
|
+
resolveIterationDir,
|
|
3368
|
+
resolveHarnessDir,
|
|
3369
|
+
resolveCompassEnforcement,
|
|
3370
|
+
resolveAssetPath,
|
|
3371
|
+
releaseLease,
|
|
3372
|
+
referenceExists,
|
|
3373
|
+
redactSecrets,
|
|
3374
|
+
readProgressLedger,
|
|
3375
|
+
readJson,
|
|
3376
|
+
readHarnessVersion,
|
|
3377
|
+
pushCadenceProbe,
|
|
3378
|
+
planQualityBar,
|
|
3379
|
+
planExecutionLeaseLocations,
|
|
3380
|
+
parseEnforcementFlag,
|
|
3381
|
+
parseDesignFrontmatter,
|
|
3382
|
+
parseBranchPolicyDirectOnBranch,
|
|
3383
|
+
parseAssignmentFields,
|
|
3384
|
+
parseAssignmentBranchForms,
|
|
3385
|
+
normalizeSeverity,
|
|
3386
|
+
lintStrategySections,
|
|
3387
|
+
lintSkillFrontmatter,
|
|
3388
|
+
lintLoadOrder,
|
|
3389
|
+
lintSkillFrontmatter as lintFrontmatter,
|
|
3390
|
+
lintFiveQuestion,
|
|
3391
|
+
l2PreDispatchCheck,
|
|
3392
|
+
l1PreDispatchCheck,
|
|
3393
|
+
isReadOnlyAssignmentRole,
|
|
3394
|
+
implementerSessionStickyRules,
|
|
3395
|
+
findingsCleanupGate,
|
|
3396
|
+
findTemporaryMarkers,
|
|
3397
|
+
findSimplifyMarkers,
|
|
3398
|
+
executionModeToN,
|
|
3399
|
+
evaluatePhaseGate,
|
|
3400
|
+
emitGitignoreSnippet,
|
|
3401
|
+
detectHost,
|
|
3402
|
+
compoundRefreshScope,
|
|
3403
|
+
completenessLevel,
|
|
3404
|
+
claimLease,
|
|
3405
|
+
canSteal,
|
|
3406
|
+
assignmentHeaderRegion,
|
|
3407
|
+
assertTriIdentity,
|
|
3408
|
+
assertSddTddTriple,
|
|
3409
|
+
assertQcAlignment,
|
|
3410
|
+
assertPlanWritingPath,
|
|
3411
|
+
assertLightDarkParity,
|
|
3412
|
+
assertIndexRows,
|
|
3413
|
+
assertIndexRowObligations,
|
|
3414
|
+
assertDefaultBranchProtected,
|
|
3415
|
+
assertControlVsFeaturePath,
|
|
3416
|
+
assertBranchAlignment,
|
|
3417
|
+
assertBaseSha,
|
|
3418
|
+
archiveResiduals,
|
|
3419
|
+
applyEnforcement,
|
|
3420
|
+
antiRecursionPrecheck,
|
|
3421
|
+
SddScriptError,
|
|
3422
|
+
SHARED_FAMILIES,
|
|
3423
|
+
SEVERITY_ORDER,
|
|
3424
|
+
ROLE_MAPPING,
|
|
3425
|
+
QC_REVIEWER_PARAMS,
|
|
3426
|
+
KNOWLEDGE_SEVERITIES,
|
|
3427
|
+
KNOWLEDGE_RESOLUTION_TYPES,
|
|
3428
|
+
KNOWLEDGE_REQUIRED_FIELDS,
|
|
3429
|
+
KNOWLEDGE_PROBLEM_TYPES,
|
|
3430
|
+
KNOWLEDGE_KNOWLEDGE_PROBLEM_TYPES,
|
|
3431
|
+
KNOWLEDGE_CATEGORY_MAP,
|
|
3432
|
+
KNOWLEDGE_BUG_PROBLEM_TYPES,
|
|
3433
|
+
FIVE_QUESTION_SECTIONS,
|
|
3434
|
+
DEV_TRACK_PARAMS,
|
|
3435
|
+
AUDIT_RISKS,
|
|
3436
|
+
AUDIT_PRIORITIES,
|
|
3437
|
+
AUDIT_EFFORTS,
|
|
3438
|
+
AUDIT_CATEGORIES
|
|
3439
|
+
};
|