cleargate 0.1.0-alpha.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/dist/MANIFEST.json +160 -0
- package/dist/cli.cjs +4383 -16
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +4367 -4
- package/dist/cli.js.map +1 -1
- package/dist/templates/cleargate-planning/.claude/agents/architect.md +57 -0
- package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-ingest.md +145 -0
- package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-lint.md +256 -0
- package/dist/templates/cleargate-planning/.claude/agents/cleargate-wiki-query.md +143 -0
- package/dist/templates/cleargate-planning/.claude/agents/developer.md +58 -0
- package/dist/templates/cleargate-planning/.claude/agents/qa.md +57 -0
- package/dist/templates/cleargate-planning/.claude/agents/reporter.md +129 -0
- package/dist/templates/cleargate-planning/.claude/hooks/session-start.sh +4 -0
- package/dist/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +18 -0
- package/dist/templates/cleargate-planning/.claude/hooks/token-ledger.sh +174 -0
- package/dist/templates/cleargate-planning/.claude/settings.json +35 -0
- package/dist/templates/cleargate-planning/.claude/skills/flashcard/SKILL.md +92 -0
- package/dist/templates/cleargate-planning/.cleargate/FLASHCARD.md +7 -0
- package/dist/templates/cleargate-planning/.cleargate/delivery/archive/.gitkeep +0 -0
- package/dist/templates/cleargate-planning/.cleargate/delivery/pending-sync/.gitkeep +0 -0
- package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +508 -0
- package/dist/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +133 -0
- package/dist/templates/cleargate-planning/.cleargate/templates/Bug.md +82 -0
- package/dist/templates/cleargate-planning/.cleargate/templates/CR.md +77 -0
- package/dist/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +63 -0
- package/dist/templates/cleargate-planning/.cleargate/templates/epic.md +120 -0
- package/dist/templates/cleargate-planning/.cleargate/templates/initiative.md +53 -0
- package/dist/templates/cleargate-planning/.cleargate/templates/proposal.md +52 -0
- package/dist/templates/cleargate-planning/.cleargate/templates/story.md +135 -0
- package/dist/templates/cleargate-planning/CLAUDE.md +42 -0
- package/dist/templates/cleargate-planning/MANIFEST.json +160 -0
- package/dist/templates/synthesis/active-sprint.md +30 -0
- package/dist/templates/synthesis/open-gates.md +38 -0
- package/dist/templates/synthesis/product-state.md +32 -0
- package/dist/templates/synthesis/roadmap.md +63 -0
- package/package.json +9 -2
- package/templates/cleargate-planning/.claude/agents/architect.md +57 -0
- package/templates/cleargate-planning/.claude/agents/cleargate-wiki-ingest.md +145 -0
- package/templates/cleargate-planning/.claude/agents/cleargate-wiki-lint.md +256 -0
- package/templates/cleargate-planning/.claude/agents/cleargate-wiki-query.md +143 -0
- package/templates/cleargate-planning/.claude/agents/developer.md +58 -0
- package/templates/cleargate-planning/.claude/agents/qa.md +57 -0
- package/templates/cleargate-planning/.claude/agents/reporter.md +129 -0
- package/templates/cleargate-planning/.claude/hooks/session-start.sh +4 -0
- package/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +18 -0
- package/templates/cleargate-planning/.claude/hooks/token-ledger.sh +174 -0
- package/templates/cleargate-planning/.claude/settings.json +35 -0
- package/templates/cleargate-planning/.claude/skills/flashcard/SKILL.md +92 -0
- package/templates/cleargate-planning/.cleargate/FLASHCARD.md +7 -0
- package/templates/cleargate-planning/.cleargate/delivery/archive/.gitkeep +0 -0
- package/templates/cleargate-planning/.cleargate/delivery/pending-sync/.gitkeep +0 -0
- package/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +508 -0
- package/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +133 -0
- package/templates/cleargate-planning/.cleargate/templates/Bug.md +82 -0
- package/templates/cleargate-planning/.cleargate/templates/CR.md +77 -0
- package/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +63 -0
- package/templates/cleargate-planning/.cleargate/templates/epic.md +120 -0
- package/templates/cleargate-planning/.cleargate/templates/initiative.md +53 -0
- package/templates/cleargate-planning/.cleargate/templates/proposal.md +52 -0
- package/templates/cleargate-planning/.cleargate/templates/story.md +135 -0
- package/templates/cleargate-planning/CLAUDE.md +42 -0
- package/templates/cleargate-planning/MANIFEST.json +160 -0
- package/templates/synthesis/active-sprint.md +30 -0
- package/templates/synthesis/open-gates.md +38 -0
- package/templates/synthesis/product-state.md +32 -0
- package/templates/synthesis/roadmap.md +63 -0
package/dist/cli.js
CHANGED
|
@@ -9,10 +9,10 @@ import { Command } from "commander";
|
|
|
9
9
|
// package.json
|
|
10
10
|
var package_default = {
|
|
11
11
|
name: "cleargate",
|
|
12
|
-
version: "0.
|
|
12
|
+
version: "0.2.1",
|
|
13
13
|
private: false,
|
|
14
14
|
type: "module",
|
|
15
|
-
description: "
|
|
15
|
+
description: "Planning framework for Claude Code agents \u2014 sprint/epic/story protocol, four-agent loop (architect/developer/qa/reporter), Karpathy-style awareness wiki.",
|
|
16
16
|
bin: {
|
|
17
17
|
cleargate: "dist/cli.js"
|
|
18
18
|
},
|
|
@@ -32,12 +32,14 @@ var package_default = {
|
|
|
32
32
|
},
|
|
33
33
|
files: [
|
|
34
34
|
"dist",
|
|
35
|
+
"templates",
|
|
35
36
|
"README.md"
|
|
36
37
|
],
|
|
37
38
|
engines: {
|
|
38
39
|
node: ">=24.0.0"
|
|
39
40
|
},
|
|
40
41
|
scripts: {
|
|
42
|
+
prebuild: "tsx scripts/build-manifest.ts && node scripts/copy-planning-payload.mjs",
|
|
41
43
|
build: "tsup",
|
|
42
44
|
dev: "tsup --watch",
|
|
43
45
|
typecheck: "tsc --noEmit",
|
|
@@ -48,11 +50,16 @@ var package_default = {
|
|
|
48
50
|
dependencies: {
|
|
49
51
|
"@napi-rs/keyring": "^1.2.0",
|
|
50
52
|
commander: "^12",
|
|
53
|
+
diff: "^5.2.2",
|
|
54
|
+
"js-yaml": "^4.1.0",
|
|
51
55
|
zod: "^4.3.0"
|
|
52
56
|
},
|
|
53
57
|
devDependencies: {
|
|
58
|
+
"@types/diff": "^5.2.3",
|
|
59
|
+
"@types/js-yaml": "^4.0.9",
|
|
54
60
|
"@types/node": "^24.0.0",
|
|
55
61
|
tsup: "^8",
|
|
62
|
+
tsx: "^4.21.0",
|
|
56
63
|
typescript: "^5.8.0",
|
|
57
64
|
vitest: "^2.1.0"
|
|
58
65
|
}
|
|
@@ -261,6 +268,4266 @@ async function joinHandler(opts) {
|
|
|
261
268
|
}
|
|
262
269
|
}
|
|
263
270
|
|
|
271
|
+
// src/commands/stamp.ts
|
|
272
|
+
import * as fs4 from "fs";
|
|
273
|
+
import * as os3 from "os";
|
|
274
|
+
import * as path3 from "path";
|
|
275
|
+
|
|
276
|
+
// src/lib/codebase-version.ts
|
|
277
|
+
import { execSync } from "child_process";
|
|
278
|
+
import * as fs2 from "fs";
|
|
279
|
+
import * as path2 from "path";
|
|
280
|
+
function makeDefaultExec(cwd) {
|
|
281
|
+
return (cmd, args) => {
|
|
282
|
+
try {
|
|
283
|
+
const stdout = execSync([cmd, ...args].join(" "), {
|
|
284
|
+
cwd,
|
|
285
|
+
encoding: "utf8",
|
|
286
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
287
|
+
});
|
|
288
|
+
return { stdout: stdout.trim(), code: 0 };
|
|
289
|
+
} catch (err) {
|
|
290
|
+
const e = err;
|
|
291
|
+
return {
|
|
292
|
+
stdout: typeof e.stdout === "string" ? e.stdout.trim() : "",
|
|
293
|
+
code: typeof e.status === "number" ? e.status : 1
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function findPackageJson(startDir) {
|
|
299
|
+
let current = startDir;
|
|
300
|
+
while (true) {
|
|
301
|
+
const candidate = path2.join(current, "package.json");
|
|
302
|
+
if (fs2.existsSync(candidate)) {
|
|
303
|
+
return candidate;
|
|
304
|
+
}
|
|
305
|
+
const parent = path2.dirname(current);
|
|
306
|
+
if (parent === current) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
current = parent;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function getCodebaseVersion(opts) {
|
|
313
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
314
|
+
const execFn = opts?.exec ?? makeDefaultExec(cwd);
|
|
315
|
+
const shaResult = execFn("git", ["rev-parse", "--short", "HEAD"]);
|
|
316
|
+
if (shaResult.code === 0 && shaResult.stdout.length > 0) {
|
|
317
|
+
const sha = shaResult.stdout.trim();
|
|
318
|
+
const statusResult = execFn("git", ["status", "--porcelain"]);
|
|
319
|
+
const dirty = statusResult.code === 0 && statusResult.stdout.trim().length > 0;
|
|
320
|
+
const version_string = dirty ? `${sha}-dirty` : sha;
|
|
321
|
+
return {
|
|
322
|
+
sha,
|
|
323
|
+
dirty,
|
|
324
|
+
tag: null,
|
|
325
|
+
package_version: null,
|
|
326
|
+
version_string
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const pkgPath = findPackageJson(cwd);
|
|
330
|
+
if (pkgPath !== null) {
|
|
331
|
+
try {
|
|
332
|
+
const raw = fs2.readFileSync(pkgPath, "utf8");
|
|
333
|
+
const pkg = JSON.parse(raw);
|
|
334
|
+
const package_version = typeof pkg.version === "string" ? pkg.version : null;
|
|
335
|
+
if (package_version !== null) {
|
|
336
|
+
return {
|
|
337
|
+
sha: null,
|
|
338
|
+
dirty: false,
|
|
339
|
+
tag: null,
|
|
340
|
+
package_version,
|
|
341
|
+
version_string: package_version
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
console.warn("[cleargate] codebase-version: could not determine version (no git, no package.json)");
|
|
348
|
+
return {
|
|
349
|
+
sha: null,
|
|
350
|
+
dirty: false,
|
|
351
|
+
tag: null,
|
|
352
|
+
package_version: null,
|
|
353
|
+
version_string: "unknown"
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/lib/stamp-frontmatter.ts
|
|
358
|
+
import * as fs3 from "fs/promises";
|
|
359
|
+
|
|
360
|
+
// src/wiki/parse-frontmatter.ts
|
|
361
|
+
import yaml from "js-yaml";
|
|
362
|
+
function parseFrontmatter(raw) {
|
|
363
|
+
const lines = raw.split("\n");
|
|
364
|
+
if (lines[0] !== "---") {
|
|
365
|
+
throw new Error("parseFrontmatter: input does not start with ---");
|
|
366
|
+
}
|
|
367
|
+
let closeIdx = -1;
|
|
368
|
+
for (let i = 1; i < lines.length; i++) {
|
|
369
|
+
if (lines[i] === "---") {
|
|
370
|
+
closeIdx = i;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (closeIdx === -1) {
|
|
375
|
+
throw new Error("parseFrontmatter: missing closing ---");
|
|
376
|
+
}
|
|
377
|
+
const yamlText = lines.slice(1, closeIdx).join("\n");
|
|
378
|
+
const bodyLines = lines.slice(closeIdx + 1);
|
|
379
|
+
if (bodyLines[0] === "") bodyLines.shift();
|
|
380
|
+
const body = bodyLines.join("\n");
|
|
381
|
+
if (yamlText.trim() === "") {
|
|
382
|
+
return { fm: {}, body };
|
|
383
|
+
}
|
|
384
|
+
let parsed;
|
|
385
|
+
try {
|
|
386
|
+
parsed = yaml.load(yamlText, { schema: yaml.CORE_SCHEMA });
|
|
387
|
+
} catch (err) {
|
|
388
|
+
throw new Error(`parseFrontmatter: invalid YAML: ${err.message}`);
|
|
389
|
+
}
|
|
390
|
+
if (parsed === null || parsed === void 0) {
|
|
391
|
+
return { fm: {}, body };
|
|
392
|
+
}
|
|
393
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
394
|
+
throw new Error("parseFrontmatter: frontmatter is not a YAML mapping");
|
|
395
|
+
}
|
|
396
|
+
return { fm: parsed, body };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/lib/frontmatter-yaml.ts
|
|
400
|
+
import yaml2 from "js-yaml";
|
|
401
|
+
function serializeFrontmatter(fm) {
|
|
402
|
+
if (Object.keys(fm).length === 0) {
|
|
403
|
+
return "---\n---";
|
|
404
|
+
}
|
|
405
|
+
const yamlBody = yaml2.dump(fm, {
|
|
406
|
+
schema: yaml2.CORE_SCHEMA,
|
|
407
|
+
lineWidth: -1,
|
|
408
|
+
noRefs: true,
|
|
409
|
+
noCompatMode: true,
|
|
410
|
+
quotingType: '"',
|
|
411
|
+
forceQuotes: false
|
|
412
|
+
});
|
|
413
|
+
return `---
|
|
414
|
+
${yamlBody.replace(/\n+$/, "")}
|
|
415
|
+
---`;
|
|
416
|
+
}
|
|
417
|
+
function toIsoSecond(d) {
|
|
418
|
+
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/lib/stamp-frontmatter.ts
|
|
422
|
+
var WRITE_TEMPLATE_KEYS = /* @__PURE__ */ new Set([
|
|
423
|
+
"story_id",
|
|
424
|
+
"epic_id",
|
|
425
|
+
"proposal_id",
|
|
426
|
+
"cr_id",
|
|
427
|
+
"bug_id"
|
|
428
|
+
]);
|
|
429
|
+
var DEFAULT_ARCHIVE_MATCHER = (absPath) => /\/\.cleargate\/delivery\/archive\//.test(absPath);
|
|
430
|
+
async function stampFrontmatter(absPath, opts) {
|
|
431
|
+
const isArchive = (opts?.archivePathMatcher ?? DEFAULT_ARCHIVE_MATCHER)(absPath);
|
|
432
|
+
if (isArchive) {
|
|
433
|
+
const raw2 = await fs3.readFile(absPath, "utf8");
|
|
434
|
+
let fm2 = {};
|
|
435
|
+
try {
|
|
436
|
+
({ fm: fm2 } = parseFrontmatter(raw2));
|
|
437
|
+
} catch {
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
changed: false,
|
|
441
|
+
frontmatterBefore: fm2,
|
|
442
|
+
frontmatterAfter: fm2,
|
|
443
|
+
reason: "noop-archive"
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
const raw = await fs3.readFile(absPath, "utf8");
|
|
447
|
+
const hasFrontmatter = raw.trimStart().startsWith("---");
|
|
448
|
+
let fm = {};
|
|
449
|
+
let body = raw;
|
|
450
|
+
if (hasFrontmatter) {
|
|
451
|
+
const parsed = parseFrontmatter(raw);
|
|
452
|
+
fm = parsed.fm;
|
|
453
|
+
body = parsed.body;
|
|
454
|
+
}
|
|
455
|
+
const frontmatterBefore = { ...fm };
|
|
456
|
+
const nowFn = opts?.now ?? (() => /* @__PURE__ */ new Date());
|
|
457
|
+
const now = nowFn();
|
|
458
|
+
const nowIso = toIsoSecond(now);
|
|
459
|
+
const version = opts?.version ?? { sha: null, dirty: false, tag: null, package_version: null, version_string: "unknown" };
|
|
460
|
+
const versionString = version.version_string;
|
|
461
|
+
const hasCreatedAt = "created_at" in fm && fm["created_at"] !== void 0 && fm["created_at"] !== "" && fm["created_at"] !== null;
|
|
462
|
+
const isWriteTemplate = WRITE_TEMPLATE_KEYS.has(Object.keys(fm).find((k) => WRITE_TEMPLATE_KEYS.has(k)) ?? "");
|
|
463
|
+
const newFm = {};
|
|
464
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
465
|
+
newFm[k] = v;
|
|
466
|
+
}
|
|
467
|
+
if (!hasCreatedAt) {
|
|
468
|
+
newFm["created_at"] = nowIso;
|
|
469
|
+
newFm["updated_at"] = nowIso;
|
|
470
|
+
newFm["created_at_version"] = versionString;
|
|
471
|
+
newFm["updated_at_version"] = versionString;
|
|
472
|
+
if (isWriteTemplate && !("server_pushed_at_version" in newFm)) {
|
|
473
|
+
newFm["server_pushed_at_version"] = null;
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
newFm["updated_at"] = nowIso;
|
|
477
|
+
newFm["updated_at_version"] = versionString;
|
|
478
|
+
if (isWriteTemplate && !("server_pushed_at_version" in newFm)) {
|
|
479
|
+
newFm["server_pushed_at_version"] = null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const unchanged = newFm["updated_at"] === fm["updated_at"] && newFm["updated_at_version"] === fm["updated_at_version"] && newFm["created_at"] === fm["created_at"] && newFm["created_at_version"] === fm["created_at_version"];
|
|
483
|
+
if (unchanged && hasCreatedAt) {
|
|
484
|
+
return {
|
|
485
|
+
changed: false,
|
|
486
|
+
frontmatterBefore,
|
|
487
|
+
frontmatterAfter: newFm,
|
|
488
|
+
reason: "noop-unchanged"
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const fmBlock = serializeFrontmatter(newFm);
|
|
492
|
+
const newContent = body.length > 0 ? `${fmBlock}
|
|
493
|
+
|
|
494
|
+
${body}` : `${fmBlock}
|
|
495
|
+
`;
|
|
496
|
+
await fs3.writeFile(absPath, newContent, "utf8");
|
|
497
|
+
return {
|
|
498
|
+
changed: true,
|
|
499
|
+
frontmatterBefore,
|
|
500
|
+
frontmatterAfter: newFm,
|
|
501
|
+
reason: hasCreatedAt ? "updated" : "created"
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// src/commands/stamp.ts
|
|
506
|
+
function buildDiffPreview(filePath, before, after) {
|
|
507
|
+
const lines = [`--- ${filePath}`, `+++ ${filePath} (after stamp)`];
|
|
508
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
509
|
+
for (const key of [...Object.keys(before), ...Object.keys(after)]) {
|
|
510
|
+
if (seenKeys.has(key)) continue;
|
|
511
|
+
seenKeys.add(key);
|
|
512
|
+
const bVal = before[key];
|
|
513
|
+
const aVal = after[key];
|
|
514
|
+
if (bVal === aVal) {
|
|
515
|
+
lines.push(` ${key}: ${String(bVal)}`);
|
|
516
|
+
} else {
|
|
517
|
+
if (key in before) {
|
|
518
|
+
lines.push(`-${key}: ${String(bVal)}`);
|
|
519
|
+
}
|
|
520
|
+
if (key in after) {
|
|
521
|
+
lines.push(`+${key}: ${String(aVal)}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return lines.join("\n");
|
|
526
|
+
}
|
|
527
|
+
async function stampHandler(file, opts, cli) {
|
|
528
|
+
const stdoutFn = cli?.stdout ?? ((s) => process.stdout.write(s + "\n"));
|
|
529
|
+
const exitFn = cli?.exit ?? ((code) => process.exit(code));
|
|
530
|
+
const cwd = cli?.cwd ?? process.cwd();
|
|
531
|
+
const absPath = path3.isAbsolute(file) ? file : path3.resolve(cwd, file);
|
|
532
|
+
if (!fs4.existsSync(absPath)) {
|
|
533
|
+
process.stderr.write(`[cleargate stamp] error: file not found: ${absPath}
|
|
534
|
+
`);
|
|
535
|
+
return exitFn(1);
|
|
536
|
+
}
|
|
537
|
+
const version = cli?.getVersion ? cli.getVersion() : getCodebaseVersion({ cwd });
|
|
538
|
+
if (opts.dryRun) {
|
|
539
|
+
const tmpDir = fs4.mkdtempSync(path3.join(os3.tmpdir(), "cg-stamp-dry-"));
|
|
540
|
+
try {
|
|
541
|
+
const tmpFile = path3.join(tmpDir, path3.basename(absPath));
|
|
542
|
+
fs4.copyFileSync(absPath, tmpFile);
|
|
543
|
+
let before = {};
|
|
544
|
+
try {
|
|
545
|
+
const raw = fs4.readFileSync(absPath, "utf8");
|
|
546
|
+
if (raw.trimStart().startsWith("---")) {
|
|
547
|
+
({ fm: before } = parseFrontmatter(raw));
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
}
|
|
551
|
+
const stampOpts2 = { version };
|
|
552
|
+
if (cli?.now) {
|
|
553
|
+
stampOpts2.now = cli.now;
|
|
554
|
+
}
|
|
555
|
+
const result2 = await stampFrontmatter(tmpFile, stampOpts2);
|
|
556
|
+
const diff = buildDiffPreview(file, before, result2.frontmatterAfter);
|
|
557
|
+
stdoutFn(diff);
|
|
558
|
+
if (result2.reason === "noop-archive" || result2.reason === "noop-unchanged") {
|
|
559
|
+
stdoutFn(`[dry-run] no changes (${result2.reason})`);
|
|
560
|
+
}
|
|
561
|
+
} finally {
|
|
562
|
+
fs4.rmSync(tmpDir, { recursive: true, force: true });
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const stampOpts = { version };
|
|
567
|
+
if (cli?.now) {
|
|
568
|
+
stampOpts.now = cli.now;
|
|
569
|
+
}
|
|
570
|
+
const result = await stampFrontmatter(absPath, stampOpts);
|
|
571
|
+
stdoutFn(`[stamped] ${file} (${result.reason})`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/commands/init.ts
|
|
575
|
+
import * as fs12 from "fs";
|
|
576
|
+
import * as path12 from "path";
|
|
577
|
+
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
578
|
+
|
|
579
|
+
// src/init/copy-payload.ts
|
|
580
|
+
import * as fs5 from "fs";
|
|
581
|
+
import * as path4 from "path";
|
|
582
|
+
function listFilesRecursive(dir) {
|
|
583
|
+
const results = [];
|
|
584
|
+
function walk(current, rel) {
|
|
585
|
+
const entries = fs5.readdirSync(current, { withFileTypes: true });
|
|
586
|
+
for (const entry of entries) {
|
|
587
|
+
const entryRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
588
|
+
const entryAbs = path4.join(current, entry.name);
|
|
589
|
+
if (entry.isDirectory()) {
|
|
590
|
+
walk(entryAbs, entryRel);
|
|
591
|
+
} else {
|
|
592
|
+
results.push(entryRel);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
walk(dir, "");
|
|
597
|
+
return results;
|
|
598
|
+
}
|
|
599
|
+
function copyPayload(payloadDir, targetCwd, opts) {
|
|
600
|
+
const report = { created: 0, skipped: 0, overwritten: 0, actions: [] };
|
|
601
|
+
if (!fs5.existsSync(payloadDir)) {
|
|
602
|
+
throw new Error(`copyPayload: payloadDir does not exist: ${payloadDir}`);
|
|
603
|
+
}
|
|
604
|
+
const files = listFilesRecursive(payloadDir);
|
|
605
|
+
for (const relPath of files) {
|
|
606
|
+
const srcPath = path4.join(payloadDir, relPath);
|
|
607
|
+
const dstPath = path4.join(targetCwd, relPath);
|
|
608
|
+
fs5.mkdirSync(path4.dirname(dstPath), { recursive: true });
|
|
609
|
+
const srcContent = fs5.readFileSync(srcPath);
|
|
610
|
+
if (fs5.existsSync(dstPath)) {
|
|
611
|
+
const dstContent = fs5.readFileSync(dstPath);
|
|
612
|
+
if (srcContent.equals(dstContent)) {
|
|
613
|
+
report.skipped++;
|
|
614
|
+
report.actions.push({ action: "skipped", relPath });
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (!opts.force) {
|
|
618
|
+
report.skipped++;
|
|
619
|
+
report.actions.push({ action: "skipped", relPath });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
fs5.writeFileSync(dstPath, srcContent);
|
|
623
|
+
report.overwritten++;
|
|
624
|
+
report.actions.push({ action: "overwritten", relPath });
|
|
625
|
+
} else {
|
|
626
|
+
fs5.writeFileSync(dstPath, srcContent);
|
|
627
|
+
report.created++;
|
|
628
|
+
report.actions.push({ action: "created", relPath });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return report;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/init/merge-settings.ts
|
|
635
|
+
function deepClone(obj) {
|
|
636
|
+
return JSON.parse(JSON.stringify(obj));
|
|
637
|
+
}
|
|
638
|
+
function mergeSettings(existing, addition) {
|
|
639
|
+
if (existing === null) {
|
|
640
|
+
return deepClone(addition);
|
|
641
|
+
}
|
|
642
|
+
const result = deepClone(existing);
|
|
643
|
+
if (!result.hooks) {
|
|
644
|
+
result.hooks = {};
|
|
645
|
+
}
|
|
646
|
+
for (const [eventName, eventArray] of Object.entries(addition.hooks ?? {})) {
|
|
647
|
+
if (!result.hooks[eventName]) {
|
|
648
|
+
result.hooks[eventName] = [];
|
|
649
|
+
}
|
|
650
|
+
for (const newEntry of eventArray) {
|
|
651
|
+
const matchingIdx = result.hooks[eventName].findIndex(
|
|
652
|
+
(e) => e.matcher === newEntry.matcher
|
|
653
|
+
);
|
|
654
|
+
if (matchingIdx === -1) {
|
|
655
|
+
result.hooks[eventName].push(deepClone(newEntry));
|
|
656
|
+
} else {
|
|
657
|
+
const existingEntry = result.hooks[eventName][matchingIdx];
|
|
658
|
+
const existingInner = Array.isArray(existingEntry.hooks) ? existingEntry.hooks : [];
|
|
659
|
+
for (const newInner of newEntry.hooks ?? []) {
|
|
660
|
+
if (!existingInner.some((h) => h.command === newInner.command)) {
|
|
661
|
+
existingInner.push(deepClone(newInner));
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
result.hooks[eventName][matchingIdx] = {
|
|
665
|
+
...existingEntry,
|
|
666
|
+
hooks: existingInner
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return result;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/init/inject-claude-md.ts
|
|
675
|
+
var BLOCK_REGEX = /<!-- CLEARGATE:START -->[\s\S]*<!-- CLEARGATE:END -->/;
|
|
676
|
+
function extractBlock(sourceContent) {
|
|
677
|
+
const match = BLOCK_REGEX.exec(sourceContent);
|
|
678
|
+
if (!match) {
|
|
679
|
+
throw new Error("inject-claude-md: CLEARGATE:START/END markers not found in source content");
|
|
680
|
+
}
|
|
681
|
+
return match[0];
|
|
682
|
+
}
|
|
683
|
+
function injectClaudeMd(existing, block) {
|
|
684
|
+
if (existing === null) {
|
|
685
|
+
return block + "\n";
|
|
686
|
+
}
|
|
687
|
+
if (BLOCK_REGEX.test(existing)) {
|
|
688
|
+
return existing.replace(BLOCK_REGEX, block);
|
|
689
|
+
}
|
|
690
|
+
return existing.trimEnd() + "\n\n" + block + "\n";
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/commands/wiki-build.ts
|
|
694
|
+
import * as fs11 from "fs";
|
|
695
|
+
import * as path10 from "path";
|
|
696
|
+
|
|
697
|
+
// src/wiki/scan.ts
|
|
698
|
+
import * as fs6 from "fs";
|
|
699
|
+
import * as path5 from "path";
|
|
700
|
+
|
|
701
|
+
// src/wiki/derive-bucket.ts
|
|
702
|
+
var PREFIX_MAP = [
|
|
703
|
+
{ prefix: "EPIC-", type: "epic", bucket: "epics" },
|
|
704
|
+
{ prefix: "STORY-", type: "story", bucket: "stories" },
|
|
705
|
+
{ prefix: "SPRINT-", type: "sprint", bucket: "sprints" },
|
|
706
|
+
{ prefix: "PROPOSAL-", type: "proposal", bucket: "proposals" },
|
|
707
|
+
{ prefix: "CR-", type: "cr", bucket: "crs" },
|
|
708
|
+
{ prefix: "BUG-", type: "bug", bucket: "bugs" }
|
|
709
|
+
];
|
|
710
|
+
function deriveBucket(filename) {
|
|
711
|
+
const base = filename.includes("/") ? filename.split("/").pop() : filename;
|
|
712
|
+
const stem = base.endsWith(".md") ? base.slice(0, -3) : base;
|
|
713
|
+
const underscoreIdx = stem.indexOf("_");
|
|
714
|
+
const id = underscoreIdx === -1 ? stem : stem.slice(0, underscoreIdx);
|
|
715
|
+
for (const { prefix, type, bucket } of PREFIX_MAP) {
|
|
716
|
+
if (id.startsWith(prefix)) {
|
|
717
|
+
return { type, id, bucket };
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
throw new Error(`deriveBucket: cannot determine bucket for filename: ${filename}`);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// src/wiki/derive-repo.ts
|
|
724
|
+
function deriveRepo(rawPath) {
|
|
725
|
+
if (rawPath.startsWith("cleargate-cli/")) return "cli";
|
|
726
|
+
if (rawPath.startsWith("mcp/")) return "mcp";
|
|
727
|
+
if (rawPath.startsWith(".cleargate/") || rawPath.startsWith("cleargate-planning/")) return "planning";
|
|
728
|
+
throw new Error(`cannot derive repo for path: ${rawPath}`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// src/wiki/scan.ts
|
|
732
|
+
var EXCLUDED_SUFFIXES = [
|
|
733
|
+
".cleargate/knowledge/",
|
|
734
|
+
".cleargate/templates/",
|
|
735
|
+
".cleargate/sprint-runs/",
|
|
736
|
+
".cleargate/hook-log/",
|
|
737
|
+
".cleargate/wiki/"
|
|
738
|
+
];
|
|
739
|
+
function scanRawItems(deliveryRoot, repoRoot) {
|
|
740
|
+
const results = [];
|
|
741
|
+
for (const subdir of ["pending-sync", "archive"]) {
|
|
742
|
+
const dir = path5.join(deliveryRoot, subdir);
|
|
743
|
+
if (!fs6.existsSync(dir)) continue;
|
|
744
|
+
const entries = fs6.readdirSync(dir, { recursive: true, encoding: "utf8" });
|
|
745
|
+
for (const rel of entries) {
|
|
746
|
+
if (!rel.endsWith(".md")) continue;
|
|
747
|
+
if (rel.includes("~") || rel.startsWith(".")) continue;
|
|
748
|
+
const absPath = path5.join(dir, rel);
|
|
749
|
+
const stat = fs6.statSync(absPath);
|
|
750
|
+
if (!stat.isFile()) continue;
|
|
751
|
+
const rawPath = path5.relative(repoRoot, absPath).replace(/\\/g, "/");
|
|
752
|
+
const isExcluded = EXCLUDED_SUFFIXES.some((excl) => rawPath.startsWith(excl));
|
|
753
|
+
if (isExcluded) continue;
|
|
754
|
+
const filename = path5.basename(absPath);
|
|
755
|
+
let bucketInfo;
|
|
756
|
+
try {
|
|
757
|
+
bucketInfo = deriveBucket(filename);
|
|
758
|
+
} catch {
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
let repo;
|
|
762
|
+
try {
|
|
763
|
+
repo = deriveRepo(rawPath);
|
|
764
|
+
} catch {
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const raw = fs6.readFileSync(absPath, "utf8");
|
|
768
|
+
let fm;
|
|
769
|
+
let body;
|
|
770
|
+
try {
|
|
771
|
+
const parsed = parseFrontmatter(raw);
|
|
772
|
+
fm = parsed.fm;
|
|
773
|
+
body = parsed.body;
|
|
774
|
+
} catch {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
results.push({
|
|
778
|
+
absPath,
|
|
779
|
+
rawPath,
|
|
780
|
+
id: bucketInfo.id,
|
|
781
|
+
bucket: bucketInfo.bucket,
|
|
782
|
+
type: bucketInfo.type,
|
|
783
|
+
repo,
|
|
784
|
+
fm,
|
|
785
|
+
body
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
results.sort((a, b) => a.id.localeCompare(b.id));
|
|
790
|
+
return results;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/wiki/git-sha.ts
|
|
794
|
+
import { spawnSync } from "child_process";
|
|
795
|
+
function getGitSha(rawPath, runner) {
|
|
796
|
+
const run = runner ?? defaultRunner;
|
|
797
|
+
const out = run("git", ["log", "-1", "--format=%H", "--", rawPath]).trim();
|
|
798
|
+
return out.length > 0 ? out : null;
|
|
799
|
+
}
|
|
800
|
+
function defaultRunner(cmd, args) {
|
|
801
|
+
const result = spawnSync(cmd, args, { encoding: "utf8" });
|
|
802
|
+
return result.stdout ?? "";
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/wiki/page-schema.ts
|
|
806
|
+
function serializePage(page, body) {
|
|
807
|
+
const childrenYaml = page.children.length === 0 ? "[]" : "\n" + page.children.map((c) => ` - "${c}"`).join("\n");
|
|
808
|
+
const fm = [
|
|
809
|
+
"---",
|
|
810
|
+
`type: ${page.type}`,
|
|
811
|
+
`id: "${page.id}"`,
|
|
812
|
+
`parent: "${page.parent}"`,
|
|
813
|
+
`children: ${childrenYaml}`,
|
|
814
|
+
`status: "${page.status}"`,
|
|
815
|
+
`remote_id: "${page.remote_id}"`,
|
|
816
|
+
`raw_path: "${page.raw_path}"`,
|
|
817
|
+
`last_ingest: "${page.last_ingest}"`,
|
|
818
|
+
`last_ingest_commit: "${page.last_ingest_commit}"`,
|
|
819
|
+
`repo: "${page.repo}"`,
|
|
820
|
+
"---"
|
|
821
|
+
].join("\n");
|
|
822
|
+
return `${fm}
|
|
823
|
+
|
|
824
|
+
${body}`;
|
|
825
|
+
}
|
|
826
|
+
function parsePage(raw) {
|
|
827
|
+
const { fm } = parseFmRaw(raw);
|
|
828
|
+
const type = fm["type"];
|
|
829
|
+
const id = String(fm["id"] ?? "");
|
|
830
|
+
const parent = String(fm["parent"] ?? "");
|
|
831
|
+
const rawChildren = fm["children"];
|
|
832
|
+
const children = Array.isArray(rawChildren) ? rawChildren.map(String) : [];
|
|
833
|
+
const status = String(fm["status"] ?? "");
|
|
834
|
+
const remote_id = String(fm["remote_id"] ?? "");
|
|
835
|
+
const raw_path = String(fm["raw_path"] ?? "");
|
|
836
|
+
const last_ingest = String(fm["last_ingest"] ?? "");
|
|
837
|
+
const last_ingest_commit = String(fm["last_ingest_commit"] ?? "");
|
|
838
|
+
const repo = fm["repo"];
|
|
839
|
+
return { type, id, parent, children, status, remote_id, raw_path, last_ingest, last_ingest_commit, repo };
|
|
840
|
+
}
|
|
841
|
+
function parseFmRaw(raw) {
|
|
842
|
+
const lines = raw.split("\n");
|
|
843
|
+
if (lines[0] !== "---") throw new Error("parsePage: missing opening ---");
|
|
844
|
+
let close = -1;
|
|
845
|
+
for (let i = 1; i < lines.length; i++) {
|
|
846
|
+
if (lines[i] === "---") {
|
|
847
|
+
close = i;
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (close === -1) throw new Error("parsePage: missing closing ---");
|
|
852
|
+
const fmLines = lines.slice(1, close);
|
|
853
|
+
const body = lines.slice(close + 1).join("\n").replace(/^\n/, "");
|
|
854
|
+
const fm = {};
|
|
855
|
+
for (const line of fmLines) {
|
|
856
|
+
const colon = line.indexOf(":");
|
|
857
|
+
if (colon === -1) continue;
|
|
858
|
+
const key = line.slice(0, colon).trim();
|
|
859
|
+
const val = line.slice(colon + 1).trim();
|
|
860
|
+
if (val === "[]") {
|
|
861
|
+
fm[key] = [];
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
if (val === "") {
|
|
865
|
+
fm[key] = [];
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
869
|
+
const inner = val.slice(1, -1);
|
|
870
|
+
fm[key] = inner.split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""));
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
fm[key] = val.replace(/^["']|["']$/g, "");
|
|
874
|
+
}
|
|
875
|
+
return { fm, body };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// src/wiki/synthesis/active-sprint.ts
|
|
879
|
+
import * as fs7 from "fs";
|
|
880
|
+
import * as path6 from "path";
|
|
881
|
+
import { fileURLToPath } from "url";
|
|
882
|
+
|
|
883
|
+
// src/wiki/synthesis/render.ts
|
|
884
|
+
function renderTemplate(template, data) {
|
|
885
|
+
const tagRe = /\{\{([^}]+)\}\}/g;
|
|
886
|
+
const matches = [...template.matchAll(tagRe)].map((m) => m[1].trim());
|
|
887
|
+
for (const tag of matches) {
|
|
888
|
+
if (tag.startsWith("^") || tag.startsWith(">") || tag.startsWith("!") || tag.startsWith("=")) {
|
|
889
|
+
throw new Error(`renderTemplate: unsupported tag type: {{${tag}}}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return renderSection(template, data);
|
|
893
|
+
}
|
|
894
|
+
function renderSection(template, ctx) {
|
|
895
|
+
const sectionRe = /\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g;
|
|
896
|
+
let result = template.replace(sectionRe, (_match, key, inner) => {
|
|
897
|
+
const val = ctx[key];
|
|
898
|
+
if (!Array.isArray(val)) {
|
|
899
|
+
if (!val) return "";
|
|
900
|
+
return renderSection(inner, ctx);
|
|
901
|
+
}
|
|
902
|
+
if (val.length === 0) return "";
|
|
903
|
+
return val.map((item) => {
|
|
904
|
+
const itemCtx = item !== null && typeof item === "object" ? item : { ".": item };
|
|
905
|
+
return renderSection(inner, itemCtx);
|
|
906
|
+
}).join("");
|
|
907
|
+
});
|
|
908
|
+
result = result.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
|
|
909
|
+
const val = ctx[key];
|
|
910
|
+
if (val === void 0 || val === null) return "";
|
|
911
|
+
return String(val);
|
|
912
|
+
});
|
|
913
|
+
return result;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/wiki/synthesis/active-sprint.ts
|
|
917
|
+
function compile(state, templateDir) {
|
|
918
|
+
const tplDir = templateDir ?? resolveDefaultTemplateDir();
|
|
919
|
+
const tpl = fs7.readFileSync(path6.join(tplDir, "active-sprint.md"), "utf8");
|
|
920
|
+
const sprints = state.filter((i) => i.bucket === "sprints");
|
|
921
|
+
const active = sprints.filter((s) => isSet(s.fm["activated_at"]) && !isSet(s.fm["completed_at"]));
|
|
922
|
+
const completed = sprints.filter((s) => isSet(s.fm["completed_at"]));
|
|
923
|
+
const planned = sprints.filter((s) => !isSet(s.fm["activated_at"]) && !isSet(s.fm["completed_at"]));
|
|
924
|
+
const data = {
|
|
925
|
+
active: active.map((s) => ({ id: s.id, status: String(s.fm["status"] ?? "unknown") })),
|
|
926
|
+
no_active: active.length === 0 ? [{}] : [],
|
|
927
|
+
planned: planned.map((s) => ({ id: s.id, status: String(s.fm["status"] ?? "unknown") })),
|
|
928
|
+
no_planned: planned.length === 0 ? [{}] : [],
|
|
929
|
+
completed: completed.slice(0, 3).map((s) => ({
|
|
930
|
+
id: s.id,
|
|
931
|
+
completed_at: String(s.fm["completed_at"] ?? "")
|
|
932
|
+
})),
|
|
933
|
+
no_completed: completed.length === 0 ? [{}] : []
|
|
934
|
+
};
|
|
935
|
+
return renderTemplate(tpl, data);
|
|
936
|
+
}
|
|
937
|
+
function isSet(val) {
|
|
938
|
+
if (val === null || val === void 0) return false;
|
|
939
|
+
const s = String(val).trim();
|
|
940
|
+
return s !== "" && s !== "null";
|
|
941
|
+
}
|
|
942
|
+
function resolveDefaultTemplateDir() {
|
|
943
|
+
const __dirname2 = path6.dirname(fileURLToPath(import.meta.url));
|
|
944
|
+
return path6.resolve(__dirname2, "..", "templates", "synthesis");
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// src/wiki/synthesis/open-gates.ts
|
|
948
|
+
import * as fs8 from "fs";
|
|
949
|
+
import * as path7 from "path";
|
|
950
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
951
|
+
function compile2(state, templateDir) {
|
|
952
|
+
const tplDir = templateDir ?? resolveDefaultTemplateDir2();
|
|
953
|
+
const tpl = fs8.readFileSync(path7.join(tplDir, "open-gates.md"), "utf8");
|
|
954
|
+
const gate1 = state.filter((i) => {
|
|
955
|
+
if (i.bucket !== "proposals") return false;
|
|
956
|
+
const status = String(i.fm["status"] ?? "");
|
|
957
|
+
const approved = i.fm["approved"];
|
|
958
|
+
return status === "Draft" || approved === false || approved === "false";
|
|
959
|
+
});
|
|
960
|
+
const gate2 = state.filter((i) => {
|
|
961
|
+
if (i.bucket !== "stories") return false;
|
|
962
|
+
const ambiguity = String(i.fm["ambiguity"] ?? "");
|
|
963
|
+
return ambiguity.startsWith("\u{1F7E1}") || ambiguity.startsWith("\u{1F534}");
|
|
964
|
+
});
|
|
965
|
+
const gate3 = state.filter((i) => {
|
|
966
|
+
const status = String(i.fm["status"] ?? "");
|
|
967
|
+
if (status !== "Ready") return false;
|
|
968
|
+
const remoteId = i.fm["remote_id"];
|
|
969
|
+
return remoteId === null || remoteId === void 0 || String(remoteId).trim() === "";
|
|
970
|
+
});
|
|
971
|
+
const data = {
|
|
972
|
+
gate1: gate1.map((i) => ({ id: i.id, status: String(i.fm["status"] ?? "") })),
|
|
973
|
+
no_gate1: gate1.length === 0 ? [{}] : [],
|
|
974
|
+
gate2: gate2.map((i) => ({ id: i.id, ambiguity: String(i.fm["ambiguity"] ?? "") })),
|
|
975
|
+
no_gate2: gate2.length === 0 ? [{}] : [],
|
|
976
|
+
gate3: gate3.map((i) => ({ id: i.id, status: String(i.fm["status"] ?? "") })),
|
|
977
|
+
no_gate3: gate3.length === 0 ? [{}] : []
|
|
978
|
+
};
|
|
979
|
+
return renderTemplate(tpl, data);
|
|
980
|
+
}
|
|
981
|
+
function resolveDefaultTemplateDir2() {
|
|
982
|
+
const __dirname2 = path7.dirname(fileURLToPath2(import.meta.url));
|
|
983
|
+
return path7.resolve(__dirname2, "..", "templates", "synthesis");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// src/wiki/synthesis/product-state.ts
|
|
987
|
+
import * as fs9 from "fs";
|
|
988
|
+
import * as path8 from "path";
|
|
989
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
990
|
+
function compile3(state, templateDir) {
|
|
991
|
+
const tplDir = templateDir ?? resolveDefaultTemplateDir3();
|
|
992
|
+
const tpl = fs9.readFileSync(path8.join(tplDir, "product-state.md"), "utf8");
|
|
993
|
+
function countBucket(bucket) {
|
|
994
|
+
return state.filter((i) => i.bucket === bucket);
|
|
995
|
+
}
|
|
996
|
+
function isShipped(item) {
|
|
997
|
+
return item.rawPath.includes("/archive/");
|
|
998
|
+
}
|
|
999
|
+
function isActive(item) {
|
|
1000
|
+
const status = String(item.fm["status"] ?? "");
|
|
1001
|
+
return status === "Active" || status === "In Progress" || status.startsWith("\u{1F7E2}") || status === "\u{1F7E1} in-flight";
|
|
1002
|
+
}
|
|
1003
|
+
const buckets = ["epics", "stories", "sprints", "proposals", "crs", "bugs"];
|
|
1004
|
+
function countFor(bucket, predicate) {
|
|
1005
|
+
return countBucket(bucket).filter(predicate).length;
|
|
1006
|
+
}
|
|
1007
|
+
const epics = countBucket("epics");
|
|
1008
|
+
const activeEpicsList = epics.filter(isActive);
|
|
1009
|
+
const shippedItems = state.filter(isShipped);
|
|
1010
|
+
const data = {
|
|
1011
|
+
// Totals
|
|
1012
|
+
total_epics: epics.length,
|
|
1013
|
+
total_stories: countBucket("stories").length,
|
|
1014
|
+
total_sprints: countBucket("sprints").length,
|
|
1015
|
+
total_proposals: countBucket("proposals").length,
|
|
1016
|
+
total_crs: countBucket("crs").length,
|
|
1017
|
+
total_bugs: countBucket("bugs").length,
|
|
1018
|
+
// Active counts (per bucket)
|
|
1019
|
+
...Object.fromEntries(buckets.map((b) => [`active_${b}`, countFor(b, isActive)])),
|
|
1020
|
+
// Shipped counts (per bucket)
|
|
1021
|
+
...Object.fromEntries(buckets.map((b) => [`shipped_${b}`, countFor(b, isShipped)])),
|
|
1022
|
+
// Active epics list
|
|
1023
|
+
active_epics_list: activeEpicsList.map((i) => ({
|
|
1024
|
+
id: i.id,
|
|
1025
|
+
status: String(i.fm["status"] ?? "")
|
|
1026
|
+
})),
|
|
1027
|
+
no_active_epics: activeEpicsList.length === 0 ? [{}] : [],
|
|
1028
|
+
// Shipped items list
|
|
1029
|
+
shipped_items: shippedItems.map((i) => ({
|
|
1030
|
+
id: i.id,
|
|
1031
|
+
bucket: i.bucket,
|
|
1032
|
+
status: String(i.fm["status"] ?? "")
|
|
1033
|
+
})),
|
|
1034
|
+
no_shipped: shippedItems.length === 0 ? [{}] : []
|
|
1035
|
+
};
|
|
1036
|
+
return renderTemplate(tpl, data);
|
|
1037
|
+
}
|
|
1038
|
+
function resolveDefaultTemplateDir3() {
|
|
1039
|
+
const __dirname2 = path8.dirname(fileURLToPath3(import.meta.url));
|
|
1040
|
+
return path8.resolve(__dirname2, "..", "templates", "synthesis");
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// src/wiki/synthesis/roadmap.ts
|
|
1044
|
+
import * as fs10 from "fs";
|
|
1045
|
+
import * as path9 from "path";
|
|
1046
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
1047
|
+
function compile4(state, templateDir) {
|
|
1048
|
+
const tplDir = templateDir ?? resolveDefaultTemplateDir4();
|
|
1049
|
+
const tpl = fs10.readFileSync(path9.join(tplDir, "roadmap.md"), "utf8");
|
|
1050
|
+
const sprints = state.filter((i) => i.bucket === "sprints");
|
|
1051
|
+
const epics = state.filter((i) => i.bucket === "epics");
|
|
1052
|
+
const inFlightSprints = sprints.filter(
|
|
1053
|
+
(s) => isSet2(s.fm["activated_at"]) && !isSet2(s.fm["completed_at"])
|
|
1054
|
+
);
|
|
1055
|
+
const plannedSprints = sprints.filter(
|
|
1056
|
+
(s) => !isSet2(s.fm["activated_at"]) && !isSet2(s.fm["completed_at"])
|
|
1057
|
+
);
|
|
1058
|
+
const shippedSprints = sprints.filter((s) => isSet2(s.fm["completed_at"]));
|
|
1059
|
+
const activeEpics = epics.filter((e) => isActiveStatus(String(e.fm["status"] ?? "")));
|
|
1060
|
+
const plannedEpics = epics.filter((e) => isPlannedStatus(String(e.fm["status"] ?? "")));
|
|
1061
|
+
const shippedEpics = epics.filter((e) => isShippedStatus(String(e.fm["status"] ?? "")));
|
|
1062
|
+
const data = {
|
|
1063
|
+
in_flight_sprints: inFlightSprints.map((s) => ({
|
|
1064
|
+
id: s.id,
|
|
1065
|
+
activated_at: String(s.fm["activated_at"] ?? "")
|
|
1066
|
+
})),
|
|
1067
|
+
no_in_flight_sprints: inFlightSprints.length === 0 ? [{}] : [],
|
|
1068
|
+
planned_sprints: plannedSprints.map((s) => ({
|
|
1069
|
+
id: s.id,
|
|
1070
|
+
status: String(s.fm["status"] ?? "")
|
|
1071
|
+
})),
|
|
1072
|
+
no_planned_sprints: plannedSprints.length === 0 ? [{}] : [],
|
|
1073
|
+
shipped_sprints: shippedSprints.map((s) => ({
|
|
1074
|
+
id: s.id,
|
|
1075
|
+
completed_at: String(s.fm["completed_at"] ?? "")
|
|
1076
|
+
})),
|
|
1077
|
+
no_shipped_sprints: shippedSprints.length === 0 ? [{}] : [],
|
|
1078
|
+
active_epics: activeEpics.map((e) => ({ id: e.id, status: String(e.fm["status"] ?? "") })),
|
|
1079
|
+
no_active_epics: activeEpics.length === 0 ? [{}] : [],
|
|
1080
|
+
planned_epics: plannedEpics.map((e) => ({ id: e.id, status: String(e.fm["status"] ?? "") })),
|
|
1081
|
+
no_planned_epics: plannedEpics.length === 0 ? [{}] : [],
|
|
1082
|
+
shipped_epics: shippedEpics.map((e) => ({ id: e.id, status: String(e.fm["status"] ?? "") })),
|
|
1083
|
+
no_shipped_epics: shippedEpics.length === 0 ? [{}] : []
|
|
1084
|
+
};
|
|
1085
|
+
return renderTemplate(tpl, data);
|
|
1086
|
+
}
|
|
1087
|
+
function isSet2(val) {
|
|
1088
|
+
if (val === null || val === void 0) return false;
|
|
1089
|
+
const s = String(val).trim();
|
|
1090
|
+
return s !== "" && s !== "null";
|
|
1091
|
+
}
|
|
1092
|
+
function isActiveStatus(status) {
|
|
1093
|
+
return status === "Active" || status === "In Progress" || status.startsWith("\u{1F7E2}") || status === "\u{1F7E1} in-flight";
|
|
1094
|
+
}
|
|
1095
|
+
function isPlannedStatus(status) {
|
|
1096
|
+
return status === "Ready" || status === "Planned" || status === "Draft";
|
|
1097
|
+
}
|
|
1098
|
+
function isShippedStatus(status) {
|
|
1099
|
+
return status === "Completed" || status === "Approved";
|
|
1100
|
+
}
|
|
1101
|
+
function resolveDefaultTemplateDir4() {
|
|
1102
|
+
const __dirname2 = path9.dirname(fileURLToPath4(import.meta.url));
|
|
1103
|
+
return path9.resolve(__dirname2, "..", "templates", "synthesis");
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// src/commands/wiki-build.ts
|
|
1107
|
+
var BUCKET_ORDER = ["epics", "stories", "sprints", "proposals", "crs", "bugs", "topics"];
|
|
1108
|
+
var BUCKET_LABELS = {
|
|
1109
|
+
epics: "Epics",
|
|
1110
|
+
stories: "Stories",
|
|
1111
|
+
sprints: "Sprints",
|
|
1112
|
+
proposals: "Proposals",
|
|
1113
|
+
crs: "CRs",
|
|
1114
|
+
bugs: "Bugs",
|
|
1115
|
+
topics: "Topics"
|
|
1116
|
+
};
|
|
1117
|
+
async function wikiBuildHandler(opts = {}) {
|
|
1118
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1119
|
+
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
1120
|
+
const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
|
|
1121
|
+
const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
|
|
1122
|
+
const exit = opts.exit ?? ((c) => process.exit(c));
|
|
1123
|
+
const gitRunner = opts.gitRunner;
|
|
1124
|
+
const templateDir = opts.templateDir;
|
|
1125
|
+
const deliveryRoot = path10.join(cwd, ".cleargate", "delivery");
|
|
1126
|
+
const wikiRoot = path10.join(cwd, ".cleargate", "wiki");
|
|
1127
|
+
if (!fs11.existsSync(deliveryRoot)) {
|
|
1128
|
+
stderr(`wiki build: .cleargate/delivery/ not found at ${deliveryRoot}
|
|
1129
|
+
`);
|
|
1130
|
+
exit(1);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
for (const bucket of BUCKET_ORDER) {
|
|
1134
|
+
fs11.mkdirSync(path10.join(wikiRoot, bucket), { recursive: true });
|
|
1135
|
+
}
|
|
1136
|
+
const items = scanRawItems(deliveryRoot, cwd);
|
|
1137
|
+
const timestamp = now();
|
|
1138
|
+
let pagesWritten = 0;
|
|
1139
|
+
for (const item of items) {
|
|
1140
|
+
const sha = getGitSha(item.rawPath, gitRunner) ?? "";
|
|
1141
|
+
const parent = buildParentRef(item.fm);
|
|
1142
|
+
const children = buildChildrenRefs(item.fm);
|
|
1143
|
+
const wikiPage = {
|
|
1144
|
+
type: item.type,
|
|
1145
|
+
id: item.id,
|
|
1146
|
+
parent,
|
|
1147
|
+
children,
|
|
1148
|
+
status: String(item.fm["status"] ?? ""),
|
|
1149
|
+
remote_id: String(item.fm["remote_id"] ?? ""),
|
|
1150
|
+
raw_path: item.rawPath,
|
|
1151
|
+
last_ingest: timestamp,
|
|
1152
|
+
last_ingest_commit: sha,
|
|
1153
|
+
repo: item.repo
|
|
1154
|
+
};
|
|
1155
|
+
const body = buildPageBody(item, wikiPage);
|
|
1156
|
+
const content = serializePage(wikiPage, body);
|
|
1157
|
+
const pageDir = path10.join(wikiRoot, item.bucket);
|
|
1158
|
+
fs11.mkdirSync(pageDir, { recursive: true });
|
|
1159
|
+
fs11.writeFileSync(path10.join(pageDir, `${item.id}.md`), content, "utf8");
|
|
1160
|
+
pagesWritten++;
|
|
1161
|
+
}
|
|
1162
|
+
const indexContent = buildIndex(items);
|
|
1163
|
+
fs11.writeFileSync(path10.join(wikiRoot, "index.md"), indexContent, "utf8");
|
|
1164
|
+
const logContent = buildLog(items, timestamp);
|
|
1165
|
+
fs11.writeFileSync(path10.join(wikiRoot, "log.md"), logContent, "utf8");
|
|
1166
|
+
fs11.writeFileSync(path10.join(wikiRoot, "active-sprint.md"), compile(items, templateDir), "utf8");
|
|
1167
|
+
fs11.writeFileSync(path10.join(wikiRoot, "open-gates.md"), compile2(items, templateDir), "utf8");
|
|
1168
|
+
fs11.writeFileSync(path10.join(wikiRoot, "product-state.md"), compile3(items, templateDir), "utf8");
|
|
1169
|
+
fs11.writeFileSync(path10.join(wikiRoot, "roadmap.md"), compile4(items, templateDir), "utf8");
|
|
1170
|
+
stdout(`wiki build: OK (${pagesWritten} pages written)
|
|
1171
|
+
`);
|
|
1172
|
+
}
|
|
1173
|
+
function buildParentRef(fm) {
|
|
1174
|
+
const raw = fm["parent_epic_ref"] ?? fm["parent"] ?? "";
|
|
1175
|
+
const s = String(raw);
|
|
1176
|
+
if (!s) return "";
|
|
1177
|
+
if (s.startsWith("[[") && s.endsWith("]]")) return s;
|
|
1178
|
+
return `[[${s}]]`;
|
|
1179
|
+
}
|
|
1180
|
+
function buildChildrenRefs(fm) {
|
|
1181
|
+
const raw = fm["children"];
|
|
1182
|
+
if (!raw) return [];
|
|
1183
|
+
const arr = Array.isArray(raw) ? raw : [raw];
|
|
1184
|
+
return arr.map((c) => {
|
|
1185
|
+
const s = String(c);
|
|
1186
|
+
if (s.startsWith("[[") && s.endsWith("]]")) return s;
|
|
1187
|
+
return `[[${s}]]`;
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
function buildPageBody(item, page) {
|
|
1191
|
+
const title = String(item.fm["title"] ?? item.id);
|
|
1192
|
+
const summary = String(item.fm["description"] ?? item.body.split("\n")[0] ?? "No summary available.").slice(0, 200);
|
|
1193
|
+
const blastParts = [];
|
|
1194
|
+
if (page.parent) blastParts.push(page.parent);
|
|
1195
|
+
for (const child of page.children) blastParts.push(child);
|
|
1196
|
+
const blastLine = blastParts.length > 0 ? blastParts.join(", ") : "None.";
|
|
1197
|
+
return [
|
|
1198
|
+
`# ${item.id}: ${title}`,
|
|
1199
|
+
"",
|
|
1200
|
+
summary,
|
|
1201
|
+
"",
|
|
1202
|
+
"## Blast radius",
|
|
1203
|
+
`Affects: ${blastLine}`,
|
|
1204
|
+
"",
|
|
1205
|
+
"## Open questions",
|
|
1206
|
+
"None.",
|
|
1207
|
+
""
|
|
1208
|
+
].join("\n");
|
|
1209
|
+
}
|
|
1210
|
+
function buildIndex(items) {
|
|
1211
|
+
const lines = [
|
|
1212
|
+
"# Wiki Index",
|
|
1213
|
+
"",
|
|
1214
|
+
"> Auto-generated by `cleargate wiki build`. Do not edit manually.",
|
|
1215
|
+
"",
|
|
1216
|
+
"| ID | Type | Status | Raw Path |",
|
|
1217
|
+
"|---|---|---|---|"
|
|
1218
|
+
];
|
|
1219
|
+
if (items.length === 0) {
|
|
1220
|
+
lines.push("| _(no items)_ | \u2014 | \u2014 | \u2014 |");
|
|
1221
|
+
lines.push("");
|
|
1222
|
+
} else {
|
|
1223
|
+
for (const bucket of BUCKET_ORDER) {
|
|
1224
|
+
const bucketItems = items.filter((i) => i.bucket === bucket);
|
|
1225
|
+
if (bucket === "topics") continue;
|
|
1226
|
+
lines.push("", `## ${BUCKET_LABELS[bucket]}`, "");
|
|
1227
|
+
if (bucketItems.length === 0) {
|
|
1228
|
+
lines.push("_No items._");
|
|
1229
|
+
} else {
|
|
1230
|
+
for (const item of bucketItems) {
|
|
1231
|
+
const status = String(item.fm["status"] ?? "");
|
|
1232
|
+
lines.push(`| [[${item.id}]] | ${item.type} | ${status} | ${item.rawPath} |`);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
lines.push("");
|
|
1237
|
+
}
|
|
1238
|
+
return lines.join("\n");
|
|
1239
|
+
}
|
|
1240
|
+
function buildLog(items, timestamp) {
|
|
1241
|
+
if (items.length === 0) {
|
|
1242
|
+
return "# Wiki Event Log\n\n";
|
|
1243
|
+
}
|
|
1244
|
+
const entries = items.map(
|
|
1245
|
+
(item) => [
|
|
1246
|
+
`- timestamp: "${timestamp}"`,
|
|
1247
|
+
` actor: "cleargate wiki build"`,
|
|
1248
|
+
` action: "create"`,
|
|
1249
|
+
` target: "${item.id}"`,
|
|
1250
|
+
` path: "${item.rawPath}"`
|
|
1251
|
+
].join("\n")
|
|
1252
|
+
);
|
|
1253
|
+
return ["# Wiki Event Log", "", ...entries, ""].join("\n");
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// src/lib/manifest.ts
|
|
1257
|
+
import { readFile as readFile3, writeFile as writeFile2, rename, mkdir } from "fs/promises";
|
|
1258
|
+
import { existsSync as existsSync6, readFileSync as readFileSync10 } from "fs";
|
|
1259
|
+
import * as path11 from "path";
|
|
1260
|
+
|
|
1261
|
+
// src/lib/sha256.ts
|
|
1262
|
+
import { createHash } from "crypto";
|
|
1263
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
1264
|
+
function hashNormalized(content) {
|
|
1265
|
+
let text = Buffer.isBuffer(content) ? content.toString("utf-8") : content;
|
|
1266
|
+
if (text.startsWith("\uFEFF")) {
|
|
1267
|
+
text = text.slice(1);
|
|
1268
|
+
}
|
|
1269
|
+
text = text.replace(/\r\n/g, "\n");
|
|
1270
|
+
if (!text.endsWith("\n")) {
|
|
1271
|
+
text += "\n";
|
|
1272
|
+
}
|
|
1273
|
+
return createHash("sha256").update(text, "utf-8").digest("hex");
|
|
1274
|
+
}
|
|
1275
|
+
function shortHash(full) {
|
|
1276
|
+
return full.slice(0, 8);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// src/lib/manifest.ts
|
|
1280
|
+
function resolveDefaultPackageRoot() {
|
|
1281
|
+
const here = new URL(".", import.meta.url).pathname;
|
|
1282
|
+
const distCandidate = path11.join(here, "MANIFEST.json");
|
|
1283
|
+
if (existsSync6(distCandidate)) {
|
|
1284
|
+
return here;
|
|
1285
|
+
}
|
|
1286
|
+
const oneLevelUp = path11.join(here, "..", "MANIFEST.json");
|
|
1287
|
+
if (existsSync6(oneLevelUp)) {
|
|
1288
|
+
return path11.join(here, "..");
|
|
1289
|
+
}
|
|
1290
|
+
const devCandidate = path11.join(here, "..", "..", "..", "cleargate-planning", "MANIFEST.json");
|
|
1291
|
+
if (existsSync6(devCandidate)) {
|
|
1292
|
+
return path11.join(here, "..", "..", "..", "cleargate-planning");
|
|
1293
|
+
}
|
|
1294
|
+
return here;
|
|
1295
|
+
}
|
|
1296
|
+
function loadPackageManifest(opts) {
|
|
1297
|
+
const packageRoot = opts?.packageRoot ?? resolveDefaultPackageRoot();
|
|
1298
|
+
const manifestPath = path11.join(packageRoot, "MANIFEST.json");
|
|
1299
|
+
if (!existsSync6(manifestPath)) {
|
|
1300
|
+
throw new Error(
|
|
1301
|
+
`MANIFEST.json not found at ${manifestPath}; run 'npm run build' to generate it.`
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
let raw;
|
|
1305
|
+
try {
|
|
1306
|
+
raw = readFileSync10(manifestPath, "utf-8");
|
|
1307
|
+
} catch {
|
|
1308
|
+
throw new Error(
|
|
1309
|
+
`MANIFEST.json not found at ${manifestPath}; run 'npm run build' to generate it.`
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
return JSON.parse(raw);
|
|
1313
|
+
}
|
|
1314
|
+
async function loadInstallSnapshot(projectRoot) {
|
|
1315
|
+
const snapshotPath = path11.join(projectRoot, ".cleargate", ".install-manifest.json");
|
|
1316
|
+
try {
|
|
1317
|
+
const raw = await readFile3(snapshotPath, "utf-8");
|
|
1318
|
+
return JSON.parse(raw);
|
|
1319
|
+
} catch {
|
|
1320
|
+
return null;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
async function computeCurrentSha(file, projectRoot) {
|
|
1324
|
+
const filePath = path11.join(projectRoot, file.path);
|
|
1325
|
+
try {
|
|
1326
|
+
const raw = await readFile3(filePath);
|
|
1327
|
+
return hashNormalized(raw);
|
|
1328
|
+
} catch {
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
function classify(pkgSha, installSha, currentSha, tier) {
|
|
1333
|
+
if (tier === "user-artifact") {
|
|
1334
|
+
return "untracked";
|
|
1335
|
+
}
|
|
1336
|
+
if (currentSha === null) {
|
|
1337
|
+
return "untracked";
|
|
1338
|
+
}
|
|
1339
|
+
const installEqualsPackage = installSha === pkgSha;
|
|
1340
|
+
const currentEqualsInstall = currentSha === installSha;
|
|
1341
|
+
if (installEqualsPackage && currentEqualsInstall) {
|
|
1342
|
+
return "clean";
|
|
1343
|
+
}
|
|
1344
|
+
if (installEqualsPackage && !currentEqualsInstall) {
|
|
1345
|
+
return "user-modified";
|
|
1346
|
+
}
|
|
1347
|
+
if (!installEqualsPackage && currentEqualsInstall) {
|
|
1348
|
+
return "upstream-changed";
|
|
1349
|
+
}
|
|
1350
|
+
return "both-changed";
|
|
1351
|
+
}
|
|
1352
|
+
async function writeDriftState(projectRoot, state, opts) {
|
|
1353
|
+
const cleargatDir = path11.join(projectRoot, ".cleargate");
|
|
1354
|
+
const finalPath = path11.join(cleargatDir, ".drift-state.json");
|
|
1355
|
+
const tmpPath = `${finalPath}.tmp`;
|
|
1356
|
+
const lastRefreshed = opts?.lastRefreshed ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1357
|
+
const fileContent = { last_refreshed: lastRefreshed, drift: state };
|
|
1358
|
+
await mkdir(cleargatDir, { recursive: true });
|
|
1359
|
+
await writeFile2(tmpPath, JSON.stringify(fileContent, null, 2) + "\n", "utf-8");
|
|
1360
|
+
await rename(tmpPath, finalPath);
|
|
1361
|
+
}
|
|
1362
|
+
async function readDriftState(projectRoot) {
|
|
1363
|
+
const driftPath = path11.join(projectRoot, ".cleargate", ".drift-state.json");
|
|
1364
|
+
try {
|
|
1365
|
+
const raw = await readFile3(driftPath, "utf-8");
|
|
1366
|
+
const parsed = JSON.parse(raw);
|
|
1367
|
+
if (typeof parsed === "object" && parsed !== null && "last_refreshed" in parsed && "drift" in parsed) {
|
|
1368
|
+
return parsed;
|
|
1369
|
+
}
|
|
1370
|
+
return null;
|
|
1371
|
+
} catch {
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// src/lib/prompts.ts
|
|
1377
|
+
import * as readline from "readline";
|
|
1378
|
+
async function promptYesNo(question, defaultYes, opts) {
|
|
1379
|
+
const stdoutFn = opts?.stdout ?? ((s) => process.stdout.write(s));
|
|
1380
|
+
stdoutFn(question + "\n");
|
|
1381
|
+
const inputStream = opts?.stdin ?? process.stdin;
|
|
1382
|
+
return new Promise((resolve13) => {
|
|
1383
|
+
const rl = readline.createInterface({
|
|
1384
|
+
input: inputStream,
|
|
1385
|
+
output: void 0,
|
|
1386
|
+
// we handle output ourselves
|
|
1387
|
+
terminal: false
|
|
1388
|
+
});
|
|
1389
|
+
let answered = false;
|
|
1390
|
+
rl.once("line", (line) => {
|
|
1391
|
+
answered = true;
|
|
1392
|
+
rl.close();
|
|
1393
|
+
const trimmed = line.trim().toLowerCase();
|
|
1394
|
+
if (trimmed === "") {
|
|
1395
|
+
resolve13(defaultYes);
|
|
1396
|
+
} else if (trimmed === "y" || trimmed === "yes") {
|
|
1397
|
+
resolve13(true);
|
|
1398
|
+
} else {
|
|
1399
|
+
resolve13(false);
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
rl.once("close", () => {
|
|
1403
|
+
if (!answered) {
|
|
1404
|
+
resolve13(defaultYes);
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// src/commands/init.ts
|
|
1411
|
+
var HOOK_ADDITION = {
|
|
1412
|
+
hooks: {
|
|
1413
|
+
PostToolUse: [
|
|
1414
|
+
{
|
|
1415
|
+
matcher: "Edit|Write",
|
|
1416
|
+
hooks: [
|
|
1417
|
+
{
|
|
1418
|
+
type: "command",
|
|
1419
|
+
command: "${CLAUDE_PROJECT_DIR}/.claude/hooks/stamp-and-gate.sh"
|
|
1420
|
+
}
|
|
1421
|
+
]
|
|
1422
|
+
}
|
|
1423
|
+
]
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
function resolveDefaultPayloadDir() {
|
|
1427
|
+
const thisFile = fileURLToPath5(import.meta.url);
|
|
1428
|
+
const pkgRoot = path12.resolve(path12.dirname(thisFile), "..");
|
|
1429
|
+
return path12.join(pkgRoot, "templates", "cleargate-planning");
|
|
1430
|
+
}
|
|
1431
|
+
function countDeliveryItems(cwd) {
|
|
1432
|
+
const pendingSync = path12.join(cwd, ".cleargate", "delivery", "pending-sync");
|
|
1433
|
+
const archive = path12.join(cwd, ".cleargate", "delivery", "archive");
|
|
1434
|
+
let count = 0;
|
|
1435
|
+
for (const dir of [pendingSync, archive]) {
|
|
1436
|
+
if (!fs12.existsSync(dir)) continue;
|
|
1437
|
+
const entries = fs12.readdirSync(dir);
|
|
1438
|
+
for (const f of entries) {
|
|
1439
|
+
if (f.endsWith(".md") && f !== ".gitkeep") count++;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return count;
|
|
1443
|
+
}
|
|
1444
|
+
function writeAtomic(filePath, content) {
|
|
1445
|
+
const tmpPath = filePath + ".tmp." + Date.now();
|
|
1446
|
+
fs12.writeFileSync(tmpPath, content, "utf8");
|
|
1447
|
+
fs12.renameSync(tmpPath, filePath);
|
|
1448
|
+
}
|
|
1449
|
+
async function initHandler(opts = {}) {
|
|
1450
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1451
|
+
const force = opts.force ?? false;
|
|
1452
|
+
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
1453
|
+
const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
|
|
1454
|
+
const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
|
|
1455
|
+
const exit = opts.exit ?? ((c) => process.exit(c));
|
|
1456
|
+
const runWikiBuild = opts.runWikiBuild ?? wikiBuildHandler;
|
|
1457
|
+
const promptYesNoFn = opts.promptYesNo ?? promptYesNo;
|
|
1458
|
+
if (!fs12.existsSync(cwd)) {
|
|
1459
|
+
stderr(`[cleargate init] ERROR: target directory does not exist: ${cwd}
|
|
1460
|
+
`);
|
|
1461
|
+
exit(1);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const testWritePath = path12.join(cwd, `.cleargate-init-write-test-${Date.now()}`);
|
|
1465
|
+
try {
|
|
1466
|
+
fs12.writeFileSync(testWritePath, "");
|
|
1467
|
+
fs12.unlinkSync(testWritePath);
|
|
1468
|
+
} catch {
|
|
1469
|
+
stderr(`[cleargate init] ERROR: target directory is not writable: ${cwd}
|
|
1470
|
+
`);
|
|
1471
|
+
exit(1);
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
stdout(`[cleargate init] Target: ${cwd}
|
|
1475
|
+
`);
|
|
1476
|
+
const payloadDir = opts.payloadDir ?? resolveDefaultPayloadDir();
|
|
1477
|
+
if (!fs12.existsSync(payloadDir)) {
|
|
1478
|
+
stderr(`[cleargate init] ERROR: payload directory not found: ${payloadDir}
|
|
1479
|
+
`);
|
|
1480
|
+
stderr(`[cleargate init] Run \`npm run prebuild\` to copy the payload first.
|
|
1481
|
+
`);
|
|
1482
|
+
exit(1);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
const uninstalledMarkerPath = path12.join(cwd, ".cleargate", ".uninstalled");
|
|
1486
|
+
let uninstalledMarker = null;
|
|
1487
|
+
let userChoseRestore = false;
|
|
1488
|
+
if (fs12.existsSync(uninstalledMarkerPath)) {
|
|
1489
|
+
try {
|
|
1490
|
+
const raw = fs12.readFileSync(uninstalledMarkerPath, "utf8");
|
|
1491
|
+
uninstalledMarker = JSON.parse(raw);
|
|
1492
|
+
} catch {
|
|
1493
|
+
stderr(`[cleargate init] WARNING: .uninstalled marker is malformed; ignoring it.
|
|
1494
|
+
`);
|
|
1495
|
+
}
|
|
1496
|
+
if (uninstalledMarker !== null) {
|
|
1497
|
+
const { uninstalled_at, prior_version, preserved } = uninstalledMarker;
|
|
1498
|
+
const question = `[cleargate init] Detected previous ClearGate install (uninstalled ${uninstalled_at}, prior version ${prior_version}). Restore preserved items? [Y/n]`;
|
|
1499
|
+
userChoseRestore = await promptYesNoFn(question, true);
|
|
1500
|
+
if (userChoseRestore) {
|
|
1501
|
+
for (const preservedPath of preserved) {
|
|
1502
|
+
const absPreserved = path12.isAbsolute(preservedPath) ? preservedPath : path12.join(cwd, preservedPath);
|
|
1503
|
+
if (fs12.existsSync(absPreserved)) {
|
|
1504
|
+
stdout(`[cleargate init] [preserved] ${preservedPath}
|
|
1505
|
+
`);
|
|
1506
|
+
} else {
|
|
1507
|
+
stdout(`[cleargate init] [warn] preserved path missing on disk: ${preservedPath}
|
|
1508
|
+
`);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
} else {
|
|
1512
|
+
stdout(
|
|
1513
|
+
`[cleargate init] discarding preservation; preserved files untouched on disk
|
|
1514
|
+
`
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
const copyReport = copyPayload(payloadDir, cwd, { force });
|
|
1520
|
+
for (const action of copyReport.actions) {
|
|
1521
|
+
const verb = action.action === "created" ? "Created" : action.action === "overwritten" ? "Overwritten" : "Skipped (exists)";
|
|
1522
|
+
stdout(`[cleargate init] ${verb} ${action.relPath}
|
|
1523
|
+
`);
|
|
1524
|
+
}
|
|
1525
|
+
const settingsPath = path12.join(cwd, ".claude", "settings.json");
|
|
1526
|
+
let existingSettings = null;
|
|
1527
|
+
if (fs12.existsSync(settingsPath)) {
|
|
1528
|
+
try {
|
|
1529
|
+
existingSettings = JSON.parse(fs12.readFileSync(settingsPath, "utf8"));
|
|
1530
|
+
} catch {
|
|
1531
|
+
stderr(`[cleargate init] WARNING: could not parse ${settingsPath}; treating as empty.
|
|
1532
|
+
`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
const mergedSettings = mergeSettings(existingSettings, HOOK_ADDITION);
|
|
1536
|
+
fs12.mkdirSync(path12.dirname(settingsPath), { recursive: true });
|
|
1537
|
+
writeAtomic(settingsPath, JSON.stringify(mergedSettings, null, 2) + "\n");
|
|
1538
|
+
stdout(`[cleargate init] Updated .claude/settings.json: merged PostToolUse hook
|
|
1539
|
+
`);
|
|
1540
|
+
const claudeMdPath = path12.join(cwd, "CLAUDE.md");
|
|
1541
|
+
const claudeMdSrcPath = path12.join(payloadDir, "CLAUDE.md");
|
|
1542
|
+
let claudeMdBlock;
|
|
1543
|
+
try {
|
|
1544
|
+
const claudeMdSrc = fs12.readFileSync(claudeMdSrcPath, "utf8");
|
|
1545
|
+
claudeMdBlock = extractBlock(claudeMdSrc);
|
|
1546
|
+
} catch (e) {
|
|
1547
|
+
stderr(`[cleargate init] WARNING: could not read CLAUDE.md block from payload: ${String(e)}
|
|
1548
|
+
`);
|
|
1549
|
+
claudeMdBlock = "<!-- CLEARGATE:START -->\n<!-- CLEARGATE:END -->";
|
|
1550
|
+
}
|
|
1551
|
+
const existingClaudeMd = fs12.existsSync(claudeMdPath) ? fs12.readFileSync(claudeMdPath, "utf8") : null;
|
|
1552
|
+
const newClaudeMd = injectClaudeMd(existingClaudeMd, claudeMdBlock);
|
|
1553
|
+
writeAtomic(claudeMdPath, newClaudeMd);
|
|
1554
|
+
if (existingClaudeMd === null) {
|
|
1555
|
+
stdout(`[cleargate init] Created CLAUDE.md (with bounded block)
|
|
1556
|
+
`);
|
|
1557
|
+
} else if (existingClaudeMd !== newClaudeMd) {
|
|
1558
|
+
stdout(`[cleargate init] Updated CLAUDE.md (bounded block injected/replaced)
|
|
1559
|
+
`);
|
|
1560
|
+
} else {
|
|
1561
|
+
stdout(`[cleargate init] CLAUDE.md unchanged (block already up to date)
|
|
1562
|
+
`);
|
|
1563
|
+
}
|
|
1564
|
+
const itemCount = countDeliveryItems(cwd);
|
|
1565
|
+
if (itemCount > 0) {
|
|
1566
|
+
stdout(`[cleargate init] Bootstrap: running wiki build (${itemCount} items found)...
|
|
1567
|
+
`);
|
|
1568
|
+
await runWikiBuild({ cwd, now });
|
|
1569
|
+
stdout(`[cleargate init] Bootstrap: ran wiki build (${itemCount} items ingested)
|
|
1570
|
+
`);
|
|
1571
|
+
} else {
|
|
1572
|
+
stdout(`[cleargate init] Bootstrap: no items to ingest, skipping build
|
|
1573
|
+
`);
|
|
1574
|
+
}
|
|
1575
|
+
const cleargateDir = path12.join(cwd, ".cleargate");
|
|
1576
|
+
fs12.mkdirSync(cleargateDir, { recursive: true });
|
|
1577
|
+
const snapshotPath = path12.join(cleargateDir, ".install-manifest.json");
|
|
1578
|
+
try {
|
|
1579
|
+
const readManifest = opts.readInstallManifest ?? (() => loadPackageManifest({ packageRoot: payloadDir }));
|
|
1580
|
+
const pkgManifest = readManifest();
|
|
1581
|
+
const snapshot = {
|
|
1582
|
+
...pkgManifest,
|
|
1583
|
+
installed_at: now()
|
|
1584
|
+
};
|
|
1585
|
+
writeAtomic(snapshotPath, JSON.stringify(snapshot, null, 2) + "\n");
|
|
1586
|
+
stdout(`[cleargate init] Wrote install snapshot: .cleargate/.install-manifest.json
|
|
1587
|
+
`);
|
|
1588
|
+
} catch (e) {
|
|
1589
|
+
stderr(`[cleargate init] WARNING: could not write install snapshot: ${String(e)}
|
|
1590
|
+
`);
|
|
1591
|
+
}
|
|
1592
|
+
if (uninstalledMarker !== null && fs12.existsSync(uninstalledMarkerPath)) {
|
|
1593
|
+
try {
|
|
1594
|
+
fs12.unlinkSync(uninstalledMarkerPath);
|
|
1595
|
+
} catch (e) {
|
|
1596
|
+
stderr(`[cleargate init] WARNING: could not remove .uninstalled marker: ${String(e)}
|
|
1597
|
+
`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
stdout(
|
|
1601
|
+
`[cleargate init] Done. Read CLAUDE.md and .cleargate/knowledge/cleargate-protocol.md to learn the protocol.
|
|
1602
|
+
`
|
|
1603
|
+
);
|
|
1604
|
+
void now;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// src/commands/wiki-ingest.ts
|
|
1608
|
+
import * as fs13 from "fs";
|
|
1609
|
+
import * as path13 from "path";
|
|
1610
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1611
|
+
var EXCLUDED_SUFFIXES2 = [
|
|
1612
|
+
".cleargate/knowledge/",
|
|
1613
|
+
".cleargate/templates/",
|
|
1614
|
+
".cleargate/sprint-runs/",
|
|
1615
|
+
".cleargate/hook-log/",
|
|
1616
|
+
".cleargate/wiki/"
|
|
1617
|
+
];
|
|
1618
|
+
var BUCKET_ORDER2 = ["epics", "stories", "sprints", "proposals", "crs", "bugs", "topics"];
|
|
1619
|
+
var BUCKET_LABELS2 = {
|
|
1620
|
+
epics: "Epics",
|
|
1621
|
+
stories: "Stories",
|
|
1622
|
+
sprints: "Sprints",
|
|
1623
|
+
proposals: "Proposals",
|
|
1624
|
+
crs: "CRs",
|
|
1625
|
+
bugs: "Bugs",
|
|
1626
|
+
topics: "Topics"
|
|
1627
|
+
};
|
|
1628
|
+
async function wikiIngestHandler(opts) {
|
|
1629
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1630
|
+
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
1631
|
+
const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
|
|
1632
|
+
const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
|
|
1633
|
+
const exit = opts.exit ?? ((c) => process.exit(c));
|
|
1634
|
+
const gitRunner = opts.gitRunner;
|
|
1635
|
+
const rename4 = opts.rename ?? fs13.renameSync;
|
|
1636
|
+
const templateDir = opts.templateDir;
|
|
1637
|
+
const rawPath = opts.rawPath;
|
|
1638
|
+
const absRawPath = path13.isAbsolute(rawPath) ? rawPath : path13.resolve(cwd, rawPath);
|
|
1639
|
+
const relRawPath = path13.relative(cwd, absRawPath).replace(/\\/g, "/");
|
|
1640
|
+
const deliveryRoot = path13.join(cwd, ".cleargate", "delivery");
|
|
1641
|
+
const deliveryRootNorm = deliveryRoot.replace(/\\/g, "/");
|
|
1642
|
+
const absDeliveryRoot = deliveryRoot;
|
|
1643
|
+
const relToDelivery = path13.relative(absDeliveryRoot, absRawPath);
|
|
1644
|
+
if (relToDelivery.startsWith("..") || path13.isAbsolute(relToDelivery)) {
|
|
1645
|
+
stderr(`wiki ingest: ${rawPath} not under .cleargate/delivery/
|
|
1646
|
+
`);
|
|
1647
|
+
exit(2);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
void deliveryRootNorm;
|
|
1651
|
+
const isExcluded = EXCLUDED_SUFFIXES2.some((excl) => relRawPath.startsWith(excl));
|
|
1652
|
+
if (isExcluded) {
|
|
1653
|
+
stdout(`wiki ingest: ${rawPath} excluded (skip)
|
|
1654
|
+
`);
|
|
1655
|
+
exit(0);
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const filename = path13.basename(absRawPath);
|
|
1659
|
+
let bucketInfo;
|
|
1660
|
+
try {
|
|
1661
|
+
bucketInfo = deriveBucket(filename);
|
|
1662
|
+
} catch (e) {
|
|
1663
|
+
stderr(`wiki ingest: cannot determine bucket for ${rawPath}: ${e.message}
|
|
1664
|
+
`);
|
|
1665
|
+
exit(1);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
let repo;
|
|
1669
|
+
try {
|
|
1670
|
+
repo = deriveRepo(relRawPath);
|
|
1671
|
+
} catch (e) {
|
|
1672
|
+
stderr(`wiki ingest: cannot derive repo for ${rawPath}: ${e.message}
|
|
1673
|
+
`);
|
|
1674
|
+
exit(1);
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
const { type, id, bucket } = bucketInfo;
|
|
1678
|
+
const wikiRoot = path13.join(cwd, ".cleargate", "wiki");
|
|
1679
|
+
const pageDir = path13.join(wikiRoot, bucket);
|
|
1680
|
+
const pagePath = path13.join(pageDir, `${id}.md`);
|
|
1681
|
+
let rawContent;
|
|
1682
|
+
try {
|
|
1683
|
+
rawContent = fs13.readFileSync(absRawPath, "utf8");
|
|
1684
|
+
} catch (e) {
|
|
1685
|
+
stderr(`wiki ingest: cannot read ${rawPath}: ${e.message}
|
|
1686
|
+
`);
|
|
1687
|
+
exit(1);
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
let fm;
|
|
1691
|
+
let body;
|
|
1692
|
+
try {
|
|
1693
|
+
const parsed = parseFrontmatter(rawContent);
|
|
1694
|
+
fm = parsed.fm;
|
|
1695
|
+
body = parsed.body;
|
|
1696
|
+
} catch (e) {
|
|
1697
|
+
stderr(`wiki ingest: malformed frontmatter in ${rawPath}: ${e.message}
|
|
1698
|
+
`);
|
|
1699
|
+
exit(1);
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
const currentSha = getGitSha(absRawPath, gitRunner) ?? "";
|
|
1703
|
+
const pageExists = fs13.existsSync(pagePath);
|
|
1704
|
+
if (pageExists && currentSha !== "") {
|
|
1705
|
+
let isNoOp = false;
|
|
1706
|
+
try {
|
|
1707
|
+
const existingPageContent = fs13.readFileSync(pagePath, "utf8");
|
|
1708
|
+
const existingPage = parsePage(existingPageContent);
|
|
1709
|
+
if (existingPage.last_ingest_commit === currentSha) {
|
|
1710
|
+
const contentUnchanged = checkContentUnchanged(absRawPath, currentSha, relRawPath, gitRunner);
|
|
1711
|
+
if (contentUnchanged) {
|
|
1712
|
+
isNoOp = true;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
} catch {
|
|
1716
|
+
}
|
|
1717
|
+
if (isNoOp) {
|
|
1718
|
+
stdout(`wiki ingest: ${id} unchanged (no-op)
|
|
1719
|
+
`);
|
|
1720
|
+
exit(0);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
const action = pageExists ? "update" : "create";
|
|
1725
|
+
const parent = buildParentRef2(fm);
|
|
1726
|
+
const children = buildChildrenRefs2(fm);
|
|
1727
|
+
const timestamp = now();
|
|
1728
|
+
const wikiPage = {
|
|
1729
|
+
type,
|
|
1730
|
+
id,
|
|
1731
|
+
parent,
|
|
1732
|
+
children,
|
|
1733
|
+
status: String(fm["status"] ?? ""),
|
|
1734
|
+
remote_id: String(fm["remote_id"] ?? ""),
|
|
1735
|
+
raw_path: relRawPath,
|
|
1736
|
+
last_ingest: timestamp,
|
|
1737
|
+
last_ingest_commit: currentSha,
|
|
1738
|
+
repo
|
|
1739
|
+
};
|
|
1740
|
+
const pageBody = buildPageBody2({ id, fm, body });
|
|
1741
|
+
const pageContent = serializePage(wikiPage, pageBody);
|
|
1742
|
+
fs13.mkdirSync(pageDir, { recursive: true });
|
|
1743
|
+
fs13.writeFileSync(pagePath, pageContent, "utf8");
|
|
1744
|
+
appendLogEntry(wikiRoot, { timestamp, action, id, relRawPath });
|
|
1745
|
+
updateIndex(wikiRoot, { id, type, status: wikiPage.status, relRawPath, rename: rename4 });
|
|
1746
|
+
recompileSynthesis(wikiRoot, cwd, templateDir);
|
|
1747
|
+
stdout(`wiki ingest: ${action} ${bucket}/${id}.md
|
|
1748
|
+
`);
|
|
1749
|
+
}
|
|
1750
|
+
function checkContentUnchanged(absRawPath, sha, relRawPath, gitRunner) {
|
|
1751
|
+
try {
|
|
1752
|
+
const run = gitRunner ?? defaultGitRunner;
|
|
1753
|
+
const gitContent = run("git", ["show", `${sha}:${relRawPath}`]);
|
|
1754
|
+
if (!gitContent && gitContent !== "") return false;
|
|
1755
|
+
const currentContent = fs13.readFileSync(absRawPath, "utf8");
|
|
1756
|
+
return gitContent === currentContent;
|
|
1757
|
+
} catch {
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
function defaultGitRunner(cmd, args) {
|
|
1762
|
+
const result = spawnSync2(cmd, args, { encoding: "utf8" });
|
|
1763
|
+
if (result.status !== 0) return "\0__NONZERO__";
|
|
1764
|
+
return result.stdout ?? "";
|
|
1765
|
+
}
|
|
1766
|
+
function buildParentRef2(fm) {
|
|
1767
|
+
const raw = fm["parent_epic_ref"] ?? fm["parent"] ?? "";
|
|
1768
|
+
const s = String(raw);
|
|
1769
|
+
if (!s) return "";
|
|
1770
|
+
if (s.startsWith("[[") && s.endsWith("]]")) return s;
|
|
1771
|
+
return `[[${s}]]`;
|
|
1772
|
+
}
|
|
1773
|
+
function buildChildrenRefs2(fm) {
|
|
1774
|
+
const raw = fm["children"];
|
|
1775
|
+
if (!raw) return [];
|
|
1776
|
+
const arr = Array.isArray(raw) ? raw : [raw];
|
|
1777
|
+
return arr.map((c) => {
|
|
1778
|
+
const s = String(c);
|
|
1779
|
+
if (s.startsWith("[[") && s.endsWith("]]")) return s;
|
|
1780
|
+
return `[[${s}]]`;
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
function buildPageBody2(item) {
|
|
1784
|
+
const title = String(item.fm["title"] ?? item.id);
|
|
1785
|
+
const summary = String(
|
|
1786
|
+
item.fm["description"] ?? item.body.split("\n")[0] ?? "No summary available."
|
|
1787
|
+
).slice(0, 200);
|
|
1788
|
+
const parent = buildParentRef2(item.fm);
|
|
1789
|
+
const children = buildChildrenRefs2(item.fm);
|
|
1790
|
+
const blastParts = [];
|
|
1791
|
+
if (parent) blastParts.push(parent);
|
|
1792
|
+
for (const child of children) blastParts.push(child);
|
|
1793
|
+
const blastLine = blastParts.length > 0 ? blastParts.join(", ") : "None.";
|
|
1794
|
+
return [
|
|
1795
|
+
`# ${item.id}: ${title}`,
|
|
1796
|
+
"",
|
|
1797
|
+
summary,
|
|
1798
|
+
"",
|
|
1799
|
+
"## Blast radius",
|
|
1800
|
+
`Affects: ${blastLine}`,
|
|
1801
|
+
"",
|
|
1802
|
+
"## Open questions",
|
|
1803
|
+
"None.",
|
|
1804
|
+
""
|
|
1805
|
+
].join("\n");
|
|
1806
|
+
}
|
|
1807
|
+
function appendLogEntry(wikiRoot, entry) {
|
|
1808
|
+
const logPath = path13.join(wikiRoot, "log.md");
|
|
1809
|
+
const logEntry = [
|
|
1810
|
+
`- timestamp: "${entry.timestamp}"`,
|
|
1811
|
+
` actor: "cleargate wiki ingest"`,
|
|
1812
|
+
` action: "${entry.action}"`,
|
|
1813
|
+
` target: "${entry.id}"`,
|
|
1814
|
+
` path: "${entry.relRawPath}"`
|
|
1815
|
+
].join("\n");
|
|
1816
|
+
if (fs13.existsSync(logPath)) {
|
|
1817
|
+
const existing = fs13.readFileSync(logPath, "utf8");
|
|
1818
|
+
const newContent = existing.trimEnd() + "\n" + logEntry + "\n";
|
|
1819
|
+
fs13.writeFileSync(logPath, newContent, "utf8");
|
|
1820
|
+
} else {
|
|
1821
|
+
fs13.mkdirSync(wikiRoot, { recursive: true });
|
|
1822
|
+
fs13.writeFileSync(logPath, `# Wiki Event Log
|
|
1823
|
+
|
|
1824
|
+
${logEntry}
|
|
1825
|
+
`, "utf8");
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
function updateIndex(wikiRoot, opts) {
|
|
1829
|
+
const indexPath = path13.join(wikiRoot, "index.md");
|
|
1830
|
+
const tmpPath = `${indexPath}.tmp`;
|
|
1831
|
+
const newRow = `| [[${opts.id}]] | ${opts.type} | ${opts.status} | ${opts.relRawPath} |`;
|
|
1832
|
+
let content;
|
|
1833
|
+
if (fs13.existsSync(indexPath)) {
|
|
1834
|
+
content = fs13.readFileSync(indexPath, "utf8");
|
|
1835
|
+
const idPattern = `[[${opts.id}]]`;
|
|
1836
|
+
const lines = content.split("\n");
|
|
1837
|
+
let replaced = false;
|
|
1838
|
+
const newLines = lines.map((line) => {
|
|
1839
|
+
if (line.includes(idPattern) && line.startsWith("|")) {
|
|
1840
|
+
replaced = true;
|
|
1841
|
+
return newRow;
|
|
1842
|
+
}
|
|
1843
|
+
return line;
|
|
1844
|
+
});
|
|
1845
|
+
if (replaced) {
|
|
1846
|
+
content = newLines.join("\n");
|
|
1847
|
+
} else {
|
|
1848
|
+
content = insertIntoSection(content, opts.id, newRow);
|
|
1849
|
+
}
|
|
1850
|
+
} else {
|
|
1851
|
+
content = buildMinimalIndex(opts.id, opts.type, opts.status, opts.relRawPath);
|
|
1852
|
+
}
|
|
1853
|
+
fs13.writeFileSync(tmpPath, content, "utf8");
|
|
1854
|
+
opts.rename(tmpPath, indexPath);
|
|
1855
|
+
}
|
|
1856
|
+
function insertIntoSection(content, id, newRow) {
|
|
1857
|
+
const bucket = getBucketFromId(id);
|
|
1858
|
+
const label = BUCKET_LABELS2[bucket] ?? bucket;
|
|
1859
|
+
const sectionHeader = `## ${label}`;
|
|
1860
|
+
const lines = content.split("\n");
|
|
1861
|
+
let sectionStart = -1;
|
|
1862
|
+
let nextSectionStart = -1;
|
|
1863
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1864
|
+
if (lines[i] === sectionHeader) {
|
|
1865
|
+
sectionStart = i;
|
|
1866
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1867
|
+
if (lines[j].startsWith("## ")) {
|
|
1868
|
+
nextSectionStart = j;
|
|
1869
|
+
break;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
break;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
if (sectionStart === -1) {
|
|
1876
|
+
const sectionContent = [
|
|
1877
|
+
"",
|
|
1878
|
+
sectionHeader,
|
|
1879
|
+
"",
|
|
1880
|
+
newRow,
|
|
1881
|
+
""
|
|
1882
|
+
].join("\n");
|
|
1883
|
+
return content.trimEnd() + sectionContent;
|
|
1884
|
+
}
|
|
1885
|
+
const sectionEnd = nextSectionStart === -1 ? lines.length : nextSectionStart;
|
|
1886
|
+
const sectionLines = lines.slice(sectionStart + 1, sectionEnd);
|
|
1887
|
+
let insertAt = -1;
|
|
1888
|
+
for (let i = 0; i < sectionLines.length; i++) {
|
|
1889
|
+
const line = sectionLines[i];
|
|
1890
|
+
if (line.startsWith("|") && !line.startsWith("|---|")) {
|
|
1891
|
+
const match = /\|\s*\[\[([^\]]+)\]\]/.exec(line);
|
|
1892
|
+
if (match) {
|
|
1893
|
+
const rowId = match[1];
|
|
1894
|
+
if (id.localeCompare(rowId) <= 0) {
|
|
1895
|
+
insertAt = sectionStart + 1 + i;
|
|
1896
|
+
break;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
if (insertAt === -1) {
|
|
1902
|
+
let lastRowIdx = sectionStart + 1;
|
|
1903
|
+
for (let i = sectionStart + 1; i < sectionEnd; i++) {
|
|
1904
|
+
if (lines[i].startsWith("|")) {
|
|
1905
|
+
lastRowIdx = i + 1;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
lines.splice(lastRowIdx, 0, newRow);
|
|
1909
|
+
} else {
|
|
1910
|
+
lines.splice(insertAt, 0, newRow);
|
|
1911
|
+
}
|
|
1912
|
+
return lines.join("\n");
|
|
1913
|
+
}
|
|
1914
|
+
function getBucketFromId(id) {
|
|
1915
|
+
if (id.startsWith("EPIC-")) return "epics";
|
|
1916
|
+
if (id.startsWith("STORY-")) return "stories";
|
|
1917
|
+
if (id.startsWith("SPRINT-")) return "sprints";
|
|
1918
|
+
if (id.startsWith("PROPOSAL-")) return "proposals";
|
|
1919
|
+
if (id.startsWith("CR-")) return "crs";
|
|
1920
|
+
if (id.startsWith("BUG-")) return "bugs";
|
|
1921
|
+
return "topics";
|
|
1922
|
+
}
|
|
1923
|
+
function buildMinimalIndex(id, type, status, relRawPath) {
|
|
1924
|
+
const bucket = getBucketFromId(id);
|
|
1925
|
+
const label = BUCKET_LABELS2[bucket] ?? bucket;
|
|
1926
|
+
const lines = [
|
|
1927
|
+
"# Wiki Index",
|
|
1928
|
+
"",
|
|
1929
|
+
"> Auto-generated by `cleargate wiki build`. Do not edit manually.",
|
|
1930
|
+
"",
|
|
1931
|
+
"| ID | Type | Status | Raw Path |",
|
|
1932
|
+
"|---|---|---|---|"
|
|
1933
|
+
];
|
|
1934
|
+
for (const b of BUCKET_ORDER2) {
|
|
1935
|
+
if (b === "topics") continue;
|
|
1936
|
+
lines.push("", `## ${BUCKET_LABELS2[b]}`, "");
|
|
1937
|
+
if (b === bucket) {
|
|
1938
|
+
lines.push(`| [[${id}]] | ${type} | ${status} | ${relRawPath} |`);
|
|
1939
|
+
} else {
|
|
1940
|
+
lines.push("_No items._");
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
lines.push("");
|
|
1944
|
+
void label;
|
|
1945
|
+
return lines.join("\n");
|
|
1946
|
+
}
|
|
1947
|
+
function recompileSynthesis(wikiRoot, cwd, templateDir) {
|
|
1948
|
+
const deliveryRoot = path13.join(cwd, ".cleargate", "delivery");
|
|
1949
|
+
let items = [];
|
|
1950
|
+
if (fs13.existsSync(deliveryRoot)) {
|
|
1951
|
+
try {
|
|
1952
|
+
items = scanRawItems(deliveryRoot, cwd);
|
|
1953
|
+
} catch {
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
fs13.writeFileSync(path13.join(wikiRoot, "active-sprint.md"), compile(items, templateDir), "utf8");
|
|
1957
|
+
fs13.writeFileSync(path13.join(wikiRoot, "open-gates.md"), compile2(items, templateDir), "utf8");
|
|
1958
|
+
fs13.writeFileSync(path13.join(wikiRoot, "product-state.md"), compile3(items, templateDir), "utf8");
|
|
1959
|
+
fs13.writeFileSync(path13.join(wikiRoot, "roadmap.md"), compile4(items, templateDir), "utf8");
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// src/commands/wiki-lint.ts
|
|
1963
|
+
import * as path16 from "path";
|
|
1964
|
+
|
|
1965
|
+
// src/wiki/load-wiki.ts
|
|
1966
|
+
import * as fs14 from "fs";
|
|
1967
|
+
import * as path14 from "path";
|
|
1968
|
+
var BUCKET_DIRS = ["epics", "stories", "sprints", "proposals", "crs", "bugs", "topics"];
|
|
1969
|
+
function loadWikiPages(wikiRoot) {
|
|
1970
|
+
const results = [];
|
|
1971
|
+
for (const bucket of BUCKET_DIRS) {
|
|
1972
|
+
const dir = path14.join(wikiRoot, bucket);
|
|
1973
|
+
if (!fs14.existsSync(dir)) continue;
|
|
1974
|
+
const entries = fs14.readdirSync(dir, { encoding: "utf8" });
|
|
1975
|
+
for (const filename of entries) {
|
|
1976
|
+
if (!filename.endsWith(".md")) continue;
|
|
1977
|
+
const absPath = path14.join(dir, filename);
|
|
1978
|
+
const stat = fs14.statSync(absPath);
|
|
1979
|
+
if (!stat.isFile()) continue;
|
|
1980
|
+
const raw = fs14.readFileSync(absPath, "utf8");
|
|
1981
|
+
let fm;
|
|
1982
|
+
let body;
|
|
1983
|
+
try {
|
|
1984
|
+
const parsed = parseFrontmatter(raw);
|
|
1985
|
+
fm = parsed.fm;
|
|
1986
|
+
body = parsed.body;
|
|
1987
|
+
} catch {
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
const page = {
|
|
1991
|
+
type: fm["type"] ?? "epic",
|
|
1992
|
+
id: String(fm["id"] ?? ""),
|
|
1993
|
+
parent: String(fm["parent"] ?? ""),
|
|
1994
|
+
children: Array.isArray(fm["children"]) ? fm["children"].map(String) : [],
|
|
1995
|
+
status: String(fm["status"] ?? ""),
|
|
1996
|
+
remote_id: String(fm["remote_id"] ?? ""),
|
|
1997
|
+
raw_path: String(fm["raw_path"] ?? ""),
|
|
1998
|
+
last_ingest: String(fm["last_ingest"] ?? ""),
|
|
1999
|
+
last_ingest_commit: String(fm["last_ingest_commit"] ?? ""),
|
|
2000
|
+
repo: fm["repo"] ?? "planning"
|
|
2001
|
+
};
|
|
2002
|
+
results.push({ absPath, page, body });
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
return results;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// src/wiki/lint-checks.ts
|
|
2009
|
+
import * as fs15 from "fs";
|
|
2010
|
+
import * as path15 from "path";
|
|
2011
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2012
|
+
import yaml3 from "js-yaml";
|
|
2013
|
+
|
|
2014
|
+
// src/lib/work-item-type.ts
|
|
2015
|
+
var FM_KEY_MAP = [
|
|
2016
|
+
{ key: "story_id", type: "story" },
|
|
2017
|
+
{ key: "epic_id", type: "epic" },
|
|
2018
|
+
{ key: "proposal_id", type: "proposal" },
|
|
2019
|
+
{ key: "cr_id", type: "cr" },
|
|
2020
|
+
{ key: "bug_id", type: "bug" }
|
|
2021
|
+
];
|
|
2022
|
+
var PREFIX_MAP2 = [
|
|
2023
|
+
{ prefix: "STORY-", type: "story" },
|
|
2024
|
+
{ prefix: "EPIC-", type: "epic" },
|
|
2025
|
+
{ prefix: "PROPOSAL-", type: "proposal" },
|
|
2026
|
+
{ prefix: "CR-", type: "cr" },
|
|
2027
|
+
{ prefix: "BUG-", type: "bug" }
|
|
2028
|
+
];
|
|
2029
|
+
function detectWorkItemTypeFromFm(fm) {
|
|
2030
|
+
for (const { key, type } of FM_KEY_MAP) {
|
|
2031
|
+
if (fm[key] !== void 0 && fm[key] !== null && fm[key] !== "") {
|
|
2032
|
+
return type;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return null;
|
|
2036
|
+
}
|
|
2037
|
+
function detectWorkItemType(idOrPath) {
|
|
2038
|
+
const upper = idOrPath.toUpperCase();
|
|
2039
|
+
const basename9 = upper.split("/").pop() ?? upper;
|
|
2040
|
+
for (const { prefix, type } of PREFIX_MAP2) {
|
|
2041
|
+
if (basename9.includes(prefix)) {
|
|
2042
|
+
return type;
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
return null;
|
|
2046
|
+
}
|
|
2047
|
+
var WORK_ITEM_TRANSITIONS = {
|
|
2048
|
+
proposal: ["ready-for-decomposition"],
|
|
2049
|
+
epic: ["ready-for-decomposition", "ready-for-coding"],
|
|
2050
|
+
story: ["ready-for-execution"],
|
|
2051
|
+
cr: ["ready-to-apply"],
|
|
2052
|
+
bug: ["ready-for-fix"]
|
|
2053
|
+
};
|
|
2054
|
+
|
|
2055
|
+
// src/wiki/lint-checks.ts
|
|
2056
|
+
var EXCLUDED_DIRS = [
|
|
2057
|
+
".cleargate/knowledge/",
|
|
2058
|
+
".cleargate/templates/",
|
|
2059
|
+
".cleargate/sprint-runs/",
|
|
2060
|
+
".cleargate/hook-log/",
|
|
2061
|
+
".cleargate/wiki/"
|
|
2062
|
+
];
|
|
2063
|
+
var MAX_BUCKET_ENTRIES = 50;
|
|
2064
|
+
function checkOrphan(page, repoRoot) {
|
|
2065
|
+
const rawPath = page.page.raw_path;
|
|
2066
|
+
if (!rawPath) return null;
|
|
2067
|
+
const isExcluded = EXCLUDED_DIRS.some((excl) => rawPath.startsWith(excl));
|
|
2068
|
+
if (isExcluded) return null;
|
|
2069
|
+
const absRaw = path15.join(repoRoot, rawPath);
|
|
2070
|
+
if (!fs15.existsSync(absRaw)) {
|
|
2071
|
+
const relPage = path15.relative(path15.join(repoRoot, ".cleargate", "wiki"), page.absPath).replace(/\\/g, "/");
|
|
2072
|
+
return {
|
|
2073
|
+
category: "orphan",
|
|
2074
|
+
line: `orphan: ${relPage} -> missing ${rawPath} (raw missing)`
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
return null;
|
|
2078
|
+
}
|
|
2079
|
+
function checkRepoMismatch(page, repoRoot) {
|
|
2080
|
+
const rawPath = page.page.raw_path;
|
|
2081
|
+
if (!rawPath) return null;
|
|
2082
|
+
let derivedRepo;
|
|
2083
|
+
try {
|
|
2084
|
+
derivedRepo = deriveRepo(rawPath);
|
|
2085
|
+
} catch {
|
|
2086
|
+
return null;
|
|
2087
|
+
}
|
|
2088
|
+
if (page.page.repo !== derivedRepo) {
|
|
2089
|
+
const relPage = path15.relative(path15.join(repoRoot, ".cleargate", "wiki"), page.absPath).replace(/\\/g, "/");
|
|
2090
|
+
return {
|
|
2091
|
+
category: "repo-mismatch",
|
|
2092
|
+
line: `repo-mismatch: ${relPage} declares repo:${page.page.repo} but raw_path implies repo:${derivedRepo}`
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
return null;
|
|
2096
|
+
}
|
|
2097
|
+
function checkStaleCommit(page, repoRoot, gitRunner) {
|
|
2098
|
+
const rawPath = page.page.raw_path;
|
|
2099
|
+
if (!rawPath) return null;
|
|
2100
|
+
const storedSha = page.page.last_ingest_commit;
|
|
2101
|
+
if (!storedSha) return null;
|
|
2102
|
+
let currentSha;
|
|
2103
|
+
if (gitRunner) {
|
|
2104
|
+
currentSha = gitRunner("git", ["log", "-1", "--format=%H", "--", rawPath]).trim();
|
|
2105
|
+
} else {
|
|
2106
|
+
const result = spawnSync3("git", ["log", "-1", "--format=%H", "--", rawPath], {
|
|
2107
|
+
encoding: "utf8",
|
|
2108
|
+
cwd: repoRoot
|
|
2109
|
+
});
|
|
2110
|
+
currentSha = (result.stdout ?? "").trim();
|
|
2111
|
+
}
|
|
2112
|
+
if (!currentSha) return null;
|
|
2113
|
+
if (storedSha !== currentSha) {
|
|
2114
|
+
const relPage = path15.relative(path15.join(repoRoot, ".cleargate", "wiki"), page.absPath).replace(/\\/g, "/");
|
|
2115
|
+
return {
|
|
2116
|
+
category: "stale-commit",
|
|
2117
|
+
line: `stale-commit: ${relPage} at ${storedSha}, current ${currentSha}`
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
return null;
|
|
2121
|
+
}
|
|
2122
|
+
function checkMissingIngest(page, repoRoot) {
|
|
2123
|
+
const rawPath = page.page.raw_path;
|
|
2124
|
+
if (!rawPath) return null;
|
|
2125
|
+
const absRaw = path15.join(repoRoot, rawPath);
|
|
2126
|
+
if (!fs15.existsSync(absRaw)) return null;
|
|
2127
|
+
const rawStat = fs15.statSync(absRaw);
|
|
2128
|
+
const pageStat = fs15.statSync(page.absPath);
|
|
2129
|
+
const rawMtimeMs = rawStat.mtimeMs;
|
|
2130
|
+
const pageMtimeMs = pageStat.mtimeMs;
|
|
2131
|
+
if (rawMtimeMs - pageMtimeMs > 2e3) {
|
|
2132
|
+
const relPage = path15.relative(path15.join(repoRoot, ".cleargate", "wiki"), page.absPath).replace(/\\/g, "/");
|
|
2133
|
+
const rawMtime = rawStat.mtime.toISOString();
|
|
2134
|
+
const pageMtime = pageStat.mtime.toISOString();
|
|
2135
|
+
return {
|
|
2136
|
+
category: "missing-ingest",
|
|
2137
|
+
line: `missing-ingest: ${rawPath} newer than ${relPage} (raw mtime: ${rawMtime}, page mtime: ${pageMtime})`
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
return null;
|
|
2141
|
+
}
|
|
2142
|
+
function checkBrokenBacklinks(pages, repoRoot) {
|
|
2143
|
+
const wikiRoot = path15.join(repoRoot, ".cleargate", "wiki");
|
|
2144
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2145
|
+
for (const p of pages) {
|
|
2146
|
+
if (p.page.id) byId.set(p.page.id, p);
|
|
2147
|
+
}
|
|
2148
|
+
const findings = [];
|
|
2149
|
+
for (const childPage of pages) {
|
|
2150
|
+
const parentRef = childPage.page.parent;
|
|
2151
|
+
if (!parentRef) continue;
|
|
2152
|
+
const match = parentRef.match(/\[\[(.+?)\]\]/);
|
|
2153
|
+
if (!match) continue;
|
|
2154
|
+
const parentId = match[1];
|
|
2155
|
+
const parentPage = byId.get(parentId);
|
|
2156
|
+
if (!parentPage) {
|
|
2157
|
+
const relChild = path15.relative(wikiRoot, childPage.absPath).replace(/\\/g, "/");
|
|
2158
|
+
findings.push({
|
|
2159
|
+
category: "broken-backlink",
|
|
2160
|
+
line: `broken-backlink: ${relChild} -> ${parentId} (parent missing child entry)`
|
|
2161
|
+
});
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
const childId = childPage.page.id;
|
|
2165
|
+
const childRef = `[[${childId}]]`;
|
|
2166
|
+
const parentHasChild = parentPage.page.children.some(
|
|
2167
|
+
(c) => c === childRef || c === childId
|
|
2168
|
+
);
|
|
2169
|
+
if (!parentHasChild) {
|
|
2170
|
+
const relChild = path15.relative(wikiRoot, childPage.absPath).replace(/\\/g, "/");
|
|
2171
|
+
findings.push({
|
|
2172
|
+
category: "broken-backlink",
|
|
2173
|
+
line: `broken-backlink: ${relChild} -> ${parentId} (parent missing child entry)`
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
return findings;
|
|
2178
|
+
}
|
|
2179
|
+
function checkInvalidatedCitations(pages, repoRoot) {
|
|
2180
|
+
const wikiRoot = path15.join(repoRoot, ".cleargate", "wiki");
|
|
2181
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2182
|
+
for (const p of pages) {
|
|
2183
|
+
if (p.page.id) byId.set(p.page.id, p);
|
|
2184
|
+
}
|
|
2185
|
+
const findings = [];
|
|
2186
|
+
const topicPages = pages.filter((p) => p.page.type === "topic");
|
|
2187
|
+
for (const topicPage of topicPages) {
|
|
2188
|
+
const relTopic = path15.relative(wikiRoot, topicPage.absPath).replace(/\\/g, "/");
|
|
2189
|
+
let citesList = [];
|
|
2190
|
+
try {
|
|
2191
|
+
const raw = fs15.readFileSync(topicPage.absPath, "utf8");
|
|
2192
|
+
const { fm } = parseFrontmatter(raw);
|
|
2193
|
+
const rawCites = fm["cites"];
|
|
2194
|
+
if (Array.isArray(rawCites)) {
|
|
2195
|
+
citesList = rawCites.map(String);
|
|
2196
|
+
}
|
|
2197
|
+
} catch {
|
|
2198
|
+
continue;
|
|
2199
|
+
}
|
|
2200
|
+
for (const cite of citesList) {
|
|
2201
|
+
const match = cite.match(/\[\[(.+?)\]\]/);
|
|
2202
|
+
const id = match ? match[1] : cite;
|
|
2203
|
+
const citedPage = byId.get(id);
|
|
2204
|
+
if (!citedPage) {
|
|
2205
|
+
findings.push({
|
|
2206
|
+
category: "invalidated-citation",
|
|
2207
|
+
line: `invalidated-citation: ${relTopic} cites [[${id}]] (missing)`
|
|
2208
|
+
});
|
|
2209
|
+
continue;
|
|
2210
|
+
}
|
|
2211
|
+
const status = citedPage.page.status;
|
|
2212
|
+
if (status === "cancelled" || status.toLowerCase().includes("cancelled")) {
|
|
2213
|
+
findings.push({
|
|
2214
|
+
category: "invalidated-citation",
|
|
2215
|
+
line: `invalidated-citation: ${relTopic} cites [[${id}]] (cancelled)`
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
return findings;
|
|
2221
|
+
}
|
|
2222
|
+
function checkExcludedPathIngested(page, repoRoot) {
|
|
2223
|
+
const rawPath = page.page.raw_path;
|
|
2224
|
+
if (!rawPath) return null;
|
|
2225
|
+
const isExcluded = EXCLUDED_DIRS.some((excl) => rawPath.startsWith(excl));
|
|
2226
|
+
if (isExcluded) {
|
|
2227
|
+
const relPage = path15.relative(path15.join(repoRoot, ".cleargate", "wiki"), page.absPath).replace(/\\/g, "/");
|
|
2228
|
+
return {
|
|
2229
|
+
category: "excluded-path-ingested",
|
|
2230
|
+
line: `excluded-path-ingested: ${relPage} (raw_path ${rawPath} is under an excluded directory)`
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
return null;
|
|
2234
|
+
}
|
|
2235
|
+
function checkPaginationNeeded(pages) {
|
|
2236
|
+
const bucketCounts = /* @__PURE__ */ new Map();
|
|
2237
|
+
for (const p of pages) {
|
|
2238
|
+
const bucket = path15.basename(path15.dirname(p.absPath));
|
|
2239
|
+
bucketCounts.set(bucket, (bucketCounts.get(bucket) ?? 0) + 1);
|
|
2240
|
+
}
|
|
2241
|
+
const findings = [];
|
|
2242
|
+
for (const [bucket, count] of bucketCounts) {
|
|
2243
|
+
if (count > MAX_BUCKET_ENTRIES) {
|
|
2244
|
+
findings.push({
|
|
2245
|
+
category: "pagination-needed",
|
|
2246
|
+
line: `pagination-needed: ${bucket} (${count} entries, max ${MAX_BUCKET_ENTRIES} per bucket)`
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
return findings;
|
|
2251
|
+
}
|
|
2252
|
+
var ENFORCING_TYPES = /* @__PURE__ */ new Set(["epic", "story", "cr", "bug"]);
|
|
2253
|
+
var READY_STATUSES = /* @__PURE__ */ new Set(["Ready", "Active"]);
|
|
2254
|
+
function parseCachedGateResult(raw) {
|
|
2255
|
+
if (!raw || raw === null) return null;
|
|
2256
|
+
if (typeof raw === "string") {
|
|
2257
|
+
if (!raw.startsWith("{")) return null;
|
|
2258
|
+
try {
|
|
2259
|
+
const parsed = yaml3.load(raw);
|
|
2260
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
2261
|
+
const p = parsed;
|
|
2262
|
+
return { pass: p["pass"], failing_criteria: p["failing_criteria"], last_gate_check: p["last_gate_check"] };
|
|
2263
|
+
} catch {
|
|
2264
|
+
return null;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
if (typeof raw === "object" && !Array.isArray(raw)) {
|
|
2268
|
+
const p = raw;
|
|
2269
|
+
return { pass: p["pass"], failing_criteria: p["failing_criteria"], last_gate_check: p["last_gate_check"] };
|
|
2270
|
+
}
|
|
2271
|
+
return null;
|
|
2272
|
+
}
|
|
2273
|
+
function checkGateFailure(page, repoRoot) {
|
|
2274
|
+
const rawPath = page.page.raw_path;
|
|
2275
|
+
if (!rawPath) return null;
|
|
2276
|
+
const absRaw = path15.join(repoRoot, rawPath);
|
|
2277
|
+
if (!fs15.existsSync(absRaw)) return null;
|
|
2278
|
+
let rawFm;
|
|
2279
|
+
try {
|
|
2280
|
+
const raw = fs15.readFileSync(absRaw, "utf8");
|
|
2281
|
+
const { fm } = parseFrontmatter(raw);
|
|
2282
|
+
rawFm = fm;
|
|
2283
|
+
} catch {
|
|
2284
|
+
return null;
|
|
2285
|
+
}
|
|
2286
|
+
const cgr = parseCachedGateResult(rawFm["cached_gate_result"]);
|
|
2287
|
+
if (!cgr || cgr.pass !== false) return null;
|
|
2288
|
+
const wiType = detectWorkItemTypeFromFm(rawFm);
|
|
2289
|
+
if (!wiType || !ENFORCING_TYPES.has(wiType)) return null;
|
|
2290
|
+
const status = String(rawFm["status"] ?? "");
|
|
2291
|
+
const ambiguity = String(rawFm["ambiguity"] ?? "");
|
|
2292
|
+
const isReadyCandidate = READY_STATUSES.has(status) || ambiguity === "\u{1F7E2} Low";
|
|
2293
|
+
if (!isReadyCandidate) return null;
|
|
2294
|
+
const failingCriteria = cgr.failing_criteria;
|
|
2295
|
+
const criteriaIds = [];
|
|
2296
|
+
if (Array.isArray(failingCriteria)) {
|
|
2297
|
+
for (const criterion of failingCriteria) {
|
|
2298
|
+
if (criterion && typeof criterion === "object" && "id" in criterion) {
|
|
2299
|
+
criteriaIds.push(String(criterion["id"]));
|
|
2300
|
+
} else if (typeof criterion === "string") {
|
|
2301
|
+
criteriaIds.push(criterion);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
const criteriaStr = criteriaIds.length > 0 ? criteriaIds.join(", ") : "unknown";
|
|
2306
|
+
return {
|
|
2307
|
+
category: "gate-failure",
|
|
2308
|
+
line: `gate-failure: ${rawPath} failed criteria: ${criteriaStr}`
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
function checkGateStaleness(page, repoRoot) {
|
|
2312
|
+
const rawPath = page.page.raw_path;
|
|
2313
|
+
if (!rawPath) return null;
|
|
2314
|
+
const absRaw = path15.join(repoRoot, rawPath);
|
|
2315
|
+
if (!fs15.existsSync(absRaw)) return null;
|
|
2316
|
+
let rawFm;
|
|
2317
|
+
try {
|
|
2318
|
+
const raw = fs15.readFileSync(absRaw, "utf8");
|
|
2319
|
+
const { fm } = parseFrontmatter(raw);
|
|
2320
|
+
rawFm = fm;
|
|
2321
|
+
} catch {
|
|
2322
|
+
return null;
|
|
2323
|
+
}
|
|
2324
|
+
const cgr = parseCachedGateResult(rawFm["cached_gate_result"]);
|
|
2325
|
+
if (!cgr) return null;
|
|
2326
|
+
const lastGateCheck = cgr.last_gate_check;
|
|
2327
|
+
if (!lastGateCheck || lastGateCheck === null) return null;
|
|
2328
|
+
const updatedAt = rawFm["updated_at"];
|
|
2329
|
+
if (!updatedAt) return null;
|
|
2330
|
+
const lastCheckStr = String(lastGateCheck);
|
|
2331
|
+
const updatedAtStr = String(updatedAt);
|
|
2332
|
+
if (lastCheckStr < updatedAtStr) {
|
|
2333
|
+
return {
|
|
2334
|
+
category: "gate-stale",
|
|
2335
|
+
line: `gate-stale: ${rawPath} last_gate_check=${lastCheckStr} < updated_at=${updatedAtStr}`
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
return null;
|
|
2339
|
+
}
|
|
2340
|
+
function discoverPlainTextMentions(pages, repoRoot) {
|
|
2341
|
+
const wikiRoot = path15.join(repoRoot, ".cleargate", "wiki");
|
|
2342
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2343
|
+
for (const p of pages) {
|
|
2344
|
+
if (p.page.id) byId.set(p.page.id, true);
|
|
2345
|
+
}
|
|
2346
|
+
const suggestions = [];
|
|
2347
|
+
const ID_PATTERN = /\b((?:EPIC|STORY|SPRINT|PROPOSAL|CR|BUG)-[\w-]+)\b/g;
|
|
2348
|
+
const LINK_PATTERN = /\[\[[\w-]+\]\]/g;
|
|
2349
|
+
for (const page of pages) {
|
|
2350
|
+
const relPage = path15.relative(wikiRoot, page.absPath).replace(/\\/g, "/");
|
|
2351
|
+
const wrappedRefs = /* @__PURE__ */ new Set();
|
|
2352
|
+
for (const m of page.body.matchAll(LINK_PATTERN)) {
|
|
2353
|
+
const inner = m[0].slice(2, -2);
|
|
2354
|
+
wrappedRefs.add(inner);
|
|
2355
|
+
}
|
|
2356
|
+
for (const m of page.body.matchAll(ID_PATTERN)) {
|
|
2357
|
+
const mentionedId = m[1];
|
|
2358
|
+
if (!byId.has(mentionedId)) continue;
|
|
2359
|
+
if (wrappedRefs.has(mentionedId)) continue;
|
|
2360
|
+
if (mentionedId === page.page.id) continue;
|
|
2361
|
+
suggestions.push(`suggest: ${relPage} mentions ${mentionedId} in plain text, consider [[${mentionedId}]] wrap`);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
return suggestions;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
// src/commands/wiki-lint.ts
|
|
2368
|
+
async function wikiLintHandler(opts = {}) {
|
|
2369
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2370
|
+
const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
|
|
2371
|
+
opts.stderr;
|
|
2372
|
+
const exit = opts.exit ?? ((c) => process.exit(c));
|
|
2373
|
+
const gitRunner = opts.gitRunner;
|
|
2374
|
+
const mode = opts.mode ?? "enforce";
|
|
2375
|
+
const wikiRoot = path16.join(cwd, ".cleargate", "wiki");
|
|
2376
|
+
const repoRoot = cwd;
|
|
2377
|
+
let pages = loadWikiPages(wikiRoot);
|
|
2378
|
+
const findings = [];
|
|
2379
|
+
const paginationFindings = checkPaginationNeeded(pages);
|
|
2380
|
+
findings.push(...paginationFindings);
|
|
2381
|
+
for (const page of pages) {
|
|
2382
|
+
const orphan = checkOrphan(page, repoRoot);
|
|
2383
|
+
if (orphan) findings.push(orphan);
|
|
2384
|
+
const repoMismatch = checkRepoMismatch(page, repoRoot);
|
|
2385
|
+
if (repoMismatch) findings.push(repoMismatch);
|
|
2386
|
+
const staleCommit = checkStaleCommit(page, repoRoot, gitRunner);
|
|
2387
|
+
if (staleCommit) findings.push(staleCommit);
|
|
2388
|
+
const missingIngest = checkMissingIngest(page, repoRoot);
|
|
2389
|
+
if (missingIngest) findings.push(missingIngest);
|
|
2390
|
+
const excludedPath = checkExcludedPathIngested(page, repoRoot);
|
|
2391
|
+
if (excludedPath) findings.push(excludedPath);
|
|
2392
|
+
const gateFail = checkGateFailure(page, repoRoot);
|
|
2393
|
+
if (gateFail) findings.push(gateFail);
|
|
2394
|
+
const gateStale = checkGateStaleness(page, repoRoot);
|
|
2395
|
+
if (gateStale) findings.push(gateStale);
|
|
2396
|
+
}
|
|
2397
|
+
const backlinkFindings = checkBrokenBacklinks(pages, repoRoot);
|
|
2398
|
+
findings.push(...backlinkFindings);
|
|
2399
|
+
const citationFindings = checkInvalidatedCitations(pages, repoRoot);
|
|
2400
|
+
findings.push(...citationFindings);
|
|
2401
|
+
const pageCount = pages.length;
|
|
2402
|
+
const findingCount = findings.length;
|
|
2403
|
+
if (mode === "suggest") {
|
|
2404
|
+
for (const finding of findings) {
|
|
2405
|
+
stdout(`[advisory] ${finding.line}
|
|
2406
|
+
`);
|
|
2407
|
+
}
|
|
2408
|
+
const suggestions = discoverPlainTextMentions(pages, repoRoot);
|
|
2409
|
+
for (const suggestion of suggestions) {
|
|
2410
|
+
stdout(`${suggestion}
|
|
2411
|
+
`);
|
|
2412
|
+
}
|
|
2413
|
+
stdout(`lint: OK (${pageCount} pages checked, ${findingCount} findings)
|
|
2414
|
+
`);
|
|
2415
|
+
exit(0);
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
for (const finding of findings) {
|
|
2419
|
+
stdout(`${finding.line}
|
|
2420
|
+
`);
|
|
2421
|
+
}
|
|
2422
|
+
if (findingCount > 0) {
|
|
2423
|
+
stdout(`lint: FAIL (${pageCount} pages checked, ${findingCount} findings)
|
|
2424
|
+
`);
|
|
2425
|
+
exit(1);
|
|
2426
|
+
} else {
|
|
2427
|
+
stdout(`lint: OK (${pageCount} pages checked, 0 findings)
|
|
2428
|
+
`);
|
|
2429
|
+
exit(0);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// src/commands/wiki-query.ts
|
|
2434
|
+
import * as fs16 from "fs";
|
|
2435
|
+
import * as path17 from "path";
|
|
2436
|
+
function computeSlug(query) {
|
|
2437
|
+
return query.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 40).replace(/-+$/, "");
|
|
2438
|
+
}
|
|
2439
|
+
function searchIndex(indexContent, query) {
|
|
2440
|
+
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
|
2441
|
+
const results = [];
|
|
2442
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
2443
|
+
for (const line of indexContent.split("\n")) {
|
|
2444
|
+
const lower = line.toLowerCase();
|
|
2445
|
+
const matchesAll = terms.every((term) => lower.includes(term));
|
|
2446
|
+
if (!matchesAll) continue;
|
|
2447
|
+
const match = line.match(/\[\[([^\]]+)\]\]/);
|
|
2448
|
+
if (!match) continue;
|
|
2449
|
+
const id = match[1];
|
|
2450
|
+
if (seenIds.has(id)) continue;
|
|
2451
|
+
seenIds.add(id);
|
|
2452
|
+
results.push({ id, excerpt: line.trim() });
|
|
2453
|
+
}
|
|
2454
|
+
return results;
|
|
2455
|
+
}
|
|
2456
|
+
async function wikiQueryHandler(opts) {
|
|
2457
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2458
|
+
const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
|
|
2459
|
+
const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
|
|
2460
|
+
const exit = opts.exit ?? ((c) => process.exit(c));
|
|
2461
|
+
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
2462
|
+
const query = opts.query;
|
|
2463
|
+
const persist = opts.persist ?? false;
|
|
2464
|
+
void stderr;
|
|
2465
|
+
const wikiRoot = path17.join(cwd, ".cleargate", "wiki");
|
|
2466
|
+
const indexPath = path17.join(wikiRoot, "index.md");
|
|
2467
|
+
if (!fs16.existsSync(indexPath)) {
|
|
2468
|
+
stdout(`wiki query: no index.md found at ${indexPath}
|
|
2469
|
+
`);
|
|
2470
|
+
stdout(`Run \`cleargate wiki build\` first.
|
|
2471
|
+
`);
|
|
2472
|
+
exit(1);
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
const indexContent = fs16.readFileSync(indexPath, "utf8");
|
|
2476
|
+
const matches = searchIndex(indexContent, query);
|
|
2477
|
+
if (matches.length === 0) {
|
|
2478
|
+
stdout(`wiki query: no matches for "${query}"
|
|
2479
|
+
`);
|
|
2480
|
+
exit(0);
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
const bodyLines = [
|
|
2484
|
+
`# Query: ${query}`,
|
|
2485
|
+
"",
|
|
2486
|
+
`Found ${matches.length} match(es):`,
|
|
2487
|
+
""
|
|
2488
|
+
];
|
|
2489
|
+
for (const { id, excerpt } of matches) {
|
|
2490
|
+
bodyLines.push(`- [[${id}]] \u2014 ${excerpt}`);
|
|
2491
|
+
}
|
|
2492
|
+
bodyLines.push("");
|
|
2493
|
+
const body = bodyLines.join("\n");
|
|
2494
|
+
stdout(body);
|
|
2495
|
+
if (!persist) {
|
|
2496
|
+
exit(0);
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
const slug = computeSlug(query);
|
|
2500
|
+
const topicsDir = path17.join(wikiRoot, "topics");
|
|
2501
|
+
fs16.mkdirSync(topicsDir, { recursive: true });
|
|
2502
|
+
const citesArray = matches.map(({ id }) => `"[[${id}]]"`);
|
|
2503
|
+
const createdAt = now();
|
|
2504
|
+
const frontmatter = [
|
|
2505
|
+
"---",
|
|
2506
|
+
`type: topic`,
|
|
2507
|
+
`id: "${slug}"`,
|
|
2508
|
+
`created_by: "cleargate-wiki-query"`,
|
|
2509
|
+
`created_at: "${createdAt}"`,
|
|
2510
|
+
`cites: [${citesArray.join(", ")}]`,
|
|
2511
|
+
"---"
|
|
2512
|
+
].join("\n");
|
|
2513
|
+
const topicContent = `${frontmatter}
|
|
2514
|
+
|
|
2515
|
+
${body}`;
|
|
2516
|
+
const topicPath = path17.join(topicsDir, `${slug}.md`);
|
|
2517
|
+
fs16.writeFileSync(topicPath, topicContent, "utf8");
|
|
2518
|
+
updateIndexTopicsSection(indexPath, slug, query, createdAt);
|
|
2519
|
+
exit(0);
|
|
2520
|
+
}
|
|
2521
|
+
function updateIndexTopicsSection(indexPath, slug, query, createdAt) {
|
|
2522
|
+
let content = fs16.readFileSync(indexPath, "utf8");
|
|
2523
|
+
const row = `| ${slug} | ${query} | ${createdAt} |`;
|
|
2524
|
+
if (content.includes("## Topics")) {
|
|
2525
|
+
const topicsIdx = content.indexOf("## Topics");
|
|
2526
|
+
const afterTopics = content.slice(topicsIdx);
|
|
2527
|
+
const nextSectionMatch = afterTopics.slice("## Topics".length).match(/\n## /);
|
|
2528
|
+
if (nextSectionMatch && nextSectionMatch.index !== void 0) {
|
|
2529
|
+
const insertPos = topicsIdx + "## Topics".length + nextSectionMatch.index;
|
|
2530
|
+
content = content.slice(0, insertPos) + `
|
|
2531
|
+
${row}` + content.slice(insertPos);
|
|
2532
|
+
} else {
|
|
2533
|
+
content = content.trimEnd() + `
|
|
2534
|
+
${row}
|
|
2535
|
+
`;
|
|
2536
|
+
}
|
|
2537
|
+
} else {
|
|
2538
|
+
content = content.trimEnd() + `
|
|
2539
|
+
|
|
2540
|
+
## Topics
|
|
2541
|
+
|
|
2542
|
+
${row}
|
|
2543
|
+
`;
|
|
2544
|
+
}
|
|
2545
|
+
fs16.writeFileSync(indexPath, content, "utf8");
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// src/commands/doctor.ts
|
|
2549
|
+
import * as fs17 from "fs";
|
|
2550
|
+
import * as path18 from "path";
|
|
2551
|
+
|
|
2552
|
+
// src/lib/pricing.ts
|
|
2553
|
+
var PRICING_TABLE = {
|
|
2554
|
+
"claude-opus-4-7": {
|
|
2555
|
+
input: 15,
|
|
2556
|
+
output: 75,
|
|
2557
|
+
cache_read: 1.5,
|
|
2558
|
+
cache_creation: 18.75
|
|
2559
|
+
},
|
|
2560
|
+
"claude-sonnet-4-5": {
|
|
2561
|
+
input: 3,
|
|
2562
|
+
output: 15,
|
|
2563
|
+
cache_read: 0.3,
|
|
2564
|
+
cache_creation: 3.75
|
|
2565
|
+
},
|
|
2566
|
+
"claude-sonnet-4-6": {
|
|
2567
|
+
input: 3,
|
|
2568
|
+
output: 15,
|
|
2569
|
+
cache_read: 0.3,
|
|
2570
|
+
cache_creation: 3.75
|
|
2571
|
+
},
|
|
2572
|
+
"claude-haiku-4-5": {
|
|
2573
|
+
input: 0.8,
|
|
2574
|
+
output: 4,
|
|
2575
|
+
cache_read: 0.08,
|
|
2576
|
+
cache_creation: 1
|
|
2577
|
+
}
|
|
2578
|
+
};
|
|
2579
|
+
function computeUsd(draftTokens, modelOverride) {
|
|
2580
|
+
const model = modelOverride ?? draftTokens.model ?? "";
|
|
2581
|
+
const pricing = PRICING_TABLE[model];
|
|
2582
|
+
if (!pricing) {
|
|
2583
|
+
return { usd: 0, unknownModel: true };
|
|
2584
|
+
}
|
|
2585
|
+
const input = draftTokens.input ?? 0;
|
|
2586
|
+
const output = draftTokens.output ?? 0;
|
|
2587
|
+
const cacheRead = draftTokens.cache_read ?? 0;
|
|
2588
|
+
const cacheCreation = draftTokens.cache_creation ?? 0;
|
|
2589
|
+
const usd = (input * pricing.input + output * pricing.output + cacheRead * pricing.cache_read + cacheCreation * pricing.cache_creation) / 1e6;
|
|
2590
|
+
return { usd, unknownModel: false };
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// src/commands/doctor.ts
|
|
2594
|
+
function selectMode(flags) {
|
|
2595
|
+
const modes = [];
|
|
2596
|
+
if (flags.checkScaffold) modes.push("check-scaffold");
|
|
2597
|
+
if (flags.sessionStart) modes.push("session-start");
|
|
2598
|
+
if (flags.pricing) modes.push("pricing");
|
|
2599
|
+
if (modes.length > 1) {
|
|
2600
|
+
throw new Error(
|
|
2601
|
+
`cleargate doctor: mutually exclusive flags set: ${modes.join(", ")}. Use only one mode flag at a time.`
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
if (modes.length === 1) {
|
|
2605
|
+
return modes[0];
|
|
2606
|
+
}
|
|
2607
|
+
return "hook-health";
|
|
2608
|
+
}
|
|
2609
|
+
var HOOK_LOG_24H_MS = 24 * 60 * 60 * 1e3;
|
|
2610
|
+
function parseHookLogLine(line) {
|
|
2611
|
+
const m = line.match(
|
|
2612
|
+
/^\[([^\]]+)\]\s+stamp=(\d+)\s+gate=(\d+)\s+ingest=(\d+)\s+file=(.+)$/
|
|
2613
|
+
);
|
|
2614
|
+
if (!m) return null;
|
|
2615
|
+
return {
|
|
2616
|
+
ts: m[1],
|
|
2617
|
+
stamp: parseInt(m[2], 10),
|
|
2618
|
+
gate: parseInt(m[3], 10),
|
|
2619
|
+
ingest: parseInt(m[4], 10),
|
|
2620
|
+
file: m[5].trim()
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
function runHookHealth(stdout, cwd, now) {
|
|
2624
|
+
const settingsPath = path18.join(cwd, ".claude", "settings.json");
|
|
2625
|
+
if (!fs17.existsSync(settingsPath)) {
|
|
2626
|
+
stdout("[doctor] No .claude/settings.json found \u2014 hook config unavailable.");
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
try {
|
|
2630
|
+
const raw = fs17.readFileSync(settingsPath, "utf-8");
|
|
2631
|
+
const settings = JSON.parse(raw);
|
|
2632
|
+
const hasHooks = typeof settings === "object" && settings !== null && "hooks" in settings;
|
|
2633
|
+
if (hasHooks) {
|
|
2634
|
+
stdout("[doctor] Hook config present in .claude/settings.json.");
|
|
2635
|
+
} else {
|
|
2636
|
+
stdout("[doctor] .claude/settings.json found but no hooks key \u2014 SubagentStop hook not wired.");
|
|
2637
|
+
}
|
|
2638
|
+
} catch {
|
|
2639
|
+
stdout("[doctor] .claude/settings.json is not valid JSON \u2014 cannot verify hook config.");
|
|
2640
|
+
}
|
|
2641
|
+
const logPath = path18.join(cwd, ".cleargate", "hook-log", "gate-check.log");
|
|
2642
|
+
if (!fs17.existsSync(logPath)) {
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
let logContent;
|
|
2646
|
+
try {
|
|
2647
|
+
logContent = fs17.readFileSync(logPath, "utf-8");
|
|
2648
|
+
} catch {
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
const nowMs = (now ?? /* @__PURE__ */ new Date()).getTime();
|
|
2652
|
+
const lines = logContent.split("\n").filter((l) => l.trim().length > 0);
|
|
2653
|
+
for (const line of lines) {
|
|
2654
|
+
const entry = parseHookLogLine(line);
|
|
2655
|
+
if (!entry) continue;
|
|
2656
|
+
const entryMs = new Date(entry.ts).getTime();
|
|
2657
|
+
if (isNaN(entryMs)) continue;
|
|
2658
|
+
if (nowMs - entryMs > HOOK_LOG_24H_MS) continue;
|
|
2659
|
+
const isFailing = entry.stamp !== 0 || entry.gate !== 0 || entry.ingest !== 0;
|
|
2660
|
+
if (!isFailing) continue;
|
|
2661
|
+
stdout(
|
|
2662
|
+
`\u26A0 hook failure at ${entry.ts}: stamp=${entry.stamp} gate=${entry.gate} ingest=${entry.ingest} file=${entry.file}`
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
var TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1e3;
|
|
2667
|
+
function shouldUseCache(lastRefreshed, now, sessionStartMode) {
|
|
2668
|
+
if (!sessionStartMode) {
|
|
2669
|
+
return false;
|
|
2670
|
+
}
|
|
2671
|
+
const age = now.getTime() - new Date(lastRefreshed).getTime();
|
|
2672
|
+
return age < TWENTY_FOUR_HOURS_MS;
|
|
2673
|
+
}
|
|
2674
|
+
function formatVerboseLine(filePath, entry) {
|
|
2675
|
+
const inst = entry.install_sha ? shortHash(entry.install_sha).slice(0, 6) : "null";
|
|
2676
|
+
const curr = entry.current_sha ? shortHash(entry.current_sha).slice(0, 6) : "null";
|
|
2677
|
+
const pkg = entry.package_sha ? shortHash(entry.package_sha).slice(0, 6) : "null";
|
|
2678
|
+
return ` ${filePath} ${entry.state} (${inst}\u2192${curr} vs ${pkg})`;
|
|
2679
|
+
}
|
|
2680
|
+
function zeroCounts() {
|
|
2681
|
+
return {
|
|
2682
|
+
"clean": 0,
|
|
2683
|
+
"user-modified": 0,
|
|
2684
|
+
"upstream-changed": 0,
|
|
2685
|
+
"both-changed": 0,
|
|
2686
|
+
"untracked": 0
|
|
2687
|
+
};
|
|
2688
|
+
}
|
|
2689
|
+
async function runCheckScaffold(flags, cli, cwd, now, stdout, _stderr) {
|
|
2690
|
+
const sessionStartMode = flags.sessionStartMode ?? false;
|
|
2691
|
+
const existingState = await readDriftState(cwd);
|
|
2692
|
+
if (existingState && shouldUseCache(existingState.last_refreshed, now, sessionStartMode)) {
|
|
2693
|
+
emitSummary(existingState.drift, flags.verbose ?? false, stdout);
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
const pkgManifest = loadPackageManifest({ packageRoot: cli.packageRoot });
|
|
2697
|
+
const installSnapshot = await loadInstallSnapshot(cwd);
|
|
2698
|
+
const driftMap = {};
|
|
2699
|
+
await Promise.all(
|
|
2700
|
+
pkgManifest.files.map(async (entry) => {
|
|
2701
|
+
if (entry.tier === "user-artifact") {
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
const currentSha = await computeCurrentSha(entry, cwd);
|
|
2705
|
+
const installSha = installSnapshot?.files.find((f) => f.path === entry.path)?.sha256 ?? null;
|
|
2706
|
+
const pkgSha = entry.sha256;
|
|
2707
|
+
const state = classify(pkgSha, installSha, currentSha, entry.tier);
|
|
2708
|
+
driftMap[entry.path] = {
|
|
2709
|
+
state,
|
|
2710
|
+
entry,
|
|
2711
|
+
install_sha: installSha,
|
|
2712
|
+
current_sha: currentSha,
|
|
2713
|
+
package_sha: pkgSha
|
|
2714
|
+
};
|
|
2715
|
+
})
|
|
2716
|
+
);
|
|
2717
|
+
await writeDriftState(cwd, driftMap, { lastRefreshed: now.toISOString() });
|
|
2718
|
+
emitSummary(driftMap, flags.verbose ?? false, stdout);
|
|
2719
|
+
}
|
|
2720
|
+
function emitSummary(driftMap, verbose, stdout) {
|
|
2721
|
+
const counts = zeroCounts();
|
|
2722
|
+
for (const entry of Object.values(driftMap)) {
|
|
2723
|
+
counts[entry.state]++;
|
|
2724
|
+
}
|
|
2725
|
+
stdout(
|
|
2726
|
+
`Scaffold drift: ${counts["user-modified"]} user-modified, ${counts["upstream-changed"]} upstream-changed, ${counts["both-changed"]} both-changed, ${counts["clean"]} clean`
|
|
2727
|
+
);
|
|
2728
|
+
if (counts["upstream-changed"] > 0 || counts["both-changed"] > 0) {
|
|
2729
|
+
stdout("Run cleargate upgrade to review.");
|
|
2730
|
+
}
|
|
2731
|
+
if (verbose) {
|
|
2732
|
+
for (const [filePath, entry] of Object.entries(driftMap)) {
|
|
2733
|
+
if (entry.state !== "clean" && entry.state !== "untracked") {
|
|
2734
|
+
stdout(formatVerboseLine(filePath, entry));
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
var SESSION_START_MAX_ITEMS = 10;
|
|
2740
|
+
var SESSION_START_MAX_CHARS = 400;
|
|
2741
|
+
function parseCachedGateResult2(raw) {
|
|
2742
|
+
if (raw == null) return null;
|
|
2743
|
+
let parsed = null;
|
|
2744
|
+
if (typeof raw === "object" && !Array.isArray(raw)) {
|
|
2745
|
+
parsed = raw;
|
|
2746
|
+
} else if (typeof raw === "string") {
|
|
2747
|
+
try {
|
|
2748
|
+
parsed = JSON.parse(raw);
|
|
2749
|
+
} catch {
|
|
2750
|
+
return null;
|
|
2751
|
+
}
|
|
2752
|
+
} else {
|
|
2753
|
+
return null;
|
|
2754
|
+
}
|
|
2755
|
+
return {
|
|
2756
|
+
pass: parsed.pass ?? null,
|
|
2757
|
+
failing_criteria: parsed.failing_criteria ?? []
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2760
|
+
async function runSessionStart(cwd, stdout) {
|
|
2761
|
+
const pendingSyncDir = path18.join(cwd, ".cleargate", "delivery", "pending-sync");
|
|
2762
|
+
let files;
|
|
2763
|
+
try {
|
|
2764
|
+
files = fs17.readdirSync(pendingSyncDir).filter((f) => f.endsWith(".md")).map((f) => path18.join(pendingSyncDir, f));
|
|
2765
|
+
} catch {
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
const blocked = [];
|
|
2769
|
+
for (const filePath of files) {
|
|
2770
|
+
let raw;
|
|
2771
|
+
try {
|
|
2772
|
+
raw = fs17.readFileSync(filePath, "utf-8");
|
|
2773
|
+
} catch {
|
|
2774
|
+
continue;
|
|
2775
|
+
}
|
|
2776
|
+
if (!raw.trimStart().startsWith("---")) continue;
|
|
2777
|
+
let fm;
|
|
2778
|
+
try {
|
|
2779
|
+
fm = parseFrontmatter(raw).fm;
|
|
2780
|
+
} catch {
|
|
2781
|
+
continue;
|
|
2782
|
+
}
|
|
2783
|
+
const gate2 = parseCachedGateResult2(fm["cached_gate_result"]);
|
|
2784
|
+
if (!gate2 || gate2.pass !== false) continue;
|
|
2785
|
+
const idKeys = ["story_id", "epic_id", "proposal_id", "cr_id", "bug_id", "sprint_id"];
|
|
2786
|
+
let itemId = "";
|
|
2787
|
+
for (const key of idKeys) {
|
|
2788
|
+
const val = fm[key];
|
|
2789
|
+
if (typeof val === "string" && val.trim()) {
|
|
2790
|
+
itemId = val.trim();
|
|
2791
|
+
break;
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
if (!itemId) {
|
|
2795
|
+
itemId = path18.basename(filePath, ".md");
|
|
2796
|
+
}
|
|
2797
|
+
const firstCriterionId = gate2.failing_criteria.length > 0 ? gate2.failing_criteria[0]?.id ?? "" : "";
|
|
2798
|
+
blocked.push({ id: itemId, firstCriterionId });
|
|
2799
|
+
}
|
|
2800
|
+
if (blocked.length === 0) {
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
const overflow = blocked.length > SESSION_START_MAX_ITEMS ? blocked.length - SESSION_START_MAX_ITEMS : 0;
|
|
2804
|
+
const visible = blocked.slice(0, SESSION_START_MAX_ITEMS);
|
|
2805
|
+
const lines = [`${blocked.length} items blocked:`];
|
|
2806
|
+
for (const item of visible) {
|
|
2807
|
+
const line = item.firstCriterionId ? ` ${item.id}: ${item.firstCriterionId}` : ` ${item.id}`;
|
|
2808
|
+
lines.push(line);
|
|
2809
|
+
}
|
|
2810
|
+
if (overflow > 0) {
|
|
2811
|
+
lines.push(`\u2026and ${overflow} more \u2014 run cleargate doctor for full list`);
|
|
2812
|
+
}
|
|
2813
|
+
let output = lines.join("\n");
|
|
2814
|
+
if (output.length > SESSION_START_MAX_CHARS) {
|
|
2815
|
+
output = output.slice(0, SESSION_START_MAX_CHARS - 3) + "...";
|
|
2816
|
+
}
|
|
2817
|
+
stdout(output);
|
|
2818
|
+
}
|
|
2819
|
+
async function runPricing(filePath, cwd, stdout, stderr, exit) {
|
|
2820
|
+
if (!filePath) {
|
|
2821
|
+
stderr("cleargate doctor --pricing: missing <file> argument");
|
|
2822
|
+
exit(1);
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
const absPath = path18.isAbsolute(filePath) ? filePath : path18.resolve(cwd, filePath);
|
|
2826
|
+
let raw;
|
|
2827
|
+
try {
|
|
2828
|
+
raw = fs17.readFileSync(absPath, "utf-8");
|
|
2829
|
+
} catch {
|
|
2830
|
+
stderr(`cleargate doctor --pricing: cannot read file: ${absPath}`);
|
|
2831
|
+
exit(1);
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
if (!raw.trimStart().startsWith("---")) {
|
|
2835
|
+
stderr(`cleargate doctor --pricing: file has no frontmatter: ${absPath}`);
|
|
2836
|
+
exit(1);
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
let fm;
|
|
2840
|
+
try {
|
|
2841
|
+
fm = parseFrontmatter(raw).fm;
|
|
2842
|
+
} catch {
|
|
2843
|
+
stderr(`cleargate doctor --pricing: cannot parse frontmatter in: ${absPath}`);
|
|
2844
|
+
exit(1);
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
const draftTokensRaw = fm["draft_tokens"];
|
|
2848
|
+
if (!draftTokensRaw) {
|
|
2849
|
+
stdout("draft_tokens unpopulated \u2014 run cleargate stamp-tokens first");
|
|
2850
|
+
exit(1);
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
let draftTokens;
|
|
2854
|
+
if (typeof draftTokensRaw === "object" && !Array.isArray(draftTokensRaw)) {
|
|
2855
|
+
draftTokens = draftTokensRaw;
|
|
2856
|
+
} else if (typeof draftTokensRaw === "string") {
|
|
2857
|
+
try {
|
|
2858
|
+
draftTokens = JSON.parse(draftTokensRaw);
|
|
2859
|
+
} catch {
|
|
2860
|
+
stdout("draft_tokens unpopulated \u2014 run cleargate stamp-tokens first");
|
|
2861
|
+
exit(1);
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
} else {
|
|
2865
|
+
stdout("draft_tokens unpopulated \u2014 run cleargate stamp-tokens first");
|
|
2866
|
+
exit(1);
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
if (draftTokens.input === null && draftTokens.output === null && draftTokens.cache_read === null && draftTokens.cache_creation === null) {
|
|
2870
|
+
stdout("draft_tokens unpopulated \u2014 run cleargate stamp-tokens first");
|
|
2871
|
+
exit(1);
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
const { usd, unknownModel } = computeUsd(draftTokens);
|
|
2875
|
+
const model = draftTokens.model ?? "unknown";
|
|
2876
|
+
if (unknownModel) {
|
|
2877
|
+
stderr(`cleargate doctor --pricing: unknown model '${model}' \u2014 no pricing data available`);
|
|
2878
|
+
}
|
|
2879
|
+
const input = draftTokens.input ?? 0;
|
|
2880
|
+
const output = draftTokens.output ?? 0;
|
|
2881
|
+
const cacheRead = draftTokens.cache_read ?? 0;
|
|
2882
|
+
const cacheCreation = draftTokens.cache_creation ?? 0;
|
|
2883
|
+
const fileName = path18.basename(absPath);
|
|
2884
|
+
stdout(
|
|
2885
|
+
`${fileName}: ${model} \u2014 input:${input} output:${output} cache_read:${cacheRead} cache_creation:${cacheCreation} \u2248 $${usd.toFixed(4)}`
|
|
2886
|
+
);
|
|
2887
|
+
}
|
|
2888
|
+
async function doctorHandler(flags, cli) {
|
|
2889
|
+
const cwd = cli?.cwd ?? process.cwd();
|
|
2890
|
+
const now = cli?.now ? cli.now() : /* @__PURE__ */ new Date();
|
|
2891
|
+
const stdout = cli?.stdout ?? ((s) => process.stdout.write(s + "\n"));
|
|
2892
|
+
const stderr = cli?.stderr ?? ((s) => process.stderr.write(s + "\n"));
|
|
2893
|
+
const exit = cli?.exit ?? ((code) => process.exit(code));
|
|
2894
|
+
let mode;
|
|
2895
|
+
try {
|
|
2896
|
+
mode = selectMode(flags);
|
|
2897
|
+
} catch (err) {
|
|
2898
|
+
stderr(err.message);
|
|
2899
|
+
exit(1);
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
switch (mode) {
|
|
2903
|
+
case "check-scaffold":
|
|
2904
|
+
await runCheckScaffold(flags, cli ?? {}, cwd, now, stdout, stderr);
|
|
2905
|
+
break;
|
|
2906
|
+
case "hook-health":
|
|
2907
|
+
runHookHealth(stdout, cwd, now);
|
|
2908
|
+
break;
|
|
2909
|
+
case "session-start":
|
|
2910
|
+
await runSessionStart(cwd, stdout);
|
|
2911
|
+
break;
|
|
2912
|
+
case "pricing":
|
|
2913
|
+
await runPricing(flags.pricingFile ?? "", cwd, stdout, stderr, exit);
|
|
2914
|
+
break;
|
|
2915
|
+
default: {
|
|
2916
|
+
const exhaustiveCheck = mode;
|
|
2917
|
+
stderr(`cleargate doctor: unknown mode '${String(exhaustiveCheck)}'`);
|
|
2918
|
+
exit(1);
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
// src/commands/gate.ts
|
|
2924
|
+
import * as fs20 from "fs";
|
|
2925
|
+
import * as path20 from "path";
|
|
2926
|
+
import yaml5 from "js-yaml";
|
|
2927
|
+
|
|
2928
|
+
// src/lib/readiness-predicates.ts
|
|
2929
|
+
import * as fs18 from "fs";
|
|
2930
|
+
import * as path19 from "path";
|
|
2931
|
+
function parsePredicate(src) {
|
|
2932
|
+
const s = src.trim();
|
|
2933
|
+
const fmMatch = s.match(
|
|
2934
|
+
/^frontmatter\(([^)]*)\)\.(\w+)\s*(==|!=|>=|<=)\s*(.+)$/
|
|
2935
|
+
);
|
|
2936
|
+
if (fmMatch) {
|
|
2937
|
+
const ref = fmMatch[1].trim();
|
|
2938
|
+
if (ref === "") throw new Error(`unsupported predicate shape: ${src}`);
|
|
2939
|
+
const field = fmMatch[2];
|
|
2940
|
+
const op = fmMatch[3];
|
|
2941
|
+
const rawVal = fmMatch[4].trim();
|
|
2942
|
+
const value = parseValue(rawVal);
|
|
2943
|
+
return { kind: "frontmatter", ref, field, op, value };
|
|
2944
|
+
}
|
|
2945
|
+
const bodyNotMatch = s.match(/^body does not contain ['"](.+)['"]$/);
|
|
2946
|
+
if (bodyNotMatch) {
|
|
2947
|
+
return { kind: "body-contains", needle: bodyNotMatch[1], negated: true };
|
|
2948
|
+
}
|
|
2949
|
+
const bodyMatch = s.match(/^body contains ['"](.+)['"]$/);
|
|
2950
|
+
if (bodyMatch) {
|
|
2951
|
+
return { kind: "body-contains", needle: bodyMatch[1], negated: false };
|
|
2952
|
+
}
|
|
2953
|
+
const sectionMatch = s.match(
|
|
2954
|
+
/^section\((\d+)\) has (≥|>=|==|>)(\d+) (checked-checkbox|unchecked-checkbox|listed-item)$/
|
|
2955
|
+
);
|
|
2956
|
+
if (sectionMatch) {
|
|
2957
|
+
const index = parseInt(sectionMatch[1], 10);
|
|
2958
|
+
const opChar = sectionMatch[2];
|
|
2959
|
+
const n = parseInt(sectionMatch[3], 10);
|
|
2960
|
+
const itemType = sectionMatch[4];
|
|
2961
|
+
let countOp;
|
|
2962
|
+
if (opChar === "\u2265" || opChar === ">=") countOp = ">=";
|
|
2963
|
+
else if (opChar === ">") countOp = ">";
|
|
2964
|
+
else countOp = "==";
|
|
2965
|
+
return { kind: "section", index, count: { op: countOp, n }, itemType };
|
|
2966
|
+
}
|
|
2967
|
+
const fileExistsMatch = s.match(/^file-exists\((.+)\)$/);
|
|
2968
|
+
if (fileExistsMatch) {
|
|
2969
|
+
const filePath = fileExistsMatch[1].trim().replace(/^['"]|['"]$/g, "");
|
|
2970
|
+
return { kind: "file-exists", path: filePath };
|
|
2971
|
+
}
|
|
2972
|
+
const linkMatch = s.match(/^link-target-exists\(\[\[([A-Z0-9\-]+)\]\]\)$/);
|
|
2973
|
+
if (linkMatch) {
|
|
2974
|
+
return { kind: "link-target-exists", id: linkMatch[1] };
|
|
2975
|
+
}
|
|
2976
|
+
const statusMatch = s.match(/^status-of\(\[\[([A-Z0-9\-]+)\]\]\)\s*==\s*(.+)$/);
|
|
2977
|
+
if (statusMatch) {
|
|
2978
|
+
const id = statusMatch[1];
|
|
2979
|
+
const value = statusMatch[2].trim().replace(/^['"]|['"]$/g, "");
|
|
2980
|
+
return { kind: "status-of", id, value };
|
|
2981
|
+
}
|
|
2982
|
+
throw new Error(`unsupported predicate shape: ${src}`);
|
|
2983
|
+
}
|
|
2984
|
+
function parseValue(raw) {
|
|
2985
|
+
if (raw === "true") return true;
|
|
2986
|
+
if (raw === "false") return false;
|
|
2987
|
+
if (raw === "null") return "null";
|
|
2988
|
+
const num = Number(raw);
|
|
2989
|
+
if (!isNaN(num) && raw !== "") return num;
|
|
2990
|
+
return raw.replace(/^['"]|['"]$/g, "");
|
|
2991
|
+
}
|
|
2992
|
+
function evaluate(predicate, doc, opts) {
|
|
2993
|
+
const parsed = parsePredicate(predicate);
|
|
2994
|
+
const projectRoot = opts?.projectRoot ?? process.cwd();
|
|
2995
|
+
switch (parsed.kind) {
|
|
2996
|
+
case "frontmatter":
|
|
2997
|
+
return evalFrontmatter(parsed, doc, projectRoot);
|
|
2998
|
+
case "body-contains":
|
|
2999
|
+
return evalBodyContains(parsed, doc);
|
|
3000
|
+
case "section":
|
|
3001
|
+
return evalSection(parsed, doc);
|
|
3002
|
+
case "file-exists":
|
|
3003
|
+
return evalFileExists(parsed, projectRoot);
|
|
3004
|
+
case "link-target-exists":
|
|
3005
|
+
return evalLinkTargetExists(parsed, opts);
|
|
3006
|
+
case "status-of":
|
|
3007
|
+
return evalStatusOf(parsed, opts, projectRoot);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
function evalFrontmatter(parsed, doc, projectRoot) {
|
|
3011
|
+
let fm;
|
|
3012
|
+
if (parsed.ref === ".") {
|
|
3013
|
+
fm = doc.fm;
|
|
3014
|
+
} else {
|
|
3015
|
+
const refVal = doc.fm[parsed.ref];
|
|
3016
|
+
if (refVal === void 0 || refVal === null) {
|
|
3017
|
+
return {
|
|
3018
|
+
pass: false,
|
|
3019
|
+
detail: `frontmatter key '${parsed.ref}' is missing or null in ${doc.absPath}`
|
|
3020
|
+
};
|
|
3021
|
+
}
|
|
3022
|
+
const linkedPath = resolveLinkedPath(String(refVal), doc.absPath, projectRoot);
|
|
3023
|
+
if (!linkedPath) {
|
|
3024
|
+
return {
|
|
3025
|
+
pass: false,
|
|
3026
|
+
detail: `linked file not found: ${refVal}`
|
|
3027
|
+
};
|
|
3028
|
+
}
|
|
3029
|
+
fm = readFrontmatterFromFile(linkedPath);
|
|
3030
|
+
}
|
|
3031
|
+
const actual = fm[parsed.field];
|
|
3032
|
+
const pass = compareValues(actual, parsed.op, parsed.value);
|
|
3033
|
+
const detail = pass ? `frontmatter(${parsed.ref}).${parsed.field} ${parsed.op} ${JSON.stringify(parsed.value)} \u2192 actual: ${JSON.stringify(actual)}` : `expected ${parsed.field} ${parsed.op} ${JSON.stringify(parsed.value)}, got ${JSON.stringify(actual)}`;
|
|
3034
|
+
return { pass, detail };
|
|
3035
|
+
}
|
|
3036
|
+
function compareValues(actual, op, expected) {
|
|
3037
|
+
if (expected === "null") {
|
|
3038
|
+
const isNull = actual === null || actual === void 0 || actual === "" || actual === "null";
|
|
3039
|
+
return op === "==" ? isNull : !isNull;
|
|
3040
|
+
}
|
|
3041
|
+
let a = actual;
|
|
3042
|
+
if (typeof a === "string") {
|
|
3043
|
+
a = a.replace(/^["']|["']$/g, "");
|
|
3044
|
+
if (a === "true") a = true;
|
|
3045
|
+
else if (a === "false") a = false;
|
|
3046
|
+
else {
|
|
3047
|
+
const n = Number(a);
|
|
3048
|
+
if (!isNaN(n) && a !== "") a = n;
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
switch (op) {
|
|
3052
|
+
case "==":
|
|
3053
|
+
return a === expected || String(a) === String(expected);
|
|
3054
|
+
case "!=":
|
|
3055
|
+
return a !== expected && String(a) !== String(expected);
|
|
3056
|
+
case ">=":
|
|
3057
|
+
return Number(a) >= Number(expected);
|
|
3058
|
+
case "<=":
|
|
3059
|
+
return Number(a) <= Number(expected);
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
function resolveLinkedPath(ref, docAbsPath, projectRoot) {
|
|
3063
|
+
const candidates = [
|
|
3064
|
+
path19.resolve(path19.dirname(docAbsPath), ref),
|
|
3065
|
+
path19.resolve(projectRoot, ref)
|
|
3066
|
+
];
|
|
3067
|
+
for (const candidate of candidates) {
|
|
3068
|
+
if (!candidate.startsWith(projectRoot)) continue;
|
|
3069
|
+
if (fs18.existsSync(candidate)) return candidate;
|
|
3070
|
+
}
|
|
3071
|
+
return null;
|
|
3072
|
+
}
|
|
3073
|
+
function readFrontmatterFromFile(absPath) {
|
|
3074
|
+
try {
|
|
3075
|
+
const raw = fs18.readFileSync(absPath, "utf8");
|
|
3076
|
+
const lines = raw.split("\n");
|
|
3077
|
+
if (lines[0] !== "---") return {};
|
|
3078
|
+
let closeIdx = -1;
|
|
3079
|
+
for (let i = 1; i < lines.length; i++) {
|
|
3080
|
+
if (lines[i] === "---") {
|
|
3081
|
+
closeIdx = i;
|
|
3082
|
+
break;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
if (closeIdx === -1) return {};
|
|
3086
|
+
const fmLines = lines.slice(1, closeIdx);
|
|
3087
|
+
const fm = {};
|
|
3088
|
+
for (const line of fmLines) {
|
|
3089
|
+
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
3090
|
+
const colon = line.indexOf(":");
|
|
3091
|
+
if (colon === -1) continue;
|
|
3092
|
+
const key = line.slice(0, colon).trim();
|
|
3093
|
+
const val = line.slice(colon + 1).trim();
|
|
3094
|
+
if (val === "" || val === "[]") {
|
|
3095
|
+
fm[key] = [];
|
|
3096
|
+
continue;
|
|
3097
|
+
}
|
|
3098
|
+
if (val.startsWith("{")) {
|
|
3099
|
+
fm[key] = val;
|
|
3100
|
+
continue;
|
|
3101
|
+
}
|
|
3102
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
3103
|
+
const inner = val.slice(1, -1).trim();
|
|
3104
|
+
fm[key] = inner === "" ? [] : inner.split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""));
|
|
3105
|
+
continue;
|
|
3106
|
+
}
|
|
3107
|
+
fm[key] = val.replace(/^["']|["']$/g, "");
|
|
3108
|
+
}
|
|
3109
|
+
return fm;
|
|
3110
|
+
} catch {
|
|
3111
|
+
return {};
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
function evalBodyContains(parsed, doc) {
|
|
3115
|
+
const body = doc.body;
|
|
3116
|
+
const needle = parsed.needle;
|
|
3117
|
+
let count = 0;
|
|
3118
|
+
let pos = 0;
|
|
3119
|
+
const sections = [];
|
|
3120
|
+
const bodySections = body.split(/^## /m);
|
|
3121
|
+
while ((pos = body.indexOf(needle, pos)) !== -1) {
|
|
3122
|
+
count++;
|
|
3123
|
+
const before = body.slice(0, pos);
|
|
3124
|
+
const sectionCount = (before.match(/^## /gm) || []).length;
|
|
3125
|
+
sections.push(sectionCount + 1);
|
|
3126
|
+
pos += needle.length;
|
|
3127
|
+
}
|
|
3128
|
+
const present = count > 0;
|
|
3129
|
+
void bodySections;
|
|
3130
|
+
if (parsed.negated) {
|
|
3131
|
+
if (present) {
|
|
3132
|
+
const sectionList = [...new Set(sections)].map((s) => `\xA7${s}`).join(", ");
|
|
3133
|
+
return {
|
|
3134
|
+
pass: false,
|
|
3135
|
+
detail: `${count} occurrence${count === 1 ? "" : "s"} at ${sectionList}`
|
|
3136
|
+
};
|
|
3137
|
+
}
|
|
3138
|
+
return { pass: true, detail: `'${needle}' not found in body` };
|
|
3139
|
+
} else {
|
|
3140
|
+
if (present) {
|
|
3141
|
+
return { pass: true, detail: `'${needle}' found ${count} time${count === 1 ? "" : "s"}` };
|
|
3142
|
+
}
|
|
3143
|
+
return { pass: false, detail: `'${needle}' not found in body` };
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
function evalSection(parsed, doc) {
|
|
3147
|
+
const body = doc.body;
|
|
3148
|
+
const rawParts = body.split(/^(?=## )/m);
|
|
3149
|
+
const hasPreamble = rawParts.length > 0 && !rawParts[0].startsWith("## ");
|
|
3150
|
+
const arrayIndex = hasPreamble ? parsed.index : parsed.index - 1;
|
|
3151
|
+
const sectionContent = rawParts[arrayIndex];
|
|
3152
|
+
const totalSections = hasPreamble ? rawParts.length - 1 : rawParts.length;
|
|
3153
|
+
if (!sectionContent) {
|
|
3154
|
+
return {
|
|
3155
|
+
pass: false,
|
|
3156
|
+
detail: `section ${parsed.index} not found (body has ${totalSections} sections)`
|
|
3157
|
+
};
|
|
3158
|
+
}
|
|
3159
|
+
let actualCount;
|
|
3160
|
+
switch (parsed.itemType) {
|
|
3161
|
+
case "checked-checkbox":
|
|
3162
|
+
actualCount = (sectionContent.match(/^\s*- \[x\]/gim) || []).length;
|
|
3163
|
+
break;
|
|
3164
|
+
case "unchecked-checkbox":
|
|
3165
|
+
actualCount = (sectionContent.match(/^\s*- \[ \]/gim) || []).length;
|
|
3166
|
+
break;
|
|
3167
|
+
case "listed-item":
|
|
3168
|
+
actualCount = (sectionContent.match(/^\s*- /gm) || []).length;
|
|
3169
|
+
break;
|
|
3170
|
+
}
|
|
3171
|
+
const pass = applyCountOp(actualCount, parsed.count.op, parsed.count.n);
|
|
3172
|
+
const opStr = parsed.count.op === ">=" ? "\u2265" : parsed.count.op;
|
|
3173
|
+
const detail = pass ? `section ${parsed.index} has ${actualCount} ${parsed.itemType} (${opStr}${parsed.count.n} required)` : `section ${parsed.index} has ${actualCount} ${parsed.itemType} (${opStr}${parsed.count.n} required)`;
|
|
3174
|
+
return { pass, detail };
|
|
3175
|
+
}
|
|
3176
|
+
function applyCountOp(actual, op, n) {
|
|
3177
|
+
switch (op) {
|
|
3178
|
+
case ">=":
|
|
3179
|
+
return actual >= n;
|
|
3180
|
+
case "==":
|
|
3181
|
+
return actual === n;
|
|
3182
|
+
case ">":
|
|
3183
|
+
return actual > n;
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
function evalFileExists(parsed, projectRoot) {
|
|
3187
|
+
const resolved = path19.resolve(projectRoot, parsed.path);
|
|
3188
|
+
if (!resolved.startsWith(projectRoot + path19.sep) && resolved !== projectRoot) {
|
|
3189
|
+
return {
|
|
3190
|
+
pass: false,
|
|
3191
|
+
detail: `path '${parsed.path}' resolves outside project root (sandbox violation)`
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
const exists = fs18.existsSync(resolved);
|
|
3195
|
+
return {
|
|
3196
|
+
pass: exists,
|
|
3197
|
+
detail: exists ? `${parsed.path} exists` : `${parsed.path} not found`
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
function evalLinkTargetExists(parsed, opts) {
|
|
3201
|
+
const projectRoot = opts?.projectRoot ?? process.cwd();
|
|
3202
|
+
const wikiIndexPath = opts?.wikiIndexPath ?? path19.join(projectRoot, ".cleargate", "wiki", "index.md");
|
|
3203
|
+
if (!wikiIndexPath.startsWith(projectRoot)) {
|
|
3204
|
+
return { pass: false, detail: "wikiIndexPath resolves outside project root" };
|
|
3205
|
+
}
|
|
3206
|
+
let indexContent;
|
|
3207
|
+
try {
|
|
3208
|
+
indexContent = fs18.readFileSync(wikiIndexPath, "utf8");
|
|
3209
|
+
} catch {
|
|
3210
|
+
return { pass: false, detail: `wiki index not found at ${wikiIndexPath}` };
|
|
3211
|
+
}
|
|
3212
|
+
const found = indexContent.includes(`[[${parsed.id}]]`);
|
|
3213
|
+
return {
|
|
3214
|
+
pass: found,
|
|
3215
|
+
detail: found ? `[[${parsed.id}]] found in wiki index` : `[[${parsed.id}]] not found in wiki index`
|
|
3216
|
+
};
|
|
3217
|
+
}
|
|
3218
|
+
function evalStatusOf(parsed, opts, projectRoot) {
|
|
3219
|
+
const wikiIndexPath = opts?.wikiIndexPath ?? path19.join(projectRoot, ".cleargate", "wiki", "index.md");
|
|
3220
|
+
if (!wikiIndexPath.startsWith(projectRoot)) {
|
|
3221
|
+
return { pass: false, detail: "wikiIndexPath resolves outside project root" };
|
|
3222
|
+
}
|
|
3223
|
+
let indexContent;
|
|
3224
|
+
try {
|
|
3225
|
+
indexContent = fs18.readFileSync(wikiIndexPath, "utf8");
|
|
3226
|
+
} catch {
|
|
3227
|
+
return { pass: false, detail: `wiki index not found at ${wikiIndexPath}` };
|
|
3228
|
+
}
|
|
3229
|
+
const rowMatch = indexContent.match(
|
|
3230
|
+
new RegExp(`\\[\\[${parsed.id}\\]\\]\\s*\\|[^|]+\\|[^|]+\\|\\s*([^|\\n]+)`)
|
|
3231
|
+
);
|
|
3232
|
+
if (!rowMatch) {
|
|
3233
|
+
return { pass: false, detail: `[[${parsed.id}]] not found in wiki index` };
|
|
3234
|
+
}
|
|
3235
|
+
const rawPath = rowMatch[1].trim();
|
|
3236
|
+
const fullPath = path19.resolve(projectRoot, rawPath);
|
|
3237
|
+
if (!fullPath.startsWith(projectRoot)) {
|
|
3238
|
+
return { pass: false, detail: `wiki path for ${parsed.id} resolves outside project root` };
|
|
3239
|
+
}
|
|
3240
|
+
const linkedFm = readFrontmatterFromFile(fullPath);
|
|
3241
|
+
const status = linkedFm["status"];
|
|
3242
|
+
if (status === void 0) {
|
|
3243
|
+
return { pass: false, detail: `[[${parsed.id}]] has no status field` };
|
|
3244
|
+
}
|
|
3245
|
+
const pass = String(status).replace(/^["']|["']$/g, "") === parsed.value;
|
|
3246
|
+
return {
|
|
3247
|
+
pass,
|
|
3248
|
+
detail: pass ? `status-of([[${parsed.id}]]) == ${parsed.value}` : `status-of([[${parsed.id}]]) is '${status}', expected '${parsed.value}'`
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
// src/lib/frontmatter-cache.ts
|
|
3253
|
+
import * as fs19 from "fs/promises";
|
|
3254
|
+
import yaml4 from "js-yaml";
|
|
3255
|
+
async function readCachedGate(absPath) {
|
|
3256
|
+
let raw;
|
|
3257
|
+
try {
|
|
3258
|
+
raw = await fs19.readFile(absPath, "utf8");
|
|
3259
|
+
} catch {
|
|
3260
|
+
return null;
|
|
3261
|
+
}
|
|
3262
|
+
let fm;
|
|
3263
|
+
try {
|
|
3264
|
+
({ fm } = parseFrontmatter(raw));
|
|
3265
|
+
} catch {
|
|
3266
|
+
return null;
|
|
3267
|
+
}
|
|
3268
|
+
return coerceCachedGate(fm["cached_gate_result"]);
|
|
3269
|
+
}
|
|
3270
|
+
async function writeCachedGate(absPath, result, opts) {
|
|
3271
|
+
const nowFn = opts?.now ?? (() => /* @__PURE__ */ new Date());
|
|
3272
|
+
const lastGateCheck = result.last_gate_check || toIsoSecond(nowFn());
|
|
3273
|
+
const newResult = {
|
|
3274
|
+
pass: result.pass,
|
|
3275
|
+
failing_criteria: result.failing_criteria,
|
|
3276
|
+
last_gate_check: lastGateCheck
|
|
3277
|
+
};
|
|
3278
|
+
const raw = await fs19.readFile(absPath, "utf8");
|
|
3279
|
+
let fm;
|
|
3280
|
+
let body;
|
|
3281
|
+
try {
|
|
3282
|
+
({ fm, body } = parseFrontmatter(raw));
|
|
3283
|
+
} catch {
|
|
3284
|
+
throw new Error(`writeCachedGate: failed to parse frontmatter in ${absPath}`);
|
|
3285
|
+
}
|
|
3286
|
+
const existing = coerceCachedGate(fm["cached_gate_result"]);
|
|
3287
|
+
if (existing && JSON.stringify(existing) === JSON.stringify(newResult)) {
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
3290
|
+
const newFm = {};
|
|
3291
|
+
let inserted = false;
|
|
3292
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
3293
|
+
if (k === "cached_gate_result") {
|
|
3294
|
+
newFm["cached_gate_result"] = newResult;
|
|
3295
|
+
inserted = true;
|
|
3296
|
+
} else {
|
|
3297
|
+
newFm[k] = v;
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
if (!inserted) {
|
|
3301
|
+
newFm["cached_gate_result"] = newResult;
|
|
3302
|
+
}
|
|
3303
|
+
const fmBlock = serializeFrontmatter(newFm);
|
|
3304
|
+
const newContent = body.length > 0 ? `${fmBlock}
|
|
3305
|
+
|
|
3306
|
+
${body}` : `${fmBlock}
|
|
3307
|
+
`;
|
|
3308
|
+
await fs19.writeFile(absPath, newContent, "utf8");
|
|
3309
|
+
}
|
|
3310
|
+
function coerceCachedGate(val) {
|
|
3311
|
+
if (val === void 0 || val === null) return null;
|
|
3312
|
+
if (typeof val === "object" && !Array.isArray(val)) {
|
|
3313
|
+
const c = val;
|
|
3314
|
+
return {
|
|
3315
|
+
pass: Boolean(c["pass"]),
|
|
3316
|
+
failing_criteria: Array.isArray(c["failing_criteria"]) ? c["failing_criteria"] : [],
|
|
3317
|
+
last_gate_check: String(c["last_gate_check"] ?? "")
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
if (typeof val === "string" && val.startsWith("{")) {
|
|
3321
|
+
try {
|
|
3322
|
+
const parsed = yaml4.load(val, { schema: yaml4.CORE_SCHEMA });
|
|
3323
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
3324
|
+
const p = parsed;
|
|
3325
|
+
return {
|
|
3326
|
+
pass: Boolean(p["pass"]),
|
|
3327
|
+
failing_criteria: Array.isArray(p["failing_criteria"]) ? p["failing_criteria"] : [],
|
|
3328
|
+
last_gate_check: String(p["last_gate_check"] ?? "")
|
|
3329
|
+
};
|
|
3330
|
+
} catch {
|
|
3331
|
+
return null;
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
return null;
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
// src/commands/gate.ts
|
|
3338
|
+
function loadGateBlocks(gatesDocPath) {
|
|
3339
|
+
const raw = fs20.readFileSync(gatesDocPath, "utf8");
|
|
3340
|
+
const blocks = [];
|
|
3341
|
+
const fenceRe = /^```yaml\n([\s\S]*?)^```/gm;
|
|
3342
|
+
let match;
|
|
3343
|
+
while ((match = fenceRe.exec(raw)) !== null) {
|
|
3344
|
+
const yamlContent = match[1];
|
|
3345
|
+
const parsed = yaml5.load(yamlContent);
|
|
3346
|
+
const block = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
3347
|
+
if (block && typeof block === "object" && "work_item_type" in block && "transition" in block && "severity" in block && "criteria" in block) {
|
|
3348
|
+
blocks.push(block);
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
return blocks;
|
|
3352
|
+
}
|
|
3353
|
+
function findGate(blocks, type, transition) {
|
|
3354
|
+
return blocks.find(
|
|
3355
|
+
(b) => b.work_item_type === type && b.transition === transition
|
|
3356
|
+
) ?? null;
|
|
3357
|
+
}
|
|
3358
|
+
function inferTransition(type, cachedGate) {
|
|
3359
|
+
const transitions = WORK_ITEM_TRANSITIONS[type];
|
|
3360
|
+
if (!cachedGate || !cachedGate.pass) {
|
|
3361
|
+
return transitions[0];
|
|
3362
|
+
}
|
|
3363
|
+
if (transitions.length === 1) {
|
|
3364
|
+
return transitions[0];
|
|
3365
|
+
}
|
|
3366
|
+
return transitions[1];
|
|
3367
|
+
}
|
|
3368
|
+
async function gateCheckHandler(file, opts, cli) {
|
|
3369
|
+
const stdoutFn = cli?.stdout ?? ((s) => process.stdout.write(s + "\n"));
|
|
3370
|
+
const stderrFn = cli?.stderr ?? ((s) => process.stderr.write(s + "\n"));
|
|
3371
|
+
const exitFn = cli?.exit ?? ((code) => process.exit(code));
|
|
3372
|
+
const cwd = cli?.cwd ?? process.cwd();
|
|
3373
|
+
const nowFn = cli?.now ?? (() => /* @__PURE__ */ new Date());
|
|
3374
|
+
const absPath = path20.isAbsolute(file) ? file : path20.resolve(cwd, file);
|
|
3375
|
+
if (!fs20.existsSync(absPath)) {
|
|
3376
|
+
stderrFn(`[cleargate gate] error: file not found: ${absPath}`);
|
|
3377
|
+
return exitFn(1);
|
|
3378
|
+
}
|
|
3379
|
+
let raw;
|
|
3380
|
+
try {
|
|
3381
|
+
raw = fs20.readFileSync(absPath, "utf8");
|
|
3382
|
+
} catch (err) {
|
|
3383
|
+
stderrFn(`[cleargate gate] error: cannot read file: ${absPath}`);
|
|
3384
|
+
return exitFn(1);
|
|
3385
|
+
}
|
|
3386
|
+
let fm;
|
|
3387
|
+
let body;
|
|
3388
|
+
try {
|
|
3389
|
+
({ fm, body } = parseFrontmatter(raw));
|
|
3390
|
+
} catch {
|
|
3391
|
+
stderrFn(`[cleargate gate] error: cannot parse frontmatter in: ${absPath}`);
|
|
3392
|
+
return exitFn(1);
|
|
3393
|
+
}
|
|
3394
|
+
const detectedType = detectWorkItemTypeFromFm(fm);
|
|
3395
|
+
if (!detectedType) {
|
|
3396
|
+
stderrFn(`[cleargate gate] error: unable to detect work-item type from frontmatter in: ${absPath}`);
|
|
3397
|
+
return exitFn(1);
|
|
3398
|
+
}
|
|
3399
|
+
const projectRoot = cwd;
|
|
3400
|
+
const gatesDocPath = cli?.gatesDocPath ?? path20.join(projectRoot, ".cleargate", "knowledge", "readiness-gates.md");
|
|
3401
|
+
if (!fs20.existsSync(gatesDocPath)) {
|
|
3402
|
+
stderrFn(`[cleargate gate] error: readiness-gates.md not found at: ${gatesDocPath}`);
|
|
3403
|
+
return exitFn(1);
|
|
3404
|
+
}
|
|
3405
|
+
let gateBlocks;
|
|
3406
|
+
try {
|
|
3407
|
+
gateBlocks = loadGateBlocks(gatesDocPath);
|
|
3408
|
+
} catch (err) {
|
|
3409
|
+
stderrFn(`[cleargate gate] error: failed to parse readiness-gates.md: ${String(err)}`);
|
|
3410
|
+
return exitFn(1);
|
|
3411
|
+
}
|
|
3412
|
+
const cachedGate = await readCachedGate(absPath);
|
|
3413
|
+
const transition = opts.transition ?? inferTransition(detectedType, cachedGate);
|
|
3414
|
+
const gate2 = findGate(gateBlocks, detectedType, transition);
|
|
3415
|
+
if (!gate2) {
|
|
3416
|
+
stderrFn(
|
|
3417
|
+
`[cleargate gate] error: no gate definition found for ${detectedType}.${transition}`
|
|
3418
|
+
);
|
|
3419
|
+
return exitFn(1);
|
|
3420
|
+
}
|
|
3421
|
+
const wikiIndexPath = cli?.wikiIndexPath;
|
|
3422
|
+
const parsedDoc = { fm, body, absPath };
|
|
3423
|
+
const evalOpts = { projectRoot, ...wikiIndexPath ? { wikiIndexPath } : {} };
|
|
3424
|
+
const failingCriteria = [];
|
|
3425
|
+
const allResults = [];
|
|
3426
|
+
for (const criterion of gate2.criteria) {
|
|
3427
|
+
let result;
|
|
3428
|
+
try {
|
|
3429
|
+
result = evaluate(criterion.check, parsedDoc, evalOpts);
|
|
3430
|
+
} catch (err) {
|
|
3431
|
+
result = { pass: false, detail: `predicate error: ${String(err)}` };
|
|
3432
|
+
}
|
|
3433
|
+
allResults.push({ id: criterion.id, ...result });
|
|
3434
|
+
if (!result.pass) {
|
|
3435
|
+
failingCriteria.push({ id: criterion.id, detail: result.detail });
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
const overallPass = failingCriteria.length === 0;
|
|
3439
|
+
const lastGateCheck = toIsoSecond(nowFn());
|
|
3440
|
+
const cacheResult = {
|
|
3441
|
+
pass: overallPass,
|
|
3442
|
+
failing_criteria: failingCriteria,
|
|
3443
|
+
last_gate_check: lastGateCheck
|
|
3444
|
+
};
|
|
3445
|
+
await writeCachedGate(absPath, cacheResult, { now: nowFn });
|
|
3446
|
+
const isAdvisory = gate2.severity === "advisory";
|
|
3447
|
+
const headerLine = `Gate: ${detectedType}.${transition} (${gate2.severity})`;
|
|
3448
|
+
stdoutFn(headerLine);
|
|
3449
|
+
if (overallPass) {
|
|
3450
|
+
stdoutFn(`\u2705 ${detectedType}.${transition} passed (${gate2.criteria.length} criteria)`);
|
|
3451
|
+
} else {
|
|
3452
|
+
for (const r of allResults) {
|
|
3453
|
+
if (!r.pass) {
|
|
3454
|
+
if (isAdvisory) {
|
|
3455
|
+
stdoutFn(`\u26A0 ${r.id}: ${r.detail} (advisory)`);
|
|
3456
|
+
} else {
|
|
3457
|
+
stdoutFn(`\u274C ${r.id}: ${r.detail}`);
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
if (opts.verbose) {
|
|
3461
|
+
stdoutFn(` [${r.pass ? "pass" : "fail"}] ${r.id}: ${r.detail}`);
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
if (!overallPass && !isAdvisory) {
|
|
3466
|
+
return exitFn(1);
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
async function gateExplainHandler(file, cli) {
|
|
3470
|
+
const stdoutFn = cli?.stdout ?? ((s) => process.stdout.write(s + "\n"));
|
|
3471
|
+
const stderrFn = cli?.stderr ?? ((s) => process.stderr.write(s + "\n"));
|
|
3472
|
+
const exitFn = cli?.exit ?? ((code) => process.exit(code));
|
|
3473
|
+
const cwd = cli?.cwd ?? process.cwd();
|
|
3474
|
+
const absPath = path20.isAbsolute(file) ? file : path20.resolve(cwd, file);
|
|
3475
|
+
if (!fs20.existsSync(absPath)) {
|
|
3476
|
+
stderrFn(`[cleargate gate] error: file not found: ${absPath}`);
|
|
3477
|
+
return exitFn(1);
|
|
3478
|
+
}
|
|
3479
|
+
const cached = await readCachedGate(absPath);
|
|
3480
|
+
if (!cached) {
|
|
3481
|
+
stdoutFn("no gate check cached; run: cleargate gate check <file>");
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
let raw;
|
|
3485
|
+
try {
|
|
3486
|
+
raw = fs20.readFileSync(absPath, "utf8");
|
|
3487
|
+
} catch {
|
|
3488
|
+
stderrFn(`[cleargate gate] error: cannot read file: ${absPath}`);
|
|
3489
|
+
return exitFn(1);
|
|
3490
|
+
}
|
|
3491
|
+
let fm;
|
|
3492
|
+
try {
|
|
3493
|
+
({ fm } = parseFrontmatter(raw));
|
|
3494
|
+
} catch {
|
|
3495
|
+
stderrFn(`[cleargate gate] error: cannot parse frontmatter in: ${absPath}`);
|
|
3496
|
+
return exitFn(1);
|
|
3497
|
+
}
|
|
3498
|
+
const detectedType = detectWorkItemTypeFromFm(fm) ?? "unknown";
|
|
3499
|
+
const failingIds = cached.failing_criteria.map((c) => c.id).join(", ");
|
|
3500
|
+
const statusStr = cached.pass ? "pass" : "fail";
|
|
3501
|
+
const summary = failingIds ? `${detectedType}: ${statusStr} at ${cached.last_gate_check}; ${cached.failing_criteria.length} failing: ${failingIds}` : `${detectedType}: ${statusStr} at ${cached.last_gate_check}`;
|
|
3502
|
+
stdoutFn(summary);
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
// src/commands/stamp-tokens.ts
|
|
3506
|
+
import * as fs22 from "fs";
|
|
3507
|
+
import * as path22 from "path";
|
|
3508
|
+
|
|
3509
|
+
// src/lib/ledger-reader.ts
|
|
3510
|
+
import * as fs21 from "fs";
|
|
3511
|
+
import * as path21 from "path";
|
|
3512
|
+
function findSprintRunsRoot(startDir) {
|
|
3513
|
+
let dir = startDir;
|
|
3514
|
+
while (true) {
|
|
3515
|
+
const candidate = path21.join(dir, ".cleargate", "sprint-runs");
|
|
3516
|
+
if (fs21.existsSync(candidate)) {
|
|
3517
|
+
return candidate;
|
|
3518
|
+
}
|
|
3519
|
+
const parent = path21.dirname(dir);
|
|
3520
|
+
if (parent === dir) {
|
|
3521
|
+
return null;
|
|
3522
|
+
}
|
|
3523
|
+
dir = parent;
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
function normalizeRow(raw) {
|
|
3527
|
+
const story_id = typeof raw["story_id"] === "string" ? raw["story_id"] : "";
|
|
3528
|
+
const work_item_id = typeof raw["work_item_id"] === "string" && raw["work_item_id"] !== "" ? raw["work_item_id"] : story_id;
|
|
3529
|
+
return {
|
|
3530
|
+
ts: typeof raw["ts"] === "string" ? raw["ts"] : "",
|
|
3531
|
+
sprint_id: typeof raw["sprint_id"] === "string" ? raw["sprint_id"] : "",
|
|
3532
|
+
agent_type: typeof raw["agent_type"] === "string" ? raw["agent_type"] : "unknown",
|
|
3533
|
+
story_id,
|
|
3534
|
+
work_item_id,
|
|
3535
|
+
session_id: typeof raw["session_id"] === "string" ? raw["session_id"] : "",
|
|
3536
|
+
transcript: typeof raw["transcript"] === "string" ? raw["transcript"] : "",
|
|
3537
|
+
input: typeof raw["input"] === "number" ? raw["input"] : 0,
|
|
3538
|
+
output: typeof raw["output"] === "number" ? raw["output"] : 0,
|
|
3539
|
+
cache_creation: typeof raw["cache_creation"] === "number" ? raw["cache_creation"] : 0,
|
|
3540
|
+
cache_read: typeof raw["cache_read"] === "number" ? raw["cache_read"] : 0,
|
|
3541
|
+
model: typeof raw["model"] === "string" ? raw["model"] : "",
|
|
3542
|
+
turns: typeof raw["turns"] === "number" ? raw["turns"] : 0
|
|
3543
|
+
};
|
|
3544
|
+
}
|
|
3545
|
+
function rowMatchesWorkItem(row, workItemId) {
|
|
3546
|
+
return row.work_item_id === workItemId || row.story_id === workItemId;
|
|
3547
|
+
}
|
|
3548
|
+
function buildBucket(session_id, rows) {
|
|
3549
|
+
const totals = rows.reduce(
|
|
3550
|
+
(acc, r) => ({
|
|
3551
|
+
input: acc.input + r.input,
|
|
3552
|
+
output: acc.output + r.output,
|
|
3553
|
+
cache_creation: acc.cache_creation + r.cache_creation,
|
|
3554
|
+
cache_read: acc.cache_read + r.cache_read,
|
|
3555
|
+
turns: acc.turns + r.turns
|
|
3556
|
+
}),
|
|
3557
|
+
{ input: 0, output: 0, cache_creation: 0, cache_read: 0, turns: 0 }
|
|
3558
|
+
);
|
|
3559
|
+
return { session_id, rows, totals };
|
|
3560
|
+
}
|
|
3561
|
+
function readLedgerForWorkItem(workItemId, opts = {}) {
|
|
3562
|
+
let sprintRunsRoot;
|
|
3563
|
+
if (opts.sprintRunsRoot) {
|
|
3564
|
+
sprintRunsRoot = opts.sprintRunsRoot;
|
|
3565
|
+
} else {
|
|
3566
|
+
const found = findSprintRunsRoot(process.cwd());
|
|
3567
|
+
if (!found) {
|
|
3568
|
+
return [];
|
|
3569
|
+
}
|
|
3570
|
+
sprintRunsRoot = found;
|
|
3571
|
+
}
|
|
3572
|
+
if (!fs21.existsSync(sprintRunsRoot)) {
|
|
3573
|
+
return [];
|
|
3574
|
+
}
|
|
3575
|
+
let ledgerFiles;
|
|
3576
|
+
try {
|
|
3577
|
+
const entries = fs21.readdirSync(sprintRunsRoot, { withFileTypes: true });
|
|
3578
|
+
ledgerFiles = entries.filter((e) => e.isDirectory()).map((e) => path21.join(sprintRunsRoot, e.name, "token-ledger.jsonl")).filter((f) => fs21.existsSync(f));
|
|
3579
|
+
} catch {
|
|
3580
|
+
return [];
|
|
3581
|
+
}
|
|
3582
|
+
const matchingRows = [];
|
|
3583
|
+
for (const ledgerFile of ledgerFiles) {
|
|
3584
|
+
let content;
|
|
3585
|
+
try {
|
|
3586
|
+
content = fs21.readFileSync(ledgerFile, "utf-8");
|
|
3587
|
+
} catch {
|
|
3588
|
+
continue;
|
|
3589
|
+
}
|
|
3590
|
+
const lines = content.split("\n").filter((l) => l.trim() !== "");
|
|
3591
|
+
for (const line of lines) {
|
|
3592
|
+
let raw;
|
|
3593
|
+
try {
|
|
3594
|
+
raw = JSON.parse(line);
|
|
3595
|
+
} catch {
|
|
3596
|
+
continue;
|
|
3597
|
+
}
|
|
3598
|
+
const row = normalizeRow(raw);
|
|
3599
|
+
if (opts.since && row.ts < opts.since) {
|
|
3600
|
+
continue;
|
|
3601
|
+
}
|
|
3602
|
+
if (rowMatchesWorkItem(row, workItemId)) {
|
|
3603
|
+
matchingRows.push(row);
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
const sessionMap = /* @__PURE__ */ new Map();
|
|
3608
|
+
for (const row of matchingRows) {
|
|
3609
|
+
const key = row.session_id || "(unknown-session)";
|
|
3610
|
+
const existing = sessionMap.get(key);
|
|
3611
|
+
if (existing) {
|
|
3612
|
+
existing.push(row);
|
|
3613
|
+
} else {
|
|
3614
|
+
sessionMap.set(key, [row]);
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
const buckets = Array.from(sessionMap.entries()).map(([session_id, rows]) => {
|
|
3618
|
+
rows.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
3619
|
+
return buildBucket(session_id, rows);
|
|
3620
|
+
});
|
|
3621
|
+
buckets.sort((a, b) => {
|
|
3622
|
+
const aTs = a.rows[0]?.ts ?? "";
|
|
3623
|
+
const bTs = b.rows[0]?.ts ?? "";
|
|
3624
|
+
return aTs.localeCompare(bTs);
|
|
3625
|
+
});
|
|
3626
|
+
return buckets;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
// src/commands/stamp-tokens.ts
|
|
3630
|
+
async function stampTokensHandler(file, opts, cli) {
|
|
3631
|
+
const stdoutFn = cli?.stdout ?? ((s) => process.stdout.write(s + "\n"));
|
|
3632
|
+
const exitFn = cli?.exit ?? ((code) => {
|
|
3633
|
+
process.exit(code);
|
|
3634
|
+
});
|
|
3635
|
+
const nowFn = cli?.now ?? (() => /* @__PURE__ */ new Date());
|
|
3636
|
+
const cwd = cli?.cwd ?? process.cwd();
|
|
3637
|
+
const absPath = path22.isAbsolute(file) ? file : path22.resolve(cwd, file);
|
|
3638
|
+
if (/\/\.cleargate\/delivery\/archive\//.test(absPath)) {
|
|
3639
|
+
stdoutFn(`[frozen] ${absPath}`);
|
|
3640
|
+
exitFn(0);
|
|
3641
|
+
return;
|
|
3642
|
+
}
|
|
3643
|
+
let rawContent;
|
|
3644
|
+
try {
|
|
3645
|
+
rawContent = fs22.readFileSync(absPath, "utf-8");
|
|
3646
|
+
} catch {
|
|
3647
|
+
stdoutFn(`[stamp-tokens] error: cannot read file: ${absPath}`);
|
|
3648
|
+
exitFn(1);
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
let fm = {};
|
|
3652
|
+
let body = "";
|
|
3653
|
+
const hasFrontmatter = rawContent.trimStart().startsWith("---");
|
|
3654
|
+
if (hasFrontmatter) {
|
|
3655
|
+
try {
|
|
3656
|
+
const parsed = parseFrontmatter(rawContent);
|
|
3657
|
+
fm = parsed.fm;
|
|
3658
|
+
body = parsed.body;
|
|
3659
|
+
} catch {
|
|
3660
|
+
stdoutFn(`[stamp-tokens] error: cannot parse frontmatter in: ${absPath}`);
|
|
3661
|
+
exitFn(1);
|
|
3662
|
+
return;
|
|
3663
|
+
}
|
|
3664
|
+
} else {
|
|
3665
|
+
body = rawContent;
|
|
3666
|
+
}
|
|
3667
|
+
const workItemId = extractWorkItemId(fm, absPath);
|
|
3668
|
+
if (!workItemId) {
|
|
3669
|
+
stdoutFn(`[stamp-tokens] error: cannot determine work_item_id from frontmatter or filename: ${absPath}`);
|
|
3670
|
+
exitFn(1);
|
|
3671
|
+
return;
|
|
3672
|
+
}
|
|
3673
|
+
const existingDraftTokens = coerceDraftTokens(fm["draft_tokens"]);
|
|
3674
|
+
const existingLastStamp = existingDraftTokens?.last_stamp ?? null;
|
|
3675
|
+
const buckets = readLedgerForWorkItem(workItemId, { sprintRunsRoot: cli?.sprintRunsRoot });
|
|
3676
|
+
if (existingLastStamp && buckets.length > 0) {
|
|
3677
|
+
const allRowsOlderThanLastStamp = buckets.every(
|
|
3678
|
+
(bucket) => bucket.rows.every((row) => row.ts < existingLastStamp)
|
|
3679
|
+
);
|
|
3680
|
+
if (allRowsOlderThanLastStamp && existingDraftTokens !== null) {
|
|
3681
|
+
exitFn(0);
|
|
3682
|
+
return;
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
3685
|
+
const nowIso = toIsoSecond(nowFn());
|
|
3686
|
+
let newFm;
|
|
3687
|
+
let stampError;
|
|
3688
|
+
if (buckets.length === 0) {
|
|
3689
|
+
stampError = `no ledger rows for work_item_id ${workItemId}`;
|
|
3690
|
+
const nullTokens = {
|
|
3691
|
+
input: null,
|
|
3692
|
+
output: null,
|
|
3693
|
+
cache_creation: null,
|
|
3694
|
+
cache_read: null,
|
|
3695
|
+
model: null,
|
|
3696
|
+
last_stamp: nowIso,
|
|
3697
|
+
sessions: []
|
|
3698
|
+
};
|
|
3699
|
+
newFm = buildNewFrontmatter(fm, nullTokens, stampError);
|
|
3700
|
+
} else {
|
|
3701
|
+
const tokens = aggregateBuckets(buckets, nowIso);
|
|
3702
|
+
newFm = buildNewFrontmatter(fm, tokens, void 0);
|
|
3703
|
+
delete newFm["stamp_error"];
|
|
3704
|
+
}
|
|
3705
|
+
const serialized = buildSerializedContent(newFm, body);
|
|
3706
|
+
if (opts.dryRun) {
|
|
3707
|
+
stdoutFn(`[dry-run] stamp-tokens would write draft_tokens for ${workItemId}:`);
|
|
3708
|
+
const draftTokensVal = newFm["draft_tokens"];
|
|
3709
|
+
stdoutFn(` draft_tokens: ${JSON.stringify(draftTokensVal)}`);
|
|
3710
|
+
if (stampError) {
|
|
3711
|
+
stdoutFn(` stamp_error: "${stampError}"`);
|
|
3712
|
+
}
|
|
3713
|
+
exitFn(0);
|
|
3714
|
+
return;
|
|
3715
|
+
}
|
|
3716
|
+
try {
|
|
3717
|
+
fs22.writeFileSync(absPath, serialized, "utf-8");
|
|
3718
|
+
} catch {
|
|
3719
|
+
stdoutFn(`[stamp-tokens] error: cannot write file: ${absPath}`);
|
|
3720
|
+
exitFn(1);
|
|
3721
|
+
return;
|
|
3722
|
+
}
|
|
3723
|
+
stdoutFn(`[stamped] ${absPath} (${workItemId})`);
|
|
3724
|
+
exitFn(0);
|
|
3725
|
+
}
|
|
3726
|
+
function extractWorkItemId(fm, absPath) {
|
|
3727
|
+
const idKeys = ["story_id", "epic_id", "proposal_id", "cr_id", "bug_id"];
|
|
3728
|
+
for (const key of idKeys) {
|
|
3729
|
+
const val = fm[key];
|
|
3730
|
+
if (typeof val === "string" && val.trim() !== "") {
|
|
3731
|
+
return val.trim();
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
const basename9 = path22.basename(absPath);
|
|
3735
|
+
const match = basename9.match(/^(STORY|EPIC|PROPOSAL|CR|BUG)-\d+(-\d+)?/i);
|
|
3736
|
+
if (match) {
|
|
3737
|
+
return match[0].toUpperCase();
|
|
3738
|
+
}
|
|
3739
|
+
const typeFromPath = detectWorkItemType(absPath);
|
|
3740
|
+
if (typeFromPath) {
|
|
3741
|
+
const idMatch = basename9.match(/((?:STORY|EPIC|PROPOSAL|CR|BUG)-\d+(?:-\d+)?)/i);
|
|
3742
|
+
if (idMatch) {
|
|
3743
|
+
return idMatch[1].toUpperCase();
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
return null;
|
|
3747
|
+
}
|
|
3748
|
+
function coerceDraftTokens(val) {
|
|
3749
|
+
if (val == null) return null;
|
|
3750
|
+
if (typeof val === "object" && !Array.isArray(val)) {
|
|
3751
|
+
const o = val;
|
|
3752
|
+
return {
|
|
3753
|
+
input: typeof o["input"] === "number" ? o["input"] : null,
|
|
3754
|
+
output: typeof o["output"] === "number" ? o["output"] : null,
|
|
3755
|
+
cache_creation: typeof o["cache_creation"] === "number" ? o["cache_creation"] : null,
|
|
3756
|
+
cache_read: typeof o["cache_read"] === "number" ? o["cache_read"] : null,
|
|
3757
|
+
model: typeof o["model"] === "string" ? o["model"] : null,
|
|
3758
|
+
last_stamp: typeof o["last_stamp"] === "string" ? o["last_stamp"] : "",
|
|
3759
|
+
sessions: Array.isArray(o["sessions"]) ? o["sessions"] : []
|
|
3760
|
+
};
|
|
3761
|
+
}
|
|
3762
|
+
if (typeof val === "string") {
|
|
3763
|
+
try {
|
|
3764
|
+
const parsed = JSON.parse(val);
|
|
3765
|
+
return parsed;
|
|
3766
|
+
} catch {
|
|
3767
|
+
return null;
|
|
3768
|
+
}
|
|
3769
|
+
}
|
|
3770
|
+
return null;
|
|
3771
|
+
}
|
|
3772
|
+
function aggregateBuckets(buckets, nowIso) {
|
|
3773
|
+
let totalInput = 0;
|
|
3774
|
+
let totalOutput = 0;
|
|
3775
|
+
let totalCacheCreation = 0;
|
|
3776
|
+
let totalCacheRead = 0;
|
|
3777
|
+
const uniqueModels = /* @__PURE__ */ new Set();
|
|
3778
|
+
const sessions = [];
|
|
3779
|
+
for (const bucket of buckets) {
|
|
3780
|
+
totalInput += bucket.totals.input;
|
|
3781
|
+
totalOutput += bucket.totals.output;
|
|
3782
|
+
totalCacheCreation += bucket.totals.cache_creation;
|
|
3783
|
+
totalCacheRead += bucket.totals.cache_read;
|
|
3784
|
+
const sessionModels = /* @__PURE__ */ new Set();
|
|
3785
|
+
for (const row of bucket.rows) {
|
|
3786
|
+
if (row.model) {
|
|
3787
|
+
uniqueModels.add(row.model);
|
|
3788
|
+
sessionModels.add(row.model);
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
const sessionModel = Array.from(sessionModels).sort().join(", ");
|
|
3792
|
+
sessions.push({
|
|
3793
|
+
session: bucket.session_id,
|
|
3794
|
+
model: sessionModel,
|
|
3795
|
+
input: bucket.totals.input,
|
|
3796
|
+
output: bucket.totals.output,
|
|
3797
|
+
cache_read: bucket.totals.cache_read,
|
|
3798
|
+
cache_creation: bucket.totals.cache_creation,
|
|
3799
|
+
ts: bucket.rows[0]?.ts ?? ""
|
|
3800
|
+
});
|
|
3801
|
+
}
|
|
3802
|
+
const model = Array.from(uniqueModels).sort().join(", ") || null;
|
|
3803
|
+
return {
|
|
3804
|
+
input: totalInput,
|
|
3805
|
+
output: totalOutput,
|
|
3806
|
+
cache_creation: totalCacheCreation,
|
|
3807
|
+
cache_read: totalCacheRead,
|
|
3808
|
+
model,
|
|
3809
|
+
last_stamp: nowIso,
|
|
3810
|
+
sessions
|
|
3811
|
+
};
|
|
3812
|
+
}
|
|
3813
|
+
function buildNewFrontmatter(existingFm, tokens, stampError) {
|
|
3814
|
+
const newFm = {};
|
|
3815
|
+
for (const [k, v] of Object.entries(existingFm)) {
|
|
3816
|
+
if (k !== "draft_tokens" && k !== "stamp_error") {
|
|
3817
|
+
newFm[k] = v;
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
if (stampError) {
|
|
3821
|
+
newFm["stamp_error"] = stampError;
|
|
3822
|
+
}
|
|
3823
|
+
newFm["draft_tokens"] = tokens;
|
|
3824
|
+
return newFm;
|
|
3825
|
+
}
|
|
3826
|
+
function buildSerializedContent(fm, body) {
|
|
3827
|
+
const fmBlock = serializeFrontmatter(fm);
|
|
3828
|
+
if (body.length > 0) {
|
|
3829
|
+
return `${fmBlock}
|
|
3830
|
+
|
|
3831
|
+
${body}`;
|
|
3832
|
+
}
|
|
3833
|
+
return `${fmBlock}
|
|
3834
|
+
`;
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
// src/commands/upgrade.ts
|
|
3838
|
+
import * as fsp from "fs/promises";
|
|
3839
|
+
import * as path23 from "path";
|
|
3840
|
+
|
|
3841
|
+
// src/lib/claude-md-surgery.ts
|
|
3842
|
+
var CLEARGATE_START = "<!-- CLEARGATE:START -->";
|
|
3843
|
+
var CLEARGATE_END = "<!-- CLEARGATE:END -->";
|
|
3844
|
+
var BLOCK_REGEX2 = /<!-- CLEARGATE:START -->([\s\S]*)<!-- CLEARGATE:END -->/;
|
|
3845
|
+
function readBlock(content) {
|
|
3846
|
+
const match = BLOCK_REGEX2.exec(content);
|
|
3847
|
+
if (!match) return null;
|
|
3848
|
+
return match[1];
|
|
3849
|
+
}
|
|
3850
|
+
function writeBlock(content, newBlockBody) {
|
|
3851
|
+
if (!content.includes(CLEARGATE_START)) {
|
|
3852
|
+
throw new Error("CLAUDE.md is missing <!-- CLEARGATE:START --> marker");
|
|
3853
|
+
}
|
|
3854
|
+
if (!content.includes(CLEARGATE_END)) {
|
|
3855
|
+
throw new Error("CLAUDE.md is missing <!-- CLEARGATE:END --> marker");
|
|
3856
|
+
}
|
|
3857
|
+
return content.replace(BLOCK_REGEX2, `${CLEARGATE_START}${newBlockBody}${CLEARGATE_END}`);
|
|
3858
|
+
}
|
|
3859
|
+
function removeBlock(content) {
|
|
3860
|
+
if (!content.includes(CLEARGATE_START)) {
|
|
3861
|
+
throw new Error("CLAUDE.md is missing <!-- CLEARGATE:START --> marker");
|
|
3862
|
+
}
|
|
3863
|
+
if (!content.includes(CLEARGATE_END)) {
|
|
3864
|
+
throw new Error("CLAUDE.md is missing <!-- CLEARGATE:END --> marker");
|
|
3865
|
+
}
|
|
3866
|
+
return content.replace(BLOCK_REGEX2, "");
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
// src/lib/settings-json-surgery.ts
|
|
3870
|
+
function isClearGateCommand(command) {
|
|
3871
|
+
if (command.includes("wiki ingest")) return true;
|
|
3872
|
+
return /\/\.claude\/hooks\/(token-ledger|stamp-and-gate|session-start|wiki-ingest|cleargate-[^/]*)\.sh/.test(command);
|
|
3873
|
+
}
|
|
3874
|
+
function removeClearGateHooks(settings) {
|
|
3875
|
+
if (!settings.hooks) return { ...settings };
|
|
3876
|
+
const newHooks = {};
|
|
3877
|
+
for (const [eventName, entries] of Object.entries(settings.hooks)) {
|
|
3878
|
+
if (!entries) continue;
|
|
3879
|
+
const filteredEntries = [];
|
|
3880
|
+
for (const entry of entries) {
|
|
3881
|
+
if (!entry.hooks || entry.hooks.length === 0) {
|
|
3882
|
+
filteredEntries.push(entry);
|
|
3883
|
+
continue;
|
|
3884
|
+
}
|
|
3885
|
+
const remainingInnerHooks = entry.hooks.filter(
|
|
3886
|
+
(h) => !isClearGateCommand(h.command)
|
|
3887
|
+
);
|
|
3888
|
+
if (remainingInnerHooks.length === 0) {
|
|
3889
|
+
continue;
|
|
3890
|
+
}
|
|
3891
|
+
if (remainingInnerHooks.length === entry.hooks.length) {
|
|
3892
|
+
filteredEntries.push(entry);
|
|
3893
|
+
} else {
|
|
3894
|
+
filteredEntries.push({ ...entry, hooks: remainingInnerHooks });
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
if (filteredEntries.length > 0) {
|
|
3898
|
+
newHooks[eventName] = filteredEntries;
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
const result = { ...settings };
|
|
3902
|
+
if (Object.keys(newHooks).length > 0) {
|
|
3903
|
+
result.hooks = newHooks;
|
|
3904
|
+
} else {
|
|
3905
|
+
delete result.hooks;
|
|
3906
|
+
}
|
|
3907
|
+
return result;
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
// src/lib/merge-ui.ts
|
|
3911
|
+
import { createPatch } from "diff";
|
|
3912
|
+
function renderInlineDiff(ours, theirs, filePath) {
|
|
3913
|
+
return createPatch(filePath, ours, theirs, "installed", "upstream");
|
|
3914
|
+
}
|
|
3915
|
+
async function promptMergeChoice(opts) {
|
|
3916
|
+
const { path: filePath, state, ours, theirs } = opts;
|
|
3917
|
+
const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
|
|
3918
|
+
const stdin = opts.stdin ?? process.stdin;
|
|
3919
|
+
stdout(`
|
|
3920
|
+
[merge] ${filePath} state=${state}
|
|
3921
|
+
`);
|
|
3922
|
+
const patch = renderInlineDiff(ours, theirs, filePath);
|
|
3923
|
+
stdout(patch + "\n");
|
|
3924
|
+
stdout("[k]eep mine / [t]ake theirs / [e]dit in $EDITOR: ");
|
|
3925
|
+
return new Promise((resolve13, reject) => {
|
|
3926
|
+
let buf = "";
|
|
3927
|
+
const onData = (chunk) => {
|
|
3928
|
+
buf += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
3929
|
+
const newline = buf.indexOf("\n");
|
|
3930
|
+
if (newline !== -1) {
|
|
3931
|
+
const answer = buf.slice(0, newline).trim().toLowerCase();
|
|
3932
|
+
stdin.removeListener("data", onData);
|
|
3933
|
+
stdin.removeListener("error", onError);
|
|
3934
|
+
if (answer === "k" || answer === "t" || answer === "e") {
|
|
3935
|
+
resolve13(answer);
|
|
3936
|
+
} else {
|
|
3937
|
+
stdout(`Unknown choice '${answer}'; defaulting to [k]eep mine.
|
|
3938
|
+
`);
|
|
3939
|
+
resolve13("k");
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
};
|
|
3943
|
+
const onError = (err) => {
|
|
3944
|
+
stdin.removeListener("data", onData);
|
|
3945
|
+
reject(err);
|
|
3946
|
+
};
|
|
3947
|
+
stdin.on("data", onData);
|
|
3948
|
+
stdin.once("error", onError);
|
|
3949
|
+
});
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
// src/lib/editor.ts
|
|
3953
|
+
import { spawn } from "child_process";
|
|
3954
|
+
async function openInEditor(filePath, opts) {
|
|
3955
|
+
const env = opts?.env ?? process.env;
|
|
3956
|
+
const editor = opts?.editor ?? env["EDITOR"] ?? env["VISUAL"];
|
|
3957
|
+
if (!editor) {
|
|
3958
|
+
throw new Error("$EDITOR not set; cannot [e]dit option. Set the EDITOR environment variable.");
|
|
3959
|
+
}
|
|
3960
|
+
return new Promise((resolve13, reject) => {
|
|
3961
|
+
const child = spawn(editor, [filePath], {
|
|
3962
|
+
stdio: "inherit",
|
|
3963
|
+
env: { ...env }
|
|
3964
|
+
});
|
|
3965
|
+
child.on("error", (err) => {
|
|
3966
|
+
reject(new Error(`Failed to start editor '${editor}': ${err.message}`));
|
|
3967
|
+
});
|
|
3968
|
+
child.on("close", (code) => {
|
|
3969
|
+
resolve13({ exitCode: code ?? 0 });
|
|
3970
|
+
});
|
|
3971
|
+
});
|
|
3972
|
+
}
|
|
3973
|
+
function containsConflictMarkers(content) {
|
|
3974
|
+
return content.includes("<<<<<<< ours") || content.includes(">>>>>>> theirs") || // Also catch generic git-style markers in case user merged manually
|
|
3975
|
+
/^<<<<<<< /m.test(content) || /^>>>>>>> /m.test(content);
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
// src/commands/upgrade.ts
|
|
3979
|
+
async function writeAtomic2(filePath, content) {
|
|
3980
|
+
const tmpPath = filePath + ".tmp." + Date.now();
|
|
3981
|
+
await fsp.writeFile(tmpPath, content, "utf-8");
|
|
3982
|
+
await fsp.rename(tmpPath, filePath);
|
|
3983
|
+
}
|
|
3984
|
+
async function updateSnapshotEntry(projectRoot, filePath, newSha) {
|
|
3985
|
+
const snapshotPath = path23.join(projectRoot, ".cleargate", ".install-manifest.json");
|
|
3986
|
+
let snapshot;
|
|
3987
|
+
try {
|
|
3988
|
+
const raw = await fsp.readFile(snapshotPath, "utf-8");
|
|
3989
|
+
snapshot = JSON.parse(raw);
|
|
3990
|
+
} catch {
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
const updated = {
|
|
3994
|
+
...snapshot,
|
|
3995
|
+
files: snapshot.files.map(
|
|
3996
|
+
(entry) => entry.path === filePath ? { ...entry, sha256: newSha } : entry
|
|
3997
|
+
)
|
|
3998
|
+
};
|
|
3999
|
+
await writeAtomic2(snapshotPath, JSON.stringify(updated, null, 2) + "\n");
|
|
4000
|
+
}
|
|
4001
|
+
function isClaudeMd(filePath) {
|
|
4002
|
+
return path23.basename(filePath) === "CLAUDE.md";
|
|
4003
|
+
}
|
|
4004
|
+
function isSettingsJson(filePath) {
|
|
4005
|
+
return path23.basename(filePath) === "settings.json" && filePath.includes(".claude");
|
|
4006
|
+
}
|
|
4007
|
+
async function applyAlwaysOverwrite(entry, projectRoot, packageRoot, stdout) {
|
|
4008
|
+
const targetPath = path23.join(projectRoot, entry.path);
|
|
4009
|
+
const sourcePath = path23.join(packageRoot, entry.path);
|
|
4010
|
+
try {
|
|
4011
|
+
const pkgContent = await fsp.readFile(sourcePath, "utf-8");
|
|
4012
|
+
await fsp.mkdir(path23.dirname(targetPath), { recursive: true });
|
|
4013
|
+
await writeAtomic2(targetPath, pkgContent);
|
|
4014
|
+
await updateSnapshotEntry(projectRoot, entry.path, entry.sha256);
|
|
4015
|
+
stdout(`[always] overwritten: ${entry.path}`);
|
|
4016
|
+
} catch (err) {
|
|
4017
|
+
stdout(`[always] error overwriting ${entry.path}: ${err.message}`);
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
async function applyMerge3Way(entry, projectRoot, packageRoot, installSha, currentSha, flags, opts) {
|
|
4021
|
+
const { stdout, stderr, promptMergeChoiceFn, openInEditorFn, stdin } = opts;
|
|
4022
|
+
const targetPath = path23.join(projectRoot, entry.path);
|
|
4023
|
+
const sourcePath = path23.join(packageRoot, entry.path);
|
|
4024
|
+
let ours = "";
|
|
4025
|
+
let theirs = "";
|
|
4026
|
+
try {
|
|
4027
|
+
ours = await fsp.readFile(targetPath, "utf-8");
|
|
4028
|
+
} catch {
|
|
4029
|
+
ours = "";
|
|
4030
|
+
}
|
|
4031
|
+
try {
|
|
4032
|
+
theirs = await fsp.readFile(sourcePath, "utf-8");
|
|
4033
|
+
} catch {
|
|
4034
|
+
stdout(`[merge] skip: package file not found for ${entry.path}`);
|
|
4035
|
+
return { updated: false, newSha: null };
|
|
4036
|
+
}
|
|
4037
|
+
const state = classify(entry.sha256, installSha, currentSha, entry.tier);
|
|
4038
|
+
let choice;
|
|
4039
|
+
if (flags.yes) {
|
|
4040
|
+
choice = "t";
|
|
4041
|
+
stdout(`[yes] taking theirs: ${entry.path} state=${state}`);
|
|
4042
|
+
} else {
|
|
4043
|
+
choice = await promptMergeChoiceFn({
|
|
4044
|
+
path: entry.path,
|
|
4045
|
+
state,
|
|
4046
|
+
ours,
|
|
4047
|
+
theirs,
|
|
4048
|
+
stdin,
|
|
4049
|
+
stdout
|
|
4050
|
+
});
|
|
4051
|
+
}
|
|
4052
|
+
if (choice === "k") {
|
|
4053
|
+
stdout(`[keep] ${entry.path}`);
|
|
4054
|
+
await updateSnapshotEntry(projectRoot, entry.path, currentSha);
|
|
4055
|
+
return { updated: true, newSha: currentSha };
|
|
4056
|
+
}
|
|
4057
|
+
if (choice === "t") {
|
|
4058
|
+
let mergedContent = theirs;
|
|
4059
|
+
if (isClaudeMd(entry.path)) {
|
|
4060
|
+
try {
|
|
4061
|
+
const ourBlock = readBlock(ours);
|
|
4062
|
+
const theirBlock = readBlock(theirs);
|
|
4063
|
+
if (ourBlock !== null && theirBlock !== null) {
|
|
4064
|
+
mergedContent = writeBlock(ours, theirBlock);
|
|
4065
|
+
} else if (theirBlock !== null) {
|
|
4066
|
+
mergedContent = theirs;
|
|
4067
|
+
}
|
|
4068
|
+
} catch {
|
|
4069
|
+
mergedContent = theirs;
|
|
4070
|
+
}
|
|
4071
|
+
} else if (isSettingsJson(entry.path)) {
|
|
4072
|
+
try {
|
|
4073
|
+
const ourSettings = JSON.parse(ours);
|
|
4074
|
+
const theirSettings = JSON.parse(theirs);
|
|
4075
|
+
const withoutOurCg = removeClearGateHooks(ourSettings);
|
|
4076
|
+
const cgHooks = theirSettings.hooks ?? {};
|
|
4077
|
+
const merged = { ...withoutOurCg };
|
|
4078
|
+
if (Object.keys(cgHooks).length > 0) {
|
|
4079
|
+
merged.hooks = { ...withoutOurCg.hooks ?? {}, ...cgHooks };
|
|
4080
|
+
}
|
|
4081
|
+
mergedContent = JSON.stringify(merged, null, 2) + "\n";
|
|
4082
|
+
} catch {
|
|
4083
|
+
mergedContent = theirs;
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
await fsp.mkdir(path23.dirname(targetPath), { recursive: true });
|
|
4087
|
+
await writeAtomic2(targetPath, mergedContent);
|
|
4088
|
+
const newSha2 = hashNormalized(mergedContent);
|
|
4089
|
+
await updateSnapshotEntry(projectRoot, entry.path, newSha2);
|
|
4090
|
+
stdout(`[take] ${entry.path}`);
|
|
4091
|
+
return { updated: true, newSha: newSha2 };
|
|
4092
|
+
}
|
|
4093
|
+
const mergeFilePath = targetPath + ".cleargate-merge";
|
|
4094
|
+
const conflictContent = `<<<<<<< ours (installed)
|
|
4095
|
+
${ours}=======
|
|
4096
|
+
${theirs}>>>>>>> theirs (upstream)
|
|
4097
|
+
`;
|
|
4098
|
+
await fsp.mkdir(path23.dirname(mergeFilePath), { recursive: true });
|
|
4099
|
+
await writeAtomic2(mergeFilePath, conflictContent);
|
|
4100
|
+
try {
|
|
4101
|
+
const result = await openInEditorFn(mergeFilePath);
|
|
4102
|
+
if (result.exitCode !== 0) {
|
|
4103
|
+
stderr(`[edit] editor exited with code ${result.exitCode}; markers may remain in ${mergeFilePath}`);
|
|
4104
|
+
}
|
|
4105
|
+
} catch (err) {
|
|
4106
|
+
stderr(`[edit] could not open editor: ${err.message}`);
|
|
4107
|
+
stderr(`[edit] resolve markers manually in: ${mergeFilePath}`);
|
|
4108
|
+
return { updated: false, newSha: null };
|
|
4109
|
+
}
|
|
4110
|
+
let edited = "";
|
|
4111
|
+
try {
|
|
4112
|
+
edited = await fsp.readFile(mergeFilePath, "utf-8");
|
|
4113
|
+
} catch {
|
|
4114
|
+
stderr(`[edit] could not read ${mergeFilePath} after editor exit`);
|
|
4115
|
+
return { updated: false, newSha: null };
|
|
4116
|
+
}
|
|
4117
|
+
if (containsConflictMarkers(edited)) {
|
|
4118
|
+
stderr(`[edit] unresolved conflict markers remain in ${mergeFilePath}`);
|
|
4119
|
+
stderr(`[edit] file NOT updated. Resolve manually and re-run upgrade.`);
|
|
4120
|
+
return { updated: false, newSha: null };
|
|
4121
|
+
}
|
|
4122
|
+
await writeAtomic2(targetPath, edited);
|
|
4123
|
+
try {
|
|
4124
|
+
await fsp.unlink(mergeFilePath);
|
|
4125
|
+
} catch {
|
|
4126
|
+
}
|
|
4127
|
+
const newSha = hashNormalized(edited);
|
|
4128
|
+
await updateSnapshotEntry(projectRoot, entry.path, newSha);
|
|
4129
|
+
stdout(`[edit] resolved: ${entry.path}`);
|
|
4130
|
+
return { updated: true, newSha };
|
|
4131
|
+
}
|
|
4132
|
+
async function upgradeHandler(flags, cli) {
|
|
4133
|
+
const cwd = cli?.cwd ?? process.cwd();
|
|
4134
|
+
const now = cli?.now ? cli.now() : /* @__PURE__ */ new Date();
|
|
4135
|
+
const stdout = cli?.stdout ?? ((s) => process.stdout.write(s + "\n"));
|
|
4136
|
+
const stderr = cli?.stderr ?? ((s) => process.stderr.write(s + "\n"));
|
|
4137
|
+
const exit = cli?.exit ?? ((code) => process.exit(code));
|
|
4138
|
+
const promptMergeChoiceFn = cli?.promptMergeChoice ?? promptMergeChoice;
|
|
4139
|
+
const openInEditorFn = cli?.openInEditor ?? openInEditor;
|
|
4140
|
+
const stdin = cli?.stdin;
|
|
4141
|
+
let pkgManifest;
|
|
4142
|
+
try {
|
|
4143
|
+
pkgManifest = loadPackageManifest({ packageRoot: cli?.packageRoot });
|
|
4144
|
+
} catch (err) {
|
|
4145
|
+
stderr(`[upgrade] ${err.message}`);
|
|
4146
|
+
exit(1);
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
const installSnapshot = await loadInstallSnapshot(cwd);
|
|
4150
|
+
const snapshotByPath = /* @__PURE__ */ new Map();
|
|
4151
|
+
for (const entry of installSnapshot?.files ?? []) {
|
|
4152
|
+
snapshotByPath.set(entry.path, entry.sha256);
|
|
4153
|
+
}
|
|
4154
|
+
const onlyTier = flags.only;
|
|
4155
|
+
const filteredFiles = onlyTier ? pkgManifest.files.filter((e) => e.tier === onlyTier) : pkgManifest.files;
|
|
4156
|
+
const workItems = [];
|
|
4157
|
+
await Promise.all(
|
|
4158
|
+
filteredFiles.map(async (entry) => {
|
|
4159
|
+
if (entry.tier === "user-artifact") {
|
|
4160
|
+
return;
|
|
4161
|
+
}
|
|
4162
|
+
const currentSha = await computeCurrentSha(entry, cwd);
|
|
4163
|
+
const installSha = snapshotByPath.get(entry.path) ?? null;
|
|
4164
|
+
let action;
|
|
4165
|
+
switch (entry.overwrite_policy) {
|
|
4166
|
+
case "always":
|
|
4167
|
+
action = "overwrite";
|
|
4168
|
+
break;
|
|
4169
|
+
case "skip":
|
|
4170
|
+
case "preserve":
|
|
4171
|
+
action = "skip";
|
|
4172
|
+
break;
|
|
4173
|
+
case "merge-3way":
|
|
4174
|
+
default:
|
|
4175
|
+
action = "merge-3way";
|
|
4176
|
+
break;
|
|
4177
|
+
}
|
|
4178
|
+
workItems.push({ entry, currentSha, installSha, action });
|
|
4179
|
+
})
|
|
4180
|
+
);
|
|
4181
|
+
workItems.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
|
|
4182
|
+
if (flags.dryRun) {
|
|
4183
|
+
let count = 0;
|
|
4184
|
+
for (const item of workItems) {
|
|
4185
|
+
const state = classify(item.entry.sha256, item.installSha, item.currentSha, item.entry.tier);
|
|
4186
|
+
stdout(`[dry-run] ${item.entry.path} action=${item.action} state=${state}`);
|
|
4187
|
+
count++;
|
|
4188
|
+
}
|
|
4189
|
+
stdout(`[dry-run] ${count} files planned. No changes made.`);
|
|
4190
|
+
return;
|
|
4191
|
+
}
|
|
4192
|
+
const packageRoot = cli?.packageRoot ?? cwd;
|
|
4193
|
+
const driftMap = {};
|
|
4194
|
+
for (const item of workItems) {
|
|
4195
|
+
const { entry, currentSha, installSha, action } = item;
|
|
4196
|
+
switch (action) {
|
|
4197
|
+
case "skip": {
|
|
4198
|
+
stdout(`[skip] ${entry.path} policy=${entry.overwrite_policy}`);
|
|
4199
|
+
break;
|
|
4200
|
+
}
|
|
4201
|
+
case "overwrite": {
|
|
4202
|
+
await applyAlwaysOverwrite(entry, cwd, packageRoot, stdout);
|
|
4203
|
+
break;
|
|
4204
|
+
}
|
|
4205
|
+
case "merge-3way": {
|
|
4206
|
+
await applyMerge3Way(
|
|
4207
|
+
entry,
|
|
4208
|
+
cwd,
|
|
4209
|
+
packageRoot,
|
|
4210
|
+
installSha,
|
|
4211
|
+
currentSha,
|
|
4212
|
+
{ yes: flags.yes, dryRun: false },
|
|
4213
|
+
{ stdout, stderr, promptMergeChoiceFn, openInEditorFn, stdin }
|
|
4214
|
+
);
|
|
4215
|
+
break;
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
const postSha = await computeCurrentSha(entry, cwd);
|
|
4219
|
+
driftMap[entry.path] = {
|
|
4220
|
+
state: classify(entry.sha256, installSha, postSha, entry.tier),
|
|
4221
|
+
entry,
|
|
4222
|
+
install_sha: installSha,
|
|
4223
|
+
current_sha: postSha,
|
|
4224
|
+
package_sha: entry.sha256
|
|
4225
|
+
};
|
|
4226
|
+
}
|
|
4227
|
+
await writeDriftState(cwd, driftMap, { lastRefreshed: now.toISOString() });
|
|
4228
|
+
stdout("[upgrade] complete.");
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
// src/commands/uninstall.ts
|
|
4232
|
+
import * as fs23 from "fs";
|
|
4233
|
+
import * as fsp2 from "fs/promises";
|
|
4234
|
+
import * as path24 from "path";
|
|
4235
|
+
import { execSync as execSync2 } from "child_process";
|
|
4236
|
+
var USER_ARTIFACT_TIERS = ["user-artifact"];
|
|
4237
|
+
var FRAMEWORK_TIERS = ["protocol", "template", "agent", "hook", "skill", "cli-config", "derived"];
|
|
4238
|
+
function parseTierList(raw) {
|
|
4239
|
+
const result = /* @__PURE__ */ new Set();
|
|
4240
|
+
for (const item of raw) {
|
|
4241
|
+
for (const t of item.split(",")) {
|
|
4242
|
+
const tier = t.trim();
|
|
4243
|
+
if (tier === "all") {
|
|
4244
|
+
for (const f of FRAMEWORK_TIERS) result.add(f);
|
|
4245
|
+
for (const u of USER_ARTIFACT_TIERS) result.add(u);
|
|
4246
|
+
} else {
|
|
4247
|
+
result.add(tier);
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
return result;
|
|
4252
|
+
}
|
|
4253
|
+
function shouldPreserve(entry, preserveSet, removeSet) {
|
|
4254
|
+
if (removeSet.has(entry.tier)) return false;
|
|
4255
|
+
if (preserveSet.has(entry.tier)) return true;
|
|
4256
|
+
if (USER_ARTIFACT_TIERS.includes(entry.tier)) return true;
|
|
4257
|
+
return false;
|
|
4258
|
+
}
|
|
4259
|
+
function resolveProjectName(target) {
|
|
4260
|
+
const pkgPath = path24.join(target, "package.json");
|
|
4261
|
+
if (fs23.existsSync(pkgPath)) {
|
|
4262
|
+
try {
|
|
4263
|
+
const raw = fs23.readFileSync(pkgPath, "utf-8");
|
|
4264
|
+
const parsed = JSON.parse(raw);
|
|
4265
|
+
if (parsed.name && typeof parsed.name === "string") {
|
|
4266
|
+
return parsed.name;
|
|
4267
|
+
}
|
|
4268
|
+
} catch {
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
return path24.basename(target);
|
|
4272
|
+
}
|
|
4273
|
+
function detectUncommittedChanges(target, manifestPaths, gitRunner) {
|
|
4274
|
+
const run = gitRunner ?? ((args) => {
|
|
4275
|
+
try {
|
|
4276
|
+
const out = execSync2(["git", ...args].join(" "), {
|
|
4277
|
+
cwd: target,
|
|
4278
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
4279
|
+
encoding: "utf-8"
|
|
4280
|
+
});
|
|
4281
|
+
return { stdout: out, code: 0 };
|
|
4282
|
+
} catch (e) {
|
|
4283
|
+
const err = e;
|
|
4284
|
+
return { stdout: err.stdout ?? "", code: err.status ?? 1 };
|
|
4285
|
+
}
|
|
4286
|
+
});
|
|
4287
|
+
const isGit = run(["-C", target, "rev-parse", "--is-inside-work-tree"]);
|
|
4288
|
+
if (isGit.code !== 0) {
|
|
4289
|
+
return [];
|
|
4290
|
+
}
|
|
4291
|
+
const result = run(["-C", target, "status", "--porcelain"]);
|
|
4292
|
+
if (result.code !== 0) {
|
|
4293
|
+
return [];
|
|
4294
|
+
}
|
|
4295
|
+
const changedFiles = result.stdout.split("\n").filter(Boolean).map((line) => line.slice(3).trim());
|
|
4296
|
+
const manifestSet = new Set(manifestPaths);
|
|
4297
|
+
return changedFiles.filter((f) => manifestSet.has(f));
|
|
4298
|
+
}
|
|
4299
|
+
async function removeFromPackageJson(target, dryRun) {
|
|
4300
|
+
const pkgPath = path24.join(target, "package.json");
|
|
4301
|
+
if (!fs23.existsSync(pkgPath)) return false;
|
|
4302
|
+
let raw;
|
|
4303
|
+
try {
|
|
4304
|
+
raw = await fsp2.readFile(pkgPath, "utf-8");
|
|
4305
|
+
} catch {
|
|
4306
|
+
return false;
|
|
4307
|
+
}
|
|
4308
|
+
let parsed;
|
|
4309
|
+
try {
|
|
4310
|
+
parsed = JSON.parse(raw);
|
|
4311
|
+
} catch {
|
|
4312
|
+
return false;
|
|
4313
|
+
}
|
|
4314
|
+
let modified = false;
|
|
4315
|
+
for (const key of ["dependencies", "devDependencies"]) {
|
|
4316
|
+
const deps = parsed[key];
|
|
4317
|
+
if (deps && typeof deps === "object" && "@cleargate/cli" in deps) {
|
|
4318
|
+
const updated = { ...deps };
|
|
4319
|
+
delete updated["@cleargate/cli"];
|
|
4320
|
+
parsed[key] = updated;
|
|
4321
|
+
modified = true;
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4324
|
+
if (modified && !dryRun) {
|
|
4325
|
+
await fsp2.writeFile(pkgPath, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
|
|
4326
|
+
}
|
|
4327
|
+
return modified;
|
|
4328
|
+
}
|
|
4329
|
+
async function writeAtomic3(filePath, content) {
|
|
4330
|
+
const tmpPath = `${filePath}.tmp.${Date.now()}`;
|
|
4331
|
+
await fsp2.writeFile(tmpPath, content, "utf-8");
|
|
4332
|
+
await fsp2.rename(tmpPath, filePath);
|
|
4333
|
+
}
|
|
4334
|
+
async function removeFile(filePath) {
|
|
4335
|
+
try {
|
|
4336
|
+
await fsp2.unlink(filePath);
|
|
4337
|
+
} catch {
|
|
4338
|
+
}
|
|
4339
|
+
}
|
|
4340
|
+
async function removeDir(dirPath) {
|
|
4341
|
+
try {
|
|
4342
|
+
fs23.rmSync(dirPath, { recursive: true, force: true });
|
|
4343
|
+
} catch {
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
async function uninstallHandler(opts) {
|
|
4347
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
4348
|
+
const stdout = opts.stdout ?? ((s) => process.stdout.write(s + "\n"));
|
|
4349
|
+
const stderr = opts.stderr ?? ((s) => process.stderr.write(s + "\n"));
|
|
4350
|
+
const exit = opts.exit ?? ((code) => process.exit(code));
|
|
4351
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
4352
|
+
const dryRun = opts.dryRun ?? false;
|
|
4353
|
+
const yes = opts.yes ?? false;
|
|
4354
|
+
const force = opts.force ?? false;
|
|
4355
|
+
const preserveSet = parseTierList(opts.preserve ?? []);
|
|
4356
|
+
const removeSet = parseTierList(opts.remove ?? []);
|
|
4357
|
+
const removeAll = (opts.remove ?? []).some((r) => r === "all");
|
|
4358
|
+
if (removeAll) {
|
|
4359
|
+
for (const t of FRAMEWORK_TIERS) removeSet.add(t);
|
|
4360
|
+
for (const u of USER_ARTIFACT_TIERS) removeSet.add(u);
|
|
4361
|
+
}
|
|
4362
|
+
const target = opts.path ? path24.resolve(opts.path) : cwd;
|
|
4363
|
+
const cleargateDir = path24.join(target, ".cleargate");
|
|
4364
|
+
const manifestPath = path24.join(cleargateDir, ".install-manifest.json");
|
|
4365
|
+
const uninstalledPath = path24.join(cleargateDir, ".uninstalled");
|
|
4366
|
+
if (!fs23.existsSync(manifestPath)) {
|
|
4367
|
+
if (fs23.existsSync(uninstalledPath)) {
|
|
4368
|
+
stdout("already uninstalled");
|
|
4369
|
+
exit(0);
|
|
4370
|
+
return;
|
|
4371
|
+
}
|
|
4372
|
+
stdout(`no ClearGate install detected at ${target}`);
|
|
4373
|
+
exit(0);
|
|
4374
|
+
return;
|
|
4375
|
+
}
|
|
4376
|
+
if (fs23.existsSync(uninstalledPath) && !fs23.existsSync(manifestPath)) {
|
|
4377
|
+
stdout("already uninstalled");
|
|
4378
|
+
exit(0);
|
|
4379
|
+
return;
|
|
4380
|
+
}
|
|
4381
|
+
const snapshot = await loadInstallSnapshot(target);
|
|
4382
|
+
if (!snapshot) {
|
|
4383
|
+
stdout(`no ClearGate install detected at ${target}`);
|
|
4384
|
+
exit(0);
|
|
4385
|
+
return;
|
|
4386
|
+
}
|
|
4387
|
+
if (!force) {
|
|
4388
|
+
const manifestPaths = snapshot.files.map((e) => e.path);
|
|
4389
|
+
const uncommitted = detectUncommittedChanges(target, manifestPaths, opts.git);
|
|
4390
|
+
if (uncommitted.length > 0) {
|
|
4391
|
+
stderr(
|
|
4392
|
+
`Uncommitted changes to tracked files: ${uncommitted.slice(0, 5).join(", ")}${uncommitted.length > 5 ? ` and ${uncommitted.length - 5} more` : ""}. Commit, stash, or pass --force.`
|
|
4393
|
+
);
|
|
4394
|
+
exit(1);
|
|
4395
|
+
return;
|
|
4396
|
+
}
|
|
4397
|
+
}
|
|
4398
|
+
const claudeMdPath = path24.join(target, "CLAUDE.md");
|
|
4399
|
+
let claudeMdContent = null;
|
|
4400
|
+
if (fs23.existsSync(claudeMdPath)) {
|
|
4401
|
+
claudeMdContent = fs23.readFileSync(claudeMdPath, "utf-8");
|
|
4402
|
+
if (!claudeMdContent.includes(CLEARGATE_START)) {
|
|
4403
|
+
stderr("CLAUDE.md is missing <!-- CLEARGATE:START --> marker");
|
|
4404
|
+
exit(1);
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
if (!claudeMdContent.includes(CLEARGATE_END)) {
|
|
4408
|
+
stderr("CLAUDE.md is missing <!-- CLEARGATE:END --> marker");
|
|
4409
|
+
exit(1);
|
|
4410
|
+
return;
|
|
4411
|
+
}
|
|
4412
|
+
}
|
|
4413
|
+
const toRemove = [];
|
|
4414
|
+
const toPreserve = [];
|
|
4415
|
+
const toSkip = [];
|
|
4416
|
+
for (const entry of snapshot.files) {
|
|
4417
|
+
const filePath = path24.join(target, entry.path);
|
|
4418
|
+
if (!fs23.existsSync(filePath)) {
|
|
4419
|
+
toSkip.push(entry);
|
|
4420
|
+
continue;
|
|
4421
|
+
}
|
|
4422
|
+
if (shouldPreserve(entry, preserveSet, removeSet)) {
|
|
4423
|
+
toPreserve.push(entry);
|
|
4424
|
+
} else {
|
|
4425
|
+
toRemove.push(entry);
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
stdout(`Will remove ${toRemove.length} files, keep ${toPreserve.length} files, update CLAUDE.md to strip CLEARGATE block, remove @cleargate/cli from package.json.`);
|
|
4429
|
+
if (dryRun) {
|
|
4430
|
+
stdout("");
|
|
4431
|
+
stdout("[dry-run] Planned removals:");
|
|
4432
|
+
for (const e of toRemove) {
|
|
4433
|
+
stdout(` [remove] ${e.path}`);
|
|
4434
|
+
}
|
|
4435
|
+
stdout("");
|
|
4436
|
+
stdout("[dry-run] Planned preservations:");
|
|
4437
|
+
for (const e of toPreserve) {
|
|
4438
|
+
stdout(` [keep] ${e.path}`);
|
|
4439
|
+
}
|
|
4440
|
+
if (toSkip.length > 0) {
|
|
4441
|
+
stdout("");
|
|
4442
|
+
stdout("[dry-run] Untracked (already missing on disk):");
|
|
4443
|
+
for (const e of toSkip) {
|
|
4444
|
+
stdout(` [skip] ${e.path}`);
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
stdout("");
|
|
4448
|
+
stdout("[dry-run] No files changed.");
|
|
4449
|
+
exit(0);
|
|
4450
|
+
return;
|
|
4451
|
+
}
|
|
4452
|
+
if (!yes) {
|
|
4453
|
+
const projectName = resolveProjectName(target);
|
|
4454
|
+
const promptNameFn = opts.promptName ?? (() => {
|
|
4455
|
+
return new Promise((resolve13) => {
|
|
4456
|
+
process.stdout.write(`Type the project name "${projectName}" to confirm uninstall: `);
|
|
4457
|
+
let buf = "";
|
|
4458
|
+
process.stdin.setEncoding("utf-8");
|
|
4459
|
+
process.stdin.once("data", (chunk) => {
|
|
4460
|
+
buf = chunk.toString().trim();
|
|
4461
|
+
resolve13(buf);
|
|
4462
|
+
});
|
|
4463
|
+
});
|
|
4464
|
+
});
|
|
4465
|
+
const typed = await promptNameFn();
|
|
4466
|
+
if (typed !== projectName) {
|
|
4467
|
+
stdout("name mismatch \u2014 aborting");
|
|
4468
|
+
exit(1);
|
|
4469
|
+
return;
|
|
4470
|
+
}
|
|
4471
|
+
}
|
|
4472
|
+
const removedPaths = [];
|
|
4473
|
+
const preservedPaths = [];
|
|
4474
|
+
for (const entry of toRemove) {
|
|
4475
|
+
const filePath = path24.join(target, entry.path);
|
|
4476
|
+
await removeFile(filePath);
|
|
4477
|
+
removedPaths.push(entry.path);
|
|
4478
|
+
}
|
|
4479
|
+
for (const entry of toPreserve) {
|
|
4480
|
+
preservedPaths.push(entry.path);
|
|
4481
|
+
}
|
|
4482
|
+
if (claudeMdContent !== null) {
|
|
4483
|
+
try {
|
|
4484
|
+
const stripped = removeBlock(claudeMdContent);
|
|
4485
|
+
await writeAtomic3(claudeMdPath, stripped);
|
|
4486
|
+
removedPaths.push("CLAUDE.md (CLEARGATE block)");
|
|
4487
|
+
} catch (err) {
|
|
4488
|
+
stderr(`Warning: could not strip CLAUDE.md block: ${err.message}`);
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
const settingsPath = path24.join(target, ".claude", "settings.json");
|
|
4492
|
+
if (fs23.existsSync(settingsPath)) {
|
|
4493
|
+
try {
|
|
4494
|
+
const raw = fs23.readFileSync(settingsPath, "utf-8");
|
|
4495
|
+
const settings = JSON.parse(raw);
|
|
4496
|
+
const cleaned = removeClearGateHooks(settings);
|
|
4497
|
+
await writeAtomic3(settingsPath, JSON.stringify(cleaned, null, 2) + "\n");
|
|
4498
|
+
removedPaths.push(".claude/settings.json (ClearGate hooks)");
|
|
4499
|
+
} catch (err) {
|
|
4500
|
+
stderr(`Warning: could not update settings.json: ${err.message}`);
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
const pkgModified = await removeFromPackageJson(target, false);
|
|
4504
|
+
if (pkgModified) {
|
|
4505
|
+
removedPaths.push("package.json (@cleargate/cli dep)");
|
|
4506
|
+
stdout("Removed @cleargate/cli from package.json. Run `npm install` to update package-lock.json.");
|
|
4507
|
+
}
|
|
4508
|
+
await removeFile(manifestPath);
|
|
4509
|
+
await removeFile(path24.join(cleargateDir, ".drift-state.json"));
|
|
4510
|
+
const marker = {
|
|
4511
|
+
uninstalled_at: now().toISOString(),
|
|
4512
|
+
prior_version: snapshot.cleargate_version,
|
|
4513
|
+
preserved: preservedPaths,
|
|
4514
|
+
removed: removedPaths
|
|
4515
|
+
};
|
|
4516
|
+
await fsp2.mkdir(cleargateDir, { recursive: true });
|
|
4517
|
+
await writeAtomic3(uninstalledPath, JSON.stringify(marker, null, 2) + "\n");
|
|
4518
|
+
if (removeAll) {
|
|
4519
|
+
const hasPreservedInsideCleargate = preservedPaths.some(
|
|
4520
|
+
(p) => p.startsWith(".cleargate/")
|
|
4521
|
+
);
|
|
4522
|
+
if (!hasPreservedInsideCleargate) {
|
|
4523
|
+
await removeDir(cleargateDir);
|
|
4524
|
+
}
|
|
4525
|
+
}
|
|
4526
|
+
if (preservedPaths.length > 0) {
|
|
4527
|
+
stdout(`Preserved ${preservedPaths.length} items. Run cleargate init in this directory to restore.`);
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4530
|
+
|
|
264
4531
|
// src/cli.ts
|
|
265
4532
|
var program = new Command();
|
|
266
4533
|
program.name("cleargate").description("ClearGate CLI \u2014 connects AI agent teams to the ClearGate MCP server").version(package_default.version, "-V, --version").option("--profile <name>", "configuration profile to use", "default").option("--mcp-url <url>", "MCP server URL (overrides config file and env)").showHelpAfterError("(use `cleargate --help`)");
|
|
@@ -272,9 +4539,105 @@ program.command("join <invite-url>").description("join a ClearGate workspace usi
|
|
|
272
4539
|
mcpUrlFlag: globals.mcpUrl
|
|
273
4540
|
});
|
|
274
4541
|
});
|
|
4542
|
+
program.command("init").description("initialise a repo with ClearGate scaffold (CLAUDE.md block, hook config, agents, templates)").option("--force", "overwrite existing files that differ from the bundled payload").action(async (opts) => {
|
|
4543
|
+
await initHandler({ force: opts.force ?? false });
|
|
4544
|
+
});
|
|
275
4545
|
program.command("whoami").description("print the currently authenticated agent identity").action(stubHandler("whoami"));
|
|
276
|
-
program.command("stamp").description("stamp
|
|
277
|
-
|
|
4546
|
+
program.command("stamp <file>").description("stamp ClearGate metadata fields into a file's frontmatter").option("--dry-run", "print planned changes without writing").action(async (file, opts) => {
|
|
4547
|
+
await stampHandler(file, { dryRun: opts.dryRun });
|
|
4548
|
+
});
|
|
4549
|
+
var wiki = program.command("wiki").description("query or update the workspace wiki");
|
|
4550
|
+
wiki.command("build").description("full rebuild of .cleargate/wiki/ from raw delivery items").action(async () => {
|
|
4551
|
+
await wikiBuildHandler();
|
|
4552
|
+
});
|
|
4553
|
+
wiki.command("ingest <file>").description("ingest a single raw delivery file into the wiki").action(async (file) => {
|
|
4554
|
+
await wikiIngestHandler({ rawPath: file });
|
|
4555
|
+
});
|
|
4556
|
+
wiki.command("lint").description("check wiki pages for drift vs raw sources").option("--suggest", "advisory mode \u2014 exit 0, emit suggestions only").helpOption("--help", [
|
|
4557
|
+
"Usage: cleargate wiki lint [--suggest]",
|
|
4558
|
+
"",
|
|
4559
|
+
"Enforcement mode (default): exits 1 on any finding.",
|
|
4560
|
+
"Suggest mode (--suggest): exits 0, prefixes findings with [advisory],",
|
|
4561
|
+
" and emits Karpathy cross-ref discovery candidates."
|
|
4562
|
+
].join("\n")).action(async (_opts, command) => {
|
|
4563
|
+
const cmdOpts = command.opts();
|
|
4564
|
+
await wikiLintHandler({
|
|
4565
|
+
mode: cmdOpts.suggest ? "suggest" : "enforce"
|
|
4566
|
+
});
|
|
4567
|
+
});
|
|
4568
|
+
wiki.command("query <terms...>").description("search the wiki index for matching work items").option("--persist", "write result as a topic page under wiki/topics/").addHelpText("after", [
|
|
4569
|
+
"",
|
|
4570
|
+
"NOTE: CLI synthesis is grep-and-list. For NL synthesis with the",
|
|
4571
|
+
"cleargate-wiki-query subagent, invoke from a Claude Code session.",
|
|
4572
|
+
"This diverges from PROPOSAL-002 \xA72.2 intentionally for testability",
|
|
4573
|
+
"and offline/scripted use."
|
|
4574
|
+
].join("\n")).action(async (terms, opts) => {
|
|
4575
|
+
await wikiQueryHandler({
|
|
4576
|
+
query: terms.join(" "),
|
|
4577
|
+
persist: opts.persist ?? false
|
|
4578
|
+
});
|
|
4579
|
+
});
|
|
4580
|
+
var gate = program.command("gate").description("evaluate readiness gates for a ClearGate work-item file");
|
|
4581
|
+
gate.command("check <file>").description("evaluate readiness criteria and write result to frontmatter").option("-v, --verbose", "show full expected-vs-actual detail per criterion").option("--transition <name>", "override auto-detected transition name").action(async (file, opts) => {
|
|
4582
|
+
await gateCheckHandler(file, { verbose: opts.verbose, transition: opts.transition });
|
|
4583
|
+
});
|
|
4584
|
+
gate.command("explain <file>").description("render cached gate result in \u226450 agent tokens (read-only)").action(async (file) => {
|
|
4585
|
+
await gateExplainHandler(file);
|
|
4586
|
+
});
|
|
4587
|
+
program.command("stamp-tokens <file>").description("stamp draft_tokens from token-ledger into a work-item file (hook-invoked)").option("--dry-run", "print planned changes without writing").action(async (file, opts) => {
|
|
4588
|
+
await stampTokensHandler(file, { dryRun: opts.dryRun });
|
|
4589
|
+
});
|
|
278
4590
|
program.command("admin").description("administrative operations (create-project, invite, issue-token, revoke-token)").action(stubHandler("admin"));
|
|
4591
|
+
program.command("doctor").description("diagnose scaffold drift, hook health, blocked items, and token cost").option("--check-scaffold", "check scaffold files for drift against install snapshot").option("--session-start-mode", "hidden: enables daily throttle (used by session-start hook)", false).option("--session-start", "emit blocked pending-sync items summary (used by SessionStart hook)").option("--pricing <file>", "compute USD cost estimate from a work item's draft_tokens").option("-v, --verbose", "show per-file drift detail").addHelpText("after", [
|
|
4592
|
+
"",
|
|
4593
|
+
"Modes (mutually exclusive):",
|
|
4594
|
+
" --check-scaffold Compute drift for all tracked scaffold files.",
|
|
4595
|
+
" Writes .cleargate/.drift-state.json.",
|
|
4596
|
+
" --session-start List blocked pending-sync items (\u226410, \u2264100 tokens).",
|
|
4597
|
+
" --pricing <file> Compute USD estimate from a work item's draft_tokens.",
|
|
4598
|
+
" (default) Print a minimal hook-config health report."
|
|
4599
|
+
].join("\n")).action(async (opts) => {
|
|
4600
|
+
await doctorHandler({
|
|
4601
|
+
checkScaffold: opts.checkScaffold,
|
|
4602
|
+
sessionStartMode: opts.sessionStartMode,
|
|
4603
|
+
sessionStart: opts.sessionStart,
|
|
4604
|
+
pricing: !!opts.pricing,
|
|
4605
|
+
pricingFile: opts.pricing,
|
|
4606
|
+
verbose: opts.verbose
|
|
4607
|
+
});
|
|
4608
|
+
});
|
|
4609
|
+
program.command("upgrade").description("three-way merge scaffold files with upstream changes").option("--dry-run", "print plan without making any changes").option("--yes", 'auto-accept "take theirs" for all merge-3way files (non-interactive)').option("--only <tier>", "restrict to a specific scaffold tier (protocol/template/agent/hook/skill/cli-config)").addHelpText("after", [
|
|
4610
|
+
"",
|
|
4611
|
+
"Overwrite policies:",
|
|
4612
|
+
" always \u2014 silent overwrite with package content",
|
|
4613
|
+
" never \u2014 silent skip",
|
|
4614
|
+
" preserve \u2014 silent skip",
|
|
4615
|
+
" merge-3way \u2014 interactive: [k]eep mine / [t]ake theirs / [e]dit in $EDITOR",
|
|
4616
|
+
"",
|
|
4617
|
+
"--yes auto-accepts [t]ake theirs for all merge-3way files."
|
|
4618
|
+
].join("\n")).action(async (opts) => {
|
|
4619
|
+
await upgradeHandler({ dryRun: opts.dryRun, yes: opts.yes, only: opts.only });
|
|
4620
|
+
});
|
|
4621
|
+
program.command("uninstall").description("remove ClearGate scaffold from a project (preservation-first)").option("--dry-run", "preview planned actions without making any changes (CI-safe)").option("--preserve <tiers>", "comma-separated tier ids to force-preserve (default: user-artifact)").option("--remove <tiers>", 'comma-separated tier ids to force-remove; use "all" to remove everything including user artifacts (DANGEROUS)').option("--yes", "skip typed project-name confirmation (dangerous \u2014 use in scripts/CI)").option("--path <dir>", "target directory (must contain .cleargate/.install-manifest.json); defaults to cwd").option("--force", "bypass uncommitted-changes safety check (not applicable for non-git targets)").addHelpText("after", [
|
|
4622
|
+
"",
|
|
4623
|
+
"Preservation defaults:",
|
|
4624
|
+
" user-artifact tier \u2192 kept (FLASHCARD.md, archive, pending-sync, sprint REPORT.md)",
|
|
4625
|
+
" framework tiers \u2192 removed (protocol, template, agent, hook, skill, cli-config)",
|
|
4626
|
+
"",
|
|
4627
|
+
"Always removed (no prompt): .claude/agents/*.md, ClearGate hooks,",
|
|
4628
|
+
" .claude/skills/flashcard/, CLAUDE.md CLEARGATE block,",
|
|
4629
|
+
" @cleargate/cli from package.json, .install-manifest.json, .drift-state.json.",
|
|
4630
|
+
"",
|
|
4631
|
+
"Non-git targets: uncommitted-changes check is skipped silently."
|
|
4632
|
+
].join("\n")).action(async (opts) => {
|
|
4633
|
+
await uninstallHandler({
|
|
4634
|
+
dryRun: opts.dryRun,
|
|
4635
|
+
preserve: opts.preserve ? opts.preserve.split(",").map((s) => s.trim()) : void 0,
|
|
4636
|
+
remove: opts.remove ? opts.remove.split(",").map((s) => s.trim()) : void 0,
|
|
4637
|
+
yes: opts.yes,
|
|
4638
|
+
path: opts.path,
|
|
4639
|
+
force: opts.force
|
|
4640
|
+
});
|
|
4641
|
+
});
|
|
279
4642
|
void program.parseAsync(process.argv);
|
|
280
4643
|
//# sourceMappingURL=cli.js.map
|