@litfamily/litopencode 1.0.33 → 1.0.35

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.
@@ -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
  }
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.33"><title>release: 1.0.33</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.33</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.35"><title>release: 1.0.35</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.35</text></g></svg>
@@ -53,6 +53,32 @@ npm exec --package @litfamily/litopencode@latest -- litopencode doctor
53
53
  그대로 둡니다. 기본 OpenCode 설정 파일은 `~/.config/opencode/opencode.jsonc`입니다.
54
54
  `XDG_CONFIG_HOME`이 설정되어 있으면 `$XDG_CONFIG_HOME/opencode/opencode.jsonc`를 사용합니다.
55
55
 
56
+ ### 전역 AGENTS.md 블록
57
+
58
+ OpenCode의 Rules 문서는 `~/.config/opencode/AGENTS.md`를 전역 규칙 파일로 적고, OpenCode는 그 폴더를
59
+ `$XDG_CONFIG_HOME`에서 만듭니다(`OPENCODE_CONFIG_DIR`은 위치를 바꾸지 않으며, `opencode debug paths`로 확인할 수
60
+ 있습니다). 설치 도구는 다른 모든 곳에서 쓰는 것과 같은 root의 `<root>/AGENTS.md`에 쓰므로 `--root`와
61
+ `XDG_CONFIG_HOME`이 그대로 따라갑니다. `<!-- litfamily:litopencode:start v=<version> -->`와
62
+ `<!-- litfamily:litopencode:end -->` 사이의 줄만 씁니다. 시작 표시에는 블록 앞에 덧붙인 내용도 기록해 두므로,
63
+ 블록을 빼면 LF든 CRLF든 원래 바이트로 돌아갑니다. 쓰기는 임시 파일과 rename으로 하고, 두 번째 설치는 아무것도
64
+ 바꾸지 않으며 새 백업도 만들지 않습니다.
65
+
66
+ | 명령 | 효과 |
67
+ | --- | --- |
68
+ | `install` (대화형) | 한 번 묻고 기본값은 예입니다. 아니오를 고르면 `litopencode.json`에 `"agentFile": "off"`를 저장합니다. |
69
+ | `install --yes` | `agentFile`이 `off`가 아니면 블록을 넣거나 갱신합니다. |
70
+ | `install --no-agent-file` | 이번 실행에서만 파일을 그대로 두고 아무것도 저장하지 않습니다. |
71
+ | `install` (터미널 없음, `--yes` 없음) | 블록을 새로 넣지 않습니다. 이미 있는 이전 블록은 제자리에서 갱신합니다. |
72
+ | `agent-file on` / `off` | 설정을 저장하고 블록을 바로 넣거나 뺍니다. |
73
+ | `agent-file remove` | 블록을 빼고, 설치가 만든 파일이면 파일도 지웁니다. 저장된 설정은 그대로입니다. |
74
+ | `agent-file project [--dir <path>]` | 해당 폴더의 `AGENTS.md`에 프로젝트 블록을 쓰며 백업 복사본은 만들지 않습니다. |
75
+ | `doctor` | `agentFile.state`는 `present v=<x>`, `stale v=<x>`, `absent`, `off`, `malformed`, `symlink`, `not-file`, `not-utf8` 중 하나입니다. |
76
+
77
+ `LITOPENCODE_AGENT_FILE=on|off`는 저장된 설정보다 우선합니다. 표시 쌍이 깨졌거나, 심볼릭 링크이거나, 폴더이거나,
78
+ UTF-8이 아닌 파일은 `refused-*`로 알리고 그대로 둡니다. `<root>/AGENTS.md`가 없고 `~/.claude/CLAUDE.md`가 있으면
79
+ OpenCode는 자기 전역 파일이 없을 때만 그 Claude 파일을 읽으므로 아무것도 만들지 않습니다
80
+ (`skipped-claude-fallback`). `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1`이면 이 보호는 풀립니다.
81
+
56
82
  *OpenCode의 이벤트가 플러그인으로 들어오면 TypeScript runtime이 route와 권한 경계를 적용하고, tool guard와 durable ledger로 결과를 남깁니다. 아래 흐름이 설치 후 실제로 연결되는 host surface입니다.*
