@litfamily/litopencode 1.0.27 → 1.0.29

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 CHANGED
@@ -5,6 +5,22 @@ are tracked separately from this product history.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 1.0.29 - 2026-10-19
9
+
10
+ - The write guard now catches a paragraph that keeps defining a figure by negation. Two Korean definitional negations in one paragraph, list or table column, or three in any language, are refused, and reworded forms such as `~로 읽을 수 없음`, `~와는 다름`, `does not mean` or `must not be read as` count too; exactly two, unless both are Korean, draw a warning. The refusal carries the fix: say what the figure is first, then give the caveat once, for example "18,420 visits in total, including repeat visits". A limitation stated once, short table cells such as `해당 아님`, plain comparisons such as `~와 다르다`, code and quoted examples still pass. The lit-humanizer skill and its bundled rule teach the same habit.
11
+
12
+ - Editing a line that sits between two repeated pledges in a document no longer re-raises the pledge that was already there; adding another one is still refused.
13
+
14
+ - When OpenCode is started from a subfolder of the project, the write guard now resolves relative paths such as `../report.md` against that folder, so those deliverables are checked and internal folders such as `plans/` stay exempt.
15
+
16
+ - A flood of warnings can no longer push a refusal out of the guard's output: refusals are listed first and the list is capped.
17
+
18
+ ## 1.0.28 - 2026-10-18
19
+
20
+ - The README gets you started sooner and says who LitOpenCode is for: people who give OpenCode work that spans more than one sitting, such as a feature over a few sessions, a planned refactor, research they come back to, or a report or deck built from their notes. A short evening-and-morning sketch shows a session picked up the next day, and the requirements now name OpenCode 1.18 or 1.19, because OpenCode 2 loads plugins differently and is not supported yet. The images are unchanged.
21
+
22
+ - Leftover notes about the retired skill-hint feature are gone from the release checklist and the privacy page.
23
+
8
24
  ## 1.0.27 - 2026-10-18
9
25
 
10
26
  - When a startup update fails and the rollback leaves the old setup working, OpenCode no longer reruns the snapshot and npm install on every start. Startup leaves that same version alone for a day; an explicit install still runs it, and a newer release is tried at once. The receipt names the failed step, a timeout or a missing command, without command output. `litopencode doctor` now reports the update state, its cause, what the next start will run ("installed X, running Y") and a remedy for each cause, and says it cannot see inside a running OpenCode.
package/README-Ko-KR.md CHANGED
@@ -1,29 +1,29 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitOpenCode 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitOpenCode 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
2
2
 
3
3
  # LitOpenCode
4
4
 
5
5
  **LitOpenCode는 OpenCode용 워크플로 플러그인입니다. 프롬프트에 `lit`만 붙이면 계획하고, 만들고, 확인하고, 기록까지 남겨서 다음 세션이 어디서 멈췄는지 바로 압니다.**
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/badge-version.svg" alt="1.0.27" /></a>
8
+ <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/badge-version.svg" alt="1.0.29" /></a>
9
9
  <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://img.shields.io/npm/dm/@litfamily/litopencode?label=downloads&color=D7F75B" alt="월간 npm 다운로드 수" /></a>
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/badge-license.svg" alt="MIT 라이선스" /></a>
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/badge-license.svg" alt="MIT 라이선스" /></a>
11
11
  <a href="https://github.com/wjgoarxiv/litopencode"><img src="https://img.shields.io/github/stars/wjgoarxiv/litopencode?style=social" alt="GitHub 스타" /></a>
12
12
  </p>
13
13
 
14
- LitOpenCode는 목표와 계획, 확인한 내용, 다음 할 일을 프로젝트 안의 `.litopencode/litgoal/`에 남겨 두기 때문에, 긴 작업도 다음 날 멈춘 자리에서 이어 갈 수 있습니다.
14
+ LitOpenCode는 목표와 계획, 확인한 내용, 다음 할 일을 프로젝트 안의 `.litopencode/litgoal/`에 남겨 두기 때문에, 긴 작업도 다음 날 멈춘 자리에서 이어 갈 수 있습니다. 몇 세션에 걸쳐 만드는 기능, 계획을 세워 진행하는 리팩터링, 며칠씩 이어 가는 조사, 메모로 만드는 보고서나 발표자료처럼 한 번에 끝나지 않는 OpenCode 작업에 잘 맞습니다.
15
15
 
