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,28 @@
|
|
|
1
|
+
// M13 — machine-readable migration error + exit-code mapping (S13 errors.ts).
|
|
2
|
+
//
|
|
3
|
+
// The single throwable shape for the config-migration module. Install mode
|
|
4
|
+
// surfaces it; the CLI maps `code` to a distinct exit code so the installer
|
|
5
|
+
// (M12) can react. Session-start mode catches and swallows it (exit 0 always).
|
|
6
|
+
/** Machine-readable migration error. `code` drives the install-mode exit code. */
|
|
7
|
+
export class CodexConfigMigrationError extends Error {
|
|
8
|
+
constructor(code, message, configPath, details = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "CodexConfigMigrationError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.configPath = configPath;
|
|
13
|
+
this.details = details;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Install-mode exit code for a migration error (parent Exit-code table). */
|
|
17
|
+
export function exitCodeForMigrationError(code) {
|
|
18
|
+
switch (code) {
|
|
19
|
+
case "CONFIG_MALFORMED":
|
|
20
|
+
return 2;
|
|
21
|
+
case "CONFIG_UNWRITABLE":
|
|
22
|
+
return 3;
|
|
23
|
+
case "BACKUP_FAILED":
|
|
24
|
+
return 4;
|
|
25
|
+
case "STATE_UNWRITABLE":
|
|
26
|
+
return 3;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ModelCatalog, ReasoningProfile } from "./catalog.js";
|
|
2
|
+
import type { ManagedFileState } from "./state.js";
|
|
3
|
+
export type { ModelCatalog, ReasoningProfile } from "./catalog.js";
|
|
4
|
+
export { readModelCatalog } from "./catalog.js";
|
|
5
|
+
export type { CodexConfigMigrationCode } from "./errors.js";
|
|
6
|
+
export { CodexConfigMigrationError } from "./errors.js";
|
|
7
|
+
export { ensureCodexReasoningConfig } from "./root-settings.js";
|
|
8
|
+
export type { ManagedFileState, MigrationState } from "./state.js";
|
|
9
|
+
export type { TomlShapeRejection, TomlShapeResult } from "./toml-shape.js";
|
|
10
|
+
export { validateTomlShape } from "./toml-shape.js";
|
|
11
|
+
export type MigrationMode = "session-start" | "install";
|
|
12
|
+
export interface MigrateOptions {
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
cwd?: string;
|
|
15
|
+
mode?: MigrationMode;
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface MigrateSkip {
|
|
19
|
+
path: string;
|
|
20
|
+
reason: "user-modified" | "current" | "error";
|
|
21
|
+
}
|
|
22
|
+
export interface MigrateResult {
|
|
23
|
+
changed: string[];
|
|
24
|
+
backups: string[];
|
|
25
|
+
skipped: MigrateSkip[];
|
|
26
|
+
stateWritten: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface MigrateFileOptions {
|
|
29
|
+
catalog: ModelCatalog;
|
|
30
|
+
previousState?: ManagedFileState | undefined;
|
|
31
|
+
mode: MigrationMode;
|
|
32
|
+
dryRun?: boolean | undefined;
|
|
33
|
+
}
|
|
34
|
+
export interface MigrateFileResult {
|
|
35
|
+
changed: boolean;
|
|
36
|
+
written: Partial<ReasoningProfile>;
|
|
37
|
+
managed: boolean;
|
|
38
|
+
backup: string | null;
|
|
39
|
+
skipReason: "user-modified" | "current" | null;
|
|
40
|
+
}
|
|
41
|
+
/** Orchestrate the migration across the discovered config paths. */
|
|
42
|
+
export declare function migrateCodexConfig(options?: MigrateOptions): Promise<MigrateResult>;
|
|
43
|
+
/** Per-file migration: read -> validate -> decide -> backup -> apply -> write. */
|
|
44
|
+
export declare function migrateConfigFile(configPath: string, options: MigrateFileOptions): Promise<MigrateFileResult>;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// M13 — config-migration orchestrator (S13 index.ts).
|
|
2
|
+
//
|
|
3
|
+
// Installs LitCodex's managed Codex config settings WITHOUT ever overwriting the
|
|
4
|
+
// whole file or clobbering unrelated keys/sections/comments. Two modes differ in
|
|
5
|
+
// failure semantics:
|
|
6
|
+
// - "install" — back up each existing config BEFORE any write; on the
|
|
7
|
+
// first error, throw CodexConfigMigrationError (the CLI /
|
|
8
|
+
// installer maps it to a non-zero exit).
|
|
9
|
+
// - "session-start" — fire-and-forget; never throws to the caller for a
|
|
10
|
+
// per-file error so a Codex turn can never be blocked.
|
|
11
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { backupConfigFile } from "./backup.js";
|
|
14
|
+
import { readModelCatalog } from "./catalog.js";
|
|
15
|
+
import { configPaths } from "./config-paths.js";
|
|
16
|
+
import { CodexConfigMigrationError } from "./errors.js";
|
|
17
|
+
import { forceDisableMultiAgentV2 } from "./multi-agent-v2-guard.js";
|
|
18
|
+
import { ensureCodexReasoningConfig, readRootSettings } from "./root-settings.js";
|
|
19
|
+
import { readState, resolveStatePath, writeState } from "./state.js";
|
|
20
|
+
import { validateTomlShape } from "./toml-shape.js";
|
|
21
|
+
export { readModelCatalog } from "./catalog.js";
|
|
22
|
+
export { CodexConfigMigrationError } from "./errors.js";
|
|
23
|
+
export { ensureCodexReasoningConfig } from "./root-settings.js";
|
|
24
|
+
export { validateTomlShape } from "./toml-shape.js";
|
|
25
|
+
/** Orchestrate the migration across the discovered config paths. */
|
|
26
|
+
export async function migrateCodexConfig(options = {}) {
|
|
27
|
+
const env = options.env ?? process.env;
|
|
28
|
+
const cwd = options.cwd ?? process.cwd();
|
|
29
|
+
const mode = options.mode ?? "install";
|
|
30
|
+
const dryRun = options.dryRun ?? false;
|
|
31
|
+
// Opt-out short-circuit: no reads, no writes.
|
|
32
|
+
if (env["LITCODEX_CONFIG_MIGRATION_DISABLED"]?.trim() === "1") {
|
|
33
|
+
return { changed: [], backups: [], skipped: [], stateWritten: false };
|
|
34
|
+
}
|
|
35
|
+
const catalog = await readModelCatalog(env);
|
|
36
|
+
const statePath = resolveStatePath(env);
|
|
37
|
+
const state = await readState(statePath);
|
|
38
|
+
const previousFiles = "files" in state ? state.files : undefined;
|
|
39
|
+
const paths = await configPaths({ env, cwd });
|
|
40
|
+
const nextState = { catalogVersion: catalog.version, files: {} };
|
|
41
|
+
const changed = [];
|
|
42
|
+
const backups = [];
|
|
43
|
+
const skipped = [];
|
|
44
|
+
for (const configPath of paths) {
|
|
45
|
+
const previousState = previousFiles?.[configPath];
|
|
46
|
+
let result;
|
|
47
|
+
try {
|
|
48
|
+
result = await migrateConfigFile(configPath, { catalog, previousState, mode, dryRun });
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (mode === "install") {
|
|
52
|
+
throw error; // fail-fast; backup (if any) already written.
|
|
53
|
+
}
|
|
54
|
+
// session-start: swallow, record, continue. Never re-throw.
|
|
55
|
+
skipped.push({ path: configPath, reason: "error" });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (result.changed) {
|
|
59
|
+
changed.push(configPath);
|
|
60
|
+
}
|
|
61
|
+
if (result.backup !== null) {
|
|
62
|
+
backups.push(result.backup);
|
|
63
|
+
}
|
|
64
|
+
if (!result.changed && result.skipReason !== null) {
|
|
65
|
+
skipped.push({ path: configPath, reason: result.skipReason });
|
|
66
|
+
}
|
|
67
|
+
nextState.files[configPath] = {
|
|
68
|
+
catalogVersion: catalog.version,
|
|
69
|
+
written: result.written,
|
|
70
|
+
managed: result.managed,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
let stateWritten = false;
|
|
74
|
+
if (dryRun) {
|
|
75
|
+
changed.sort();
|
|
76
|
+
backups.sort();
|
|
77
|
+
return { changed, backups, skipped, stateWritten: false };
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
await writeState(statePath, nextState);
|
|
81
|
+
stateWritten = true;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
// A state-write failure must not strand a successfully-migrated config.
|
|
85
|
+
if (mode === "install" && changed.length === 0) {
|
|
86
|
+
throw new CodexConfigMigrationError("STATE_UNWRITABLE", "Could not write the LitCodex migration state file.", null, {
|
|
87
|
+
errno: errnoOf(error),
|
|
88
|
+
statePath,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
skipped.push({ path: statePath, reason: "error" });
|
|
92
|
+
}
|
|
93
|
+
changed.sort();
|
|
94
|
+
backups.sort();
|
|
95
|
+
return { changed, backups, skipped, stateWritten };
|
|
96
|
+
}
|
|
97
|
+
/** Per-file migration: read -> validate -> decide -> backup -> apply -> write. */
|
|
98
|
+
export async function migrateConfigFile(configPath, options) {
|
|
99
|
+
const { catalog, previousState, mode } = options;
|
|
100
|
+
const dryRun = options.dryRun ?? false;
|
|
101
|
+
const fileExisted = await pathExists(configPath);
|
|
102
|
+
const before = await readConfig(configPath);
|
|
103
|
+
// Malformed detection (install: backup THEN throw; session-start: throw -> caught upstream).
|
|
104
|
+
const shape = validateTomlShape(before);
|
|
105
|
+
if (!shape.ok) {
|
|
106
|
+
let backup = null;
|
|
107
|
+
if (mode === "install" && fileExisted) {
|
|
108
|
+
backup = await backupConfigFile(configPath);
|
|
109
|
+
}
|
|
110
|
+
throw new CodexConfigMigrationError("CONFIG_MALFORMED", "Codex config could not be migrated safely; a backup was created.", configPath, {
|
|
111
|
+
reason: shape.reason,
|
|
112
|
+
line: shape.line,
|
|
113
|
+
backup,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const decision = shouldApplyCatalog(before, catalog, previousState);
|
|
117
|
+
let config = before;
|
|
118
|
+
if (decision.apply) {
|
|
119
|
+
config = ensureCodexReasoningConfig(config, catalog.current);
|
|
120
|
+
}
|
|
121
|
+
const afterGuard = forceDisableMultiAgentV2(config);
|
|
122
|
+
if (afterGuard !== config) {
|
|
123
|
+
config = afterGuard;
|
|
124
|
+
}
|
|
125
|
+
const changed = config !== before;
|
|
126
|
+
let backup = null;
|
|
127
|
+
if (changed && !dryRun) {
|
|
128
|
+
// Backup BEFORE the write so a crash mid-write leaves the backup intact.
|
|
129
|
+
if (mode === "install" && fileExisted) {
|
|
130
|
+
backup = await backupConfigFile(configPath);
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
134
|
+
await writeFile(configPath, `${config.trimEnd()}\n`);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
throw new CodexConfigMigrationError("CONFIG_UNWRITABLE", "Could not write the Codex config file.", configPath, {
|
|
138
|
+
errno: errnoOf(error),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const written = decision.apply ? catalog.current : readRootSettings(config);
|
|
143
|
+
const managed = decision.apply ? true : decision.managed;
|
|
144
|
+
const skipReason = changed ? null : decision.managed ? "current" : "user-modified";
|
|
145
|
+
return { changed, written, managed, backup, skipReason };
|
|
146
|
+
}
|
|
147
|
+
function shouldApplyCatalog(config, catalog, previousState) {
|
|
148
|
+
const current = readRootSettings(config);
|
|
149
|
+
if (Object.keys(current).length === 0) {
|
|
150
|
+
return { apply: true, reason: "empty", managed: true };
|
|
151
|
+
}
|
|
152
|
+
if (matchesProfile(current, catalog.current)) {
|
|
153
|
+
return { apply: false, reason: "current", managed: true };
|
|
154
|
+
}
|
|
155
|
+
if (previousState?.managed === true && matchesProfile(current, previousState.written)) {
|
|
156
|
+
return { apply: true, reason: "managed-state", managed: true };
|
|
157
|
+
}
|
|
158
|
+
for (const profile of catalog.managedProfiles) {
|
|
159
|
+
if (matchesProfile(current, profile.match)) {
|
|
160
|
+
return { apply: true, reason: profile.version, managed: true };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { apply: false, reason: "user-modified", managed: false };
|
|
164
|
+
}
|
|
165
|
+
function matchesProfile(current, profile) {
|
|
166
|
+
const record = current;
|
|
167
|
+
for (const [key, value] of Object.entries(profile)) {
|
|
168
|
+
if (record[key] !== value) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
async function readConfig(configPath) {
|
|
175
|
+
try {
|
|
176
|
+
return await readFile(configPath, "utf8");
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (isErrnoCode(error, "ENOENT")) {
|
|
180
|
+
return "";
|
|
181
|
+
}
|
|
182
|
+
throw new CodexConfigMigrationError("CONFIG_UNWRITABLE", "Could not read the Codex config file.", configPath, {
|
|
183
|
+
errno: errnoOf(error),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function pathExists(path) {
|
|
188
|
+
try {
|
|
189
|
+
await readFile(path);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (isErrnoCode(error, "ENOENT")) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
// A non-ENOENT read error (EACCES/EISDIR) — treat as "exists" so install
|
|
197
|
+
// mode attempts a backup and surfaces the real failure downstream.
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function isErrnoCode(error, code) {
|
|
202
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
203
|
+
}
|
|
204
|
+
function errnoOf(error) {
|
|
205
|
+
if (error instanceof Error && "code" in error) {
|
|
206
|
+
const code = error.code;
|
|
207
|
+
return typeof code === "string" ? code : null;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// M13 — multi_agent_v2 disable guard (S13 multi-agent-v2-guard.ts).
|
|
2
|
+
//
|
|
3
|
+
// Pure, idempotent, byte-identical on re-run. Forces
|
|
4
|
+
// `[features.multi_agent_v2] enabled = false`, removes the conflicting
|
|
5
|
+
// `[features]` boolean shorthand, and inserts the LitCodex managed comment
|
|
6
|
+
// exactly once (de-duped by the openai/codex#26753 marker).
|
|
7
|
+
//
|
|
8
|
+
// VERIFY-LIVE (A3 Part C #5): the multi_agent_v2 HTTP-400 failure
|
|
9
|
+
// (openai/codex#26753) is UNVERIFIED against the live Codex — confirm the bug is
|
|
10
|
+
// still live at runtime; keep the marker stable for idempotency regardless.
|
|
11
|
+
const MANAGED_COMMENT_MARKER = "openai/codex#26753";
|
|
12
|
+
const MANAGED_DISABLE_COMMENT = [
|
|
13
|
+
"# Managed by LitCodex: multi_agent_v2 is re-disabled on every Codex session start",
|
|
14
|
+
`# because enabling it fails every turn with HTTP 400 (${MANAGED_COMMENT_MARKER}).`,
|
|
15
|
+
"# Opt out: LITCODEX_CONFIG_MIGRATION_DISABLED=1.",
|
|
16
|
+
"",
|
|
17
|
+
].join("\n");
|
|
18
|
+
/** Force [features.multi_agent_v2] enabled = false. Pure + idempotent. */
|
|
19
|
+
export function forceDisableMultiAgentV2(config) {
|
|
20
|
+
const result = removeEnabledFeaturesShorthand(config);
|
|
21
|
+
const section = findSection(result, "[features.multi_agent_v2]");
|
|
22
|
+
if (section === null) {
|
|
23
|
+
if (hasDisabledFeaturesShorthand(result)) {
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
return ensureManagedComment(appendDisabledSection(result));
|
|
27
|
+
}
|
|
28
|
+
const enabledTruePattern = /^(\s*)enabled\s*=\s*true[ \t]*(#[^\n]*)?$/m;
|
|
29
|
+
if (enabledTruePattern.test(section.text)) {
|
|
30
|
+
const patched = section.text.replace(enabledTruePattern, (_match, indent, comment) => comment ? `${indent}enabled = false ${comment}` : `${indent}enabled = false`);
|
|
31
|
+
return ensureManagedComment(result.slice(0, section.start) + patched + result.slice(section.end));
|
|
32
|
+
}
|
|
33
|
+
if (/^\s*enabled\s*=\s*false[ \t]*(?:#[^\n]*)?$/m.test(section.text)) {
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
const headerEnd = section.text.indexOf("\n");
|
|
37
|
+
const insertAt = headerEnd === -1 ? section.text.length : headerEnd + 1;
|
|
38
|
+
const patched = `${section.text.slice(0, insertAt)}${headerEnd === -1 ? "\n" : ""}enabled = false\n${section.text.slice(insertAt)}`;
|
|
39
|
+
return ensureManagedComment(result.slice(0, section.start) + patched + result.slice(section.end));
|
|
40
|
+
}
|
|
41
|
+
function ensureManagedComment(config) {
|
|
42
|
+
if (config.includes(MANAGED_COMMENT_MARKER)) {
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
const section = findSection(config, "[features.multi_agent_v2]");
|
|
46
|
+
if (section === null) {
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
return config.slice(0, section.start) + MANAGED_DISABLE_COMMENT + config.slice(section.start);
|
|
50
|
+
}
|
|
51
|
+
function removeEnabledFeaturesShorthand(config) {
|
|
52
|
+
const section = findSection(config, "[features]");
|
|
53
|
+
if (section === null) {
|
|
54
|
+
return config;
|
|
55
|
+
}
|
|
56
|
+
const shorthandPattern = /^\s*multi_agent_v2\s*=\s*true[ \t]*(?:#[^\n]*)?[ \t]*\n?/m;
|
|
57
|
+
if (!shorthandPattern.test(section.text)) {
|
|
58
|
+
return config;
|
|
59
|
+
}
|
|
60
|
+
const patched = section.text.replace(shorthandPattern, "");
|
|
61
|
+
return config.slice(0, section.start) + patched + config.slice(section.end);
|
|
62
|
+
}
|
|
63
|
+
function hasDisabledFeaturesShorthand(config) {
|
|
64
|
+
const section = findSection(config, "[features]");
|
|
65
|
+
if (section === null) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return /^\s*multi_agent_v2\s*=\s*false[ \t]*(?:#[^\n]*)?$/m.test(section.text);
|
|
69
|
+
}
|
|
70
|
+
function appendDisabledSection(config) {
|
|
71
|
+
const trimmed = config.trimEnd();
|
|
72
|
+
const prefix = trimmed.length === 0 ? "" : `${trimmed}\n\n`;
|
|
73
|
+
return `${prefix}[features.multi_agent_v2]\nenabled = false\n`;
|
|
74
|
+
}
|
|
75
|
+
/** Strip a trailing inline comment (best-effort; quoted keys with # out of scope). */
|
|
76
|
+
function stripTrailingComment(line) {
|
|
77
|
+
const idx = line.indexOf("#");
|
|
78
|
+
return idx === -1 ? line : line.slice(0, idx).trim();
|
|
79
|
+
}
|
|
80
|
+
function findSection(config, headerLine) {
|
|
81
|
+
const lines = config.match(/[^\n]*\n?|$/g) ?? [];
|
|
82
|
+
let offset = 0;
|
|
83
|
+
let start = -1;
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (line.length === 0) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
const trimmed = line.trim();
|
|
89
|
+
if (start === -1) {
|
|
90
|
+
if (stripTrailingComment(trimmed) === headerLine) {
|
|
91
|
+
start = offset;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const bare = stripTrailingComment(trimmed);
|
|
96
|
+
if (bare.startsWith("[") && bare.endsWith("]")) {
|
|
97
|
+
return { start, end: offset, text: config.slice(start, offset) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
offset += line.length;
|
|
101
|
+
}
|
|
102
|
+
if (start === -1) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return { start, end: config.length, text: config.slice(start) };
|
|
106
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ReasoningProfile } from "./catalog.js";
|
|
2
|
+
export declare const MANAGED_KEYS: readonly ["model", "model_context_window", "model_reasoning_effort", "plan_mode_reasoning_effort"];
|
|
3
|
+
/** Replace/insert the four managed scalars among the ROOT lines only. Pure. */
|
|
4
|
+
export declare function ensureCodexReasoningConfig(config: string, profile: ReasoningProfile): string;
|
|
5
|
+
/** Read only the four managed keys from root scope (stops at first section). Pure. */
|
|
6
|
+
export declare function readRootSettings(config: string): Partial<ReasoningProfile>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// M13 — surgical root-setting replace/insert (S13 root-settings.ts).
|
|
2
|
+
//
|
|
3
|
+
// Pure. Operates ONLY on the root scope (lines before the first [section]
|
|
4
|
+
// header). Replaces/inserts the four managed scalars in-place, preserving every
|
|
5
|
+
// unrelated root key, section-scoped key, comment, and ordering. model + effort
|
|
6
|
+
// fields are JSON.stringify'd (QUOTED); model_context_window is an UNQUOTED
|
|
7
|
+
// integer. Section-scoped occurrences of the same key are never touched.
|
|
8
|
+
//
|
|
9
|
+
// VERIFY-LIVE (A3 Part C #5): the four managed key names are taken from the
|
|
10
|
+
// reference Codex schema — confirm against the live ~/.codex/config.toml schema
|
|
11
|
+
// at runtime, do not block.
|
|
12
|
+
export const MANAGED_KEYS = [
|
|
13
|
+
"model",
|
|
14
|
+
"model_context_window",
|
|
15
|
+
"model_reasoning_effort",
|
|
16
|
+
"plan_mode_reasoning_effort",
|
|
17
|
+
];
|
|
18
|
+
/** Replace/insert the four managed scalars among the ROOT lines only. Pure. */
|
|
19
|
+
export function ensureCodexReasoningConfig(config, profile) {
|
|
20
|
+
let next = replaceOrInsertRootSetting(config, "model", JSON.stringify(profile.model));
|
|
21
|
+
next = replaceOrInsertRootSetting(next, "model_context_window", profile.model_context_window.toString());
|
|
22
|
+
next = replaceOrInsertRootSetting(next, "model_reasoning_effort", JSON.stringify(profile.model_reasoning_effort));
|
|
23
|
+
next = replaceOrInsertRootSetting(next, "plan_mode_reasoning_effort", JSON.stringify(profile.plan_mode_reasoning_effort));
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
26
|
+
/** Read only the four managed keys from root scope (stops at first section). Pure. */
|
|
27
|
+
export function readRootSettings(config) {
|
|
28
|
+
const settings = {};
|
|
29
|
+
for (const line of config.split(/\n/)) {
|
|
30
|
+
if (isSectionHeader(line)) {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
for (const key of MANAGED_KEYS) {
|
|
34
|
+
if (!isRootSetting(line, key)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const value = parseTomlScalar(line.slice(line.indexOf("=") + 1));
|
|
38
|
+
if (value !== undefined) {
|
|
39
|
+
settings[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return settings;
|
|
44
|
+
}
|
|
45
|
+
function parseTomlScalar(value) {
|
|
46
|
+
const trimmed = value.trim();
|
|
47
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(trimmed);
|
|
50
|
+
return typeof parsed === "string" ? parsed : undefined;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof SyntaxError) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const numeric = Number(trimmed);
|
|
60
|
+
return Number.isFinite(numeric) && trimmed !== "" ? numeric : undefined;
|
|
61
|
+
}
|
|
62
|
+
function replaceOrInsertRootSetting(config, key, value) {
|
|
63
|
+
const lines = config.split(/\n/);
|
|
64
|
+
const output = [];
|
|
65
|
+
let replaced = false;
|
|
66
|
+
let inserted = false;
|
|
67
|
+
let inRoot = true;
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const sectionHeader = isSectionHeader(line);
|
|
70
|
+
if (inRoot && !inserted && sectionHeader) {
|
|
71
|
+
if (!replaced) {
|
|
72
|
+
output.push(`${key} = ${value}`);
|
|
73
|
+
}
|
|
74
|
+
inserted = true;
|
|
75
|
+
}
|
|
76
|
+
if (inRoot && isRootSetting(line, key)) {
|
|
77
|
+
if (!replaced) {
|
|
78
|
+
output.push(`${key} = ${value}`);
|
|
79
|
+
replaced = true;
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
output.push(line);
|
|
84
|
+
if (sectionHeader) {
|
|
85
|
+
inRoot = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!replaced && !inserted) {
|
|
89
|
+
output.push(`${key} = ${value}`);
|
|
90
|
+
}
|
|
91
|
+
return output.join("\n");
|
|
92
|
+
}
|
|
93
|
+
function isSectionHeader(line) {
|
|
94
|
+
const trimmed = line.trim();
|
|
95
|
+
return trimmed.startsWith("[") && trimmed.endsWith("]");
|
|
96
|
+
}
|
|
97
|
+
function isRootSetting(line, key) {
|
|
98
|
+
const trimmed = line.trimStart();
|
|
99
|
+
if (trimmed.startsWith("#") || trimmed.startsWith("[")) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
103
|
+
return match?.[1] === key;
|
|
104
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReasoningProfile } from "./catalog.js";
|
|
2
|
+
export interface ManagedFileState {
|
|
3
|
+
catalogVersion: string;
|
|
4
|
+
written: Partial<ReasoningProfile>;
|
|
5
|
+
managed: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface MigrationState {
|
|
8
|
+
catalogVersion: string;
|
|
9
|
+
files: Record<string, ManagedFileState>;
|
|
10
|
+
}
|
|
11
|
+
/** Resolve the state-file path from LITCODEX_* env, else the XDG-style default. */
|
|
12
|
+
export declare function resolveStatePath(env: NodeJS.ProcessEnv): string;
|
|
13
|
+
/** Read the state file. Never throws — returns {} on any error. */
|
|
14
|
+
export declare function readState(statePath: string): Promise<MigrationState | Record<string, never>>;
|
|
15
|
+
/** Write the state file (pretty JSON + trailing newline). Throws on write failure. */
|
|
16
|
+
export declare function writeState(statePath: string, state: MigrationState): Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// M13 — durable managed-config state (S13 state.ts).
|
|
2
|
+
//
|
|
3
|
+
// Tracks which config files LitCodex manages so a hand-edited managed key flips
|
|
4
|
+
// the file to "user-modified" and is then left untouched forever. readState
|
|
5
|
+
// swallows malformed/missing state and returns {}. Env vars use the LITCODEX_*
|
|
6
|
+
// prefix (no legacy aliases).
|
|
7
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
/** Resolve the state-file path from LITCODEX_* env, else the XDG-style default. */
|
|
11
|
+
export function resolveStatePath(env) {
|
|
12
|
+
const explicit = env["LITCODEX_MODEL_CATALOG_STATE_PATH"]?.trim();
|
|
13
|
+
if (explicit !== undefined && explicit !== "") {
|
|
14
|
+
return explicit;
|
|
15
|
+
}
|
|
16
|
+
const dataRoot = env["LITCODEX_DATA"]?.trim();
|
|
17
|
+
const root = dataRoot !== undefined && dataRoot !== "" ? dataRoot : join(homedir(), ".local", "share", "litcodex");
|
|
18
|
+
return join(root, "model-catalog-state.json");
|
|
19
|
+
}
|
|
20
|
+
/** Read the state file. Never throws — returns {} on any error. */
|
|
21
|
+
export async function readState(statePath) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(await readFile(statePath, "utf8"));
|
|
24
|
+
return isRecord(parsed) ? parsed : {};
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error instanceof Error) {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Write the state file (pretty JSON + trailing newline). Throws on write failure. */
|
|
34
|
+
export async function writeState(statePath, state) {
|
|
35
|
+
await mkdir(dirname(statePath), { recursive: true });
|
|
36
|
+
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
37
|
+
}
|
|
38
|
+
function isRecord(value) {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type TomlShapeRejection = "unterminated-string" | "unbalanced-section-header" | "duplicate-multi-agent-v2";
|
|
2
|
+
export interface TomlShapeResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
reason: TomlShapeRejection | null;
|
|
5
|
+
line: number | null;
|
|
6
|
+
}
|
|
7
|
+
/** Validate the structural shape. Pure, never throws. */
|
|
8
|
+
export declare function validateTomlShape(config: string): TomlShapeResult;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// M13 — validateTomlShape structural gate (S13 addendum Gap B).
|
|
2
|
+
//
|
|
3
|
+
// The SOLE gate between "backup + CONFIG_MALFORMED exit 2" (install) and
|
|
4
|
+
// "proceed to surgical rewrite". The accept/reject boundary is the CLOSED
|
|
5
|
+
// grammar R1/R2/R3; nothing outside these three rules is rejected. Pure, never
|
|
6
|
+
// throws. Returns the FIRST violation (R1 -> R2 -> R3, scanning top-to-bottom).
|
|
7
|
+
//
|
|
8
|
+
// Accepts exotic-but-valid TOML: multiline strings ("""/'''), arrays-of-tables
|
|
9
|
+
// ([[...]]), inline arrays/tables, section-scoped clipped strings.
|
|
10
|
+
const WELL_FORMED_HEADER = /^\s*\[\[?[^[\]]+\]\]?\s*(?:#[^\n]*)?$/;
|
|
11
|
+
const GUARD_HEADER = "[features.multi_agent_v2]";
|
|
12
|
+
/** Validate the structural shape. Pure, never throws. */
|
|
13
|
+
export function validateTomlShape(config) {
|
|
14
|
+
const lines = config.split(/\n/);
|
|
15
|
+
let inRoot = true;
|
|
16
|
+
let guardCount = 0;
|
|
17
|
+
let multilineCloser = null;
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
const raw = lines[i] ?? "";
|
|
20
|
+
const lineNo = i + 1;
|
|
21
|
+
// Inside an open multiline string: skip until the closer appears.
|
|
22
|
+
if (multilineCloser !== null) {
|
|
23
|
+
if (raw.includes(multilineCloser)) {
|
|
24
|
+
multilineCloser = null;
|
|
25
|
+
}
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const trimmed = raw.trim();
|
|
29
|
+
const isHeader = trimmed.startsWith("[");
|
|
30
|
+
if (isHeader) {
|
|
31
|
+
// R2 — unbalanced [section] header.
|
|
32
|
+
if (!WELL_FORMED_HEADER.test(raw)) {
|
|
33
|
+
return reject("unbalanced-section-header", lineNo);
|
|
34
|
+
}
|
|
35
|
+
// R3 — duplicate [features.multi_agent_v2] table.
|
|
36
|
+
if (stripInlineComment(trimmed) === GUARD_HEADER) {
|
|
37
|
+
guardCount += 1;
|
|
38
|
+
if (guardCount >= 2) {
|
|
39
|
+
return reject("duplicate-multi-agent-v2", lineNo);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
inRoot = false;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// R1 — only root-scope scalar lines are checked.
|
|
46
|
+
if (inRoot) {
|
|
47
|
+
const opener = multilineOpener(raw);
|
|
48
|
+
if (opener !== null) {
|
|
49
|
+
// A multiline opener whose closer is NOT on the same line opens a block.
|
|
50
|
+
multilineCloser = opener;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (hasUnterminatedQuote(raw, '"') || hasUnterminatedQuote(raw, "'")) {
|
|
54
|
+
return reject("unterminated-string", lineNo);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, reason: null, line: null };
|
|
59
|
+
}
|
|
60
|
+
function reject(reason, line) {
|
|
61
|
+
return { ok: false, reason, line };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Detect a multiline-string opener (`"""` or `'''`) whose closing delimiter is
|
|
65
|
+
* NOT present later on the same line. Returns the closer delimiter when the
|
|
66
|
+
* block stays open, else null.
|
|
67
|
+
*/
|
|
68
|
+
function multilineOpener(line) {
|
|
69
|
+
for (const delim of ['"""', "'''"]) {
|
|
70
|
+
const open = line.indexOf(delim);
|
|
71
|
+
if (open === -1) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const close = line.indexOf(delim, open + delim.length);
|
|
75
|
+
if (close === -1) {
|
|
76
|
+
return delim;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* R1 detection for a single delimiter: count unescaped delimiters on the value
|
|
83
|
+
* side of the `=`. An ODD count means the opening quote is never closed.
|
|
84
|
+
*/
|
|
85
|
+
function hasUnterminatedQuote(line, delim) {
|
|
86
|
+
const eq = line.indexOf("=");
|
|
87
|
+
if (eq === -1) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const value = line.slice(eq + 1);
|
|
91
|
+
let count = 0;
|
|
92
|
+
let backslashes = 0;
|
|
93
|
+
for (const ch of value) {
|
|
94
|
+
if (ch === delim) {
|
|
95
|
+
// `"` counts only when preceded by an even number of backslashes.
|
|
96
|
+
if (backslashes % 2 === 0) {
|
|
97
|
+
count += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
backslashes = ch === "\\" ? backslashes + 1 : 0;
|
|
101
|
+
}
|
|
102
|
+
return count % 2 === 1;
|
|
103
|
+
}
|
|
104
|
+
function stripInlineComment(line) {
|
|
105
|
+
const idx = line.indexOf("#");
|
|
106
|
+
return idx === -1 ? line.trim() : line.slice(0, idx).trim();
|
|
107
|
+
}
|