@mstar-harness/engine 2.4.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core.d.ts +1 -1
- package/dist/dispatch.d.ts +1 -1
- package/dist/engine.js +1600 -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 +150 -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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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")] };
|
|
1247
|
+
}
|
|
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
|
+
};
|
|
984
1255
|
}
|
|
985
|
-
if (
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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,795 @@ 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_REFERENCES_DIR = "references";
|
|
2216
|
+
var PROJECT_REGISTER_FILE = "residuals.json";
|
|
2217
|
+
var _DEFAULT_PROJECT = "_default";
|
|
2218
|
+
var ROADMAP_STATUSES = ["active", "paused", "completed"];
|
|
2219
|
+
var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
|
|
2220
|
+
var ROLLUP_FIELDS = ["total_open", "by_severity", "by_target", "by_plan"];
|
|
2221
|
+
function isPlainObject5(value) {
|
|
2222
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2223
|
+
}
|
|
2224
|
+
function violation7(severity, code, message, fix) {
|
|
2225
|
+
return { ok: false, severity, code, message, fix };
|
|
2226
|
+
}
|
|
2227
|
+
function validateNonEmptyString4(violations, value, field, missingCode, invalidCode) {
|
|
2228
|
+
if (value === undefined) {
|
|
2229
|
+
violations.push(violation7("high", missingCode, `missing required field: ${field}`));
|
|
2230
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
2231
|
+
violations.push(violation7("medium", invalidCode, `${field} must be a non-empty string`));
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
function validateRoadmap(filePath) {
|
|
2235
|
+
const violations = [];
|
|
2236
|
+
let content;
|
|
2237
|
+
try {
|
|
2238
|
+
content = readFileSync7(filePath, "utf8");
|
|
2239
|
+
} catch {
|
|
2240
|
+
return {
|
|
2241
|
+
ok: false,
|
|
2242
|
+
violations: [violation7("high", "project.roadmap.unreadable", `cannot read roadmap file: ${filePath}`)],
|
|
2243
|
+
warnings: []
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
let doc;
|
|
2247
|
+
try {
|
|
2248
|
+
doc = parseCompassFrontmatterText(content, filePath);
|
|
2249
|
+
} catch (err) {
|
|
2250
|
+
const message = err instanceof Error ? err.message : `invalid roadmap frontmatter in ${filePath}`;
|
|
2251
|
+
return { ok: false, violations: [violation7("high", "project.roadmap.invalid-frontmatter", message)], warnings: [] };
|
|
2252
|
+
}
|
|
2253
|
+
validateNonEmptyString4(violations, doc.project_id, "project_id", "project.roadmap.missing-project-id", "project.roadmap.invalid-project-id");
|
|
2254
|
+
validateNonEmptyString4(violations, doc.title, "title", "project.roadmap.missing-title", "project.roadmap.invalid-title");
|
|
2255
|
+
if (doc.status === undefined) {
|
|
2256
|
+
violations.push(violation7("high", "project.roadmap.missing-status", "missing required field: status"));
|
|
2257
|
+
} else if (typeof doc.status !== "string" || !ROADMAP_STATUSES.includes(doc.status)) {
|
|
2258
|
+
violations.push(violation7("medium", "project.roadmap.invalid-status", `status must be one of ${ROADMAP_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
|
|
2259
|
+
}
|
|
2260
|
+
if (doc.created_at === undefined) {
|
|
2261
|
+
violations.push(violation7("high", "project.roadmap.missing-created-at", "missing required field: created_at"));
|
|
2262
|
+
} else if (typeof doc.created_at !== "string" || !DATE_RE3.test(doc.created_at)) {
|
|
2263
|
+
violations.push(violation7("medium", "project.roadmap.invalid-created-at", "created_at must be YYYY-MM-DD"));
|
|
2264
|
+
}
|
|
2265
|
+
if (doc.milestones !== undefined && doc.milestones !== null) {
|
|
2266
|
+
if (!Array.isArray(doc.milestones)) {
|
|
2267
|
+
violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones must be a list of milestone names"));
|
|
2268
|
+
} else {
|
|
2269
|
+
for (const item of doc.milestones) {
|
|
2270
|
+
if (typeof item !== "string" || item.trim() === "") {
|
|
2271
|
+
violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones items must be non-empty strings"));
|
|
2272
|
+
break;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
if (doc.residuals_ref !== undefined && doc.residuals_ref !== null) {
|
|
2278
|
+
if (typeof doc.residuals_ref !== "string" || doc.residuals_ref.trim() === "") {
|
|
2279
|
+
violations.push(violation7("medium", "project.roadmap.invalid-residuals-ref", "residuals_ref must be a non-empty string"));
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
const warnings = [];
|
|
2283
|
+
const fenceEnd = linesIndexOfClosingFence(content);
|
|
2284
|
+
const body = content.split(/\r?\n/).slice(fenceEnd + 1).join(`
|
|
2285
|
+
`);
|
|
2286
|
+
if (!/^##\s+Direction\s*$/m.test(body)) {
|
|
2287
|
+
warnings.push(violation7("low", "project.roadmap.body.missing-direction", "roadmap body has no `## Direction` section (documented body convention) — state the project direction there"));
|
|
2288
|
+
}
|
|
2289
|
+
if (!/^\s*[-*]\s+\[[xX ]\]/m.test(body)) {
|
|
2290
|
+
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"));
|
|
2291
|
+
}
|
|
2292
|
+
return { ok: violations.length === 0, violations, warnings };
|
|
2293
|
+
}
|
|
2294
|
+
function linesIndexOfClosingFence(content) {
|
|
2295
|
+
return content.split(/\r?\n/).indexOf("---", 1);
|
|
2296
|
+
}
|
|
2297
|
+
function validateProjectRegister(doc) {
|
|
2298
|
+
const violations = [];
|
|
2299
|
+
if (!isPlainObject5(doc)) {
|
|
2300
|
+
return {
|
|
2301
|
+
ok: false,
|
|
2302
|
+
violations: [violation7("high", "project.register.invalid", "project register must be an object")]
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
if (doc.entries === undefined) {
|
|
2306
|
+
violations.push(violation7("high", "project.register.missing-entries", "missing required field: entries"));
|
|
2307
|
+
} else if (!isPlainObject5(doc.entries)) {
|
|
2308
|
+
violations.push(violation7("high", "project.register.invalid-entries", "entries must be an object keyed by plan id"));
|
|
2309
|
+
} else {
|
|
2310
|
+
for (const [key, entries] of Object.entries(doc.entries)) {
|
|
2311
|
+
if (key.trim() === "") {
|
|
2312
|
+
violations.push(violation7("medium", "project.register.invalid-key", "entries keys must be non-empty plan ids"));
|
|
2313
|
+
}
|
|
2314
|
+
if (!Array.isArray(entries)) {
|
|
2315
|
+
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)`));
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
for (const entry of entries) {
|
|
2319
|
+
violations.push(...validateResidual(entry).violations);
|
|
2320
|
+
if (!isPlainObject5(entry))
|
|
2321
|
+
continue;
|
|
2322
|
+
validateNonEmptyString4(violations, entry.source_plan, "source_plan", "project.register.missing-source-plan", "project.register.invalid-source-plan");
|
|
2323
|
+
if (entry.registered_at === undefined) {
|
|
2324
|
+
violations.push(violation7("high", "project.register.missing-registered-at", "missing required field: registered_at"));
|
|
2325
|
+
} else if (typeof entry.registered_at !== "string" || !DATE_RE3.test(entry.registered_at)) {
|
|
2326
|
+
violations.push(violation7("medium", "project.register.invalid-registered-at", "registered_at must be YYYY-MM-DD"));
|
|
2327
|
+
}
|
|
2328
|
+
if (entry.lifecycle_id !== undefined && (typeof entry.lifecycle_id !== "string" || entry.lifecycle_id.trim() === "")) {
|
|
2329
|
+
violations.push(violation7("medium", "project.register.invalid-lifecycle-id", "lifecycle_id must be a non-empty string"));
|
|
2330
|
+
}
|
|
2331
|
+
if (typeof entry.source_plan === "string" && entry.source_plan.trim() !== "" && entry.source_plan !== key) {
|
|
2332
|
+
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`));
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
return { ok: violations.length === 0, violations };
|
|
2338
|
+
}
|
|
2339
|
+
function findingsCleanupGate(register, planId, opts) {
|
|
2340
|
+
const mode = opts?.mode ?? "allow-residual";
|
|
2341
|
+
const violations = [];
|
|
2342
|
+
const entries = isPlainObject5(register.entries) ? register.entries[planId] : undefined;
|
|
2343
|
+
if (entries === undefined) {
|
|
2344
|
+
return { ok: true, violations };
|
|
2345
|
+
}
|
|
2346
|
+
if (!Array.isArray(entries)) {
|
|
2347
|
+
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)`));
|
|
2348
|
+
return { ok: false, violations };
|
|
2349
|
+
}
|
|
2350
|
+
if (entries.length === 0) {
|
|
2351
|
+
return { ok: true, violations };
|
|
2352
|
+
}
|
|
2353
|
+
for (const entry of entries) {
|
|
2354
|
+
if (!isOpenResidual(entry))
|
|
2355
|
+
continue;
|
|
2356
|
+
const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
|
|
2357
|
+
const label = `R#${id}`;
|
|
2358
|
+
if (mode === "zero-residual") {
|
|
2359
|
+
if (entry.severity === "nit") {
|
|
2360
|
+
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`));
|
|
2361
|
+
} else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
|
|
2362
|
+
violations.push(violation7("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
|
|
2363
|
+
} else if (entry.decision === "defer") {
|
|
2364
|
+
if (typeof entry.target !== "string" || entry.target.trim() === "") {
|
|
2365
|
+
violations.push(violation7("medium", "findings.zero-residual-defer-no-target", `${label}: blocker-defer requires a target (next iteration/milestone) under zero-residual`));
|
|
2366
|
+
}
|
|
2367
|
+
} else {
|
|
2368
|
+
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`));
|
|
2369
|
+
}
|
|
2370
|
+
} else if (normalizeSeverity(entry.severity) === "critical") {
|
|
2371
|
+
violations.push(violation7("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
return { ok: violations.length === 0, violations };
|
|
2375
|
+
}
|
|
2376
|
+
function groupCount(values) {
|
|
2377
|
+
const counts = new Map;
|
|
2378
|
+
for (const value of values) {
|
|
2379
|
+
const key = typeof value === "string" ? value : String(value);
|
|
2380
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
2381
|
+
}
|
|
2382
|
+
return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
2383
|
+
}
|
|
2384
|
+
function techDebtRollup(projectDir) {
|
|
2385
|
+
const items = [];
|
|
2386
|
+
let entries;
|
|
2387
|
+
try {
|
|
2388
|
+
entries = readdirSync5(projectDir, { withFileTypes: true });
|
|
2389
|
+
} catch {
|
|
2390
|
+
entries = [];
|
|
2391
|
+
}
|
|
2392
|
+
for (const project of entries) {
|
|
2393
|
+
if (!project.isDirectory())
|
|
2394
|
+
continue;
|
|
2395
|
+
const registerPath = join9(projectDir, project.name, PROJECT_REGISTER_FILE);
|
|
2396
|
+
if (!existsSync5(registerPath))
|
|
2397
|
+
continue;
|
|
2398
|
+
let register;
|
|
2399
|
+
try {
|
|
2400
|
+
register = readJson(registerPath);
|
|
2401
|
+
} catch {
|
|
2402
|
+
continue;
|
|
2403
|
+
}
|
|
2404
|
+
if (!isPlainObject5(register) || !isPlainObject5(register.entries))
|
|
2405
|
+
continue;
|
|
2406
|
+
for (const [plan, planEntries] of Object.entries(register.entries)) {
|
|
2407
|
+
if (!Array.isArray(planEntries))
|
|
2408
|
+
continue;
|
|
2409
|
+
for (const entry of planEntries) {
|
|
2410
|
+
if (!isPlainObject5(entry) || !isOpenResidual(entry))
|
|
2411
|
+
continue;
|
|
2412
|
+
items.push({ plan, entry });
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
const bySeverity = {};
|
|
2417
|
+
for (const severity of SEVERITY_ORDER) {
|
|
2418
|
+
bySeverity[severity] = items.filter(({ entry }) => normalizeSeverity(entry.severity) === severity).length;
|
|
2419
|
+
}
|
|
2420
|
+
const computed = {
|
|
2421
|
+
total_open: items.length,
|
|
2422
|
+
by_severity: bySeverity,
|
|
2423
|
+
by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
|
|
2424
|
+
by_plan: groupCount(items.map(({ plan }) => plan))
|
|
2425
|
+
};
|
|
2426
|
+
const stored = null;
|
|
2427
|
+
const checks = ROLLUP_FIELDS.map((field) => ({ field, status: "DRIFT" }));
|
|
2428
|
+
const overall = "DRIFT";
|
|
2429
|
+
return { computed, stored, checks, overall };
|
|
2430
|
+
}
|
|
2431
|
+
function listProjectReferenceFiles(projectDir) {
|
|
2432
|
+
const root = join9(projectDir, PROJECT_REFERENCES_DIR);
|
|
2433
|
+
let entries;
|
|
2434
|
+
try {
|
|
2435
|
+
entries = readdirSync5(root, { withFileTypes: true });
|
|
2436
|
+
} catch {
|
|
2437
|
+
return [];
|
|
2438
|
+
}
|
|
2439
|
+
const files = [];
|
|
2440
|
+
for (const entry of entries) {
|
|
2441
|
+
if (entry.name === PROJECT_ROADMAP_FILE || entry.name === PROJECT_REGISTER_FILE)
|
|
2442
|
+
continue;
|
|
2443
|
+
if (entry.isFile()) {
|
|
2444
|
+
files.push(entry.name);
|
|
2445
|
+
} else if (entry.isDirectory()) {
|
|
2446
|
+
let nested;
|
|
2447
|
+
try {
|
|
2448
|
+
nested = readdirSync5(join9(root, entry.name), { withFileTypes: true });
|
|
2449
|
+
} catch {
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
for (const child of nested) {
|
|
2453
|
+
if (child.isFile())
|
|
2454
|
+
files.push(`${entry.name}/${child.name}`);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
return files.sort();
|
|
2459
|
+
}
|
|
2460
|
+
// src/migrate.ts
|
|
2461
|
+
import { copyFileSync, mkdirSync as mkdirSync6, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2462
|
+
import { dirname as dirname7, isAbsolute as isAbsolute6, join as join10, relative as relative3, resolve as resolve8, sep as sep2 } from "node:path";
|
|
2463
|
+
var MIGRATE_STATUS_FILE = "status.json";
|
|
2464
|
+
var ARCHIVED_STATUS_V1_FILE = "archived/status.v1.json";
|
|
2465
|
+
var NOTES_LEDGER_FILE = "notes.jsonl";
|
|
2466
|
+
var DATE_RE4 = /^\d{4}-\d{2}-\d{2}$/;
|
|
2467
|
+
var ROOT_METADATA_LIFT_KEYS = {
|
|
2468
|
+
plan_parallelism: true,
|
|
2469
|
+
worktree_mode: true,
|
|
2470
|
+
push_policy: true,
|
|
2471
|
+
iteration_base_branch: true,
|
|
2472
|
+
target_branch: true,
|
|
2473
|
+
spec_integration_branch: true,
|
|
2474
|
+
control_worktree_path: true,
|
|
2475
|
+
integration_merge_lease: true,
|
|
2476
|
+
program_roadmap: true,
|
|
2477
|
+
updated_at: true
|
|
2478
|
+
};
|
|
2479
|
+
function isPlainObject6(value) {
|
|
2480
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2481
|
+
}
|
|
2482
|
+
function dateString(value) {
|
|
2483
|
+
return typeof value === "string" && DATE_RE4.test(value) ? value : undefined;
|
|
2484
|
+
}
|
|
2485
|
+
function rowIdOf(row) {
|
|
2486
|
+
if (typeof row.id === "string" && row.id.trim() !== "")
|
|
2487
|
+
return row.id;
|
|
2488
|
+
if (typeof row.plan_id === "string" && row.plan_id.trim() !== "")
|
|
2489
|
+
return row.plan_id;
|
|
2490
|
+
return null;
|
|
2491
|
+
}
|
|
2492
|
+
function compareIds(a, b) {
|
|
2493
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
2494
|
+
}
|
|
2495
|
+
function todayString2() {
|
|
2496
|
+
const now = new Date;
|
|
2497
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
2498
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
2499
|
+
return `${now.getFullYear()}-${month}-${day}`;
|
|
2500
|
+
}
|
|
2501
|
+
function scanCompasses(harnessDir) {
|
|
2502
|
+
const iterationsDir = join10(harnessDir, "iterations");
|
|
2503
|
+
const out = [];
|
|
2504
|
+
let entries;
|
|
2505
|
+
try {
|
|
2506
|
+
entries = readdirSync6(iterationsDir, { withFileTypes: true });
|
|
2507
|
+
} catch {
|
|
2508
|
+
return out;
|
|
2509
|
+
}
|
|
2510
|
+
for (const entry of entries) {
|
|
2511
|
+
if (!entry.isDirectory())
|
|
2512
|
+
continue;
|
|
2513
|
+
const compassPath = join10(iterationsDir, entry.name, "delivery-compass.md");
|
|
2514
|
+
let content;
|
|
2515
|
+
try {
|
|
2516
|
+
content = readFileSync8(compassPath, "utf8");
|
|
2517
|
+
} catch {
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
2520
|
+
const doc = parseCompassFrontmatterText(content, compassPath);
|
|
2521
|
+
const status = doc.status;
|
|
2522
|
+
if (status !== "active" && status !== "locked" && status !== "completed") {
|
|
2523
|
+
throw new Error(`refusing to migrate: compass ${JSON.stringify(compassPath)} has unsupported status ${JSON.stringify(status)} (expected active | locked | completed)`);
|
|
2524
|
+
}
|
|
2525
|
+
const plans = Array.isArray(doc.plans) ? doc.plans.filter((p) => typeof p === "string" && p !== "") : [];
|
|
2526
|
+
out.push({
|
|
2527
|
+
id: typeof doc.iteration_id === "string" && doc.iteration_id !== "" ? doc.iteration_id : entry.name,
|
|
2528
|
+
file: compassPath,
|
|
2529
|
+
status,
|
|
2530
|
+
plans,
|
|
2531
|
+
startDate: dateString(doc.start_date),
|
|
2532
|
+
endDate: dateString(doc.end_date)
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
out.sort((a, b) => compareIds(a.id, b.id));
|
|
2536
|
+
return out;
|
|
2537
|
+
}
|
|
2538
|
+
function pickDate(preferred, fallback) {
|
|
2539
|
+
for (const candidate of preferred) {
|
|
2540
|
+
if (candidate !== undefined)
|
|
2541
|
+
return candidate;
|
|
2542
|
+
}
|
|
2543
|
+
return fallback;
|
|
2544
|
+
}
|
|
2545
|
+
function groupRows(rows, compasses) {
|
|
2546
|
+
const rowById = new Map;
|
|
2547
|
+
for (const row of rows) {
|
|
2548
|
+
const id = rowIdOf(row);
|
|
2549
|
+
if (id !== null && !rowById.has(id))
|
|
2550
|
+
rowById.set(id, row);
|
|
2551
|
+
}
|
|
2552
|
+
const byPlan = new Map;
|
|
2553
|
+
for (const compass of compasses) {
|
|
2554
|
+
for (const planId of compass.plans) {
|
|
2555
|
+
if (!byPlan.has(planId))
|
|
2556
|
+
byPlan.set(planId, compass);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
return { byPlan, rowById };
|
|
2560
|
+
}
|
|
2561
|
+
function buildIterationSnapshot(compass, rowById, rootUpdatedAt) {
|
|
2562
|
+
const rows = compass.plans.map((planId) => rowById.get(planId)).filter((row) => row !== undefined);
|
|
2563
|
+
rows.sort((a, b) => compareIds(rowIdOf(a) ?? "", rowIdOf(b) ?? ""));
|
|
2564
|
+
const rowDates = rows.flatMap((row) => [
|
|
2565
|
+
dateString(row.created_at),
|
|
2566
|
+
dateString(row.updated_at),
|
|
2567
|
+
dateString(row.done_at)
|
|
2568
|
+
]);
|
|
2569
|
+
const startedAt = pickDate([compass.startDate, ...rowDates], rootUpdatedAt);
|
|
2570
|
+
const endedAt = pickDate([compass.endDate, ...rowDates], rootUpdatedAt);
|
|
2571
|
+
const status = compass.status === "completed" ? "completed" : "running";
|
|
2572
|
+
const compactMissing = compass.plans.filter((planId) => !rowById.has(planId)).sort();
|
|
2573
|
+
const snapshot = {
|
|
2574
|
+
schema_version: 1,
|
|
2575
|
+
id: compass.id,
|
|
2576
|
+
type: "iteration",
|
|
2577
|
+
status,
|
|
2578
|
+
started_at: startedAt,
|
|
2579
|
+
...status === "completed" ? { ended_at: endedAt } : {},
|
|
2580
|
+
updated_at: status === "completed" ? endedAt : startedAt,
|
|
2581
|
+
plans: rows,
|
|
2582
|
+
compass_ref: `iterations/${compass.id}/delivery-compass.md`
|
|
2583
|
+
};
|
|
2584
|
+
const legacyMetadata = {};
|
|
2585
|
+
if (compactMissing.length > 0)
|
|
2586
|
+
legacyMetadata.compact_missing = compactMissing;
|
|
2587
|
+
if (Object.keys(legacyMetadata).length > 0)
|
|
2588
|
+
snapshot.legacy_metadata = legacyMetadata;
|
|
2589
|
+
return {
|
|
2590
|
+
id: compass.id,
|
|
2591
|
+
type: "iteration",
|
|
2592
|
+
status,
|
|
2593
|
+
file: join10("workflows", compass.id, WORKFLOW_SNAPSHOT_FILE),
|
|
2594
|
+
source: join10("iterations", compass.id, "delivery-compass.md"),
|
|
2595
|
+
data: snapshot
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
function buildStandaloneSnapshot(row, rootUpdatedAt, migrationNotes) {
|
|
2599
|
+
const id = rowIdOf(row) ?? "<unnamed>";
|
|
2600
|
+
const statusValue = row.status;
|
|
2601
|
+
let status;
|
|
2602
|
+
if (statusValue === "Done")
|
|
2603
|
+
status = "completed";
|
|
2604
|
+
else if (statusValue === "InProgress" || statusValue === "InReview")
|
|
2605
|
+
status = "running";
|
|
2606
|
+
else if (statusValue === "Todo" || statusValue === "Blocked")
|
|
2607
|
+
status = "paused";
|
|
2608
|
+
else {
|
|
2609
|
+
status = "paused";
|
|
2610
|
+
migrationNotes.push(`row ${JSON.stringify(id)} has unrecognized v1 status ${JSON.stringify(statusValue)} — snapshot status defaults to paused (row status stays verbatim)`);
|
|
2611
|
+
}
|
|
2612
|
+
const startedAt = pickDate([dateString(row.created_at), dateString(row.updated_at)], rootUpdatedAt);
|
|
2613
|
+
const updatedAt = pickDate([dateString(row.updated_at), dateString(row.done_at), dateString(row.created_at)], rootUpdatedAt);
|
|
2614
|
+
const endedAt = pickDate([dateString(row.done_at), dateString(row.updated_at)], rootUpdatedAt);
|
|
2615
|
+
const snapshot = {
|
|
2616
|
+
schema_version: 1,
|
|
2617
|
+
id,
|
|
2618
|
+
type: "plan",
|
|
2619
|
+
status,
|
|
2620
|
+
started_at: startedAt,
|
|
2621
|
+
...status === "completed" ? { ended_at: endedAt } : {},
|
|
2622
|
+
updated_at: status === "completed" ? endedAt : updatedAt,
|
|
2623
|
+
plans: [row]
|
|
2624
|
+
};
|
|
2625
|
+
return {
|
|
2626
|
+
id,
|
|
2627
|
+
type: "plan",
|
|
2628
|
+
status,
|
|
2629
|
+
file: join10("workflows", id, WORKFLOW_SNAPSHOT_FILE),
|
|
2630
|
+
source: "status.json plans[] row",
|
|
2631
|
+
data: snapshot
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
function applyRootMetadataLift(snapshot, metadata, migrationNotes, activeIterations) {
|
|
2635
|
+
const data = snapshot.data;
|
|
2636
|
+
const policy = {};
|
|
2637
|
+
for (const key of ["plan_parallelism", "worktree_mode", "push_policy"]) {
|
|
2638
|
+
if (metadata[key] !== undefined)
|
|
2639
|
+
policy[key] = metadata[key];
|
|
2640
|
+
}
|
|
2641
|
+
if (Object.keys(policy).length > 0)
|
|
2642
|
+
data.execution_policy = policy;
|
|
2643
|
+
const branch = {};
|
|
2644
|
+
const branchKeys = [
|
|
2645
|
+
["base", "iteration_base_branch"],
|
|
2646
|
+
["integration", "spec_integration_branch"],
|
|
2647
|
+
["target", "target_branch"]
|
|
2648
|
+
];
|
|
2649
|
+
for (const [target, source] of branchKeys) {
|
|
2650
|
+
const value = metadata[source];
|
|
2651
|
+
if (typeof value === "string" && value !== "")
|
|
2652
|
+
branch[target] = value;
|
|
2653
|
+
}
|
|
2654
|
+
if (Object.keys(branch).length > 0)
|
|
2655
|
+
data.branch = branch;
|
|
2656
|
+
if (typeof metadata.control_worktree_path === "string" && metadata.control_worktree_path !== "") {
|
|
2657
|
+
data.control_worktree_path = metadata.control_worktree_path;
|
|
2658
|
+
}
|
|
2659
|
+
if (isPlainObject6(metadata.integration_merge_lease)) {
|
|
2660
|
+
data.integration_merge_lease = metadata.integration_merge_lease;
|
|
2661
|
+
}
|
|
2662
|
+
const legacyMetadata = isPlainObject6(data.legacy_metadata) ? { ...data.legacy_metadata } : {};
|
|
2663
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
2664
|
+
if (key === "harness_root")
|
|
2665
|
+
continue;
|
|
2666
|
+
if (ROOT_METADATA_LIFT_KEYS[key] === true)
|
|
2667
|
+
continue;
|
|
2668
|
+
legacyMetadata[key] = value;
|
|
2669
|
+
}
|
|
2670
|
+
if (metadata.harness_root !== undefined) {
|
|
2671
|
+
legacyMetadata.harness_root_note = `dropped as redundant (v2 harness dir derives from status.json location): ${String(metadata.harness_root)}`;
|
|
2672
|
+
}
|
|
2673
|
+
if (Object.keys(legacyMetadata).length > 0)
|
|
2674
|
+
data.legacy_metadata = legacyMetadata;
|
|
2675
|
+
if (activeIterations > 1) {
|
|
2676
|
+
migrationNotes.push(`${activeIterations} active iterations present — root-metadata lifts applied to ${JSON.stringify(snapshot.id)} only`);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
function buildRoadmap(programRoadmap, projectId, migratedAt) {
|
|
2680
|
+
const title = typeof programRoadmap.title === "string" && programRoadmap.title !== "" ? programRoadmap.title : "Program roadmap";
|
|
2681
|
+
const doc = typeof programRoadmap.doc === "string" ? programRoadmap.doc : "";
|
|
2682
|
+
const completionVersion = typeof programRoadmap.completion_version === "string" ? programRoadmap.completion_version : "";
|
|
2683
|
+
const branch = typeof programRoadmap.branch === "string" ? programRoadmap.branch : "";
|
|
2684
|
+
const milestones = Array.isArray(programRoadmap.slices) ? programRoadmap.slices.map((slice) => String(slice)).filter((slice) => slice !== "") : [];
|
|
2685
|
+
const deferred = Array.isArray(programRoadmap.deferred_beyond) ? programRoadmap.deferred_beyond.map((item) => String(item)).filter((item) => item !== "") : [];
|
|
2686
|
+
const lines = [
|
|
2687
|
+
"---",
|
|
2688
|
+
`project_id: ${projectId}`,
|
|
2689
|
+
`title: ${title}`,
|
|
2690
|
+
"status: active",
|
|
2691
|
+
`created_at: ${migratedAt}`
|
|
2692
|
+
];
|
|
2693
|
+
if (milestones.length > 0) {
|
|
2694
|
+
lines.push("milestones:");
|
|
2695
|
+
for (const milestone of milestones)
|
|
2696
|
+
lines.push(` - ${milestone}`);
|
|
2697
|
+
}
|
|
2698
|
+
lines.push("residuals_ref: residuals.json", "---", "", "# Roadmap", "", "## Direction");
|
|
2699
|
+
const provenance = [doc !== "" ? `doc: ${doc}` : "", completionVersion !== "" ? `completion_version: ${completionVersion}` : "", branch !== "" ? `branch: ${branch}` : ""].filter((part) => part !== "").join(", ");
|
|
2700
|
+
lines.push(`Migrated from legacy status.json \`metadata.program_roadmap\`${provenance !== "" ? ` (${provenance})` : ""}.`);
|
|
2701
|
+
if (programRoadmap.no_intermediate_releases !== undefined) {
|
|
2702
|
+
lines.push(`no_intermediate_releases: ${String(programRoadmap.no_intermediate_releases)}`);
|
|
2703
|
+
}
|
|
2704
|
+
if (deferred.length > 0) {
|
|
2705
|
+
lines.push("", "### Deferred beyond", ...deferred.map((item) => `- ${item}`));
|
|
2706
|
+
}
|
|
2707
|
+
lines.push("");
|
|
2708
|
+
return lines.join(`
|
|
2709
|
+
`);
|
|
2710
|
+
}
|
|
2711
|
+
function buildRegister(residualFindings, byPlan, projectId, migratedAt) {
|
|
2712
|
+
const entries = {};
|
|
2713
|
+
const planKeys = Object.keys(residualFindings).sort();
|
|
2714
|
+
for (const planId of planKeys) {
|
|
2715
|
+
const raw = residualFindings[planId];
|
|
2716
|
+
if (!Array.isArray(raw))
|
|
2717
|
+
continue;
|
|
2718
|
+
const open = raw.filter((entry) => isPlainObject6(entry) && isOpenResidual(entry)).sort((a, b) => {
|
|
2719
|
+
const aId = typeof a.id === "string" ? a.id : "";
|
|
2720
|
+
const bId = typeof b.id === "string" ? b.id : "";
|
|
2721
|
+
return compareIds(aId, bId);
|
|
2722
|
+
});
|
|
2723
|
+
if (open.length === 0)
|
|
2724
|
+
continue;
|
|
2725
|
+
const owner = byPlan.get(planId);
|
|
2726
|
+
entries[planId] = open.map((entry) => ({
|
|
2727
|
+
...entry,
|
|
2728
|
+
source_plan: planId,
|
|
2729
|
+
registered_at: migratedAt,
|
|
2730
|
+
...owner !== undefined ? { lifecycle_id: owner.id } : {}
|
|
2731
|
+
}));
|
|
2732
|
+
}
|
|
2733
|
+
if (Object.keys(entries).length === 0)
|
|
2734
|
+
return null;
|
|
2735
|
+
const doc = { entries };
|
|
2736
|
+
return {
|
|
2737
|
+
file: join10("projects", projectId, PROJECT_REGISTER_FILE),
|
|
2738
|
+
source: "status.json residual_findings",
|
|
2739
|
+
data: doc
|
|
2740
|
+
};
|
|
2741
|
+
}
|
|
2742
|
+
function collectNotesFiles(snapshots) {
|
|
2743
|
+
const out = [];
|
|
2744
|
+
for (const snapshot of snapshots) {
|
|
2745
|
+
const lines = [];
|
|
2746
|
+
let source = "";
|
|
2747
|
+
for (const row of snapshot.data.plans) {
|
|
2748
|
+
if (Array.isArray(row.notes)) {
|
|
2749
|
+
source = "status.json plans[].notes arrays";
|
|
2750
|
+
for (const note of row.notes) {
|
|
2751
|
+
if (typeof note !== "string")
|
|
2752
|
+
continue;
|
|
2753
|
+
lines.push(JSON.stringify({ kind: "note", ts: snapshot.data.updated_at, text: note }));
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
if (snapshot.type === "plan" && snapshot.status === "paused") {
|
|
2758
|
+
const row = snapshot.data.plans[0];
|
|
2759
|
+
if (row !== undefined && row.status === "Todo") {
|
|
2760
|
+
source = source === "" ? "status.json plans[] Todo rows (not-started note)" : source;
|
|
2761
|
+
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)";
|
|
2762
|
+
lines.push(JSON.stringify({ kind: "note", ts: snapshot.data.updated_at, text: noteText }));
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
if (lines.length === 0)
|
|
2766
|
+
continue;
|
|
2767
|
+
out.push({
|
|
2768
|
+
file: join10(dirname7(snapshot.file), NOTES_LEDGER_FILE),
|
|
2769
|
+
source,
|
|
2770
|
+
lines
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
return out;
|
|
2774
|
+
}
|
|
2775
|
+
function migrateHarnessTree(root, opts = {}) {
|
|
2776
|
+
const harnessDir = resolve8(root);
|
|
2777
|
+
const workflowDir = resolveWorkflowDir(harnessDir, { harnessDir });
|
|
2778
|
+
const projectDir = resolveProjectDir(harnessDir, { harnessDir });
|
|
2779
|
+
const projectId = opts.projectId ?? _DEFAULT_PROJECT;
|
|
2780
|
+
const statusPath = join10(harnessDir, MIGRATE_STATUS_FILE);
|
|
2781
|
+
const legacy = readJson(statusPath);
|
|
2782
|
+
if (legacy.version === 2) {
|
|
2783
|
+
const updatedAt = typeof legacy.updated_at === "string" && legacy.updated_at !== "" ? legacy.updated_at : "1970-01-01";
|
|
2784
|
+
return {
|
|
2785
|
+
root: harnessDir,
|
|
2786
|
+
workflowDir,
|
|
2787
|
+
projectDir,
|
|
2788
|
+
dryRun: opts.dryRun === true,
|
|
2789
|
+
alreadyMigrated: true,
|
|
2790
|
+
message: `no-op: ${statusPath} is already at schema version 2 (migrated) — nothing to do`,
|
|
2791
|
+
snapshots: [],
|
|
2792
|
+
notesFiles: [],
|
|
2793
|
+
register: null,
|
|
2794
|
+
roadmap: null,
|
|
2795
|
+
rootV2: { file: MIGRATE_STATUS_FILE, data: { version: 2, updated_at: updatedAt, workflows: [] } },
|
|
2796
|
+
archive: { file: ARCHIVED_STATUS_V1_FILE, source: MIGRATE_STATUS_FILE },
|
|
2797
|
+
migrationNotes: [],
|
|
2798
|
+
steps: []
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
if (legacy.version !== undefined && legacy.version !== 1) {
|
|
2802
|
+
throw new Error(`refusing to migrate: ${statusPath} has unrecognized schema version ${JSON.stringify(legacy.version)} (expected 1)`);
|
|
2803
|
+
}
|
|
2804
|
+
if (legacy.version === undefined) {
|
|
2805
|
+
throw new Error(`refusing to migrate: no v1 status.json found at ${statusPath} (nothing to migrate)`);
|
|
2806
|
+
}
|
|
2807
|
+
const rows = Array.isArray(legacy.plans) ? legacy.plans.filter(isPlainObject6) : [];
|
|
2808
|
+
if (Array.isArray(legacy.plans)) {
|
|
2809
|
+
const unLiftable = [];
|
|
2810
|
+
const idCounts = new Map;
|
|
2811
|
+
for (const row of legacy.plans) {
|
|
2812
|
+
if (!isPlainObject6(row) || rowIdOf(row) === null) {
|
|
2813
|
+
unLiftable.push(row);
|
|
2814
|
+
continue;
|
|
2815
|
+
}
|
|
2816
|
+
const id = rowIdOf(row);
|
|
2817
|
+
assertSafePathComponent(id, "plan id");
|
|
2818
|
+
idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
|
|
2819
|
+
}
|
|
2820
|
+
if (unLiftable.length > 0) {
|
|
2821
|
+
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`);
|
|
2822
|
+
}
|
|
2823
|
+
const duplicates = [...idCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id);
|
|
2824
|
+
if (duplicates.length > 0) {
|
|
2825
|
+
throw new Error(`refusing to migrate: ${duplicates.length} duplicate plan id(s) (${duplicates.join(", ")}) — every v1 row must land in exactly one snapshot`);
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
const metadata = isPlainObject6(legacy.metadata) ? legacy.metadata : {};
|
|
2829
|
+
const rootUpdatedAt = dateString(legacy.updated_at) ?? dateString(metadata.updated_at) ?? todayString2();
|
|
2830
|
+
const migratedAt = dateString(metadata.updated_at) ?? rootUpdatedAt;
|
|
2831
|
+
const migrationNotes = [];
|
|
2832
|
+
const compasses = scanCompasses(harnessDir);
|
|
2833
|
+
for (const compass of compasses) {
|
|
2834
|
+
assertSafePathComponent(compass.id, "iteration id");
|
|
2835
|
+
}
|
|
2836
|
+
assertSafePathComponent(projectId, "projectId");
|
|
2837
|
+
const { byPlan, rowById } = groupRows(rows, compasses);
|
|
2838
|
+
const snapshots = compasses.map((compass) => buildIterationSnapshot(compass, rowById, rootUpdatedAt));
|
|
2839
|
+
for (const row of rows) {
|
|
2840
|
+
const id = rowIdOf(row);
|
|
2841
|
+
if (id !== null && !byPlan.has(id))
|
|
2842
|
+
snapshots.push(buildStandaloneSnapshot(row, rootUpdatedAt, migrationNotes));
|
|
2843
|
+
}
|
|
2844
|
+
const lifecycleSources = new Map;
|
|
2845
|
+
for (const snapshot of snapshots) {
|
|
2846
|
+
const sources = lifecycleSources.get(snapshot.id) ?? [];
|
|
2847
|
+
sources.push(snapshot.type === "iteration" ? "iteration" : "standalone plan");
|
|
2848
|
+
lifecycleSources.set(snapshot.id, sources);
|
|
2849
|
+
}
|
|
2850
|
+
const projectSources = lifecycleSources.get(projectId) ?? [];
|
|
2851
|
+
projectSources.push("project");
|
|
2852
|
+
lifecycleSources.set(projectId, projectSources);
|
|
2853
|
+
const collisions = [...lifecycleSources.entries()].filter(([, sources]) => sources.length > 1);
|
|
2854
|
+
if (collisions.length > 0) {
|
|
2855
|
+
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)`);
|
|
2856
|
+
}
|
|
2857
|
+
snapshots.sort((a, b) => compareIds(a.id, b.id));
|
|
2858
|
+
const activeIterations = snapshots.filter((snapshot) => snapshot.status === "running" && snapshot.type === "iteration");
|
|
2859
|
+
if (activeIterations.length > 0) {
|
|
2860
|
+
applyRootMetadataLift(activeIterations[0], metadata, migrationNotes, activeIterations.length);
|
|
2861
|
+
} else if (Object.keys(metadata).length > 0) {
|
|
2862
|
+
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)");
|
|
2863
|
+
}
|
|
2864
|
+
const notesFiles = collectNotesFiles(snapshots);
|
|
2865
|
+
const residualFindings = isPlainObject6(legacy.residual_findings) ? legacy.residual_findings : {};
|
|
2866
|
+
const register = buildRegister(residualFindings, byPlan, projectId, migratedAt);
|
|
2867
|
+
const programRoadmap = isPlainObject6(metadata.program_roadmap) ? metadata.program_roadmap : null;
|
|
2868
|
+
let roadmap = null;
|
|
2869
|
+
if (programRoadmap) {
|
|
2870
|
+
const rawTitle = typeof programRoadmap.title === "string" && programRoadmap.title !== "" ? programRoadmap.title : "Program roadmap";
|
|
2871
|
+
const sanitizedTitle = rawTitle.replace(/[\r\n]+/g, " ").trim();
|
|
2872
|
+
if (sanitizedTitle !== rawTitle) {
|
|
2873
|
+
migrationNotes.push(`roadmap title sanitized for frontmatter (line breaks replaced with spaces): ${JSON.stringify(rawTitle)}`);
|
|
2874
|
+
}
|
|
2875
|
+
roadmap = {
|
|
2876
|
+
file: join10("projects", projectId, PROJECT_ROADMAP_FILE),
|
|
2877
|
+
source: "status.json metadata.program_roadmap",
|
|
2878
|
+
content: buildRoadmap({ ...programRoadmap, title: sanitizedTitle }, projectId, migratedAt)
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
2881
|
+
const rootV2 = {
|
|
2882
|
+
file: MIGRATE_STATUS_FILE,
|
|
2883
|
+
data: { version: 2, updated_at: migratedAt, workflows: [] }
|
|
2884
|
+
};
|
|
2885
|
+
const archive = { file: ARCHIVED_STATUS_V1_FILE, source: MIGRATE_STATUS_FILE };
|
|
2886
|
+
const steps = [
|
|
2887
|
+
{ kind: "archive-status-v1", source: archive.source, destination: archive.file },
|
|
2888
|
+
...snapshots.map((snapshot) => ({
|
|
2889
|
+
kind: "write-snapshot",
|
|
2890
|
+
source: snapshot.source,
|
|
2891
|
+
destination: snapshot.file
|
|
2892
|
+
})),
|
|
2893
|
+
...notesFiles.map((notes) => ({
|
|
2894
|
+
kind: "write-notes",
|
|
2895
|
+
source: notes.source,
|
|
2896
|
+
destination: notes.file
|
|
2897
|
+
}))
|
|
2898
|
+
];
|
|
2899
|
+
if (register !== null)
|
|
2900
|
+
steps.push({ kind: "write-register", source: register.source, destination: register.file });
|
|
2901
|
+
if (roadmap !== null)
|
|
2902
|
+
steps.push({ kind: "write-roadmap", source: roadmap.source, destination: roadmap.file });
|
|
2903
|
+
steps.push({ kind: "replace-root-v2", source: `${MIGRATE_STATUS_FILE} (v1)`, destination: `${MIGRATE_STATUS_FILE} (v2)` });
|
|
2904
|
+
return {
|
|
2905
|
+
root: harnessDir,
|
|
2906
|
+
workflowDir,
|
|
2907
|
+
projectDir,
|
|
2908
|
+
dryRun: opts.dryRun === true,
|
|
2909
|
+
alreadyMigrated: false,
|
|
2910
|
+
message: `planned migration of ${snapshots.length} lifecycles (${steps.length} steps)`,
|
|
2911
|
+
snapshots,
|
|
2912
|
+
notesFiles,
|
|
2913
|
+
register,
|
|
2914
|
+
roadmap,
|
|
2915
|
+
rootV2,
|
|
2916
|
+
archive,
|
|
2917
|
+
migrationNotes,
|
|
2918
|
+
steps
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
async function applyMigratePlan(plan) {
|
|
2922
|
+
if (plan.dryRun) {
|
|
2923
|
+
return { applied: false, message: `dry-run: ${plan.steps.length} steps planned (source → destination), zero writes` };
|
|
2924
|
+
}
|
|
2925
|
+
const statusPath = join10(plan.root, MIGRATE_STATUS_FILE);
|
|
2926
|
+
const current = readJson(statusPath);
|
|
2927
|
+
if (current.version === 2) {
|
|
2928
|
+
return { applied: false, message: "no-op: status.json already at schema version 2 (migrated) — nothing to do" };
|
|
2929
|
+
}
|
|
2930
|
+
const harnessRoot = resolve8(plan.root);
|
|
2931
|
+
const workflowRoot = resolve8(plan.workflowDir);
|
|
2932
|
+
const projectRoot = resolve8(plan.projectDir);
|
|
2933
|
+
if (!isAbsolute6(plan.workflowDir) || !isAbsolute6(plan.projectDir)) {
|
|
2934
|
+
throw new Error(`refusing to apply migration: plan workflowDir/projectDir must be absolute (got ${JSON.stringify(plan.workflowDir)} / ${JSON.stringify(plan.projectDir)})`);
|
|
2935
|
+
}
|
|
2936
|
+
const workflowTargetOf = (canonicalFile) => join10(workflowRoot, relative3("workflows", canonicalFile));
|
|
2937
|
+
const projectTargetOf = (canonicalFile) => join10(projectRoot, relative3("projects", canonicalFile));
|
|
2938
|
+
const allDestinations = [
|
|
2939
|
+
plan.archive.file,
|
|
2940
|
+
...plan.snapshots.map((snapshot) => snapshot.file),
|
|
2941
|
+
...plan.notesFiles.map((notes) => notes.file),
|
|
2942
|
+
...plan.register !== null ? [plan.register.file] : [],
|
|
2943
|
+
...plan.roadmap !== null ? [plan.roadmap.file] : []
|
|
2944
|
+
];
|
|
2945
|
+
for (const destination of allDestinations) {
|
|
2946
|
+
const resolvedDest = resolve8(join10(plan.root, destination));
|
|
2947
|
+
const inside = (dir) => resolvedDest === dir || resolvedDest.startsWith(`${dir}${sep2}`);
|
|
2948
|
+
if (!inside(harnessRoot) && !inside(workflowRoot) && !inside(projectRoot)) {
|
|
2949
|
+
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)})`);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
mkdirSync6(join10(plan.root, dirname7(plan.archive.file)), { recursive: true });
|
|
2953
|
+
copyFileSync(statusPath, join10(plan.root, plan.archive.file));
|
|
2954
|
+
for (const snapshot of plan.snapshots) {
|
|
2955
|
+
await writeWorkflowSnapshot(snapshot.data, dirname7(workflowTargetOf(snapshot.file)));
|
|
2956
|
+
}
|
|
2957
|
+
for (const notes of plan.notesFiles) {
|
|
2958
|
+
const filePath = workflowTargetOf(notes.file);
|
|
2959
|
+
mkdirSync6(dirname7(filePath), { recursive: true });
|
|
2960
|
+
const content = notes.lines.length > 0 ? `${notes.lines.join(`
|
|
2961
|
+
`)}
|
|
2962
|
+
` : "";
|
|
2963
|
+
writeFileSync4(filePath, content, "utf8");
|
|
2964
|
+
}
|
|
2965
|
+
if (plan.register !== null) {
|
|
2966
|
+
const gate2 = validateProjectRegister(plan.register.data);
|
|
2967
|
+
if (!gate2.ok) {
|
|
2968
|
+
throw new Error(`refusing to apply migration: invalid project register: ${gate2.violations.map((v) => v.message).join("; ")}`);
|
|
2969
|
+
}
|
|
2970
|
+
const filePath = projectTargetOf(plan.register.file);
|
|
2971
|
+
mkdirSync6(dirname7(filePath), { recursive: true });
|
|
2972
|
+
writeJson(filePath, plan.register.data);
|
|
2973
|
+
}
|
|
2974
|
+
if (plan.roadmap !== null) {
|
|
2975
|
+
const filePath = projectTargetOf(plan.roadmap.file);
|
|
2976
|
+
mkdirSync6(dirname7(filePath), { recursive: true });
|
|
2977
|
+
writeFileSync4(filePath, plan.roadmap.content, "utf8");
|
|
2978
|
+
}
|
|
2979
|
+
const rootGate = validateStatusV2(plan.rootV2.data, { harnessDir: plan.root });
|
|
2980
|
+
if (!rootGate.ok) {
|
|
2981
|
+
throw new Error(`refusing to apply migration: invalid v2 root: ${rootGate.violations.map((v) => v.message).join("; ")}`);
|
|
2982
|
+
}
|
|
2983
|
+
return withStatusWriteLock(statusPath, () => {
|
|
2984
|
+
const latest = readJson(statusPath);
|
|
2985
|
+
if (latest.version === 2) {
|
|
2986
|
+
return {
|
|
2987
|
+
applied: false,
|
|
2988
|
+
message: "no-op: status.json already at schema version 2 (migrated) — nothing to do"
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
writeJson(statusPath, plan.rootV2.data);
|
|
2992
|
+
return {
|
|
2993
|
+
applied: true,
|
|
2994
|
+
message: `migrated ${plan.snapshots.length} lifecycles into workflows/, project layer seeded, root status.json replaced (v1 archived to ${plan.archive.file})`
|
|
2995
|
+
};
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
1948
2998
|
// src/design-md.ts
|
|
1949
|
-
function
|
|
2999
|
+
function violation8(severity, code, message, fix) {
|
|
1950
3000
|
return { ok: false, severity, code, message, fix };
|
|
1951
3001
|
}
|
|
1952
3002
|
var RAW_GROUP = "__raw";
|
|
@@ -2059,7 +3109,7 @@ function validateDesignTokenFrontmatter(frontmatterText) {
|
|
|
2059
3109
|
const violations = [];
|
|
2060
3110
|
const fm = parseDesignFrontmatter(frontmatterText);
|
|
2061
3111
|
if (fm === null) {
|
|
2062
|
-
violations.push(
|
|
3112
|
+
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
3113
|
return { ok: false, violations };
|
|
2064
3114
|
}
|
|
2065
3115
|
const groupEntries = (group) => {
|
|
@@ -2074,61 +3124,61 @@ function validateDesignTokenFrontmatter(frontmatterText) {
|
|
|
2074
3124
|
};
|
|
2075
3125
|
for (const group of ["colors", "typography", "spacing", "rounded"]) {
|
|
2076
3126
|
if (!isMap(fm[group])) {
|
|
2077
|
-
violations.push(
|
|
3127
|
+
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
3128
|
} else if (!groupIsMap(group)) {
|
|
2079
|
-
violations.push(
|
|
3129
|
+
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
3130
|
} else if (groupEntries(group).length === 0) {
|
|
2081
|
-
violations.push(
|
|
3131
|
+
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
3132
|
}
|
|
2083
3133
|
}
|
|
2084
3134
|
if (!isMap(fm.components) || !groupIsMap("components")) {
|
|
2085
|
-
violations.push(
|
|
3135
|
+
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
3136
|
}
|
|
2087
|
-
const placeholder = (group, name, value) => violations.push(
|
|
3137
|
+
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
3138
|
for (const [name, value] of groupEntries("colors")) {
|
|
2089
3139
|
if (typeof value !== "string") {
|
|
2090
|
-
violations.push(
|
|
3140
|
+
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
3141
|
continue;
|
|
2092
3142
|
}
|
|
2093
3143
|
if (isPlaceholder(value)) {
|
|
2094
3144
|
placeholder("colors", name, value);
|
|
2095
3145
|
} else if (!HEX_RE.test(value) && !OKLCH_RE.test(value)) {
|
|
2096
|
-
violations.push(
|
|
3146
|
+
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
3147
|
}
|
|
2098
3148
|
}
|
|
2099
3149
|
for (const [name, value] of groupEntries("typography")) {
|
|
2100
3150
|
if (!isMap(value)) {
|
|
2101
|
-
violations.push(
|
|
3151
|
+
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
3152
|
continue;
|
|
2103
3153
|
}
|
|
2104
3154
|
const keys = Object.keys(value);
|
|
2105
3155
|
const missing = TYPOGRAPHY_PROPS.filter((p) => !keys.includes(p));
|
|
2106
3156
|
const extra = keys.filter((k) => !TYPOGRAPHY_PROPS.includes(k));
|
|
2107
3157
|
if (missing.length > 0 || extra.length > 0) {
|
|
2108
|
-
violations.push(
|
|
3158
|
+
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
3159
|
}
|
|
2110
3160
|
for (const prop of ["fontFamily", "fontSize"]) {
|
|
2111
3161
|
const v = value[prop];
|
|
2112
3162
|
if (typeof v === "string" && isPlaceholder(v))
|
|
2113
3163
|
placeholder("typography", name, v);
|
|
2114
3164
|
else if (typeof v !== "string" || v.trim() === "") {
|
|
2115
|
-
violations.push(
|
|
3165
|
+
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
3166
|
}
|
|
2117
3167
|
}
|
|
2118
3168
|
}
|
|
2119
3169
|
if (groupIsMap("spacing")) {
|
|
2120
3170
|
const spacing = fm.spacing;
|
|
2121
3171
|
if (!Object.prototype.hasOwnProperty.call(spacing, "base")) {
|
|
2122
|
-
violations.push(
|
|
3172
|
+
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
3173
|
}
|
|
2124
3174
|
for (const [name, value] of Object.entries(spacing)) {
|
|
2125
3175
|
if (name !== "base" && name !== RAW_GROUP && !/^\d+$/.test(name)) {
|
|
2126
|
-
violations.push(
|
|
3176
|
+
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
3177
|
}
|
|
2128
3178
|
if (typeof value === "string" && isPlaceholder(value)) {
|
|
2129
3179
|
placeholder("spacing", name, value);
|
|
2130
3180
|
} else if (typeof value !== "string" || !PX_RE.test(value)) {
|
|
2131
|
-
violations.push(
|
|
3181
|
+
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
3182
|
}
|
|
2133
3183
|
}
|
|
2134
3184
|
}
|
|
@@ -2136,12 +3186,12 @@ function validateDesignTokenFrontmatter(frontmatterText) {
|
|
|
2136
3186
|
if (typeof value === "string" && isPlaceholder(value)) {
|
|
2137
3187
|
placeholder("rounded", name, value);
|
|
2138
3188
|
} else if (typeof value !== "string" || !PX_RE.test(value)) {
|
|
2139
|
-
violations.push(
|
|
3189
|
+
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
3190
|
}
|
|
2141
3191
|
}
|
|
2142
3192
|
for (const [name, value] of groupEntries("components")) {
|
|
2143
3193
|
if (!isMap(value)) {
|
|
2144
|
-
violations.push(
|
|
3194
|
+
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
3195
|
continue;
|
|
2146
3196
|
}
|
|
2147
3197
|
for (const [prop, v] of Object.entries(value)) {
|
|
@@ -2157,7 +3207,7 @@ function validateDesignTokenFrontmatter(frontmatterText) {
|
|
|
2157
3207
|
const [, refGroup, refKey] = ref;
|
|
2158
3208
|
const resolves = REF_GROUPS.includes(refGroup) && groupIsMap(refGroup) && Object.prototype.hasOwnProperty.call(fm[refGroup], refKey);
|
|
2159
3209
|
if (!resolves) {
|
|
2160
|
-
violations.push(
|
|
3210
|
+
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
3211
|
}
|
|
2162
3212
|
}
|
|
2163
3213
|
}
|
|
@@ -2169,7 +3219,7 @@ function assertLightDarkParity(lightFm, darkFm) {
|
|
|
2169
3219
|
const light = parseDesignFrontmatter(lightFm);
|
|
2170
3220
|
const dark = parseDesignFrontmatter(darkFm);
|
|
2171
3221
|
if (light === null || dark === null) {
|
|
2172
|
-
violations.push(
|
|
3222
|
+
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
3223
|
return { ok: false, violations };
|
|
2174
3224
|
}
|
|
2175
3225
|
const activeKeys = (fm) => {
|
|
@@ -2187,12 +3237,12 @@ function assertLightDarkParity(lightFm, darkFm) {
|
|
|
2187
3237
|
const darkKeys = activeKeys(dark);
|
|
2188
3238
|
for (const key of lightKeys) {
|
|
2189
3239
|
if (!darkKeys.has(key)) {
|
|
2190
|
-
violations.push(
|
|
3240
|
+
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
3241
|
}
|
|
2192
3242
|
}
|
|
2193
3243
|
for (const key of darkKeys) {
|
|
2194
3244
|
if (!lightKeys.has(key)) {
|
|
2195
|
-
violations.push(
|
|
3245
|
+
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
3246
|
}
|
|
2197
3247
|
}
|
|
2198
3248
|
return { ok: violations.length === 0, violations };
|
|
@@ -2346,9 +3396,9 @@ function completenessLevel(frontmatterText, checklist) {
|
|
|
2346
3396
|
return { level, items, missing, placeholders, upgradeTo, bodyUnverified };
|
|
2347
3397
|
}
|
|
2348
3398
|
// src/audit.ts
|
|
2349
|
-
import { mkdirSync as
|
|
2350
|
-
import { join as
|
|
2351
|
-
function
|
|
3399
|
+
import { mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
|
|
3400
|
+
import { join as join11, resolve as resolve9 } from "node:path";
|
|
3401
|
+
function violation9(severity, code, message, fix) {
|
|
2352
3402
|
return { ok: false, severity, code, message, fix };
|
|
2353
3403
|
}
|
|
2354
3404
|
var AUDIT_PRIORITIES = ["P1", "P2", "P3"];
|
|
@@ -2392,14 +3442,14 @@ function validateAuditStatusBlocks(planText) {
|
|
|
2392
3442
|
const violations = [];
|
|
2393
3443
|
const blocks = parseStatusBlocks(planText);
|
|
2394
3444
|
if (blocks.length === 0) {
|
|
2395
|
-
violations.push(
|
|
3445
|
+
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
3446
|
return { ok: false, violations };
|
|
2397
3447
|
}
|
|
2398
3448
|
blocks.forEach((block, index) => {
|
|
2399
3449
|
const label = blocks.length > 1 ? ` #${index + 1}` : "";
|
|
2400
3450
|
for (const field of AUDIT_STATUS_FIELDS) {
|
|
2401
3451
|
if (!block.fields.has(field)) {
|
|
2402
|
-
violations.push(
|
|
3452
|
+
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
3453
|
}
|
|
2404
3454
|
}
|
|
2405
3455
|
const check = (field, pattern, code, expected) => {
|
|
@@ -2407,7 +3457,7 @@ function validateAuditStatusBlocks(planText) {
|
|
|
2407
3457
|
if (value === undefined)
|
|
2408
3458
|
return;
|
|
2409
3459
|
if (!pattern.test(value)) {
|
|
2410
|
-
violations.push(
|
|
3460
|
+
violations.push(violation9("medium", code, `Status block${label} "${field}" = "${value}" — expected ${expected} (mstar-audit SKILL § Plan files)`, `fix \`- **${field}**:\` to one of: ${expected}`));
|
|
2411
3461
|
}
|
|
2412
3462
|
};
|
|
2413
3463
|
check("Priority", /^P[123]$/, "audit.status.invalid-priority", "P1 | P2 | P3");
|
|
@@ -2521,7 +3571,7 @@ function renderPlanFile(finding, plannedAt) {
|
|
|
2521
3571
|
`;
|
|
2522
3572
|
}
|
|
2523
3573
|
function readPlanFileSummary(filePath) {
|
|
2524
|
-
const text =
|
|
3574
|
+
const text = readFileSync9(filePath, "utf8");
|
|
2525
3575
|
const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
|
|
2526
3576
|
const blocks = parseStatusBlocks(text);
|
|
2527
3577
|
return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
|
|
@@ -2560,8 +3610,8 @@ function renderIndex(params) {
|
|
|
2560
3610
|
function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
2561
3611
|
const date = options.date ?? new Date().toISOString().slice(0, 10);
|
|
2562
3612
|
const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
|
|
2563
|
-
|
|
2564
|
-
const existing =
|
|
3613
|
+
mkdirSync7(outDir, { recursive: true });
|
|
3614
|
+
const existing = readdirSync7(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
|
|
2565
3615
|
let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
|
|
2566
3616
|
const written = [];
|
|
2567
3617
|
const usedSlugs = new Set;
|
|
@@ -2576,13 +3626,13 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
2576
3626
|
}
|
|
2577
3627
|
usedSlugs.add(slug);
|
|
2578
3628
|
const file = `${num}-${slug}.md`;
|
|
2579
|
-
|
|
3629
|
+
writeFileSync5(join11(outDir, file), renderPlanFile(finding, plannedAt));
|
|
2580
3630
|
written.push(file);
|
|
2581
3631
|
next++;
|
|
2582
3632
|
}
|
|
2583
3633
|
const all = [...existing, ...written].sort();
|
|
2584
3634
|
const rows = all.map((file) => {
|
|
2585
|
-
const summary = readPlanFileSummary(
|
|
3635
|
+
const summary = readPlanFileSummary(join11(outDir, file));
|
|
2586
3636
|
const fields = summary.fields;
|
|
2587
3637
|
return {
|
|
2588
3638
|
num: file.slice(0, 3),
|
|
@@ -2614,19 +3664,19 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
2614
3664
|
row.dependsOn = finding.dependsOn ?? "none";
|
|
2615
3665
|
}
|
|
2616
3666
|
});
|
|
2617
|
-
|
|
3667
|
+
writeFileSync5(join11(outDir, "README.md"), renderIndex({
|
|
2618
3668
|
date,
|
|
2619
3669
|
repoName: options.repoName ?? "repo",
|
|
2620
3670
|
repoShortSha: options.repoShortSha ?? "unknown",
|
|
2621
3671
|
rows,
|
|
2622
3672
|
rejected: options.rejected ?? []
|
|
2623
3673
|
}));
|
|
2624
|
-
return { outDir:
|
|
3674
|
+
return { outDir: resolve9(outDir), date, files: written, nextNumber: next };
|
|
2625
3675
|
}
|
|
2626
3676
|
// src/compound.ts
|
|
2627
|
-
import { existsSync as
|
|
2628
|
-
import { basename as basename4, isAbsolute as
|
|
2629
|
-
function
|
|
3677
|
+
import { existsSync as existsSync6, readdirSync as readdirSync8, readFileSync as readFileSync10 } from "node:fs";
|
|
3678
|
+
import { basename as basename4, isAbsolute as isAbsolute7, join as join12, relative as relative4, resolve as resolve10, sep as sep3 } from "node:path";
|
|
3679
|
+
function violation10(severity, code, message, fix) {
|
|
2630
3680
|
return { ok: false, severity, code, message, fix };
|
|
2631
3681
|
}
|
|
2632
3682
|
var KNOWLEDGE_REQUIRED_FIELDS = ["module", "date", "problem_type", "category", "severity"];
|
|
@@ -2710,7 +3760,7 @@ var KNOWLEDGE_CATEGORY_MAP = {
|
|
|
2710
3760
|
developer_experience: "developer-experience",
|
|
2711
3761
|
documentation_gap: "documentation"
|
|
2712
3762
|
};
|
|
2713
|
-
var
|
|
3763
|
+
var DATE_RE5 = /^\d{4}-\d{2}-\d{2}$/;
|
|
2714
3764
|
var isMap2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2715
3765
|
function parseScalar2(raw) {
|
|
2716
3766
|
const trimmed = raw.trim();
|
|
@@ -2803,36 +3853,36 @@ function validateSchemaYaml(frontmatterText) {
|
|
|
2803
3853
|
const violations = [];
|
|
2804
3854
|
const doc = parseYamlLite(frontmatterText);
|
|
2805
3855
|
if (doc === null) {
|
|
2806
|
-
violations.push(
|
|
3856
|
+
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
3857
|
return { ok: false, violations };
|
|
2808
3858
|
}
|
|
2809
3859
|
const isStr = (v) => typeof v === "string";
|
|
2810
|
-
const missing = (field) => violations.push(
|
|
3860
|
+
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
3861
|
for (const field of KNOWLEDGE_REQUIRED_FIELDS) {
|
|
2812
3862
|
if (!(field in doc) || doc[field] === "")
|
|
2813
3863
|
missing(field);
|
|
2814
3864
|
}
|
|
2815
|
-
if (doc.date !== undefined && (!isStr(doc.date) || !
|
|
2816
|
-
violations.push(
|
|
3865
|
+
if (doc.date !== undefined && (!isStr(doc.date) || !DATE_RE5.test(doc.date))) {
|
|
3866
|
+
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
3867
|
}
|
|
2818
3868
|
const problemType = doc.problem_type;
|
|
2819
3869
|
if (problemType !== undefined && !isStr(problemType)) {
|
|
2820
|
-
violations.push(
|
|
3870
|
+
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
3871
|
}
|
|
2822
3872
|
const problemTypeValid = isStr(problemType) && KNOWLEDGE_PROBLEM_TYPES.includes(problemType);
|
|
2823
3873
|
if (isStr(problemType) && !problemTypeValid) {
|
|
2824
|
-
violations.push(
|
|
3874
|
+
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
3875
|
}
|
|
2826
3876
|
if (doc.severity !== undefined && !isStr(doc.severity)) {
|
|
2827
|
-
violations.push(
|
|
3877
|
+
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
3878
|
}
|
|
2829
3879
|
if (isStr(doc.severity) && !KNOWLEDGE_SEVERITIES.includes(doc.severity)) {
|
|
2830
|
-
violations.push(
|
|
3880
|
+
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
3881
|
}
|
|
2832
3882
|
if (problemTypeValid && isStr(doc.category)) {
|
|
2833
3883
|
const expected = KNOWLEDGE_CATEGORY_MAP[problemType];
|
|
2834
3884
|
if (doc.category !== expected) {
|
|
2835
|
-
violations.push(
|
|
3885
|
+
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
3886
|
}
|
|
2837
3887
|
}
|
|
2838
3888
|
if (problemTypeValid) {
|
|
@@ -2840,37 +3890,37 @@ function validateSchemaYaml(frontmatterText) {
|
|
|
2840
3890
|
if (isBug) {
|
|
2841
3891
|
for (const field of ["symptoms", "root_cause", "resolution_type"]) {
|
|
2842
3892
|
if (!(field in doc)) {
|
|
2843
|
-
violations.push(
|
|
3893
|
+
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
3894
|
}
|
|
2845
3895
|
}
|
|
2846
3896
|
if (doc.symptoms !== undefined && !Array.isArray(doc.symptoms)) {
|
|
2847
|
-
violations.push(
|
|
3897
|
+
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
3898
|
}
|
|
2849
3899
|
if (doc.root_cause !== undefined && !isStr(doc.root_cause)) {
|
|
2850
|
-
violations.push(
|
|
3900
|
+
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
3901
|
}
|
|
2852
3902
|
if (isStr(doc.resolution_type) && !KNOWLEDGE_RESOLUTION_TYPES.includes(doc.resolution_type)) {
|
|
2853
|
-
violations.push(
|
|
3903
|
+
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
3904
|
}
|
|
2855
3905
|
} else if (doc.applies_when !== undefined && !Array.isArray(doc.applies_when)) {
|
|
2856
|
-
violations.push(
|
|
3906
|
+
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
3907
|
}
|
|
2858
3908
|
}
|
|
2859
3909
|
if (doc.plan_id !== undefined && !isStr(doc.plan_id)) {
|
|
2860
|
-
violations.push(
|
|
3910
|
+
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
3911
|
}
|
|
2862
3912
|
if (doc.tags !== undefined) {
|
|
2863
3913
|
if (!Array.isArray(doc.tags)) {
|
|
2864
|
-
violations.push(
|
|
3914
|
+
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
3915
|
} else if (doc.tags.length > 8) {
|
|
2866
|
-
violations.push(
|
|
3916
|
+
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
3917
|
}
|
|
2868
3918
|
}
|
|
2869
|
-
if (doc.last_updated !== undefined && (!isStr(doc.last_updated) || !
|
|
2870
|
-
violations.push(
|
|
3919
|
+
if (doc.last_updated !== undefined && (!isStr(doc.last_updated) || !DATE_RE5.test(doc.last_updated))) {
|
|
3920
|
+
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
3921
|
}
|
|
2872
3922
|
if (doc.related_components !== undefined && !Array.isArray(doc.related_components)) {
|
|
2873
|
-
violations.push(
|
|
3923
|
+
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
3924
|
}
|
|
2875
3925
|
return { ok: violations.length === 0, violations };
|
|
2876
3926
|
}
|
|
@@ -2892,7 +3942,7 @@ function referenceExists(repoRoot, docText) {
|
|
|
2892
3942
|
if (ref === "" || seen.has(ref))
|
|
2893
3943
|
continue;
|
|
2894
3944
|
seen.add(ref);
|
|
2895
|
-
if (SCHEME_RE.test(ref) || ref.startsWith("{") || ref.startsWith("#") || ref.includes("*") || ref.includes("?") || ref.startsWith("~") ||
|
|
3945
|
+
if (SCHEME_RE.test(ref) || ref.startsWith("{") || ref.startsWith("#") || ref.includes("*") || ref.includes("?") || ref.startsWith("~") || isAbsolute7(ref)) {
|
|
2896
3946
|
continue;
|
|
2897
3947
|
}
|
|
2898
3948
|
if (ref.includes("/") || REF_EXT_RE.test(ref)) {
|
|
@@ -2911,7 +3961,7 @@ function referenceExists(repoRoot, docText) {
|
|
|
2911
3961
|
const dir = stack.pop();
|
|
2912
3962
|
let entries;
|
|
2913
3963
|
try {
|
|
2914
|
-
entries =
|
|
3964
|
+
entries = readdirSync8(dir, { withFileTypes: true });
|
|
2915
3965
|
} catch {
|
|
2916
3966
|
continue;
|
|
2917
3967
|
}
|
|
@@ -2920,7 +3970,7 @@ function referenceExists(repoRoot, docText) {
|
|
|
2920
3970
|
break;
|
|
2921
3971
|
if (entry.isDirectory()) {
|
|
2922
3972
|
if (!WALK_SKIP_DIRS.has(entry.name))
|
|
2923
|
-
stack.push(
|
|
3973
|
+
stack.push(join12(dir, entry.name));
|
|
2924
3974
|
} else if (!entry.isSymbolicLink()) {
|
|
2925
3975
|
const base = entry.name.replace(/\.(?:ts|tsx|js|jsx|mjs|cjs)$/, "");
|
|
2926
3976
|
if (moduleNames.has(base))
|
|
@@ -2932,15 +3982,15 @@ function referenceExists(repoRoot, docText) {
|
|
|
2932
3982
|
for (const { ref, isSymbol, module } of refs) {
|
|
2933
3983
|
if (!isSymbol || module === undefined) {
|
|
2934
3984
|
const candidate = ref.replace(LINE_SUFFIX_RE, "").replace(ANCHOR_RE, "");
|
|
2935
|
-
if (
|
|
3985
|
+
if (existsSync6(resolve10(repoRoot, candidate))) {
|
|
2936
3986
|
checked++;
|
|
2937
3987
|
} else {
|
|
2938
|
-
violations.push(
|
|
3988
|
+
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
3989
|
}
|
|
2940
3990
|
} else if (foundModules.has(module)) {
|
|
2941
3991
|
checked++;
|
|
2942
3992
|
} else {
|
|
2943
|
-
violations.push(
|
|
3993
|
+
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
3994
|
}
|
|
2945
3995
|
}
|
|
2946
3996
|
return { ok: violations.length === 0, violations, checked };
|
|
@@ -2952,18 +4002,18 @@ function collectKnowledgeDocs(dir) {
|
|
|
2952
4002
|
const current = stack.pop();
|
|
2953
4003
|
let entries;
|
|
2954
4004
|
try {
|
|
2955
|
-
entries =
|
|
4005
|
+
entries = readdirSync8(current, { withFileTypes: true });
|
|
2956
4006
|
} catch {
|
|
2957
4007
|
continue;
|
|
2958
4008
|
}
|
|
2959
4009
|
for (const entry of entries) {
|
|
2960
4010
|
if (entry.isSymbolicLink())
|
|
2961
4011
|
continue;
|
|
2962
|
-
const full =
|
|
4012
|
+
const full = join12(current, entry.name);
|
|
2963
4013
|
if (entry.isDirectory()) {
|
|
2964
4014
|
stack.push(full);
|
|
2965
4015
|
} else if (entry.name.endsWith(".md") && entry.name !== "README.md" && entry.name !== "index.md") {
|
|
2966
|
-
docs.push(
|
|
4016
|
+
docs.push(relative4(dir, full).split(sep3).join("/"));
|
|
2967
4017
|
}
|
|
2968
4018
|
}
|
|
2969
4019
|
}
|
|
@@ -2979,14 +4029,14 @@ function normalizeIndexRef(cell) {
|
|
|
2979
4029
|
}
|
|
2980
4030
|
function assertIndexRows(knowledgeDir) {
|
|
2981
4031
|
const violations = [];
|
|
2982
|
-
const readmePath =
|
|
2983
|
-
if (!
|
|
2984
|
-
violations.push(
|
|
4032
|
+
const readmePath = join12(knowledgeDir, "README.md");
|
|
4033
|
+
if (!existsSync6(readmePath)) {
|
|
4034
|
+
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
4035
|
return { ok: false, violations };
|
|
2986
4036
|
}
|
|
2987
4037
|
const docs = collectKnowledgeDocs(knowledgeDir);
|
|
2988
4038
|
const rows = new Set;
|
|
2989
|
-
for (const line of
|
|
4039
|
+
for (const line of readFileSync10(readmePath, "utf8").split(/\r?\n/)) {
|
|
2990
4040
|
if (!line.trim().startsWith("|"))
|
|
2991
4041
|
continue;
|
|
2992
4042
|
const cells = line.split("|").map((c) => c.trim());
|
|
@@ -2998,42 +4048,42 @@ function assertIndexRows(knowledgeDir) {
|
|
|
2998
4048
|
}
|
|
2999
4049
|
for (const doc of docs) {
|
|
3000
4050
|
if (!rows.has(doc)) {
|
|
3001
|
-
violations.push(
|
|
4051
|
+
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
4052
|
}
|
|
3003
4053
|
}
|
|
3004
4054
|
return { ok: violations.length === 0, violations };
|
|
3005
4055
|
}
|
|
3006
4056
|
function compoundRefreshScope(harnessDir, projectRoot) {
|
|
3007
4057
|
return [
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
4058
|
+
join12(harnessDir, "knowledge"),
|
|
4059
|
+
join12(harnessDir, "knowledge", "README.md"),
|
|
4060
|
+
join12(projectRoot, "CONCEPTS.md"),
|
|
4061
|
+
join12(harnessDir, "status.json")
|
|
3012
4062
|
];
|
|
3013
4063
|
}
|
|
3014
4064
|
function isFileLikeRoot(root) {
|
|
3015
4065
|
return /^[^.]*\.[A-Za-z0-9]{1,10}$/.test(basename4(root));
|
|
3016
4066
|
}
|
|
3017
4067
|
function scopeGuard(path, allowedRoots) {
|
|
3018
|
-
const resolved =
|
|
4068
|
+
const resolved = resolve10(path);
|
|
3019
4069
|
for (const root of allowedRoots) {
|
|
3020
|
-
const r =
|
|
4070
|
+
const r = resolve10(root);
|
|
3021
4071
|
if (isFileLikeRoot(r)) {
|
|
3022
4072
|
if (resolved === r)
|
|
3023
4073
|
return { ok: true, violations: [] };
|
|
3024
|
-
} else if (resolved === r || resolved.startsWith(r +
|
|
4074
|
+
} else if (resolved === r || resolved.startsWith(r + sep3)) {
|
|
3025
4075
|
return { ok: true, violations: [] };
|
|
3026
4076
|
}
|
|
3027
4077
|
}
|
|
3028
4078
|
return {
|
|
3029
4079
|
ok: false,
|
|
3030
4080
|
violations: [
|
|
3031
|
-
|
|
4081
|
+
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
4082
|
]
|
|
3033
4083
|
};
|
|
3034
4084
|
}
|
|
3035
4085
|
// src/lint.ts
|
|
3036
|
-
function
|
|
4086
|
+
function violation11(severity, code, message, fix) {
|
|
3037
4087
|
return { ok: false, severity, code, message, fix };
|
|
3038
4088
|
}
|
|
3039
4089
|
var COMMENT_INTRODUCER = "(?:\\/\\/|\\/\\*|#|;|--|\\s\\*)";
|
|
@@ -3076,7 +4126,7 @@ function findTemporaryMarkers(fileText) {
|
|
|
3076
4126
|
}
|
|
3077
4127
|
markers.push({ line: i + 1, text, removalPath });
|
|
3078
4128
|
if (removalPath === null) {
|
|
3079
|
-
violations.push(
|
|
4129
|
+
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
4130
|
}
|
|
3081
4131
|
}
|
|
3082
4132
|
return { ok: violations.length === 0, violations, markers };
|
|
@@ -3123,13 +4173,13 @@ function assertSddTddTriple(reportText) {
|
|
|
3123
4173
|
break;
|
|
3124
4174
|
}
|
|
3125
4175
|
if (!hasTests) {
|
|
3126
|
-
violations.push(
|
|
4176
|
+
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
4177
|
}
|
|
3128
4178
|
if (!hasCommand) {
|
|
3129
|
-
violations.push(
|
|
4179
|
+
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
4180
|
}
|
|
3131
4181
|
if (!hasOutput) {
|
|
3132
|
-
violations.push(
|
|
4182
|
+
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
4183
|
}
|
|
3134
4184
|
return { ok: violations.length === 0, violations };
|
|
3135
4185
|
}
|
|
@@ -3170,7 +4220,7 @@ function planQualityBar(planText) {
|
|
|
3170
4220
|
if (token !== null) {
|
|
3171
4221
|
const text = trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
|
|
3172
4222
|
findings.push({ token, line: i + 1, text });
|
|
3173
|
-
violations.push(
|
|
4223
|
+
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
4224
|
}
|
|
3175
4225
|
}
|
|
3176
4226
|
return { ok: violations.length === 0, violations, findings };
|
|
@@ -3182,18 +4232,18 @@ function lintSkillFrontmatter(frontmatterText) {
|
|
|
3182
4232
|
const violations = [];
|
|
3183
4233
|
const fm = parseFrontmatter(frontmatterText);
|
|
3184
4234
|
if (fm === null) {
|
|
3185
|
-
violations.push(
|
|
4235
|
+
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
4236
|
return { ok: false, violations };
|
|
3187
4237
|
}
|
|
3188
4238
|
const name = fm.name ?? "";
|
|
3189
4239
|
if (name === "") {
|
|
3190
|
-
violations.push(
|
|
4240
|
+
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
4241
|
} else if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
|
3192
|
-
violations.push(
|
|
4242
|
+
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
4243
|
}
|
|
3194
4244
|
const description = fm.description ?? "";
|
|
3195
4245
|
if (description === "") {
|
|
3196
|
-
violations.push(
|
|
4246
|
+
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
4247
|
} else {
|
|
3198
4248
|
const stripped = description.replace(/`[^`]*`/g, " ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
|
|
3199
4249
|
let pronoun = null;
|
|
@@ -3204,15 +4254,15 @@ function lintSkillFrontmatter(frontmatterText) {
|
|
|
3204
4254
|
break;
|
|
3205
4255
|
}
|
|
3206
4256
|
if (pronoun !== null) {
|
|
3207
|
-
violations.push(
|
|
4257
|
+
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
4258
|
}
|
|
3209
4259
|
const start = description.trim().replace(/^[*_#>]+/, "").replace(/^["'`]+/, "").trim();
|
|
3210
4260
|
if (WORKFLOW_VERB_START_RE.test(start)) {
|
|
3211
|
-
violations.push(
|
|
4261
|
+
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
4262
|
} else {
|
|
3213
4263
|
const words = description.trim().split(/\s+/).filter(Boolean).length;
|
|
3214
4264
|
if (words > DESCRIPTION_MAX_WORDS) {
|
|
3215
|
-
violations.push(
|
|
4265
|
+
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
4266
|
}
|
|
3217
4267
|
}
|
|
3218
4268
|
}
|
|
@@ -3259,15 +4309,15 @@ function lintStrategySections(docText) {
|
|
|
3259
4309
|
}
|
|
3260
4310
|
for (const required of REQUIRED_STRATEGY_SECTIONS) {
|
|
3261
4311
|
if (!headings.has(required.toLowerCase())) {
|
|
3262
|
-
violations.push(
|
|
4312
|
+
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
4313
|
}
|
|
3264
4314
|
}
|
|
3265
4315
|
return { ok: violations.length === 0, violations };
|
|
3266
4316
|
}
|
|
3267
4317
|
// src/roles.ts
|
|
3268
|
-
import { existsSync as
|
|
3269
|
-
import { join as
|
|
3270
|
-
function
|
|
4318
|
+
import { existsSync as existsSync7 } from "node:fs";
|
|
4319
|
+
import { join as join13 } from "node:path";
|
|
4320
|
+
function violation12(severity, code, message, fix) {
|
|
3271
4321
|
return { ok: false, severity, code, message, fix };
|
|
3272
4322
|
}
|
|
3273
4323
|
var ROLE_MAPPING = [
|
|
@@ -3322,19 +4372,19 @@ function validateRoleMapping(rolesDir, options = {}) {
|
|
|
3322
4372
|
const violations = [];
|
|
3323
4373
|
const referenceById = new Map(mapping.map((m) => [m.agentId, m.reference]));
|
|
3324
4374
|
for (const { agentId, reference } of mapping) {
|
|
3325
|
-
if (!
|
|
3326
|
-
violations.push(
|
|
4375
|
+
if (!existsSync7(join13(rolesDir, reference))) {
|
|
4376
|
+
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
4377
|
}
|
|
3328
4378
|
}
|
|
3329
4379
|
for (const { family, memberIds } of families) {
|
|
3330
4380
|
const absent = memberIds.filter((id) => !referenceById.has(id));
|
|
3331
4381
|
for (const id of absent) {
|
|
3332
|
-
violations.push(
|
|
4382
|
+
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
4383
|
}
|
|
3334
4384
|
if (absent.length === 0) {
|
|
3335
4385
|
const refs = new Set(memberIds.map((id) => referenceById.get(id)));
|
|
3336
4386
|
if (refs.size !== 1) {
|
|
3337
|
-
violations.push(
|
|
4387
|
+
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
4388
|
}
|
|
3339
4389
|
}
|
|
3340
4390
|
}
|
|
@@ -3343,12 +4393,12 @@ function validateRoleMapping(rolesDir, options = {}) {
|
|
|
3343
4393
|
for (const row of rows) {
|
|
3344
4394
|
const existing = tableByRole.get(row.roleId);
|
|
3345
4395
|
if (existing !== undefined) {
|
|
3346
|
-
violations.push(
|
|
4396
|
+
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
4397
|
} else {
|
|
3348
4398
|
tableByRole.set(row.roleId, table);
|
|
3349
4399
|
}
|
|
3350
4400
|
if (!referenceById.has(row.roleId)) {
|
|
3351
|
-
violations.push(
|
|
4401
|
+
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
4402
|
}
|
|
3353
4403
|
}
|
|
3354
4404
|
};
|
|
@@ -3356,20 +4406,20 @@ function validateRoleMapping(rolesDir, options = {}) {
|
|
|
3356
4406
|
checkParamRoles(qcReviewers, "QC reviewer");
|
|
3357
4407
|
for (const row of devTrack) {
|
|
3358
4408
|
if (row.track !== "primary" && row.track !== "parallel_secondary") {
|
|
3359
|
-
violations.push(
|
|
4409
|
+
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
4410
|
}
|
|
3361
4411
|
}
|
|
3362
4412
|
const indices = qcReviewers.map((r) => r.reviewerIndex).sort((a, b) => a - b);
|
|
3363
4413
|
const unique = new Set(indices);
|
|
3364
4414
|
if (indices.length !== 3 || unique.size !== 3 || indices[0] !== 1 || indices[1] !== 2 || indices[2] !== 3) {
|
|
3365
|
-
violations.push(
|
|
4415
|
+
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
4416
|
}
|
|
3367
4417
|
for (const row of qcReviewers) {
|
|
3368
4418
|
if (row.focus.trim() === "") {
|
|
3369
|
-
violations.push(
|
|
4419
|
+
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
4420
|
}
|
|
3371
4421
|
if (row.reportSuffix !== `qc${row.reviewerIndex}`) {
|
|
3372
|
-
violations.push(
|
|
4422
|
+
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
4423
|
}
|
|
3374
4424
|
}
|
|
3375
4425
|
return { ok: violations.length === 0, violations };
|
|
@@ -3408,11 +4458,11 @@ function lintLoadOrder(skillTexts) {
|
|
|
3408
4458
|
continue;
|
|
3409
4459
|
const section = extractLoadOrderSection(text);
|
|
3410
4460
|
if (section === null) {
|
|
3411
|
-
violations.push(
|
|
4461
|
+
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
4462
|
continue;
|
|
3413
4463
|
}
|
|
3414
4464
|
if (!section.includes("mstar-harness-core")) {
|
|
3415
|
-
violations.push(
|
|
4465
|
+
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
4466
|
}
|
|
3417
4467
|
}
|
|
3418
4468
|
return { ok: violations.length === 0, violations };
|
|
@@ -3458,7 +4508,7 @@ function resolveSkillRoot(host, paths) {
|
|
|
3458
4508
|
}
|
|
3459
4509
|
}
|
|
3460
4510
|
// src/skill-authoring.ts
|
|
3461
|
-
function
|
|
4511
|
+
function violation13(severity, code, message, fix) {
|
|
3462
4512
|
return { ok: false, severity, code, message, fix };
|
|
3463
4513
|
}
|
|
3464
4514
|
var FIVE_QUESTION_SECTIONS = [
|
|
@@ -3519,7 +4569,7 @@ function lintFiveQuestion(bodyText, mode = "authoring") {
|
|
|
3519
4569
|
const aliases = mode === "runtime" ? RUNTIME_HEADING_ALIASES[section.key] ?? [] : [];
|
|
3520
4570
|
const covered = headings.some((heading) => heading.includes(label) || aliases.some((alias) => heading.includes(alias)));
|
|
3521
4571
|
if (!covered) {
|
|
3522
|
-
violations.push(
|
|
4572
|
+
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
4573
|
}
|
|
3524
4574
|
}
|
|
3525
4575
|
return { ok: violations.length === 0, violations };
|
|
@@ -3528,13 +4578,19 @@ function resolveAssetPath(skillName, relPath, host) {
|
|
|
3528
4578
|
return `skill \`${skillName}\` → ${relPath} (${resolveSkillRoot(host, { skill: skillName, rel: relPath })})`;
|
|
3529
4579
|
}
|
|
3530
4580
|
export {
|
|
4581
|
+
writeWorkflowSnapshot,
|
|
3531
4582
|
writeJson,
|
|
3532
4583
|
withStatusWriteLock,
|
|
3533
4584
|
verifyPlanExecutionLease,
|
|
4585
|
+
validateWorkflowSnapshot,
|
|
4586
|
+
validateWorkflowEntry,
|
|
4587
|
+
validateStatusV2,
|
|
3534
4588
|
validateStatus,
|
|
3535
4589
|
validateSchemaYaml,
|
|
3536
4590
|
validateRoleMapping,
|
|
4591
|
+
validateRoadmap,
|
|
3537
4592
|
validateResidual,
|
|
4593
|
+
validateProjectRegister,
|
|
3538
4594
|
validatePlanRow,
|
|
3539
4595
|
validateIntegrationMergeLease,
|
|
3540
4596
|
validateGitignore,
|
|
@@ -3543,6 +4599,7 @@ export {
|
|
|
3543
4599
|
validateCompassFrontmatter,
|
|
3544
4600
|
validateAuditStatusBlocks,
|
|
3545
4601
|
validateAssignmentFields,
|
|
4602
|
+
unregisterWorkflow,
|
|
3546
4603
|
techDebtRollup,
|
|
3547
4604
|
taskReportExists,
|
|
3548
4605
|
taskBrief,
|
|
@@ -3554,16 +4611,22 @@ export {
|
|
|
3554
4611
|
scaffoldAuditPlan,
|
|
3555
4612
|
sameHolderResume,
|
|
3556
4613
|
reviewPackage,
|
|
4614
|
+
resolveWorkflowDir,
|
|
3557
4615
|
resolveSpecsDir,
|
|
3558
4616
|
resolveSkillRoot,
|
|
3559
4617
|
resolveSddDir,
|
|
4618
|
+
resolveRepoEnforcement,
|
|
3560
4619
|
resolveProjectRoot,
|
|
4620
|
+
resolveProjectDir,
|
|
3561
4621
|
resolvePlanDir,
|
|
4622
|
+
resolveMstarcEnforcement,
|
|
4623
|
+
resolveKnowledgeDir,
|
|
3562
4624
|
resolveIterationDir,
|
|
3563
4625
|
resolveHarnessDir,
|
|
3564
4626
|
resolveCompassEnforcement,
|
|
3565
4627
|
resolveAssetPath,
|
|
3566
4628
|
releaseLease,
|
|
4629
|
+
registerWorkflow,
|
|
3567
4630
|
referenceExists,
|
|
3568
4631
|
redactSecrets,
|
|
3569
4632
|
readProgressLedger,
|
|
@@ -3572,13 +4635,17 @@ export {
|
|
|
3572
4635
|
pushCadenceProbe,
|
|
3573
4636
|
planQualityBar,
|
|
3574
4637
|
planExecutionLeaseLocations,
|
|
4638
|
+
parseMstarc,
|
|
3575
4639
|
parseEnforcementFlag,
|
|
3576
4640
|
parseDesignFrontmatter,
|
|
4641
|
+
parseCompassFrontmatterText,
|
|
3577
4642
|
parseCompassFrontmatter,
|
|
3578
4643
|
parseBranchPolicyDirectOnBranch,
|
|
3579
4644
|
parseAssignmentFields,
|
|
3580
4645
|
parseAssignmentBranchForms,
|
|
3581
4646
|
normalizeSeverity,
|
|
4647
|
+
migrateHarnessTree,
|
|
4648
|
+
listProjectReferenceFiles,
|
|
3582
4649
|
lintStrategySections,
|
|
3583
4650
|
lintSkillFrontmatter,
|
|
3584
4651
|
lintLoadOrder,
|
|
@@ -3591,6 +4658,7 @@ export {
|
|
|
3591
4658
|
findingsCleanupGate,
|
|
3592
4659
|
findTemporaryMarkers,
|
|
3593
4660
|
findSimplifyMarkers,
|
|
4661
|
+
findMstarc,
|
|
3594
4662
|
findEphemeralCitations,
|
|
3595
4663
|
executionModeToN,
|
|
3596
4664
|
evaluatePhaseGate,
|
|
@@ -3613,15 +4681,31 @@ export {
|
|
|
3613
4681
|
assertControlVsFeaturePath,
|
|
3614
4682
|
assertBranchAlignment,
|
|
3615
4683
|
assertBaseSha,
|
|
3616
|
-
|
|
4684
|
+
applyMigratePlan,
|
|
3617
4685
|
applyEnforcement,
|
|
3618
4686
|
antiRecursionPrecheck,
|
|
4687
|
+
_DEFAULT_PROJECT,
|
|
4688
|
+
WORKFLOW_TERMINAL_STATUSES,
|
|
4689
|
+
WORKFLOW_SNAPSHOT_FILE,
|
|
4690
|
+
WORKFLOW_LIFECYCLE_TYPES,
|
|
4691
|
+
WORKFLOW_LIFECYCLE_STATUSES,
|
|
3619
4692
|
SddScriptError,
|
|
3620
4693
|
SHARED_FAMILIES,
|
|
3621
4694
|
SEVERITY_ORDER,
|
|
3622
4695
|
RUNTIME_HEADING_ALIASES,
|
|
3623
4696
|
ROLE_MAPPING,
|
|
4697
|
+
ROADMAP_STATUSES,
|
|
3624
4698
|
QC_REVIEWER_PARAMS,
|
|
4699
|
+
PROJECT_ROADMAP_FILE,
|
|
4700
|
+
PROJECT_REGISTER_FILE,
|
|
4701
|
+
PROJECT_REFERENCES_DIR,
|
|
4702
|
+
NOTES_LEDGER_FILE,
|
|
4703
|
+
MSTARC_WORKFLOW_DIR_KEY,
|
|
4704
|
+
MSTARC_SECTION,
|
|
4705
|
+
MSTARC_PROJECT_DIR_KEY,
|
|
4706
|
+
MSTARC_HARNESS_DIR_KEY,
|
|
4707
|
+
MSTARC_FILE,
|
|
4708
|
+
MIGRATE_STATUS_FILE,
|
|
3625
4709
|
KNOWLEDGE_SEVERITIES,
|
|
3626
4710
|
KNOWLEDGE_RESOLUTION_TYPES,
|
|
3627
4711
|
KNOWLEDGE_REQUIRED_FIELDS,
|
|
@@ -3634,5 +4718,6 @@ export {
|
|
|
3634
4718
|
AUDIT_RISKS,
|
|
3635
4719
|
AUDIT_PRIORITIES,
|
|
3636
4720
|
AUDIT_EFFORTS,
|
|
3637
|
-
AUDIT_CATEGORIES
|
|
4721
|
+
AUDIT_CATEGORIES,
|
|
4722
|
+
ARCHIVED_STATUS_V1_FILE
|
|
3638
4723
|
};
|