57
83
 
58
84
  ```mermaid
@@ -263,6 +289,7 @@ flowchart LR
263
289
  npm uninstall -g @litfamily/litopencode
264
290
  ```
265
291
 
292
+ 설치가 전역 `AGENTS.md`에 LitFamily 블록을 넣었다면 그 전에 `litopencode agent-file remove`를 실행하세요.
266
293
  그 다음 OpenCode가 관리하는 `opencode.jsonc`(또는 custom root의 `opencode.json`)의
267
294
  `plugin` 배열에서 `@litfamily/litopencode` 또는 `@litfamily/litopencode@<version>` 항목을 제거합니다.
268
295
  더 이상 route가 필요하지 않을 때만 `~/.config/opencode/litopencode.json`을 제거하세요.
package/docs/reference.md CHANGED
@@ -54,6 +54,32 @@ routes remain user-owned. The default OpenCode config is
54
54
  `~/.config/opencode/opencode.jsonc`, or `$XDG_CONFIG_HOME/opencode/opencode.jsonc`
55
55
  when `XDG_CONFIG_HOME` is set.
56
56
 
57
+ ### Global AGENTS.md block
58
+
59
+ OpenCode's Rules page names `~/.config/opencode/AGENTS.md` as the global rules file, and OpenCode builds that
60
+ folder from `$XDG_CONFIG_HOME` (`OPENCODE_CONFIG_DIR` does not move it; `opencode debug paths` prints it). The
61
+ installer writes to `<root>/AGENTS.md` for the same root it uses everywhere else, so `--root` and
62
+ `XDG_CONFIG_HOME` carry over. Only the lines between `<!-- litfamily:litopencode:start v=<version> -->` and
63
+ `<!-- litfamily:litopencode:end -->` are written. The start marker also records what was added in front of the
64
+ block, so removing it restores the original bytes under LF and CRLF alike. Writes use a temporary file and a
65
+ rename; a second install changes nothing and makes no new backup.
66
+
67
+ | Command | Effect |
68
+ | --- | --- |
69
+ | `install` (interactive) | Asks once, default yes; No stores `"agentFile": "off"` in `litopencode.json`. |
70
+ | `install --yes` | Adds or refreshes the block, unless `agentFile` is `off`. |
71
+ | `install --no-agent-file` | Leaves the file alone for that run and stores nothing. |
72
+ | `install` (no terminal, no `--yes`) | Never adds a block; an older block is refreshed in place. |
73
+ | `agent-file on` / `off` | Stores the setting and adds or removes the block at once. |
74
+ | `agent-file remove` | Removes the block and, when install created the file, the file; the stored setting is untouched. |
75
+ | `agent-file project [--dir <path>]` | Writes the project block into that folder's `AGENTS.md`, with no backup copy. |
76
+ | `doctor` | `agentFile.state` is `present v=<x>`, `stale v=<x>`, `absent`, `off`, `malformed`, `symlink`, `not-file` or `not-utf8`. |
77
+
78
+ `LITOPENCODE_AGENT_FILE=on|off` overrides the stored setting. A malformed marker pair, a symlink, a folder or a
79
+ file that is not UTF-8 is reported as `refused-*` and left untouched. When `<root>/AGENTS.md` does not exist and
80
+ `~/.claude/CLAUDE.md` does, nothing is created (`skipped-claude-fallback`), because OpenCode reads that Claude file
81
+ only while its own global file is missing; `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1` lifts the guard.
82
+
57
83
  *OpenCode provides the event surfaces; LitOpenCode turns them into bounded routes and guards, then records the durable ledger. This is the host connection behind the commands above.*
58
84
 
