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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/bin/litcodex.js +12 -0
  4. package/dist/cli.d.ts +23 -0
  5. package/dist/cli.js +183 -0
  6. package/dist/config-migration/backup.d.ts +2 -0
  7. package/dist/config-migration/backup.js +42 -0
  8. package/dist/config-migration/catalog.d.ts +22 -0
  9. package/dist/config-migration/catalog.js +99 -0
  10. package/dist/config-migration/cli.d.ts +14 -0
  11. package/dist/config-migration/cli.js +85 -0
  12. package/dist/config-migration/config-paths.d.ts +4 -0
  13. package/dist/config-migration/config-paths.js +64 -0
  14. package/dist/config-migration/errors.d.ts +11 -0
  15. package/dist/config-migration/errors.js +28 -0
  16. package/dist/config-migration/index.d.ts +44 -0
  17. package/dist/config-migration/index.js +210 -0
  18. package/dist/config-migration/multi-agent-v2-guard.d.ts +2 -0
  19. package/dist/config-migration/multi-agent-v2-guard.js +106 -0
  20. package/dist/config-migration/root-settings.d.ts +6 -0
  21. package/dist/config-migration/root-settings.js +104 -0
  22. package/dist/config-migration/state.d.ts +16 -0
  23. package/dist/config-migration/state.js +40 -0
  24. package/dist/config-migration/toml-shape.d.ts +8 -0
  25. package/dist/config-migration/toml-shape.js +107 -0
  26. package/dist/install/codex.d.ts +34 -0
  27. package/dist/install/codex.js +94 -0
  28. package/dist/install/doctor.d.ts +12 -0
  29. package/dist/install/doctor.js +83 -0
  30. package/dist/install/errors.d.ts +19 -0
  31. package/dist/install/errors.js +43 -0
  32. package/dist/install/execute.d.ts +39 -0
  33. package/dist/install/execute.js +193 -0
  34. package/dist/install/index.d.ts +19 -0
  35. package/dist/install/index.js +193 -0
  36. package/dist/install/marketplace.d.ts +5 -0
  37. package/dist/install/marketplace.js +10 -0
  38. package/dist/install/plan.d.ts +3 -0
  39. package/dist/install/plan.js +54 -0
  40. package/dist/install/render-plan.d.ts +3 -0
  41. package/dist/install/render-plan.js +10 -0
  42. package/dist/install/types.d.ts +45 -0
  43. package/dist/install/types.js +5 -0
  44. package/model-catalog.json +31 -0
  45. package/node_modules/@litcodex/lit-loop/CHANGELOG.md +19 -0
  46. package/node_modules/@litcodex/lit-loop/LICENSE +21 -0
  47. package/node_modules/@litcodex/lit-loop/NOTICE +8 -0
  48. package/node_modules/@litcodex/lit-loop/README.md +37 -0
  49. package/node_modules/@litcodex/lit-loop/agents/litcodex-explorer.toml +75 -0
  50. package/node_modules/@litcodex/lit-loop/agents/litcodex-librarian.toml +98 -0
  51. package/node_modules/@litcodex/lit-loop/agents/litcodex-litwork-reviewer.toml +21 -0
  52. package/node_modules/@litcodex/lit-loop/agents/litcodex-metis.toml +64 -0
  53. package/node_modules/@litcodex/lit-loop/agents/litcodex-momus.toml +68 -0
  54. package/node_modules/@litcodex/lit-loop/agents/litcodex-plan.toml +163 -0
  55. package/node_modules/@litcodex/lit-loop/directive.md +85 -0
  56. package/node_modules/@litcodex/lit-loop/directives/lit-plan.md +286 -0
  57. package/node_modules/@litcodex/lit-loop/directives/litgoal.md +103 -0
  58. package/node_modules/@litcodex/lit-loop/directives/litwork.md +363 -0
  59. package/node_modules/@litcodex/lit-loop/dist/_scaffold.d.ts +1 -0
  60. package/node_modules/@litcodex/lit-loop/dist/_scaffold.js +3 -0
  61. package/node_modules/@litcodex/lit-loop/dist/cli.d.ts +6 -0
  62. package/node_modules/@litcodex/lit-loop/dist/cli.js +44 -0
  63. package/node_modules/@litcodex/lit-loop/dist/codex-goal-instruction.d.ts +18 -0
  64. package/node_modules/@litcodex/lit-loop/dist/codex-goal-instruction.js +94 -0
  65. package/node_modules/@litcodex/lit-loop/dist/codex-hook.d.ts +38 -0
  66. package/node_modules/@litcodex/lit-loop/dist/codex-hook.js +126 -0
  67. package/node_modules/@litcodex/lit-loop/dist/directive.d.ts +35 -0
  68. package/node_modules/@litcodex/lit-loop/dist/directive.js +80 -0
  69. package/node_modules/@litcodex/lit-loop/dist/goal-status.d.ts +12 -0
  70. package/node_modules/@litcodex/lit-loop/dist/goal-status.js +25 -0
  71. package/node_modules/@litcodex/lit-loop/dist/guards.d.ts +73 -0
  72. package/node_modules/@litcodex/lit-loop/dist/guards.js +215 -0
  73. package/node_modules/@litcodex/lit-loop/dist/hook-cli.d.ts +17 -0
  74. package/node_modules/@litcodex/lit-loop/dist/hook-cli.js +94 -0
  75. package/node_modules/@litcodex/lit-loop/dist/loop-cli.d.ts +19 -0
  76. package/node_modules/@litcodex/lit-loop/dist/loop-cli.js +106 -0
  77. package/node_modules/@litcodex/lit-loop/dist/loop-doctor-render.d.ts +7 -0
  78. package/node_modules/@litcodex/lit-loop/dist/loop-doctor-render.js +39 -0
  79. package/node_modules/@litcodex/lit-loop/dist/loop-doctor-types.d.ts +52 -0
  80. package/node_modules/@litcodex/lit-loop/dist/loop-doctor-types.js +7 -0
  81. package/node_modules/@litcodex/lit-loop/dist/loop-doctor.d.ts +21 -0
  82. package/node_modules/@litcodex/lit-loop/dist/loop-doctor.js +283 -0
  83. package/node_modules/@litcodex/lit-loop/dist/loop-errors.d.ts +15 -0
  84. package/node_modules/@litcodex/lit-loop/dist/loop-errors.js +43 -0
  85. package/node_modules/@litcodex/lit-loop/dist/loop-handlers.d.ts +18 -0
  86. package/node_modules/@litcodex/lit-loop/dist/loop-handlers.js +311 -0
  87. package/node_modules/@litcodex/lit-loop/dist/loop-model.d.ts +51 -0
  88. package/node_modules/@litcodex/lit-loop/dist/loop-model.js +165 -0
  89. package/node_modules/@litcodex/lit-loop/dist/loop-stdout.d.ts +6 -0
  90. package/node_modules/@litcodex/lit-loop/dist/loop-stdout.js +11 -0
  91. package/node_modules/@litcodex/lit-loop/dist/loop-types.d.ts +26 -0
  92. package/node_modules/@litcodex/lit-loop/dist/loop-types.js +8 -0
  93. package/node_modules/@litcodex/lit-loop/dist/markers.d.ts +9 -0
  94. package/node_modules/@litcodex/lit-loop/dist/markers.js +14 -0
  95. package/node_modules/@litcodex/lit-loop/dist/modes.d.ts +15 -0
  96. package/node_modules/@litcodex/lit-loop/dist/modes.js +56 -0
  97. package/node_modules/@litcodex/lit-loop/dist/state-paths.d.ts +41 -0
  98. package/node_modules/@litcodex/lit-loop/dist/state-paths.js +111 -0
  99. package/node_modules/@litcodex/lit-loop/dist/state-store.d.ts +39 -0
  100. package/node_modules/@litcodex/lit-loop/dist/state-store.js +419 -0
  101. package/node_modules/@litcodex/lit-loop/dist/state-types.d.ts +90 -0
  102. package/node_modules/@litcodex/lit-loop/dist/state-types.js +61 -0
  103. package/node_modules/@litcodex/lit-loop/dist/trigger.d.ts +54 -0
  104. package/node_modules/@litcodex/lit-loop/dist/trigger.js +75 -0
  105. package/node_modules/@litcodex/lit-loop/package.json +27 -0
  106. package/package.json +30 -0
