litcodex-ai 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/bin/litcodex.js +12 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.js +183 -0
- package/dist/config-migration/backup.d.ts +2 -0
- package/dist/config-migration/backup.js +42 -0
- package/dist/config-migration/catalog.d.ts +22 -0
- package/dist/config-migration/catalog.js +99 -0
- package/dist/config-migration/cli.d.ts +14 -0
- package/dist/config-migration/cli.js +85 -0
- package/dist/config-migration/config-paths.d.ts +4 -0
- package/dist/config-migration/config-paths.js +64 -0
- package/dist/config-migration/errors.d.ts +11 -0
- package/dist/config-migration/errors.js +28 -0
- package/dist/config-migration/index.d.ts +44 -0
- package/dist/config-migration/index.js +210 -0
- package/dist/config-migration/multi-agent-v2-guard.d.ts +2 -0
- package/dist/config-migration/multi-agent-v2-guard.js +106 -0
- package/dist/config-migration/root-settings.d.ts +6 -0
- package/dist/config-migration/root-settings.js +104 -0
- package/dist/config-migration/state.d.ts +16 -0
- package/dist/config-migration/state.js +40 -0
- package/dist/config-migration/toml-shape.d.ts +8 -0
- package/dist/config-migration/toml-shape.js +107 -0
- package/dist/install/codex.d.ts +34 -0
- package/dist/install/codex.js +94 -0
- package/dist/install/doctor.d.ts +12 -0
- package/dist/install/doctor.js +83 -0
- package/dist/install/errors.d.ts +19 -0
- package/dist/install/errors.js +43 -0
- package/dist/install/execute.d.ts +39 -0
- package/dist/install/execute.js +193 -0
- package/dist/install/index.d.ts +19 -0
- package/dist/install/index.js +193 -0
- package/dist/install/marketplace.d.ts +5 -0
- package/dist/install/marketplace.js +10 -0
- package/dist/install/plan.d.ts +3 -0
- package/dist/install/plan.js +54 -0
- package/dist/install/render-plan.d.ts +3 -0
- package/dist/install/render-plan.js +10 -0
- package/dist/install/types.d.ts +45 -0
- package/dist/install/types.js +5 -0
- package/model-catalog.json +31 -0
- package/node_modules/@litcodex/lit-loop/CHANGELOG.md +19 -0
- package/node_modules/@litcodex/lit-loop/LICENSE +21 -0
- package/node_modules/@litcodex/lit-loop/NOTICE +8 -0
- package/node_modules/@litcodex/lit-loop/README.md +37 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-explorer.toml +75 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-librarian.toml +98 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-litwork-reviewer.toml +21 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-metis.toml +64 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-momus.toml +68 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-plan.toml +163 -0
- package/node_modules/@litcodex/lit-loop/directive.md +85 -0
- package/node_modules/@litcodex/lit-loop/directives/lit-plan.md +286 -0
- package/node_modules/@litcodex/lit-loop/directives/litgoal.md +103 -0
- package/node_modules/@litcodex/lit-loop/directives/litwork.md +363 -0
- package/node_modules/@litcodex/lit-loop/dist/_scaffold.d.ts +1 -0
- package/node_modules/@litcodex/lit-loop/dist/_scaffold.js +3 -0
- package/node_modules/@litcodex/lit-loop/dist/cli.d.ts +6 -0
- package/node_modules/@litcodex/lit-loop/dist/cli.js +44 -0
- package/node_modules/@litcodex/lit-loop/dist/codex-goal-instruction.d.ts +18 -0
- package/node_modules/@litcodex/lit-loop/dist/codex-goal-instruction.js +94 -0
- package/node_modules/@litcodex/lit-loop/dist/codex-hook.d.ts +38 -0
- package/node_modules/@litcodex/lit-loop/dist/codex-hook.js +126 -0
- package/node_modules/@litcodex/lit-loop/dist/directive.d.ts +35 -0
- package/node_modules/@litcodex/lit-loop/dist/directive.js +80 -0
- package/node_modules/@litcodex/lit-loop/dist/goal-status.d.ts +12 -0
- package/node_modules/@litcodex/lit-loop/dist/goal-status.js +25 -0
- package/node_modules/@litcodex/lit-loop/dist/guards.d.ts +73 -0
- package/node_modules/@litcodex/lit-loop/dist/guards.js +215 -0
- package/node_modules/@litcodex/lit-loop/dist/hook-cli.d.ts +17 -0
- package/node_modules/@litcodex/lit-loop/dist/hook-cli.js +94 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-cli.d.ts +19 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-cli.js +106 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor-render.d.ts +7 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor-render.js +39 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor-types.d.ts +52 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor-types.js +7 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor.d.ts +21 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor.js +283 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-errors.d.ts +15 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-errors.js +43 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-handlers.d.ts +18 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-handlers.js +311 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-model.d.ts +51 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-model.js +165 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-stdout.d.ts +6 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-stdout.js +11 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-types.d.ts +26 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-types.js +8 -0
- package/node_modules/@litcodex/lit-loop/dist/markers.d.ts +9 -0
- package/node_modules/@litcodex/lit-loop/dist/markers.js +14 -0
- package/node_modules/@litcodex/lit-loop/dist/modes.d.ts +15 -0
- package/node_modules/@litcodex/lit-loop/dist/modes.js +56 -0
- package/node_modules/@litcodex/lit-loop/dist/state-paths.d.ts +41 -0
- package/node_modules/@litcodex/lit-loop/dist/state-paths.js +111 -0
- package/node_modules/@litcodex/lit-loop/dist/state-store.d.ts +39 -0
- package/node_modules/@litcodex/lit-loop/dist/state-store.js +419 -0
- package/node_modules/@litcodex/lit-loop/dist/state-types.d.ts +90 -0
- package/node_modules/@litcodex/lit-loop/dist/state-types.js +61 -0
- package/node_modules/@litcodex/lit-loop/dist/trigger.d.ts +54 -0
- package/node_modules/@litcodex/lit-loop/dist/trigger.js +75 -0
- package/node_modules/@litcodex/lit-loop/package.json +27 -0
- package/package.json +30 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// src/hook-cli.ts — M06 process adapter (A2 §2.4, A3 addendum A3/A4).
|
|
2
|
+
//
|
|
3
|
+
// The ONLY module that touches process streams + exit codes for the hook route. Reads stdin to a
|
|
4
|
+
// UTF-8 string defensively, then BOM-strip → empty-check → JSON.parse (in that exact order, A3 A4),
|
|
5
|
+
// hands the parsed value to the pure engine, and writes the decision. Exit map: any structurally
|
|
6
|
+
// valid event → 0 (no-op writes zero bytes); unparseable or oversized stdin → 2 with a
|
|
7
|
+
// machine-readable LitHookError JSON line on stderr. NEVER calls process.exit (the dispatcher does)
|
|
8
|
+
// and NEVER throws.
|
|
9
|
+
import { runUserPromptSubmitHook } from "./codex-hook.js";
|
|
10
|
+
/** Hard cap on stdin to bound memory / ReDoS exposure inside the 5 s Codex hook budget. 8 MB. */
|
|
11
|
+
export const MAX_STDIN_BYTES = 8_000_000;
|
|
12
|
+
const INVALID_JSON_MESSAGE = "lit hook: stdin was not valid JSON";
|
|
13
|
+
const TOO_LARGE_MESSAGE = `lit hook: stdin exceeded ${MAX_STDIN_BYTES} bytes`;
|
|
14
|
+
function errorLine(code, message) {
|
|
15
|
+
const payload = { ok: false, error: { code, message } };
|
|
16
|
+
return `${JSON.stringify(payload)}\n`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Read stdin to completion, enforcing MAX_STDIN_BYTES per chunk on the raw byte total BEFORE any
|
|
20
|
+
* decode (A3 A3). Resolves the decoded UTF-8 string, or `null` if the cap was exceeded (in which
|
|
21
|
+
* case the stream is destroyed and no decode/parse is attempted on the over-limit buffer).
|
|
22
|
+
*/
|
|
23
|
+
function readStdin(stdin) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const chunks = [];
|
|
26
|
+
let total = 0;
|
|
27
|
+
let settled = false;
|
|
28
|
+
const finish = (value) => {
|
|
29
|
+
if (settled) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
settled = true;
|
|
33
|
+
resolve(value);
|
|
34
|
+
};
|
|
35
|
+
stdin.on("data", (chunk) => {
|
|
36
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
37
|
+
total += buf.length;
|
|
38
|
+
if (total > MAX_STDIN_BYTES) {
|
|
39
|
+
// Stop reading and abandon the over-limit buffer without decoding/parsing it.
|
|
40
|
+
stdin.pause();
|
|
41
|
+
const destroy = stdin.destroy;
|
|
42
|
+
if (typeof destroy === "function") {
|
|
43
|
+
destroy.call(stdin);
|
|
44
|
+
}
|
|
45
|
+
finish(null);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
chunks.push(buf);
|
|
49
|
+
});
|
|
50
|
+
stdin.on("end", () => {
|
|
51
|
+
finish(Buffer.concat(chunks).toString("utf8"));
|
|
52
|
+
});
|
|
53
|
+
stdin.on("error", () => {
|
|
54
|
+
// Treat a stream error like end-of-input over what we collected; the parse step decides.
|
|
55
|
+
finish(Buffer.concat(chunks).toString("utf8"));
|
|
56
|
+
});
|
|
57
|
+
stdin.on("close", () => {
|
|
58
|
+
finish(Buffer.concat(chunks).toString("utf8"));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Stream-driven entry point. Resolves exactly one exit code (0 or 2); NEVER calls process.exit and
|
|
64
|
+
* NEVER throws. Writes the camelCase activation line to stdout (empty on no-op), or a LitHookError
|
|
65
|
+
* line to stderr on malformed / oversized stdin.
|
|
66
|
+
*/
|
|
67
|
+
export async function runUserPromptSubmitHookCli(stdin, stdout, stderr) {
|
|
68
|
+
const decoded = await readStdin(stdin);
|
|
69
|
+
if (decoded === null) {
|
|
70
|
+
stderr.write(errorLine("LIT_HOOK_STDIN_TOO_LARGE", TOO_LARGE_MESSAGE));
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
// A3 A4 ordering: strip ONE leading BOM, then empty-check, then parse.
|
|
74
|
+
let raw = decoded;
|
|
75
|
+
if (raw.charCodeAt(0) === 0xfeff) {
|
|
76
|
+
raw = raw.slice(1);
|
|
77
|
+
}
|
|
78
|
+
if (raw.trim().length === 0) {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
stderr.write(errorLine("LIT_HOOK_STDIN_INVALID_JSON", INVALID_JSON_MESSAGE));
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
89
|
+
const decision = runUserPromptSubmitHook(parsed);
|
|
90
|
+
if (decision.kind === "inject") {
|
|
91
|
+
stdout.write(decision.stdout);
|
|
92
|
+
}
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { exitCodeForLoop, LitLoopError, type LoopErrorCode } from "./loop-errors.js";
|
|
2
|
+
export type { LoopClock } from "./loop-handlers.js";
|
|
3
|
+
export { LOOP_CREATE_STDOUT } from "./loop-stdout.js";
|
|
4
|
+
/** The 7 MVP subcommands, help-order, including `help` (A3 D2). M09 is the single source. */
|
|
5
|
+
export declare const LOOP_SUBCOMMANDS: readonly ["help", "create", "status", "run", "checkpoint", "record-evidence", "doctor"];
|
|
6
|
+
export type LoopSubcommand = (typeof LOOP_SUBCOMMANDS)[number];
|
|
7
|
+
export declare function isLoopSubcommand(value: string): value is LoopSubcommand;
|
|
8
|
+
export interface LoopIo {
|
|
9
|
+
readonly stdout: NodeJS.WritableStream;
|
|
10
|
+
readonly stderr: NodeJS.WritableStream;
|
|
11
|
+
readonly stdin: NodeJS.ReadableStream;
|
|
12
|
+
/** Test seam: override the repo root (default process.cwd()) without process.chdir. */
|
|
13
|
+
readonly cwd: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Entry point. `argv` is already sliced past `loop`. NEVER throws and NEVER calls process.exit —
|
|
17
|
+
* resolves exactly one exit code. The M03 router owns process.exit.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loopCommand(argv: readonly string[], io?: Partial<LoopIo>, clock?: () => string): Promise<number>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// src/loop-cli.ts — M09/T14 loop command surface dispatcher (A3 C5/C6/C7/C9/C14, S09 §5).
|
|
2
|
+
//
|
|
3
|
+
// The thin `litcodex loop <sub>` dispatcher. PURE of process.exit and uncaught throws: loopCommand
|
|
4
|
+
// always RESOLVES one exit code (the M03 router owns process.exit). Arg parsing + the five handlers
|
|
5
|
+
// live in loop-handlers.ts; the error model + LOOP_CREATE_STDOUT are re-exported from
|
|
6
|
+
// loop-errors.ts / loop-stdout.ts so this module's public surface is unchanged. All fs I/O is
|
|
7
|
+
// delegated to the M08 store via flat `./state-store.js` (NEVER `../state/...`, A3 C9); M09 never
|
|
8
|
+
// touches node:fs. Error model branches on `err.code` via exitCodeForLoop (A3 C7 — NOT instanceof):
|
|
9
|
+
// store PLAN_MISSING→3, PLAN_CORRUPT→4, WRITE_FAILED→5; unknown subcommand/usage→1; bad args→2;
|
|
10
|
+
// not-found→3. The `doctor` route delegates to the canonical M11 6-check doctor (A3 C5) and ALWAYS
|
|
11
|
+
// resolves exit 0 (a diagnostic, never a gate).
|
|
12
|
+
import { renderDoctorJson, renderDoctorText, runLoopDoctor } from "./loop-doctor.js";
|
|
13
|
+
import { exitCodeForLoop, LitLoopError } from "./loop-errors.js";
|
|
14
|
+
import { handleCheckpoint, handleCreate, handleRecordEvidence, handleRun, handleStatus, hasFlag, } from "./loop-handlers.js";
|
|
15
|
+
import { resolveLoopScope } from "./state-store.js";
|
|
16
|
+
// Re-export the error model + stdout contract so callers (M03 router, M20, tests) keep importing
|
|
17
|
+
// them from `./loop-cli.js` as the M09 public surface.
|
|
18
|
+
export { exitCodeForLoop, LitLoopError } from "./loop-errors.js";
|
|
19
|
+
export { LOOP_CREATE_STDOUT } from "./loop-stdout.js";
|
|
20
|
+
// ── Public contract ──────────────────────────────────────────────────────────
|
|
21
|
+
/** The 7 MVP subcommands, help-order, including `help` (A3 D2). M09 is the single source. */
|
|
22
|
+
export const LOOP_SUBCOMMANDS = ["help", "create", "status", "run", "checkpoint", "record-evidence", "doctor"];
|
|
23
|
+
export function isLoopSubcommand(value) {
|
|
24
|
+
return LOOP_SUBCOMMANDS.includes(value);
|
|
25
|
+
}
|
|
26
|
+
const HELP_TEXT = `litcodex loop <subcommand>
|
|
27
|
+
|
|
28
|
+
Subcommands:
|
|
29
|
+
help Show this help.
|
|
30
|
+
create Derive goals from a brief and seed success criteria.
|
|
31
|
+
status Print the plan summary (use --json for a machine envelope).
|
|
32
|
+
run Schedule the next runnable goal and print a handoff.
|
|
33
|
+
checkpoint Set a goal terminal status (gated on all-criteria-pass for complete).
|
|
34
|
+
record-evidence Capture per-criterion evidence.
|
|
35
|
+
doctor Diagnose loop state (read-only health report; use --json for an envelope).
|
|
36
|
+
`;
|
|
37
|
+
/**
|
|
38
|
+
* Entry point. `argv` is already sliced past `loop`. NEVER throws and NEVER calls process.exit —
|
|
39
|
+
* resolves exactly one exit code. The M03 router owns process.exit.
|
|
40
|
+
*/
|
|
41
|
+
export async function loopCommand(argv, io, clock) {
|
|
42
|
+
const stdout = io?.stdout ?? process.stdout;
|
|
43
|
+
const stderr = io?.stderr ?? process.stderr;
|
|
44
|
+
const now = clock ?? (() => new Date().toISOString());
|
|
45
|
+
const repoRoot = io?.cwd ?? process.cwd();
|
|
46
|
+
let head = argv[0] ?? "help";
|
|
47
|
+
if (head === "--help" || head === "-h") {
|
|
48
|
+
head = "help";
|
|
49
|
+
}
|
|
50
|
+
const rest = argv.slice(1);
|
|
51
|
+
const json = hasFlag(argv, "--json");
|
|
52
|
+
if (head === "help") {
|
|
53
|
+
stdout.write(HELP_TEXT);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (!isLoopSubcommand(head)) {
|
|
57
|
+
return renderError(new LitLoopError(`unknown subcommand: ${head}`, "LIT_LOOP_SUBCOMMAND_UNKNOWN", { subcommand: head }), json, stdout, stderr);
|
|
58
|
+
}
|
|
59
|
+
const scope = resolveLoopScope({ argv: rest, env: process.env });
|
|
60
|
+
if (head === "doctor") {
|
|
61
|
+
// A3 C5: delegate to the canonical M11 6-check doctor; ALWAYS exit 0 (diagnostic, not gate).
|
|
62
|
+
const report = await runLoopDoctor({ repoRoot, scope });
|
|
63
|
+
stdout.write(json ? renderDoctorJson(report) : renderDoctorText(report));
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const result = await dispatch(head, rest, { repoRoot, scope, now });
|
|
68
|
+
stdout.write(json ? `${JSON.stringify(result.json)}\n` : result.text);
|
|
69
|
+
return result.exitCode;
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
return renderError(err, json, stdout, stderr);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function dispatch(sub, argv, ctx) {
|
|
76
|
+
switch (sub) {
|
|
77
|
+
case "create":
|
|
78
|
+
return handleCreate(argv, ctx);
|
|
79
|
+
case "status":
|
|
80
|
+
return handleStatus(ctx);
|
|
81
|
+
case "run":
|
|
82
|
+
return handleRun(argv, ctx);
|
|
83
|
+
case "checkpoint":
|
|
84
|
+
return handleCheckpoint(argv, ctx);
|
|
85
|
+
case "record-evidence":
|
|
86
|
+
return handleRecordEvidence(argv, ctx);
|
|
87
|
+
default:
|
|
88
|
+
throw new LitLoopError(`unknown subcommand: ${sub}`, "LIT_LOOP_SUBCOMMAND_UNKNOWN", { subcommand: sub });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function renderError(err, json, stdout, stderr) {
|
|
92
|
+
const code = typeof err === "object" && err !== null && "code" in err
|
|
93
|
+
? String(err.code)
|
|
94
|
+
: "LIT_LOOP_INTERNAL";
|
|
95
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
+
const details = err instanceof LitLoopError && err.details !== undefined ? err.details : undefined;
|
|
97
|
+
if (json) {
|
|
98
|
+
const error = { code, message };
|
|
99
|
+
if (details !== undefined) {
|
|
100
|
+
error["details"] = details;
|
|
101
|
+
}
|
|
102
|
+
stdout.write(`${JSON.stringify({ ok: false, error })}\n`);
|
|
103
|
+
}
|
|
104
|
+
stderr.write(`[lit-loop] ${message}\n`);
|
|
105
|
+
return exitCodeForLoop(err);
|
|
106
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { LoopDoctorReport } from "./loop-doctor-types.js";
|
|
2
|
+
/** Render the --json envelope: one line + trailing `\n`. */
|
|
3
|
+
export declare function renderDoctorJson(report: LoopDoctorReport): string;
|
|
4
|
+
/** Render the human (text-mode) report. */
|
|
5
|
+
export declare function renderDoctorText(report: LoopDoctorReport): string;
|
|
6
|
+
/** Strip control chars (incl. newlines) and cap to 80 chars so an id can never forge a render line. */
|
|
7
|
+
export declare function sanitizeId(value: string): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/loop-doctor-render.ts — M11/T16 pure doctor renderers (split from loop-doctor.ts for the
|
|
2
|
+
// 250-LOC ceiling). Deterministic, no env reads, no untrusted-text interpolation: only ids,
|
|
3
|
+
// statuses, integer counts, repo-relative paths, and ISO timestamps appear. `sanitizeId` strips
|
|
4
|
+
// control chars and caps length so a crafted goalId can never forge a render line.
|
|
5
|
+
const SYMBOL = { ok: "ok", warn: "!!", fail: "XX" };
|
|
6
|
+
/** Render the --json envelope: one line + trailing `\n`. */
|
|
7
|
+
export function renderDoctorJson(report) {
|
|
8
|
+
return `${JSON.stringify({ ok: report.healthy, report })}\n`;
|
|
9
|
+
}
|
|
10
|
+
/** Render the human (text-mode) report. */
|
|
11
|
+
export function renderDoctorText(report) {
|
|
12
|
+
const failCount = report.checks.filter((c) => c.status === "fail").length;
|
|
13
|
+
const verdict = report.healthy ? "lit-loop doctor: HEALTHY" : `lit-loop doctor: UNHEALTHY (${failCount} issue(s))`;
|
|
14
|
+
const lines = [verdict, `state dir: ${report.stateDir}`];
|
|
15
|
+
for (const c of report.checks) {
|
|
16
|
+
lines.push(` [${SYMBOL[c.status]}] ${c.name}: ${c.detail}`);
|
|
17
|
+
}
|
|
18
|
+
if (report.latestCheckpoint) {
|
|
19
|
+
const cp = report.latestCheckpoint;
|
|
20
|
+
lines.push(`latest checkpoint: ${sanitizeId(cp.goalId)} -> ${cp.status} @ ${sanitizeId(cp.at)}`);
|
|
21
|
+
}
|
|
22
|
+
if (report.counts) {
|
|
23
|
+
const s = report.counts;
|
|
24
|
+
lines.push(`goals: ${s.total} (${s.pending} pending, ${s.in_progress} in progress, ${s.complete} complete, ${s.failed} failed, ${s.blocked} blocked)`);
|
|
25
|
+
}
|
|
26
|
+
return `${lines.join("\n")}\n`;
|
|
27
|
+
}
|
|
28
|
+
/** Strip control chars (incl. newlines) and cap to 80 chars so an id can never forge a render line. */
|
|
29
|
+
export function sanitizeId(value) {
|
|
30
|
+
let out = "";
|
|
31
|
+
for (const ch of value) {
|
|
32
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
33
|
+
// Drop C0 controls (incl. \n \r \t), DEL, and C1 controls; keep printable text.
|
|
34
|
+
if (code >= 0x20 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f)) {
|
|
35
|
+
out += ch;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out.slice(0, 80);
|
|
39
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { LoopGoalStatus, PlanSummary } from "./loop-types.js";
|
|
2
|
+
import type { LitLoopScope } from "./state-paths.js";
|
|
3
|
+
/** The six diagnostic surfaces, in stable render order. */
|
|
4
|
+
export type LoopDoctorCheckName = "state-dir" | "plan-schema" | "ledger" | "evidence-dir" | "hook" | "checkpoint";
|
|
5
|
+
/** ok = healthy; warn = absent-but-recoverable (NOT a failure); fail = corrupt/unusable. */
|
|
6
|
+
export type LoopDoctorCheckStatus = "ok" | "warn" | "fail";
|
|
7
|
+
export interface LoopDoctorCheck {
|
|
8
|
+
readonly name: LoopDoctorCheckName;
|
|
9
|
+
readonly status: LoopDoctorCheckStatus;
|
|
10
|
+
/** One sanitized line. NO absolute paths beyond repo-relative artifacts; NO raw prompt/evidence text. */
|
|
11
|
+
readonly detail: string;
|
|
12
|
+
/** Optional machine-readable extras (e.g. backup path, ledger counts). JSON-serializable scalars only. */
|
|
13
|
+
readonly data?: Readonly<Record<string, string | number | boolean | null>>;
|
|
14
|
+
}
|
|
15
|
+
/** A pointer to the most-recent terminal goal event seen in the ledger tail. */
|
|
16
|
+
export interface LoopCheckpointRef {
|
|
17
|
+
readonly goalId: string;
|
|
18
|
+
readonly status: LoopGoalStatus;
|
|
19
|
+
readonly at: string;
|
|
20
|
+
}
|
|
21
|
+
export interface LoopDoctorReport {
|
|
22
|
+
/** false iff ANY check.status === "fail". A `warn` alone keeps healthy = true. */
|
|
23
|
+
readonly healthy: boolean;
|
|
24
|
+
/** Repo-relative resolved state dir, e.g. ".litcodex/lit-loop" or ".litcodex/lit-loop/<sid>". */
|
|
25
|
+
readonly stateDir: string;
|
|
26
|
+
/** Always present, always length 6, always in LoopDoctorCheckName order. */
|
|
27
|
+
readonly checks: ReadonlyArray<LoopDoctorCheck>;
|
|
28
|
+
/** Most recent terminal checkpoint, or null when none / ledger unreadable. */
|
|
29
|
+
readonly latestCheckpoint: LoopCheckpointRef | null;
|
|
30
|
+
/** Goal/criterion counts when the plan is readable; null when missing/corrupt. */
|
|
31
|
+
readonly counts: PlanSummary | null;
|
|
32
|
+
}
|
|
33
|
+
export interface RunLoopDoctorOptions {
|
|
34
|
+
/** Absolute project root. MUST be absolute (the doctor never infers it from import.meta.url). */
|
|
35
|
+
readonly repoRoot: string;
|
|
36
|
+
/** Session scope to inspect; undefined ⇒ unscoped root dir. */
|
|
37
|
+
readonly scope?: LitLoopScope | undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Injectable seams for deterministic, side-effect-free testing. ALL default to the real M08
|
|
41
|
+
* store + the real aggregate-hooks-manifest probe. Tests override individually for fault injection.
|
|
42
|
+
*/
|
|
43
|
+
export interface RunLoopDoctorDeps {
|
|
44
|
+
readonly readPlan: (repoRoot: string, scope?: LitLoopScope) => Promise<unknown>;
|
|
45
|
+
readonly readLedger: (repoRoot: string, scope?: LitLoopScope) => Promise<{
|
|
46
|
+
entries: ReadonlyArray<Record<string, unknown>>;
|
|
47
|
+
skipped: number;
|
|
48
|
+
}>;
|
|
49
|
+
readonly statExists: (absPath: string) => Promise<boolean>;
|
|
50
|
+
/** Resolves true iff a UserPromptSubmit command is wired in the aggregate plugins/litcodex/hooks/hooks.json. */
|
|
51
|
+
readonly hookRegistered: (repoRoot: string) => Promise<boolean>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// src/loop-doctor-types.ts — M11/T16 doctor-owned shapes (A3 C5/C9, S11 §Public-contract).
|
|
2
|
+
//
|
|
3
|
+
// Types only; ZERO I/O. The canonical LoopDoctorReport (6 checks incl. `checkpoint`, a per-check
|
|
4
|
+
// `data` field, `healthy` flips ONLY on a `fail`) lives here, NOT in M09 (A3 C5 — M11 is the
|
|
5
|
+
// single doctor owner). Imports `PlanSummary`/`LoopGoalStatus` from M09's `loop-types.js` barrel
|
|
6
|
+
// and `LitLoopScope` from M08's `state-paths.js` — flat siblings, NEVER `../state/...` (A3 C9).
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { LoopCheckpointRef, LoopDoctorReport, RunLoopDoctorDeps, RunLoopDoctorOptions } from "./loop-doctor-types.js";
|
|
2
|
+
export { renderDoctorJson, renderDoctorText } from "./loop-doctor-render.js";
|
|
3
|
+
/** The ONLY ledger kinds the checkpoint scan treats as terminal. Each is in BOTH producer enums. */
|
|
4
|
+
export declare const TERMINAL_LEDGER_KINDS: {
|
|
5
|
+
readonly goal_completed: "complete";
|
|
6
|
+
readonly goal_failed: "failed";
|
|
7
|
+
readonly goal_blocked: "blocked";
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Resolves true iff the AGGREGATE hooks.json registers UserPromptSubmit with the canonical command
|
|
11
|
+
* (all three HOOK_COMMAND_FRAGMENTS). ENOENT ⇒ false; any read/parse error rethrows to checkHook's
|
|
12
|
+
* catch (→ warn). Never executes any command from the manifest.
|
|
13
|
+
*/
|
|
14
|
+
export declare function hookRegistered(repoRoot: string): Promise<boolean>;
|
|
15
|
+
/** Scan the ledger tail backward for the last terminal entry; null when none usable. */
|
|
16
|
+
export declare function latestCheckpointFromLedger(entries: ReadonlyArray<Record<string, unknown>>): LoopCheckpointRef | null;
|
|
17
|
+
/**
|
|
18
|
+
* Produce a LoopDoctorReport. NEVER throws, NEVER mutates state, NEVER calls process.exit. Every
|
|
19
|
+
* sub-check is independently fail-soft. Deterministic given the same on-disk state + injected deps.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runLoopDoctor(options: RunLoopDoctorOptions, deps?: Partial<RunLoopDoctorDeps>): Promise<LoopDoctorReport>;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// src/loop-doctor.ts — M11/T16 canonical read-only loop doctor (A3 C5/C6/C9, S11 + addendum).
|
|
2
|
+
//
|
|
3
|
+
// The single doctor owner (A3 C5): six checks — state-dir / plan-schema / ledger / evidence-dir /
|
|
4
|
+
// hook / checkpoint — a per-check `data` field, and `healthy` flips to false ONLY on a `fail`
|
|
5
|
+
// (warnings keep healthy true). `runLoopDoctor` NEVER throws, NEVER mutates state, NEVER calls
|
|
6
|
+
// process.exit, and ALWAYS resolves a report (M09 maps it to exit 0 unconditionally). Every
|
|
7
|
+
// sub-check is independently try-wrapped; `checkStateDir`/`checkEvidenceDir` CATCH the `statExists`
|
|
8
|
+
// non-ENOENT THROW (LIT_LOOP_WRITE_FAILED) and downgrade to warn (A3 C6 — statExists re-throws
|
|
9
|
+
// EACCES/ELOOP/EIO; the doctor must not propagate it). The hook probe scans the AGGREGATE manifest
|
|
10
|
+
// plugins/litcodex/hooks/hooks.json (M14/M19 ship it) and degrades to warn when absent.
|
|
11
|
+
//
|
|
12
|
+
// Imports flat siblings `./state-store.js`/`./state-paths.js`/`./loop-model.js`/`./loop-types.js`
|
|
13
|
+
// (A3 C9 — NEVER `../state/...`). No `node:fs` import: existence-probing is M08 `statExists` only.
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { sanitizeId } from "./loop-doctor-render.js";
|
|
17
|
+
import { summarizePlan } from "./loop-model.js";
|
|
18
|
+
import { litLoopDir, litLoopEvidenceDir, repoRelative } from "./state-paths.js";
|
|
19
|
+
import { readLedger, readPlan, statExists } from "./state-store.js";
|
|
20
|
+
// Re-export the pure renderers so the M11 public surface stays single-import from `./loop-doctor.js`
|
|
21
|
+
// (A2 §2.5; loop-cli's doctor route imports both from here). The bodies live in loop-doctor-render.ts.
|
|
22
|
+
export { renderDoctorJson, renderDoctorText } from "./loop-doctor-render.js";
|
|
23
|
+
// ── Hook-probe contract (A3 §G11.2; byte-identical to M14 hookCommandFragments) ──────────────
|
|
24
|
+
/** The aggregate manifest the host actually loads, POSIX-joined onto the absolute repoRoot. */
|
|
25
|
+
const HOOK_MANIFEST_RELPATH = "plugins/litcodex/hooks/hooks.json";
|
|
26
|
+
/** All three MUST appear in a `command` string for a registration to count (no bare-cli.js shortcut). */
|
|
27
|
+
const HOOK_COMMAND_FRAGMENTS = [
|
|
28
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: inert literal the host expands; the doctor scans for it verbatim and MUST NOT interpolate it.
|
|
29
|
+
"${PLUGIN_ROOT}",
|
|
30
|
+
"components/lit-loop/dist/cli.js",
|
|
31
|
+
"hook user-prompt-submit",
|
|
32
|
+
];
|
|
33
|
+
// ── Terminal-kind contract (A3 §G11.3; the M08∩M09 intersection, closed constant) ────────────
|
|
34
|
+
/** The ONLY ledger kinds the checkpoint scan treats as terminal. Each is in BOTH producer enums. */
|
|
35
|
+
export const TERMINAL_LEDGER_KINDS = {
|
|
36
|
+
goal_completed: "complete",
|
|
37
|
+
goal_failed: "failed",
|
|
38
|
+
goal_blocked: "blocked",
|
|
39
|
+
};
|
|
40
|
+
// ── Default hooks-manifest probe (pure-Node: no spawn, no network, never runs the command) ───
|
|
41
|
+
/**
|
|
42
|
+
* Resolves true iff the AGGREGATE hooks.json registers UserPromptSubmit with the canonical command
|
|
43
|
+
* (all three HOOK_COMMAND_FRAGMENTS). ENOENT ⇒ false; any read/parse error rethrows to checkHook's
|
|
44
|
+
* catch (→ warn). Never executes any command from the manifest.
|
|
45
|
+
*/
|
|
46
|
+
export async function hookRegistered(repoRoot) {
|
|
47
|
+
const manifestAbs = join(repoRoot, ...HOOK_MANIFEST_RELPATH.split("/"));
|
|
48
|
+
if (!(await statExists(manifestAbs))) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const raw = await readFile(manifestAbs, "utf8");
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
return scanForCommand(parsed);
|
|
54
|
+
}
|
|
55
|
+
/** Recursively scan every string value under a `command` key for ALL three fragments. */
|
|
56
|
+
function scanForCommand(node) {
|
|
57
|
+
if (Array.isArray(node)) {
|
|
58
|
+
return node.some(scanForCommand);
|
|
59
|
+
}
|
|
60
|
+
if (node !== null && typeof node === "object") {
|
|
61
|
+
for (const [key, value] of Object.entries(node)) {
|
|
62
|
+
if (key === "command" && typeof value === "string" && HOOK_COMMAND_FRAGMENTS.every((f) => value.includes(f))) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (scanForCommand(value)) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
// ── Per-check helpers (each pure given its injected dep; the orchestrator try-wraps them) ─────
|
|
73
|
+
async function checkStateDir(stateDirAbs, statExistsFn) {
|
|
74
|
+
try {
|
|
75
|
+
const exists = await statExistsFn(stateDirAbs);
|
|
76
|
+
return exists
|
|
77
|
+
? { name: "state-dir", status: "ok", detail: "state directory present" }
|
|
78
|
+
: { name: "state-dir", status: "warn", detail: "not created — run `litcodex loop create`" };
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// A3 C6: statExists re-throws non-ENOENT (EACCES/ELOOP) as LIT_LOOP_WRITE_FAILED. Downgrade
|
|
82
|
+
// to warn — an unreadable/absent state dir is benign for a diagnostic; NEVER `fail`.
|
|
83
|
+
return {
|
|
84
|
+
name: "state-dir",
|
|
85
|
+
status: "warn",
|
|
86
|
+
detail: "state directory unreadable (permission or I/O)",
|
|
87
|
+
data: { code: "LIT_LOOP_WRITE_FAILED" },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function checkPlanSchema(repoRoot, scope, readPlanFn) {
|
|
92
|
+
try {
|
|
93
|
+
const plan = (await readPlanFn(repoRoot, scope));
|
|
94
|
+
const counts = summarizePlan(plan);
|
|
95
|
+
return {
|
|
96
|
+
check: {
|
|
97
|
+
name: "plan-schema",
|
|
98
|
+
status: "ok",
|
|
99
|
+
detail: `goals.json valid (version 1, ${plan.goals.length} goal(s))`,
|
|
100
|
+
},
|
|
101
|
+
counts,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const code = errCode(err);
|
|
106
|
+
if (code === "LIT_LOOP_PLAN_MISSING") {
|
|
107
|
+
return {
|
|
108
|
+
check: { name: "plan-schema", status: "warn", detail: "no plan yet — run `litcodex loop create`" },
|
|
109
|
+
counts: null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (code === "LIT_LOOP_PLAN_CORRUPT") {
|
|
113
|
+
const backup = errBackup(err);
|
|
114
|
+
return {
|
|
115
|
+
check: {
|
|
116
|
+
name: "plan-schema",
|
|
117
|
+
status: "fail",
|
|
118
|
+
detail: "goals.json was corrupt; quarantined to backup",
|
|
119
|
+
data: { backup },
|
|
120
|
+
},
|
|
121
|
+
counts: null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Any other error (EACCES/EIO on read) — no raw message echoed.
|
|
125
|
+
return {
|
|
126
|
+
check: { name: "plan-schema", status: "fail", detail: "could not read goals.json (permission or I/O error)" },
|
|
127
|
+
counts: null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function checkLedger(repoRoot, scope, readLedgerFn) {
|
|
132
|
+
try {
|
|
133
|
+
const { entries, skipped } = await readLedgerFn(repoRoot, scope);
|
|
134
|
+
if (entries.length === 0 && skipped === 0) {
|
|
135
|
+
return { check: { name: "ledger", status: "warn", detail: "no ledger yet" }, entries: [] };
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
check: {
|
|
139
|
+
name: "ledger",
|
|
140
|
+
status: skipped > 0 ? "warn" : "ok",
|
|
141
|
+
detail: `ledger present (${entries.length} entries, ${skipped} skipped)`,
|
|
142
|
+
data: { entries: entries.length, skipped },
|
|
143
|
+
},
|
|
144
|
+
entries,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Defensive — readLedger is fail-open, but the ledger check NEVER fails and NEVER throws.
|
|
149
|
+
return {
|
|
150
|
+
check: { name: "ledger", status: "warn", detail: "ledger unreadable (treated as absent)" },
|
|
151
|
+
entries: [],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function checkEvidenceDir(repoRoot, scope, statExistsFn) {
|
|
156
|
+
try {
|
|
157
|
+
const exists = await statExistsFn(litLoopEvidenceDir(repoRoot, scope));
|
|
158
|
+
return exists
|
|
159
|
+
? { name: "evidence-dir", status: "ok", detail: "evidence/ present" }
|
|
160
|
+
: { name: "evidence-dir", status: "warn", detail: "evidence/ missing — created lazily on first capture" };
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// A3 C6: same downgrade as state-dir; evidence-dir NEVER produces `fail`.
|
|
164
|
+
return {
|
|
165
|
+
name: "evidence-dir",
|
|
166
|
+
status: "warn",
|
|
167
|
+
detail: "evidence/ unreadable (permission or I/O)",
|
|
168
|
+
data: { code: "LIT_LOOP_WRITE_FAILED" },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function checkHook(repoRoot, hookRegisteredFn) {
|
|
173
|
+
try {
|
|
174
|
+
const wired = await hookRegisteredFn(repoRoot);
|
|
175
|
+
return wired
|
|
176
|
+
? { name: "hook", status: "ok", detail: "UserPromptSubmit hook registered" }
|
|
177
|
+
: { name: "hook", status: "warn", detail: "UserPromptSubmit hook not registered — run `litcodex install`" };
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Manifest unreadable / parse error / probe threw ⇒ warn. The hook check NEVER produces `fail`.
|
|
181
|
+
return { name: "hook", status: "warn", detail: "could not confirm hook registration" };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// ── Checkpoint derivation (pure) ──────────────────────────────────────────────────────────────
|
|
185
|
+
/** Scan the ledger tail backward for the last terminal entry; null when none usable. */
|
|
186
|
+
export function latestCheckpointFromLedger(entries) {
|
|
187
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
188
|
+
const entry = entries[i];
|
|
189
|
+
if (entry === undefined) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const kind = entry["kind"];
|
|
193
|
+
if (typeof kind !== "string" || !(kind in TERMINAL_LEDGER_KINDS)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const goalId = entry["goalId"];
|
|
197
|
+
const at = entry["at"];
|
|
198
|
+
if (typeof goalId === "string" && goalId !== "" && typeof at === "string" && at !== "") {
|
|
199
|
+
return { goalId, status: TERMINAL_LEDGER_KINDS[kind], at };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
// ── Orchestrator ──────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
/**
|
|
206
|
+
* Produce a LoopDoctorReport. NEVER throws, NEVER mutates state, NEVER calls process.exit. Every
|
|
207
|
+
* sub-check is independently fail-soft. Deterministic given the same on-disk state + injected deps.
|
|
208
|
+
*/
|
|
209
|
+
export async function runLoopDoctor(options, deps) {
|
|
210
|
+
const { repoRoot, scope } = options;
|
|
211
|
+
const d = {
|
|
212
|
+
readPlan: deps?.readPlan ?? ((r, s) => readPlan(r, s)),
|
|
213
|
+
readLedger: deps?.readLedger ??
|
|
214
|
+
(async (r, s) => {
|
|
215
|
+
const { entries, skipped } = await readLedger(r, s);
|
|
216
|
+
return { entries: entries, skipped };
|
|
217
|
+
}),
|
|
218
|
+
statExists: deps?.statExists ?? statExists,
|
|
219
|
+
hookRegistered: deps?.hookRegistered ?? hookRegistered,
|
|
220
|
+
};
|
|
221
|
+
// Murphy backstop (A3/S11 §9): a non-absolute root is misuse — never throw; report all-warn,
|
|
222
|
+
// with plan-schema `fail` so `healthy` is false (M09 always passes an absolute cwd).
|
|
223
|
+
if (typeof repoRoot !== "string" || !repoRoot.startsWith("/")) {
|
|
224
|
+
const detail = "invalid repoRoot (not absolute)";
|
|
225
|
+
const names = [
|
|
226
|
+
"state-dir",
|
|
227
|
+
"plan-schema",
|
|
228
|
+
"ledger",
|
|
229
|
+
"evidence-dir",
|
|
230
|
+
"hook",
|
|
231
|
+
"checkpoint",
|
|
232
|
+
];
|
|
233
|
+
const checks = names.map((name) => ({
|
|
234
|
+
name,
|
|
235
|
+
status: name === "plan-schema" ? "fail" : "warn",
|
|
236
|
+
detail,
|
|
237
|
+
}));
|
|
238
|
+
return { healthy: false, stateDir: "", checks, latestCheckpoint: null, counts: null };
|
|
239
|
+
}
|
|
240
|
+
const stateDirAbs = litLoopDir(repoRoot, scope);
|
|
241
|
+
const stateDir = repoRelative(stateDirAbs, repoRoot);
|
|
242
|
+
const stateDirCheck = await checkStateDir(stateDirAbs, d.statExists);
|
|
243
|
+
const planResult = await checkPlanSchema(repoRoot, scope, d.readPlan);
|
|
244
|
+
const ledgerResult = await checkLedger(repoRoot, scope, d.readLedger);
|
|
245
|
+
const evidenceCheck = await checkEvidenceDir(repoRoot, scope, d.statExists);
|
|
246
|
+
const hookCheck = await checkHook(repoRoot, d.hookRegistered);
|
|
247
|
+
const latestCheckpoint = latestCheckpointFromLedger(ledgerResult.entries);
|
|
248
|
+
const checkpointCheck = latestCheckpoint
|
|
249
|
+
? {
|
|
250
|
+
name: "checkpoint",
|
|
251
|
+
status: "ok",
|
|
252
|
+
detail: `latest checkpoint ${sanitizeId(latestCheckpoint.goalId)} -> ${latestCheckpoint.status}`,
|
|
253
|
+
}
|
|
254
|
+
: { name: "checkpoint", status: "warn", detail: "no checkpoint recorded yet" };
|
|
255
|
+
const checks = [
|
|
256
|
+
stateDirCheck,
|
|
257
|
+
planResult.check,
|
|
258
|
+
ledgerResult.check,
|
|
259
|
+
evidenceCheck,
|
|
260
|
+
hookCheck,
|
|
261
|
+
checkpointCheck,
|
|
262
|
+
];
|
|
263
|
+
const healthy = checks.every((c) => c.status !== "fail");
|
|
264
|
+
return { healthy, stateDir, checks, latestCheckpoint, counts: planResult.counts };
|
|
265
|
+
}
|
|
266
|
+
// ── Error helpers ─────────────────────────────────────────────────────────────────────────────
|
|
267
|
+
function errCode(err) {
|
|
268
|
+
if (typeof err === "object" && err !== null && "code" in err) {
|
|
269
|
+
const code = err.code;
|
|
270
|
+
return typeof code === "string" ? code : undefined;
|
|
271
|
+
}
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
function errBackup(err) {
|
|
275
|
+
if (typeof err === "object" && err !== null && "details" in err) {
|
|
276
|
+
const details = err.details;
|
|
277
|
+
if (typeof details === "object" && details !== null && "backup" in details) {
|
|
278
|
+
const backup = details.backup;
|
|
279
|
+
return typeof backup === "string" ? backup : "";
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Stable SCREAMING_SNAKE error codes; consumers map them to an exit code via `.code`. */
|
|
2
|
+
export type LoopErrorCode = "LIT_LOOP_SUBCOMMAND_UNKNOWN" | "LIT_LOOP_ARGUMENT_MISSING" | "LIT_LOOP_ARGUMENT_INVALID" | "LIT_LOOP_BRIEF_REQUIRED" | "LIT_LOOP_BRIEF_FILE_UNREADABLE" | "LIT_LOOP_EVIDENCE_STATUS_INVALID" | "LIT_LOOP_GOAL_NOT_FOUND" | "LIT_LOOP_CRITERION_NOT_FOUND" | "LIT_LOOP_CRITERIA_NOT_ALL_PASS";
|
|
3
|
+
/** Machine-readable CLI error. `code` is the stable token; the exit code is derived from it. */
|
|
4
|
+
export declare class LitLoopError extends Error {
|
|
5
|
+
readonly name = "LitLoopError";
|
|
6
|
+
readonly code: LoopErrorCode;
|
|
7
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
8
|
+
constructor(message: string, code: LoopErrorCode, details?: Readonly<Record<string, unknown>>);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Exit code for any thrown error, branching ONLY on `err.code` (A3 C7 — never `instanceof`
|
|
12
|
+
* PlanMissingError/PlanCorruptError). Store codes (PLAN_MISSING→3, PLAN_CORRUPT→4, WRITE_FAILED→5)
|
|
13
|
+
* defer to the store-owned {@link exitCodeFor}; the CLI's own codes map here.
|
|
14
|
+
*/
|
|
15
|
+
export declare function exitCodeForLoop(err: unknown): number;
|