59
85
  ```mermaid
@@ -370,7 +396,8 @@ There is no `uninstall` subcommand. For a global install, remove the npm binary
370
396
  npm uninstall -g @litfamily/litopencode
371
397
  ```
372
398
 
373
- Then remove the `@litfamily/litopencode` or `@litfamily/litopencode@<version>` entry from the `plugin` array in
399
+ Run `litopencode agent-file remove` before that if install added the LitFamily block to the global
400
+ `AGENTS.md`. Then remove the `@litfamily/litopencode` or `@litfamily/litopencode@<version>` entry from the `plugin` array in
374
401
  OpenCode's managed `opencode.jsonc` (or the custom root's `opencode.json`). Remove
375
402
  `~/.config/opencode/litopencode.json` only when you no longer need its user-owned routes.
376
403
  Project `.litopencode/` state is separate; preserve it for later resume or remove it only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litopencode",
3
- "version": "1.0.33",
3
+ "version": "1.0.35",
4
4
  "description": "Workflow plugin for OpenCode: add lit to a prompt and the work is planned, built, checked and written down so the next session can pick it up.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -57,7 +57,7 @@ Use the OpenCode vocabulary for this contract: `chat.message`, `command.execute.
57
57
  | `session_id` | The id of the session that typed the request. The route prompt states it; pass it as `--session`. Without it the brief still prints but no `go` step is armed. |
58
58
  | `scope` | This worktree and the other worktrees of the same git repository. `--root <dir>` points elsewhere; `--all-dirs` is not used unless the user asks. |
59
59
  | `store` | The OpenCode session database (`opencode.db`), opened read-only, else the older JSON session store, else nothing. A missing store is said in words. |
60
- | `local_state` | `plans/*.md` and `.litopencode/plans/*.md` checklists, the active goal, the bounded start-work run, `.litopencode/workflow-stack/<session>.json`, `.litopencode/liveness/<session>.json`, `HANDOFF*.md` headings and the first item under their `Next` heading, and `git log`. These small records are read before the session store, so a large store cannot use up the read budget first. |
60
+ | `local_state` | `plans/*.md` and `.litopencode/plans/*.md` checklists, the active goal, the bounded start-work run, `.litopencode/workflow-stack/<session>.json`, `.litopencode/liveness/<session>.json`, `HANDOFF*.md` headings and the first open item under their `Next` heading (newest file first; an item marked ✅, ✓, `[x]`, `~~struck~~`, `DONE` or `SHIPPED` is skipped), and `git log`. Under `HERDR_ENV=1` it also runs `herdr agent list` once (3 s timeout; a failure reads `unknown` and counts nothing). These small records are read before the session store, so a large store cannot use up the read budget first. |
61
61
 
62
62
  ## #contract.mode_matrix
63
63
 
@@ -169,14 +169,14 @@ The collector draws `[▉▉▉░░░░░░░] 3/10` only when both numbe
169
169
 
170
170
  ### The recommended step
171
171
 
172
- One step, chosen in this order: something waiting on the user, a blocked session, a paused discipline, another running session (with what it is doing now), the running start-work run, the single open plan, the first item under this worktree's `HANDOFF.md` `Next` heading (it never runs on `go`), the newest unfinished session, an open plan in another worktree, the most recent session that stopped. The box always names a real step from the records; only an empty window asks what to start. If the step belongs to this session and is one of continue, keep working, or start the plan, the collector writes the next-step record and `go` follows it. If it belongs to another session or worktree, the box says where to act and `go` here only repeats that pointer; it never starts the step.
172
+ One step, chosen in this order: something waiting on the user, a blocked session, another session's paused discipline, lanes at work (other running sessions of this worktree family, and under `HERDR_ENV=1` herdr agents working or blocked in this herdr workspace or inside the repo, never this pane; a lane seen both ways counts once), this session's own paused discipline, this session's running start-work run, another session's running start-work run (followed from that session), the single open plan, the first item under this worktree's `HANDOFF.md` `Next` heading (it never runs on `go`), the newest unfinished session, an open plan in another worktree, the most recent session that stopped. The box always names a real step from the records; only an empty window asks what to start. If the step belongs to this session and is one of continue, keep working, or start the plan, the collector writes the next-step record and `go` follows it. If it belongs to another session or worktree, the box says where to act and `go` here only repeats that pointer; it never starts the step. Lanes are counted before anything that resumes work here: while they work, this session's own paused discipline and its own run are held back, and an approval stays the one step offered. The box says to wait for them (or to answer the one waiting on the user), nothing is armed, and the closing line is ``Do not type `go` until they finish.``
173
173
 
