@litfamily/litopencode 1.0.17 → 1.0.19
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/CHANGELOG.md +16 -0
- package/README-Ko-KR.md +56 -86
- package/README.md +57 -86
- package/dist/activation-routing.js +3 -1
- package/dist/cli/args.js +5 -1
- package/dist/cli/auto-update.js +1 -1
- package/dist/cli/doctor.js +7 -5
- package/dist/cli/host-limits.js +2 -5
- package/dist/cli/install-config.d.ts +4 -0
- package/dist/cli/install-config.js +27 -1
- package/dist/cli/install-report.js +4 -1
- package/dist/cli/install.js +89 -28
- package/dist/cli/json.d.ts +1 -0
- package/dist/cli/json.js +10 -4
- package/dist/cli/jsonc.d.ts +17 -0
- package/dist/cli/jsonc.js +174 -0
- package/dist/cli/loop.d.ts +1 -1
- package/dist/cli/loop.js +81 -1
- package/dist/cli/motion-runtime.d.ts +13 -1
- package/dist/cli/motion-runtime.js +89 -2
- package/dist/cli/plugin-mutation.js +11 -6
- package/dist/cli/tui-config.d.ts +52 -0
- package/dist/cli/tui-config.js +198 -0
- package/dist/cli/types.d.ts +4 -0
- package/dist/cli.js +8 -3
- package/dist/deliverable-hedge-guard.js +5 -3
- package/dist/hooks.js +2 -1
- package/dist/tui/mission-band.d.ts +29 -0
- package/dist/tui/mission-band.js +116 -0
- package/dist/tui/mission-model.d.ts +139 -0
- package/dist/tui/mission-model.js +318 -0
- package/dist/tui/mission-motion.d.ts +66 -0
- package/dist/tui/mission-motion.js +147 -0
- package/dist/tui/mission-source.d.ts +56 -0
- package/dist/tui/mission-source.js +88 -0
- package/dist/tui/mission-text.d.ts +103 -0
- package/dist/tui/mission-text.js +613 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +317 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +72 -1
- package/docs/reference.md +82 -1
- package/package.json +21 -2
- package/skills/lit-docx/scripts/docx_design.py +1 -1
- package/skills/lit-docx/templates/tonalities/brief.yaml +0 -1
- package/skills/lit-docx/templates/tonalities/journal.yaml +0 -1
- package/skills/lit-docx/templates/tonalities/manual.yaml +0 -1
- package/skills/lit-docx/templates/tonalities/memo.yaml +0 -1
- package/skills/lit-docx/templates/tonalities/proposal.yaml +0 -1
- package/skills/lit-docx/templates/tonalities/report.yaml +0 -1
- package/skills/lit-pptx/references/tonalities/atlas.md +3 -4
- package/skills/lit-pptx/references/tonalities/chalk.md +2 -2
- package/skills/lit-pptx/references/tonalities/gazette.md +3 -3
- package/skills/lit-pptx/references/tonalities/ledger.md +6 -7
- package/skills/lit-pptx/references/tonalities/night.md +3 -3
- package/skills/lit-pptx/references/tonalities/paper.md +4 -5
- package/skills/lit-pptx/references/tonalities/signal.md +3 -4
- package/skills/lit-pptx/references/tonalities/studio.md +3 -3
- package/skills/lit-pptx/scripts/lib/template-registry.js +1 -1
- package/skills/lit-pptx/scripts/validate_pptx.py +10 -4
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +2 -14
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +0 -12
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +0 -12
- package/skills/managed-skill-manifest.json +25 -25
- package/tools/check-pack-payload.mjs +20 -5
- package/tools/version-manifests.json +18 -6
package/dist/cli/install.js
CHANGED
|
@@ -6,9 +6,9 @@ import { promisify } from "node:util";
|
|
|
6
6
|
import { createRuntimePaths } from "../state.js";
|
|
7
7
|
import { describeCommandAliasMutation, ensureCommandAliases, inspectCommandAliases } from "./command-aliases.js";
|
|
8
8
|
import { openCodeHostCapabilities } from "./host-capabilities.js";
|
|
9
|
-
import { describeHostLimitMutation, ensureHostLimits } from "./host-limits.js";
|
|
10
|
-
import { assertExistingModelRoutesSafe, describeInstallRoutes, describeLitConfigMutation, ensureLitConfig, readExistingOutputStyle, readInstallModelClassification, readInstallModelSelection,
|
|
11
|
-
import { readJsonObjectIfPresent, readPackageMetadata } from "./json.js";
|
|
9
|
+
import { applyHostLimits, describeHostLimitMutation, ensureHostLimits } from "./host-limits.js";
|
|
10
|
+
import { assertExistingModelRoutesSafe, describeInstallRoutes, describeLitConfigMutation, ensureLitConfig, OpenCodeConfigCommentError, readExistingOutputStyle, readInstallModelClassification, readInstallModelSelection, renderOpenCodeConfig, writeOpenCodeConfig } from "./install-config.js";
|
|
11
|
+
import { readJsonObjectIfPresent, readPackageMetadata, readTextIfPresent } from "./json.js";
|
|
12
12
|
import { renderInstallReport, wrapReceiptLine } from "./install-report.js";
|
|
13
13
|
import { renderLitOpenCodeWordmark } from "./install-tui.js";
|
|
14
14
|
import { promptModelSelection, promptOutputStyle, promptPermissionMode } from "./model-routing.js";
|
|
@@ -16,6 +16,7 @@ import { litOpenCodeConfigFileForOpenCodeRoot } from "../state.js";
|
|
|
16
16
|
import { describeNativeSkillMutation, ensureNativeSkills, inspectNativeSkills } from "./native-skills.js";
|
|
17
17
|
import { applyPluginMutation, describePluginMutation, isLitOpenCodeEntry } from "./plugin-mutation.js";
|
|
18
18
|
import { prewarmMotionRuntime } from "./motion-runtime.js";
|
|
19
|
+
import { applyTuiWrites, backupTuiFiles, inspectTuiPlugin, planTuiInstall } from "./tui-config.js";
|
|
19
20
|
const execFileAsync = promisify(execFile);
|
|
20
21
|
function withProgress(progress, label, task) {
|
|
21
22
|
return progress === undefined ? task() : progress.run(label, task);
|
|
@@ -118,29 +119,72 @@ async function installWithOpenCode(target) {
|
|
|
118
119
|
throw new Error("OpenCode plugin install failed for " + target.label + (detail ? ":\n" + detail : ""));
|
|
119
120
|
}
|
|
120
121
|
}
|
|
122
|
+
async function normalizedPluginEntries(root, target, pluginValue) {
|
|
123
|
+
const nextPlugin = [];
|
|
124
|
+
let registered = false;
|
|
125
|
+
for (const entry of pluginValue) {
|
|
126
|
+
const id = Array.isArray(entry) ? entry[0] : entry;
|
|
127
|
+
if (id === target.value || isLitOpenCodeEntry(entry) || (await isLitOpenCodePathEntry(root, id))) {
|
|
128
|
+
if (!registered)
|
|
129
|
+
nextPlugin.push(Array.isArray(entry) ? [target.value, ...entry.slice(1)] : target.value);
|
|
130
|
+
registered = true;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
nextPlugin.push(entry);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return nextPlugin;
|
|
137
|
+
}
|
|
121
138
|
async function normalizeOpenCodeEntries(root, target) {
|
|
122
139
|
for (const configFile of openCodeConfigCandidates(root)) {
|
|
123
140
|
const config = await readJsonObjectIfPresent(configFile);
|
|
124
141
|
const pluginValue = config?.plugin;
|
|
125
142
|
if (!Array.isArray(pluginValue))
|
|
126
143
|
continue;
|
|
127
|
-
const nextPlugin =
|
|
128
|
-
let registered = false;
|
|
129
|
-
for (const entry of pluginValue) {
|
|
130
|
-
const id = Array.isArray(entry) ? entry[0] : entry;
|
|
131
|
-
if (id === target.value || isLitOpenCodeEntry(entry) || (await isLitOpenCodePathEntry(root, id))) {
|
|
132
|
-
if (!registered)
|
|
133
|
-
nextPlugin.push(Array.isArray(entry) ? [target.value, ...entry.slice(1)] : target.value);
|
|
134
|
-
registered = true;
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
nextPlugin.push(entry);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
144
|
+
const nextPlugin = await normalizedPluginEntries(root, target, pluginValue);
|
|
140
145
|
if (JSON.stringify(nextPlugin) === JSON.stringify(pluginValue))
|
|
141
146
|
continue;
|
|
142
|
-
await
|
|
147
|
+
await writeOpenCodeConfig(configFile, { ...config, plugin: nextPlugin });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Works out every OpenCode config edit the install will make before anything is written, and returns
|
|
151
|
+
// the refusal when one of them would delete a user comment. On the default root OpenCode registers the
|
|
152
|
+
// plugin itself; LitOpenCode then only cleans up stale entries and sets host limits.
|
|
153
|
+
async function commentLossRefusal(root, configFile, target, before, mutation, reconfigureHostLimits, useOpenCodeInstaller) {
|
|
154
|
+
const planned = [];
|
|
155
|
+
if (!useOpenCodeInstaller) {
|
|
156
|
+
const next = applyPluginMutation(before, mutation, target.value);
|
|
157
|
+
planned.push({ file: configFile, next: reconfigureHostLimits ? applyHostLimits(next) : next });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
for (const file of openCodeConfigCandidates(root)) {
|
|
161
|
+
const config = file === configFile ? before : await readJsonObjectIfPresent(file);
|
|
162
|
+
let next = config;
|
|
163
|
+
const pluginValue = config?.plugin;
|
|
164
|
+
if (config !== null && Array.isArray(pluginValue)) {
|
|
165
|
+
const registered = file === configFile &&
|
|
166
|
+
!pluginValue.some((entry) => (Array.isArray(entry) ? entry[0] : entry) === target.value)
|
|
167
|
+
? [...pluginValue, target.value]
|
|
168
|
+
: pluginValue;
|
|
169
|
+
const normalized = await normalizedPluginEntries(root, target, registered);
|
|
170
|
+
if (JSON.stringify(normalized) !== JSON.stringify(registered))
|
|
171
|
+
next = { ...config, plugin: normalized };
|
|
172
|
+
}
|
|
173
|
+
planned.push({ file, next: file === configFile && reconfigureHostLimits ? applyHostLimits(next) : next });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
for (const { file, next } of planned) {
|
|
178
|
+
if (next !== null)
|
|
179
|
+
renderOpenCodeConfig(file, await readTextIfPresent(file), next);
|
|
180
|
+
}
|
|
143
181
|
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error instanceof OpenCodeConfigCommentError)
|
|
184
|
+
return error.message;
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
144
188
|
}
|
|
145
189
|
async function verifyInstalledSurfaces(root, configFile, target, metadata, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle) {
|
|
146
190
|
const config = await readJsonObjectIfPresent(configFile);
|
|
@@ -151,9 +195,12 @@ async function verifyInstalledSurfaces(root, configFile, target, metadata, selec
|
|
|
151
195
|
const litConfig = await describeLitConfigMutation(root, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle);
|
|
152
196
|
const commandAliases = await inspectCommandAliases(root);
|
|
153
197
|
const nativeSkills = await inspectNativeSkills(root, metadata);
|
|
198
|
+
const tui = await inspectTuiPlugin(root, target.value);
|
|
154
199
|
const failures = [];
|
|
155
200
|
if (targetCount !== 1 || staleLitEntries.length > 0)
|
|
156
201
|
failures.push("plugin registration");
|
|
202
|
+
if (!tui.ok)
|
|
203
|
+
failures.push("TUI plugin registration in " + tui.path);
|
|
157
204
|
if (litConfig.changed)
|
|
158
205
|
failures.push("LitOpenCode routes");
|
|
159
206
|
if (!commandAliases.ok)
|
|
@@ -202,12 +249,14 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
|
|
|
202
249
|
commandAliases: await describeCommandAliasMutation(root),
|
|
203
250
|
nativeSkills: await describeNativeSkillMutation(root, metadata),
|
|
204
251
|
hostLimits: describeHostLimitMutation(before, selectedModel !== undefined),
|
|
252
|
+
tui: await planTuiInstall(root, resolved.target.value),
|
|
205
253
|
routes: dryRun
|
|
206
254
|
? await describeInstallRoutes(root, before, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle)
|
|
207
255
|
: undefined
|
|
208
256
|
};
|
|
209
257
|
});
|
|
210
|
-
const { configFile, before, mutation, litopencodeConfig, commandAliases, nativeSkills, hostLimits, routes } = inspected;
|
|
258
|
+
const { configFile, before, mutation, litopencodeConfig, commandAliases, nativeSkills, hostLimits, routes, tui } = inspected;
|
|
259
|
+
const blocked = (await commentLossRefusal(root, configFile, resolved.target, before, mutation, hostLimits.reconfigure, resolved.useOpenCodeInstaller)) ?? tui.blocked;
|
|
211
260
|
const report = {
|
|
212
261
|
dryRun,
|
|
213
262
|
path: configFile,
|
|
@@ -215,20 +264,28 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
|
|
|
215
264
|
plugin: mutation.plugin,
|
|
216
265
|
commandAliases,
|
|
217
266
|
nativeSkills,
|
|
267
|
+
tui: tui.report,
|
|
218
268
|
patch: mutation.patch,
|
|
219
|
-
changed: mutation.changed || litopencodeConfig.changed || commandAliases.changed || nativeSkills.changed || hostLimits.changed,
|
|
269
|
+
changed: mutation.changed || litopencodeConfig.changed || commandAliases.changed || nativeSkills.changed || hostLimits.changed || tui.report.changed,
|
|
270
|
+
...(blocked === undefined ? {} : { blocked }),
|
|
220
271
|
routes,
|
|
221
272
|
hostLimits: { path: configFile, ...hostLimits },
|
|
222
273
|
capabilities: openCodeHostCapabilities(),
|
|
223
274
|
package: { name: metadata.name, version: metadata.version }
|
|
224
275
|
};
|
|
225
276
|
if (dryRun) {
|
|
226
|
-
return {
|
|
277
|
+
return {
|
|
278
|
+
exitCode: blocked === undefined ? 0 : 1,
|
|
279
|
+
stdout: JSON.stringify(report, null, 2),
|
|
280
|
+
...(blocked === undefined ? {} : { stderr: "ERROR: " + blocked })
|
|
281
|
+
};
|
|
227
282
|
}
|
|
228
283
|
if (nativeSkills.collisions.length > 0) {
|
|
229
284
|
throw new Error("Native skill collision: refusing to overwrite pre-existing roots without managed entrypoints: " +
|
|
230
285
|
nativeSkills.collisions.join(", "));
|
|
231
286
|
}
|
|
287
|
+
if (blocked !== undefined)
|
|
288
|
+
throw new OpenCodeConfigCommentError(blocked);
|
|
232
289
|
await withProgress(activeProgress, "Write litopencode.json", async () => {
|
|
233
290
|
await ensureLitConfig(root, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle);
|
|
234
291
|
await ensureNativeSkills(root, metadata);
|
|
@@ -236,23 +293,27 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
|
|
|
236
293
|
});
|
|
237
294
|
await withProgress(activeProgress, "Register plugin", async () => {
|
|
238
295
|
if (resolved.useOpenCodeInstaller) {
|
|
296
|
+
// The package's `./tui` export makes `opencode plugin` add the TUI entry itself, so back up
|
|
297
|
+
// first, then normalize whatever it wrote without a second backup.
|
|
298
|
+
await backupTuiFiles(tui);
|
|
239
299
|
await installWithOpenCode(resolved.target);
|
|
240
300
|
const installed = await readJsonObjectIfPresent(configFile);
|
|
241
301
|
if (!Array.isArray(installed?.plugin) || !installed.plugin.some((entry) => (Array.isArray(entry) ? entry[0] : entry) === resolved.target.value))
|
|
242
302
|
throw new Error("OpenCode did not register the requested package target");
|
|
243
303
|
await normalizeOpenCodeEntries(root, resolved.target);
|
|
244
304
|
await ensureHostLimits(configFile, await readJsonObjectIfPresent(configFile), hostLimits.reconfigure);
|
|
305
|
+
const settled = await planTuiInstall(root, resolved.target.value);
|
|
306
|
+
if (settled.blocked !== undefined)
|
|
307
|
+
throw new OpenCodeConfigCommentError(settled.blocked);
|
|
308
|
+
await applyTuiWrites(settled.writes, { backup: false });
|
|
245
309
|
return;
|
|
246
310
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
if (!mutation.changed)
|
|
311
|
+
await applyTuiWrites(tui.writes);
|
|
312
|
+
const withPlugin = applyPluginMutation(before, mutation, resolved.target.value);
|
|
313
|
+
const next = hostLimits.reconfigure ? applyHostLimits(withPlugin) : withPlugin;
|
|
314
|
+
if (JSON.stringify(next) === JSON.stringify(before))
|
|
253
315
|
return;
|
|
254
|
-
await
|
|
255
|
-
await writeTextAtomically(configFile, JSON.stringify(next, null, 2) + "\n");
|
|
316
|
+
await writeOpenCodeConfig(configFile, next);
|
|
256
317
|
});
|
|
257
318
|
await withProgress(activeProgress, "Verify install", async () => verifyInstalledSurfaces(root, configFile, resolved.target, metadata, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle));
|
|
258
319
|
await activeProgress?.complete();
|
package/dist/cli/json.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PackageMetadata } from "./types.ts";
|
|
2
2
|
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
3
3
|
export declare function readPackageMetadata(): Promise<PackageMetadata>;
|
|
4
|
+
export declare function readTextIfPresent(filePath: string): Promise<string | null>;
|
|
4
5
|
export declare function readJsonObjectIfPresent(filePath: string): Promise<Record<string, unknown> | null>;
|
package/dist/cli/json.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { parseJsonc } from "./jsonc.js";
|
|
4
5
|
export function isRecord(value) {
|
|
5
6
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
7
|
}
|
|
@@ -13,17 +14,22 @@ export async function readPackageMetadata() {
|
|
|
13
14
|
}
|
|
14
15
|
return { name: parsed.name, version: parsed.version, packageRoot };
|
|
15
16
|
}
|
|
16
|
-
export async function
|
|
17
|
-
let raw;
|
|
17
|
+
export async function readTextIfPresent(filePath) {
|
|
18
18
|
try {
|
|
19
|
-
|
|
19
|
+
return await fs.readFile(filePath, "utf8");
|
|
20
20
|
}
|
|
21
21
|
catch (error) {
|
|
22
22
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
23
23
|
return null;
|
|
24
24
|
throw error;
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
}
|
|
27
|
+
// OpenCode configs are JSONC, so every reader here accepts comments and trailing commas.
|
|
28
|
+
export async function readJsonObjectIfPresent(filePath) {
|
|
29
|
+
const raw = await readTextIfPresent(filePath);
|
|
30
|
+
if (raw === null)
|
|
31
|
+
return null;
|
|
32
|
+
const parsed = parseJsonc(raw);
|
|
27
33
|
if (!isRecord(parsed)) {
|
|
28
34
|
throw new Error(`Malformed JSON at ${filePath}: expected an object.`);
|
|
29
35
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type JsoncEdit = {
|
|
2
|
+
readonly ok: true;
|
|
3
|
+
readonly text: string;
|
|
4
|
+
} | {
|
|
5
|
+
readonly ok: false;
|
|
6
|
+
readonly key: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function lineEnding(text: string): string;
|
|
9
|
+
export declare function parseJsonc(text: string): unknown;
|
|
10
|
+
export declare function hasJsoncComments(text: string): boolean;
|
|
11
|
+
export declare function formatJsoncMember(key: string, value: unknown, eol?: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Rewrites only the top-level entries of `text` whose value differs from `next`, leaving every other
|
|
14
|
+
* byte (comments, a leading BOM and the line ending included) in place. Refuses, naming the entry,
|
|
15
|
+
* when an edited value holds a comment or when an entry would have to be removed.
|
|
16
|
+
*/
|
|
17
|
+
export declare function editJsoncObject(text: string, next: Record<string, unknown>): JsoncEdit;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// OpenCode reads its config as JSONC (line and block comments, trailing commas). Comments are blanked
|
|
2
|
+
// in place, keeping every offset, so structure found in the blanked copy addresses the original text.
|
|
3
|
+
function stringEnd(text, start) {
|
|
4
|
+
let index = start + 1;
|
|
5
|
+
while (index < text.length) {
|
|
6
|
+
if (text[index] === "\\")
|
|
7
|
+
index += 2;
|
|
8
|
+
else if (text[index] === '"')
|
|
9
|
+
return index + 1;
|
|
10
|
+
else
|
|
11
|
+
index += 1;
|
|
12
|
+
}
|
|
13
|
+
return text.length;
|
|
14
|
+
}
|
|
15
|
+
function blankComments(text) {
|
|
16
|
+
let out = "";
|
|
17
|
+
let index = 0;
|
|
18
|
+
while (index < text.length) {
|
|
19
|
+
if (text[index] === '"') {
|
|
20
|
+
const end = stringEnd(text, index);
|
|
21
|
+
out += text.slice(index, end);
|
|
22
|
+
index = end;
|
|
23
|
+
}
|
|
24
|
+
else if (text.startsWith("//", index)) {
|
|
25
|
+
const newline = text.indexOf("\n", index);
|
|
26
|
+
const end = newline === -1 ? text.length : newline;
|
|
27
|
+
out += " ".repeat(end - index);
|
|
28
|
+
index = end;
|
|
29
|
+
}
|
|
30
|
+
else if (text.startsWith("/*", index)) {
|
|
31
|
+
const close = text.indexOf("*/", index + 2);
|
|
32
|
+
if (close === -1) {
|
|
33
|
+
throw new SyntaxError(`Unterminated block comment in JSONC starting at line ${text.slice(0, index).split("\n").length}`);
|
|
34
|
+
}
|
|
35
|
+
const end = close + 2;
|
|
36
|
+
out += text.slice(index, end).replace(/[^\r\n]/gu, " ");
|
|
37
|
+
index = end;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
out += text[index];
|
|
41
|
+
index += 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function blankTrailingCommas(text) {
|
|
47
|
+
let out = "";
|
|
48
|
+
let index = 0;
|
|
49
|
+
while (index < text.length) {
|
|
50
|
+
if (text[index] === '"') {
|
|
51
|
+
const end = stringEnd(text, index);
|
|
52
|
+
out += text.slice(index, end);
|
|
53
|
+
index = end;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (text[index] === "," && /^\s*[\]}]/u.test(text.slice(index + 1)))
|
|
57
|
+
out += " ";
|
|
58
|
+
else
|
|
59
|
+
out += text[index];
|
|
60
|
+
index += 1;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
const bom = "\uFEFF";
|
|
65
|
+
function withoutBom(text) {
|
|
66
|
+
return text.startsWith(bom) ? text.slice(1) : text;
|
|
67
|
+
}
|
|
68
|
+
export function lineEnding(text) {
|
|
69
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
70
|
+
}
|
|
71
|
+
export function parseJsonc(text) {
|
|
72
|
+
return JSON.parse(blankTrailingCommas(blankComments(withoutBom(text))));
|
|
73
|
+
}
|
|
74
|
+
export function hasJsoncComments(text) {
|
|
75
|
+
return blankComments(text) !== text;
|
|
76
|
+
}
|
|
77
|
+
function skipWhitespace(text, index) {
|
|
78
|
+
while (index < text.length && /\s/u.test(text[index]))
|
|
79
|
+
index += 1;
|
|
80
|
+
return index;
|
|
81
|
+
}
|
|
82
|
+
function valueEnd(text, start) {
|
|
83
|
+
if (text[start] === '"')
|
|
84
|
+
return stringEnd(text, start);
|
|
85
|
+
if (text[start] !== "{" && text[start] !== "[") {
|
|
86
|
+
let index = start;
|
|
87
|
+
while (index < text.length && !/[\s,\]}]/u.test(text[index]))
|
|
88
|
+
index += 1;
|
|
89
|
+
return index;
|
|
90
|
+
}
|
|
91
|
+
let depth = 0;
|
|
92
|
+
let index = start;
|
|
93
|
+
while (index < text.length) {
|
|
94
|
+
const char = text[index];
|
|
95
|
+
if (char === '"') {
|
|
96
|
+
index = stringEnd(text, index);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (char === "{" || char === "[")
|
|
100
|
+
depth += 1;
|
|
101
|
+
if (char === "}" || char === "]")
|
|
102
|
+
depth -= 1;
|
|
103
|
+
index += 1;
|
|
104
|
+
if (depth === 0)
|
|
105
|
+
return index;
|
|
106
|
+
}
|
|
107
|
+
return index;
|
|
108
|
+
}
|
|
109
|
+
// `blanked` is text that already parsed as a JSONC object, with its comments blanked.
|
|
110
|
+
function topLevelMembers(blanked) {
|
|
111
|
+
const members = [];
|
|
112
|
+
let index = skipWhitespace(blanked, 0) + 1;
|
|
113
|
+
for (;;) {
|
|
114
|
+
index = skipWhitespace(blanked, index);
|
|
115
|
+
if (blanked[index] === ",")
|
|
116
|
+
index = skipWhitespace(blanked, index + 1);
|
|
117
|
+
if (blanked[index] === "}" || index >= blanked.length)
|
|
118
|
+
return { close: index, members };
|
|
119
|
+
const keyEnd = stringEnd(blanked, index);
|
|
120
|
+
const key = JSON.parse(blanked.slice(index, keyEnd));
|
|
121
|
+
const valueStart = skipWhitespace(blanked, skipWhitespace(blanked, keyEnd) + 1);
|
|
122
|
+
const end = valueEnd(blanked, valueStart);
|
|
123
|
+
members.push({ key, valueStart, valueEnd: end });
|
|
124
|
+
index = end;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function formatValue(value, eol = "\n") {
|
|
128
|
+
return JSON.stringify(value, null, 2).replace(/\n/gu, `${eol} `);
|
|
129
|
+
}
|
|
130
|
+
export function formatJsoncMember(key, value, eol = "\n") {
|
|
131
|
+
return `${JSON.stringify(key)}: ${formatValue(value, eol)}`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Rewrites only the top-level entries of `text` whose value differs from `next`, leaving every other
|
|
135
|
+
* byte (comments, a leading BOM and the line ending included) in place. Refuses, naming the entry,
|
|
136
|
+
* when an edited value holds a comment or when an entry would have to be removed.
|
|
137
|
+
*/
|
|
138
|
+
export function editJsoncObject(text, next) {
|
|
139
|
+
if (text.startsWith(bom)) {
|
|
140
|
+
const edit = editJsoncObject(text.slice(1), next);
|
|
141
|
+
return edit.ok ? { ok: true, text: bom + edit.text } : edit;
|
|
142
|
+
}
|
|
143
|
+
const eol = lineEnding(text);
|
|
144
|
+
const current = parseJsonc(text);
|
|
145
|
+
const removed = Object.keys(current).find((key) => !(key in next));
|
|
146
|
+
if (removed !== undefined)
|
|
147
|
+
return { ok: false, key: removed };
|
|
148
|
+
let edited = text;
|
|
149
|
+
for (const [key, value] of Object.entries(next)) {
|
|
150
|
+
if (JSON.stringify(current[key]) === JSON.stringify(value))
|
|
151
|
+
continue;
|
|
152
|
+
const blanked = blankComments(edited);
|
|
153
|
+
const { close, members } = topLevelMembers(blanked);
|
|
154
|
+
const member = [...members].reverse().find((candidate) => candidate.key === key);
|
|
155
|
+
if (member !== undefined) {
|
|
156
|
+
if (blanked.slice(member.valueStart, member.valueEnd) !== edited.slice(member.valueStart, member.valueEnd)) {
|
|
157
|
+
return { ok: false, key };
|
|
158
|
+
}
|
|
159
|
+
edited = edited.slice(0, member.valueStart) + formatValue(value, eol) + edited.slice(member.valueEnd);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
let lineStart = close;
|
|
163
|
+
while (lineStart > 0 && (edited[lineStart - 1] === " " || edited[lineStart - 1] === "\t"))
|
|
164
|
+
lineStart -= 1;
|
|
165
|
+
const ownLine = lineStart > 0 && edited[lineStart - 1] === "\n";
|
|
166
|
+
const at = ownLine ? lineStart : close;
|
|
167
|
+
edited = edited.slice(0, at) + (ownLine ? "" : eol) + " " + formatJsoncMember(key, value, eol) + eol + edited.slice(at);
|
|
168
|
+
const last = members.at(-1);
|
|
169
|
+
if (last !== undefined && !blanked.slice(last.valueEnd, close).includes(",")) {
|
|
170
|
+
edited = edited.slice(0, last.valueEnd) + "," + edited.slice(last.valueEnd);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, text: edited };
|
|
174
|
+
}
|
package/dist/cli/loop.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { CliResult, LoopArgs } from "./types.ts";
|
|
2
2
|
import type { LoopCommand } from "./args.ts";
|
|
3
|
-
export declare function runLoopCommand(command: LoopCommand, loop: LoopArgs): Promise<CliResult>;
|
|
3
|
+
export declare function runLoopCommand(command: LoopCommand, loop: LoopArgs, dryRun?: boolean): Promise<CliResult>;
|
package/dist/cli/loop.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { completeGoals, createGoals, EvidenceLedgerError, evaluateGoalGate, readEvidenceLedgerStatus, recordCheckpoint, recordEvidence, recordReviewBlocker, recordSteering, resolveReviewBlocker } from "../ledger.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
2
6
|
import { createRuntimePaths } from "../state.js";
|
|
3
7
|
// Exit codes are distinct so a caller can tell a refusal from a usage error: 2 is a malformed
|
|
4
8
|
// invocation, 3 is a deliberate refusal, 1 is a gate that did not pass.
|
|
@@ -38,7 +42,83 @@ function describeGoal(goal) {
|
|
|
38
42
|
lines.push(` - ${reason}`);
|
|
39
43
|
return lines.join("\n");
|
|
40
44
|
}
|
|
41
|
-
|
|
45
|
+
async function snapshotTree(dir) {
|
|
46
|
+
const files = new Map();
|
|
47
|
+
const walk = async (current) => {
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
54
|
+
return;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const full = path.join(current, entry.name);
|
|
59
|
+
if (entry.isDirectory())
|
|
60
|
+
await walk(full);
|
|
61
|
+
else
|
|
62
|
+
files.set(path.relative(dir, full), createHash("sha256").update(await fs.readFile(full)).digest("hex"));
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
await walk(dir);
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
// A dry run executes the verb against a private copy of the project's .litopencode state, so the
|
|
69
|
+
// same validation, refusals and gates apply, then reports which files the real run would change.
|
|
70
|
+
// The copy dereferences symlinks, so a write in the copy can never reach a linked target.
|
|
71
|
+
async function dryRunLoopCommand(command, loop) {
|
|
72
|
+
const realRuntimeDir = createRuntimePaths(loop.projectRoot).runtimeDir;
|
|
73
|
+
const shadowRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "litopencode-dry-run-shadow-")));
|
|
74
|
+
try {
|
|
75
|
+
const shadowRuntimeDir = createRuntimePaths(shadowRoot).runtimeDir;
|
|
76
|
+
try {
|
|
77
|
+
await fs.cp(realRuntimeDir, shadowRuntimeDir, { recursive: true, dereference: true });
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
const before = await snapshotTree(shadowRuntimeDir);
|
|
84
|
+
const result = await runLoopCommandAt(command, { ...loop, projectRoot: shadowRoot });
|
|
85
|
+
const after = await snapshotTree(shadowRuntimeDir);
|
|
86
|
+
const writes = [];
|
|
87
|
+
for (const [file, hash] of after) {
|
|
88
|
+
const previous = before.get(file);
|
|
89
|
+
if (previous === undefined)
|
|
90
|
+
writes.push({ path: path.join(realRuntimeDir, file), change: "create" });
|
|
91
|
+
else if (previous !== hash)
|
|
92
|
+
writes.push({ path: path.join(realRuntimeDir, file), change: "update" });
|
|
93
|
+
}
|
|
94
|
+
for (const file of before.keys())
|
|
95
|
+
if (!after.has(file))
|
|
96
|
+
writes.push({ path: path.join(realRuntimeDir, file), change: "delete" });
|
|
97
|
+
writes.sort((left, right) => left.path.localeCompare(right.path));
|
|
98
|
+
const realProjectRoot = path.resolve(loop.projectRoot);
|
|
99
|
+
const relabel = (text) => text?.split(shadowRoot).join(realProjectRoot);
|
|
100
|
+
if (loop.json && result.stdout !== undefined) {
|
|
101
|
+
const payload = { dryRun: true, command, wouldWrite: writes, result: JSON.parse(relabel(result.stdout) ?? "null") };
|
|
102
|
+
return { exitCode: result.exitCode, stdout: JSON.stringify(payload, null, 2), stderr: relabel(result.stderr) };
|
|
103
|
+
}
|
|
104
|
+
const plan = writes.length === 0
|
|
105
|
+
? `DRY RUN: nothing was written. ${command} would change no files.`
|
|
106
|
+
: [`DRY RUN: nothing was written. ${command} would change:`, ...writes.map((entry) => ` ${entry.change} ${entry.path}`)].join("\n");
|
|
107
|
+
const preview = relabel(result.stdout);
|
|
108
|
+
const stdout = preview === undefined || preview === "" ? plan : `${plan}\nThe real run would report:\n${preview}`;
|
|
109
|
+
return { exitCode: result.exitCode, stdout, stderr: relabel(result.stderr) };
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await fs.rm(shadowRoot, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// status only reads, so --dry-run changes nothing about it; every other verb writes the ledger.
|
|
116
|
+
export async function runLoopCommand(command, loop, dryRun = false) {
|
|
117
|
+
if (dryRun && command !== "status")
|
|
118
|
+
return await dryRunLoopCommand(command, loop);
|
|
119
|
+
return await runLoopCommandAt(command, loop);
|
|
120
|
+
}
|
|
121
|
+
async function runLoopCommandAt(command, loop) {
|
|
42
122
|
const root = loop.projectRoot;
|
|
43
123
|
try {
|
|
44
124
|
if (command === "create-goals") {
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import type { CliResult } from "./types.ts";
|
|
2
2
|
export declare const motionInstallCommand = "litopencode motion-runtime install";
|
|
3
|
-
export
|
|
3
|
+
export type MotionRuntimeArgs = {
|
|
4
|
+
readonly command: "help";
|
|
5
|
+
} | {
|
|
6
|
+
readonly command: "install";
|
|
7
|
+
readonly audio: boolean;
|
|
8
|
+
readonly wordTiming: boolean;
|
|
9
|
+
readonly dryRun: boolean;
|
|
10
|
+
} | {
|
|
11
|
+
readonly command: "status";
|
|
12
|
+
readonly json: boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare function parseMotionRuntimeArgs(args: readonly string[]): MotionRuntimeArgs;
|
|
15
|
+
export declare function runMotionRuntime(packageRoot: string, args: readonly string[]): Promise<CliResult>;
|
|
4
16
|
export declare function motionPrewarmSkipped(env?: NodeJS.ProcessEnv): string | undefined;
|
|
5
17
|
export declare function prewarmMotionRuntime(packageRoot: string, env?: NodeJS.ProcessEnv): string;
|
|
6
18
|
export declare function probeMotionRuntime(packageRoot: string): unknown;
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
1
9
|
import { spawnSync } from "node:child_process";
|
|
2
10
|
import path from "node:path";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
3
12
|
// lit-typographic-motion's runtime lives in the managed skill (runtime.mjs, probe.mjs). The CLI verb
|
|
4
13
|
// `litopencode motion-runtime install|status` runs it under this same Node binary; the installer
|
|
5
14
|
// attempts the same pre-warm and reduces the outcome to one receipt line.
|
|
@@ -7,8 +16,86 @@ function motionScript(packageRoot, file) {
|
|
|
7
16
|
return path.join(packageRoot, "skills", "lit-typographic-motion", file);
|
|
8
17
|
}
|
|
9
18
|
export const motionInstallCommand = "litopencode motion-runtime install";
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
const motionUsage = "Usage: litopencode motion-runtime install [--audio] [--word-timing] [--dry-run] | status [--json]";
|
|
20
|
+
// Every flag is checked before the runtime script runs, because install downloads and writes the
|
|
21
|
+
// cache: an unknown or misspelled flag must stop the command, never be ignored.
|
|
22
|
+
export function parseMotionRuntimeArgs(args) {
|
|
23
|
+
const [command, ...flags] = args;
|
|
24
|
+
const refuse = (reason) => {
|
|
25
|
+
throw new Error(`motion-runtime: ${reason}\n${motionUsage}`);
|
|
26
|
+
};
|
|
27
|
+
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
28
|
+
if (flags.length > 0)
|
|
29
|
+
refuse(`unexpected argument after ${command ?? "motion-runtime"}: ${flags[0]}`);
|
|
30
|
+
return { command: "help" };
|
|
31
|
+
}
|
|
32
|
+
if (command === "install") {
|
|
33
|
+
let audio = false;
|
|
34
|
+
let wordTiming = false;
|
|
35
|
+
let dryRun = false;
|
|
36
|
+
for (const flag of flags) {
|
|
37
|
+
if (flag === "--audio")
|
|
38
|
+
audio = true;
|
|
39
|
+
else if (flag === "--word-timing")
|
|
40
|
+
wordTiming = true;
|
|
41
|
+
else if (flag === "--dry-run" || flag === "--dry-run=true")
|
|
42
|
+
dryRun = true;
|
|
43
|
+
else
|
|
44
|
+
refuse(`unknown install flag ${flag}`);
|
|
45
|
+
}
|
|
46
|
+
return { command: "install", audio, wordTiming, dryRun };
|
|
47
|
+
}
|
|
48
|
+
if (command === "status") {
|
|
49
|
+
let json = false;
|
|
50
|
+
for (const flag of flags) {
|
|
51
|
+
if (flag === "--json")
|
|
52
|
+
json = true;
|
|
53
|
+
else if (flag === "--dry-run" || flag.startsWith("--dry-run="))
|
|
54
|
+
refuse("status only reads the cache, so --dry-run does not apply");
|
|
55
|
+
else
|
|
56
|
+
refuse(`unknown status flag ${flag}`);
|
|
57
|
+
}
|
|
58
|
+
return { command: "status", json };
|
|
59
|
+
}
|
|
60
|
+
return refuse(`unknown command ${command}`);
|
|
61
|
+
}
|
|
62
|
+
// Reads the same pins and cache state the install uses and reports each step it would take.
|
|
63
|
+
// Nothing here downloads, creates the cache, or takes the install lock.
|
|
64
|
+
async function describeMotionInstall(packageRoot, options) {
|
|
65
|
+
const runtime = await import(__rewriteRelativeImportExtension(pathToFileURL(motionScript(packageRoot, "runtime.mjs")).href));
|
|
66
|
+
const paths = runtime.runtimePaths(process.env);
|
|
67
|
+
const state = runtime.inspectWarmState(process.env);
|
|
68
|
+
const lines = ["DRY RUN: nothing was downloaded or written.", `motion runtime cache: ${paths.base}`];
|
|
69
|
+
if (options.wordTiming) {
|
|
70
|
+
lines.push(runtime.describeWordTiming());
|
|
71
|
+
if (!runtime.inspectWordTiming().pinned) {
|
|
72
|
+
lines.push("would stop: no licence-verified Korean alignment model is pinned, so the real install exits 14 (BLOCKED_DEPS_NOT_PREWARMED) before downloading anything.");
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const fonts = [...state.missingFonts, ...state.mismatchedFonts];
|
|
77
|
+
lines.push(fonts.length === 0 ? "fonts: all pinned fonts are present" : `would fetch and verify ${fonts.length} pinned font file(s) into ${paths.fonts}: ${fonts.join(", ")}`);
|
|
78
|
+
lines.push(state.deps.length === 0 ? "engine dependencies: already installed" : `would run npm ci for ${state.deps.join(", ")} into ${paths.node}`);
|
|
79
|
+
if (options.audio)
|
|
80
|
+
lines.push(state.audio.ready ? "audio venv: already ready" : `would create the pinned librosa audio venv in ${paths.audio}`);
|
|
81
|
+
lines.push("Run the same command without --dry-run to install.");
|
|
82
|
+
return lines.join("\n");
|
|
83
|
+
}
|
|
84
|
+
export async function runMotionRuntime(packageRoot, args) {
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = parseMotionRuntimeArgs(args);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return { exitCode: 2, stderr: error instanceof Error ? error.message : String(error) };
|
|
91
|
+
}
|
|
92
|
+
if (parsed.command === "install" && parsed.dryRun) {
|
|
93
|
+
return { exitCode: 0, stdout: await describeMotionInstall(packageRoot, parsed) };
|
|
94
|
+
}
|
|
95
|
+
const forwarded = parsed.command === "help" ? ["--help"]
|
|
96
|
+
: parsed.command === "status" ? ["status", ...(parsed.json ? ["--json"] : [])]
|
|
97
|
+
: ["install", ...(parsed.audio ? ["--audio"] : []), ...(parsed.wordTiming ? ["--word-timing"] : [])];
|
|
98
|
+
const result = spawnSync(process.execPath, [motionScript(packageRoot, "runtime.mjs"), ...forwarded], { stdio: "inherit" });
|
|
12
99
|
return { exitCode: typeof result.status === "number" ? result.status : 1 };
|
|
13
100
|
}
|
|
14
101
|
// A test run never reaches the network or the real cache from the installer: node --test marks its
|