16
16
  **[전체 안내, 화면, 스킬 갤러리는 GitHub에서 →](https://github.com/wjgoarxiv/litopencode/blob/main/README-Ko-KR.md)**
17
17
 
18
18
  ## 빠르게 시작하기
19
19
 
20
- Node.js와 npm, 그리고 모델 제공자를 설정해 둔 OpenCode가 있으면 됩니다.
20
+ Node.js와 npm, 그리고 모델 제공자를 설정해 둔 OpenCode 1.18 또는 1.19가 있으면 됩니다. LitOpenCode는 OpenCode 1용입니다. 플러그인 로딩 방식이 달라진 OpenCode 2는 아직 지원하지 않습니다.
21
21
 
22
22
  ```sh
23
23
  npm exec --package @litfamily/litopencode@latest -- litopencode install
24
24
  ```
25
25
 
26
- 기본값으로 바로 설치하려면 `--yes`를, 아무것도 쓰지 않고 무엇이 바뀔지만 보려면 `--dry-run`을 붙이세요. OpenCode를 다시 시작하고 **Tab** 키로 **lit-loop**를 고른 뒤, 빈 폴더를 열고 이렇게 보냅니다.
26
+ 무엇이 바뀔지 먼저 보려면 `--dry-run`을, 기본값으로 질문 없이 설치하려면 `--yes`를 붙이세요. OpenCode를 다시 시작하고 **Tab** 키로 **lit-loop**를 고른 뒤, 빈 폴더를 열고 이렇게 보냅니다.
27
27
 
28
28
  ```text
29
29
  lit 외부 의존성 없이 index.html 하나로 할 일 목록을 만들고, 추가와 완료 표시를 확인한 뒤 다음 할 일을 남겨줘.
@@ -46,7 +46,7 @@ lit 외부 의존성 없이 index.html 하나로 할 일 목록을 만들고,
46
46
 
47
47
  ## 업데이트와 제거
48
48
 
49
- LitOpenCode는 대화형으로 시작할 때와 install·doctor가 성공한 뒤 새 버전을 확인합니다. 끄려면 `--no-auto-update`를 붙이거나 `LITOPENCODE_NO_AUTO_UPDATE=1`을 설정하세요. `npm exec --package @litfamily/litopencode@latest -- litopencode doctor`는 설치 상태를 확인할 뿐 아무것도 바꾸지 않습니다.
49
+ LitOpenCode는 대화형으로 시작할 때와 install·doctor가 성공한 뒤 새 버전을 확인합니다. 업데이트 시점을 직접 정하고 싶다면 `--no-auto-update`를 붙이거나 `LITOPENCODE_NO_AUTO_UPDATE=1`을 설정하세요. `npm exec --package @litfamily/litopencode@latest -- litopencode doctor`는 설치 상태를 확인할 뿐 아무것도 바꾸지 않습니다.
50
50
 
51
51
  제거하려면 OpenCode의 `opencode.jsonc`에서 `plugin` 배열의 `@litfamily/litopencode` 항목을 지우고 OpenCode를 다시 시작하세요. 전역으로 설치했다면 다음 명령도 실행합니다.
52
52
 
@@ -57,9 +57,9 @@ npm uninstall -g @litfamily/litopencode
57
57
  ## 더 보기
58
58
 
59
59
  - [GitHub의 사용 안내](https://github.com/wjgoarxiv/litopencode/blob/main/docs/guide-Ko-KR.md): 설치 옵션, 자동 인수인계, 문제 해결, 제거
60
- - [워크플로 상세 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/reference-Ko-KR.md) · [English reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/reference.md)
61
- - [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/CHANGELOG.md) · [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/privacy.md)
62
- - [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/LICENSE) · [ASCII 글꼴 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/JetBrainsMono-OFL.txt)
60
+ - [워크플로 상세 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/reference-Ko-KR.md) · [English reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/reference.md)
61
+ - [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/CHANGELOG.md) · [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/privacy.md)
62
+ - [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/LICENSE) · [ASCII 글꼴 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/JetBrainsMono-OFL.txt)
63
63
 
64
64
  <details>
65
65
  <summary>ASCII 로고 복사</summary>
package/README.md CHANGED
@@ -1,29 +1,29 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitOpenCode robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitOpenCode robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
2
2
 
3
3
  # LitOpenCode
4
4
 
5
5
  **LitOpenCode is a workflow plugin for OpenCode. Add `lit` to a prompt and the work gets planned, built, checked and written down, so the next session knows where you stopped.**
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/badge-version.svg" alt="1.0.27" /></a>
8
+ <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/badge-version.svg" alt="1.0.29" /></a>
9
9
  <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://img.shields.io/npm/dm/@litfamily/litopencode?label=downloads&color=D7F75B" alt="npm downloads per month" /></a>
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
11
11
  <a href="https://github.com/wjgoarxiv/litopencode"><img src="https://img.shields.io/github/stars/wjgoarxiv/litopencode?style=social" alt="GitHub stars" /></a>
12
12
  </p>
13
13
 
14
- LitOpenCode keeps the goal, the plan, what was checked and the next step in `.litopencode/litgoal/` inside your project, so a long task picks up the next day where it stopped.
14
+ LitOpenCode keeps the goal, the plan, what was checked and the next step in `.litopencode/litgoal/` inside your project, so a long task picks up the next day where it stopped. It is made for OpenCode work that spans more than one sitting: features over several sessions, planned refactors, research you return to, and reports or decks built from your notes.
15
15
 
16
16
  **[Full guide, screens and skills gallery on GitHub →](https://github.com/wjgoarxiv/litopencode#readme)**
17
17
 
18
18
  ## Quick start
19
19
 
20
- You need Node.js with npm, and OpenCode with a model provider set up.
20
+ You need Node.js with npm, and OpenCode 1.18 or 1.19 with a model provider set up. LitOpenCode is built for OpenCode 1. OpenCode 2 changed how plugins load, and LitOpenCode does not support it yet.
21
21
 
22
22
  ```sh
23
23
  npm exec --package @litfamily/litopencode@latest -- litopencode install
24
24
  ```
25
25
 
26
- Add `--yes` to take the defaults, or `--dry-run` to preview without writing anything. Restart OpenCode, press **Tab**, pick **lit-loop**, open an empty folder and send:
26
+ Add `--dry-run` to see what would change before anything is written, or `--yes` to take the defaults and skip the questions. Restart OpenCode, press **Tab**, pick **lit-loop**, open an empty folder and send:
27
27
 
28
28
  ```text
29
29
  lit Build a to-do list in one index.html with no dependencies; check adding and completing items, then leave results and next steps.
@@ -46,7 +46,7 @@ Each skill is pictured in the [skills gallery on GitHub](https://github.com/wjgo
46
46
 
47
47
  ## Updates and removal
48
48
 
49
- LitOpenCode checks for updates on interactive start and after a successful install or doctor run. To turn that off, pass `--no-auto-update` or set `LITOPENCODE_NO_AUTO_UPDATE=1`. `npm exec --package @litfamily/litopencode@latest -- litopencode doctor` checks the setup and changes nothing.
49
+ LitOpenCode checks for updates on interactive start and after a successful install or doctor run. If you'd rather choose when to update, pass `--no-auto-update` or set `LITOPENCODE_NO_AUTO_UPDATE=1`. `npm exec --package @litfamily/litopencode@latest -- litopencode doctor` checks the setup and changes nothing.
50
50
 
51
51
  To remove it, delete the `@litfamily/litopencode` entry from the `plugin` array in OpenCode's `opencode.jsonc` and restart OpenCode. If you installed it globally:
52
52
 
@@ -57,9 +57,9 @@ npm uninstall -g @litfamily/litopencode
57
57
  ## More
58
58
 
59
59
  - [Guide on GitHub](https://github.com/wjgoarxiv/litopencode/blob/main/docs/guide.md): install options, automatic handoff, troubleshooting and removal.
60
- - [Workflow reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/reference.md)
61
- - [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/CHANGELOG.md) · [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/privacy.md)
62
- - [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/LICENSE) · [ASCII font license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/JetBrainsMono-OFL.txt)
60
+ - [Workflow reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/reference.md)
61
+ - [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/CHANGELOG.md) · [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/privacy.md)
62
+ - [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/LICENSE) · [ASCII font license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/JetBrainsMono-OFL.txt)
63
63
 
64
64
  <details>
65
65
  <summary>Copy ASCII logo</summary>
@@ -255,6 +255,15 @@ function applyEdit(oldText, oldString, newString, replaceAll) {
255
255
  }
256
256
  return oldText.slice(0, index) + newString + oldText.slice(index + oldString.length);
257
257
  }
258
+ // The block path throws this text, so the model sees the positive-first rewrite in the refusal itself.
259
+ const negationAdvice = "Several negations sit in one place. Say what the thing is first, then add the caveat once. " +
260
+ 'Example: write "18,420 visits in total, including repeat visits" instead of "18,420 is not the number of residents".';
261
+ function warnMessage(count, file, negationCluster) {
262
+ const base = `LIT_HUMANIZER_WARN findings=${count} path=${displayPath(file)}`;
263
+ return negationCluster
264
+ ? `${base} rule=negation-cluster. ${negationAdvice}`
265
+ : `${base}. Review the new wording; keep any accurate, useful warning.`;
266
+ }
258
267
  function remember(pending, callID, notice) {
259
268
  if (pending.size >= maxPendingCalls)
260
269
  pending.delete(pending.keys().next().value);
@@ -303,9 +312,10 @@ export function createDeliverableHedgeGuard(options) {
303
312
  const block = findings.filter((finding) => finding.severity === "block");
304
313
  if (block.length > 0) {
305
314
  const first = block[0];
306
- throw new Error(`LIT_HUMANIZER_BLOCKED rule=${first.rule} line=${first.line} path=${displayPath(file)}. Revise the new text before saving.`);
315
+ const advice = block.some((finding) => finding.rule === "negation-cluster") ? ` ${negationAdvice}` : "";
316
+ throw new Error(`LIT_HUMANIZER_BLOCKED rule=${first.rule} line=${first.line} path=${displayPath(file)}. Revise the new text before saving.${advice}`);
307
317
  }
308
- return findings.length;
318
+ return findings;
309
319
  }
310
320
  // An apply_patch call can add several files; each deliverable-path target gets the same per-file scan as write
311
321
  // and edit. A chunk that cannot be located in the current file is scanned as appended text instead, so an added
@@ -316,6 +326,7 @@ export function createDeliverableHedgeGuard(options) {
316
326
  throw new Error("patch text exceeds scan limit");
317
327
  let warned = 0;
318
328
  let warnedFile = "";
329
+ let negationCluster = false;
319
330
  for (const entry of parsePatch(patchText)) {
320
331
  const target = entry.moveTo ?? entry.file;
321
332
  if (isInternalPath(target) || !readerFacingExtensions.has(path.extname(target).toLowerCase()))
@@ -332,15 +343,16 @@ export function createDeliverableHedgeGuard(options) {
332
343
  newText = `${oldText}\n${entry.added.join("\n")}`;
333
344
  }
334
345
  }
335
- const count = await scanTarget(target, oldText, newText);
336
- if (count > 0 && warned === 0)
346
+ const found = await scanTarget(target, oldText, newText);
347
+ if (found.length > 0 && warned === 0)
337
348
  warnedFile = target;
338
- warned += count;
349
+ warned += found.length;
350
+ negationCluster ||= found.some((finding) => finding.rule === "negation-cluster");
339
351
  }
340
352
  if (warned > 0) {
341
353
  remember(pending, callID, {
342
354
  tier: "warn",
343
- message: `LIT_HUMANIZER_WARN findings=${warned} path=${displayPath(warnedFile)}. Review the new wording; keep any accurate, useful warning.`
355
+ message: warnMessage(warned, warnedFile, negationCluster)
344
356
  });
345
357
  }
346
358
  }
@@ -377,11 +389,11 @@ export function createDeliverableHedgeGuard(options) {
377
389
  const newText = input.tool === "write"
378
390
  ? text.value ?? ""
379
391
  : applyEdit(oldText, input.args.oldString ?? input.args.old_string, text.value ?? "", input.args.replaceAll);
380
- const warned = await scanTarget(text.file, oldText, newText);
381
- if (warned > 0) {
392
+ const found = await scanTarget(text.file, oldText, newText);
393
+ if (found.length > 0) {
382
394
  remember(pending, input.callID, {
383
395
  tier: "warn",
384
- message: `LIT_HUMANIZER_WARN findings=${warned} path=${displayPath(text.file)}. Review the new wording; keep any accurate, useful warning.`
396
+ message: warnMessage(found.length, text.file, found.some((finding) => finding.rule === "negation-cluster"))
385
397
  });
386
398
  }
387
399
  }
package/dist/server.js CHANGED
@@ -54,7 +54,8 @@ export function createLitOpenCodePlugin(autoUpdateRunner = runPluginAutoUpdate)
54
54
  config: loaded.config.autoHandoff,
55
55
  ...(getSession === undefined ? {} : { getSession })
56
56
  });
57
- const deliverableHedgeGuard = createDeliverableHedgeGuard({ projectRoot: root });
57
+ // The host resolves a relative write or edit path against its launch directory, which can be a subfolder of the worktree.
58
+ const deliverableHedgeGuard = createDeliverableHedgeGuard({ projectRoot: input?.directory?.trim() || root });
58
59
  const recordSkillActivation = async (sessionID, skillId, appendedLit = false) => {
59
60
  showActivationToast(input?.client, skillId);
60
61
  ignitionState.activate(sessionID, skillId, !appendedLit);
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.27"><title>release: 1.0.27</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.27</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.29"><title>release: 1.0.29</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.29</text></g></svg>
package/docs/privacy.md CHANGED
@@ -14,7 +14,6 @@ providers, npm, and other tools have their own behavior and policies.
14
14
  | Project `.litopencode/litgoal/lit-loop/` | Goals, criteria, checkpoints, session identifiers, evidence references, a brief, ledger events, and bounded lifecycle state used to resume work. Evidence files can contain commands, output, or project material. |
15
15
  | Project `.litopencode/knowledge/` | Locally captured knowledge claims and review state. Legacy skill-learning files from earlier releases are left untouched and are no longer read. |
16
16
  | Project `.litopencode/logs/litopencode.log` | The logger's JSON-lines destination for timestamps, levels, and messages when explicitly enabled. The current server creates the logger with logging disabled by default. |
17
- | Project `.litopencode/logs/jev-skill-hint.jsonl` | No longer written. Earlier releases could leave this debug trace of the removed Jev skill hint; LitOpenCode never reads it, and you can delete it. |
18
17
  | OpenCode config root | Plugin registration, `litopencode.json` routes and choices, and installed command/skill files. The default is `~/.config/opencode`; `XDG_CONFIG_HOME` or a CLI `--root` changes the applicable root. |
19
18
  | Home `~/.litopencode/` | Update cache, locks, transaction journal and receipt, and retained recovery backups after an unsuccessful automatic update. Backups can contain OpenCode configuration and installed command/skill content. |
20
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litopencode",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
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",
@@ -9,4 +9,6 @@ Check only new or changed model text. Skip fenced and inline code, Markdown bloc
9
9
 
10
10
  Use footnotes or a reference list when citations are requested. Do not put a confidence ledger, verification inventory, or limitations checklist in a reader-facing deliverable. State a material risk once, plainly, in the chat reply. Keep internal evidence, plans, ledgers, handoffs, status, and machine-readable records detailed. Preserve a real limitation when removing it would change meaning or mislead the reader.
11
11
 
12
+ Lead with what a figure or thing is, then give a needed caveat once. Rewrite `18,420회는 주민 수가 아님` as "18,420회는 같은 사람의 반복 방문을 포함한 방문 횟수 합계입니다". The pre-write guard refuses a paragraph, list or table column that stacks two Korean definitional negations or three in any language, and reworded forms such as `~로 읽을 수 없음` or `does not mean` count too. Exactly two that are not both Korean draw a `negation-cluster` warning. Write bad examples in inline code or a blockquote.
13
+
12
14
  Review comments, commit messages, pull requests, changelogs, and README prose. Remove narration and needless one-use code wrappers only when behavior, trust-boundary checks, accessibility, and data integrity remain intact.
@@ -119,4 +119,8 @@ Scan only text being added or changed. Preserve pre-existing file content and us
119
119
 
120
120
  Block only high-confidence patterns such as label-only evidence lines, stacked disclaimers, and chatbot residue. Word choice, contrast, triads, and rhythm are warnings. A hit is an editing signal, not proof of model authorship. Stop when another change would alter meaning, hide a real limitation, or flatten the writer's voice.
121
121
 
122
+ ## Positive-first definitions
123
+
124
+ Say what a number or thing is first, then add the caveat once. `18,420회는 주민 수가 아님. 고유 방문자 수도 아님.` leaves the reader guessing; "18,420회는 같은 사람의 반복 방문을 포함한 방문 횟수 합계입니다" answers the question and carries the caveat inside the definition. The `negation-cluster` check counts definitional negations per paragraph, list block or Markdown table column: the plain forms (Korean `아닙니다`, `아님`, `아니다`, `아니며`, `아니라`, `아닌`; English `is not`, `are not`, `isn't`, `aren't`, `not a`, `rather than`) and the reworded forms that restate the same definition (`~로 읽을 수 없음`, `~로 볼 수 없음`, `~로 보지 않음`, `~을 뜻하지 않음`, `~를 의미하지 않음`, `~와는 다름`, `~로 읽으면 안 됨`; English `should not be read as`, `cannot be taken as`, `does not mean`). Two Korean negations in one block, or three in any language, are block tier: the pre-write hook refuses the write, and the refusal carries the rewrite advice with the 18,420 example. Exactly two that are not both Korean stay a warning, and the write goes through with an after-write notice. Swapping `아님` for `~로 읽을 수 없음`, `~와는 다르다` or `~로 읽으면 안 된다` therefore does not get past the guard; rewrite the block so it says what the figure is. An honest limitation stated once, such as "고유 이용자 지표는 자료에 없습니다", passes. Table cells of 12 characters or fewer, such as a `해당 아님` status, do not count, and code, quotes and internal paths stay exempt as above. Put any bad example you write in inline code or a blockquote so it stays exempt too. The check sits beside the rule table in `scripts/core.mjs` because it counts per block.
125
+
122
126
  For complete pattern lists, channel guidance, Korean metrics, and motion work, open the named reference rather than loading the entire corpus. Detector rules and examples are packaged with this skill; internal product records remain outside the reader-facing guard.
@@ -189,6 +189,11 @@ function ruleSegments(rows, rule, cache) {
189
189
  return segments;
190
190
  }
191
191
 
192
+ function lineTextAt(text, offset) {
193
+ const end = text.indexOf('\n', offset);
194
+ return text.slice(text.lastIndexOf('\n', offset - 1) + 1, end < 0 ? text.length : end).trim();
195
+ }
196
+
192
197
  export function scanText(text, rules, file = '<stdin>') {
193
198
  if (isInternalPath(file)) return [];
194
199
  const findings = [];
@@ -200,15 +205,90 @@ export function scanText(text, rules, file = '<stdin>') {
200
205
  rule.regex.lastIndex = 0;
201
206
  const match = rule.regex.exec(segment.text);
202
207
  if (!match) continue;
203
- findings.push({
208
+ const finding = {
204
209
  file,
205
210
  rule: rule.id,
206
211
  severity: rule.severity,
207
212
  line: lineAtOffset(segment, match.index),
208
213
  excerpt: segment.text.trim().replace(/\s+/g, ' ').slice(0, 180),
214
+ };
215
+ // A document-scope match runs from its first occurrence to its last; the lines in between are not part of it.
216
+ if (rule.scope === 'document') {
217
+ finding.occurrences = [lineTextAt(segment.text, match.index), lineTextAt(segment.text, match.index + match[0].length - 1)];
218
+ }
219
+ findings.push(finding);
220
+ }
221
+ }
222
+ return findings;
223
+ }
224
+
225
+ // negation-cluster sits beside the rule engine because it counts per block, not per line or sentence.
226
+ // A paragraph or list block (split on blank lines) is counted as a whole; a Markdown table is counted per column
227
+ // and ignores cells of 12 characters or fewer, where status values such as '해당 아님' live. Fenced code,
228
+ // blockquote lines and inline code were already blanked by sourceRows. Reworded forms that restate a definition
229
+ // count too, so swapping one negation for another does not pass. Two Korean negations, or three in any language,
230
+ // block; exactly two that are not both Korean warn.
231
+ const NEGATION_KO = /아닙니다|아님|아니다|아니며|아니라|아닌/gu;
232
+ const NEGATION_EN = /(?<![\p{L}\p{N}_])(?:is not|are not|isn't|aren't|not a|rather than)(?![\p{L}\p{N}_])/giu;
233
+ const NEGATION_KO_REWORD = /(?:로|으로) (?:읽을|볼|해석할|간주할) 수 없|(?:로|으로) 보지 않|(?:을|를) (?:뜻하지|의미하지|나타내지|가리키지) 않|(?:와|과)는 다(?:릅니다|르다|름|르며|르고|르므로)|(?:로|으로) (?:읽으면|보면|해석하면) 안 /gu;
234
+ const NEGATION_EN_REWORD = /(?<![\p{L}\p{N}_])(?:(?:should not|cannot|can't|must not) be (?:read|taken|interpreted|treated) as|(?:does|do) not (?:mean|represent|indicate))(?![\p{L}\p{N}_])/giu;
235
+ const NEGATION_BLOCK_KO = 2;
236
+ const NEGATION_BLOCK_TOTAL = 3;
237
+ const NEGATION_WARN_TOTAL = 2;
238
+
239
+ function matches(text, pattern) {
240
+ return text.match(pattern)?.length ?? 0;
241
+ }
242
+
243
+ function negationTier(text) {
244
+ const ko = matches(text, NEGATION_KO) + matches(text, NEGATION_KO_REWORD);
245
+ const total = ko + matches(text, NEGATION_EN) + matches(text, NEGATION_EN_REWORD);
246
+ if (ko >= NEGATION_BLOCK_KO || total >= NEGATION_BLOCK_TOTAL) return 'block';
247
+ return total >= NEGATION_WARN_TOTAL ? 'warn' : null;
248
+ }
249
+
250
+ function clusteredText(block) {
251
+ const lines = block.map((row) => row.candidate);
252
+ if (!lines.every((line) => /^\s*\|/.test(line))) {
253
+ const text = lines.join('\n');
254
+ const severity = negationTier(text);
255
+ return severity ? { text, severity } : null;
256
+ }
257
+ const cells = lines.filter((line) => !/^[\s|:-]+$/.test(line))
258
+ .map((line) => line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => cell.trim()));
259
+ const width = Math.max(...cells.map((row) => row.length));
260
+ let warned = null;
261
+ for (let column = 0; column < width; column += 1) {
262
+ const text = cells.map((row) => row[column] ?? '').filter((cell) => [...cell].length > 12).join('\n');
263
+ const severity = negationTier(text);
264
+ if (severity === 'block') return { text, severity };
265
+ if (severity === 'warn') warned ??= { text, severity };
266
+ }
267
+ return warned;
268
+ }
269
+
270
+ export function negationClusterFindings(text, file = '<stdin>') {
271
+ if (isInternalPath(file)) return [];
272
+ const findings = [];
273
+ let block = [];
274
+ const flush = () => {
275
+ const hit = block.length ? clusteredText(block) : null;
276
+ if (hit !== null) {
277
+ findings.push({
278
+ file,
279
+ rule: 'negation-cluster',
280
+ severity: hit.severity,
281
+ line: block[0].number,
282
+ excerpt: hit.text.trim().replace(/\s+/g, ' ').slice(0, 180),
209
283
  });
210
284
  }
285
+ block = [];
286
+ };
287
+ for (const row of sourceRows(text)) {
288
+ if (row.skipped || !row.candidate.trim()) flush();
289
+ else block.push(row);
211
290
  }
291
+ flush();
212
292
  return findings;
213
293
  }
214
294
 
@@ -3,7 +3,7 @@ import { spawnSync } from 'node:child_process';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { dirname, extname, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { exitCode, parseRules, scanText } from './core.mjs';
6
+ import { exitCode, negationClusterFindings, parseRules, scanText } from './core.mjs';
7
7
 
8
8
  const here = dirname(fileURLToPath(import.meta.url));
9
9
  const usage = 'Usage: node scripts/detect.mjs [--json] [--rules FILE] [--path LABEL] [FILE ...] (use - or omit FILE to read stdin)';
@@ -62,7 +62,8 @@ function main() {
62
62
  for (const file of files) {
63
63
  try {
64
64
  const label = file === '-' ? stdinLabel : file;
65
- const hits = scanText(contentFor(file), rules, label);
65
+ const content = contentFor(file);
66
+ const hits = [...scanText(content, rules, label), ...negationClusterFindings(content, label)];
66
67
  findings.push(...hits);
67
68
  scanned.push(file);
68
69
  } catch (error) {
@@ -2,10 +2,12 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { parseRules, scanText } from "./core.mjs";
5
+ import { negationClusterFindings, parseRules, scanText } from "./core.mjs";
6
6
 
7
7
  const here = dirname(fileURLToPath(import.meta.url));
8
8
  const maxInputBytes = 8 * 1024 * 1024;
9
+ // The host refuses worker output over its byte cap and then allows the write, so blocks go first and the list is bounded.
10
+ const maxFindings = 1000;
9
11
 
10
12
  try {
11
13
  const input = readFileSync(0);
@@ -15,21 +17,25 @@ try {
15
17
  throw new Error("guard input must contain file, oldText, and newText strings");
16
18
  }
17
19
  const rules = parseRules(readFileSync(join(here, "../rules.json"), "utf8"));
18
- const oldFindings = scanText(request.oldText, rules, request.file);
20
+ const scan = (text) => [...scanText(text, rules, request.file), ...negationClusterFindings(text, request.file)];
21
+ // A document-scope finding is keyed by its occurrence lines, so an edit between two old occurrences does not re-raise it.
22
+ const keyOf = (finding) => JSON.stringify([finding.rule, finding.severity, finding.occurrences ?? finding.excerpt]);
23
+ const oldFindings = scan(request.oldText);
19
24
  const remaining = new Map();
20
25
  for (const finding of oldFindings) {
21
- const key = JSON.stringify([finding.rule, finding.severity, finding.excerpt]);
26
+ const key = keyOf(finding);
22
27
  remaining.set(key, (remaining.get(key) ?? 0) + 1);
23
28
  }
24
- const findings = scanText(request.newText, rules, request.file).filter((finding) => {
25
- const key = JSON.stringify([finding.rule, finding.severity, finding.excerpt]);
29
+ const findings = scan(request.newText).filter((finding) => {
30
+ const key = keyOf(finding);
26
31
  const count = remaining.get(key) ?? 0;
27
32
  if (count === 0) return true;
28
33
  if (count === 1) remaining.delete(key);
29
34
  else remaining.set(key, count - 1);
30
35
  return false;
31
36
  });
32
- process.stdout.write(`${JSON.stringify({ findings })}\n`);
37
+ const ordered = [...findings.filter((finding) => finding.severity === "block"), ...findings.filter((finding) => finding.severity !== "block")];
38
+ process.stdout.write(`${JSON.stringify({ findings: ordered.slice(0, maxFindings) })}\n`);
33
39
  } catch (error) {
34
40
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
35
41
  process.exitCode = 2;
@@ -301,11 +301,11 @@
301
301
  },
302
302
  {
303
303
  "path": "scripts/core.mjs",
304
- "sha256": "abaff0390680e9c2602cde701f7224acd3586ebed0dcb2a807570359baac62fc"
304
+ "sha256": "c237695aa44185e948fbd43eaccf71c15e264311325d06563d8d06b74af1e560"
305
305
  },
306
306
  {
307
307
  "path": "scripts/detect.mjs",
308
- "sha256": "45a853a1341c5e4d61022edd4ed1289e49028005251682e3298a3946411f18bf"
308
+ "sha256": "5b70f4253334681e382fad7d1d6070509afd3bfc821cb83fea718456c9eea817"
309
309
  },
310
310
  {
311
311
  "path": "scripts/extract_office_text.py",
@@ -313,7 +313,7 @@
313
313
  },
314
314
  {
315
315
  "path": "scripts/guard-scan.mjs",
316
- "sha256": "f9677316adaf262496cc2222eb4b3fca8c1f3b8bf8288a324ca68b97fe09d67d"
316
+ "sha256": "6567964e02e5a6843a8b2b5920cfcad4f80f2642ccf84eb2eb316facb714e6a7"
317
317
  },
318
318
  {
319
319
  "path": "scripts/ko-metrics.mjs",