174
174
  ## Following `go`
175
175
 
176
176
  `go` is live only while the next-step record for this session is younger than 30 minutes. It is read once and removed.
177
177
 
178
178
  - A paused discipline: the same return as `lit continue`, at the same stage and count.
179
- - The running start-work run: a note to keep going from the checked plan; nothing restarts.
179
+ - The running start-work run: a note to keep going from the checked plan; nothing restarts. `go` first re-checks that the run is still active and still this session's; if not, the reply says the step no longer holds and starts nothing.
180
180
  - A single open plan: the typed start-work path, which opens a bounded run with read, write and execute under this workspace and nothing more.
181
181
 
182
182
  A pending boundary is never answered by `go`. The user types `lit approve` to grant exactly that boundary, or `lit deny`. In LitOpenCode `lit deny` ends the run, because the run lifecycle has no refused-and-still-paused state; the reply says so and says how to start it again (`start-work`, then answer the boundary differently). Forbidden actions (commit, push, publish, release, version-bump, host-config, destructive) can never become a pending boundary, so `lit approve` cannot grant them.
@@ -17,7 +17,7 @@ The brief is Markdown, so OpenCode's chat draws it with the theme's colours.
17
17
  3. Ended sessions (done, idle) under `### ⚪ Ended · <n>` as one table, newest first, at most eight rows and an italic count of the rest. Columns: id, where (cut to 20 cells), goal (cut to 44 cells), ago (the largest unit only: `5d`, `14h`, `20m`), done (`finished`, a file count, or `·`).
18
18
  4. `### 🗺️ Roadmap`: per worktree with something to show, up to three recent commits with their month-day (`✓`), the plan step in hand (`▶ **now**`), up to two steps after it (`○ **later**`); another worktree's lines carry its name in inline code.
19
19
  5. The box, `Recommended next step`, in a code block with no language tag, so its frame keeps its columns and the chat draws it as plain text: the step wrapped at word breaks to at most three lines, then `type: go` when a step is armed.
20
- 6. The closing line: ``Type **`go`** to do it.`` when a step is armed; otherwise `Nothing runs on its own here: open that session, or say what to do.`
20
+ 6. The closing line: ``Type **`go`** to do it.`` when a step is armed; ``Do not type `go` until they finish.`` while lanes work; otherwise `Nothing runs on its own here: open that session, or say what to do.`
21
21
  7. When the read budget ran out before every plan was read, an italic line: `<n> plans were not read (read budget used up).` Plans and the other small records are read before the session store, so this happens only with very large plan folders.
22
22
 
23
23
  ## Plans the roadmap leaves out
@@ -31,4 +31,4 @@ A `done` card never names a next step: what it left open is history. An `alive`
31
31
 
32
32
  ## Recommended-step precedence
33
33
 
34
- Waiting, blocked, paused, another session still working, the running start-work run (this session only), the single open plan, this worktree's `HANDOFF.md` `Next` item, the newest idle session with a known next step. The next-step record is written only for `continue`, `work` and `plan`, and only when the step belongs to the asking session; a `HANDOFF.md` item writes no record at all.
34
+ Waiting, blocked, another session's paused discipline, lanes at work (other working sessions plus herdr agents under `HERDR_ENV=1`, counted once each; no record), this session's own paused discipline, the running start-work run (this session only), another session's running run (a pointer there), the single open plan, this worktree's newest `HANDOFF*.md` first open `Next` item, the newest idle session with a known next step. The next-step record is written only for `continue`, `work` and `plan`, and only when the step belongs to the asking session; a `HANDOFF.md` item writes no record at all.
@@ -265,7 +265,10 @@ function goalOf(raw) {
265
265
  };