@@ -0,0 +1,126 @@
1
+ // src/codex-hook.ts — M06 pure hook engine (A2 §2.4, A3 C3/C12).
2
+ //
3
+ // Takes an already-parsed `unknown` (the JSON.parse'd stdin value) and returns a structured
4
+ // HookDecision. NO process / stdin / stdout / exit lives here — that is hook-cli.ts. The engine is
5
+ // total over `unknown` and NEVER throws on a structurally-valid event.
6
+ //
7
+ // Activation policy is delegated to the SINGLE guard entry point
8
+ // `shouldSuppressLitLoopInjection(prompt, transcriptPath)` from ./guards.js (A3 C3): the engine
9
+ // branches only on its `{suppress, reason}` result and imports NOTHING from the trigger module
10
+ // (guard 0 `not-a-trigger` already covers lexical trigger detection). The engine never reads the
11
+ // transcript itself and never slices the prompt by any offset (A3 A6).
12
+ //
13
+ // Casing (A3 C12): the input type-guard accepts BOTH the snake_case wire key `hook_event_name`
14
+ // (primary) AND the camelCase `hookEventName` (fallback). The emitted envelope is ALWAYS camelCase
15
+ // (`hookSpecificOutput.hookEventName`).
16
+ //
17
+ // On activation the payload is the trusted bundled directive, loaded behind a FAIL-SILENT boundary:
18
+ // a directive-load error resolves to "" (→ noop), so a missing dist/directive.md degrades the hook
19
+ // to a silent no-op + exit 0 rather than throwing into the Codex host.
20
+ import { loadDirectiveFrom } from "./directive.js";
21
+ import { shouldSuppressInjection } from "./guards.js";
22
+ import { modeForToken } from "./modes.js";
23
+ import { matchLitTrigger } from "./trigger.js";
24
+ const HOOK_EVENT_NAME = "UserPromptSubmit";
25
+ /** Narrow to a plain (non-null, non-array) record. */
26
+ function isRecord(value) {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+ /** Read either casing of the event-name key (snake wins on conflict, A3 C12). */
30
+ function readEventName(record) {
31
+ if (Object.hasOwn(record, "hook_event_name")) {
32
+ return record["hook_event_name"];
33
+ }
34
+ return record["hookEventName"];
35
+ }
36
+ /** Read either casing of the transcript-path key (snake wins on conflict). */
37
+ function readTranscriptPath(record) {
38
+ if (Object.hasOwn(record, "transcript_path")) {
39
+ return record["transcript_path"];
40
+ }
41
+ if (Object.hasOwn(record, "transcriptPath")) {
42
+ return record["transcriptPath"];
43
+ }
44
+ return undefined;
45
+ }
46
+ function isTranscriptPathValue(value) {
47
+ return value === undefined || value === null || typeof value === "string";
48
+ }
49
+ /**
50
+ * Type-guard. True iff `value` is a record whose accepted event-name key (snake `hook_event_name`
51
+ * primary, camel `hookEventName` fallback) === "UserPromptSubmit", `prompt` is a string, and the
52
+ * transcript-path key (either casing) is undefined | null | string (A3 C12). This is a runtime
53
+ * accept-set, not a structural type assert, so a camelCase-keyed record also returns true.
54
+ */
55
+ export function isLitUserPromptSubmitInput(value) {
56
+ if (!isRecord(value)) {
57
+ return false;
58
+ }
59
+ if (readEventName(value) !== HOOK_EVENT_NAME) {
60
+ return false;
61
+ }
62
+ if (typeof value["prompt"] !== "string") {
63
+ return false;
64
+ }
65
+ return isTranscriptPathValue(readTranscriptPath(value));
66
+ }
67
+ /**
68
+ * Wraps directive text in the Codex output envelope. Returns "" if the normalized context is empty
69
+ * (caller treats "" as noop). Output is a single-line JSON + trailing "\n":
70
+ * {"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":<ctx>}}\n
71
+ */
72
+ export function formatAdditionalContextOutput(additionalContext) {
73
+ const normalized = additionalContext.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
74
+ if (normalized.length === 0) {
75
+ return "";
76
+ }
77
+ const output = {
78
+ hookSpecificOutput: {
79
+ hookEventName: HOOK_EVENT_NAME,
80
+ additionalContext: normalized,
81
+ },
82
+ };
83
+ return `${JSON.stringify(output)}\n`;
84
+ }
85
+ /**
86
+ * Load the matched mode's directive behind a fail-silent boundary: any loader error (e.g. a missing
87
+ * directives/<mode>.md or a marker-wrapper mismatch) resolves to "" so the hook degrades to a silent
88
+ * no-op instead of throwing into the Codex host.
89
+ */
90
+ function loadDirectiveForModeFailSilent(mode) {
91
+ try {
92
+ return loadDirectiveFrom(mode.directivePath, mode.openMarker, mode.closeMarker);
93
+ }
94
+ catch {
95
+ return "";
96
+ }
97
+ }
98
+ /**
99
+ * Pure decision function (multi-mode router). Takes an already-parsed value (NOT a raw string).
100
+ * Total over `unknown`; NEVER throws. Flow: validate shape → match the lit-family trigger token
101
+ * (guard 0) → resolve the mode → run guards 1-3 against the MATCHED mode's marker (RC1) → load that
102
+ * mode's directive fail-silent. Returns { kind:"noop" } for a wrong-shape value, no trigger, a guard
103
+ * veto, or a fail-silent empty directive; { kind:"inject", stdout } only on a real activation.
104
+ */
105
+ export function runUserPromptSubmitHook(input) {
106
+ if (!isLitUserPromptSubmitInput(input)) {
107
+ return { kind: "noop" };
108
+ }
109
+ // `input` is a narrowed record here; re-read the transcript key (either casing) as a raw value.
110
+ const transcriptPath = readTranscriptPath(input);
111
+ // Guard 0 / routing: which bounded lit-family token matched → which mode + directive.
112
+ const match = matchLitTrigger(input.prompt);
113
+ if (match === null) {
114
+ return { kind: "noop" };
115
+ }
116
+ const mode = modeForToken(match.token);
117
+ const decision = shouldSuppressInjection(input.prompt, transcriptPath, mode.openMarker);
118
+ if (decision.suppress) {
119
+ return { kind: "noop" };
120
+ }
121
+ const stdout = formatAdditionalContextOutput(loadDirectiveForModeFailSilent(mode));
122
+ if (stdout === "") {
123
+ return { kind: "noop" };
124
+ }
125
+ return { kind: "inject", stdout };
126
+ }
@@ -0,0 +1,35 @@
1
+ export { LIT_LOOP_DIRECTIVE_MARKER } from "./markers.js";
2
+ /** Machine-readable codes for the three load-failure classes (ordered: unreadable → empty → marker). */
3
+ export type LitLoopDirectiveErrorCode = "LIT_LOOP_DIRECTIVE_UNREADABLE" | "LIT_LOOP_DIRECTIVE_EMPTY" | "LIT_LOOP_DIRECTIVE_MARKER_MISSING";
4
+ /** Typed error carrying a machine-readable code and the absolute path the loader attempted. */
5
+ export declare class LitLoopDirectiveError extends Error {
6
+ readonly code: LitLoopDirectiveErrorCode;
7
+ readonly resolvedPath: string;
8
+ constructor(code: LitLoopDirectiveErrorCode, message: string, resolvedPath: string);
9
+ }
10
+ /**
11
+ * Generic (RC2): read + normalize + validate ANY mode's directive at an explicit path against an
12
+ * explicit `openMarker`/`closeMarker` pair. The multi-mode router (codex-hook → modes) uses this to
13
+ * load litwork / lit-plan / litgoal directives; `loadLitLoopDirectiveFrom` is the lit-loop wrapper.
14
+ * Normalization: CRLF→LF, lone CR→LF, then trim(). Fail-loud with a typed error (codes are generic).
15
+ */
16
+ export declare function loadDirectiveFrom(resolvedPath: string, openMarker: string, closeMarker: string): string;
17
+ /**
18
+ * Read + normalize + validate the lit-loop directive at an explicit path. Test-only seam (S10
19
+ * §Test plan) so corrupt / CRLF / missing-file cases can be exercised without swapping the bundled
20
+ * file. Delegates to the generic `loadDirectiveFrom` with the lit-loop marker pair.
21
+ */
22
+ export declare function loadLitLoopDirectiveFrom(resolvedPath: string): string;
23
+ /**
24
+ * Load the bundled directive.md from its resolved dist path. Re-readable on demand (tests /
25
+ * dev hot-reload); production consumers use the eagerly-computed `LIT_LOOP_DIRECTIVE` constant.
26
+ *
27
+ * @throws LitLoopDirectiveError code UNREADABLE / EMPTY / MARKER_MISSING.
28
+ */
29
+ export declare function loadLitLoopDirective(): string;
30
+ /**
31
+ * The normalized directive text, read once at module-load time. Guaranteed to start with
32
+ * `<lit-loop-mode>`, end with `</lit-loop-mode>`, and contain no `\r`; non-empty; frozen for
33
+ * the process lifetime.
34
+ */
35
+ export declare const LIT_LOOP_DIRECTIVE: string;
@@ -0,0 +1,80 @@
1
+ // src/directive.ts — M10 lit-loop directive loader.
2
+ //
3
+ // Reads the bundled directive.md at module-load time, normalizes line endings + trims, and
4
+ // exports the normalized text plus the wrapper marker. This module owns content + loading only:
5
+ // it never parses prompts, emits hook JSON, applies guards, or persists loop state, and it
6
+ // imports NOTHING from trigger / guards / state-store / codex-hook (one-way: hook → directive).
7
+ //
8
+ // A3 C1: the marker is the single-source `markers.ts` value — re-exported here, never re-declared.
9
+ // A3 G6 (SUPERSEDES C8): the single authored `directive.md` lives at the COMPONENT ROOT (tracked,
10
+ // in package.json files[]). The loader resolves `../directive.md` relative to its own module
11
+ // so it lands on that one file from BOTH layouts: src/directive.ts → <component>/directive.md
12
+ // (dev / vitest src-tree) AND dist/directive.js → <pkg>/directive.md (published tarball,
13
+ // since files[] ships directive.md at the tarball root beside dist/). No build-copy, no
14
+ // src/dist mirror — so a clean checkout passes `npm test` BEFORE any `npm run build`.
15
+ // Resolution uses `fileURLToPath(new URL(...))` — the percent-decoding, Windows-safe form
16
+ // that survives space/#/Hangul in the repo path.
17
+ import { readFileSync } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+ import { LIT_LOOP_DIRECTIVE_CLOSE, LIT_LOOP_DIRECTIVE_MARKER } from "./markers.js";
20
+ // Re-export the single-source marker (A3 C1) so consumers import it from directive.ts unchanged.
21
+ export { LIT_LOOP_DIRECTIVE_MARKER } from "./markers.js";
22
+ /** Typed error carrying a machine-readable code and the absolute path the loader attempted. */
23
+ export class LitLoopDirectiveError extends Error {
24
+ constructor(code, message, resolvedPath) {
25
+ super(message);
26
+ this.name = "LitLoopDirectiveError";
27
+ this.code = code;
28
+ this.resolvedPath = resolvedPath;
29
+ }
30
+ }
31
+ // A3 G6: directive.md is the authored component-root file, one level above this module's dir
32
+ // (above src/ in the dev tree; above dist/ in the published tarball — files[] ships it at root).
33
+ const DIRECTIVE_PATH = fileURLToPath(new URL("../directive.md", import.meta.url));
34
+ /**
35
+ * Generic (RC2): read + normalize + validate ANY mode's directive at an explicit path against an
36
+ * explicit `openMarker`/`closeMarker` pair. The multi-mode router (codex-hook → modes) uses this to
37
+ * load litwork / lit-plan / litgoal directives; `loadLitLoopDirectiveFrom` is the lit-loop wrapper.
38
+ * Normalization: CRLF→LF, lone CR→LF, then trim(). Fail-loud with a typed error (codes are generic).
39
+ */
40
+ export function loadDirectiveFrom(resolvedPath, openMarker, closeMarker) {
41
+ let raw;
42
+ try {
43
+ raw = readFileSync(resolvedPath, "utf8");
44
+ }
45
+ catch (err) {
46
+ const detail = err instanceof Error ? err.message : String(err);
47
+ throw new LitLoopDirectiveError("LIT_LOOP_DIRECTIVE_UNREADABLE", `directive: unreadable at ${resolvedPath}: ${detail}`, resolvedPath);
48
+ }
49
+ const normalized = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
50
+ if (normalized.length === 0) {
51
+ throw new LitLoopDirectiveError("LIT_LOOP_DIRECTIVE_EMPTY", `directive: empty after normalization at ${resolvedPath}`, resolvedPath);
52
+ }
53
+ if (!normalized.startsWith(openMarker) || !normalized.endsWith(closeMarker)) {
54
+ throw new LitLoopDirectiveError("LIT_LOOP_DIRECTIVE_MARKER_MISSING", `directive: missing ${openMarker} … ${closeMarker} wrapper at ${resolvedPath}`, resolvedPath);
55
+ }
56
+ return normalized;
57
+ }
58
+ /**
59
+ * Read + normalize + validate the lit-loop directive at an explicit path. Test-only seam (S10
60
+ * §Test plan) so corrupt / CRLF / missing-file cases can be exercised without swapping the bundled
61
+ * file. Delegates to the generic `loadDirectiveFrom` with the lit-loop marker pair.
62
+ */
63
+ export function loadLitLoopDirectiveFrom(resolvedPath) {
64
+ return loadDirectiveFrom(resolvedPath, LIT_LOOP_DIRECTIVE_MARKER, LIT_LOOP_DIRECTIVE_CLOSE);
65
+ }
66
+ /**
67
+ * Load the bundled directive.md from its resolved dist path. Re-readable on demand (tests /
68
+ * dev hot-reload); production consumers use the eagerly-computed `LIT_LOOP_DIRECTIVE` constant.
69
+ *
70
+ * @throws LitLoopDirectiveError code UNREADABLE / EMPTY / MARKER_MISSING.
71
+ */
72
+ export function loadLitLoopDirective() {
73
+ return loadLitLoopDirectiveFrom(DIRECTIVE_PATH);
74
+ }
75
+ /**
76
+ * The normalized directive text, read once at module-load time. Guaranteed to start with
77
+ * `<lit-loop-mode>`, end with `</lit-loop-mode>`, and contain no `\r`; non-empty; frozen for
78
+ * the process lifetime.
79
+ */
80
+ export const LIT_LOOP_DIRECTIVE = loadLitLoopDirective();
@@ -0,0 +1,12 @@
1
+ import type { LoopGoal, LoopPlan } from "./loop-types.js";
2
+ /** The Codex goal objective for a given lit-loop goal (per-story: one Codex goal per goal). */
3
+ export declare function expectedCodexObjective(goal: LoopGoal): string;
4
+ /** True when the goal has at least one criterion and every criterion is `pass`. */
5
+ export declare function hasAllCriteriaPass(goal: LoopGoal): boolean;
6
+ /** True when every goal in the plan is `complete`. */
7
+ export declare function isLitLoopDone(plan: LoopPlan): boolean;
8
+ /**
9
+ * True when the given goal is NOT complete AND every OTHER goal IS complete — meaning completing
10
+ * this goal would finish the entire plan.
11
+ */
12
+ export declare function isFinalRunCompletionCandidate(plan: LoopPlan, goal: LoopGoal): boolean;
@@ -0,0 +1,25 @@
1
+ // src/goal-status.ts — pure goal-status helpers for Codex native-goal integration.
2
+ //
3
+ // Predicates and objective resolver for the lit-loop ↔ Codex `/goal` handoff. All functions are
4
+ // PURE — no I/O, no store, no clock. Imports only loop-types (which re-exports state-types).
5
+ /** The Codex goal objective for a given lit-loop goal (per-story: one Codex goal per goal). */
6
+ export function expectedCodexObjective(goal) {
7
+ return goal.objective;
8
+ }
9
+ /** True when the goal has at least one criterion and every criterion is `pass`. */
10
+ export function hasAllCriteriaPass(goal) {
11
+ return goal.successCriteria.length > 0 && goal.successCriteria.every((c) => c.status === "pass");
12
+ }
13
+ /** True when every goal in the plan is `complete`. */
14
+ export function isLitLoopDone(plan) {
15
+ return plan.goals.every((g) => g.status === "complete");
16
+ }
17
+ /**
18
+ * True when the given goal is NOT complete AND every OTHER goal IS complete — meaning completing
19
+ * this goal would finish the entire plan.
20
+ */
21
+ export function isFinalRunCompletionCandidate(plan, goal) {
22
+ if (goal.status === "complete")
23
+ return false;
24
+ return plan.goals.every((g) => g.id === goal.id || g.status === "complete");
25
+ }
@@ -0,0 +1,73 @@
1
+ export { LIT_LOOP_DIRECTIVE_MARKER } from "./markers.js";
2
+ /**
3
+ * Context-pressure / compaction / recovery markers. Lowercase substrings; matching is
4
+ * case-insensitive (input is lowercased before .includes). VERBATIM and ORDER-STABLE — the single
5
+ * source of truth (A3 §E): any module needing context-pressure detection MUST import this array,
6
+ * never copy it. Marker 5 retains the literal token `codex` (the host runtime, NOT a legacy brand —
7
+ * A3 C16) and the literal U+0027 apostrophe in `model's`.
8
+ */
9
+ export declare const CONTEXT_PRESSURE_MARKERS: readonly ["context compacted", "context_length_exceeded", "skill descriptions were shortened", "context_too_large", "codex ran out of room in the model's context window", "your input exceeds the context window", "long threads and multiple compactions"];
10
+ /** Tail window (bytes) read for the guard-2 directive-dedupe scan. Exactly 512000. */
11
+ export declare const TRANSCRIPT_SEARCH_BYTES: 512000;
12
+ /**
13
+ * Per-end byte budget for the guard-3 (context-pressure-transcript) read. Guard 3 reads at most the
14
+ * first and last GUARD3_WINDOW_BYTES of the transcript (whole file when smaller than 2×). Exactly
15
+ * 1 MiB per end so worst-case guard-3 work is ≤ 2 MiB of fixed-string scanning (A3 addendum §A).
16
+ * Distinct from TRANSCRIPT_SEARCH_BYTES (the guard-2 tail); the two MUST NOT be aliased.
17
+ */
18
+ export declare const GUARD3_WINDOW_BYTES: 1048576;
19
+ /**
20
+ * The hook OUTPUT envelope event-name value, written to the transcript by M06 and matched by guard
21
+ * 2 (A3 addendum §D). camelCase, load-bearing; declared exactly once here so M06's emitter and this
22
+ * guard cannot drift. M06 imports this from ./guards.js (one-way: codex-hook -> guards).
23
+ */
24
+ export declare const HOOK_OUTPUT_EVENT_NAME: "UserPromptSubmit";
25
+ /** Normalized transcript-path input shape accepted by the file-reading guards. */
26
+ export type GuardTranscriptInput = string | null | undefined;
27
+ /** Structured suppression decision returned to the M06 hook runner. */
28
+ export interface LitLoopGuardDecision {
29
+ /** true => the hook MUST emit "" (no directive); false => injection may proceed. */
30
+ readonly suppress: boolean;
31
+ /** Machine-readable reason. "none" only when suppress === false. */
32
+ readonly reason: "none" | "context-pressure-prompt" | "already-injected" | "context-pressure-transcript" | "not-a-trigger";
33
+ }
34
+ /**
35
+ * Pure lexical test: does `text` contain ANY context-pressure marker? Lowercases `text`, then
36
+ * CONTEXT_PRESSURE_MARKERS.some(m => lower.includes(m)). No I/O, no state, idempotent.
37
+ * @throws TypeError if `text` is not a string.
38
+ */
39
+ export declare function hasContextPressureMarker(text: string): boolean;
40
+ /** Alias used by the prompt-level guard (guard 1). Identical semantics to hasContextPressureMarker. */
41
+ export declare function isContextPressurePrompt(prompt: string): boolean;
42
+ /**
43
+ * Generic per-mode idempotency check (RC1). Reads the TAIL (last TRANSCRIPT_SEARCH_BYTES bytes) of
44
+ * the transcript, parses each JSONL line, and returns true iff some line is a prior HOOK OUTPUT
45
+ * envelope: parsed.hookSpecificOutput.hookEventName === HOOK_OUTPUT_EVENT_NAME AND
46
+ * typeof additionalContext === "string" AND additionalContext.includes(`marker`). A marker quoted
47
+ * inside a user/assistant content STRING (or a forged envelope embedded as text) is NOT a top-level
48
+ * envelope line and is skipped. FAIL-OPEN: any thrown Error => false. The multi-mode hook passes the
49
+ * MATCHED mode's open marker so re-injection is suppressed per mode (mode switching is allowed).
50
+ */
51
+ export declare function transcriptHasDirectiveMarker(transcriptPath: GuardTranscriptInput, marker: string): boolean;
52
+ /** Lit-loop idempotency guard (guard 2) — the generic check bound to the lit-loop marker. */
53
+ export declare function transcriptHasLitLoopDirective(transcriptPath: GuardTranscriptInput): boolean;
54
+ /**
55
+ * Context-pressure transcript guard (guard 3). Reads a bounded head+tail window of the transcript
56
+ * (A3 addendum §A): the whole file when ≤ 2×GUARD3_WINDOW_BYTES, else the first + last
57
+ * GUARD3_WINDOW_BYTES joined by a "\n" separator (so a marker cannot be manufactured across the
58
+ * cut), then runs hasContextPressureMarker over it. Markers buried in nested JSON are caught by
59
+ * design. FAIL-OPEN: any thrown Error => false. null/undefined path => false.
60
+ */
61
+ export declare function transcriptHasContextPressureMarker(transcriptPath: GuardTranscriptInput): boolean;
62
+ /**
63
+ * THE single decision the M06 hook runner calls. Evaluates the four guards in fixed order and
64
+ * returns the first veto, else {suppress:false, reason:"none"}. Suppression dominates activation:
65
+ * guard 0 (not-a-trigger) runs first so the file-I/O guards only fire when a trigger is present;
66
+ * guard 1 (context-pressure-prompt) runs before the transcript guards so a recovery prompt that
67
+ * also contains `lit` is suppressed without any file read. Never throws for any string prompt +
68
+ * GuardTranscriptInput path (the only throw is the non-string prompt defensive guard).
69
+ * @throws TypeError only if `prompt` is not a string (M06 type-guards this upstream).
70
+ */
71
+ export declare function shouldSuppressInjection(prompt: string, transcriptPath: GuardTranscriptInput, openMarker: string): LitLoopGuardDecision;
72
+ /** Lit-loop entry point (backward-compatible) — the generic decision bound to the lit-loop marker. */
73
+ export declare function shouldSuppressLitLoopInjection(prompt: string, transcriptPath: GuardTranscriptInput): LitLoopGuardDecision;
@@ -0,0 +1,215 @@
1
+ // src/guards.ts — M07 activation-policy guard layer.
2
+ //
3
+ // Sits between the M05 bounded trigger matcher (./trigger.js) and the M06 hook runner. M05 asks
4
+ // "does this prompt lexically contain a `lit` trigger?"; M07 asks "given the surrounding session
5
+ // state, should we actually inject the <lit-loop-mode> directive NOW?". It owns four suppression
6
+ // guards, evaluated in a fixed order (suppression dominates activation):
7
+ // 0. not a lit trigger -> suppress, "not-a-trigger"
8
+ // 1. prompt is a context-pressure msg -> suppress, "context-pressure-prompt"
9
+ // 2. transcript already hook-injected -> suppress, "already-injected"
10
+ // 3. transcript shows context pressure -> suppress, "context-pressure-transcript"
11
+ // else -> allow, "none"
12
+ //
13
+ // Detection is PURELY lexical/structural (fixed marker tables + a fixed JSON-envelope shape), so
14
+ // prompt prose cannot alter any guard outcome. Every file-reading guard is FAIL-OPEN: any thrown
15
+ // Error returns false (no suppression from that guard), never throwing — a missing, unreadable,
16
+ // oversized, or malformed transcript can never crash the Codex turn. This module reads transcript
17
+ // files but writes nothing and holds no mutable state.
18
+ //
19
+ // Dependency direction (A3 C9 flat layout; one-way: codex-hook -> guards -> trigger): imports ONLY
20
+ // ./trigger.js and ./markers.js; NEVER ./directive.js, ./state-store.js, or ./codex-hook.js
21
+ // (no-cycle invariant). The marker is the single-source markers.ts value, re-exported here
22
+ // (A3 C1) — never re-declared.
23
+ import { readFileSync } from "node:fs";
24
+ import { LIT_LOOP_DIRECTIVE_MARKER } from "./markers.js";
25
+ import { isLitTriggerPrompt } from "./trigger.js";
26
+ // Re-export the single-source directive marker (A3 C1) so the guards public surface is unchanged
27
+ // while markers.ts stays the only declaration site (guards never imports directive — no cycle).
28
+ export { LIT_LOOP_DIRECTIVE_MARKER } from "./markers.js";
29
+ /**
30
+ * Context-pressure / compaction / recovery markers. Lowercase substrings; matching is
31
+ * case-insensitive (input is lowercased before .includes). VERBATIM and ORDER-STABLE — the single
32
+ * source of truth (A3 §E): any module needing context-pressure detection MUST import this array,
33
+ * never copy it. Marker 5 retains the literal token `codex` (the host runtime, NOT a legacy brand —
34
+ * A3 C16) and the literal U+0027 apostrophe in `model's`.
35
+ */
36
+ export const CONTEXT_PRESSURE_MARKERS = Object.freeze([
37
+ "context compacted",
38
+ "context_length_exceeded",
39
+ "skill descriptions were shortened",
40
+ "context_too_large",
41
+ "codex ran out of room in the model's context window",
42
+ "your input exceeds the context window",
43
+ "long threads and multiple compactions",
44
+ ]);
45
+ /** Tail window (bytes) read for the guard-2 directive-dedupe scan. Exactly 512000. */
46
+ export const TRANSCRIPT_SEARCH_BYTES = 512000;
47
+ /**
48
+ * Per-end byte budget for the guard-3 (context-pressure-transcript) read. Guard 3 reads at most the
49
+ * first and last GUARD3_WINDOW_BYTES of the transcript (whole file when smaller than 2×). Exactly
50
+ * 1 MiB per end so worst-case guard-3 work is ≤ 2 MiB of fixed-string scanning (A3 addendum §A).
51
+ * Distinct from TRANSCRIPT_SEARCH_BYTES (the guard-2 tail); the two MUST NOT be aliased.
52
+ */
53
+ export const GUARD3_WINDOW_BYTES = 1048576;
54
+ /**
55
+ * The hook OUTPUT envelope event-name value, written to the transcript by M06 and matched by guard
56
+ * 2 (A3 addendum §D). camelCase, load-bearing; declared exactly once here so M06's emitter and this
57
+ * guard cannot drift. M06 imports this from ./guards.js (one-way: codex-hook -> guards).
58
+ */
59
+ export const HOOK_OUTPUT_EVENT_NAME = "UserPromptSubmit";
60
+ const PROMPT_NOT_A_STRING = "lit guard: prompt must be a string";
61
+ const TEXT_NOT_A_STRING = "lit guard: text must be a string";
62
+ // --- Internal helpers (not exported; semantics load-bearing) ----------------------------------
63
+ /** Narrow to a plain (non-null, non-array) record. */
64
+ function isRecord(value) {
65
+ return typeof value === "object" && value !== null && !Array.isArray(value);
66
+ }
67
+ /**
68
+ * Parse one JSONL line. Empty/whitespace line -> null; JSON.parse Error -> null (skipped);
69
+ * a non-Error throw (rare) is rethrown so the caller's fail-open boundary handles only Errors.
70
+ */
71
+ function parseJsonLine(line) {
72
+ if (line.trim().length === 0) {
73
+ return null;
74
+ }
75
+ try {
76
+ return JSON.parse(line);
77
+ }
78
+ catch (error) {
79
+ if (error instanceof Error) {
80
+ return null;
81
+ }
82
+ throw error;
83
+ }
84
+ }
85
+ // --- Pure marker matcher (guard 1 primitive) ---------------------------------------------------
86
+ /**
87
+ * Pure lexical test: does `text` contain ANY context-pressure marker? Lowercases `text`, then
88
+ * CONTEXT_PRESSURE_MARKERS.some(m => lower.includes(m)). No I/O, no state, idempotent.
89
+ * @throws TypeError if `text` is not a string.
90
+ */
91
+ export function hasContextPressureMarker(text) {
92
+ if (typeof text !== "string") {
93
+ throw new TypeError(TEXT_NOT_A_STRING);
94
+ }
95
+ const lower = text.toLowerCase();
96
+ return CONTEXT_PRESSURE_MARKERS.some((marker) => lower.includes(marker));
97
+ }
98
+ /** Alias used by the prompt-level guard (guard 1). Identical semantics to hasContextPressureMarker. */
99
+ export function isContextPressurePrompt(prompt) {
100
+ return hasContextPressureMarker(prompt);
101
+ }
102
+ // --- Guard 2: idempotency (already-injected) ---------------------------------------------------
103
+ /**
104
+ * Generic per-mode idempotency check (RC1). Reads the TAIL (last TRANSCRIPT_SEARCH_BYTES bytes) of
105
+ * the transcript, parses each JSONL line, and returns true iff some line is a prior HOOK OUTPUT
106
+ * envelope: parsed.hookSpecificOutput.hookEventName === HOOK_OUTPUT_EVENT_NAME AND
107
+ * typeof additionalContext === "string" AND additionalContext.includes(`marker`). A marker quoted
108
+ * inside a user/assistant content STRING (or a forged envelope embedded as text) is NOT a top-level
109
+ * envelope line and is skipped. FAIL-OPEN: any thrown Error => false. The multi-mode hook passes the
110
+ * MATCHED mode's open marker so re-injection is suppressed per mode (mode switching is allowed).
111
+ */
112
+ export function transcriptHasDirectiveMarker(transcriptPath, marker) {
113
+ if (transcriptPath === null || transcriptPath === undefined) {
114
+ return false;
115
+ }
116
+ try {
117
+ const buf = readFileSync(transcriptPath);
118
+ const tail = buf.subarray(Math.max(0, buf.byteLength - TRANSCRIPT_SEARCH_BYTES)).toString("utf8");
119
+ for (const line of tail.split(/\r?\n/)) {
120
+ const parsed = parseJsonLine(line);
121
+ if (parsed === null || !isRecord(parsed)) {
122
+ continue;
123
+ }
124
+ const hso = parsed["hookSpecificOutput"];
125
+ if (!isRecord(hso)) {
126
+ continue;
127
+ }
128
+ if (hso["hookEventName"] !== HOOK_OUTPUT_EVENT_NAME) {
129
+ continue;
130
+ }
131
+ const additionalContext = hso["additionalContext"];
132
+ if (typeof additionalContext === "string" && additionalContext.includes(marker)) {
133
+ return true;
134
+ }
135
+ }
136
+ return false;
137
+ }
138
+ catch (error) {
139
+ if (error instanceof Error) {
140
+ return false; // fail-open
141
+ }
142
+ throw error;
143
+ }
144
+ }
145
+ /** Lit-loop idempotency guard (guard 2) — the generic check bound to the lit-loop marker. */
146
+ export function transcriptHasLitLoopDirective(transcriptPath) {
147
+ return transcriptHasDirectiveMarker(transcriptPath, LIT_LOOP_DIRECTIVE_MARKER);
148
+ }
149
+ // --- Guard 3: context-pressure transcript ------------------------------------------------------
150
+ /**
151
+ * Context-pressure transcript guard (guard 3). Reads a bounded head+tail window of the transcript
152
+ * (A3 addendum §A): the whole file when ≤ 2×GUARD3_WINDOW_BYTES, else the first + last
153
+ * GUARD3_WINDOW_BYTES joined by a "\n" separator (so a marker cannot be manufactured across the
154
+ * cut), then runs hasContextPressureMarker over it. Markers buried in nested JSON are caught by
155
+ * design. FAIL-OPEN: any thrown Error => false. null/undefined path => false.
156
+ */
157
+ export function transcriptHasContextPressureMarker(transcriptPath) {
158
+ if (transcriptPath === null || transcriptPath === undefined) {
159
+ return false;
160
+ }
161
+ try {
162
+ const buf = readFileSync(transcriptPath);
163
+ const n = buf.byteLength;
164
+ let text;
165
+ if (n <= 2 * GUARD3_WINDOW_BYTES) {
166
+ text = buf.toString("utf8");
167
+ }
168
+ else {
169
+ const head = buf.subarray(0, GUARD3_WINDOW_BYTES).toString("utf8");
170
+ const tail = buf.subarray(n - GUARD3_WINDOW_BYTES).toString("utf8");
171
+ text = `${head}\n${tail}`;
172
+ }
173
+ return hasContextPressureMarker(text);
174
+ }
175
+ catch (error) {
176
+ if (error instanceof Error) {
177
+ return false; // fail-open
178
+ }
179
+ throw error;
180
+ }
181
+ }
182
+ // --- Single entry point (A3 C3) -----------------------------------------------------------------
183
+ /**
184
+ * THE single decision the M06 hook runner calls. Evaluates the four guards in fixed order and
185
+ * returns the first veto, else {suppress:false, reason:"none"}. Suppression dominates activation:
186
+ * guard 0 (not-a-trigger) runs first so the file-I/O guards only fire when a trigger is present;
187
+ * guard 1 (context-pressure-prompt) runs before the transcript guards so a recovery prompt that
188
+ * also contains `lit` is suppressed without any file read. Never throws for any string prompt +
189
+ * GuardTranscriptInput path (the only throw is the non-string prompt defensive guard).
190
+ * @throws TypeError only if `prompt` is not a string (M06 type-guards this upstream).
191
+ */
192
+ export function shouldSuppressInjection(prompt, transcriptPath, openMarker) {
193
+ if (typeof prompt !== "string") {
194
+ throw new TypeError(PROMPT_NOT_A_STRING);
195
+ }
196
+ if (!isLitTriggerPrompt(prompt)) {
197
+ return { suppress: true, reason: "not-a-trigger" };
198
+ }
199
+ if (isContextPressurePrompt(prompt)) {
200
+ return { suppress: true, reason: "context-pressure-prompt" };
201
+ }
202
+ // RC1: guard 2 checks the MATCHED mode's marker only — re-injecting the SAME mode is suppressed,
203
+ // switching modes (e.g. `lit` after `litwork`) is allowed.
204
+ if (transcriptHasDirectiveMarker(transcriptPath, openMarker)) {
205
+ return { suppress: true, reason: "already-injected" };
206
+ }
207
+ if (transcriptHasContextPressureMarker(transcriptPath)) {
208
+ return { suppress: true, reason: "context-pressure-transcript" };
209
+ }
210
+ return { suppress: false, reason: "none" };
211
+ }
212
+ /** Lit-loop entry point (backward-compatible) — the generic decision bound to the lit-loop marker. */
213
+ export function shouldSuppressLitLoopInjection(prompt, transcriptPath) {
214
+ return shouldSuppressInjection(prompt, transcriptPath, LIT_LOOP_DIRECTIVE_MARKER);
215
+ }
@@ -0,0 +1,17 @@
1
+ /** Hard cap on stdin to bound memory / ReDoS exposure inside the 5 s Codex hook budget. 8 MB. */
2
+ export declare const MAX_STDIN_BYTES = 8000000;
3
+ /** Machine-readable error envelope emitted to stderr on a NON-zero exit. */
4
+ export interface LitHookError {
5
+ readonly ok: false;
6
+ readonly error: {
7
+ readonly code: LitHookErrorCode;
8
+ readonly message: string;
9
+ };
10
+ }
11
+ export type LitHookErrorCode = "LIT_HOOK_STDIN_INVALID_JSON" | "LIT_HOOK_STDIN_TOO_LARGE";
12
+ /**
13
+ * Stream-driven entry point. Resolves exactly one exit code (0 or 2); NEVER calls process.exit and
14
+ * NEVER throws. Writes the camelCase activation line to stdout (empty on no-op), or a LitHookError
15
+ * line to stderr on malformed / oversized stdin.
16
+ */
17
+ export declare function runUserPromptSubmitHookCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream): Promise<number>;