@mstar-harness/engine 2.4.1 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core.d.ts +1 -1
- package/dist/dispatch.d.ts +1 -1
- package/dist/engine.js +1568 -515
- package/dist/host.d.ts +2 -2
- package/dist/index.d.ts +12 -4
- package/dist/iteration.d.ts +37 -6
- package/dist/lease.d.ts +26 -25
- package/dist/lint.d.ts +1 -2
- package/dist/migrate.d.ts +134 -0
- package/dist/mstarc.d.ts +72 -0
- package/dist/path.d.ts +61 -21
- package/dist/project.d.ts +134 -0
- package/dist/sdd.d.ts +6 -4
- package/dist/status.d.ts +93 -65
- package/dist/workflow.d.ts +78 -0
- package/package.json +1 -1
package/dist/engine.js
CHANGED
|
@@ -81,27 +81,118 @@ function harnessVersionFrom(moduleDir) {
|
|
|
81
81
|
function readHarnessVersion() {
|
|
82
82
|
return harnessVersionFrom(dirname(fileURLToPath(import.meta.url)));
|
|
83
83
|
}
|
|
84
|
+
// src/mstarc.ts
|
|
85
|
+
import { readFileSync as readFileSync2, statSync } from "node:fs";
|
|
86
|
+
import { dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
|
|
87
|
+
var MSTARC_FILE = ".mstarc";
|
|
88
|
+
var MSTARC_SECTION = "config";
|
|
89
|
+
var MSTARC_HARNESS_DIR_KEY = "harness_dir";
|
|
90
|
+
var MSTARC_PLAN_DIR_KEY = "plan_dir";
|
|
91
|
+
var MSTARC_SDD_DIR_KEY = "sdd_dir";
|
|
92
|
+
var MSTARC_ITERATION_DIR_KEY = "iteration_dir";
|
|
93
|
+
var MSTARC_KNOWLEDGE_DIR_KEY = "knowledge_dir";
|
|
94
|
+
var MSTARC_SPECS_DIR_KEY = "specs_dir";
|
|
95
|
+
var MSTARC_WORKFLOW_DIR_KEY = "workflow_dir";
|
|
96
|
+
var MSTARC_PROJECT_DIR_KEY = "project_dir";
|
|
97
|
+
var MSTARC_ENFORCEMENT_KEY = "enforcement";
|
|
98
|
+
var CONFIG_KEYS = {
|
|
99
|
+
[MSTARC_HARNESS_DIR_KEY]: "harnessDir",
|
|
100
|
+
[MSTARC_PLAN_DIR_KEY]: "planDir",
|
|
101
|
+
[MSTARC_SDD_DIR_KEY]: "sddDir",
|
|
102
|
+
[MSTARC_ITERATION_DIR_KEY]: "iterationDir",
|
|
103
|
+
[MSTARC_KNOWLEDGE_DIR_KEY]: "knowledgeDir",
|
|
104
|
+
[MSTARC_SPECS_DIR_KEY]: "specsDir",
|
|
105
|
+
[MSTARC_WORKFLOW_DIR_KEY]: "workflowDir",
|
|
106
|
+
[MSTARC_PROJECT_DIR_KEY]: "projectDir",
|
|
107
|
+
[MSTARC_ENFORCEMENT_KEY]: "enforcement"
|
|
108
|
+
};
|
|
109
|
+
function parseMstarc(text) {
|
|
110
|
+
let section = null;
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
113
|
+
const line = raw.trim();
|
|
114
|
+
if (line === "" || line.startsWith("#") || line.startsWith(";"))
|
|
115
|
+
continue;
|
|
116
|
+
const header = /^\[([^\]]+)\]$/.exec(line);
|
|
117
|
+
if (header !== null) {
|
|
118
|
+
section = header[1].trim();
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (section !== MSTARC_SECTION)
|
|
122
|
+
continue;
|
|
123
|
+
const eq = line.indexOf("=");
|
|
124
|
+
if (eq === -1)
|
|
125
|
+
continue;
|
|
126
|
+
const field = CONFIG_KEYS[line.slice(0, eq).trim()];
|
|
127
|
+
if (field === undefined)
|
|
128
|
+
continue;
|
|
129
|
+
const value = line.slice(eq + 1).trim();
|
|
130
|
+
if (value === "")
|
|
131
|
+
continue;
|
|
132
|
+
if (field === "enforcement" && value !== "hard" && value !== "soft")
|
|
133
|
+
continue;
|
|
134
|
+
out[field] = value;
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function isFile(file) {
|
|
139
|
+
try {
|
|
140
|
+
return statSync(file).isFile();
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function findMstarc(startDir, boundary) {
|
|
146
|
+
let dir = resolve2(startDir);
|
|
147
|
+
const bound = resolve2(boundary);
|
|
148
|
+
for (;; ) {
|
|
149
|
+
if (!isAtOrBelow(dir, bound))
|
|
150
|
+
return null;
|
|
151
|
+
const candidate = join2(dir, MSTARC_FILE);
|
|
152
|
+
if (isFile(candidate))
|
|
153
|
+
return candidate;
|
|
154
|
+
if (dir === bound)
|
|
155
|
+
return null;
|
|
156
|
+
const parent = dirname2(dir);
|
|
157
|
+
if (parent === dir)
|
|
158
|
+
return null;
|
|
159
|
+
dir = parent;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function loadMstarc(startDir, boundary) {
|
|
163
|
+
const file = findMstarc(startDir, boundary);
|
|
164
|
+
if (file === null)
|
|
165
|
+
return null;
|
|
166
|
+
return { file, dir: dirname2(file), config: parseMstarc(readFileSync2(file, "utf8")) };
|
|
167
|
+
}
|
|
168
|
+
function isAtOrBelow(dir, root) {
|
|
169
|
+
const rel = relative(root, dir);
|
|
170
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
171
|
+
}
|
|
84
172
|
// src/path.ts
|
|
85
|
-
import { mkdirSync as mkdirSync2, readdirSync, readFileSync as
|
|
173
|
+
import { mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
|
|
86
174
|
import { execFileSync } from "node:child_process";
|
|
87
|
-
import { basename as basename2, dirname as
|
|
175
|
+
import { basename as basename2, dirname as dirname3, isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve3 } from "node:path";
|
|
88
176
|
function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
|
|
89
|
-
const start =
|
|
177
|
+
const start = resolve3(startDir);
|
|
90
178
|
const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
|
|
91
179
|
if (explicit)
|
|
92
|
-
return
|
|
93
|
-
const boundary =
|
|
180
|
+
return resolve3(start, explicit);
|
|
181
|
+
const boundary = resolve3(start, opts.workspaceRoot ?? defaultWorkspaceRoot(start));
|
|
182
|
+
const rc = loadMstarc(start, boundary);
|
|
183
|
+
if (rc !== null && rc.config.harnessDir)
|
|
184
|
+
return resolve3(rc.dir, rc.config.harnessDir);
|
|
94
185
|
let dir = start;
|
|
95
186
|
for (;; ) {
|
|
96
|
-
if (!
|
|
187
|
+
if (!isAtOrBelow2(dir, boundary))
|
|
97
188
|
return null;
|
|
98
|
-
for (const candidate of [
|
|
189
|
+
for (const candidate of [join3(dir, ".mstar"), join3(dir, ".agents"), join3(dir, ".plans"), join3(dir, "plans")]) {
|
|
99
190
|
if (isDirectory(candidate))
|
|
100
191
|
return candidate;
|
|
101
192
|
}
|
|
102
193
|
if (dir === boundary)
|
|
103
194
|
return null;
|
|
104
|
-
const parent =
|
|
195
|
+
const parent = dirname3(dir);
|
|
105
196
|
if (parent === dir)
|
|
106
197
|
return null;
|
|
107
198
|
dir = parent;
|
|
@@ -119,41 +210,56 @@ function defaultWorkspaceRoot(startDir) {
|
|
|
119
210
|
let boundary = startDir;
|
|
120
211
|
for (const segment of cdup.split(/[\\/]/)) {
|
|
121
212
|
if (segment && segment !== ".")
|
|
122
|
-
boundary =
|
|
213
|
+
boundary = dirname3(boundary);
|
|
123
214
|
}
|
|
124
|
-
return
|
|
215
|
+
return resolve3(boundary);
|
|
125
216
|
} catch {}
|
|
126
217
|
return startDir;
|
|
127
218
|
}
|
|
128
|
-
function
|
|
129
|
-
const rel =
|
|
130
|
-
return rel === "" || !rel.startsWith("..") && !
|
|
219
|
+
function isAtOrBelow2(dir, root) {
|
|
220
|
+
const rel = relative2(root, dir);
|
|
221
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
|
|
222
|
+
}
|
|
223
|
+
function mstarcDirOverride(harnessDir, key) {
|
|
224
|
+
const dir = resolve3(harnessDir);
|
|
225
|
+
const rc = loadMstarc(dir, dirname3(dir));
|
|
226
|
+
const declared = rc?.config[key];
|
|
227
|
+
return declared ? resolve3(rc.dir, declared) : null;
|
|
131
228
|
}
|
|
132
229
|
function resolveSpecsDir(harnessDir, opts = {}) {
|
|
133
|
-
const
|
|
134
|
-
|
|
230
|
+
const declared = mstarcDirOverride(harnessDir, "specsDir");
|
|
231
|
+
if (declared !== null) {
|
|
232
|
+
if (opts.create !== false)
|
|
233
|
+
mkdirSync2(declared, { recursive: true });
|
|
234
|
+
return declared;
|
|
235
|
+
}
|
|
236
|
+
const harness = resolve3(harnessDir);
|
|
237
|
+
const repoRoot = dirname3(harness);
|
|
135
238
|
const candidates = [
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
239
|
+
join3(harness, "specs"),
|
|
240
|
+
join3(repoRoot, "docs", "specs"),
|
|
241
|
+
join3(repoRoot, "specs"),
|
|
242
|
+
join3(harness, "designs"),
|
|
243
|
+
join3(repoRoot, "designs")
|
|
141
244
|
];
|
|
142
245
|
for (const candidate of candidates) {
|
|
143
246
|
if (isDirectory(candidate) && hasFiles(candidate))
|
|
144
247
|
return candidate;
|
|
145
248
|
}
|
|
146
|
-
const fallback =
|
|
249
|
+
const fallback = join3(harness, "specs");
|
|
147
250
|
if (opts.create !== false)
|
|
148
251
|
mkdirSync2(fallback, { recursive: true });
|
|
149
252
|
return fallback;
|
|
150
253
|
}
|
|
151
254
|
function resolvePlanDir(harnessDir) {
|
|
152
|
-
const
|
|
255
|
+
const declared = mstarcDirOverride(harnessDir, "planDir");
|
|
256
|
+
if (declared !== null)
|
|
257
|
+
return declared;
|
|
258
|
+
const dir = resolve3(harnessDir);
|
|
153
259
|
const name = basename2(dir);
|
|
154
260
|
if (name === ".plans" || name === "plans")
|
|
155
261
|
return dir;
|
|
156
|
-
return
|
|
262
|
+
return join3(dir, "plans");
|
|
157
263
|
}
|
|
158
264
|
function assertSafePathComponent(value, what) {
|
|
159
265
|
if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
@@ -162,24 +268,48 @@ function assertSafePathComponent(value, what) {
|
|
|
162
268
|
}
|
|
163
269
|
function resolveSddDir(harnessDir, planId) {
|
|
164
270
|
assertSafePathComponent(planId, "planId");
|
|
165
|
-
|
|
271
|
+
const base = resolve3(harnessDir);
|
|
272
|
+
const declared = mstarcDirOverride(base, "sddDir");
|
|
273
|
+
const sddBase = declared !== null ? declared : join3(base, "sdd");
|
|
274
|
+
return join3(sddBase, planId);
|
|
166
275
|
}
|
|
167
276
|
function resolveIterationDir(harnessDir) {
|
|
168
|
-
|
|
277
|
+
const declared = mstarcDirOverride(harnessDir, "iterationDir");
|
|
278
|
+
if (declared !== null)
|
|
279
|
+
return declared;
|
|
280
|
+
return join3(resolve3(harnessDir), "iterations");
|
|
281
|
+
}
|
|
282
|
+
function resolveKnowledgeDir(harnessDir) {
|
|
283
|
+
const declared = mstarcDirOverride(harnessDir, "knowledgeDir");
|
|
284
|
+
if (declared !== null)
|
|
285
|
+
return declared;
|
|
286
|
+
return join3(resolve3(harnessDir), "knowledge");
|
|
287
|
+
}
|
|
288
|
+
function resolveHarnessSubdir(startDir, opts, key, fallback) {
|
|
289
|
+
const harness = resolveHarnessDir(startDir, opts);
|
|
290
|
+
if (harness === null) {
|
|
291
|
+
throw new Error(`harness dir not found from ${resolve3(startDir)} — cannot resolve the ${fallback} dir (run \`mstar init\`, pass opts.harnessDir, or set MSTAR_HARNESS_DIR)`);
|
|
292
|
+
}
|
|
293
|
+
const declared = mstarcDirOverride(harness, key);
|
|
294
|
+
return declared !== null ? declared : join3(resolve3(harness), fallback);
|
|
295
|
+
}
|
|
296
|
+
function resolveWorkflowDir(startDir = process.cwd(), opts = {}) {
|
|
297
|
+
return resolveHarnessSubdir(startDir, opts, "workflowDir", "workflows");
|
|
298
|
+
}
|
|
299
|
+
function resolveProjectDir(startDir = process.cwd(), opts = {}) {
|
|
300
|
+
return resolveHarnessSubdir(startDir, opts, "projectDir", "projects");
|
|
169
301
|
}
|
|
170
302
|
var EMPTY_STATUS_TEMPLATE = {
|
|
171
|
-
version:
|
|
303
|
+
version: 2,
|
|
172
304
|
updated_at: "1970-01-01",
|
|
173
|
-
|
|
174
|
-
residual_findings: {},
|
|
175
|
-
metadata: {}
|
|
305
|
+
workflows: []
|
|
176
306
|
};
|
|
177
307
|
var SCAFFOLD_DIRS = ["plans", "iterations", "knowledge", "specs", "sdd"];
|
|
178
308
|
function scaffoldHarness(root) {
|
|
179
|
-
const harnessDir =
|
|
309
|
+
const harnessDir = join3(resolve3(root), ".mstar");
|
|
180
310
|
for (const dir of SCAFFOLD_DIRS)
|
|
181
|
-
mkdirSync2(
|
|
182
|
-
const statusPath =
|
|
311
|
+
mkdirSync2(join3(harnessDir, dir), { recursive: true });
|
|
312
|
+
const statusPath = join3(harnessDir, "status.json");
|
|
183
313
|
if (Object.keys(readJson(statusPath)).length === 0)
|
|
184
314
|
writeJson(statusPath, EMPTY_STATUS_TEMPLATE);
|
|
185
315
|
return harnessDir;
|
|
@@ -193,6 +323,8 @@ var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
|
|
|
193
323
|
!.mstar/knowledge/**
|
|
194
324
|
!.mstar/specs/
|
|
195
325
|
!.mstar/specs/**
|
|
326
|
+
# .mstarc — repo-local harness config (may declare [config] harness_dir=<name>)
|
|
327
|
+
.mstarc
|
|
196
328
|
`;
|
|
197
329
|
var GITIGNORE_SNIPPET_AGENTS = `# Morning Star harness (.agents/) — legacy
|
|
198
330
|
# Default-ignore everything under .agents/, then re-include the tracked results.
|
|
@@ -215,11 +347,11 @@ function emitGitignoreSnippet(kind) {
|
|
|
215
347
|
return `${GITIGNORE_SNIPPET}${GITIGNORE_SNIPPET_AGENTS}`;
|
|
216
348
|
}
|
|
217
349
|
function validateGitignore(root) {
|
|
218
|
-
const gitignorePath =
|
|
350
|
+
const gitignorePath = join3(resolve3(root), ".gitignore");
|
|
219
351
|
const kind = detectHarnessKind(resolveHarnessDir(root));
|
|
220
352
|
let content;
|
|
221
353
|
try {
|
|
222
|
-
content =
|
|
354
|
+
content = readFileSync3(gitignorePath, "utf8");
|
|
223
355
|
} catch {
|
|
224
356
|
return {
|
|
225
357
|
ok: false,
|
|
@@ -263,7 +395,7 @@ function validateGitignore(root) {
|
|
|
263
395
|
function detectHarnessKind(harnessDir) {
|
|
264
396
|
if (!harnessDir)
|
|
265
397
|
return null;
|
|
266
|
-
const name = basename2(
|
|
398
|
+
const name = basename2(resolve3(harnessDir));
|
|
267
399
|
if (name === ".mstar")
|
|
268
400
|
return "mstar";
|
|
269
401
|
if (name === ".agents")
|
|
@@ -271,7 +403,7 @@ function detectHarnessKind(harnessDir) {
|
|
|
271
403
|
return null;
|
|
272
404
|
}
|
|
273
405
|
function assertPlanWritingPath(planPath, harnessDir) {
|
|
274
|
-
const planAbs =
|
|
406
|
+
const planAbs = resolve3(planPath);
|
|
275
407
|
if (!harnessDir) {
|
|
276
408
|
return {
|
|
277
409
|
ok: false,
|
|
@@ -282,8 +414,8 @@ function assertPlanWritingPath(planPath, harnessDir) {
|
|
|
282
414
|
};
|
|
283
415
|
}
|
|
284
416
|
const planDir = resolvePlanDir(harnessDir);
|
|
285
|
-
const rel =
|
|
286
|
-
const inside = rel === "" || !rel.startsWith("..") && !
|
|
417
|
+
const rel = relative2(planDir, planAbs);
|
|
418
|
+
const inside = rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
|
|
287
419
|
if (!inside) {
|
|
288
420
|
return {
|
|
289
421
|
ok: false,
|
|
@@ -302,7 +434,7 @@ function assertPlanWritingPath(planPath, harnessDir) {
|
|
|
302
434
|
}
|
|
303
435
|
function isDirectory(dir) {
|
|
304
436
|
try {
|
|
305
|
-
return
|
|
437
|
+
return statSync2(dir).isDirectory();
|
|
306
438
|
} catch {
|
|
307
439
|
return false;
|
|
308
440
|
}
|
|
@@ -311,7 +443,7 @@ function hasFiles(dir) {
|
|
|
311
443
|
try {
|
|
312
444
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
313
445
|
if (entry.isDirectory()) {
|
|
314
|
-
if (hasFiles(
|
|
446
|
+
if (hasFiles(join3(dir, entry.name)))
|
|
315
447
|
return true;
|
|
316
448
|
} else if (entry.isFile()) {
|
|
317
449
|
return true;
|
|
@@ -323,12 +455,12 @@ function hasFiles(dir) {
|
|
|
323
455
|
}
|
|
324
456
|
}
|
|
325
457
|
// src/status.ts
|
|
326
|
-
import { existsSync as existsSync2, readFileSync as
|
|
327
|
-
import { join as
|
|
458
|
+
import { existsSync as existsSync2, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync } from "node:fs";
|
|
459
|
+
import { dirname as dirname5, join as join6, resolve as resolve5, sep } from "node:path";
|
|
328
460
|
|
|
329
461
|
// src/lease.ts
|
|
330
|
-
import { mkdirSync as mkdirSync3, rmdirSync, statSync as
|
|
331
|
-
import { dirname as
|
|
462
|
+
import { mkdirSync as mkdirSync3, rmdirSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
463
|
+
import { dirname as dirname4, isAbsolute as isAbsolute3, join as join4, resolve as resolve4 } from "node:path";
|
|
332
464
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
333
465
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
334
466
|
function isPlainObject(value) {
|
|
@@ -370,7 +502,7 @@ function validateExecutionLease(lease) {
|
|
|
370
502
|
violations.push(violation("high", "lease.execution-lease.missing-worktree-path", "missing required field: worktree_path"));
|
|
371
503
|
} else if (typeof lease.worktree_path !== "string" || lease.worktree_path.trim() === "") {
|
|
372
504
|
violations.push(violation("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be a non-empty string"));
|
|
373
|
-
} else if (!
|
|
505
|
+
} else if (!isAbsolute3(lease.worktree_path)) {
|
|
374
506
|
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)"));
|
|
375
507
|
}
|
|
376
508
|
validateNonEmptyString(violations, lease.working_branch, "working_branch", "lease.execution-lease.missing-working-branch", "lease.execution-lease.invalid-working-branch");
|
|
@@ -500,13 +632,11 @@ function canSteal(lease, holder, opts = {}) {
|
|
|
500
632
|
return opts.userOverride === true;
|
|
501
633
|
}
|
|
502
634
|
function planExecutionLeaseLocations(row) {
|
|
503
|
-
|
|
504
|
-
const metadataLease = meta && typeof meta === "object" && !Array.isArray(meta) ? meta.execution_lease : undefined;
|
|
505
|
-
return { row: row.execution_lease, metadata: metadataLease };
|
|
635
|
+
return { row: row.execution_lease };
|
|
506
636
|
}
|
|
507
637
|
function verifyPlanExecutionLease(row, planId) {
|
|
508
|
-
const { row: rowLease
|
|
509
|
-
const lease = rowLease
|
|
638
|
+
const { row: rowLease } = planExecutionLeaseLocations(row);
|
|
639
|
+
const lease = rowLease;
|
|
510
640
|
if (lease === undefined) {
|
|
511
641
|
if (row.status === "InProgress") {
|
|
512
642
|
return {
|
|
@@ -519,16 +649,11 @@ function verifyPlanExecutionLease(row, planId) {
|
|
|
519
649
|
return {
|
|
520
650
|
ok: false,
|
|
521
651
|
violations: [
|
|
522
|
-
violation("high", "lease.verify.missing", `plan ${planId} has no execution_lease
|
|
652
|
+
violation("high", "lease.verify.missing", `plan ${planId} has no execution_lease at the SSOT location plans[].execution_lease`)
|
|
523
653
|
]
|
|
524
654
|
};
|
|
525
655
|
}
|
|
526
656
|
const violations = [];
|
|
527
|
-
if (rowLease !== undefined && metadataLease !== undefined) {
|
|
528
|
-
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"));
|
|
529
|
-
} else if (rowLease === undefined) {
|
|
530
|
-
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)"));
|
|
531
|
-
}
|
|
532
657
|
violations.push(...validateExecutionLease(lease).violations);
|
|
533
658
|
return { ok: violations.length === 0, violations, lease };
|
|
534
659
|
}
|
|
@@ -536,7 +661,7 @@ var STATUS_WRITE_LOCKDIR = ".status-write.lockdir";
|
|
|
536
661
|
var LOCKDIR_HOLDER_PID = "holder.pid";
|
|
537
662
|
var heldLockDirs = new AsyncLocalStorage;
|
|
538
663
|
async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
539
|
-
const lockDir =
|
|
664
|
+
const lockDir = join4(dirname4(resolve4(statusPath)), STATUS_WRITE_LOCKDIR);
|
|
540
665
|
const held = heldLockDirs.getStore();
|
|
541
666
|
if (held !== undefined && held.has(lockDir)) {
|
|
542
667
|
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`);
|
|
@@ -548,7 +673,7 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
|
548
673
|
for (;; ) {
|
|
549
674
|
try {
|
|
550
675
|
mkdirSync3(lockDir);
|
|
551
|
-
const st =
|
|
676
|
+
const st = statSync3(lockDir);
|
|
552
677
|
acquired = { dev: st.dev, ino: st.ino };
|
|
553
678
|
break;
|
|
554
679
|
} catch (error) {
|
|
@@ -561,7 +686,7 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
|
561
686
|
}
|
|
562
687
|
}
|
|
563
688
|
try {
|
|
564
|
-
writeFileSync2(
|
|
689
|
+
writeFileSync2(join4(lockDir, LOCKDIR_HOLDER_PID), String(process.pid), "utf8");
|
|
565
690
|
} catch {}
|
|
566
691
|
const owns = held ?? new Set;
|
|
567
692
|
owns.add(lockDir);
|
|
@@ -570,10 +695,10 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
|
570
695
|
} finally {
|
|
571
696
|
owns.delete(lockDir);
|
|
572
697
|
try {
|
|
573
|
-
const current =
|
|
698
|
+
const current = statSync3(lockDir);
|
|
574
699
|
if (acquired !== null && current.dev === acquired.dev && current.ino === acquired.ino) {
|
|
575
700
|
try {
|
|
576
|
-
unlinkSync2(
|
|
701
|
+
unlinkSync2(join4(lockDir, LOCKDIR_HOLDER_PID));
|
|
577
702
|
} catch {}
|
|
578
703
|
rmdirSync(lockDir);
|
|
579
704
|
}
|
|
@@ -818,16 +943,141 @@ function antiRecursionPrecheck(subagentType, executeAs) {
|
|
|
818
943
|
return { ok: true, violations: [] };
|
|
819
944
|
}
|
|
820
945
|
|
|
946
|
+
// src/workflow.ts
|
|
947
|
+
import { mkdirSync as mkdirSync4 } from "node:fs";
|
|
948
|
+
import { join as join5 } from "node:path";
|
|
949
|
+
var WORKFLOW_SNAPSHOT_FILE = "snapshot.json";
|
|
950
|
+
var WORKFLOW_LIFECYCLE_STATUSES = ["running", "paused", "completed", "failed", "stopped"];
|
|
951
|
+
var WORKFLOW_TERMINAL_STATUSES = ["completed", "failed", "stopped"];
|
|
952
|
+
var WORKFLOW_LIFECYCLE_TYPES = ["plan", "iteration"];
|
|
953
|
+
function isPlainObject2(value) {
|
|
954
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
955
|
+
}
|
|
956
|
+
function violation3(severity, code, message, fix) {
|
|
957
|
+
return { ok: false, severity, code, message, fix };
|
|
958
|
+
}
|
|
959
|
+
function validateNonEmptyString2(violations, value, field, missingCode, invalidCode) {
|
|
960
|
+
if (value === undefined) {
|
|
961
|
+
violations.push(violation3("high", missingCode, `missing required field: ${field}`));
|
|
962
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
963
|
+
violations.push(violation3("medium", invalidCode, `${field} must be a non-empty string`));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
function validateWorkflowSnapshot(doc) {
|
|
967
|
+
const violations = [];
|
|
968
|
+
if (!isPlainObject2(doc)) {
|
|
969
|
+
return {
|
|
970
|
+
ok: false,
|
|
971
|
+
violations: [violation3("high", "workflow.snapshot.invalid", "workflow snapshot must be an object")]
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
if (doc.schema_version === undefined) {
|
|
975
|
+
violations.push(violation3("high", "workflow.snapshot.missing-schema-version", "missing required field: schema_version"));
|
|
976
|
+
} else if (doc.schema_version !== 1) {
|
|
977
|
+
violations.push(violation3("high", "workflow.snapshot.invalid-schema-version", `schema_version must be 1 — got ${JSON.stringify(doc.schema_version)} (version is reserved for the root file discriminator)`));
|
|
978
|
+
}
|
|
979
|
+
if (doc.version !== undefined) {
|
|
980
|
+
violations.push(violation3("medium", "workflow.snapshot.reserved-version", `top-level version is reserved for the root status.json discriminator — snapshots use schema_version; remove the version key (got ${JSON.stringify(doc.version)})`, "remove the version key from the snapshot"));
|
|
981
|
+
}
|
|
982
|
+
validateNonEmptyString2(violations, doc.id, "id", "workflow.snapshot.missing-id", "workflow.snapshot.invalid-id");
|
|
983
|
+
if (doc.type === undefined) {
|
|
984
|
+
violations.push(violation3("high", "workflow.snapshot.missing-type", "missing required field: type"));
|
|
985
|
+
} else if (typeof doc.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(doc.type)) {
|
|
986
|
+
violations.push(violation3("medium", "workflow.snapshot.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(doc.type)}`));
|
|
987
|
+
}
|
|
988
|
+
if (doc.status === undefined) {
|
|
989
|
+
violations.push(violation3("high", "workflow.snapshot.missing-status", "missing required field: status"));
|
|
990
|
+
} else if (typeof doc.status !== "string" || !WORKFLOW_LIFECYCLE_STATUSES.includes(doc.status)) {
|
|
991
|
+
violations.push(violation3("medium", "workflow.snapshot.invalid-status", `status must be one of ${WORKFLOW_LIFECYCLE_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
|
|
992
|
+
}
|
|
993
|
+
validateNonEmptyString2(violations, doc.started_at, "started_at", "workflow.snapshot.missing-started-at", "workflow.snapshot.invalid-started-at");
|
|
994
|
+
validateNonEmptyString2(violations, doc.updated_at, "updated_at", "workflow.snapshot.missing-updated-at", "workflow.snapshot.invalid-updated-at");
|
|
995
|
+
if (doc.ended_at !== undefined) {
|
|
996
|
+
validateNonEmptyString2(violations, doc.ended_at, "ended_at", "workflow.snapshot.missing-ended-at", "workflow.snapshot.invalid-ended-at");
|
|
997
|
+
}
|
|
998
|
+
if (doc.phase !== undefined && typeof doc.phase !== "string") {
|
|
999
|
+
violations.push(violation3("medium", "workflow.snapshot.invalid-phase", "phase must be a string (free-form phase machine label)"));
|
|
1000
|
+
}
|
|
1001
|
+
if (doc.plans === undefined) {
|
|
1002
|
+
violations.push(violation3("high", "workflow.snapshot.missing-plans", "missing required field: plans"));
|
|
1003
|
+
} else if (!Array.isArray(doc.plans)) {
|
|
1004
|
+
violations.push(violation3("high", "workflow.snapshot.invalid-plans", "plans must be an array of legacy plan rows"));
|
|
1005
|
+
} else {
|
|
1006
|
+
for (const row of doc.plans) {
|
|
1007
|
+
violations.push(...validatePlanRow(row).violations);
|
|
1008
|
+
if (isPlainObject2(row) && row.execution_lease !== undefined) {
|
|
1009
|
+
violations.push(...validateExecutionLease(row.execution_lease).violations);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (doc.execution_policy !== undefined) {
|
|
1014
|
+
if (!isPlainObject2(doc.execution_policy)) {
|
|
1015
|
+
violations.push(violation3("medium", "workflow.snapshot.invalid-execution-policy", "execution_policy must be an object"));
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (doc.integration_merge_lease !== undefined) {
|
|
1019
|
+
violations.push(...validateIntegrationMergeLease(doc.integration_merge_lease).violations);
|
|
1020
|
+
}
|
|
1021
|
+
if (doc.branch !== undefined) {
|
|
1022
|
+
if (!isPlainObject2(doc.branch)) {
|
|
1023
|
+
violations.push(violation3("medium", "workflow.snapshot.invalid-branch", "branch must be an object"));
|
|
1024
|
+
} else {
|
|
1025
|
+
for (const key of ["base", "integration", "target"]) {
|
|
1026
|
+
if (doc.branch[key] !== undefined && (typeof doc.branch[key] !== "string" || doc.branch[key].trim() === "")) {
|
|
1027
|
+
violations.push(violation3("medium", "workflow.snapshot.invalid-branch", `branch.${key} must be a non-empty string`));
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (doc.control_worktree_path !== undefined) {
|
|
1033
|
+
validateNonEmptyString2(violations, doc.control_worktree_path, "control_worktree_path", "workflow.snapshot.missing-control-worktree-path", "workflow.snapshot.invalid-control-worktree-path");
|
|
1034
|
+
}
|
|
1035
|
+
if (doc.legacy_metadata !== undefined && !isPlainObject2(doc.legacy_metadata)) {
|
|
1036
|
+
violations.push(violation3("medium", "workflow.snapshot.invalid-legacy-metadata", "legacy_metadata must be an object"));
|
|
1037
|
+
}
|
|
1038
|
+
if (doc.compass_ref !== undefined) {
|
|
1039
|
+
validateNonEmptyString2(violations, doc.compass_ref, "compass_ref", "workflow.snapshot.missing-compass-ref", "workflow.snapshot.invalid-compass-ref");
|
|
1040
|
+
}
|
|
1041
|
+
const terminal = typeof doc.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(doc.status);
|
|
1042
|
+
if (terminal) {
|
|
1043
|
+
if (doc.ended_at === undefined) {
|
|
1044
|
+
violations.push(violation3("high", "workflow.snapshot.missing-ended-at", `terminal status ${JSON.stringify(doc.status)} requires ended_at — a terminal snapshot must record when the lifecycle ended`));
|
|
1045
|
+
}
|
|
1046
|
+
if (Array.isArray(doc.plans)) {
|
|
1047
|
+
for (const row of doc.plans) {
|
|
1048
|
+
if (isPlainObject2(row) && row.execution_lease !== undefined) {
|
|
1049
|
+
violations.push(violation3("high", "workflow.snapshot.terminal-dangling-execution-lease", `terminal snapshot must not carry a row execution_lease (dangling lease) — release every lease before the lifecycle ends`));
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (doc.integration_merge_lease !== undefined) {
|
|
1054
|
+
violations.push(violation3("high", "workflow.snapshot.terminal-dangling-merge-lease", "terminal snapshot must not carry integration_merge_lease (dangling lease) — release the merge lease before the lifecycle ends"));
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return { ok: violations.length === 0, violations };
|
|
1058
|
+
}
|
|
1059
|
+
async function writeWorkflowSnapshot(snapshot, dir) {
|
|
1060
|
+
const gate = validateWorkflowSnapshot(snapshot);
|
|
1061
|
+
if (!gate.ok) {
|
|
1062
|
+
const detail = gate.violations.map((v) => v.message).join("; ");
|
|
1063
|
+
throw new Error(`refusing to write invalid workflow snapshot: ${detail}`);
|
|
1064
|
+
}
|
|
1065
|
+
const snapshotPath = join5(dir, WORKFLOW_SNAPSHOT_FILE);
|
|
1066
|
+
mkdirSync4(dir, { recursive: true });
|
|
1067
|
+
await withStatusWriteLock(snapshotPath, () => {
|
|
1068
|
+
writeJson(snapshotPath, snapshot);
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
|
|
821
1072
|
// src/status.ts
|
|
822
1073
|
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
823
1074
|
var PLAN_STATUSES = ["Todo", "InProgress", "InReview", "Blocked", "Done"];
|
|
824
1075
|
var RESIDUAL_DECISIONS = ["defer", "accept", "risk-accepted"];
|
|
825
1076
|
var RESIDUAL_LIFECYCLES = ["open", "resolved", "waived", "superseded", "duplicate"];
|
|
826
|
-
|
|
827
|
-
function isPlainObject2(value) {
|
|
1077
|
+
function isPlainObject3(value) {
|
|
828
1078
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
829
1079
|
}
|
|
830
|
-
function
|
|
1080
|
+
function violation4(severity, code, message, fix) {
|
|
831
1081
|
return { ok: false, severity, code, message, fix };
|
|
832
1082
|
}
|
|
833
1083
|
function todayString() {
|
|
@@ -848,254 +1098,299 @@ function isOpenResidual(entry) {
|
|
|
848
1098
|
const effective = lifecycle === false || lifecycle === null || lifecycle === undefined ? "open" : lifecycle;
|
|
849
1099
|
return effective === "open";
|
|
850
1100
|
}
|
|
851
|
-
function
|
|
1101
|
+
function validateNonEmptyString3(violations, value, field, missingCode, invalidCode) {
|
|
852
1102
|
if (value === undefined) {
|
|
853
|
-
violations.push(
|
|
1103
|
+
violations.push(violation4("high", missingCode, `missing required field: ${field}`));
|
|
854
1104
|
} else if (typeof value !== "string" || value.trim() === "") {
|
|
855
|
-
violations.push(
|
|
1105
|
+
violations.push(violation4("medium", invalidCode, `${field} must be a non-empty string`));
|
|
856
1106
|
}
|
|
857
1107
|
}
|
|
858
1108
|
function validatePlanRow(row) {
|
|
859
1109
|
const violations = [];
|
|
860
|
-
if (!
|
|
861
|
-
return { ok: false, violations: [
|
|
1110
|
+
if (!isPlainObject3(row)) {
|
|
1111
|
+
return { ok: false, violations: [violation4("high", "status.plan-row.invalid", "plan row must be an object")] };
|
|
862
1112
|
}
|
|
863
1113
|
const { id, plan_id: planId, title, file, status, metadata, execution_lease } = row;
|
|
864
1114
|
if (id === undefined && planId === undefined) {
|
|
865
|
-
violations.push(
|
|
1115
|
+
violations.push(violation4("high", "status.plan-row.missing-id", "missing required field: id (or legacy plan_id)"));
|
|
866
1116
|
} else {
|
|
867
1117
|
if (id !== undefined) {
|
|
868
|
-
|
|
1118
|
+
validateNonEmptyString3(violations, id, "id", "status.plan-row.missing-id", "status.plan-row.invalid-id");
|
|
869
1119
|
}
|
|
870
1120
|
if (planId !== undefined) {
|
|
871
|
-
|
|
1121
|
+
validateNonEmptyString3(violations, planId, "plan_id", "status.plan-row.missing-plan-id", "status.plan-row.invalid-plan-id");
|
|
872
1122
|
}
|
|
873
1123
|
if (id !== undefined && planId !== undefined && id !== planId) {
|
|
874
|
-
violations.push(
|
|
1124
|
+
violations.push(violation4("medium", "status.plan-row.dual-id", "row has both id and plan_id with different values — write one canonical key (prefer id)"));
|
|
875
1125
|
}
|
|
876
1126
|
}
|
|
877
|
-
|
|
878
|
-
|
|
1127
|
+
validateNonEmptyString3(violations, title, "title", "status.plan-row.missing-title", "status.plan-row.invalid-title");
|
|
1128
|
+
validateNonEmptyString3(violations, file, "file", "status.plan-row.missing-file", "status.plan-row.invalid-file");
|
|
879
1129
|
if (status === undefined) {
|
|
880
|
-
violations.push(
|
|
1130
|
+
violations.push(violation4("high", "status.plan-row.missing-status", "missing required field: status"));
|
|
881
1131
|
} else if (typeof status !== "string" || !PLAN_STATUSES.includes(status)) {
|
|
882
|
-
violations.push(
|
|
1132
|
+
violations.push(violation4("medium", "status.plan-row.invalid-status", `status must be one of ${PLAN_STATUSES.join(" | ")} — got ${JSON.stringify(status)}`));
|
|
883
1133
|
}
|
|
884
|
-
if (metadata !== undefined && !
|
|
885
|
-
violations.push(
|
|
1134
|
+
if (metadata !== undefined && !isPlainObject3(metadata)) {
|
|
1135
|
+
violations.push(violation4("medium", "status.plan-row.invalid-metadata", "metadata must be an object"));
|
|
886
1136
|
}
|
|
887
|
-
if (execution_lease !== undefined && !
|
|
888
|
-
violations.push(
|
|
1137
|
+
if (execution_lease !== undefined && !isPlainObject3(execution_lease)) {
|
|
1138
|
+
violations.push(violation4("medium", "status.plan-row.invalid-execution-lease", "execution_lease must be an object"));
|
|
889
1139
|
}
|
|
890
1140
|
if (status === "Done" && execution_lease !== undefined) {
|
|
891
|
-
violations.push(
|
|
1141
|
+
violations.push(violation4("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"'));
|
|
892
1142
|
}
|
|
893
1143
|
return { ok: violations.length === 0, violations };
|
|
894
1144
|
}
|
|
895
1145
|
function validateResidual(entry) {
|
|
896
1146
|
const violations = [];
|
|
897
|
-
if (!
|
|
898
|
-
return { ok: false, violations: [
|
|
1147
|
+
if (!isPlainObject3(entry)) {
|
|
1148
|
+
return { ok: false, violations: [violation4("high", "status.residual.invalid", "residual entry must be an object")] };
|
|
899
1149
|
}
|
|
900
1150
|
const { id, title, severity, source, scope, decision, owner, target, tracking, detail_doc, lifecycle, closed_at } = entry;
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1151
|
+
validateNonEmptyString3(violations, id, "id", "status.residual.missing-id", "status.residual.invalid-id");
|
|
1152
|
+
validateNonEmptyString3(violations, title, "title", "status.residual.missing-title", "status.residual.invalid-title");
|
|
1153
|
+
validateNonEmptyString3(violations, source, "source", "status.residual.missing-source", "status.residual.invalid-source");
|
|
1154
|
+
validateNonEmptyString3(violations, scope, "scope", "status.residual.missing-scope", "status.residual.invalid-scope");
|
|
1155
|
+
validateNonEmptyString3(violations, owner, "owner", "status.residual.missing-owner", "status.residual.invalid-owner");
|
|
906
1156
|
if (severity === undefined) {
|
|
907
|
-
violations.push(
|
|
1157
|
+
violations.push(violation4("high", "status.residual.missing-severity", "missing required field: severity"));
|
|
908
1158
|
} else if (typeof severity !== "string" || !SEVERITY_ORDER.includes(severity) && severity !== "warning") {
|
|
909
|
-
violations.push(
|
|
1159
|
+
violations.push(violation4("medium", "status.residual.invalid-severity", `severity must be one of ${SEVERITY_ORDER.join(" | ")} — got ${JSON.stringify(severity)}`));
|
|
910
1160
|
} else if (severity === "warning") {
|
|
911
|
-
violations.push(
|
|
1161
|
+
violations.push(violation4("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')`));
|
|
912
1162
|
}
|
|
913
1163
|
if (decision === undefined) {
|
|
914
|
-
violations.push(
|
|
1164
|
+
violations.push(violation4("high", "status.residual.missing-decision", "missing required field: decision"));
|
|
915
1165
|
} else if (typeof decision !== "string" || !RESIDUAL_DECISIONS.includes(decision)) {
|
|
916
|
-
violations.push(
|
|
1166
|
+
violations.push(violation4("medium", "status.residual.invalid-decision", `decision must be one of ${RESIDUAL_DECISIONS.join(" | ")} — got ${JSON.stringify(decision)}`));
|
|
917
1167
|
}
|
|
918
1168
|
if (target === undefined) {
|
|
919
|
-
violations.push(
|
|
1169
|
+
violations.push(violation4("high", "status.residual.missing-target", "missing required field: target"));
|
|
920
1170
|
} else if (typeof target !== "string" && target !== null) {
|
|
921
|
-
violations.push(
|
|
1171
|
+
violations.push(violation4("medium", "status.residual.invalid-target", "target must be a string or null"));
|
|
922
1172
|
}
|
|
923
1173
|
if (tracking === undefined) {
|
|
924
|
-
violations.push(
|
|
1174
|
+
violations.push(violation4("high", "status.residual.missing-tracking", "missing required field: tracking"));
|
|
925
1175
|
} else if (typeof tracking !== "string" && tracking !== null) {
|
|
926
|
-
violations.push(
|
|
1176
|
+
violations.push(violation4("medium", "status.residual.invalid-tracking", "tracking must be a string or null"));
|
|
927
1177
|
}
|
|
928
1178
|
if (detail_doc !== undefined && typeof detail_doc !== "string" && detail_doc !== null) {
|
|
929
|
-
violations.push(
|
|
1179
|
+
violations.push(violation4("medium", "status.residual.invalid-detail-doc", "detail_doc must be a string or null"));
|
|
930
1180
|
}
|
|
931
1181
|
if (closed_at !== undefined && (typeof closed_at !== "string" || !DATE_RE.test(closed_at))) {
|
|
932
|
-
violations.push(
|
|
1182
|
+
violations.push(violation4("medium", "status.residual.invalid-closed-at", "closed_at must be YYYY-MM-DD"));
|
|
933
1183
|
}
|
|
934
1184
|
if (lifecycle !== undefined) {
|
|
935
1185
|
if (typeof lifecycle !== "string" || !RESIDUAL_LIFECYCLES.includes(lifecycle)) {
|
|
936
|
-
violations.push(
|
|
1186
|
+
violations.push(violation4("medium", "status.residual.invalid-lifecycle", `lifecycle must be one of ${RESIDUAL_LIFECYCLES.join(" | ")} — got ${JSON.stringify(lifecycle)}`));
|
|
937
1187
|
} else if (lifecycle !== "open") {
|
|
938
1188
|
if (closed_at === undefined) {
|
|
939
|
-
violations.push(
|
|
1189
|
+
violations.push(violation4("high", "status.residual.closed-missing-closed-at", `lifecycle "${lifecycle}" requires closed_at (YYYY-MM-DD)`, 'set closed_at (e.g. "2026-08-08")'));
|
|
940
1190
|
}
|
|
941
1191
|
if (entry.closure_note === undefined) {
|
|
942
|
-
violations.push(
|
|
1192
|
+
violations.push(violation4("medium", "status.residual.closed-missing-closure-note", `lifecycle "${lifecycle}" requires closure_note (what changed; how verified)`, "add closure_note explaining the close"));
|
|
943
1193
|
}
|
|
944
1194
|
}
|
|
945
1195
|
}
|
|
946
1196
|
return { ok: violations.length === 0, violations };
|
|
947
1197
|
}
|
|
948
|
-
function
|
|
1198
|
+
function isHarnessRelativePath(dir) {
|
|
1199
|
+
if (dir.startsWith("/") || dir.startsWith("\\"))
|
|
1200
|
+
return false;
|
|
1201
|
+
if (/^[A-Za-z]:[\\/]/.test(dir))
|
|
1202
|
+
return false;
|
|
1203
|
+
return !dir.split(/[\\/]+/).includes("..");
|
|
1204
|
+
}
|
|
1205
|
+
function validateWorkflowEntry(entry) {
|
|
1206
|
+
const violations = [];
|
|
1207
|
+
if (!isPlainObject3(entry)) {
|
|
1208
|
+
return {
|
|
1209
|
+
ok: false,
|
|
1210
|
+
violations: [violation4("high", "status.workflow.invalid", "workflow entry must be an object")]
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
validateNonEmptyString3(violations, entry.id, "id", "status.workflow.missing-id", "status.workflow.invalid-id");
|
|
1214
|
+
if (entry.type === undefined) {
|
|
1215
|
+
violations.push(violation4("high", "status.workflow.missing-type", "missing required field: type"));
|
|
1216
|
+
} else if (typeof entry.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(entry.type)) {
|
|
1217
|
+
violations.push(violation4("medium", "status.workflow.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(entry.type)}`));
|
|
1218
|
+
}
|
|
1219
|
+
validateNonEmptyString3(violations, entry.started_at, "started_at", "status.workflow.missing-started-at", "status.workflow.invalid-started-at");
|
|
1220
|
+
if (entry.dir === undefined) {
|
|
1221
|
+
violations.push(violation4("high", "status.workflow.missing-dir", "missing required field: dir"));
|
|
1222
|
+
} else if (typeof entry.dir !== "string" || entry.dir.trim() === "") {
|
|
1223
|
+
violations.push(violation4("medium", "status.workflow.invalid-dir", "dir must be a non-empty string"));
|
|
1224
|
+
} else if (!isHarnessRelativePath(entry.dir)) {
|
|
1225
|
+
violations.push(violation4("medium", "status.workflow.invalid-dir", `dir must be a harness-relative path (no absolute paths, no ".." segments) — got ${JSON.stringify(entry.dir)}`));
|
|
1226
|
+
}
|
|
1227
|
+
return { ok: violations.length === 0, violations };
|
|
1228
|
+
}
|
|
1229
|
+
function validateStatusV2(docOrPath, opts = {}) {
|
|
949
1230
|
let doc;
|
|
1231
|
+
let harnessDir = opts.harnessDir;
|
|
950
1232
|
if (typeof docOrPath === "string") {
|
|
951
1233
|
try {
|
|
952
1234
|
doc = readJson(docOrPath);
|
|
1235
|
+
harnessDir = dirname5(resolve5(docOrPath));
|
|
953
1236
|
} catch (error) {
|
|
954
1237
|
return {
|
|
955
1238
|
ok: false,
|
|
956
|
-
violations: [
|
|
1239
|
+
violations: [violation4("high", "status.invalid-json", error.message)]
|
|
957
1240
|
};
|
|
958
1241
|
}
|
|
959
1242
|
} else {
|
|
960
1243
|
doc = docOrPath;
|
|
961
1244
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
if (version === undefined) {
|
|
965
|
-
violations.push(violation3("high", "status.missing-version", "missing required field: version"));
|
|
966
|
-
} else if (typeof version !== "number" || !Number.isInteger(version)) {
|
|
967
|
-
violations.push(violation3("high", "status.invalid-version", "version must be an integer"));
|
|
968
|
-
} else if (version !== 1) {
|
|
969
|
-
violations.push(violation3("medium", "status.unsupported-version", `unsupported status.json schema version ${version} — expected 1`));
|
|
970
|
-
}
|
|
971
|
-
if (updated_at === undefined) {
|
|
972
|
-
violations.push(violation3("high", "status.missing-updated-at", "missing required field: updated_at"));
|
|
973
|
-
} else if (typeof updated_at !== "string" || !DATE_RE.test(updated_at)) {
|
|
974
|
-
violations.push(violation3("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
|
|
975
|
-
}
|
|
976
|
-
if (plans === undefined) {
|
|
977
|
-
violations.push(violation3("high", "status.missing-plans", "missing required field: plans"));
|
|
978
|
-
} else if (!Array.isArray(plans)) {
|
|
979
|
-
violations.push(violation3("high", "status.invalid-plans", "plans must be an array"));
|
|
980
|
-
} else {
|
|
981
|
-
for (const row of plans) {
|
|
982
|
-
violations.push(...validatePlanRow(row).violations);
|
|
983
|
-
}
|
|
1245
|
+
if (!isPlainObject3(doc)) {
|
|
1246
|
+
return { ok: false, violations: [violation4("high", "status.invalid-doc", "status document must be an object")] };
|
|
984
1247
|
}
|
|
985
|
-
if (
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1248
|
+
if (doc.version !== 2) {
|
|
1249
|
+
return {
|
|
1250
|
+
ok: false,
|
|
1251
|
+
violations: [
|
|
1252
|
+
violation4("high", "status.migration-required", `status.json schema version 2 required — got ${JSON.stringify(doc.version)} (v1 or unknown version); run \`mstar migrate\` to convert the tree`, "run `mstar migrate`")
|
|
1253
|
+
]
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
if (Array.isArray(doc.plans)) {
|
|
1257
|
+
return {
|
|
1258
|
+
ok: false,
|
|
1259
|
+
violations: [
|
|
1260
|
+
violation4("high", "status.migration-required", "v1-shaped status.json (root plans[]) is not a v2 document — run `mstar migrate` to convert the tree", "run `mstar migrate`")
|
|
1261
|
+
]
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
if (doc.residual_findings !== undefined) {
|
|
1265
|
+
return {
|
|
1266
|
+
ok: false,
|
|
1267
|
+
violations: [
|
|
1268
|
+
violation4("high", "status.migration-required", "v1-shaped status.json (root residual_findings) is not a v2 document — run `mstar migrate` to convert the tree", "run `mstar migrate`")
|
|
1269
|
+
]
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
const violations = [];
|
|
1273
|
+
if (doc.updated_at === undefined) {
|
|
1274
|
+
violations.push(violation4("high", "status.missing-updated-at", "missing required field: updated_at"));
|
|
1275
|
+
} else if (typeof doc.updated_at !== "string" || !DATE_RE.test(doc.updated_at)) {
|
|
1276
|
+
violations.push(violation4("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
|
|
1277
|
+
}
|
|
1278
|
+
if (doc.workflows === undefined) {
|
|
1279
|
+
violations.push(violation4("high", "status.missing-workflows", "missing required field: workflows"));
|
|
1280
|
+
} else if (!Array.isArray(doc.workflows)) {
|
|
1281
|
+
violations.push(violation4("high", "status.invalid-workflows", "workflows must be an array"));
|
|
989
1282
|
} else {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
for (const entry of list) {
|
|
997
|
-
violations.push(...validateResidual(entry).violations);
|
|
1283
|
+
const seen = new Set;
|
|
1284
|
+
for (const entry of doc.workflows) {
|
|
1285
|
+
violations.push(...validateWorkflowEntry(entry).violations);
|
|
1286
|
+
if (isPlainObject3(entry) && typeof entry.id === "string") {
|
|
1287
|
+
if (seen.has(entry.id)) {
|
|
1288
|
+
violations.push(violation4("medium", "status.workflow.duplicate-id", `duplicate workflow id in workflows[]: ${JSON.stringify(entry.id)}`));
|
|
998
1289
|
}
|
|
1290
|
+
seen.add(entry.id);
|
|
999
1291
|
}
|
|
1000
1292
|
}
|
|
1001
1293
|
}
|
|
1002
|
-
if (
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1294
|
+
if (harnessDir !== undefined && Array.isArray(doc.workflows)) {
|
|
1295
|
+
let realHarnessDir = null;
|
|
1296
|
+
try {
|
|
1297
|
+
realHarnessDir = realpathSync(harnessDir);
|
|
1298
|
+
} catch {}
|
|
1299
|
+
for (const entry of doc.workflows) {
|
|
1300
|
+
if (!isPlainObject3(entry) || typeof entry.dir !== "string")
|
|
1301
|
+
continue;
|
|
1302
|
+
const relSnapshot = join6(entry.dir, WORKFLOW_SNAPSHOT_FILE);
|
|
1303
|
+
const snapshotPath = join6(harnessDir, relSnapshot);
|
|
1304
|
+
const label = typeof entry.id === "string" ? entry.id : relSnapshot;
|
|
1305
|
+
let physical;
|
|
1306
|
+
try {
|
|
1307
|
+
physical = realpathSync(snapshotPath);
|
|
1308
|
+
} catch {
|
|
1309
|
+
violations.push(violation4("high", "status.workflow.snapshot-missing", `workflows[] lists ${JSON.stringify(label)} but its snapshot does not exist at ${JSON.stringify(relSnapshot)} — the root holds active lifecycles only; unregister the id when its snapshot is removed`));
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
if (realHarnessDir !== null && physical !== realHarnessDir && !physical.startsWith(`${realHarnessDir}${sep}`)) {
|
|
1313
|
+
violations.push(violation4("high", "status.workflow.snapshot-outside-harness", `workflows[] lists ${JSON.stringify(label)} but its snapshot resolves outside the harness dir (${JSON.stringify(physical)}) — symlinked snapshot paths are rejected; the snapshot must physically live under ${JSON.stringify(harnessDir)}`));
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
let snapshot;
|
|
1317
|
+
try {
|
|
1318
|
+
snapshot = readJson(snapshotPath);
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
violations.push(violation4("high", "status.workflow.snapshot-invalid", `snapshot at ${JSON.stringify(relSnapshot)} is not valid JSON: ${error.message}`));
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
if (typeof snapshot.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(snapshot.status)) {
|
|
1324
|
+
violations.push(violation4("high", "status.workflow.terminal-listed", `workflows[] lists ${JSON.stringify(label)} whose snapshot status is terminal (${snapshot.status}) — removal-at-terminal: terminal writers unregister AFTER the snapshot write`));
|
|
1325
|
+
}
|
|
1326
|
+
if (typeof entry.type === "string" && typeof snapshot.type === "string" && entry.type !== snapshot.type) {
|
|
1327
|
+
violations.push(violation4("medium", "status.workflow.mismatched-type", `workflows[] entry ${JSON.stringify(label)} type ${JSON.stringify(entry.type)} does not match its snapshot type ${JSON.stringify(snapshot.type)} — the root entry mirrors the snapshot; align them`));
|
|
1328
|
+
}
|
|
1329
|
+
if (typeof entry.started_at === "string" && typeof snapshot.started_at === "string" && entry.started_at !== snapshot.started_at) {
|
|
1330
|
+
violations.push(violation4("medium", "status.workflow.mismatched-started-at", `workflows[] entry ${JSON.stringify(label)} started_at ${JSON.stringify(entry.started_at)} does not match its snapshot started_at ${JSON.stringify(snapshot.started_at)} — the root entry mirrors the snapshot; align them`));
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1008
1333
|
}
|
|
1009
1334
|
return { ok: violations.length === 0, violations };
|
|
1010
1335
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
assertSafePathComponent(planId, "planId");
|
|
1017
|
-
const statusPath = join4(dir, "status.json");
|
|
1018
|
-
if (!existsSync2(statusPath)) {
|
|
1019
|
-
throw new Error(`status file not found: ${statusPath}`);
|
|
1336
|
+
var validateStatus = validateStatusV2;
|
|
1337
|
+
async function registerWorkflow(root, entry) {
|
|
1338
|
+
const entryGate = validateWorkflowEntry(entry);
|
|
1339
|
+
if (!entryGate.ok) {
|
|
1340
|
+
throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
|
|
1020
1341
|
}
|
|
1342
|
+
const statusPath = resolve5(root);
|
|
1343
|
+
const harnessDir = dirname5(statusPath);
|
|
1021
1344
|
return withStatusWriteLock(statusPath, () => {
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
return !existingIds.has(entry.id);
|
|
1039
|
-
}).map((entry) => ({ ...entry, archived_at: today }));
|
|
1040
|
-
if (moved.length > 0) {
|
|
1041
|
-
writeJson(archivePath, { plan_id: planId, schema_version: 1, entries: [...existing, ...moved] });
|
|
1345
|
+
const current = readJson(statusPath);
|
|
1346
|
+
const fresh = Object.keys(current).length === 0;
|
|
1347
|
+
const doc = fresh ? { version: 2, updated_at: todayString(), workflows: [] } : current;
|
|
1348
|
+
if (!fresh && !Array.isArray(doc.workflows)) {
|
|
1349
|
+
throw new Error("refusing to modify status.json: workflows must be an array — a v1 root must be migrated first (run `mstar migrate`)");
|
|
1350
|
+
}
|
|
1351
|
+
const existing = doc.workflows.findIndex((wf) => wf.id === entry.id);
|
|
1352
|
+
if (existing >= 0) {
|
|
1353
|
+
doc.workflows[existing] = entry;
|
|
1354
|
+
} else {
|
|
1355
|
+
doc.workflows.push(entry);
|
|
1356
|
+
}
|
|
1357
|
+
doc.updated_at = todayString();
|
|
1358
|
+
const gate = validateStatusV2(doc, { harnessDir });
|
|
1359
|
+
if (!gate.ok) {
|
|
1360
|
+
throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
|
|
1042
1361
|
}
|
|
1043
|
-
delete doc.residual_findings[planId];
|
|
1044
|
-
doc.updated_at = today;
|
|
1045
1362
|
writeJson(statusPath, doc);
|
|
1046
|
-
return
|
|
1363
|
+
return doc;
|
|
1047
1364
|
});
|
|
1048
1365
|
}
|
|
1049
|
-
function
|
|
1050
|
-
if (
|
|
1051
|
-
|
|
1052
|
-
for (const row of doc.plans) {
|
|
1053
|
-
if (!isPlainObject2(row))
|
|
1054
|
-
continue;
|
|
1055
|
-
const rowId = row.id ?? row.plan_id;
|
|
1056
|
-
if (rowId !== planId)
|
|
1057
|
-
continue;
|
|
1058
|
-
if (!isPlainObject2(row.metadata))
|
|
1059
|
-
return;
|
|
1060
|
-
const mode = row.metadata.findings_cleanup;
|
|
1061
|
-
if (mode === "zero-residual" || mode === "allow-residual")
|
|
1062
|
-
return mode;
|
|
1063
|
-
return;
|
|
1366
|
+
async function unregisterWorkflow(root, id) {
|
|
1367
|
+
if (typeof id !== "string" || id.trim() === "") {
|
|
1368
|
+
throw new Error("refusing to unregister workflow: id must be a non-empty string");
|
|
1064
1369
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
if (!Array.isArray(list))
|
|
1072
|
-
return [];
|
|
1073
|
-
return list.filter((entry) => isPlainObject2(entry) && isOpenResidual(entry));
|
|
1074
|
-
}
|
|
1075
|
-
function findingsCleanupGate(doc, planId, opts) {
|
|
1076
|
-
const mode = opts?.mode ?? planFindingsCleanup(doc, planId) ?? "allow-residual";
|
|
1077
|
-
const violations = [];
|
|
1078
|
-
const residuals = openResidualsOf(doc, planId);
|
|
1079
|
-
for (const entry of residuals) {
|
|
1080
|
-
const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
|
|
1081
|
-
const label = `R#${id}`;
|
|
1082
|
-
if (mode === "zero-residual") {
|
|
1083
|
-
if (entry.severity === "nit") {
|
|
1084
|
-
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`));
|
|
1085
|
-
} else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
|
|
1086
|
-
violations.push(violation3("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
|
|
1087
|
-
} else if (entry.decision === "defer") {
|
|
1088
|
-
if (typeof entry.target !== "string" || entry.target.trim() === "") {
|
|
1089
|
-
violations.push(violation3("medium", "findings.zero-residual-defer-no-target", `${label}: blocker-defer requires a target (next iteration/milestone) under zero-residual`));
|
|
1090
|
-
}
|
|
1091
|
-
} else {
|
|
1092
|
-
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`));
|
|
1093
|
-
}
|
|
1094
|
-
} else if (normalizeSeverity(entry.severity) === "critical") {
|
|
1095
|
-
violations.push(violation3("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
|
|
1370
|
+
const statusPath = resolve5(root);
|
|
1371
|
+
const harnessDir = dirname5(statusPath);
|
|
1372
|
+
return withStatusWriteLock(statusPath, () => {
|
|
1373
|
+
const current = readJson(statusPath);
|
|
1374
|
+
if (Object.keys(current).length === 0) {
|
|
1375
|
+
return { version: 2, updated_at: todayString(), workflows: [] };
|
|
1096
1376
|
}
|
|
1097
|
-
|
|
1098
|
-
|
|
1377
|
+
const doc = current;
|
|
1378
|
+
if (!Array.isArray(doc.workflows)) {
|
|
1379
|
+
throw new Error("refusing to modify status.json: workflows must be an array — a v1 root must be migrated first (run `mstar migrate`)");
|
|
1380
|
+
}
|
|
1381
|
+
const remaining = doc.workflows.filter((wf) => wf.id !== id);
|
|
1382
|
+
if (remaining.length === doc.workflows.length) {
|
|
1383
|
+
return doc;
|
|
1384
|
+
}
|
|
1385
|
+
doc.workflows = remaining;
|
|
1386
|
+
doc.updated_at = todayString();
|
|
1387
|
+
const gate = validateStatusV2(doc, { harnessDir });
|
|
1388
|
+
if (!gate.ok) {
|
|
1389
|
+
throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
|
|
1390
|
+
}
|
|
1391
|
+
writeJson(statusPath, doc);
|
|
1392
|
+
return doc;
|
|
1393
|
+
});
|
|
1099
1394
|
}
|
|
1100
1395
|
function resolveCompassEnforcement(harnessDir) {
|
|
1101
1396
|
const iterationsDir = resolveIterationDir(harnessDir);
|
|
@@ -1110,12 +1405,12 @@ function resolveCompassEnforcement(harnessDir) {
|
|
|
1110
1405
|
for (const entry of entries) {
|
|
1111
1406
|
if (!entry.isDirectory())
|
|
1112
1407
|
continue;
|
|
1113
|
-
const compassPath =
|
|
1408
|
+
const compassPath = join6(iterationsDir, entry.name, "delivery-compass.md");
|
|
1114
1409
|
if (!existsSync2(compassPath))
|
|
1115
1410
|
continue;
|
|
1116
1411
|
let content;
|
|
1117
1412
|
try {
|
|
1118
|
-
content =
|
|
1413
|
+
content = readFileSync4(compassPath, "utf8");
|
|
1119
1414
|
} catch {
|
|
1120
1415
|
continue;
|
|
1121
1416
|
}
|
|
@@ -1129,58 +1424,26 @@ function resolveCompassEnforcement(harnessDir) {
|
|
|
1129
1424
|
}
|
|
1130
1425
|
return { hard: false, source: "none" };
|
|
1131
1426
|
}
|
|
1132
|
-
function
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1427
|
+
function resolveMstarcEnforcement(harnessDir) {
|
|
1428
|
+
const dir = resolve5(harnessDir);
|
|
1429
|
+
const rc = loadMstarc(dir, dirname5(dir));
|
|
1430
|
+
const value = rc?.config.enforcement;
|
|
1431
|
+
if (value === "hard")
|
|
1432
|
+
return { hard: true, source: "mstarc" };
|
|
1433
|
+
if (value === "soft")
|
|
1434
|
+
return { hard: false, source: "mstarc" };
|
|
1435
|
+
return { hard: false, source: "none" };
|
|
1139
1436
|
}
|
|
1140
|
-
function
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
const merged = { ...canonical, ...legacy };
|
|
1146
|
-
const items = [];
|
|
1147
|
-
for (const [plan, list] of Object.entries(merged)) {
|
|
1148
|
-
if (!Array.isArray(list))
|
|
1149
|
-
continue;
|
|
1150
|
-
for (const value of list) {
|
|
1151
|
-
if (!isPlainObject2(value) || !isOpenResidual(value))
|
|
1152
|
-
continue;
|
|
1153
|
-
items.push({ plan, entry: value });
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
const bySeverity = {};
|
|
1157
|
-
for (const severity of SEVERITY_ORDER) {
|
|
1158
|
-
bySeverity[severity] = items.filter(({ entry }) => normalizeSeverity(entry.severity) === severity).length;
|
|
1159
|
-
}
|
|
1160
|
-
const computed = {
|
|
1161
|
-
total_open: items.length,
|
|
1162
|
-
by_severity: bySeverity,
|
|
1163
|
-
by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
|
|
1164
|
-
by_plan: groupCount(items.map(({ plan }) => plan))
|
|
1165
|
-
};
|
|
1166
|
-
const storedRaw = metadata.tech_debt_summary ?? null;
|
|
1167
|
-
const stored = storedRaw === null ? null : storedRaw;
|
|
1168
|
-
const checks = ROLLUP_FIELDS.map((field) => {
|
|
1169
|
-
const computedField = computed[field];
|
|
1170
|
-
if (stored === null)
|
|
1171
|
-
return { field, status: "DRIFT" };
|
|
1172
|
-
const storedField = stored[field];
|
|
1173
|
-
const storedCompared = storedField === false ? null : storedField ?? null;
|
|
1174
|
-
const status = JSON.stringify(computedField) === JSON.stringify(storedCompared) ? "PASS" : "DRIFT";
|
|
1175
|
-
return { field, status };
|
|
1176
|
-
});
|
|
1177
|
-
const overall = checks.every((check) => check.status === "PASS") ? "PASS" : "DRIFT";
|
|
1178
|
-
return { computed, stored, checks, overall };
|
|
1437
|
+
function resolveRepoEnforcement(harnessDir) {
|
|
1438
|
+
const rc = resolveMstarcEnforcement(harnessDir);
|
|
1439
|
+
if (rc.source !== "none")
|
|
1440
|
+
return rc;
|
|
1441
|
+
return resolveCompassEnforcement(harnessDir);
|
|
1179
1442
|
}
|
|
1180
1443
|
// src/worktree.ts
|
|
1181
1444
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
1182
1445
|
import { existsSync as existsSync3 } from "node:fs";
|
|
1183
|
-
import { isAbsolute as
|
|
1446
|
+
import { isAbsolute as isAbsolute4, resolve as resolve6 } from "node:path";
|
|
1184
1447
|
var DEFAULT_PROBE_TIMEOUT_MS = 1e4;
|
|
1185
1448
|
function probeTimeoutMs() {
|
|
1186
1449
|
const raw = process.env.MSTAR_GIT_PROBE_TIMEOUT_MS;
|
|
@@ -1189,7 +1452,7 @@ function probeTimeoutMs() {
|
|
|
1189
1452
|
const parsed = Number(raw);
|
|
1190
1453
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PROBE_TIMEOUT_MS;
|
|
1191
1454
|
}
|
|
1192
|
-
function
|
|
1455
|
+
function violation5(severity, code, message, fix) {
|
|
1193
1456
|
return { ok: false, severity, code, message, fix };
|
|
1194
1457
|
}
|
|
1195
1458
|
function gate(violations) {
|
|
@@ -1223,25 +1486,25 @@ function l1PreDispatchCheck(input, opts = {}) {
|
|
|
1223
1486
|
const violations = [];
|
|
1224
1487
|
const { controlWorktreePath, leaseWorktreePath, leaseWorkingBranch, planId } = input;
|
|
1225
1488
|
if (controlWorktreePath.trim() === "") {
|
|
1226
|
-
violations.push(
|
|
1489
|
+
violations.push(violation5("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"));
|
|
1227
1490
|
}
|
|
1228
1491
|
if (leaseWorktreePath.trim() === "") {
|
|
1229
|
-
violations.push(
|
|
1492
|
+
violations.push(violation5("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"));
|
|
1230
1493
|
}
|
|
1231
1494
|
if (leaseWorkingBranch.trim() === "") {
|
|
1232
|
-
violations.push(
|
|
1495
|
+
violations.push(violation5("high", "worktree.l1.lease-branch-missing", `execution_lease.working_branch is empty for plan "${planId}"`, "record the lease working_branch before dispatch"));
|
|
1233
1496
|
}
|
|
1234
|
-
if (controlWorktreePath !== "" && leaseWorktreePath !== "" &&
|
|
1235
|
-
violations.push(
|
|
1497
|
+
if (controlWorktreePath !== "" && leaseWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(leaseWorktreePath)) {
|
|
1498
|
+
violations.push(violation5("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"));
|
|
1236
1499
|
}
|
|
1237
1500
|
if (leaseWorktreePath !== "" && !existsSync3(leaseWorktreePath)) {
|
|
1238
|
-
violations.push(
|
|
1501
|
+
violations.push(violation5("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>`));
|
|
1239
1502
|
} else if (leaseWorktreePath !== "" && leaseWorkingBranch !== "") {
|
|
1240
1503
|
const probe = probeBranch(leaseWorktreePath, opts);
|
|
1241
1504
|
if ("error" in probe) {
|
|
1242
|
-
violations.push(
|
|
1505
|
+
violations.push(violation5("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)"));
|
|
1243
1506
|
} else if (probe.branch !== leaseWorkingBranch) {
|
|
1244
|
-
violations.push(
|
|
1507
|
+
violations.push(violation5("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`));
|
|
1245
1508
|
}
|
|
1246
1509
|
}
|
|
1247
1510
|
return gate(violations);
|
|
@@ -1251,41 +1514,41 @@ function l2PreDispatchCheck(input, opts = {}) {
|
|
|
1251
1514
|
const tracks = input.tracks ?? [];
|
|
1252
1515
|
const seenPaths = new Set;
|
|
1253
1516
|
if (tracks.length < 1) {
|
|
1254
|
-
violations.push(
|
|
1517
|
+
violations.push(violation5("high", "worktree.l2.no-tracks", "no parallel writable tracks — the L2 pre-dispatch checklist requires at least one track with an absolute worktreePath and Working branch", "pass each track's absolute Worktree path and PM-approved Working branch"));
|
|
1255
1518
|
}
|
|
1256
1519
|
tracks.forEach((track, index) => {
|
|
1257
1520
|
if (track.worktreePath.trim() === "" || track.workingBranch.trim() === "") {
|
|
1258
|
-
violations.push(
|
|
1521
|
+
violations.push(violation5("high", "worktree.l2.track-invalid", `track ${index + 1} is missing worktreePath and/or workingBranch`, "fill both fields for every track"));
|
|
1259
1522
|
return;
|
|
1260
1523
|
}
|
|
1261
|
-
if (!
|
|
1262
|
-
violations.push(
|
|
1524
|
+
if (!isAbsolute4(track.worktreePath)) {
|
|
1525
|
+
violations.push(violation5("high", "worktree.l2.track-path-relative", `track ${index + 1} worktreePath "${track.worktreePath}" is not an absolute path — L2 tracks MUST use absolute worktree checkout paths (consistent with the lease validator's absolute worktree_path enforcement)`, `use an absolute path for track ${index + 1} (e.g. /Users/<you>/worktrees/<branch>)`));
|
|
1263
1526
|
return;
|
|
1264
1527
|
}
|
|
1265
|
-
const normalized =
|
|
1528
|
+
const normalized = resolve6(track.worktreePath);
|
|
1266
1529
|
if (seenPaths.has(normalized)) {
|
|
1267
|
-
violations.push(
|
|
1530
|
+
violations.push(violation5("high", "worktree.l2.track-path-collision", `duplicate worktreePath "${track.worktreePath}" across parallel tracks — L2 parallel-writable isolation requires a distinct absolute Worktree path per track (N parallel invokes ≠ isolation)`, "give every parallel track its own git worktree checkout"));
|
|
1268
1531
|
return;
|
|
1269
1532
|
}
|
|
1270
1533
|
seenPaths.add(normalized);
|
|
1271
1534
|
if (!existsSync3(track.worktreePath)) {
|
|
1272
|
-
violations.push(
|
|
1535
|
+
violations.push(violation5("high", "worktree.l2.track-missing", `track worktree directory "${track.worktreePath}" does not exist`, `create it before dispatch: git worktree add ${track.worktreePath} ${track.workingBranch}`));
|
|
1273
1536
|
return;
|
|
1274
1537
|
}
|
|
1275
1538
|
const probe = probeBranch(track.worktreePath, opts);
|
|
1276
1539
|
if ("error" in probe) {
|
|
1277
|
-
violations.push(
|
|
1540
|
+
violations.push(violation5("high", "worktree.l2.branch-probe-failed", `cannot probe branch at "${track.worktreePath}": ${probe.error}`, "verify the path is a git worktree checkout on its Working branch (not detached)"));
|
|
1278
1541
|
} else if (probe.branch !== track.workingBranch) {
|
|
1279
|
-
violations.push(
|
|
1542
|
+
violations.push(violation5("high", "worktree.l2.branch-mismatch", `track worktree "${track.worktreePath}" is on branch "${probe.branch}", expected Working branch "${track.workingBranch}"`, `checkout ${track.workingBranch} in that worktree`));
|
|
1280
1543
|
}
|
|
1281
1544
|
});
|
|
1282
1545
|
return gate(violations);
|
|
1283
1546
|
}
|
|
1284
1547
|
function assertControlVsFeaturePath(controlWorktreePath, featureWorktreePath) {
|
|
1285
1548
|
const violations = [];
|
|
1286
|
-
const samePath = controlWorktreePath === "" && featureWorktreePath === "" || controlWorktreePath !== "" && featureWorktreePath !== "" &&
|
|
1549
|
+
const samePath = controlWorktreePath === "" && featureWorktreePath === "" || controlWorktreePath !== "" && featureWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(featureWorktreePath);
|
|
1287
1550
|
if (samePath) {
|
|
1288
|
-
violations.push(
|
|
1551
|
+
violations.push(violation5("critical", "worktree.control-feature.same", `control worktree path equals feature/lease worktree path "${controlWorktreePath}" — execution_lease.worktree_path MUST differ from metadata.control_worktree_path`, "use a distinct feature worktree for the plan's product edits"));
|
|
1289
1552
|
}
|
|
1290
1553
|
return gate(violations);
|
|
1291
1554
|
}
|
|
@@ -1293,9 +1556,9 @@ function assertBranchAlignment(worktreePath, expectedBranch, opts = {}) {
|
|
|
1293
1556
|
const violations = [];
|
|
1294
1557
|
const probe = probeBranch(worktreePath, opts);
|
|
1295
1558
|
if ("error" in probe) {
|
|
1296
|
-
violations.push(
|
|
1559
|
+
violations.push(violation5("high", "worktree.branch-probe-failed", `cannot probe branch at "${worktreePath}": ${probe.error}`, "verify the path is a git worktree checkout on the expected branch (not detached)"));
|
|
1297
1560
|
} else if (probe.branch !== expectedBranch) {
|
|
1298
|
-
violations.push(
|
|
1561
|
+
violations.push(violation5("high", "worktree.branch-mismatch", `worktree "${worktreePath}" is on branch "${probe.branch}", expected "${expectedBranch}" (Assignment Working branch)`, `checkout ${expectedBranch} in that worktree`));
|
|
1299
1562
|
}
|
|
1300
1563
|
return gate(violations);
|
|
1301
1564
|
}
|
|
@@ -1310,7 +1573,7 @@ function assertQcAlignment(assignments) {
|
|
|
1310
1573
|
for (const { key, label } of QC_ALIGNMENT_FIELDS) {
|
|
1311
1574
|
const distinct = [...new Set(list.map((a) => a[key]))];
|
|
1312
1575
|
if (distinct.length > 1) {
|
|
1313
|
-
violations.push(
|
|
1576
|
+
violations.push(violation5("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`));
|
|
1314
1577
|
}
|
|
1315
1578
|
}
|
|
1316
1579
|
return gate(violations);
|
|
@@ -1320,19 +1583,19 @@ function singleReviewSnapshot(assignments) {
|
|
|
1320
1583
|
const list = assignments ?? [];
|
|
1321
1584
|
list.forEach((a, index) => {
|
|
1322
1585
|
if ((a.head ?? "").trim() === "") {
|
|
1323
|
-
violations.push(
|
|
1586
|
+
violations.push(violation5("high", "qc.alignment.snapshot-missing", `review head not provided for assignment ${index + 1} (plan_id "${a.planId}") — cannot confirm the single review snapshot precondition`, "precompute and pass the review HEAD (full SHA) for every assignment"));
|
|
1324
1587
|
}
|
|
1325
1588
|
});
|
|
1326
1589
|
const distinct = [...new Set(list.map((a) => a.head ?? "").filter((h) => h.trim() !== ""))];
|
|
1327
1590
|
if (distinct.length > 1) {
|
|
1328
|
-
violations.push(
|
|
1591
|
+
violations.push(violation5("high", "qc.alignment.single-snapshot", `assignments cover ${distinct.length} different review heads (${distinct.join(", ")}) — all reviewable commits must sit on ONE Working branch HEAD before QC tri + QA`, "merge the parallel tracks to a single Working branch HEAD, then re-derive the heads"));
|
|
1329
1592
|
}
|
|
1330
1593
|
return gate(violations);
|
|
1331
1594
|
}
|
|
1332
1595
|
// src/sdd.ts
|
|
1333
1596
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
1334
|
-
import { mkdirSync as
|
|
1335
|
-
import { basename as basename3, dirname as
|
|
1597
|
+
import { mkdirSync as mkdirSync5, readdirSync as readdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync2, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1598
|
+
import { basename as basename3, dirname as dirname6, isAbsolute as isAbsolute5, join as join7, resolve as resolve7 } from "node:path";
|
|
1336
1599
|
class SddScriptError extends Error {
|
|
1337
1600
|
exitCode;
|
|
1338
1601
|
constructor(message, exitCode) {
|
|
@@ -1343,14 +1606,14 @@ class SddScriptError extends Error {
|
|
|
1343
1606
|
}
|
|
1344
1607
|
function isDirectory2(dir) {
|
|
1345
1608
|
try {
|
|
1346
|
-
return
|
|
1609
|
+
return statSync4(dir).isDirectory();
|
|
1347
1610
|
} catch {
|
|
1348
1611
|
return false;
|
|
1349
1612
|
}
|
|
1350
1613
|
}
|
|
1351
|
-
function
|
|
1614
|
+
function isFile2(file) {
|
|
1352
1615
|
try {
|
|
1353
|
-
return
|
|
1616
|
+
return statSync4(file).isFile();
|
|
1354
1617
|
} catch {
|
|
1355
1618
|
return false;
|
|
1356
1619
|
}
|
|
@@ -1369,25 +1632,48 @@ function gitOut(cwd, args) {
|
|
|
1369
1632
|
}
|
|
1370
1633
|
}
|
|
1371
1634
|
function probeHarnessWithStatus(root) {
|
|
1372
|
-
if (
|
|
1373
|
-
return
|
|
1374
|
-
if (
|
|
1375
|
-
return
|
|
1635
|
+
if (isFile2(join7(root, ".mstar", "status.json")))
|
|
1636
|
+
return join7(root, ".mstar");
|
|
1637
|
+
if (isFile2(join7(root, ".agents", "status.json")))
|
|
1638
|
+
return join7(root, ".agents");
|
|
1639
|
+
if (hasWorkflowSnapshot(join7(root, ".mstar")))
|
|
1640
|
+
return join7(root, ".mstar");
|
|
1641
|
+
if (hasWorkflowSnapshot(join7(root, ".agents")))
|
|
1642
|
+
return join7(root, ".agents");
|
|
1376
1643
|
return null;
|
|
1377
1644
|
}
|
|
1645
|
+
function hasWorkflowSnapshot(harnessDir) {
|
|
1646
|
+
let workflowsDir;
|
|
1647
|
+
try {
|
|
1648
|
+
workflowsDir = resolveWorkflowDir(harnessDir, { harnessDir });
|
|
1649
|
+
} catch {
|
|
1650
|
+
workflowsDir = join7(harnessDir, "workflows");
|
|
1651
|
+
}
|
|
1652
|
+
if (!isDirectory2(workflowsDir))
|
|
1653
|
+
return false;
|
|
1654
|
+
try {
|
|
1655
|
+
for (const entry of readdirSync3(workflowsDir, { withFileTypes: true })) {
|
|
1656
|
+
if (entry.isDirectory() && isFile2(join7(workflowsDir, entry.name, "snapshot.json")))
|
|
1657
|
+
return true;
|
|
1658
|
+
}
|
|
1659
|
+
} catch {
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1662
|
+
return false;
|
|
1663
|
+
}
|
|
1378
1664
|
function isLinkedWorktree(root) {
|
|
1379
1665
|
const gitDirRaw = gitOut(root, ["rev-parse", "--git-dir"]);
|
|
1380
1666
|
const commonRaw = gitOut(root, ["rev-parse", "--git-common-dir"]);
|
|
1381
1667
|
if (gitDirRaw === null || commonRaw === null)
|
|
1382
1668
|
return false;
|
|
1383
|
-
const gitDir =
|
|
1384
|
-
const common =
|
|
1669
|
+
const gitDir = isAbsolute5(gitDirRaw) ? gitDirRaw : join7(root, gitDirRaw);
|
|
1670
|
+
const common = isAbsolute5(commonRaw) ? commonRaw : join7(root, commonRaw);
|
|
1385
1671
|
if (gitDir.includes("/.git/worktrees/") || gitDir.includes("/worktrees/"))
|
|
1386
1672
|
return true;
|
|
1387
1673
|
try {
|
|
1388
|
-
const gdParent =
|
|
1389
|
-
const cmAbs =
|
|
1390
|
-
return
|
|
1674
|
+
const gdParent = realpathSync2(dirname6(gitDir));
|
|
1675
|
+
const cmAbs = realpathSync2(common);
|
|
1676
|
+
return join7(gdParent, basename3(gitDir)) !== cmAbs && gitDir !== cmAbs;
|
|
1391
1677
|
} catch {
|
|
1392
1678
|
return false;
|
|
1393
1679
|
}
|
|
@@ -1404,10 +1690,10 @@ function sddWorkspace(planId, opts = {}) {
|
|
|
1404
1690
|
if (!isDirectory2(controlRoot)) {
|
|
1405
1691
|
throw new SddScriptError(`mstar sdd workspace: CONTROL_ROOT / MSTAR_CONTROL_ROOT is not a directory: ${controlRoot}`, 1);
|
|
1406
1692
|
}
|
|
1407
|
-
root =
|
|
1693
|
+
root = realpathSync2(controlRoot);
|
|
1408
1694
|
} else {
|
|
1409
1695
|
const topLevel = gitOut(cwd, ["rev-parse", "--show-toplevel"]);
|
|
1410
|
-
root =
|
|
1696
|
+
root = realpathSync2(topLevel ?? cwd);
|
|
1411
1697
|
}
|
|
1412
1698
|
if (!controlRoot && isLinkedWorktree(root)) {
|
|
1413
1699
|
throw new SddScriptError(`mstar sdd workspace: linked worktree at ${root} has no {HARNESS_DIR}/status.json (default gitignore).
|
|
@@ -1418,24 +1704,30 @@ function sddWorkspace(planId, opts = {}) {
|
|
|
1418
1704
|
const harnessOverride = opts.harnessDir ?? (process.env.MSTAR_HARNESS_DIR || undefined);
|
|
1419
1705
|
let harnessDir;
|
|
1420
1706
|
if (harnessOverride) {
|
|
1421
|
-
harnessDir =
|
|
1707
|
+
harnessDir = resolve7(root, harnessOverride);
|
|
1422
1708
|
} else {
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
harnessDir = join5(root, ".mstar");
|
|
1428
|
-
} else if (isDirectory2(join5(root, ".agents"))) {
|
|
1429
|
-
harnessDir = join5(root, ".agents");
|
|
1709
|
+
const rc = findMstarc(root, root);
|
|
1710
|
+
const rcHarnessDir = rc !== null ? parseMstarc(readFileSync5(rc, "utf8")).harnessDir : undefined;
|
|
1711
|
+
if (rcHarnessDir) {
|
|
1712
|
+
harnessDir = resolve7(rc !== null ? dirname6(rc) : root, rcHarnessDir);
|
|
1430
1713
|
} else {
|
|
1431
|
-
|
|
1714
|
+
const probed = probeHarnessWithStatus(root);
|
|
1715
|
+
if (probed) {
|
|
1716
|
+
harnessDir = probed;
|
|
1717
|
+
} else if (isDirectory2(join7(root, ".mstar"))) {
|
|
1718
|
+
harnessDir = join7(root, ".mstar");
|
|
1719
|
+
} else if (isDirectory2(join7(root, ".agents"))) {
|
|
1720
|
+
harnessDir = join7(root, ".agents");
|
|
1721
|
+
} else {
|
|
1722
|
+
harnessDir = join7(root, ".mstar");
|
|
1723
|
+
}
|
|
1432
1724
|
}
|
|
1433
1725
|
}
|
|
1434
1726
|
const sddDir = resolveSddDir(harnessDir, planId);
|
|
1435
|
-
|
|
1436
|
-
writeFileSync3(
|
|
1727
|
+
mkdirSync5(sddDir, { recursive: true });
|
|
1728
|
+
writeFileSync3(join7(sddDir, ".gitignore"), `*
|
|
1437
1729
|
`);
|
|
1438
|
-
return
|
|
1730
|
+
return realpathSync2(sddDir);
|
|
1439
1731
|
}
|
|
1440
1732
|
function taskBrief(planFile, taskN, outFile, opts = {}) {
|
|
1441
1733
|
if (!planFile || !Number.isInteger(taskN) || taskN < 1) {
|
|
@@ -1443,7 +1735,7 @@ function taskBrief(planFile, taskN, outFile, opts = {}) {
|
|
|
1443
1735
|
}
|
|
1444
1736
|
let content;
|
|
1445
1737
|
try {
|
|
1446
|
-
content =
|
|
1738
|
+
content = readFileSync5(planFile, "utf8");
|
|
1447
1739
|
} catch {
|
|
1448
1740
|
throw new SddScriptError(`no such plan file: ${planFile}`, 2);
|
|
1449
1741
|
}
|
|
@@ -1455,8 +1747,8 @@ function taskBrief(planFile, taskN, outFile, opts = {}) {
|
|
|
1455
1747
|
if (!sddDir) {
|
|
1456
1748
|
throw new SddScriptError("mstar sdd task-brief: set SDD_DIR or pass OUTFILE (run mstar sdd workspace PLAN_ID first)", 2);
|
|
1457
1749
|
}
|
|
1458
|
-
|
|
1459
|
-
out =
|
|
1750
|
+
mkdirSync5(sddDir, { recursive: true });
|
|
1751
|
+
out = join7(sddDir, `task-${taskN}-brief.md`);
|
|
1460
1752
|
}
|
|
1461
1753
|
const records = content.endsWith(`
|
|
1462
1754
|
`) ? content.split(`
|
|
@@ -1506,10 +1798,10 @@ function reviewPackage(base, head, outFile, opts = {}) {
|
|
|
1506
1798
|
if (!sddDir) {
|
|
1507
1799
|
throw new SddScriptError("mstar sdd review-package: set SDD_DIR or pass OUTFILE", 2);
|
|
1508
1800
|
}
|
|
1509
|
-
|
|
1801
|
+
mkdirSync5(sddDir, { recursive: true });
|
|
1510
1802
|
const shortBase = gitOut(cwd, ["rev-parse", "--short", base]) ?? base;
|
|
1511
1803
|
const shortHead = gitOut(cwd, ["rev-parse", "--short", head]) ?? head;
|
|
1512
|
-
out =
|
|
1804
|
+
out = join7(sddDir, `review-${shortBase}..${shortHead}.diff`);
|
|
1513
1805
|
}
|
|
1514
1806
|
const run = (args) => execFileSync3("git", args, { cwd, maxBuffer: GIT_CAPTURE_MAX_BYTES });
|
|
1515
1807
|
const parts = [
|
|
@@ -1545,7 +1837,7 @@ function assertBaseSha(ref, opts = {}) {
|
|
|
1545
1837
|
}
|
|
1546
1838
|
function taskReportExists(sddDir, taskN) {
|
|
1547
1839
|
try {
|
|
1548
|
-
const st =
|
|
1840
|
+
const st = statSync4(join7(sddDir, `task-${taskN}-report.md`));
|
|
1549
1841
|
return st.isFile() && st.size > 0;
|
|
1550
1842
|
} catch {
|
|
1551
1843
|
return false;
|
|
@@ -1554,7 +1846,7 @@ function taskReportExists(sddDir, taskN) {
|
|
|
1554
1846
|
function readProgressLedger(sddDir) {
|
|
1555
1847
|
let content;
|
|
1556
1848
|
try {
|
|
1557
|
-
content =
|
|
1849
|
+
content = readFileSync5(join7(sddDir, "progress.md"), "utf8");
|
|
1558
1850
|
} catch {
|
|
1559
1851
|
return [];
|
|
1560
1852
|
}
|
|
@@ -1587,8 +1879,8 @@ function implementerSessionStickyRules(input) {
|
|
|
1587
1879
|
return { resume: true, reason: `sticky resume OK: host_agent_id ${session.host_agent_id}, next task ${nextTask}` };
|
|
1588
1880
|
}
|
|
1589
1881
|
// src/iteration.ts
|
|
1590
|
-
import { existsSync as existsSync4, readdirSync as
|
|
1591
|
-
import { join as
|
|
1882
|
+
import { existsSync as existsSync4, readdirSync as readdirSync4, readFileSync as readFileSync6 } from "node:fs";
|
|
1883
|
+
import { join as join8 } from "node:path";
|
|
1592
1884
|
var COMPASS_STATUSES = ["active", "locked", "completed"];
|
|
1593
1885
|
var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
|
|
1594
1886
|
var PLAN_STATUS_DONE = "Done";
|
|
@@ -1666,18 +1958,18 @@ function validateCompassShape(doc) {
|
|
|
1666
1958
|
}
|
|
1667
1959
|
};
|
|
1668
1960
|
}
|
|
1669
|
-
function
|
|
1961
|
+
function violation6(severity, code, message, fix) {
|
|
1670
1962
|
return { ok: false, severity, code, message, fix };
|
|
1671
1963
|
}
|
|
1672
|
-
function
|
|
1964
|
+
function isPlainObject4(value) {
|
|
1673
1965
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1674
1966
|
}
|
|
1675
1967
|
function validateCompassFrontmatter(doc) {
|
|
1676
|
-
if (!
|
|
1968
|
+
if (!isPlainObject4(doc)) {
|
|
1677
1969
|
return {
|
|
1678
1970
|
ok: false,
|
|
1679
1971
|
violations: [
|
|
1680
|
-
|
|
1972
|
+
violation6("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")
|
|
1681
1973
|
]
|
|
1682
1974
|
};
|
|
1683
1975
|
}
|
|
@@ -1687,17 +1979,17 @@ function validateCompassFrontmatter(doc) {
|
|
|
1687
1979
|
ok: false,
|
|
1688
1980
|
violations: parsed.issues.map((issue) => {
|
|
1689
1981
|
const field = issue.path.join(".") || "(root)";
|
|
1690
|
-
return
|
|
1982
|
+
return violation6("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)`);
|
|
1691
1983
|
})
|
|
1692
1984
|
};
|
|
1693
1985
|
}
|
|
1694
1986
|
const violations = [];
|
|
1695
1987
|
const { status, end_date } = parsed.data;
|
|
1696
1988
|
if (status === "completed" && end_date === undefined) {
|
|
1697
|
-
violations.push(
|
|
1989
|
+
violations.push(violation6("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"));
|
|
1698
1990
|
}
|
|
1699
1991
|
if (status !== "completed" && end_date !== undefined) {
|
|
1700
|
-
violations.push(
|
|
1992
|
+
violations.push(violation6("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"));
|
|
1701
1993
|
}
|
|
1702
1994
|
return { ok: violations.length === 0, violations };
|
|
1703
1995
|
}
|
|
@@ -1706,11 +1998,11 @@ function registeredPlanIds(compassDoc) {
|
|
|
1706
1998
|
return [];
|
|
1707
1999
|
return compassDoc.plans.filter((plan) => typeof plan === "string" && plan.length > 0);
|
|
1708
2000
|
}
|
|
1709
|
-
function findPlanRow(
|
|
1710
|
-
if (!Array.isArray(
|
|
2001
|
+
function findPlanRow(snapshotDoc, planId) {
|
|
2002
|
+
if (!Array.isArray(snapshotDoc.plans))
|
|
1711
2003
|
return null;
|
|
1712
|
-
for (const row of
|
|
1713
|
-
if (!
|
|
2004
|
+
for (const row of snapshotDoc.plans) {
|
|
2005
|
+
if (!isPlainObject4(row))
|
|
1714
2006
|
continue;
|
|
1715
2007
|
const rowId = typeof row.id === "string" ? row.id : typeof row.plan_id === "string" ? row.plan_id : null;
|
|
1716
2008
|
if (rowId === planId)
|
|
@@ -1718,65 +2010,35 @@ function findPlanRow(statusDoc, planId) {
|
|
|
1718
2010
|
}
|
|
1719
2011
|
return null;
|
|
1720
2012
|
}
|
|
1721
|
-
function entryPlansAllDone(
|
|
2013
|
+
function entryPlansAllDone(snapshotDoc, registered) {
|
|
1722
2014
|
const violations = [];
|
|
1723
2015
|
if (registered.length === 0) {
|
|
1724
|
-
violations.push(
|
|
2016
|
+
violations.push(violation6("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`"));
|
|
1725
2017
|
return violations;
|
|
1726
2018
|
}
|
|
1727
2019
|
for (const planId of registered) {
|
|
1728
|
-
const row = findPlanRow(
|
|
2020
|
+
const row = findPlanRow(snapshotDoc, planId);
|
|
1729
2021
|
if (row === null) {
|
|
1730
|
-
violations.push(
|
|
2022
|
+
violations.push(violation6("high", "PLAN_NOT_IN_STATUS", `Plan '${planId}' is registered in the compass frontmatter but has no row in the workflow snapshot plans[] (mstar-iteration §3.1 entry item 1)`, "Add the plan row to {HARNESS_DIR}/workflows/<id>/snapshot.json"));
|
|
1731
2023
|
continue;
|
|
1732
2024
|
}
|
|
1733
2025
|
if (row.status !== PLAN_STATUS_DONE) {
|
|
1734
|
-
violations.push(
|
|
2026
|
+
violations.push(violation6("high", "PLAN_NOT_DONE", `Plan '${planId}' status is ${JSON.stringify(row.status)} in the workflow snapshot — all compass-registered plans must be 'Done' before iteration-close (mstar-iteration §3.1 entry item 1)`));
|
|
1735
2027
|
}
|
|
1736
2028
|
}
|
|
1737
2029
|
return violations;
|
|
1738
2030
|
}
|
|
1739
|
-
function entryResidualsOpen(statusDoc, planId) {
|
|
1740
|
-
const violations = [];
|
|
1741
|
-
const residualRoot = statusDoc.residual_findings;
|
|
1742
|
-
if (residualRoot === undefined || residualRoot === null)
|
|
1743
|
-
return violations;
|
|
1744
|
-
if (!isPlainObject3(residualRoot)) {
|
|
1745
|
-
violations.push(violation5("medium", "RESIDUAL_MALFORMED", "status.json residual_findings must be a plan-id → entries object (mstar-iteration §3.1 entry item 2)"));
|
|
1746
|
-
return violations;
|
|
1747
|
-
}
|
|
1748
|
-
const entries = residualRoot[planId];
|
|
1749
|
-
if (entries === undefined)
|
|
1750
|
-
return violations;
|
|
1751
|
-
if (!Array.isArray(entries)) {
|
|
1752
|
-
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)`));
|
|
1753
|
-
return violations;
|
|
1754
|
-
}
|
|
1755
|
-
const openIds = [];
|
|
1756
|
-
for (const entry of entries) {
|
|
1757
|
-
if (!isPlainObject3(entry) || !isOpenResidual(entry))
|
|
1758
|
-
continue;
|
|
1759
|
-
const isBlockerDefer = entry.decision === "defer" && typeof entry.target === "string" && entry.target.trim() !== "";
|
|
1760
|
-
if (isBlockerDefer)
|
|
1761
|
-
continue;
|
|
1762
|
-
openIds.push(typeof entry.id === "string" ? entry.id : "<unnamed>");
|
|
1763
|
-
}
|
|
1764
|
-
if (openIds.length > 0) {
|
|
1765
|
-
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"));
|
|
1766
|
-
}
|
|
1767
|
-
return violations;
|
|
1768
|
-
}
|
|
1769
2031
|
function entryFrontmatterComplete(compassDoc) {
|
|
1770
2032
|
return validateCompassFrontmatter(compassDoc).violations;
|
|
1771
2033
|
}
|
|
1772
2034
|
function exitFrontmatterClosed(compassDoc) {
|
|
1773
2035
|
const violations = [];
|
|
1774
2036
|
if (compassDoc.status !== "completed") {
|
|
1775
|
-
violations.push(
|
|
2037
|
+
violations.push(violation6("high", "EXIT_STATUS_NOT_COMPLETED", `Compass frontmatter status must be 'completed' at close exit — current: ${JSON.stringify(compassDoc.status)} (mstar-iteration §3.4 / §3.5 exit item 4)`));
|
|
1776
2038
|
}
|
|
1777
2039
|
const endDate = compassDoc.end_date;
|
|
1778
2040
|
if (typeof endDate !== "string" || !DATE_RE2.test(endDate)) {
|
|
1779
|
-
violations.push(
|
|
2041
|
+
violations.push(violation6("high", "EXIT_END_DATE_REQUIRED", "Compass frontmatter end_date (YYYY-MM-DD) is required when closing (mstar-iteration §3.4 / §3.5 exit item 4)"));
|
|
1780
2042
|
}
|
|
1781
2043
|
return violations;
|
|
1782
2044
|
}
|
|
@@ -1784,9 +2046,9 @@ function exitBranchCheck(opts) {
|
|
|
1784
2046
|
const violations = [];
|
|
1785
2047
|
const { currentBranch, specIntegrationBranch } = opts;
|
|
1786
2048
|
if (currentBranch === undefined || specIntegrationBranch === undefined) {
|
|
1787
|
-
violations.push(
|
|
2049
|
+
violations.push(violation6("medium", "EXIT_BRANCH_UNVERIFIABLE", "Cannot verify the current branch is spec_integration_branch — missing currentBranch / specIntegrationBranch probe inputs (mstar-iteration §3.5 exit item 5)"));
|
|
1788
2050
|
} else if (currentBranch !== specIntegrationBranch) {
|
|
1789
|
-
violations.push(
|
|
2051
|
+
violations.push(violation6("high", "EXIT_BRANCH_MISMATCH", `Current branch '${currentBranch}' is not the spec_integration_branch '${specIntegrationBranch}' (mstar-iteration §3.5 exit item 5)`));
|
|
1790
2052
|
}
|
|
1791
2053
|
return violations;
|
|
1792
2054
|
}
|
|
@@ -1795,17 +2057,16 @@ function exitPrBaseCheck(compassDoc, opts) {
|
|
|
1795
2057
|
const target = compassDoc.target_branch;
|
|
1796
2058
|
const { prBaseBranch } = opts;
|
|
1797
2059
|
if (prBaseBranch === undefined) {
|
|
1798
|
-
violations.push(
|
|
2060
|
+
violations.push(violation6("medium", "EXIT_PR_BASE_UNVERIFIABLE", "Cannot verify the PR base — missing prBaseBranch probe input (mstar-iteration §3.5 exit item 6)"));
|
|
1799
2061
|
} else if (typeof target !== "string" || prBaseBranch !== target) {
|
|
1800
|
-
violations.push(
|
|
2062
|
+
violations.push(violation6("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)`));
|
|
1801
2063
|
}
|
|
1802
2064
|
return violations;
|
|
1803
2065
|
}
|
|
1804
|
-
function evaluatePhaseGate(
|
|
2066
|
+
function evaluatePhaseGate(snapshotDoc, compassDoc, opts = {}) {
|
|
1805
2067
|
const registered = registeredPlanIds(compassDoc);
|
|
1806
2068
|
const entryViolations = [
|
|
1807
|
-
...entryPlansAllDone(
|
|
1808
|
-
...registered.flatMap((planId) => entryResidualsOpen(statusDoc, planId)),
|
|
2069
|
+
...entryPlansAllDone(snapshotDoc, registered),
|
|
1809
2070
|
...entryFrontmatterComplete(compassDoc)
|
|
1810
2071
|
];
|
|
1811
2072
|
const exitViolations = [
|
|
@@ -1814,7 +2075,7 @@ function evaluatePhaseGate(statusDoc, compassDoc, opts = {}) {
|
|
|
1814
2075
|
...exitPrBaseCheck(compassDoc, opts)
|
|
1815
2076
|
];
|
|
1816
2077
|
const allPlansDone = registered.length > 0 && registered.every((planId) => {
|
|
1817
|
-
const row = findPlanRow(
|
|
2078
|
+
const row = findPlanRow(snapshotDoc, planId);
|
|
1818
2079
|
return row !== null && row.status === PLAN_STATUS_DONE;
|
|
1819
2080
|
});
|
|
1820
2081
|
const entry = { ok: entryViolations.length === 0, violations: entryViolations };
|
|
@@ -1839,10 +2100,10 @@ function evaluatePhaseGate(statusDoc, compassDoc, opts = {}) {
|
|
|
1839
2100
|
function pushCadenceProbe(ciRunning, reviewWaveActive) {
|
|
1840
2101
|
const violations = [];
|
|
1841
2102
|
if (ciRunning) {
|
|
1842
|
-
violations.push(
|
|
2103
|
+
violations.push(violation6("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"));
|
|
1843
2104
|
}
|
|
1844
2105
|
if (reviewWaveActive) {
|
|
1845
|
-
violations.push(
|
|
2106
|
+
violations.push(violation6("high", "PUSH_BLOCKED_REVIEW_WAVE", "An AI/bot review wave is still running on the current head — do not push until it settles (mstar-iteration §5.1a push gate 2)", "Wait for the review wave, then push once"));
|
|
1846
2107
|
}
|
|
1847
2108
|
return { ok: violations.length === 0, violations };
|
|
1848
2109
|
}
|
|
@@ -1851,24 +2112,24 @@ function assertIndexRowObligations(iterationsDir) {
|
|
|
1851
2112
|
return {
|
|
1852
2113
|
ok: false,
|
|
1853
2114
|
violations: [
|
|
1854
|
-
|
|
2115
|
+
violation6("high", "INDEX_ITERATIONS_DIR_MISSING", `{ITERATION_DIR} '${iterationsDir}' does not exist (mstar-iteration §1.4)`, "Create the iterations directory (path.resolveIterationDir)")
|
|
1855
2116
|
]
|
|
1856
2117
|
};
|
|
1857
2118
|
}
|
|
1858
|
-
const iterationIds =
|
|
1859
|
-
const readmePath =
|
|
2119
|
+
const iterationIds = readdirSync4(iterationsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).filter((entry) => existsSync4(join8(iterationsDir, entry.name, COMPASS_FILE))).map((entry) => entry.name).sort();
|
|
2120
|
+
const readmePath = join8(iterationsDir, INDEX_README);
|
|
1860
2121
|
if (!existsSync4(readmePath)) {
|
|
1861
2122
|
return {
|
|
1862
2123
|
ok: false,
|
|
1863
2124
|
violations: [
|
|
1864
|
-
|
|
2125
|
+
violation6("high", "INDEX_README_MISSING", `{ITERATION_DIR}/README.md does not exist — one row per iteration is required (mstar-iteration §1.4)`, `Create {ITERATION_DIR}/README.md with the header '${INDEX_HEADER}' and one row per iteration`)
|
|
1865
2126
|
]
|
|
1866
2127
|
};
|
|
1867
2128
|
}
|
|
1868
2129
|
const violations = [];
|
|
1869
|
-
const lines =
|
|
2130
|
+
const lines = readFileSync6(readmePath, "utf8").split(/\r?\n/);
|
|
1870
2131
|
if (!lines.some((line) => line.includes(INDEX_HEADER))) {
|
|
1871
|
-
violations.push(
|
|
2132
|
+
violations.push(violation6("medium", "INDEX_HEADER_MISSING", `{ITERATION_DIR}/README.md lacks the table header '${INDEX_HEADER}' (mstar-iteration §1.4)`, "Add the header row on first creation"));
|
|
1872
2133
|
}
|
|
1873
2134
|
const indexed = new Set;
|
|
1874
2135
|
for (const line of lines) {
|
|
@@ -1878,13 +2139,15 @@ function assertIndexRowObligations(iterationsDir) {
|
|
|
1878
2139
|
}
|
|
1879
2140
|
for (const id of iterationIds) {
|
|
1880
2141
|
if (!indexed.has(id)) {
|
|
1881
|
-
violations.push(
|
|
2142
|
+
violations.push(violation6("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> |`));
|
|
1882
2143
|
}
|
|
1883
2144
|
}
|
|
1884
2145
|
return { ok: violations.length === 0, violations };
|
|
1885
2146
|
}
|
|
1886
2147
|
function parseCompassFrontmatter(filePath) {
|
|
1887
|
-
|
|
2148
|
+
return parseCompassFrontmatterText(readFileSync6(filePath, "utf8"), filePath);
|
|
2149
|
+
}
|
|
2150
|
+
function parseCompassFrontmatterText(content, filePath) {
|
|
1888
2151
|
const lines = content.split(/\r?\n/);
|
|
1889
2152
|
if (lines[0]?.trim() !== "---") {
|
|
1890
2153
|
throw new Error(`no YAML frontmatter fence in ${filePath} (expected first line "---")`);
|
|
@@ -1945,8 +2208,765 @@ function parseFlowArray(raw, filePath) {
|
|
|
1945
2208
|
}
|
|
1946
2209
|
return items;
|
|
1947
2210
|
}
|
|
2211
|
+
// src/project.ts
|
|
2212
|
+
import { existsSync as existsSync5, readFileSync as readFileSync7, readdirSync as readdirSync5 } from "node:fs";
|
|
2213
|
+
import { join as join9 } from "node:path";
|
|
2214
|
+
var PROJECT_ROADMAP_FILE = "roadmap.md";
|
|
2215
|
+
var PROJECT_REGISTER_FILE = "residuals.json";
|
|
2216
|
+
var _DEFAULT_PROJECT = "_default";
|
|
2217
|
+
var ROADMAP_STATUSES = ["active", "paused", "completed"];
|
|
2218
|
+
var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
|
|
2219
|
+
var ROLLUP_FIELDS = ["total_open", "by_severity", "by_target", "by_plan"];
|
|
2220
|
+
function isPlainObject5(value) {
|
|
2221
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2222
|
+
}
|
|
2223
|
+
function violation7(severity, code, message, fix) {
|
|
2224
|
+
return { ok: false, severity, code, message, fix };
|
|
2225
|
+
}
|
|
2226
|
+
function validateNonEmptyString4(violations, value, field, missingCode, invalidCode) {
|
|
2227
|
+
if (value === undefined) {
|
|
2228
|
+
violations.push(violation7("high", missingCode, `missing required field: ${field}`));
|
|
2229
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
2230
|
+
violations.push(violation7("medium", invalidCode, `${field} must be a non-empty string`));
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
function validateRoadmap(filePath) {
|
|
2234
|
+
const violations = [];
|
|
2235
|
+
let content;
|
|
2236
|
+
try {
|
|
2237
|
+
content = readFileSync7(filePath, "utf8");
|
|
2238
|
+
} catch {
|
|
2239
|
+
return {
|
|
2240
|
+
ok: false,
|
|
2241
|
+
violations: [violation7("high", "project.roadmap.unreadable", `cannot read roadmap file: ${filePath}`)],
|
|
2242
|
+
warnings: []
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
let doc;
|
|
2246
|
+
try {
|
|
2247
|
+
doc = parseCompassFrontmatterText(content, filePath);
|
|
2248
|
+
} catch (err) {
|
|
2249
|
+
const message = err instanceof Error ? err.message : `invalid roadmap frontmatter in ${filePath}`;
|
|
2250
|
+
return { ok: false, violations: [violation7("high", "project.roadmap.invalid-frontmatter", message)], warnings: [] };
|
|
2251
|
+
}
|
|
2252
|
+
validateNonEmptyString4(violations, doc.project_id, "project_id", "project.roadmap.missing-project-id", "project.roadmap.invalid-project-id");
|
|
2253
|
+
validateNonEmptyString4(violations, doc.title, "title", "project.roadmap.missing-title", "project.roadmap.invalid-title");
|
|
2254
|
+
if (doc.status === undefined) {
|
|
2255
|
+
violations.push(violation7("high", "project.roadmap.missing-status", "missing required field: status"));
|
|
2256
|
+
} else if (typeof doc.status !== "string" || !ROADMAP_STATUSES.includes(doc.status)) {
|
|
2257
|
+
violations.push(violation7("medium", "project.roadmap.invalid-status", `status must be one of ${ROADMAP_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
|
|
2258
|
+
}
|
|
2259
|
+
if (doc.created_at === undefined) {
|
|
2260
|
+
violations.push(violation7("high", "project.roadmap.missing-created-at", "missing required field: created_at"));
|
|
2261
|
+
} else if (typeof doc.created_at !== "string" || !DATE_RE3.test(doc.created_at)) {
|
|
2262
|
+
violations.push(violation7("medium", "project.roadmap.invalid-created-at", "created_at must be YYYY-MM-DD"));
|
|
2263
|
+
}
|
|
2264
|
+
if (doc.milestones !== undefined && doc.milestones !== null) {
|
|
2265
|
+
if (!Array.isArray(doc.milestones)) {
|
|
2266
|
+
violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones must be a list of milestone names"));
|
|
2267
|
+
} else {
|
|
2268
|
+
for (const item of doc.milestones) {
|
|
2269
|
+
if (typeof item !== "string" || item.trim() === "") {
|
|
2270
|
+
violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones items must be non-empty strings"));
|
|
2271
|
+
break;
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
if (doc.residuals_ref !== undefined && doc.residuals_ref !== null) {
|
|
2277
|
+
if (typeof doc.residuals_ref !== "string" || doc.residuals_ref.trim() === "") {
|
|
2278
|
+
violations.push(violation7("medium", "project.roadmap.invalid-residuals-ref", "residuals_ref must be a non-empty string"));
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
const warnings = [];
|
|
2282
|
+
const fenceEnd = linesIndexOfClosingFence(content);
|
|
2283
|
+
const body = content.split(/\r?\n/).slice(fenceEnd + 1).join(`
|
|
2284
|
+
`);
|
|
2285
|
+
if (!/^##\s+Direction\s*$/m.test(body)) {
|
|
2286
|
+
warnings.push(violation7("low", "project.roadmap.body.missing-direction", "roadmap body has no `## Direction` section (documented body convention) — state the project direction there"));
|
|
2287
|
+
}
|
|
2288
|
+
if (!/^\s*[-*]\s+\[[xX ]\]/m.test(body)) {
|
|
2289
|
+
warnings.push(violation7("low", "project.roadmap.body.no-goal-items", "roadmap body has no goal-item task list (documented body convention) — list goals as `- [ ]` / `- [x]` markdown task items"));
|
|
2290
|
+
}
|
|
2291
|
+
return { ok: violations.length === 0, violations, warnings };
|
|
2292
|
+
}
|
|
2293
|
+
function linesIndexOfClosingFence(content) {
|
|
2294
|
+
return content.split(/\r?\n/).indexOf("---", 1);
|
|
2295
|
+
}
|
|
2296
|
+
function validateProjectRegister(doc) {
|
|
2297
|
+
const violations = [];
|
|
2298
|
+
if (!isPlainObject5(doc)) {
|
|
2299
|
+
return {
|
|
2300
|
+
ok: false,
|
|
2301
|
+
violations: [violation7("high", "project.register.invalid", "project register must be an object")]
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
if (doc.entries === undefined) {
|
|
2305
|
+
violations.push(violation7("high", "project.register.missing-entries", "missing required field: entries"));
|
|
2306
|
+
} else if (!isPlainObject5(doc.entries)) {
|
|
2307
|
+
violations.push(violation7("high", "project.register.invalid-entries", "entries must be an object keyed by plan id"));
|
|
2308
|
+
} else {
|
|
2309
|
+
for (const [key, entries] of Object.entries(doc.entries)) {
|
|
2310
|
+
if (key.trim() === "") {
|
|
2311
|
+
violations.push(violation7("medium", "project.register.invalid-key", "entries keys must be non-empty plan ids"));
|
|
2312
|
+
}
|
|
2313
|
+
if (!Array.isArray(entries)) {
|
|
2314
|
+
violations.push(violation7("high", "project.register.invalid-entry-list", `entries[${JSON.stringify(key)}] must be an array of residual entries (one entry per residual; v1 multi-finding semantics)`));
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
for (const entry of entries) {
|
|
2318
|
+
violations.push(...validateResidual(entry).violations);
|
|
2319
|
+
if (!isPlainObject5(entry))
|
|
2320
|
+
continue;
|
|
2321
|
+
validateNonEmptyString4(violations, entry.source_plan, "source_plan", "project.register.missing-source-plan", "project.register.invalid-source-plan");
|
|
2322
|
+
if (entry.registered_at === undefined) {
|
|
2323
|
+
violations.push(violation7("high", "project.register.missing-registered-at", "missing required field: registered_at"));
|
|
2324
|
+
} else if (typeof entry.registered_at !== "string" || !DATE_RE3.test(entry.registered_at)) {
|
|
2325
|
+
violations.push(violation7("medium", "project.register.invalid-registered-at", "registered_at must be YYYY-MM-DD"));
|
|
2326
|
+
}
|
|
2327
|
+
if (entry.lifecycle_id !== undefined && (typeof entry.lifecycle_id !== "string" || entry.lifecycle_id.trim() === "")) {
|
|
2328
|
+
violations.push(violation7("medium", "project.register.invalid-lifecycle-id", "lifecycle_id must be a non-empty string"));
|
|
2329
|
+
}
|
|
2330
|
+
if (typeof entry.source_plan === "string" && entry.source_plan.trim() !== "" && entry.source_plan !== key) {
|
|
2331
|
+
violations.push(violation7("medium", "project.register.mismatched-source-plan", `source_plan ${JSON.stringify(entry.source_plan)} does not match the entries key ${JSON.stringify(key)} — entries are keyed by plan id`));
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
return { ok: violations.length === 0, violations };
|
|
2337
|
+
}
|
|
2338
|
+
function findingsCleanupGate(register, planId, opts) {
|
|
2339
|
+
const mode = opts?.mode ?? "allow-residual";
|
|
2340
|
+
const violations = [];
|
|
2341
|
+
const entries = isPlainObject5(register.entries) ? register.entries[planId] : undefined;
|
|
2342
|
+
if (entries === undefined) {
|
|
2343
|
+
return { ok: true, violations };
|
|
2344
|
+
}
|
|
2345
|
+
if (!Array.isArray(entries)) {
|
|
2346
|
+
violations.push(violation7("high", "project.register.invalid-entry-list", `entries[${JSON.stringify(planId)}] must be an array of residual entries (one entry per residual; v1 multi-finding semantics)`));
|
|
2347
|
+
return { ok: false, violations };
|
|
2348
|
+
}
|
|
2349
|
+
if (entries.length === 0) {
|
|
2350
|
+
return { ok: true, violations };
|
|
2351
|
+
}
|
|
2352
|
+
for (const entry of entries) {
|
|
2353
|
+
if (!isOpenResidual(entry))
|
|
2354
|
+
continue;
|
|
2355
|
+
const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
|
|
2356
|
+
const label = `R#${id}`;
|
|
2357
|
+
if (mode === "zero-residual") {
|
|
2358
|
+
if (entry.severity === "nit") {
|
|
2359
|
+
violations.push(violation7("medium", "findings.zero-residual-nit", `${label}: style-only nits must be fixed in-session or dropped — never left open under zero-residual`));
|
|
2360
|
+
} else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
|
|
2361
|
+
violations.push(violation7("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
|
|
2362
|
+
} else if (entry.decision === "defer") {
|
|
2363
|
+
if (typeof entry.target !== "string" || entry.target.trim() === "") {
|
|
2364
|
+
violations.push(violation7("medium", "findings.zero-residual-defer-no-target", `${label}: blocker-defer requires a target (next iteration/milestone) under zero-residual`));
|
|
2365
|
+
}
|
|
2366
|
+
} else {
|
|
2367
|
+
violations.push(violation7("medium", "findings.zero-residual-open-fixable", `${label}: fixable finding must not remain open under zero-residual — fix now or convert to a blocker-defer`));
|
|
2368
|
+
}
|
|
2369
|
+
} else if (normalizeSeverity(entry.severity) === "critical") {
|
|
2370
|
+
violations.push(violation7("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
return { ok: violations.length === 0, violations };
|
|
2374
|
+
}
|
|
2375
|
+
function groupCount(values) {
|
|
2376
|
+
const counts = new Map;
|
|
2377
|
+
for (const value of values) {
|
|
2378
|
+
const key = typeof value === "string" ? value : String(value);
|
|
2379
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
2380
|
+
}
|
|
2381
|
+
return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
2382
|
+
}
|
|
2383
|
+
function techDebtRollup(projectDir) {
|
|
2384
|
+
const items = [];
|
|
2385
|
+
let entries;
|
|
2386
|
+
try {
|
|
2387
|
+
entries = readdirSync5(projectDir, { withFileTypes: true });
|
|
2388
|
+
} catch {
|
|
2389
|
+
entries = [];
|
|
2390
|
+
}
|
|
2391
|
+
for (const project of entries) {
|
|
2392
|
+
if (!project.isDirectory())
|
|
2393
|
+
continue;
|
|
2394
|
+
const registerPath = join9(projectDir, project.name, PROJECT_REGISTER_FILE);
|
|
2395
|
+
if (!existsSync5(registerPath))
|
|
2396
|
+
continue;
|
|
2397
|
+
let register;
|
|
2398
|
+
try {
|
|
2399
|
+
register = readJson(registerPath);
|
|
2400
|
+
} catch {
|
|
2401
|
+
continue;
|
|
2402
|
+
}
|
|
2403
|
+
if (!isPlainObject5(register) || !isPlainObject5(register.entries))
|
|
2404
|
+
continue;
|
|
2405
|
+
for (const [plan, planEntries] of Object.entries(register.entries)) {
|
|
2406
|
+
if (!Array.isArray(planEntries))
|
|
2407
|
+
continue;
|
|
2408
|
+
for (const entry of planEntries) {
|
|
2409
|
+
if (!isPlainObject5(entry) || !isOpenResidual(entry))
|
|
2410
|
+
continue;
|
|
2411
|
+
items.push({ plan, entry });
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
const bySeverity = {};
|
|
2416
|
+
for (const severity of SEVERITY_ORDER) {
|
|
2417
|
+
bySeverity[severity] = items.filter(({ entry }) => normalizeSeverity(entry.severity) === severity).length;
|
|
2418
|
+
}
|
|
2419
|
+
const computed = {
|
|
2420
|
+
total_open: items.length,
|
|
2421
|
+
by_severity: bySeverity,
|
|
2422
|
+
by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
|
|
2423
|
+
by_plan: groupCount(items.map(({ plan }) => plan))
|
|
2424
|
+
};
|
|
2425
|
+
const stored = null;
|
|
2426
|
+
const checks = ROLLUP_FIELDS.map((field) => ({ field, status: "DRIFT" }));
|
|
2427
|
+
const overall = "DRIFT";
|
|
2428
|
+
return { computed, stored, checks, overall };
|
|
2429
|
+
}
|
|
2430
|
+
// src/migrate.ts
|
|
2431
|
+
import { copyFileSync, mkdirSync as mkdirSync6, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2432
|
+
import { dirname as dirname7, isAbsolute as isAbsolute6, join as join10, relative as relative3, resolve as resolve8, sep as sep2 } from "node:path";
|
|
2433
|
+
var MIGRATE_STATUS_FILE = "status.json";
|
|
2434
|
+
var ARCHIVED_STATUS_V1_FILE = "archived/status.v1.json";
|
|
2435
|
+
var NOTES_LEDGER_FILE = "notes.jsonl";
|
|
2436
|
+
var DATE_RE4 = /^\d{4}-\d{2}-\d{2}$/;
|
|
2437
|
+
var ROOT_METADATA_LIFT_KEYS = {
|
|
2438
|
+
plan_parallelism: true,
|
|
2439
|
+
worktree_mode: true,
|
|
2440
|
+
push_policy: true,
|
|
2441
|
+
iteration_base_branch: true,
|
|
2442
|
+
target_branch: true,
|
|
2443
|
+
spec_integration_branch: true,
|
|
2444
|
+
control_worktree_path: true,
|
|
2445
|
+
integration_merge_lease: true,
|
|
2446
|
+
program_roadmap: true,
|
|
2447
|
+
updated_at: true
|
|
2448
|
+
};
|
|
2449
|
+
function isPlainObject6(value) {
|
|
2450
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2451
|
+
}
|
|
2452
|
+
function dateString(value) {
|
|
2453
|
+
return typeof value === "string" && DATE_RE4.test(value) ? value : undefined;
|
|
2454
|
+
}
|
|
2455
|
+
function rowIdOf(row) {
|
|
2456
|
+
if (typeof row.id === "string" && row.id.trim() !== "")
|
|
2457
|
+
return row.id;
|
|
2458
|
+
if (typeof row.plan_id === "string" && row.plan_id.trim() !== "")
|
|
2459
|
+
return row.plan_id;
|
|
2460
|
+
return null;
|
|
2461
|
+
}
|
|
2462
|
+
function compareIds(a, b) {
|
|
2463
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
2464
|
+
}
|
|
2465
|
+
function todayString2() {
|
|
2466
|
+
const now = new Date;
|
|
2467
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
2468
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
2469
|
+
return `${now.getFullYear()}-${month}-${day}`;
|
|
2470
|
+
}
|
|
2471
|
+
function scanCompasses(harnessDir) {
|
|
2472
|
+
const iterationsDir = join10(harnessDir, "iterations");
|
|
2473
|
+
const out = [];
|
|
2474
|
+
let entries;
|
|
2475
|
+
try {
|
|
2476
|
+
entries = readdirSync6(iterationsDir, { withFileTypes: true });
|
|
2477
|
+
} catch {
|
|
2478
|
+
return out;
|
|
2479
|
+
}
|
|
2480
|
+
for (const entry of entries) {
|
|
2481
|
+
if (!entry.isDirectory())
|
|
2482
|
+
continue;
|
|
2483
|
+
const compassPath = join10(iterationsDir, entry.name, "delivery-compass.md");
|
|
2484
|
+
let content;
|
|
2485
|
+
try {
|
|
2486
|
+
content = readFileSync8(compassPath, "utf8");
|
|
2487
|
+
} catch {
|
|
2488
|
+
continue;
|
|
2489
|
+
}
|
|
2490
|
+
const doc = parseCompassFrontmatterText(content, compassPath);
|
|
2491
|
+
const status = doc.status;
|
|
2492
|
+
if (status !== "active" && status !== "locked" && status !== "completed") {
|
|
2493
|
+
throw new Error(`refusing to migrate: compass ${JSON.stringify(compassPath)} has unsupported status ${JSON.stringify(status)} (expected active | locked | completed)`);
|
|
2494
|
+
}
|
|
2495
|
+
const plans = Array.isArray(doc.plans) ? doc.plans.filter((p) => typeof p === "string" && p !== "") : [];
|
|
2496
|
+
out.push({
|
|
2497
|
+
id: typeof doc.iteration_id === "string" && doc.iteration_id !== "" ? doc.iteration_id : entry.name,
|
|
2498
|
+
file: compassPath,
|
|
2499
|
+
status,
|
|
2500
|
+
plans,
|
|
2501
|
+
startDate: dateString(doc.start_date),
|
|
2502
|
+
endDate: dateString(doc.end_date)
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
out.sort((a, b) => compareIds(a.id, b.id));
|
|
2506
|
+
return out;
|
|
2507
|
+
}
|
|
2508
|
+
function pickDate(preferred, fallback) {
|
|
2509
|
+
for (const candidate of preferred) {
|
|
2510
|
+
if (candidate !== undefined)
|
|
2511
|
+
return candidate;
|
|
2512
|
+
}
|
|
2513
|
+
return fallback;
|
|
2514
|
+
}
|
|
2515
|
+
function groupRows(rows, compasses) {
|
|
2516
|
+
const rowById = new Map;
|
|
2517
|
+
for (const row of rows) {
|
|
2518
|
+
const id = rowIdOf(row);
|
|
2519
|
+
if (id !== null && !rowById.has(id))
|
|
2520
|
+
rowById.set(id, row);
|
|
2521
|
+
}
|
|
2522
|
+
const byPlan = new Map;
|
|
2523
|
+
for (const compass of compasses) {
|
|
2524
|
+
for (const planId of compass.plans) {
|
|
2525
|
+
if (!byPlan.has(planId))
|
|
2526
|
+
byPlan.set(planId, compass);
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
return { byPlan, rowById };
|
|
2530
|
+
}
|
|
2531
|
+
function buildIterationSnapshot(compass, rowById, rootUpdatedAt) {
|
|
2532
|
+
const rows = compass.plans.map((planId) => rowById.get(planId)).filter((row) => row !== undefined);
|
|
2533
|
+
rows.sort((a, b) => compareIds(rowIdOf(a) ?? "", rowIdOf(b) ?? ""));
|
|
2534
|
+
const rowDates = rows.flatMap((row) => [
|
|
2535
|
+
dateString(row.created_at),
|
|
2536
|
+
dateString(row.updated_at),
|
|
2537
|
+
dateString(row.done_at)
|
|
2538
|
+
]);
|
|
2539
|
+
const startedAt = pickDate([compass.startDate, ...rowDates], rootUpdatedAt);
|
|
2540
|
+
const endedAt = pickDate([compass.endDate, ...rowDates], rootUpdatedAt);
|
|
2541
|
+
const status = compass.status === "completed" ? "completed" : "running";
|
|
2542
|
+
const compactMissing = compass.plans.filter((planId) => !rowById.has(planId)).sort();
|
|
2543
|
+
const snapshot = {
|
|
2544
|
+
schema_version: 1,
|
|
2545
|
+
id: compass.id,
|
|
2546
|
+
type: "iteration",
|
|
2547
|
+
status,
|
|
2548
|
+
started_at: startedAt,
|
|
2549
|
+
...status === "completed" ? { ended_at: endedAt } : {},
|
|
2550
|
+
updated_at: status === "completed" ? endedAt : startedAt,
|
|
2551
|
+
plans: rows,
|
|
2552
|
+
compass_ref: `iterations/${compass.id}/delivery-compass.md`
|
|
2553
|
+
};
|
|
2554
|
+
const legacyMetadata = {};
|
|
2555
|
+
if (compactMissing.length > 0)
|
|
2556
|
+
legacyMetadata.compact_missing = compactMissing;
|
|
2557
|
+
if (Object.keys(legacyMetadata).length > 0)
|
|
2558
|
+
snapshot.legacy_metadata = legacyMetadata;
|
|
2559
|
+
return {
|
|
2560
|
+
id: compass.id,
|
|
2561
|
+
type: "iteration",
|
|
2562
|
+
status,
|
|
2563
|
+
file: join10("workflows", compass.id, WORKFLOW_SNAPSHOT_FILE),
|
|
2564
|
+
source: join10("iterations", compass.id, "delivery-compass.md"),
|
|
2565
|
+
data: snapshot
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
function buildStandaloneSnapshot(row, rootUpdatedAt, migrationNotes) {
|
|
2569
|
+
const id = rowIdOf(row) ?? "<unnamed>";
|
|
2570
|
+
const statusValue = row.status;
|
|
2571
|
+
let status;
|
|
2572
|
+
if (statusValue === "Done")
|
|
2573
|
+
status = "completed";
|
|
2574
|
+
else if (statusValue === "InProgress" || statusValue === "InReview")
|
|
2575
|
+
status = "running";
|
|
2576
|
+
else if (statusValue === "Todo" || statusValue === "Blocked")
|
|
2577
|
+
status = "paused";
|
|
2578
|
+
else {
|
|
2579
|
+
status = "paused";
|
|
2580
|
+
migrationNotes.push(`row ${JSON.stringify(id)} has unrecognized v1 status ${JSON.stringify(statusValue)} — snapshot status defaults to paused (row status stays verbatim)`);
|
|
2581
|
+
}
|
|
2582
|
+
const startedAt = pickDate([dateString(row.created_at), dateString(row.updated_at)], rootUpdatedAt);
|
|
2583
|
+
const updatedAt = pickDate([dateString(row.updated_at), dateString(row.done_at), dateString(row.created_at)], rootUpdatedAt);
|
|
2584
|
+
const endedAt = pickDate([dateString(row.done_at), dateString(row.updated_at)], rootUpdatedAt);
|
|
2585
|
+
const snapshot = {
|
|
2586
|
+
schema_version: 1,
|
|
2587
|
+
id,
|
|
2588
|
+
type: "plan",
|
|
2589
|
+
status,
|
|
2590
|
+
started_at: startedAt,
|
|
2591
|
+
...status === "completed" ? { ended_at: endedAt } : {},
|
|
2592
|
+
updated_at: status === "completed" ? endedAt : updatedAt,
|
|
2593
|
+
plans: [row]
|
|
2594
|
+
};
|
|
2595
|
+
return {
|
|
2596
|
+
id,
|
|
2597
|
+
type: "plan",
|
|
2598
|
+
status,
|
|
2599
|
+
file: join10("workflows", id, WORKFLOW_SNAPSHOT_FILE),
|
|
2600
|
+
source: "status.json plans[] row",
|
|
2601
|
+
data: snapshot
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
function applyRootMetadataLift(snapshot, metadata, migrationNotes, activeIterations) {
|
|
2605
|
+
const data = snapshot.data;
|
|
2606
|
+
const policy = {};
|
|
2607
|
+
for (const key of ["plan_parallelism", "worktree_mode", "push_policy"]) {
|
|
2608
|
+
if (metadata[key] !== undefined)
|
|
2609
|
+
policy[key] = metadata[key];
|
|
2610
|
+
}
|
|
2611
|
+
if (Object.keys(policy).length > 0)
|
|
2612
|
+
data.execution_policy = policy;
|
|
2613
|
+
const branch = {};
|
|
2614
|
+
const branchKeys = [
|
|
2615
|
+
["base", "iteration_base_branch"],
|
|
2616
|
+
["integration", "spec_integration_branch"],
|
|
2617
|
+
["target", "target_branch"]
|
|
2618
|
+
];
|
|
2619
|
+
for (const [target, source] of branchKeys) {
|
|
2620
|
+
const value = metadata[source];
|
|
2621
|
+
if (typeof value === "string" && value !== "")
|
|
2622
|
+
branch[target] = value;
|
|
2623
|
+
}
|
|
2624
|
+
if (Object.keys(branch).length > 0)
|
|
2625
|
+
data.branch = branch;
|
|
2626
|
+
if (typeof metadata.control_worktree_path === "string" && metadata.control_worktree_path !== "") {
|
|
2627
|
+
data.control_worktree_path = metadata.control_worktree_path;
|
|
2628
|
+
}
|
|
2629
|
+
if (isPlainObject6(metadata.integration_merge_lease)) {
|
|
2630
|
+
data.integration_merge_lease = metadata.integration_merge_lease;
|
|
2631
|
+
}
|
|
2632
|
+
const legacyMetadata = isPlainObject6(data.legacy_metadata) ? { ...data.legacy_metadata } : {};
|
|
2633
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
2634
|
+
if (key === "harness_root")
|
|
2635
|
+
continue;
|
|
2636
|
+
if (ROOT_METADATA_LIFT_KEYS[key] === true)
|
|
2637
|
+
continue;
|
|
2638
|
+
legacyMetadata[key] = value;
|
|
2639
|
+
}
|
|
2640
|
+
if (metadata.harness_root !== undefined) {
|
|
2641
|
+
legacyMetadata.harness_root_note = `dropped as redundant (v2 harness dir derives from status.json location): ${String(metadata.harness_root)}`;
|
|
2642
|
+
}
|
|
2643
|
+
if (Object.keys(legacyMetadata).length > 0)
|
|
2644
|
+
data.legacy_metadata = legacyMetadata;
|
|
2645
|
+
if (activeIterations > 1) {
|
|
2646
|
+
migrationNotes.push(`${activeIterations} active iterations present — root-metadata lifts applied to ${JSON.stringify(snapshot.id)} only`);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
function buildRoadmap(programRoadmap, projectId, migratedAt) {
|
|
2650
|
+
const title = typeof programRoadmap.title === "string" && programRoadmap.title !== "" ? programRoadmap.title : "Program roadmap";
|
|
2651
|
+
const doc = typeof programRoadmap.doc === "string" ? programRoadmap.doc : "";
|
|
2652
|
+
const completionVersion = typeof programRoadmap.completion_version === "string" ? programRoadmap.completion_version : "";
|
|
2653
|
+
const branch = typeof programRoadmap.branch === "string" ? programRoadmap.branch : "";
|
|
2654
|
+
const milestones = Array.isArray(programRoadmap.slices) ? programRoadmap.slices.map((slice) => String(slice)).filter((slice) => slice !== "") : [];
|
|
2655
|
+
const deferred = Array.isArray(programRoadmap.deferred_beyond) ? programRoadmap.deferred_beyond.map((item) => String(item)).filter((item) => item !== "") : [];
|
|
2656
|
+
const lines = [
|
|
2657
|
+
"---",
|
|
2658
|
+
`project_id: ${projectId}`,
|
|
2659
|
+
`title: ${title}`,
|
|
2660
|
+
"status: active",
|
|
2661
|
+
`created_at: ${migratedAt}`
|
|
2662
|
+
];
|
|
2663
|
+
if (milestones.length > 0) {
|
|
2664
|
+
lines.push("milestones:");
|
|
2665
|
+
for (const milestone of milestones)
|
|
2666
|
+
lines.push(` - ${milestone}`);
|
|
2667
|
+
}
|
|
2668
|
+
lines.push("residuals_ref: residuals.json", "---", "", "# Roadmap", "", "## Direction");
|
|
2669
|
+
const provenance = [doc !== "" ? `doc: ${doc}` : "", completionVersion !== "" ? `completion_version: ${completionVersion}` : "", branch !== "" ? `branch: ${branch}` : ""].filter((part) => part !== "").join(", ");
|
|
2670
|
+
lines.push(`Migrated from legacy status.json \`metadata.program_roadmap\`${provenance !== "" ? ` (${provenance})` : ""}.`);
|
|
2671
|
+
if (programRoadmap.no_intermediate_releases !== undefined) {
|
|
2672
|
+
lines.push(`no_intermediate_releases: ${String(programRoadmap.no_intermediate_releases)}`);
|
|
2673
|
+
}
|
|
2674
|
+
if (deferred.length > 0) {
|
|
2675
|
+
lines.push("", "### Deferred beyond", ...deferred.map((item) => `- ${item}`));
|
|
2676
|
+
}
|
|
2677
|
+
lines.push("");
|
|
2678
|
+
return lines.join(`
|
|
2679
|
+
`);
|
|
2680
|
+
}
|
|
2681
|
+
function buildRegister(residualFindings, byPlan, projectId, migratedAt) {
|
|
2682
|
+
const entries = {};
|
|
2683
|
+
const planKeys = Object.keys(residualFindings).sort();
|
|
2684
|
+
for (const planId of planKeys) {
|
|
2685
|
+
const raw = residualFindings[planId];
|
|
2686
|
+
if (!Array.isArray(raw))
|
|
2687
|
+
continue;
|
|
2688
|
+
const open = raw.filter((entry) => isPlainObject6(entry) && isOpenResidual(entry)).sort((a, b) => {
|
|
2689
|
+
const aId = typeof a.id === "string" ? a.id : "";
|
|
2690
|
+
const bId = typeof b.id === "string" ? b.id : "";
|
|
2691
|
+
return compareIds(aId, bId);
|
|
2692
|
+
});
|
|
2693
|
+
if (open.length === 0)
|
|
2694
|
+
continue;
|
|
2695
|
+
const owner = byPlan.get(planId);
|
|
2696
|
+
entries[planId] = open.map((entry) => ({
|
|
2697
|
+
...entry,
|
|
2698
|
+
source_plan: planId,
|
|
2699
|
+
registered_at: migratedAt,
|
|
2700
|
+
...owner !== undefined ? { lifecycle_id: owner.id } : {}
|
|
2701
|
+
}));
|
|
2702
|
+
}
|
|
2703
|
+
if (Object.keys(entries).length === 0)
|
|
2704
|
+
return null;
|
|
2705
|
+
const doc = { entries };
|
|
2706
|
+
return {
|
|
2707
|
+
file: join10("projects", projectId, PROJECT_REGISTER_FILE),
|
|
2708
|
+
source: "status.json residual_findings",
|
|
2709
|
+
data: doc
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
function collectNotesFiles(snapshots) {
|
|
2713
|
+
const out = [];
|
|
2714
|
+
for (const snapshot of snapshots) {
|
|
2715
|
+
const lines = [];
|
|
2716
|
+
let source = "";
|
|
2717
|
+
for (const row of snapshot.data.plans) {
|
|
2718
|
+
if (Array.isArray(row.notes)) {
|
|
2719
|
+
source = "status.json plans[].notes arrays";
|
|
2720
|
+
for (const note of row.notes) {
|
|
2721
|
+
if (typeof note !== "string")
|
|
2722
|
+
continue;
|
|
2723
|
+
lines.push(JSON.stringify({ kind: "note", ts: snapshot.data.updated_at, text: note }));
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
if (snapshot.type === "plan" && snapshot.status === "paused") {
|
|
2728
|
+
const row = snapshot.data.plans[0];
|
|
2729
|
+
if (row !== undefined && row.status === "Todo") {
|
|
2730
|
+
source = source === "" ? "status.json plans[] Todo rows (not-started note)" : source;
|
|
2731
|
+
const noteText = typeof row.id === "string" ? `${row.id} not started (v1 row status Todo mapped to snapshot status paused)` : "not started (v1 row status Todo mapped to snapshot status paused)";
|
|
2732
|
+
lines.push(JSON.stringify({ kind: "note", ts: snapshot.data.updated_at, text: noteText }));
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
if (lines.length === 0)
|
|
2736
|
+
continue;
|
|
2737
|
+
out.push({
|
|
2738
|
+
file: join10(dirname7(snapshot.file), NOTES_LEDGER_FILE),
|
|
2739
|
+
source,
|
|
2740
|
+
lines
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
return out;
|
|
2744
|
+
}
|
|
2745
|
+
function migrateHarnessTree(root, opts = {}) {
|
|
2746
|
+
const harnessDir = resolve8(root);
|
|
2747
|
+
const workflowDir = resolveWorkflowDir(harnessDir, { harnessDir });
|
|
2748
|
+
const projectDir = resolveProjectDir(harnessDir, { harnessDir });
|
|
2749
|
+
const projectId = opts.projectId ?? _DEFAULT_PROJECT;
|
|
2750
|
+
const statusPath = join10(harnessDir, MIGRATE_STATUS_FILE);
|
|
2751
|
+
const legacy = readJson(statusPath);
|
|
2752
|
+
if (legacy.version === 2) {
|
|
2753
|
+
const updatedAt = typeof legacy.updated_at === "string" && legacy.updated_at !== "" ? legacy.updated_at : "1970-01-01";
|
|
2754
|
+
return {
|
|
2755
|
+
root: harnessDir,
|
|
2756
|
+
workflowDir,
|
|
2757
|
+
projectDir,
|
|
2758
|
+
dryRun: opts.dryRun === true,
|
|
2759
|
+
alreadyMigrated: true,
|
|
2760
|
+
message: `no-op: ${statusPath} is already at schema version 2 (migrated) — nothing to do`,
|
|
2761
|
+
snapshots: [],
|
|
2762
|
+
notesFiles: [],
|
|
2763
|
+
register: null,
|
|
2764
|
+
roadmap: null,
|
|
2765
|
+
rootV2: { file: MIGRATE_STATUS_FILE, data: { version: 2, updated_at: updatedAt, workflows: [] } },
|
|
2766
|
+
archive: { file: ARCHIVED_STATUS_V1_FILE, source: MIGRATE_STATUS_FILE },
|
|
2767
|
+
migrationNotes: [],
|
|
2768
|
+
steps: []
|
|
2769
|
+
};
|
|
2770
|
+
}
|
|
2771
|
+
if (legacy.version !== undefined && legacy.version !== 1) {
|
|
2772
|
+
throw new Error(`refusing to migrate: ${statusPath} has unrecognized schema version ${JSON.stringify(legacy.version)} (expected 1)`);
|
|
2773
|
+
}
|
|
2774
|
+
if (legacy.version === undefined) {
|
|
2775
|
+
throw new Error(`refusing to migrate: no v1 status.json found at ${statusPath} (nothing to migrate)`);
|
|
2776
|
+
}
|
|
2777
|
+
const rows = Array.isArray(legacy.plans) ? legacy.plans.filter(isPlainObject6) : [];
|
|
2778
|
+
if (Array.isArray(legacy.plans)) {
|
|
2779
|
+
const unLiftable = [];
|
|
2780
|
+
const idCounts = new Map;
|
|
2781
|
+
for (const row of legacy.plans) {
|
|
2782
|
+
if (!isPlainObject6(row) || rowIdOf(row) === null) {
|
|
2783
|
+
unLiftable.push(row);
|
|
2784
|
+
continue;
|
|
2785
|
+
}
|
|
2786
|
+
const id = rowIdOf(row);
|
|
2787
|
+
assertSafePathComponent(id, "plan id");
|
|
2788
|
+
idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
|
|
2789
|
+
}
|
|
2790
|
+
if (unLiftable.length > 0) {
|
|
2791
|
+
throw new Error(`refusing to migrate: ${unLiftable.length} plans[] row(s) cannot be lifted (missing id/plan_id or not an object) — every v1 row must land in exactly one snapshot`);
|
|
2792
|
+
}
|
|
2793
|
+
const duplicates = [...idCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id);
|
|
2794
|
+
if (duplicates.length > 0) {
|
|
2795
|
+
throw new Error(`refusing to migrate: ${duplicates.length} duplicate plan id(s) (${duplicates.join(", ")}) — every v1 row must land in exactly one snapshot`);
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
const metadata = isPlainObject6(legacy.metadata) ? legacy.metadata : {};
|
|
2799
|
+
const rootUpdatedAt = dateString(legacy.updated_at) ?? dateString(metadata.updated_at) ?? todayString2();
|
|
2800
|
+
const migratedAt = dateString(metadata.updated_at) ?? rootUpdatedAt;
|
|
2801
|
+
const migrationNotes = [];
|
|
2802
|
+
const compasses = scanCompasses(harnessDir);
|
|
2803
|
+
for (const compass of compasses) {
|
|
2804
|
+
assertSafePathComponent(compass.id, "iteration id");
|
|
2805
|
+
}
|
|
2806
|
+
assertSafePathComponent(projectId, "projectId");
|
|
2807
|
+
const { byPlan, rowById } = groupRows(rows, compasses);
|
|
2808
|
+
const snapshots = compasses.map((compass) => buildIterationSnapshot(compass, rowById, rootUpdatedAt));
|
|
2809
|
+
for (const row of rows) {
|
|
2810
|
+
const id = rowIdOf(row);
|
|
2811
|
+
if (id !== null && !byPlan.has(id))
|
|
2812
|
+
snapshots.push(buildStandaloneSnapshot(row, rootUpdatedAt, migrationNotes));
|
|
2813
|
+
}
|
|
2814
|
+
const lifecycleSources = new Map;
|
|
2815
|
+
for (const snapshot of snapshots) {
|
|
2816
|
+
const sources = lifecycleSources.get(snapshot.id) ?? [];
|
|
2817
|
+
sources.push(snapshot.type === "iteration" ? "iteration" : "standalone plan");
|
|
2818
|
+
lifecycleSources.set(snapshot.id, sources);
|
|
2819
|
+
}
|
|
2820
|
+
const projectSources = lifecycleSources.get(projectId) ?? [];
|
|
2821
|
+
projectSources.push("project");
|
|
2822
|
+
lifecycleSources.set(projectId, projectSources);
|
|
2823
|
+
const collisions = [...lifecycleSources.entries()].filter(([, sources]) => sources.length > 1);
|
|
2824
|
+
if (collisions.length > 0) {
|
|
2825
|
+
throw new Error(`refusing to migrate: ${collisions.length} lifecycle id collision(s) (${collisions.map(([id, sources]) => `${JSON.stringify(id)} shared by ${sources.join(" + ")}`).join("; ")}) — every id must be unique across iterations, standalone plans and the project id (each id becomes a workflow/project dir segment)`);
|
|
2826
|
+
}
|
|
2827
|
+
snapshots.sort((a, b) => compareIds(a.id, b.id));
|
|
2828
|
+
const activeIterations = snapshots.filter((snapshot) => snapshot.status === "running" && snapshot.type === "iteration");
|
|
2829
|
+
if (activeIterations.length > 0) {
|
|
2830
|
+
applyRootMetadataLift(activeIterations[0], metadata, migrationNotes, activeIterations.length);
|
|
2831
|
+
} else if (Object.keys(metadata).length > 0) {
|
|
2832
|
+
migrationNotes.push("no active iteration snapshot found — root metadata execution-policy/branch keys have no lift home and stay unmapped (visible here, not silently dropped)");
|
|
2833
|
+
}
|
|
2834
|
+
const notesFiles = collectNotesFiles(snapshots);
|
|
2835
|
+
const residualFindings = isPlainObject6(legacy.residual_findings) ? legacy.residual_findings : {};
|
|
2836
|
+
const register = buildRegister(residualFindings, byPlan, projectId, migratedAt);
|
|
2837
|
+
const programRoadmap = isPlainObject6(metadata.program_roadmap) ? metadata.program_roadmap : null;
|
|
2838
|
+
let roadmap = null;
|
|
2839
|
+
if (programRoadmap) {
|
|
2840
|
+
const rawTitle = typeof programRoadmap.title === "string" && programRoadmap.title !== "" ? programRoadmap.title : "Program roadmap";
|
|
2841
|
+
const sanitizedTitle = rawTitle.replace(/[\r\n]+/g, " ").trim();
|
|
2842
|
+
if (sanitizedTitle !== rawTitle) {
|
|
2843
|
+
migrationNotes.push(`roadmap title sanitized for frontmatter (line breaks replaced with spaces): ${JSON.stringify(rawTitle)}`);
|
|
2844
|
+
}
|
|
2845
|
+
roadmap = {
|
|
2846
|
+
file: join10("projects", projectId, PROJECT_ROADMAP_FILE),
|
|
2847
|
+
source: "status.json metadata.program_roadmap",
|
|
2848
|
+
content: buildRoadmap({ ...programRoadmap, title: sanitizedTitle }, projectId, migratedAt)
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
const rootV2 = {
|
|
2852
|
+
file: MIGRATE_STATUS_FILE,
|
|
2853
|
+
data: { version: 2, updated_at: migratedAt, workflows: [] }
|
|
2854
|
+
};
|
|
2855
|
+
const archive = { file: ARCHIVED_STATUS_V1_FILE, source: MIGRATE_STATUS_FILE };
|
|
2856
|
+
const steps = [
|
|
2857
|
+
{ kind: "archive-status-v1", source: archive.source, destination: archive.file },
|
|
2858
|
+
...snapshots.map((snapshot) => ({
|
|
2859
|
+
kind: "write-snapshot",
|
|
2860
|
+
source: snapshot.source,
|
|
2861
|
+
destination: snapshot.file
|
|
2862
|
+
})),
|
|
2863
|
+
...notesFiles.map((notes) => ({
|
|
2864
|
+
kind: "write-notes",
|
|
2865
|
+
source: notes.source,
|
|
2866
|
+
destination: notes.file
|
|
2867
|
+
}))
|
|
2868
|
+
];
|
|
2869
|
+
if (register !== null)
|
|
2870
|
+
steps.push({ kind: "write-register", source: register.source, destination: register.file });
|
|
2871
|
+
if (roadmap !== null)
|
|
2872
|
+
steps.push({ kind: "write-roadmap", source: roadmap.source, destination: roadmap.file });
|
|
2873
|
+
steps.push({ kind: "replace-root-v2", source: `${MIGRATE_STATUS_FILE} (v1)`, destination: `${MIGRATE_STATUS_FILE} (v2)` });
|
|
2874
|
+
return {
|
|
2875
|
+
root: harnessDir,
|
|
2876
|
+
workflowDir,
|
|
2877
|
+
projectDir,
|
|
2878
|
+
dryRun: opts.dryRun === true,
|
|
2879
|
+
alreadyMigrated: false,
|
|
2880
|
+
message: `planned migration of ${snapshots.length} lifecycles (${steps.length} steps)`,
|
|
2881
|
+
snapshots,
|
|
2882
|
+
notesFiles,
|
|
2883
|
+
register,
|
|
2884
|
+
roadmap,
|
|
2885
|
+
rootV2,
|
|
2886
|
+
archive,
|
|
2887
|
+
migrationNotes,
|
|
2888
|
+
steps
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2891
|
+
async function applyMigratePlan(plan) {
|
|
2892
|
+
if (plan.dryRun) {
|
|
2893
|
+
return { applied: false, message: `dry-run: ${plan.steps.length} steps planned (source → destination), zero writes` };
|
|
2894
|
+
}
|
|
2895
|
+
const statusPath = join10(plan.root, MIGRATE_STATUS_FILE);
|
|
2896
|
+
const current = readJson(statusPath);
|
|
2897
|
+
if (current.version === 2) {
|
|
2898
|
+
return { applied: false, message: "no-op: status.json already at schema version 2 (migrated) — nothing to do" };
|
|
2899
|
+
}
|
|
2900
|
+
const harnessRoot = resolve8(plan.root);
|
|
2901
|
+
const workflowRoot = resolve8(plan.workflowDir);
|
|
2902
|
+
const projectRoot = resolve8(plan.projectDir);
|
|
2903
|
+
if (!isAbsolute6(plan.workflowDir) || !isAbsolute6(plan.projectDir)) {
|
|
2904
|
+
throw new Error(`refusing to apply migration: plan workflowDir/projectDir must be absolute (got ${JSON.stringify(plan.workflowDir)} / ${JSON.stringify(plan.projectDir)})`);
|
|
2905
|
+
}
|
|
2906
|
+
const workflowTargetOf = (canonicalFile) => join10(workflowRoot, relative3("workflows", canonicalFile));
|
|
2907
|
+
const projectTargetOf = (canonicalFile) => join10(projectRoot, relative3("projects", canonicalFile));
|
|
2908
|
+
const allDestinations = [
|
|
2909
|
+
plan.archive.file,
|
|
2910
|
+
...plan.snapshots.map((snapshot) => snapshot.file),
|
|
2911
|
+
...plan.notesFiles.map((notes) => notes.file),
|
|
2912
|
+
...plan.register !== null ? [plan.register.file] : [],
|
|
2913
|
+
...plan.roadmap !== null ? [plan.roadmap.file] : []
|
|
2914
|
+
];
|
|
2915
|
+
for (const destination of allDestinations) {
|
|
2916
|
+
const resolvedDest = resolve8(join10(plan.root, destination));
|
|
2917
|
+
const inside = (dir) => resolvedDest === dir || resolvedDest.startsWith(`${dir}${sep2}`);
|
|
2918
|
+
if (!inside(harnessRoot) && !inside(workflowRoot) && !inside(projectRoot)) {
|
|
2919
|
+
throw new Error(`refusing to apply migration: destination escapes the harness dir (${JSON.stringify(destination)}) — every write must stay under ${JSON.stringify(plan.root)}, the workflow dir (${JSON.stringify(plan.workflowDir)}) or the project dir (${JSON.stringify(plan.projectDir)})`);
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
mkdirSync6(join10(plan.root, dirname7(plan.archive.file)), { recursive: true });
|
|
2923
|
+
copyFileSync(statusPath, join10(plan.root, plan.archive.file));
|
|
2924
|
+
for (const snapshot of plan.snapshots) {
|
|
2925
|
+
await writeWorkflowSnapshot(snapshot.data, dirname7(workflowTargetOf(snapshot.file)));
|
|
2926
|
+
}
|
|
2927
|
+
for (const notes of plan.notesFiles) {
|
|
2928
|
+
const filePath = workflowTargetOf(notes.file);
|
|
2929
|
+
mkdirSync6(dirname7(filePath), { recursive: true });
|
|
2930
|
+
const content = notes.lines.length > 0 ? `${notes.lines.join(`
|
|
2931
|
+
`)}
|
|
2932
|
+
` : "";
|
|
2933
|
+
writeFileSync4(filePath, content, "utf8");
|
|
2934
|
+
}
|
|
2935
|
+
if (plan.register !== null) {
|
|
2936
|
+
const gate2 = validateProjectRegister(plan.register.data);
|
|
2937
|
+
if (!gate2.ok) {
|
|
2938
|
+
throw new Error(`refusing to apply migration: invalid project register: ${gate2.violations.map((v) => v.message).join("; ")}`);
|
|
2939
|
+
}
|
|
2940
|
+
const filePath = projectTargetOf(plan.register.file);
|
|
2941
|
+
mkdirSync6(dirname7(filePath), { recursive: true });
|
|
2942
|
+
writeJson(filePath, plan.register.data);
|
|
2943
|
+
}
|
|
2944
|
+
if (plan.roadmap !== null) {
|
|
2945
|
+
const filePath = projectTargetOf(plan.roadmap.file);
|
|
2946
|
+
mkdirSync6(dirname7(filePath), { recursive: true });
|
|
2947
|
+
writeFileSync4(filePath, plan.roadmap.content, "utf8");
|
|
2948
|
+
}
|
|
2949
|
+
const rootGate = validateStatusV2(plan.rootV2.data, { harnessDir: plan.root });
|
|
2950
|
+
if (!rootGate.ok) {
|
|
2951
|
+
throw new Error(`refusing to apply migration: invalid v2 root: ${rootGate.violations.map((v) => v.message).join("; ")}`);
|
|
2952
|
+
}
|
|
2953
|
+
return withStatusWriteLock(statusPath, () => {
|
|
2954
|
+
const latest = readJson(statusPath);
|
|
2955
|
+
if (latest.version === 2) {
|
|
2956
|
+
return {
|
|
2957
|
+
applied: false,
|
|
2958
|
+
message: "no-op: status.json already at schema version 2 (migrated) — nothing to do"
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
writeJson(statusPath, plan.rootV2.data);
|
|
2962
|
+
return {
|
|
2963
|
+
applied: true,
|
|
2964
|
+
message: `migrated ${plan.snapshots.length} lifecycles into workflows/, project layer seeded, root status.json replaced (v1 archived to ${plan.archive.file})`
|
|
2965
|
+
};
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
1948
2968
|
// src/design-md.ts
|
|
1949
|
-
function
|
|
2969
|
+
function violation8(severity, code, message, fix) {
|
|
1950
2970
|
return { ok: false, severity, code, message, fix };
|
|
1951
2971
|
}
|
|
1952
2972
|
var RAW_GROUP = "__raw";
|
|
@@ -2059,7 +3079,7 @@ function validateDesignTokenFrontmatter(frontmatterText) {
|
|
|
2059
3079
|
const violations = [];
|
|
2060
3080
|
const fm = parseDesignFrontmatter(frontmatterText);
|
|
2061
3081
|
if (fm === null) {
|
|
2062
|
-
violations.push(
|
|
3082
|
+
violations.push(violation8("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"));
|
|
2063
3083
|
return { ok: false, violations };
|
|
2064
3084
|
}
|
|
2065
3085
|
const groupEntries = (group) => {
|
|
@@ -2074,61 +3094,61 @@ function validateDesignTokenFrontmatter(frontmatterText) {
|
|
|
2074
3094
|
};
|
|
2075
3095
|
for (const group of ["colors", "typography", "spacing", "rounded"]) {
|
|
2076
3096
|
if (!isMap(fm[group])) {
|
|
2077
|
-
violations.push(
|
|
3097
|
+
violations.push(violation8("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`));
|
|
2078
3098
|
} else if (!groupIsMap(group)) {
|
|
2079
|
-
violations.push(
|
|
3099
|
+
violations.push(violation8("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`));
|
|
2080
3100
|
} else if (groupEntries(group).length === 0) {
|
|
2081
|
-
violations.push(
|
|
3101
|
+
violations.push(violation8("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`));
|
|
2082
3102
|
}
|
|
2083
3103
|
}
|
|
2084
3104
|
if (!isMap(fm.components) || !groupIsMap("components")) {
|
|
2085
|
-
violations.push(
|
|
3105
|
+
violations.push(violation8("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`));
|
|
2086
3106
|
}
|
|
2087
|
-
const placeholder = (group, name, value) => violations.push(
|
|
3107
|
+
const placeholder = (group, name, value) => violations.push(violation8("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`));
|
|
2088
3108
|
for (const [name, value] of groupEntries("colors")) {
|
|
2089
3109
|
if (typeof value !== "string") {
|
|
2090
|
-
violations.push(
|
|
3110
|
+
violations.push(violation8("medium", "design-md.tokens.color-format", `color "${name}" must be a string value (design-md-spec §2.2)`, "quote the color value"));
|
|
2091
3111
|
continue;
|
|
2092
3112
|
}
|
|
2093
3113
|
if (isPlaceholder(value)) {
|
|
2094
3114
|
placeholder("colors", name, value);
|
|
2095
3115
|
} else if (!HEX_RE.test(value) && !OKLCH_RE.test(value)) {
|
|
2096
|
-
violations.push(
|
|
3116
|
+
violations.push(violation8("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"));
|
|
2097
3117
|
}
|
|
2098
3118
|
}
|
|
2099
3119
|
for (const [name, value] of groupEntries("typography")) {
|
|
2100
3120
|
if (!isMap(value)) {
|
|
2101
|
-
violations.push(
|
|
3121
|
+
violations.push(violation8("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"));
|
|
2102
3122
|
continue;
|
|
2103
3123
|
}
|
|
2104
3124
|
const keys = Object.keys(value);
|
|
2105
3125
|
const missing = TYPOGRAPHY_PROPS.filter((p) => !keys.includes(p));
|
|
2106
3126
|
const extra = keys.filter((k) => !TYPOGRAPHY_PROPS.includes(k));
|
|
2107
3127
|
if (missing.length > 0 || extra.length > 0) {
|
|
2108
|
-
violations.push(
|
|
3128
|
+
violations.push(violation8("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"));
|
|
2109
3129
|
}
|
|
2110
3130
|
for (const prop of ["fontFamily", "fontSize"]) {
|
|
2111
3131
|
const v = value[prop];
|
|
2112
3132
|
if (typeof v === "string" && isPlaceholder(v))
|
|
2113
3133
|
placeholder("typography", name, v);
|
|
2114
3134
|
else if (typeof v !== "string" || v.trim() === "") {
|
|
2115
|
-
violations.push(
|
|
3135
|
+
violations.push(violation8("medium", "design-md.tokens.typography-shape", `typography token "${name}" has an empty \`${prop}\` (design-md-spec §1.5)`, `fill \`${prop}\` with a concrete value`));
|
|
2116
3136
|
}
|
|
2117
3137
|
}
|
|
2118
3138
|
}
|
|
2119
3139
|
if (groupIsMap("spacing")) {
|
|
2120
3140
|
const spacing = fm.spacing;
|
|
2121
3141
|
if (!Object.prototype.hasOwnProperty.call(spacing, "base")) {
|
|
2122
|
-
violations.push(
|
|
3142
|
+
violations.push(violation8("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"));
|
|
2123
3143
|
}
|
|
2124
3144
|
for (const [name, value] of Object.entries(spacing)) {
|
|
2125
3145
|
if (name !== "base" && name !== RAW_GROUP && !/^\d+$/.test(name)) {
|
|
2126
|
-
violations.push(
|
|
3146
|
+
violations.push(violation8("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`"));
|
|
2127
3147
|
}
|
|
2128
3148
|
if (typeof value === "string" && isPlaceholder(value)) {
|
|
2129
3149
|
placeholder("spacing", name, value);
|
|
2130
3150
|
} else if (typeof value !== "string" || !PX_RE.test(value)) {
|
|
2131
|
-
violations.push(
|
|
3151
|
+
violations.push(violation8("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`"));
|
|
2132
3152
|
}
|
|
2133
3153
|
}
|
|
2134
3154
|
}
|
|
@@ -2136,12 +3156,12 @@ function validateDesignTokenFrontmatter(frontmatterText) {
|
|
|
2136
3156
|
if (typeof value === "string" && isPlaceholder(value)) {
|
|
2137
3157
|
placeholder("rounded", name, value);
|
|
2138
3158
|
} else if (typeof value !== "string" || !PX_RE.test(value)) {
|
|
2139
|
-
violations.push(
|
|
3159
|
+
violations.push(violation8("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`"));
|
|
2140
3160
|
}
|
|
2141
3161
|
}
|
|
2142
3162
|
for (const [name, value] of groupEntries("components")) {
|
|
2143
3163
|
if (!isMap(value)) {
|
|
2144
|
-
violations.push(
|
|
3164
|
+
violations.push(violation8("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"));
|
|
2145
3165
|
continue;
|
|
2146
3166
|
}
|
|
2147
3167
|
for (const [prop, v] of Object.entries(value)) {
|
|
@@ -2157,7 +3177,7 @@ function validateDesignTokenFrontmatter(frontmatterText) {
|
|
|
2157
3177
|
const [, refGroup, refKey] = ref;
|
|
2158
3178
|
const resolves = REF_GROUPS.includes(refGroup) && groupIsMap(refGroup) && Object.prototype.hasOwnProperty.call(fm[refGroup], refKey);
|
|
2159
3179
|
if (!resolves) {
|
|
2160
|
-
violations.push(
|
|
3180
|
+
violations.push(violation8("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`));
|
|
2161
3181
|
}
|
|
2162
3182
|
}
|
|
2163
3183
|
}
|
|
@@ -2169,7 +3189,7 @@ function assertLightDarkParity(lightFm, darkFm) {
|
|
|
2169
3189
|
const light = parseDesignFrontmatter(lightFm);
|
|
2170
3190
|
const dark = parseDesignFrontmatter(darkFm);
|
|
2171
3191
|
if (light === null || dark === null) {
|
|
2172
|
-
violations.push(
|
|
3192
|
+
violations.push(violation8("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"));
|
|
2173
3193
|
return { ok: false, violations };
|
|
2174
3194
|
}
|
|
2175
3195
|
const activeKeys = (fm) => {
|
|
@@ -2187,12 +3207,12 @@ function assertLightDarkParity(lightFm, darkFm) {
|
|
|
2187
3207
|
const darkKeys = activeKeys(dark);
|
|
2188
3208
|
for (const key of lightKeys) {
|
|
2189
3209
|
if (!darkKeys.has(key)) {
|
|
2190
|
-
violations.push(
|
|
3210
|
+
violations.push(violation8("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"));
|
|
2191
3211
|
}
|
|
2192
3212
|
}
|
|
2193
3213
|
for (const key of darkKeys) {
|
|
2194
3214
|
if (!lightKeys.has(key)) {
|
|
2195
|
-
violations.push(
|
|
3215
|
+
violations.push(violation8("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"));
|
|
2196
3216
|
}
|
|
2197
3217
|
}
|
|
2198
3218
|
return { ok: violations.length === 0, violations };
|
|
@@ -2346,9 +3366,9 @@ function completenessLevel(frontmatterText, checklist) {
|
|
|
2346
3366
|
return { level, items, missing, placeholders, upgradeTo, bodyUnverified };
|
|
2347
3367
|
}
|
|
2348
3368
|
// src/audit.ts
|
|
2349
|
-
import { mkdirSync as
|
|
2350
|
-
import { join as
|
|
2351
|
-
function
|
|
3369
|
+
import { mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
|
|
3370
|
+
import { join as join11, resolve as resolve9 } from "node:path";
|
|
3371
|
+
function violation9(severity, code, message, fix) {
|
|
2352
3372
|
return { ok: false, severity, code, message, fix };
|
|
2353
3373
|
}
|
|
2354
3374
|
var AUDIT_PRIORITIES = ["P1", "P2", "P3"];
|
|
@@ -2392,14 +3412,14 @@ function validateAuditStatusBlocks(planText) {
|
|
|
2392
3412
|
const violations = [];
|
|
2393
3413
|
const blocks = parseStatusBlocks(planText);
|
|
2394
3414
|
if (blocks.length === 0) {
|
|
2395
|
-
violations.push(
|
|
3415
|
+
violations.push(violation9("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"));
|
|
2396
3416
|
return { ok: false, violations };
|
|
2397
3417
|
}
|
|
2398
3418
|
blocks.forEach((block, index) => {
|
|
2399
3419
|
const label = blocks.length > 1 ? ` #${index + 1}` : "";
|
|
2400
3420
|
for (const field of AUDIT_STATUS_FIELDS) {
|
|
2401
3421
|
if (!block.fields.has(field)) {
|
|
2402
|
-
violations.push(
|
|
3422
|
+
violations.push(violation9("medium", "audit.status.missing-field", `Status block${label} missing required field "${field}" (mstar-audit SKILL § Plan files)`, `add \`- **${field}**: <value>\` to the Status block`));
|
|
2403
3423
|
}
|
|
2404
3424
|
}
|
|
2405
3425
|
const check = (field, pattern, code, expected) => {
|
|
@@ -2407,7 +3427,7 @@ function validateAuditStatusBlocks(planText) {
|
|
|
2407
3427
|
if (value === undefined)
|
|
2408
3428
|
return;
|
|
2409
3429
|
if (!pattern.test(value)) {
|
|
2410
|
-
violations.push(
|
|
3430
|
+
violations.push(violation9("medium", code, `Status block${label} "${field}" = "${value}" — expected ${expected} (mstar-audit SKILL § Plan files)`, `fix \`- **${field}**:\` to one of: ${expected}`));
|
|
2411
3431
|
}
|
|
2412
3432
|
};
|
|
2413
3433
|
check("Priority", /^P[123]$/, "audit.status.invalid-priority", "P1 | P2 | P3");
|
|
@@ -2521,7 +3541,7 @@ function renderPlanFile(finding, plannedAt) {
|
|
|
2521
3541
|
`;
|
|
2522
3542
|
}
|
|
2523
3543
|
function readPlanFileSummary(filePath) {
|
|
2524
|
-
const text =
|
|
3544
|
+
const text = readFileSync9(filePath, "utf8");
|
|
2525
3545
|
const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
|
|
2526
3546
|
const blocks = parseStatusBlocks(text);
|
|
2527
3547
|
return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
|
|
@@ -2560,8 +3580,8 @@ function renderIndex(params) {
|
|
|
2560
3580
|
function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
2561
3581
|
const date = options.date ?? new Date().toISOString().slice(0, 10);
|
|
2562
3582
|
const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
|
|
2563
|
-
|
|
2564
|
-
const existing =
|
|
3583
|
+
mkdirSync7(outDir, { recursive: true });
|
|
3584
|
+
const existing = readdirSync7(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
|
|
2565
3585
|
let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
|
|
2566
3586
|
const written = [];
|
|
2567
3587
|
const usedSlugs = new Set;
|
|
@@ -2576,13 +3596,13 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
2576
3596
|
}
|
|
2577
3597
|
usedSlugs.add(slug);
|
|
2578
3598
|
const file = `${num}-${slug}.md`;
|
|
2579
|
-
|
|
3599
|
+
writeFileSync5(join11(outDir, file), renderPlanFile(finding, plannedAt));
|
|
2580
3600
|
written.push(file);
|
|
2581
3601
|
next++;
|
|
2582
3602
|
}
|
|
2583
3603
|
const all = [...existing, ...written].sort();
|
|
2584
3604
|
const rows = all.map((file) => {
|
|
2585
|
-
const summary = readPlanFileSummary(
|
|
3605
|
+
const summary = readPlanFileSummary(join11(outDir, file));
|
|
2586
3606
|
const fields = summary.fields;
|
|
2587
3607
|
return {
|
|
2588
3608
|
num: file.slice(0, 3),
|
|
@@ -2614,19 +3634,19 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
2614
3634
|
row.dependsOn = finding.dependsOn ?? "none";
|
|
2615
3635
|
}
|
|
2616
3636
|
});
|
|
2617
|
-
|
|
3637
|
+
writeFileSync5(join11(outDir, "README.md"), renderIndex({
|
|
2618
3638
|
date,
|
|
2619
3639
|
repoName: options.repoName ?? "repo",
|
|
2620
3640
|
repoShortSha: options.repoShortSha ?? "unknown",
|
|
2621
3641
|
rows,
|
|
2622
3642
|
rejected: options.rejected ?? []
|
|
2623
3643
|
}));
|
|
2624
|
-
return { outDir:
|
|
3644
|
+
return { outDir: resolve9(outDir), date, files: written, nextNumber: next };
|
|
2625
3645
|
}
|
|
2626
3646
|
// src/compound.ts
|
|
2627
|
-
import { existsSync as
|
|
2628
|
-
import { basename as basename4, isAbsolute as
|
|
2629
|
-
function
|
|
3647
|
+
import { existsSync as existsSync6, readdirSync as readdirSync8, readFileSync as readFileSync10 } from "node:fs";
|
|
3648
|
+
import { basename as basename4, isAbsolute as isAbsolute7, join as join12, relative as relative4, resolve as resolve10, sep as sep3 } from "node:path";
|
|
3649
|
+
function violation10(severity, code, message, fix) {
|
|
2630
3650
|
return { ok: false, severity, code, message, fix };
|
|
2631
3651
|
}
|
|
2632
3652
|
var KNOWLEDGE_REQUIRED_FIELDS = ["module", "date", "problem_type", "category", "severity"];
|
|
@@ -2710,7 +3730,7 @@ var KNOWLEDGE_CATEGORY_MAP = {
|
|
|
2710
3730
|
developer_experience: "developer-experience",
|
|
2711
3731
|
documentation_gap: "documentation"
|
|
2712
3732
|
};
|
|
2713
|
-
var
|
|
3733
|
+
var DATE_RE5 = /^\d{4}-\d{2}-\d{2}$/;
|
|
2714
3734
|
var isMap2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2715
3735
|
function parseScalar2(raw) {
|
|
2716
3736
|
const trimmed = raw.trim();
|
|
@@ -2803,36 +3823,36 @@ function validateSchemaYaml(frontmatterText) {
|
|
|
2803
3823
|
const violations = [];
|
|
2804
3824
|
const doc = parseYamlLite(frontmatterText);
|
|
2805
3825
|
if (doc === null) {
|
|
2806
|
-
violations.push(
|
|
3826
|
+
violations.push(violation10("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"));
|
|
2807
3827
|
return { ok: false, violations };
|
|
2808
3828
|
}
|
|
2809
3829
|
const isStr = (v) => typeof v === "string";
|
|
2810
|
-
const missing = (field) => violations.push(
|
|
3830
|
+
const missing = (field) => violations.push(violation10("medium", "compound.schema.missing-field", `missing required frontmatter field "${field}" (schema.yaml required_fields)`, `add \`${field}: <value>\` to the frontmatter`));
|
|
2811
3831
|
for (const field of KNOWLEDGE_REQUIRED_FIELDS) {
|
|
2812
3832
|
if (!(field in doc) || doc[field] === "")
|
|
2813
3833
|
missing(field);
|
|
2814
3834
|
}
|
|
2815
|
-
if (doc.date !== undefined && (!isStr(doc.date) || !
|
|
2816
|
-
violations.push(
|
|
3835
|
+
if (doc.date !== undefined && (!isStr(doc.date) || !DATE_RE5.test(doc.date))) {
|
|
3836
|
+
violations.push(violation10("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`"));
|
|
2817
3837
|
}
|
|
2818
3838
|
const problemType = doc.problem_type;
|
|
2819
3839
|
if (problemType !== undefined && !isStr(problemType)) {
|
|
2820
|
-
violations.push(
|
|
3840
|
+
violations.push(violation10("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"));
|
|
2821
3841
|
}
|
|
2822
3842
|
const problemTypeValid = isStr(problemType) && KNOWLEDGE_PROBLEM_TYPES.includes(problemType);
|
|
2823
3843
|
if (isStr(problemType) && !problemTypeValid) {
|
|
2824
|
-
violations.push(
|
|
3844
|
+
violations.push(violation10("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"));
|
|
2825
3845
|
}
|
|
2826
3846
|
if (doc.severity !== undefined && !isStr(doc.severity)) {
|
|
2827
|
-
violations.push(
|
|
3847
|
+
violations.push(violation10("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"));
|
|
2828
3848
|
}
|
|
2829
3849
|
if (isStr(doc.severity) && !KNOWLEDGE_SEVERITIES.includes(doc.severity)) {
|
|
2830
|
-
violations.push(
|
|
3850
|
+
violations.push(violation10("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"));
|
|
2831
3851
|
}
|
|
2832
3852
|
if (problemTypeValid && isStr(doc.category)) {
|
|
2833
3853
|
const expected = KNOWLEDGE_CATEGORY_MAP[problemType];
|
|
2834
3854
|
if (doc.category !== expected) {
|
|
2835
|
-
violations.push(
|
|
3855
|
+
violations.push(violation10("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})`));
|
|
2836
3856
|
}
|
|
2837
3857
|
}
|
|
2838
3858
|
if (problemTypeValid) {
|
|
@@ -2840,37 +3860,37 @@ function validateSchemaYaml(frontmatterText) {
|
|
|
2840
3860
|
if (isBug) {
|
|
2841
3861
|
for (const field of ["symptoms", "root_cause", "resolution_type"]) {
|
|
2842
3862
|
if (!(field in doc)) {
|
|
2843
|
-
violations.push(
|
|
3863
|
+
violations.push(violation10("medium", "compound.schema.missing-track-field", `bug-track doc missing required field "${field}" (schema.yaml track_rules.bug)`, `add \`${field}:\` to the frontmatter`));
|
|
2844
3864
|
}
|
|
2845
3865
|
}
|
|
2846
3866
|
if (doc.symptoms !== undefined && !Array.isArray(doc.symptoms)) {
|
|
2847
|
-
violations.push(
|
|
3867
|
+
violations.push(violation10("medium", "compound.schema.invalid-symptoms", "bug-track `symptoms` must be a YAML list (schema.yaml track_rules.bug)", "list the observable symptoms under `symptoms:`"));
|
|
2848
3868
|
}
|
|
2849
3869
|
if (doc.root_cause !== undefined && !isStr(doc.root_cause)) {
|
|
2850
|
-
violations.push(
|
|
3870
|
+
violations.push(violation10("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"));
|
|
2851
3871
|
}
|
|
2852
3872
|
if (isStr(doc.resolution_type) && !KNOWLEDGE_RESOLUTION_TYPES.includes(doc.resolution_type)) {
|
|
2853
|
-
violations.push(
|
|
3873
|
+
violations.push(violation10("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"));
|
|
2854
3874
|
}
|
|
2855
3875
|
} else if (doc.applies_when !== undefined && !Array.isArray(doc.applies_when)) {
|
|
2856
|
-
violations.push(
|
|
3876
|
+
violations.push(violation10("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:`"));
|
|
2857
3877
|
}
|
|
2858
3878
|
}
|
|
2859
3879
|
if (doc.plan_id !== undefined && !isStr(doc.plan_id)) {
|
|
2860
|
-
violations.push(
|
|
3880
|
+
violations.push(violation10("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"));
|
|
2861
3881
|
}
|
|
2862
3882
|
if (doc.tags !== undefined) {
|
|
2863
3883
|
if (!Array.isArray(doc.tags)) {
|
|
2864
|
-
violations.push(
|
|
3884
|
+
violations.push(violation10("low", "compound.schema.invalid-tags", "optional `tags` must be a YAML list (schema.yaml optional_fields.tags)", "list lowercase, hyphen-separated keywords"));
|
|
2865
3885
|
} else if (doc.tags.length > 8) {
|
|
2866
|
-
violations.push(
|
|
3886
|
+
violations.push(violation10("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"));
|
|
2867
3887
|
}
|
|
2868
3888
|
}
|
|
2869
|
-
if (doc.last_updated !== undefined && (!isStr(doc.last_updated) || !
|
|
2870
|
-
violations.push(
|
|
3889
|
+
if (doc.last_updated !== undefined && (!isStr(doc.last_updated) || !DATE_RE5.test(doc.last_updated))) {
|
|
3890
|
+
violations.push(violation10("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`"));
|
|
2871
3891
|
}
|
|
2872
3892
|
if (doc.related_components !== undefined && !Array.isArray(doc.related_components)) {
|
|
2873
|
-
violations.push(
|
|
3893
|
+
violations.push(violation10("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"));
|
|
2874
3894
|
}
|
|
2875
3895
|
return { ok: violations.length === 0, violations };
|
|
2876
3896
|
}
|
|
@@ -2892,7 +3912,7 @@ function referenceExists(repoRoot, docText) {
|
|
|
2892
3912
|
if (ref === "" || seen.has(ref))
|
|
2893
3913
|
continue;
|
|
2894
3914
|
seen.add(ref);
|
|
2895
|
-
if (SCHEME_RE.test(ref) || ref.startsWith("{") || ref.startsWith("#") || ref.includes("*") || ref.includes("?") || ref.startsWith("~") ||
|
|
3915
|
+
if (SCHEME_RE.test(ref) || ref.startsWith("{") || ref.startsWith("#") || ref.includes("*") || ref.includes("?") || ref.startsWith("~") || isAbsolute7(ref)) {
|
|
2896
3916
|
continue;
|
|
2897
3917
|
}
|
|
2898
3918
|
if (ref.includes("/") || REF_EXT_RE.test(ref)) {
|
|
@@ -2911,7 +3931,7 @@ function referenceExists(repoRoot, docText) {
|
|
|
2911
3931
|
const dir = stack.pop();
|
|
2912
3932
|
let entries;
|
|
2913
3933
|
try {
|
|
2914
|
-
entries =
|
|
3934
|
+
entries = readdirSync8(dir, { withFileTypes: true });
|
|
2915
3935
|
} catch {
|
|
2916
3936
|
continue;
|
|
2917
3937
|
}
|
|
@@ -2920,7 +3940,7 @@ function referenceExists(repoRoot, docText) {
|
|
|
2920
3940
|
break;
|
|
2921
3941
|
if (entry.isDirectory()) {
|
|
2922
3942
|
if (!WALK_SKIP_DIRS.has(entry.name))
|
|
2923
|
-
stack.push(
|
|
3943
|
+
stack.push(join12(dir, entry.name));
|
|
2924
3944
|
} else if (!entry.isSymbolicLink()) {
|
|
2925
3945
|
const base = entry.name.replace(/\.(?:ts|tsx|js|jsx|mjs|cjs)$/, "");
|
|
2926
3946
|
if (moduleNames.has(base))
|
|
@@ -2932,15 +3952,15 @@ function referenceExists(repoRoot, docText) {
|
|
|
2932
3952
|
for (const { ref, isSymbol, module } of refs) {
|
|
2933
3953
|
if (!isSymbol || module === undefined) {
|
|
2934
3954
|
const candidate = ref.replace(LINE_SUFFIX_RE, "").replace(ANCHOR_RE, "");
|
|
2935
|
-
if (
|
|
3955
|
+
if (existsSync6(resolve10(repoRoot, candidate))) {
|
|
2936
3956
|
checked++;
|
|
2937
3957
|
} else {
|
|
2938
|
-
violations.push(
|
|
3958
|
+
violations.push(violation10("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"));
|
|
2939
3959
|
}
|
|
2940
3960
|
} else if (foundModules.has(module)) {
|
|
2941
3961
|
checked++;
|
|
2942
3962
|
} else {
|
|
2943
|
-
violations.push(
|
|
3963
|
+
violations.push(violation10("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"));
|
|
2944
3964
|
}
|
|
2945
3965
|
}
|
|
2946
3966
|
return { ok: violations.length === 0, violations, checked };
|
|
@@ -2952,18 +3972,18 @@ function collectKnowledgeDocs(dir) {
|
|
|
2952
3972
|
const current = stack.pop();
|
|
2953
3973
|
let entries;
|
|
2954
3974
|
try {
|
|
2955
|
-
entries =
|
|
3975
|
+
entries = readdirSync8(current, { withFileTypes: true });
|
|
2956
3976
|
} catch {
|
|
2957
3977
|
continue;
|
|
2958
3978
|
}
|
|
2959
3979
|
for (const entry of entries) {
|
|
2960
3980
|
if (entry.isSymbolicLink())
|
|
2961
3981
|
continue;
|
|
2962
|
-
const full =
|
|
3982
|
+
const full = join12(current, entry.name);
|
|
2963
3983
|
if (entry.isDirectory()) {
|
|
2964
3984
|
stack.push(full);
|
|
2965
3985
|
} else if (entry.name.endsWith(".md") && entry.name !== "README.md" && entry.name !== "index.md") {
|
|
2966
|
-
docs.push(
|
|
3986
|
+
docs.push(relative4(dir, full).split(sep3).join("/"));
|
|
2967
3987
|
}
|
|
2968
3988
|
}
|
|
2969
3989
|
}
|
|
@@ -2979,14 +3999,14 @@ function normalizeIndexRef(cell) {
|
|
|
2979
3999
|
}
|
|
2980
4000
|
function assertIndexRows(knowledgeDir) {
|
|
2981
4001
|
const violations = [];
|
|
2982
|
-
const readmePath =
|
|
2983
|
-
if (!
|
|
2984
|
-
violations.push(
|
|
4002
|
+
const readmePath = join12(knowledgeDir, "README.md");
|
|
4003
|
+
if (!existsSync6(readmePath)) {
|
|
4004
|
+
violations.push(violation10("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"));
|
|
2985
4005
|
return { ok: false, violations };
|
|
2986
4006
|
}
|
|
2987
4007
|
const docs = collectKnowledgeDocs(knowledgeDir);
|
|
2988
4008
|
const rows = new Set;
|
|
2989
|
-
for (const line of
|
|
4009
|
+
for (const line of readFileSync10(readmePath, "utf8").split(/\r?\n/)) {
|
|
2990
4010
|
if (!line.trim().startsWith("|"))
|
|
2991
4011
|
continue;
|
|
2992
4012
|
const cells = line.split("|").map((c) => c.trim());
|
|
@@ -2998,42 +4018,42 @@ function assertIndexRows(knowledgeDir) {
|
|
|
2998
4018
|
}
|
|
2999
4019
|
for (const doc of docs) {
|
|
3000
4020
|
if (!rows.has(doc)) {
|
|
3001
|
-
violations.push(
|
|
4021
|
+
violations.push(violation10("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`));
|
|
3002
4022
|
}
|
|
3003
4023
|
}
|
|
3004
4024
|
return { ok: violations.length === 0, violations };
|
|
3005
4025
|
}
|
|
3006
4026
|
function compoundRefreshScope(harnessDir, projectRoot) {
|
|
3007
4027
|
return [
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
4028
|
+
join12(harnessDir, "knowledge"),
|
|
4029
|
+
join12(harnessDir, "knowledge", "README.md"),
|
|
4030
|
+
join12(projectRoot, "CONCEPTS.md"),
|
|
4031
|
+
join12(harnessDir, "status.json")
|
|
3012
4032
|
];
|
|
3013
4033
|
}
|
|
3014
4034
|
function isFileLikeRoot(root) {
|
|
3015
4035
|
return /^[^.]*\.[A-Za-z0-9]{1,10}$/.test(basename4(root));
|
|
3016
4036
|
}
|
|
3017
4037
|
function scopeGuard(path, allowedRoots) {
|
|
3018
|
-
const resolved =
|
|
4038
|
+
const resolved = resolve10(path);
|
|
3019
4039
|
for (const root of allowedRoots) {
|
|
3020
|
-
const r =
|
|
4040
|
+
const r = resolve10(root);
|
|
3021
4041
|
if (isFileLikeRoot(r)) {
|
|
3022
4042
|
if (resolved === r)
|
|
3023
4043
|
return { ok: true, violations: [] };
|
|
3024
|
-
} else if (resolved === r || resolved.startsWith(r +
|
|
4044
|
+
} else if (resolved === r || resolved.startsWith(r + sep3)) {
|
|
3025
4045
|
return { ok: true, violations: [] };
|
|
3026
4046
|
}
|
|
3027
4047
|
}
|
|
3028
4048
|
return {
|
|
3029
4049
|
ok: false,
|
|
3030
4050
|
violations: [
|
|
3031
|
-
|
|
4051
|
+
violation10("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")
|
|
3032
4052
|
]
|
|
3033
4053
|
};
|
|
3034
4054
|
}
|
|
3035
4055
|
// src/lint.ts
|
|
3036
|
-
function
|
|
4056
|
+
function violation11(severity, code, message, fix) {
|
|
3037
4057
|
return { ok: false, severity, code, message, fix };
|
|
3038
4058
|
}
|
|
3039
4059
|
var COMMENT_INTRODUCER = "(?:\\/\\/|\\/\\*|#|;|--|\\s\\*)";
|
|
@@ -3076,7 +4096,7 @@ function findTemporaryMarkers(fileText) {
|
|
|
3076
4096
|
}
|
|
3077
4097
|
markers.push({ line: i + 1, text, removalPath });
|
|
3078
4098
|
if (removalPath === null) {
|
|
3079
|
-
violations.push(
|
|
4099
|
+
violations.push(violation11("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"'));
|
|
3080
4100
|
}
|
|
3081
4101
|
}
|
|
3082
4102
|
return { ok: violations.length === 0, violations, markers };
|
|
@@ -3123,13 +4143,13 @@ function assertSddTddTriple(reportText) {
|
|
|
3123
4143
|
break;
|
|
3124
4144
|
}
|
|
3125
4145
|
if (!hasTests) {
|
|
3126
|
-
violations.push(
|
|
4146
|
+
violations.push(violation11("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'));
|
|
3127
4147
|
}
|
|
3128
4148
|
if (!hasCommand) {
|
|
3129
|
-
violations.push(
|
|
4149
|
+
violations.push(violation11("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'));
|
|
3130
4150
|
}
|
|
3131
4151
|
if (!hasOutput) {
|
|
3132
|
-
violations.push(
|
|
4152
|
+
violations.push(violation11("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'));
|
|
3133
4153
|
}
|
|
3134
4154
|
return { ok: violations.length === 0, violations };
|
|
3135
4155
|
}
|
|
@@ -3170,7 +4190,7 @@ function planQualityBar(planText) {
|
|
|
3170
4190
|
if (token !== null) {
|
|
3171
4191
|
const text = trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
|
|
3172
4192
|
findings.push({ token, line: i + 1, text });
|
|
3173
|
-
violations.push(
|
|
4193
|
+
violations.push(violation11("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)"));
|
|
3174
4194
|
}
|
|
3175
4195
|
}
|
|
3176
4196
|
return { ok: violations.length === 0, violations, findings };
|
|
@@ -3182,18 +4202,18 @@ function lintSkillFrontmatter(frontmatterText) {
|
|
|
3182
4202
|
const violations = [];
|
|
3183
4203
|
const fm = parseFrontmatter(frontmatterText);
|
|
3184
4204
|
if (fm === null) {
|
|
3185
|
-
violations.push(
|
|
4205
|
+
violations.push(violation11("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"));
|
|
3186
4206
|
return { ok: false, violations };
|
|
3187
4207
|
}
|
|
3188
4208
|
const name = fm.name ?? "";
|
|
3189
4209
|
if (name === "") {
|
|
3190
|
-
violations.push(
|
|
4210
|
+
violations.push(violation11("medium", "lint.frontmatter.name.missing", "frontmatter `name` is missing — required (mstar-skill-authoring § Frontmatter Contract)", "add `name: <lowercase-hyphen-id>` to the frontmatter"));
|
|
3191
4211
|
} else if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
|
3192
|
-
violations.push(
|
|
4212
|
+
violations.push(violation11("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`"));
|
|
3193
4213
|
}
|
|
3194
4214
|
const description = fm.description ?? "";
|
|
3195
4215
|
if (description === "") {
|
|
3196
|
-
violations.push(
|
|
4216
|
+
violations.push(violation11("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)"));
|
|
3197
4217
|
} else {
|
|
3198
4218
|
const stripped = description.replace(/`[^`]*`/g, " ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
|
|
3199
4219
|
let pronoun = null;
|
|
@@ -3204,15 +4224,15 @@ function lintSkillFrontmatter(frontmatterText) {
|
|
|
3204
4224
|
break;
|
|
3205
4225
|
}
|
|
3206
4226
|
if (pronoun !== null) {
|
|
3207
|
-
violations.push(
|
|
4227
|
+
violations.push(violation11("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 …"'));
|
|
3208
4228
|
}
|
|
3209
4229
|
const start = description.trim().replace(/^[*_#>]+/, "").replace(/^["'`]+/, "").trim();
|
|
3210
4230
|
if (WORKFLOW_VERB_START_RE.test(start)) {
|
|
3211
|
-
violations.push(
|
|
4231
|
+
violations.push(violation11("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"));
|
|
3212
4232
|
} else {
|
|
3213
4233
|
const words = description.trim().split(/\s+/).filter(Boolean).length;
|
|
3214
4234
|
if (words > DESCRIPTION_MAX_WORDS) {
|
|
3215
|
-
violations.push(
|
|
4235
|
+
violations.push(violation11("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"));
|
|
3216
4236
|
}
|
|
3217
4237
|
}
|
|
3218
4238
|
}
|
|
@@ -3259,15 +4279,15 @@ function lintStrategySections(docText) {
|
|
|
3259
4279
|
}
|
|
3260
4280
|
for (const required of REQUIRED_STRATEGY_SECTIONS) {
|
|
3261
4281
|
if (!headings.has(required.toLowerCase())) {
|
|
3262
|
-
violations.push(
|
|
4282
|
+
violations.push(violation11("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"));
|
|
3263
4283
|
}
|
|
3264
4284
|
}
|
|
3265
4285
|
return { ok: violations.length === 0, violations };
|
|
3266
4286
|
}
|
|
3267
4287
|
// src/roles.ts
|
|
3268
|
-
import { existsSync as
|
|
3269
|
-
import { join as
|
|
3270
|
-
function
|
|
4288
|
+
import { existsSync as existsSync7 } from "node:fs";
|
|
4289
|
+
import { join as join13 } from "node:path";
|
|
4290
|
+
function violation12(severity, code, message, fix) {
|
|
3271
4291
|
return { ok: false, severity, code, message, fix };
|
|
3272
4292
|
}
|
|
3273
4293
|
var ROLE_MAPPING = [
|
|
@@ -3322,19 +4342,19 @@ function validateRoleMapping(rolesDir, options = {}) {
|
|
|
3322
4342
|
const violations = [];
|
|
3323
4343
|
const referenceById = new Map(mapping.map((m) => [m.agentId, m.reference]));
|
|
3324
4344
|
for (const { agentId, reference } of mapping) {
|
|
3325
|
-
if (!
|
|
3326
|
-
violations.push(
|
|
4345
|
+
if (!existsSync7(join13(rolesDir, reference))) {
|
|
4346
|
+
violations.push(violation12("medium", "roles.mapping.reference.missing", `role "${agentId}" maps to ${reference} which does not exist under ${rolesDir} (mstar-roles § Role Reference Mapping)`, `create ${join13(rolesDir, reference)} or fix the mapping row`));
|
|
3327
4347
|
}
|
|
3328
4348
|
}
|
|
3329
4349
|
for (const { family, memberIds } of families) {
|
|
3330
4350
|
const absent = memberIds.filter((id) => !referenceById.has(id));
|
|
3331
4351
|
for (const id of absent) {
|
|
3332
|
-
violations.push(
|
|
4352
|
+
violations.push(violation12("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`));
|
|
3333
4353
|
}
|
|
3334
4354
|
if (absent.length === 0) {
|
|
3335
4355
|
const refs = new Set(memberIds.map((id) => referenceById.get(id)));
|
|
3336
4356
|
if (refs.size !== 1) {
|
|
3337
|
-
violations.push(
|
|
4357
|
+
violations.push(violation12("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`));
|
|
3338
4358
|
}
|
|
3339
4359
|
}
|
|
3340
4360
|
}
|
|
@@ -3343,12 +4363,12 @@ function validateRoleMapping(rolesDir, options = {}) {
|
|
|
3343
4363
|
for (const row of rows) {
|
|
3344
4364
|
const existing = tableByRole.get(row.roleId);
|
|
3345
4365
|
if (existing !== undefined) {
|
|
3346
|
-
violations.push(
|
|
4366
|
+
violations.push(violation12("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"));
|
|
3347
4367
|
} else {
|
|
3348
4368
|
tableByRole.set(row.roleId, table);
|
|
3349
4369
|
}
|
|
3350
4370
|
if (!referenceById.has(row.roleId)) {
|
|
3351
|
-
violations.push(
|
|
4371
|
+
violations.push(violation12("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`));
|
|
3352
4372
|
}
|
|
3353
4373
|
}
|
|
3354
4374
|
};
|
|
@@ -3356,20 +4376,20 @@ function validateRoleMapping(rolesDir, options = {}) {
|
|
|
3356
4376
|
checkParamRoles(qcReviewers, "QC reviewer");
|
|
3357
4377
|
for (const row of devTrack) {
|
|
3358
4378
|
if (row.track !== "primary" && row.track !== "parallel_secondary") {
|
|
3359
|
-
violations.push(
|
|
4379
|
+
violations.push(violation12("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"'));
|
|
3360
4380
|
}
|
|
3361
4381
|
}
|
|
3362
4382
|
const indices = qcReviewers.map((r) => r.reviewerIndex).sort((a, b) => a - b);
|
|
3363
4383
|
const unique = new Set(indices);
|
|
3364
4384
|
if (indices.length !== 3 || unique.size !== 3 || indices[0] !== 1 || indices[1] !== 2 || indices[2] !== 3) {
|
|
3365
|
-
violations.push(
|
|
4385
|
+
violations.push(violation12("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"));
|
|
3366
4386
|
}
|
|
3367
4387
|
for (const row of qcReviewers) {
|
|
3368
4388
|
if (row.focus.trim() === "") {
|
|
3369
|
-
violations.push(
|
|
4389
|
+
violations.push(violation12("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"));
|
|
3370
4390
|
}
|
|
3371
4391
|
if (row.reportSuffix !== `qc${row.reviewerIndex}`) {
|
|
3372
|
-
violations.push(
|
|
4392
|
+
violations.push(violation12("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}`));
|
|
3373
4393
|
}
|
|
3374
4394
|
}
|
|
3375
4395
|
return { ok: violations.length === 0, violations };
|
|
@@ -3408,11 +4428,11 @@ function lintLoadOrder(skillTexts) {
|
|
|
3408
4428
|
continue;
|
|
3409
4429
|
const section = extractLoadOrderSection(text);
|
|
3410
4430
|
if (section === null) {
|
|
3411
|
-
violations.push(
|
|
4431
|
+
violations.push(violation12("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`));
|
|
3412
4432
|
continue;
|
|
3413
4433
|
}
|
|
3414
4434
|
if (!section.includes("mstar-harness-core")) {
|
|
3415
|
-
violations.push(
|
|
4435
|
+
violations.push(violation12("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"));
|
|
3416
4436
|
}
|
|
3417
4437
|
}
|
|
3418
4438
|
return { ok: violations.length === 0, violations };
|
|
@@ -3458,7 +4478,7 @@ function resolveSkillRoot(host, paths) {
|
|
|
3458
4478
|
}
|
|
3459
4479
|
}
|
|
3460
4480
|
// src/skill-authoring.ts
|
|
3461
|
-
function
|
|
4481
|
+
function violation13(severity, code, message, fix) {
|
|
3462
4482
|
return { ok: false, severity, code, message, fix };
|
|
3463
4483
|
}
|
|
3464
4484
|
var FIVE_QUESTION_SECTIONS = [
|
|
@@ -3519,7 +4539,7 @@ function lintFiveQuestion(bodyText, mode = "authoring") {
|
|
|
3519
4539
|
const aliases = mode === "runtime" ? RUNTIME_HEADING_ALIASES[section.key] ?? [] : [];
|
|
3520
4540
|
const covered = headings.some((heading) => heading.includes(label) || aliases.some((alias) => heading.includes(alias)));
|
|
3521
4541
|
if (!covered) {
|
|
3522
|
-
violations.push(
|
|
4542
|
+
violations.push(violation13("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}`));
|
|
3523
4543
|
}
|
|
3524
4544
|
}
|
|
3525
4545
|
return { ok: violations.length === 0, violations };
|
|
@@ -3528,13 +4548,19 @@ function resolveAssetPath(skillName, relPath, host) {
|
|
|
3528
4548
|
return `skill \`${skillName}\` → ${relPath} (${resolveSkillRoot(host, { skill: skillName, rel: relPath })})`;
|
|
3529
4549
|
}
|
|
3530
4550
|
export {
|
|
4551
|
+
writeWorkflowSnapshot,
|
|
3531
4552
|
writeJson,
|
|
3532
4553
|
withStatusWriteLock,
|
|
3533
4554
|
verifyPlanExecutionLease,
|
|
4555
|
+
validateWorkflowSnapshot,
|
|
4556
|
+
validateWorkflowEntry,
|
|
4557
|
+
validateStatusV2,
|
|
3534
4558
|
validateStatus,
|
|
3535
4559
|
validateSchemaYaml,
|
|
3536
4560
|
validateRoleMapping,
|
|
4561
|
+
validateRoadmap,
|
|
3537
4562
|
validateResidual,
|
|
4563
|
+
validateProjectRegister,
|
|
3538
4564
|
validatePlanRow,
|
|
3539
4565
|
validateIntegrationMergeLease,
|
|
3540
4566
|
validateGitignore,
|
|
@@ -3543,6 +4569,7 @@ export {
|
|
|
3543
4569
|
validateCompassFrontmatter,
|
|
3544
4570
|
validateAuditStatusBlocks,
|
|
3545
4571
|
validateAssignmentFields,
|
|
4572
|
+
unregisterWorkflow,
|
|
3546
4573
|
techDebtRollup,
|
|
3547
4574
|
taskReportExists,
|
|
3548
4575
|
taskBrief,
|
|
@@ -3554,16 +4581,22 @@ export {
|
|
|
3554
4581
|
scaffoldAuditPlan,
|
|
3555
4582
|
sameHolderResume,
|
|
3556
4583
|
reviewPackage,
|
|
4584
|
+
resolveWorkflowDir,
|
|
3557
4585
|
resolveSpecsDir,
|
|
3558
4586
|
resolveSkillRoot,
|
|
3559
4587
|
resolveSddDir,
|
|
4588
|
+
resolveRepoEnforcement,
|
|
3560
4589
|
resolveProjectRoot,
|
|
4590
|
+
resolveProjectDir,
|
|
3561
4591
|
resolvePlanDir,
|
|
4592
|
+
resolveMstarcEnforcement,
|
|
4593
|
+
resolveKnowledgeDir,
|
|
3562
4594
|
resolveIterationDir,
|
|
3563
4595
|
resolveHarnessDir,
|
|
3564
4596
|
resolveCompassEnforcement,
|
|
3565
4597
|
resolveAssetPath,
|
|
3566
4598
|
releaseLease,
|
|
4599
|
+
registerWorkflow,
|
|
3567
4600
|
referenceExists,
|
|
3568
4601
|
redactSecrets,
|
|
3569
4602
|
readProgressLedger,
|
|
@@ -3572,13 +4605,16 @@ export {
|
|
|
3572
4605
|
pushCadenceProbe,
|
|
3573
4606
|
planQualityBar,
|
|
3574
4607
|
planExecutionLeaseLocations,
|
|
4608
|
+
parseMstarc,
|
|
3575
4609
|
parseEnforcementFlag,
|
|
3576
4610
|
parseDesignFrontmatter,
|
|
4611
|
+
parseCompassFrontmatterText,
|
|
3577
4612
|
parseCompassFrontmatter,
|
|
3578
4613
|
parseBranchPolicyDirectOnBranch,
|
|
3579
4614
|
parseAssignmentFields,
|
|
3580
4615
|
parseAssignmentBranchForms,
|
|
3581
4616
|
normalizeSeverity,
|
|
4617
|
+
migrateHarnessTree,
|
|
3582
4618
|
lintStrategySections,
|
|
3583
4619
|
lintSkillFrontmatter,
|
|
3584
4620
|
lintLoadOrder,
|
|
@@ -3591,6 +4627,7 @@ export {
|
|
|
3591
4627
|
findingsCleanupGate,
|
|
3592
4628
|
findTemporaryMarkers,
|
|
3593
4629
|
findSimplifyMarkers,
|
|
4630
|
+
findMstarc,
|
|
3594
4631
|
findEphemeralCitations,
|
|
3595
4632
|
executionModeToN,
|
|
3596
4633
|
evaluatePhaseGate,
|
|
@@ -3613,15 +4650,30 @@ export {
|
|
|
3613
4650
|
assertControlVsFeaturePath,
|
|
3614
4651
|
assertBranchAlignment,
|
|
3615
4652
|
assertBaseSha,
|
|
3616
|
-
|
|
4653
|
+
applyMigratePlan,
|
|
3617
4654
|
applyEnforcement,
|
|
3618
4655
|
antiRecursionPrecheck,
|
|
4656
|
+
_DEFAULT_PROJECT,
|
|
4657
|
+
WORKFLOW_TERMINAL_STATUSES,
|
|
4658
|
+
WORKFLOW_SNAPSHOT_FILE,
|
|
4659
|
+
WORKFLOW_LIFECYCLE_TYPES,
|
|
4660
|
+
WORKFLOW_LIFECYCLE_STATUSES,
|
|
3619
4661
|
SddScriptError,
|
|
3620
4662
|
SHARED_FAMILIES,
|
|
3621
4663
|
SEVERITY_ORDER,
|
|
3622
4664
|
RUNTIME_HEADING_ALIASES,
|
|
3623
4665
|
ROLE_MAPPING,
|
|
4666
|
+
ROADMAP_STATUSES,
|
|
3624
4667
|
QC_REVIEWER_PARAMS,
|
|
4668
|
+
PROJECT_ROADMAP_FILE,
|
|
4669
|
+
PROJECT_REGISTER_FILE,
|
|
4670
|
+
NOTES_LEDGER_FILE,
|
|
4671
|
+
MSTARC_WORKFLOW_DIR_KEY,
|
|
4672
|
+
MSTARC_SECTION,
|
|
4673
|
+
MSTARC_PROJECT_DIR_KEY,
|
|
4674
|
+
MSTARC_HARNESS_DIR_KEY,
|
|
4675
|
+
MSTARC_FILE,
|
|
4676
|
+
MIGRATE_STATUS_FILE,
|
|
3625
4677
|
KNOWLEDGE_SEVERITIES,
|
|
3626
4678
|
KNOWLEDGE_RESOLUTION_TYPES,
|
|
3627
4679
|
KNOWLEDGE_REQUIRED_FIELDS,
|
|
@@ -3634,5 +4686,6 @@ export {
|
|
|
3634
4686
|
AUDIT_RISKS,
|
|
3635
4687
|
AUDIT_PRIORITIES,
|
|
3636
4688
|
AUDIT_EFFORTS,
|
|
3637
|
-
AUDIT_CATEGORIES
|
|
4689
|
+
AUDIT_CATEGORIES,
|
|
4690
|
+
ARCHIVED_STATUS_V1_FILE
|
|
3638
4691
|
};
|