@litfamily/litopencode 1.0.34 → 1.0.36

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 (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README-Ko-KR.md +6 -6
  3. package/README.md +6 -6
  4. package/dist/activation-primary-prompts.js +7 -3
  5. package/dist/activation.js +5 -0
  6. package/dist/adhd-next.d.ts +1 -1
  7. package/dist/adhd-next.js +11 -0
  8. package/dist/cli/agent-file-command.d.ts +21 -0
  9. package/dist/cli/agent-file-command.js +99 -0
  10. package/dist/cli/agent-file.d.ts +46 -0
  11. package/dist/cli/agent-file.js +197 -0
  12. package/dist/cli/args.d.ts +1 -0
  13. package/dist/cli/args.js +25 -1
  14. package/dist/cli/doctor.js +10 -0
  15. package/dist/cli/install.d.ts +4 -1
  16. package/dist/cli/install.js +8 -3
  17. package/dist/cli/managed-block.d.ts +26 -0
  18. package/dist/cli/managed-block.js +80 -0
  19. package/dist/cli/model-routing.d.ts +5 -0
  20. package/dist/cli/model-routing.js +1 -1
  21. package/dist/cli/types.d.ts +10 -0
  22. package/dist/cli.js +14 -1
  23. package/dist/commands.js +17 -4
  24. package/dist/native-goal-ledger.d.ts +43 -0
  25. package/dist/native-goal-ledger.js +74 -0
  26. package/dist/v2/commands.d.ts +1 -0
  27. package/dist/v2/commands.js +2 -2
  28. package/dist/v2/context.d.ts +6 -1
  29. package/dist/v2/native-goal-runtime.d.ts +74 -0
  30. package/dist/v2/native-goal-runtime.js +193 -0
  31. package/dist/v2/native-goal.d.ts +49 -0
  32. package/dist/v2/native-goal.js +71 -0
  33. package/dist/v2/server.js +11 -1
  34. package/dist/v2/session-hooks.d.ts +4 -0
  35. package/dist/v2/session-hooks.js +13 -0
  36. package/docs/assets/readme/badge-version.svg +1 -1
  37. package/docs/migration.md +1 -1
  38. package/docs/reference-Ko-KR.md +27 -0
  39. package/docs/reference.md +33 -3
  40. package/package.json +2 -2
  41. package/skills/lit-adhd/SKILL.md +3 -3
  42. package/skills/lit-adhd/references/brief-format.md +1 -1
  43. package/skills/lit-adhd/references/status-taxonomy.md +1 -1
  44. package/skills/lit-adhd/scripts/rewind.mjs +73 -14
  45. package/skills/lit-init/SKILL.md +4 -0
  46. package/skills/litgoal/SKILL.md +2 -2
  47. package/skills/litwork/SKILL.md +4 -0
  48. package/skills/managed-skill-manifest.json +3 -3
  49. package/skills/native-goal-verdict/SKILL.md +34 -60
  50. package/skills/start-work/SKILL.md +4 -0
  51. package/skills/workflow-loop/SKILL.md +4 -0
@@ -7,4 +7,7 @@ import { type HostChoice } from "./host-detect.ts";
7
7
  */
8
8
  export declare function withoutV2PluginEntries(config: Record<string, unknown> | null): Record<string, unknown> | null;
9
9
  export declare function assertNoHostOnlyKeysAdded(before: Record<string, unknown> | null, next: Record<string, unknown> | null): void;
10
- export declare function install(root: string, dryRun: boolean, modelSelection?: InstallModelSelection | undefined, modelPrompt?: ModelPromptMode, permissionMode?: InstallPermissionMode, permissionModeExplicit?: boolean, permissionPrompt?: PermissionPromptMode, progress?: InstallProgress, outputStylePrompt?: PermissionPromptMode, host?: HostChoice): Promise<CliResult>;
10
+ export declare function install(root: string, dryRun: boolean, modelSelection?: InstallModelSelection | undefined, modelPrompt?: ModelPromptMode, permissionMode?: InstallPermissionMode, permissionModeExplicit?: boolean, permissionPrompt?: PermissionPromptMode, progress?: InstallProgress, outputStylePrompt?: PermissionPromptMode, host?: HostChoice, agentFileChoice?: {
11
+ readonly assumeYes: boolean;
12
+ readonly skip: boolean;
13
+ }): Promise<CliResult>;
@@ -13,6 +13,7 @@ import { renderLitOpenCodeWordmark } from "./install-tui.js";
13
13
  import { promptModelSelection, promptOutputStyle, promptPermissionMode } from "./model-routing.js";
14
14
  import { litOpenCodeConfigFileForOpenCodeRoot } from "../state.js";
15
15
  import { describeNativeSkillMutation, ensureNativeSkills, inspectNativeSkills } from "./native-skills.js";
16
+ import { installAgentFile, planAgentFile } from "./agent-file-command.js";
16
17
  import { applyPluginMutation, describePluginMutation, isLitOpenCodeEntry } from "./plugin-mutation.js";
17
18
  import { prewarmMotionRuntime } from "./motion-runtime.js";
18
19
  import { applyCliJsonCleanup, applyTuiWrites, backupTuiFiles, inspectTuiPlugin, planCliJsonCleanup, planTuiInstall, skippedTuiPlan } from "./tui-config.js";
@@ -257,7 +258,7 @@ async function verifyInstalledSurfaces(root, configFile, target, metadata, selec
257
258
  if (failures.length > 0)
258
259
  throw new Error("Install verification failed: " + failures.join("; "));
259
260
  }
260
- export async function install(root, dryRun, modelSelection = undefined, modelPrompt = "auto", permissionMode = "safe", permissionModeExplicit = false, permissionPrompt = "auto", progress, outputStylePrompt = "auto", host = "auto") {
261
+ export async function install(root, dryRun, modelSelection = undefined, modelPrompt = "auto", permissionMode = "safe", permissionModeExplicit = false, permissionPrompt = "auto", progress, outputStylePrompt = "auto", host = "auto", agentFileChoice = { assumeYes: false, skip: false }) {
261
262
  const retiredNotice = await migrateRetiredEfforts(root, dryRun);
262
263
  if (retiredNotice !== undefined)
263
264
  process.stdout.write(retiredNotice + "\n");
@@ -336,10 +337,12 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
336
337
  }
337
338
  const { configFile, before, mutation, litopencodeConfig, commandAliases, nativeSkills, hostLimits, routes, tui, cliJson } = inspected;
338
339
  const v2PluginsChanged = resolved.forV2 && withoutV2PluginEntries(before) !== before;
340
+ const agentFileInput = { root, version: metadata.version, ...agentFileChoice };
339
341
  const report = {
340
342
  dryRun,
341
343
  path: configFile,
342
344
  litopencodeConfig,
345
+ ...(dryRun ? { agentFile: await planAgentFile(agentFileInput) } : {}),
343
346
  plugin: mutation.plugin,
344
347
  commandAliases,
345
348
  nativeSkills,
@@ -423,12 +426,14 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
423
426
  await applyCliJsonCleanup(cliJson);
424
427
  await withProgress(activeProgress, "Verify install", async () => verifyInstalledSurfaces(root, configFile, resolved.target, metadata, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle, resolved.detection));
425
428
  await activeProgress?.complete();
429
+ // The block is the last step: it asks a question, and a refusal here never undoes a finished install.
430
+ const agentFileLine = wrapReceiptLine(await installAgentFile(agentFileInput));
426
431
  // MO-A-42: the installer attempts the motion pre-warm; a failure never fails the install.
427
432
  const motionReceipt = wrapReceiptLine(prewarmMotionRuntime(metadata.packageRoot));
428
433
  return {
429
434
  exitCode: 0,
430
435
  stdout: activeProgress === undefined
431
- ? renderLitOpenCodeWordmark(pluginSpec(metadata), false) + "\n" + renderInstallReport(report) + "\n" + motionReceipt
432
- : motionReceipt
436
+ ? renderLitOpenCodeWordmark(pluginSpec(metadata), false) + "\n" + renderInstallReport(report) + "\n" + agentFileLine + "\n" + motionReceipt
437
+ : agentFileLine + "\n" + motionReceipt
433
438
  };
434
439
  }
@@ -0,0 +1,26 @@
1
+ export declare class ManagedBlockError extends Error {
2
+ }
3
+ export type ManagedBlock = {
4
+ readonly id: string;
5
+ readonly version: string;
6
+ readonly body: string;
7
+ };
8
+ export type BlockInspection = {
9
+ readonly state: "absent" | "present" | "malformed";
10
+ readonly version: string | null;
11
+ readonly created: boolean;
12
+ };
13
+ export declare function inspectBlock(text: string, id: string): BlockInspection;
14
+ /** Adds the block, or swaps the bytes between an existing block's markers. `created` marks a file made for the block. */
15
+ export declare function upsertBlock(text: string, block: ManagedBlock, options?: {
16
+ readonly created?: boolean;
17
+ }): {
18
+ text: string;
19
+ changed: boolean;
20
+ };
21
+ /** Removes the block, the line ending after it and whatever was added in front of it. */
22
+ export declare function removeBlock(text: string, id: string): {
23
+ text: string;
24
+ changed: boolean;
25
+ created: boolean;
26
+ };
@@ -0,0 +1,80 @@
1
+ // A LitFamily block inside a file the user owns (OpenCode's global AGENTS.md, a repo's AGENTS.md): the
2
+ // lines between `<!-- litfamily:<id>:start v=<version> -->` and `<!-- litfamily:<id>:end -->`. Only those
3
+ // lines are written. The start marker also records what was put in front of the block (`pre=`, hex) and
4
+ // whether the file was made for it (`new=1`), so removing the block takes back exactly that and an
5
+ // add-then-remove returns the original bytes under any line ending. A start without its end, or two of
6
+ // either, is malformed and refused rather than guessed at.
7
+ import { Buffer } from "node:buffer";
8
+ export class ManagedBlockError extends Error {
9
+ }
10
+ const startOf = (id) => `<!-- litfamily:${id}:start`;
11
+ const endOf = (id) => `<!-- litfamily:${id}:end -->`;
12
+ const startLine = /^<!-- litfamily:[^:\s]+:start v=(\S+)((?: [a-z]+=[0-9a-f]*)*) -->/u;
13
+ function lineEnding(text) {
14
+ return text.includes("\r\n") ? "\r\n" : "\n";
15
+ }
16
+ function positionsOf(text, needle) {
17
+ const found = [];
18
+ for (let at = text.indexOf(needle); at !== -1; at = text.indexOf(needle, at + needle.length))
19
+ found.push(at);
20
+ return found;
21
+ }
22
+ function locate(text, id) {
23
+ const starts = positionsOf(text, startOf(id));
24
+ const ends = positionsOf(text, endOf(id));
25
+ if (starts.length === 0 && ends.length === 0)
26
+ return null;
27
+ if (starts.length !== 1 || ends.length !== 1 || ends[0] < starts[0]) {
28
+ throw new ManagedBlockError(`malformed LitFamily ${id} block: expected one start marker followed by one end marker`);
29
+ }
30
+ const head = startLine.exec(text.slice(starts[0]));
31
+ if (head === null)
32
+ throw new ManagedBlockError(`malformed LitFamily ${id} block: unreadable start marker`);
33
+ const attributes = new Map(head[2].trim().split(" ").filter(Boolean).map((pair) => pair.split("=")));
34
+ const pre = attributes.get("pre");
35
+ return {
36
+ start: starts[0],
37
+ end: ends[0] + endOf(id).length,
38
+ version: head[1],
39
+ pre: pre === undefined || pre === "" ? "" : Buffer.from(pre, "hex").toString("utf8"),
40
+ created: attributes.get("new") === "1"
41
+ };
42
+ }
43
+ function render(block, eol, added) {
44
+ const attributes = `${added.pre === "" ? "" : ` pre=${Buffer.from(added.pre, "utf8").toString("hex")}`}${added.created ? " new=1" : ""}`;
45
+ const body = block.body.replace(/\r?\n$/u, "").split(/\r?\n/u);
46
+ return [`${startOf(block.id)} v=${block.version}${attributes} -->`, ...body, endOf(block.id)].join(eol);
47
+ }
48
+ export function inspectBlock(text, id) {
49
+ try {
50
+ const span = locate(text, id);
51
+ return span === null ? { state: "absent", version: null, created: false } : { state: "present", version: span.version, created: span.created };
52
+ }
53
+ catch {
54
+ return { state: "malformed", version: null, created: false };
55
+ }
56
+ }
57
+ /** Adds the block, or swaps the bytes between an existing block's markers. `created` marks a file made for the block. */
58
+ export function upsertBlock(text, block, options = {}) {
59
+ const eol = lineEnding(text);
60
+ const span = locate(text, block.id);
61
+ if (span !== null) {
62
+ const next = text.slice(0, span.start) + render(block, eol, span) + text.slice(span.end);
63
+ return { text: next, changed: next !== text };
64
+ }
65
+ const pre = text === "" ? "" : (text.endsWith("\n") ? "" : eol) + eol;
66
+ return { text: text + pre + render(block, eol, { pre, created: options.created === true }) + eol, changed: true };
67
+ }
68
+ /** Removes the block, the line ending after it and whatever was added in front of it. */
69
+ export function removeBlock(text, id) {
70
+ const span = locate(text, id);
71
+ if (span === null)
72
+ return { text, changed: false, created: false };
73
+ let before = text.slice(0, span.start);
74
+ let after = text.slice(span.end);
75
+ const eol = after.startsWith("\r\n") ? "\r\n" : after.startsWith("\n") ? "\n" : "";
76
+ after = after.slice(eol.length);
77
+ if (span.pre !== "" && before.endsWith(span.pre))
78
+ before = before.slice(0, -span.pre.length);
79
+ return { text: before + after, changed: true, created: span.created };
80
+ }
@@ -1,5 +1,10 @@
1
1
  import type { InstallModelSelection, InstallPermissionChoice, InstallPermissionMode, ModelConfigClassification, ModelPromptMode, PermissionPromptMode } from "./types.ts";
2
2
  export declare function promptOutputStyle(existing: string, promptMode: PermissionPromptMode, dryRun: boolean): Promise<string>;
3
+ export type PromptReader = {
4
+ question(prompt: string): Promise<string>;
5
+ close(): void;
6
+ };
7
+ export declare function createPromptReader(): PromptReader;
3
8
  export declare function promptPermissionMode(selected: InstallPermissionMode, mode: PermissionPromptMode, dryRun: boolean): Promise<InstallPermissionChoice>;
4
9
  export declare function renderModelRouteCard(selection: InstallModelSelection, configPath: string): string;
5
10
  export declare function promptModelSelection(selection: InstallModelSelection | undefined, mode: ModelPromptMode, dryRun: boolean, classification: ModelConfigClassification, configPath: string, currentSelection?: InstallModelSelection | undefined): Promise<InstallModelSelection | undefined>;
@@ -57,7 +57,7 @@ function formatChoice(index, label, hint) {
57
57
  async function askLine(reader, prompt) {
58
58
  return (await reader.question(prompt)).trim();
59
59
  }
60
- function createPromptReader() {
60
+ export function createPromptReader() {
61
61
  if (process.stdin.isTTY === true) {
62
62
  return readline.createInterface({ input: process.stdin, output: process.stdout, terminal: markColorMode() !== "none" });
63
63
  }
@@ -28,6 +28,10 @@ export type ParsedArgs = {
28
28
  readonly outputStylePrompt: PermissionPromptMode;
29
29
  readonly loop: LoopArgs;
30
30
  readonly tuiAction?: string;
31
+ readonly agentFileAction?: string;
32
+ readonly agentFileDir?: string;
33
+ readonly agentFile: "auto" | "skip";
34
+ readonly assumeYes: boolean;
31
35
  readonly host: HostChoice;
32
36
  };
33
37
  export type LoopArgs = {
@@ -158,6 +162,12 @@ export type InstallReport = {
158
162
  readonly changed: boolean;
159
163
  };
160
164
  };
165
+ readonly agentFile?: {
166
+ readonly path: string;
167
+ readonly state: string;
168
+ readonly mode: string;
169
+ readonly action: string;
170
+ };
161
171
  readonly plugin: PluginMutation["plugin"];
162
172
  readonly commandAliases: {
163
173
  readonly path: string;
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { isLoopCommand, parseArgs } from "./cli/args.js";
2
2
  import { runLoopCommand } from "./cli/loop.js";
3
3
  import { doctor } from "./cli/doctor.js";
4
+ import { runAgentFileCommand } from "./cli/agent-file-command.js";
4
5
  import { install } from "./cli/install.js";
5
6
  import { createInstallTui, renderLitOpenCodeWordmark, shouldRenderInstallTui } from "./cli/install-tui.js";
6
7
  import { runUpdateNotifier } from "./cli/update-notifier.js";
@@ -33,7 +34,10 @@ function helpText() {
33
34
  " litopencode install [--dry-run] [--root <dir>] --provider <openai|xai> --model <id> [--effort <e>] [--subagent-model <provider/id>] [--subagent-effort <e>] [--yolo]",
34
35
  " litopencode install [--model-prompt | --no-model-prompt | --yes]",
35
36
  " litopencode install [--permission-prompt | --no-permission-prompt] [--permission-mode <safe|balanced|yolo>]",
37
+ " litopencode install [--no-agent-file]",
36
38
  " litopencode install|doctor [--no-auto-update]",
39
+ " litopencode agent-file on|off|remove [--dry-run] [--root <dir>]",
40
+ " litopencode agent-file project [--dir <path>] [--dry-run]",
37
41
  " litopencode doctor [--json] [--root <dir>]",
38
42
  " litopencode tui remove [--dry-run] [--root <dir>]",
39
43
  " litopencode motion-runtime install|status [--audio] [--word-timing] [--dry-run] (install) [--json] (status)",
@@ -54,6 +58,7 @@ function helpText() {
54
58
  "Commands:",
55
59
  " install Register litopencode with OpenCode using a branded installer UI.",
56
60
  " doctor Report package, config, and runtime path status without writing files.",
61
+ " agent-file Add, remove or switch off the short LitFamily block in OpenCode's global AGENTS.md; project writes it into a repo's AGENTS.md.",
57
62
  " tui remove Remove only LitOpenCode's TUI panel entry from tui.json/tui.jsonc (backed up first).",
58
63
  " fetch-public Fetch a public URL with SSRF guards, verdicts, and trace output.",
59
64
  " --allow-private-network is opt-in for reviewed local fixtures or trusted internal tests.",
@@ -121,6 +126,12 @@ function helpText() {
121
126
  " --no-auto-update or LITOPENCODE_NO_AUTO_UPDATE=1 disables the barrier; NO_UPDATE_NOTIFIER and LITOPENCODE_NO_UPDATE_CHECK also disable it.",
122
127
  " The detached update notice remains cache-refresh-only and never installs.",
123
128
  "",
129
+ "LitFamily block:",
130
+ " An interactive install asks once whether to add a short, marker-delimited block to <root>/AGENTS.md, the global file OpenCode reads every session.",
131
+ " --yes adds it; --no-agent-file skips it for one run; a non-interactive install without --yes never writes it. Your own text stays byte for byte.",
132
+ " OpenCode reads ~/.claude/CLAUDE.md only while <root>/AGENTS.md is missing, so the installer never creates AGENTS.md over an existing CLAUDE.md.",
133
+ " agent-file off removes the block and stores the choice in litopencode.json; agent-file remove only removes it. doctor prints the block state.",
134
+ "",
124
135
  "Default root:",
125
136
  " ~/.config/opencode, or $XDG_CONFIG_HOME/opencode when XDG_CONFIG_HOME is set."
126
137
  ].join("\n");
@@ -146,6 +157,8 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
146
157
  return await runLoopCommand(parsed.command, parsed.loop, parsed.dryRun);
147
158
  if (parsed.command === "doctor")
148
159
  return await doctor(parsed.root, parsed.host);
160
+ if (parsed.command === "agent-file")
161
+ return await runAgentFileCommand(parsed, (await readPackageMetadata()).version);
149
162
  if (parsed.command === "tui") {
150
163
  const removed = await removeTuiEntries(parsed.root, parsed.dryRun, { cliJson: parsed.host !== "v1" });
151
164
  return { exitCode: 0, stdout: JSON.stringify(removed, null, 2) };
@@ -163,7 +176,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
163
176
  return { exitCode: output.ok ? 0 : 1, stdout: renderFetchPublicText(output) };
164
177
  }
165
178
  if (parsed.command === "install") {
166
- return await install(parsed.root, parsed.dryRun, parsed.modelSelection, parsed.modelPrompt, parsed.permissionMode, parsed.permissionModeExplicit, parsed.permissionPrompt, options.installProgress, parsed.outputStylePrompt, parsed.host);
179
+ return await install(parsed.root, parsed.dryRun, parsed.modelSelection, parsed.modelPrompt, parsed.permissionMode, parsed.permissionModeExplicit, parsed.permissionPrompt, options.installProgress, parsed.outputStylePrompt, parsed.host, { assumeYes: parsed.assumeYes, skip: parsed.agentFile === "skip" });
167
180
  }
168
181
  return { exitCode: 2, stderr: `Unknown command: ${parsed.command}` };
169
182
  }
package/dist/commands.js CHANGED
@@ -9,8 +9,18 @@ import { writeNext } from "./adhd-next.js";
9
9
  import { autoHandoffSettingText } from "./auto-handoff.js";
10
10
  import { formatResolvedPlanNotice, formatSavedPlanNotice, parsePlanSaveArgument, persistApprovedPlan, PlanScopeError, resolveLatestDurablePlan } from "./durable-plan.js";
11
11
  import { isWorkflowFamilyMode, workflowFamilyCommandDefinitions } from "./workflow-families.js";
12
+ import { recordNativeGoalUnavailable } from "./native-goal-ledger.js";
13
+ import { nativeGoalRoute } from "./v2/native-goal.js";
12
14
  import { captureKnowledgeEvent, inspectKnowledge, queryKnowledge, recoverKnowledge, reviewKnowledgeRecord } from "./knowledge.js";
13
15
  export { litActivationBanner, startWorkPromptInjection } from "./activation.js";
16
+ /**
17
+ * The four loop commands share one prompt, and an installed alias file hands the host exactly that text,
18
+ * so the prompt alone cannot say which command was typed. A last line names it; the plugin reads it to tell
19
+ * `/lit-loop` and `/litwork` (which set a native goal) from bare `/lit` (which does not).
20
+ */
21
+ function loopCommandText(id) {
22
+ return `${litLoopPromptInjection}\nCommand route: "${id}"`;
23
+ }
14
24
  export const litOpenCodeCommands = Object.freeze([
15
25
  ...workflowFamilyCommandDefinitions,
16
26
  {
@@ -35,7 +45,7 @@ export const litOpenCodeCommands = Object.freeze([
35
45
  title: "LitOpenCode",
36
46
  description: "Activate the LitOpenCode model probe line and durable goal loop.",
37
47
  banner: activationBanner("lit"),
38
- activationText: litLoopPromptInjection
48
+ activationText: loopCommandText("lit")
39
49
  },
40
50
  {
41
51
  id: "lit-loop",
@@ -43,7 +53,7 @@ export const litOpenCodeCommands = Object.freeze([
43
53
  title: "LitOpenCode Lit Loop",
44
54
  description: "Activate the full LitOpenCode durable lit-loop prompt.",
45
55
  banner: activationBanner("lit-loop"),
46
- activationText: litLoopPromptInjection
56
+ activationText: loopCommandText("lit-loop")
47
57
  },
48
58
  {
49
59
  id: "litwork",
@@ -51,7 +61,7 @@ export const litOpenCodeCommands = Object.freeze([
51
61
  title: "LitOpenCode Work Loop",
52
62
  description: "Resume a LitOpenCode work loop with durable ledger-backed progress.",
53
63
  banner: activationBanner("litwork"),
54
- activationText: litLoopPromptInjection
64
+ activationText: loopCommandText("litwork")
55
65
  },
56
66
  {
57
67
  id: "lit-work",
@@ -59,7 +69,7 @@ export const litOpenCodeCommands = Object.freeze([
59
69
  title: "LitOpenCode Work Loop",
60
70
  description: "Hyphenated alias for /litwork.",
61
71
  banner: activationBanner("lit-work"),
62
- activationText: litLoopPromptInjection
72
+ activationText: loopCommandText("lit-work")
63
73
  },
64
74
  {
65
75
  id: "start-work",
@@ -664,6 +674,9 @@ export function createCommandActivationHook(projectRoot, options = {}) {
664
674
  }
665
675
  });
666
676
  await options.onSkillActivation?.(input.sessionID, command.id);
677
+ const goalRoute = nativeGoalRoute(command.id, "");
678
+ if (goalRoute !== undefined)
679
+ await recordNativeGoalUnavailable(projectRoot, input.sessionID, goalRoute, "opencode-1");
667
680
  if (command.id === "lit-scientific-visualization") {
668
681
  options.onScientificVisualizationActivation?.(input.sessionID);
669
682
  }
@@ -0,0 +1,43 @@
1
+ export type NativeGoalLedgerEvent = {
2
+ readonly type: "native-goal.created";
3
+ readonly goalID: string;
4
+ } | {
5
+ readonly type: "native-goal.resumed";
6
+ readonly goalID: string;
7
+ } | {
8
+ readonly type: "native-goal.cleared";
9
+ readonly goalID: string;
10
+ } | {
11
+ readonly type: "native-goal.left-alone";
12
+ readonly goalID: string;
13
+ readonly status: string;
14
+ } | {
15
+ readonly type: "native-goal.conflict";
16
+ readonly goalID: string;
17
+ readonly reason: string;
18
+ } | {
19
+ readonly type: "native-goal.failed";
20
+ readonly step: string;
21
+ readonly detail: string;
22
+ } | {
23
+ readonly type: "native-goal.unavailable";
24
+ readonly host: string;
25
+ };
26
+ export type NativeGoalOwnership = {
27
+ readonly goalID: string | undefined;
28
+ readonly resumes: number;
29
+ readonly createdAt: string | undefined;
30
+ };
31
+ export declare function recordNativeGoal(projectRoot: string, sessionID: string, route: string | undefined, event: NativeGoalLedgerEvent): Promise<boolean>;
32
+ /** The goal id LitOpenCode created for this session and has not cleared, with its resume count. */
33
+ export declare function nativeGoalOwnership(projectRoot: string, sessionID: string): Promise<NativeGoalOwnership>;
34
+ /** OpenCode 1 and older OpenCode 2 hosts have no `/goal`: say so once per session and route. */
35
+ export declare function recordNativeGoalUnavailable(projectRoot: string, sessionID: string, route: string, host: string): Promise<void>;
36
+ /**
37
+ * True when the evidence ledger holds a goal for this session that is complete and was last written after
38
+ * the native goal was created. A session's first ledger goal stays complete for good, so an older one must
39
+ * not close a later native goal.
40
+ */
41
+ export declare function ledgerGoalDone(projectRoot: string, sessionID: string, since?: string): Promise<boolean>;
42
+ /** Whether the ledger already holds an event of this type for the session (used to say a thing once). */
43
+ export declare function nativeGoalRecorded(projectRoot: string, sessionID: string, type: NativeGoalLedgerEvent["type"], route?: string): Promise<boolean>;
@@ -0,0 +1,74 @@
1
+ // The native-goal records in LitOpenCode's own ledger (`.litopencode/litgoal`). OpenCode's goal has no
2
+ // owner field, so the ledger is where LitOpenCode remembers which goal id it created, how often it
3
+ // resumed it, and that a host had no `/goal`. Every write is best-effort: a ledger failure never blocks
4
+ // the work, and a read failure reads as "owns nothing", which only makes LitOpenCode do less.
5
+ import { createLitGoalOperations, readEvidenceLedger } from "./ledger.js";
6
+ export async function recordNativeGoal(projectRoot, sessionID, route, event) {
7
+ try {
8
+ const operations = createLitGoalOperations(projectRoot);
9
+ await operations.init();
10
+ await operations.append({ ...event, sessionID, ...(route === undefined ? {} : { route }), timestamp: new Date().toISOString() });
11
+ return true;
12
+ }
13
+ catch { // no-excuse-ok: catch -- a ledger failure must never block the user's work.
14
+ return false;
15
+ }
16
+ }
17
+ /** The goal id LitOpenCode created for this session and has not cleared, with its resume count. */
18
+ export async function nativeGoalOwnership(projectRoot, sessionID) {
19
+ let goalID;
20
+ let createdAt;
21
+ let resumes = 0;
22
+ try {
23
+ for (const event of await createLitGoalOperations(projectRoot).read()) {
24
+ if (event["sessionID"] !== sessionID || typeof event["goalID"] !== "string")
25
+ continue;
26
+ if (event.type === "native-goal.created") {
27
+ goalID = event["goalID"];
28
+ createdAt = typeof event["timestamp"] === "string" ? event["timestamp"] : undefined;
29
+ resumes = 0;
30
+ }
31
+ else if (event.type === "native-goal.cleared" && event["goalID"] === goalID) {
32
+ goalID = undefined;
33
+ createdAt = undefined;
34
+ }
35
+ else if (event.type === "native-goal.resumed" && event["goalID"] === goalID) {
36
+ resumes += 1;
37
+ }
38
+ }
39
+ }
40
+ catch { // no-excuse-ok: catch -- an unreadable ledger reads as "owns nothing", so LitOpenCode touches nothing.
41
+ return { goalID: undefined, resumes: 0, createdAt: undefined };
42
+ }
43
+ return { goalID, resumes, createdAt };
44
+ }
45
+ /** OpenCode 1 and older OpenCode 2 hosts have no `/goal`: say so once per session and route. */
46
+ export async function recordNativeGoalUnavailable(projectRoot, sessionID, route, host) {
47
+ if (await nativeGoalRecorded(projectRoot, sessionID, "native-goal.unavailable", route))
48
+ return;
49
+ await recordNativeGoal(projectRoot, sessionID, route, { type: "native-goal.unavailable", host });
50
+ }
51
+ /**
52
+ * True when the evidence ledger holds a goal for this session that is complete and was last written after
53
+ * the native goal was created. A session's first ledger goal stays complete for good, so an older one must
54
+ * not close a later native goal.
55
+ */
56
+ export async function ledgerGoalDone(projectRoot, sessionID, since) {
57
+ try {
58
+ const state = await readEvidenceLedger(projectRoot);
59
+ const floor = since === undefined ? Number.NaN : Date.parse(since);
60
+ return state.goals.some((goal) => goal.sessionId === sessionID && goal.status === "complete" && (Number.isNaN(floor) || Date.parse(goal.updatedAt) >= floor));
61
+ }
62
+ catch { // no-excuse-ok: catch -- an unreadable evidence ledger is not done.
63
+ return false;
64
+ }
65
+ }
66
+ /** Whether the ledger already holds an event of this type for the session (used to say a thing once). */
67
+ export async function nativeGoalRecorded(projectRoot, sessionID, type, route) {
68
+ try {
69
+ return (await createLitGoalOperations(projectRoot).read()).some((event) => event.type === type && event["sessionID"] === sessionID && (route === undefined || event["route"] === route));
70
+ }
71
+ catch { // no-excuse-ok: catch -- an unreadable ledger cannot prove the record exists.
72
+ return false;
73
+ }
74
+ }
@@ -36,6 +36,7 @@ export declare function pluginOwnedCommandIds(roots: readonly string[]): readonl
36
36
  export type CommandMark = {
37
37
  readonly command: string;
38
38
  readonly banner: string;
39
+ readonly work?: string;
39
40
  };
40
41
  export declare function createV2Commands(roots: readonly string[], session: CommandSessionLike): {
41
42
  definitions: CommandDefinitionLike[];
@@ -29,8 +29,8 @@ export function createV2Commands(roots, session) {
29
29
  async execute(input) {
30
30
  const nonce = randomUUID();
31
31
  const mark = { command: command.id, banner: command.banner };
32
- pending.set(input.sessionID, { nonce, mark });
33
32
  const argumentsText = input.prompt.text.trim();
33
+ pending.set(input.sessionID, { nonce, mark, work: argumentsText });
34
34
  if (command.agent !== undefined)
35
35
  await session.switchAgent({ sessionID: input.sessionID, agent: command.agent });
36
36
  await session.prompt({
@@ -52,7 +52,7 @@ export function createV2Commands(roots, session) {
52
52
  if (expected === undefined || marker.nonce !== expected.nonce)
53
53
  return undefined;
54
54
  pending.delete(sessionID);
55
- return expected.mark;
55
+ return { ...expected.mark, work: expected.work };
56
56
  }
57
57
  return {
58
58
  definitions,
@@ -3,6 +3,7 @@ import type { AgentEditorLike, ModelEditorLike, PermissionEvaluationLike, Sessio
3
3
  import type { CommandEditorLike, CommandSessionLike } from "./commands.ts";
4
4
  import type { EventDomainLike, EventModelLike, EventSessionLike } from "./events.ts";
5
5
  import type { ToolAfterEvent, ToolBeforeEvent } from "./hooks.ts";
6
+ import type { NativeGoalHostLike } from "./native-goal-runtime.ts";
6
7
  import type { ContextEvent, PromptEvent } from "./session-hooks.ts";
7
8
  import type { ToolEditorLike } from "./tools.ts";
8
9
  export type Registration = {
@@ -28,6 +29,9 @@ type SessionHook = {
28
29
  (name: "prompt", callback: Callback<PromptEvent>): Promise<Registration>;
29
30
  (name: "context", callback: Callback<ContextEvent>): Promise<Registration>;
30
31
  (name: "compaction", callback: Callback<ContextEvent>): Promise<Registration>;
32
+ (name: "experimental.idle", callback: Callback<{
33
+ readonly sessionID: string;
34
+ }>): Promise<Registration>;
31
35
  };
32
36
  export type SessionDomainLike = {
33
37
  readonly get: (input: {
@@ -49,7 +53,8 @@ export type ServerContextLike = {
49
53
  readonly command: {
50
54
  readonly transform: Transform<CommandEditorLike>;
51
55
  };
52
- readonly session: SessionDomainLike & SessionModelDomainLike & CommandSessionLike & EventSessionLike & {
56
+ readonly rpc?: NativeGoalHostLike["rpc"];
57
+ readonly session: SessionDomainLike & SessionModelDomainLike & CommandSessionLike & EventSessionLike & NativeGoalHostLike["session"] & {
53
58
  readonly hook: SessionHook;
54
59
  };
55
60
  readonly event: EventDomainLike;
@@ -0,0 +1,74 @@
1
+ import { type NativeGoalRoute } from "./native-goal.ts";
2
+ /** The `opencode.goal` RPC contract as JSON Schema, so the package needs no runtime import of the host's schema library. */
3
+ declare const goalDefinition: Readonly<{
4
+ id: "opencode.goal";
5
+ methods: Readonly<{
6
+ get: Readonly<{
7
+ input: Readonly<{
8
+ type: "object";
9
+ properties: Readonly<{
10
+ sessionID: Readonly<{
11
+ type: "string";
12
+ }>;
13
+ }>;
14
+ required: readonly string[];
15
+ }>;
16
+ output: Readonly<{}>;
17
+ }>;
18
+ }>;
19
+ events: Readonly<{
20
+ changed: Readonly<{
21
+ schema: Readonly<{
22
+ type: "object";
23
+ properties: Readonly<{
24
+ sessionID: Readonly<{
25
+ type: "string";
26
+ }>;
27
+ }>;
28
+ required: readonly string[];
29
+ }>;
30
+ }>;
31
+ }>;
32
+ }>;
33
+ export type GoalRpcClientLike = {
34
+ readonly get: (input: {
35
+ readonly sessionID: string;
36
+ }) => Promise<unknown>;
37
+ };
38
+ export type NativeGoalHostLike = {
39
+ /** `context.rpc`; absent on a host that has no RPC domain. */
40
+ readonly rpc?: (definition: typeof goalDefinition) => GoalRpcClientLike;
41
+ readonly session: {
42
+ readonly command: (input: {
43
+ readonly sessionID: string;
44
+ readonly name: string;
45
+ readonly text: string;
46
+ readonly delivery?: "steer" | "queue";
47
+ }) => Promise<unknown>;
48
+ readonly synthetic: (input: {
49
+ readonly sessionID: string;
50
+ readonly text: string;
51
+ readonly description?: string;
52
+ readonly metadata?: Readonly<Record<string, string>>;
53
+ readonly resume?: boolean;
54
+ }) => Promise<unknown>;
55
+ };
56
+ };
57
+ /** A prompt the host expanded from an installed command alias file arrives as the command body, unmarked. */
58
+ export declare function aliasGoalRoute(input: string): {
59
+ readonly route: NativeGoalRoute;
60
+ readonly work: string;
61
+ } | undefined;
62
+ export declare function createNativeGoal(options: {
63
+ readonly projectRoot: string;
64
+ readonly host: NativeGoalHostLike;
65
+ }): {
66
+ /** Starts the goal step for a qualifying activation without holding up the user's prompt. */
67
+ activate(sessionID: string, route: NativeGoalRoute, work: string): Promise<void>;
68
+ /** The session finished its model work: clear a finished owned goal or report a conflict. */
69
+ idle(sessionID: string): Promise<void>;
70
+ /** Resolves when every started goal step has finished. */
71
+ settled(): Promise<void>;
72
+ };
73
+ export type NativeGoal = ReturnType<typeof createNativeGoal>;
74
+ export {};