266
266
  }
267
267
 
268
- // The first list item or line of text under a `Next` or `Next Steps` heading, without its marker.
268
+ // An item someone already marked finished; the words are matched case-sensitively, so "done" in prose stays open.
269
+ const HANDOFF_DONE = /✅|✓|^\[[xX]\]|~~.+~~|\b(?:DONE|SHIPPED)\b/u;
270
+
271
+ // The first open list item or line of text under a `Next` or `Next Steps` heading, without its marker.
269
272
  function handoffNextOf(text) {
270
273
  const lines = text.split(/\r?\n/u);
271
274
  const start = lines.findIndex((line) => /^#{1,6}\s+Next(?:\s+Steps)?\s*$/iu.test(line.trim()));
@@ -273,7 +276,7 @@ function handoffNextOf(text) {
273
276
  for (const line of lines.slice(start + 1)) {
274
277
  if (/^#{1,6}\s/u.test(line.trim())) return undefined;
275
278
  const item = line.trim().replace(/^(?:[-*+]|\d+[.)])\s+/u, "");
276
- if (item !== "") return item;
279
+ if (item !== "" && !HANDOFF_DONE.test(item)) return item;
277
280
  }
278
281
  return undefined;
279
282
  }
@@ -324,7 +327,7 @@ export function readLocalState(dir) {
324
327
  const record = readJson(join(livenessDir, name), 1024);
325
328
  const atMs = isRecord(record) ? Date.parse(str(record.at)) : Number.NaN;
326
329
  if (isRecord(record) && SAFE_SESSION.test(str(record.sessionId)) && ["alive", "idle", "ended"].includes(record.state) && Number.isFinite(atMs)) {
327
- state.liveness.set(record.sessionId, { state: record.state, atMs });
330
+ state.liveness.set(record.sessionId, { state: record.state, atMs, pane: typeof record.herdrPane === "string" ? clean(record.herdrPane, 32) : undefined });
328
331
  }
329
332
  }
330
333
  } catch {
@@ -339,8 +342,17 @@ export function readLocalState(dir) {
339
342
  } catch {
340
343
  // no stacks recorded for this worktree
341
344
  }
345
+ // The newest handoff file first: a lane's fresh HANDOFF beats an old root one.
342
346
  try {
343
- for (const name of readdirSync(dir).filter((entry) => /^HANDOFF.*\.md$/u.test(entry)).slice(0, 3)) {
347
+ const changed = (name) => {
348
+ try {
349
+ return lstatSync(join(dir, name)).mtimeMs;
350
+ } catch {
351
+ return 0;
352
+ }
353
+ };
354
+ const names = readdirSync(dir).filter((entry) => /^HANDOFF.*\.md$/u.test(entry)).slice(0, 40);
355
+ for (const name of names.map((entry) => [entry, changed(entry)]).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([entry]) => entry)) {
344
356
  const text = readSmall(join(dir, name), FILE_CAP);
345
357
  if (text === undefined) continue;
346
358
  state.handoff.push(...[...text.matchAll(/^#{1,3}\s+(.+)$/gmu)].slice(0, 4).map((match) => clean(match[1], 80)));
@@ -607,6 +619,7 @@ export function buildCard({ row, detail, local, lanes, now, self = false }) {
607
619
  },
608
620
  updated: new Date(row.updated).toISOString(),
609
621
  boundary: local.work?.sessionID === row.id && local.work.boundary ? clean(local.work.boundary, 80) : undefined,
622
+ ...(local.liveness?.get(row.id)?.pane ? { pane: local.liveness.get(row.id).pane } : {}),
610
623
  };
611
624
  }
612
625
 
@@ -622,7 +635,37 @@ function roadmapOf(local) {
622
635
  return out;
623
636
  }
624
637
 
625
- export function recommend({ groups, sessionID }) {
638
+ /**
639
+ * Herdr agents at work beside this session, read only under HERDR_ENV=1: working or blocked, in this
640
+ * herdr workspace or with a cwd inside one of `dirs`, never the asking pane. A failed, slow or malformed
641
+ * `herdr agent list` reads "unknown" and counts no lane.
642
+ */
643
+ export function readHerdrLanes(dirs, env = process.env) {
644
+ if (env.HERDR_ENV !== "1") return { state: "off", agents: [] };
645
+ let doc;
646
+ try {
647
+ doc = JSON.parse(execFileSync("herdr", ["agent", "list"], { encoding: "utf8", timeout: 3000, env, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024 * 1024 }));
648
+ } catch {
649
+ return { state: "unknown", agents: [] };
650
+ }
651
+ const list = isRecord(doc) && isRecord(doc.result) && Array.isArray(doc.result.agents) ? doc.result.agents : undefined;
652
+ if (list === undefined) return { state: "unknown", agents: [] };
653
+ const inside = (cwd) => isAbsolute(str(cwd)) && dirs.some((dir) => {
654
+ const rel = relative(dir, resolve(str(cwd)));
655
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
656
+ });
657
+ const ownPane = str(env.HERDR_PANE_ID);
658
+ const workspace = str(env.HERDR_WORKSPACE_ID);
659
+ const agents = list
660
+ .filter((entry) => isRecord(entry) && (entry.agent_status === "working" || entry.agent_status === "blocked"))
661
+ .filter((entry) => ownPane === "" || str(entry.pane_id) !== ownPane)
662
+ .filter((entry) => (workspace !== "" && str(entry.workspace_id) === workspace) || inside(entry.cwd) || inside(entry.foreground_cwd))
663
+ .slice(0, 40)
664
+ .map((entry) => ({ pane: clean(str(entry.pane_id), 32), status: entry.agent_status, session: isRecord(entry.agent_session) ? str(entry.agent_session.value) : "", title: clean(str(entry.terminal_title_stripped), 60) }));
665
+ return { state: "read", agents };
666
+ }
667
+
668
+ export function recommend({ groups, sessionID, herdr = [] }) {
626
669
  const cards = groups.flatMap((group) => group.sessions.map((card) => ({ card, group })));
627
670
  const waiting = cards.find(({ card }) => card.status === "waits-on-you");
628
671
  if (waiting) {
@@ -631,19 +674,34 @@ export function recommend({ groups, sessionID }) {
631
674
  }
632
675
  const blocked = cards.find(({ card }) => card.status === "blocked");
633
676
  if (blocked) return { kind: "session", sessionID: blocked.card.id, text: `Open "${clip(blocked.card.goal, 50)}": its last step failed and nothing has run since.` };
634
- const paused = cards.find(({ card }) => card.status === "paused");
677
+ // Lanes: other sessions at work here, and herdr agents beside this one; one seen both ways counts once.
678
+ // They are counted before anything that resumes work here: while a lane runs, this session's own paused
679
+ // frame and its own run are held, and only an approval (above) stays the user's call.
680
+ const working = cards.filter(({ card }) => card.status === "working" && card.id !== sessionID);
681
+ const seen = new Set(working.flatMap(({ card }) => [card.id, card.pane].filter(Boolean)));
682
+ const agents = herdr.filter((agent) => agent.session === "" || (agent.session !== sessionID && !seen.has(agent.session))).filter((agent) => !seen.has(agent.pane));
683
+ const lanes = working.length + agents.length;
684
+ const paused = cards.find(({ card }) => card.status === "paused" && !(lanes > 0 && card.id === sessionID));
635
685
  if (paused) {
636
686
  return paused.card.id === sessionID
637
687
  ? { kind: "continue", sessionID, text: `Return to the paused discipline in "${clip(paused.card.goal, 50)}" where it stopped.` }
638
688
  : { kind: "session", sessionID: paused.card.id, text: `Open "${clip(paused.card.goal, 50)}" and say lit continue.` };
639
689
  }
640
- const working = cards.find(({ card }) => card.status === "working" && card.id !== sessionID);
641
- if (working) {
642
- const doing = working.card.facts.lastAction ? ` (now: ${working.card.facts.lastAction})` : "";
643
- return { kind: "session", sessionID: working.card.id, text: `"${clip(working.card.goal, 50)}" in ${working.group.worktree} is still running${doing}; check on it before starting more.` };
690
+ if (lanes > 0) {
691
+ const plural = `${lanes} lane${lanes === 1 ? "" : "s"}`;
692
+ const waiting = agents.find((agent) => agent.status === "blocked");
693
+ const first = working[0];
694
+ const doing = first?.card.facts.lastAction ? ` (now: ${first.card.facts.lastAction})` : "";
695
+ const what = first ? `"${clip(first.card.goal, 50)}" in ${first.group.worktree} is still running${doing}` : `${agents[0].title || "an agent"} in pane ${agents[0].pane}`;
696
+ const text = waiting
697
+ ? `A lane waits on you: answer the one waiting on you in pane ${waiting.pane}, then wait for ${plural} to finish before starting more.`
698
+ : `Wait for ${plural} to finish before starting more: ${what}.`;
699
+ return { kind: "lanes", sessionID: first?.card.id ?? sessionID, lanes, text, short: waiting ? `answer pane ${waiting.pane}, then wait for ${plural}` : `wait for ${plural} to finish` };
644
700
  }
645
701
  const run = groups.find((group) => group.work?.status === "active");
646
702
  if (sessionID !== undefined && run?.work.sessionID === sessionID) return { kind: "work", sessionID, text: "Keep the running start-work run going from its checked plan." };
703
+ // Another session's active run is followed from that session, never started again here.
704
+ if (run) return { kind: "session", sessionID: run.work.sessionID || undefined, text: `start-work on ${run.work.plan || "its plan"} is running in another session; open that session to follow it.` };
647
705
  const here = groups.find((group) => group.current);
648
706
  const open = here?.plans.filter((plan) => plan.done < plan.total) ?? [];
649
707
  if (open.length === 1) return { kind: "plan", sessionID, target: open[0].path, text: `Start work on ${open[0].path}: ${open[0].total - open[0].done} step${open[0].total - open[0].done === 1 ? "" : "s"} left.` };
@@ -701,7 +759,8 @@ export async function collect(options = {}) {
701
759
  const legacy = openLegacy(options.storage ?? join(dataHome, "opencode", "storage"));
702
760
  store = legacy.error ? { ...store, error: `${store.error}; ${legacy.error}` } : legacy;
703
761
  }
704
- const report = { schema: REWIND_SCHEMA, generatedAt: new Date(now).toISOString(), root: clean(root, 200), source: { store: store.kind ?? "none", ...(store.error ? { note: store.error } : {}) }, groups: [] };
762
+ const herdr = readHerdrLanes(dirs.length > 0 ? dirs : [root], options.env ?? process.env);
763
+ const report = { schema: REWIND_SCHEMA, generatedAt: new Date(now).toISOString(), root: clean(root, 200), source: { store: store.kind ?? "none", ...(store.error ? { note: store.error } : {}), ...(herdr.state === "off" ? {} : { herdr: herdr.state }) }, groups: [] };
705
764
  const rows = store.kind ? store.sessions(dirs, limit) : [];
706
765
  const lanes = store.kind ? store.lanes(rows.map((row) => row.id)) : new Map();
707
766
  for (const { entry, dir, local } of locals) {
@@ -728,7 +787,7 @@ export async function collect(options = {}) {
728
787
  });
729
788
  }
730
789
  report.plansSkipped = locals.reduce((total, { local }) => total + local.plansSkipped, 0);
731
- report.recommended = recommend({ groups: report.groups, sessionID: options.session });
790
+ report.recommended = recommend({ groups: report.groups, sessionID: options.session, herdr: herdr.agents });
732
791
  const all = report.groups.flatMap((group) => group.sessions);
733
792
  const waits = all.filter((card) => card.status === "waits-on-you").length;
734
793
  report.headline = all.length === 0
@@ -740,7 +799,7 @@ export async function collect(options = {}) {
740
799
  if (options.writeNext && forThisSession && (kind === "continue" || kind === "work" || kind === "plan")) {
741
800
  const record = { kind, target: report.recommended.target ?? kind, at: now, sessionID: options.session };
742
801
  if (writeNext(root, record)) report.nextRecord = { kind, target: record.target };
743
- } else if (options.writeNext && options.session !== undefined && kind !== "none" && kind !== "handoff") {
802
+ } else if (options.writeNext && options.session !== undefined && kind !== "none" && kind !== "handoff" && kind !== "lanes") {
744
803
  // The step belongs to another session or worktree: go here cannot run it, so it answers with where to go.
745
804
  const record = { kind: "elsewhere", target: clip(report.recommended.text, 300), at: now, sessionID: options.session };
746
805
  if (writeNext(root, record)) report.nextRecord = { kind: "elsewhere", target: record.target };
@@ -889,7 +948,7 @@ export function renderBrief(report, { ascii = false, width = 72, sessionID } = {
889
948
  for (const text of wrapCells(report.recommended.text, inner)) lines.push(`${glyph("│", "|")} ${padCells(text, inner)} ${glyph("│", "|")}`);
890
949
  if (runsHere) lines.push(`${glyph("│", "|")} ${padCells("type: go", inner)} ${glyph("│", "|")}`);
891
950
  lines.push(`${glyph("╰", "+")}${glyph("─", "-").repeat(inner + 2)}${glyph("╯", "+")}`, "```", "");
892
- lines.push(runsHere ? "Type **`go`** to do it." : report.nextRecord ? "Nothing runs on its own here: open that session, or say what to do (`go` says where)." : "Nothing runs on its own here: open that session, or say what to do.");
951
+ lines.push(runsHere ? "Type **`go`** to do it." : report.recommended.kind === "lanes" ? "Do not type `go` until they finish." : report.nextRecord ? "Nothing runs on its own here: open that session, or say what to do (`go` says where)." : "Nothing runs on its own here: open that session, or say what to do.");
893
952
  if (report.plansSkipped > 0) lines.push("", `*${report.plansSkipped} ${report.plansSkipped === 1 ? "plan was" : "plans were"} not read (read budget used up).*`);
894
953
  return lines.join("\n");
895
954
  }
@@ -197,6 +197,10 @@ Every lit-init run should try to prove itself wrong before completion. Pick at l
197
197
 
198
198
  If a claim cannot be verified quickly, mark it as uncertain or omit it. Future agents are better served by a small verified map than a large speculative one.
199
199
 
200
+ ## LitFamily Project Block
201
+
202
+ When the user has approved writing the root `AGENTS.md`, the short LitFamily block can go in with `litopencode agent-file project --dir <repo root>`. It writes only the lines between its own `litfamily:litopencode` markers at the end of the file, keeps every byte of the hand-written text, makes no backup copy next to tracked files, and reports `unchanged` on a repeat. A malformed marker pair or a symlinked `AGENTS.md` is refused and reported, never rewritten. Run `litopencode agent-file project --dir <repo root> --dry-run` first when the file already exists, and treat the block as one more thing the receipt lists.
203
+
200
204
  ## Completion Receipt
201
205
 
202
206
  The final receipt should list files created, files updated, directories intentionally skipped, key evidence sources, tests or checks run, and cleanup. If no files were written because the run was discovery-only, say so. If local handoff or ledger state influenced the map, state that it was cross-checked. If any temp files or exploration artifacts were created, remove them or identify them as ignored evidence. Do not commit the guidance unless the user explicitly asks for a commit.