@litfamily/litclaude 1.0.29 → 1.0.31

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
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.31 - 2026-10-17
4
+
5
+ - In the Claude Code desktop app, the mod card used to blink: every real change, such as a new step, tool or skill, swapped in a whole new page, so the card went blank for a moment and its animations started over. The desktop card is now built from the app's own boxes and text, and a change updates only what moved. It looks the same but no longer animates. The terminal and phone cards are unchanged.
6
+
7
+ ## 1.0.30 - 2026-10-16
8
+
9
+ - The pre-write guard now refuses more of the lines that tell a reader what a document is not for, such as `This table is not a ranking.` or `이 문서는 제품을 비교하려는 것이 아닙니다.` These forms came from real sessions. Bold-label callouts such as `> **주의:** ...` are now read too, while ordinary quoted text is still skipped. Lines from manuals and lab reports that only look similar stay allowed, and a large flood of matches can no longer push the scan past its limit into an allowed write.
10
+ - In the Claude Code desktop app, the mod card was sent again every two seconds while a tool ran, because its elapsed clock changed. The desktop card now leaves the clock out and redraws only when something it shows changes; the terminal card keeps its live clock.
11
+
3
12
  ## 1.0.29 - 2026-10-15
4
13
 
5
14
  - Reports and decks no longer fill up with lines that tell the reader what the document is not for, such as `동일 조건의 독립 성능 비교로 쓰지 않음.` When the model writes one into a file, the pre-write guard refuses the save and the model rewrites it. Three new rules cover Korean self-referencing pledges, repeated disclaimers and the English forms; legal and financial notices, safety warnings and real negative findings stay untouched. The rules finish in milliseconds even on very long runs of spaces.
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitClaude 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/litclaude@1.0.31/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitClaude robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
2
2
 
3
3
  <h1 align="center">LitClaude</h1>
4
4
  <p align="center"><strong>Keep the work lit.</strong></p>
5
5
  <p align="center">A Claude Code plugin that helps every session plan the work, build it, check it, and leave a note the next session can pick up.</p>
6
6
  <p align="center">
7
- <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/assets/readme/badge-version.svg" alt="1.0.29" />
8
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
7
+ <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/readme/badge-version.svg" alt="1.0.31" />
8
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
9
9
  </p>
10
10
  <p align="center">
11
- <a href="https://github.com/wjgoarxiv/litclaude#readme">Full guide and skills gallery on GitHub</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/README_ko-KR.md">한국어</a>
11
+ <a href="https://github.com/wjgoarxiv/litclaude#readme">Full guide and skills gallery on GitHub</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/README_ko-KR.md">한국어</a>
12
12
  </p>
13
13
 
14
14
  Add `lit` to a Claude Code prompt and Claude pins the goal, writes a failing test first,
@@ -73,7 +73,7 @@ the `/plugin` menu.
73
73
  ## More
74
74
 
75
75
  - [Full guide and skills gallery on GitHub](https://github.com/wjgoarxiv/litclaude#readme)
76
- - [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/privacy.md)
77
- - [Migration and ownership conflicts](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/migration.md)
78
- - [Release history](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/CHANGELOG.md)
79
- - [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/LICENSE)
76
+ - [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/privacy.md)
77
+ - [Migration and ownership conflicts](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/migration.md)
78
+ - [Release history](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/CHANGELOG.md)
79
+ - [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/LICENSE)
package/README_ko-KR.md CHANGED
@@ -1,14 +1,14 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitClaude 로봇의 눈과 테두리가 빛난 뒤 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/litclaude@1.0.31/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitClaude 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
2
2
 
3
3
  <h1 align="center">LitClaude</h1>
4
4
  <p align="center"><strong>Keep the work lit.</strong></p>
5
5
  <p align="center">Claude Code에서 세션마다 계획하고, 만들고, 확인한 뒤 다음 세션이 이어받을 기록을 남기게 돕는 플러그인입니다.</p>
6
6
  <p align="center">
7
- <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/assets/readme/badge-version.svg" alt="1.0.29" />
8
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
7
+ <img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/readme/badge-version.svg" alt="1.0.31" />
8
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
9
9
  </p>
10
10
  <p align="center">
11
- <a href="https://github.com/wjgoarxiv/litclaude/blob/main/README_ko-KR.md">전체 안내와 스킬 갤러리는 GitHub에서</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/README.md">English</a>
11
+ <a href="https://github.com/wjgoarxiv/litclaude/blob/main/README_ko-KR.md">전체 안내와 스킬 갤러리는 GitHub에서</a> · <a href="https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/README.md">English</a>
12
12
  </p>
13
13
 
14
14
  Claude Code 프롬프트에 `lit`을 붙이면 Claude가 목표를 고정하고, 실패하는 테스트를 먼저 쓰고,
@@ -71,7 +71,7 @@ LitClaude는 프로젝트 상태를 `.litclaude/`에 두고, 그 안의 `.gitign
71
71
  ## 더 보기
72
72
 
73
73
  - [전체 안내와 스킬 갤러리 (GitHub)](https://github.com/wjgoarxiv/litclaude/blob/main/README_ko-KR.md)
74
- - [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/privacy.md)
75
- - [이전과 소유권 충돌](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/docs/migration.md)
76
- - [변경 이력](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/CHANGELOG.md)
77
- - [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.29/LICENSE)
74
+ - [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/privacy.md)
75
+ - [이전과 소유권 충돌](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/docs/migration.md)
76
+ - [변경 이력](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/CHANGELOG.md)
77
+ - [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litclaude@1.0.31/LICENSE)
@@ -1 +1 @@
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>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.31"><title>release: 1.0.31</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.31</text></g></svg>
package/docs/hooks.md CHANGED
@@ -462,14 +462,25 @@ step's spinner (`- \ | /`) and clock are ASCII, and a glyph swaps only together
462
462
 
463
463
  The desktop app's Code tab, and the other surfaces that are not a terminal, draw a mod's `Text` in
464
464
  a proportional font and cut each span to the cells it was laid out for, so the card above comes
465
- apart there. On those surfaces the mod draws the same five rows as one `Svg` element instead
466
- (`lib/mod/svg-card.mjs`): monospace text placed by counted cells, real pills and a rounded border.
467
- It shows the settled state, with no entrances or crossfades. While LitClaude works it moves with
468
- SMIL animation inside the drawing, which the surface runs in a script-less sandboxed frame: the
469
- pill and the border breathe and the step's spinner turns, and on `full` a light crosses the
470
- current stage and the current chips, a head runs the rail leaving it, the count bar's head
471
- shimmers and a comet runs around the border. An idle card is a still image. The step's clock
472
- moves with each new drawing, about every two seconds.
465
+ apart there. In the desktop app the mod builds the same five rows from the app's own boxes and
466
+ text instead (`lib/mod/native-card.mjs`): a dark card with a rounded border, the LIT pill, the
467
+ stage pills, the running tool, the skill chips and the usage figures, each row laid out by the app
468
+ in its monospaced face. The app updates that card in place when something changes, so a new stage,
469
+ tool or skill no longer blanks it. It holds still: the app's boxes and text have no animation, and
470
+ the card shows the settled state with no entrances or crossfades.
471
+
472
+ On a phone the card is one `Svg` drawing (`lib/mod/svg-card.mjs`): monospace text placed by
473
+ counted cells, real pills and a rounded border. While LitClaude works it moves with SMIL animation
474
+ inside the drawing: the pill and the border breathe and the step's spinner turns, and on `full` a
475
+ light crosses the current stage and the current chips, a head runs the rail leaving it, the count
476
+ bar's head shimmers and a comet runs around the border. An idle card is a still image.
477
+
478
+ Neither the desktop card nor the phone drawing has the step clock or the loop countdown: a new
479
+ drawing restarts the phone's animation, and the desktop card would only show a frozen clock. Both
480
+ name the running tool and show the loop round, and are redrawn only when something you would
481
+ notice changes, such as the stage, the tool, a skill, a usage figure or the goal. While the card
482
+ shows in a terminal and the app at once, the terminal's loop countdown and lane times wait for such
483
+ a change too; its step clock keeps counting on its own.
473
484
 
474
485
  Options, in `/config` under LitClaude (stored in `pluginConfigs` in `~/.claude/settings.json`,
475
486
  which the terminal and the desktop app's Code tab both read). The installer writes each default
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litclaude",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "Claude Code plugin: add lit to a prompt and Claude plans, tests, checks and reviews the work, then leaves a handoff for the next session.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "litclaude",
3
3
  "description": "Claude Code-native workflow plugin with a bounded-authority start-work lifecycle.",
4
- "version": "1.0.29",
4
+ "version": "1.0.31",
5
5
  "author": {
6
6
  "name": "LitClaude contributors"
7
7
  },
@@ -9,7 +9,7 @@ import {
9
9
  } from "../lib/wikify-knowledge.mjs";
10
10
 
11
11
  const protocolVersion = "2024-11-05";
12
- const serverVersion = "1.0.29";
12
+ const serverVersion = "1.0.31";
13
13
 
14
14
  const publicSourceReadTool = {
15
15
  name: "public_source_read",
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { composeBand, composeEvidence } from "../lib/mod/frame.mjs";
16
16
  import { TIMINGS } from "../lib/mod/motion.mjs";
17
+ import { composeNativeCard } from "../lib/mod/native-card.mjs";
17
18
  import { renderBand, renderRows } from "../lib/mod/render.mjs";
18
19
  import { accentFromSettings, normalizeModSettings, resolveGlyphs } from "../lib/mod/settings.mjs";
19
20
  import { composeSvgCard } from "../lib/mod/svg-card.mjs";
@@ -50,6 +51,8 @@ const LONG_TURN_MS = 3 * 60 * 1000;
50
51
 
51
52
  const createLive = (options) => ({
52
53
  band: normalizeModSettings(options).band,
54
+ motion: normalizeModSettings(options).motion,
55
+ drawnOn: {},
53
56
  iconFont: { present: false, path: null, note: "basic: not checked yet" },
54
57
  env: {},
55
58
  snapshot: null,
@@ -90,6 +93,29 @@ const stateIo = ($) => ({
90
93
 
91
94
  const toastText = (raised) => `🔥 ${raised.title}${raised.detail ? ` · ${raised.detail}` : ""}`;
92
95
 
96
+ // The model less what moves with time alone: the step clock, the loop countdown, the lanes' run times.
97
+ const stillModel = (model) => model && {
98
+ ...model,
99
+ step: model.step && { name: model.step.name },
100
+ autoloop: model.autoloop && { n: model.autoloop.n, max: model.autoloop.max },
101
+ lanes: { ...model.lanes, list: model.lanes.list.map(({ elapsedMs, ...lane }) => lane) },
102
+ };
103
+
104
+ // What the card would show on each surface still attached. Invalidation redraws every surface at
105
+ // once. A phone's Svg is loaded afresh with each new source, restarting its animations, and the
106
+ // desktop's own-element card shows no clock; so while either is attached, time alone never
107
+ // redraws: the Svg is compared by its drawing, the desktop and the terminal by the still model (the
108
+ // terminal's surface module counts the step clock on by itself). A terminal on its own is compared
109
+ // by the whole model, so its countdown and lane times move too.
110
+ function drawnKey(live, model) {
111
+ const kinds = new Set(Object.values(live.drawnOn));
112
+ if (kinds.size === 0) return JSON.stringify(model);
113
+ const still = kinds.has("svg") || kinds.has("native");
114
+ return [...kinds].map((kind) => (kind === "svg"
115
+ ? (model?.visible ? composeSvgCard(model, { motion: live.motion, accent: live.accent }).source : "hidden")
116
+ : JSON.stringify(still ? stillModel(model) : model))).join("\n");
117
+ }
118
+
93
119
  async function rebuild($, live) {
94
120
  const now = await $.clock.now();
95
121
  const previous = live.model;
@@ -117,7 +143,7 @@ async function rebuild($, live) {
117
143
  $.ui.invalidate("ui.render");
118
144
  });
119
145
  }
120
- if (JSON.stringify(previous) !== JSON.stringify(next)) $.ui.invalidate("ui.render");
146
+ if (drawnKey(live, previous) !== drawnKey(live, next)) $.ui.invalidate("ui.render");
121
147
  return next;
122
148
  }
123
149
 
@@ -328,6 +354,14 @@ export const register = (on, options) => {
328
354
  return next(e);
329
355
  }).catch(($, e, next) => next(e));
330
356
 
357
+ // A client left: once no client of its surface remains, that surface stops counting.
358
+ on("session.detach", async ($, e, next) => {
359
+ const result = await next(e);
360
+ const roster = await $.session.surfaces();
361
+ if (!roster.includes(e.surface)) delete live.drawnOn[e.surface];
362
+ return result;
363
+ }).catch(($, e, next) => next(e));
364
+
331
365
  on("ui.fault", async ($, e, next) => {
332
366
  if (e.component === "AbovePrompt") live.clientFailed = true;
333
367
  return next(e);
@@ -341,9 +375,14 @@ export const register = (on, options) => {
341
375
  const elements = $.ui.resolve(e);
342
376
  const columns = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80);
343
377
  let drawn = null;
344
- if (e.surface !== "terminal" && elements.Svg) {
345
- // A desktop or editor draws Text in a proportional font, so the cell-built card comes apart
346
- // there; those surfaces get the card as one drawing instead.
378
+ const native = e.surface === "desktop";
379
+ live.drawnOn[e.surface] = native ? "native" : e.surface !== "terminal" && elements.Svg ? "svg" : "cells";
380
+ if (native) {
381
+ // The desktop draws Text in a proportional font, so the cell-built card comes apart there, and
382
+ // it loads each new Svg into a fresh frame, blanking it; it gets a card of flex rows instead.
383
+ if (model.visible) drawn = composeNativeCard(elements, model, { accent: live.accent });
384
+ } else if (e.surface !== "terminal" && elements.Svg) {
385
+ // A phone or editor draws Text in a proportional font too; it gets the card as one drawing.
347
386
  if (model.visible) {
348
387
  const { source, alt, animated } = composeSvgCard(model, { motion: settings.motion, accent: live.accent });
349
388
  drawn = elements.Svg({ source, alt, ...(animated ? { isInteractive: true } : {}) });
@@ -7,7 +7,7 @@ export const canonicalSkillResourceManifest = Object.freeze(
7
7
  ["lib/canonical-frontend-corpus.mjs", "a03490b0d7b136fb5efe86d37704e9b4f945c462530db5d51bbfd5b1c70c8b2c"],
8
8
  ["lib/canonical-runtime-closures.mjs", "80e7512135240ee9a47818412a578a1a7db0800cbab8a9721e5efa0421292bf5"],
9
9
  ["lib/canonical-runtime-commitments.mjs", "dc92035af3ef5fc61459d8feae25868cdbee9819d7b70f344de92c1a16d3cddd"],
10
- ["lib/deliverable-hedge-guard.mjs", "9018c5793e0c9e45e416a1d9db453f2e18283ababba7f87a92ff288ec442864d"],
10
+ ["lib/deliverable-hedge-guard.mjs", "266086125959cd77501f563442bfe68b75fe197907224a46023fe4fac5b7c9f2"],
11
11
  ["lib/durable-plan.mjs", "33fc7c2ba6305602c73650c8f29e6141e93b8ac32a444f772807e11bf8c22e44"],
12
12
  ["lib/immutable-expected-file-map.mjs", "4061486bc8eda8a53e56b7b346a1e400e7c858c0a682c83362fea460b46dfd4b"],
13
13
  ["lib/lit-mark.mjs", "cb57921a3da0ff983c74e9426dcd3b2379db090198743139e379a059c128021e"],
@@ -557,7 +557,7 @@ export const canonicalSkillResourceManifest = Object.freeze(
557
557
  ["skills/lit-docx/templates/tonalities/proposal.yaml", "b1b7f4875a89208939ca82d69e6cfb30c35b64ea91d67d5aecc98fe04bde3bfc"],
558
558
  ["skills/lit-docx/templates/tonalities/report.yaml", "23ac1d24f8936c6d522e19354d5948f0d00dce6d60c34fa42e37adafcc68cb8c"],
559
559
  ["skills/lit-humanizer/NOTICE", "c2608d2bc42cac22e77ac64eb137f8fc40d8f9a0b08ae7d7a8a75cf11d9289be"],
560
- ["skills/lit-humanizer/SKILL.md", "673864584ac4ca8d054be6adb8460a45e19c950456cd26ba7b2f41923f40ac6b"],
560
+ ["skills/lit-humanizer/SKILL.md", "5cf7f622bfd651d989a966a7b3bbd18a751ef6ecf51b4edbf68c571c012eb285"],
561
561
  ["skills/lit-humanizer/assets/always-on-rule.md", "1c0e1d9724f012f05142c69d06e8a8cc89596556cba2c8ff1438e99c7848a5cf"],
562
562
  ["skills/lit-humanizer/assets/report-skeleton.md", "e641dae43c1c13398a13f56c7d404fb821cdb9e813bf5eff562c29cd55b7dcd0"],
563
563
  ["skills/lit-humanizer/assets/slide-text-skeleton.md", "489fd07c010beaa1ecc8e02fc77add03d42a12ef1fc90f964d6711c51286cfd7"],
@@ -617,14 +617,14 @@ export const canonicalSkillResourceManifest = Object.freeze(
617
617
  ["skills/lit-humanizer/references/litclaude-rewrite-rules.md", "ba0a9de5d02a427a1dd955bfb71f215abf76d658a50a31e7d42d862b3db44870"],
618
618
  ["skills/lit-humanizer/references/litclaude-style-patterns.md", "19932e2dd5eda9e1e48f0d83086170ad4548e89d05333f702b1d342494c7af5e"],
619
619
  ["skills/lit-humanizer/references/rewrite-playbook.md", "3b0bae2e24e9aada73ea527402e80c25a9bf471b65871248858496f1a2fe8ffb"],
620
- ["skills/lit-humanizer/references/taxonomy.md", "c69c11a0e9315b5dfb8c339e3c97522593cd7452955b187526406b29d1f16671"],
621
- ["skills/lit-humanizer/rules.json", "6153ca0835b04fc9512c903b4c5080c3a45cd1fd68c1a9dbad8972b5a150fb6c"],
620
+ ["skills/lit-humanizer/references/taxonomy.md", "f962ce4cc3458d2c2874858501d81751110280f0b53a2fbf380e77412535a463"],
621
+ ["skills/lit-humanizer/rules.json", "d79e5619f673912e032bcd54fd24ea299b1b2e22643fb128cbe522ad020cc6eb"],
622
622
  ["skills/lit-humanizer/scripts/artifact-text.mjs", "36f893449d0b7719c747cbb512e0b7caed4146cb97b87acf813b3d911577a0e6"],
623
- ["skills/lit-humanizer/scripts/core.mjs", "748b008b02d1fab1be4b7784cc7b195f89a3b1a992b8975b6040fc30fc16c904"],
623
+ ["skills/lit-humanizer/scripts/core.mjs", "12b9086662ee4fb70056553acedbd052d8d0c4c41f363aa2affc81c19dedf4ad"],
624
624
  ["skills/lit-humanizer/scripts/detect.mjs", "cdec0aa65c61c36fc1d7fb78551029a5a362ff8693ba8e04a52b7366d5e42a36"],
625
625
  ["skills/lit-humanizer/scripts/extract_office_text.py", "98eb5507bb78b6d3bf0119da2c8de4a5613efb5806aeef487dff3b6161cd2a13"],
626
626
  ["skills/lit-humanizer/scripts/ko-metrics.mjs", "5e53d6dfb212241fbcbddaa5d89cacfda61fa739616b4d1a3dd4d76268b0d871"],
627
- ["skills/lit-humanizer/scripts/scan-input.mjs", "2743185b72ab3ef5d94b389f0f6aba12d3beb945e717e06090e4aeaffdd00ac9"],
627
+ ["skills/lit-humanizer/scripts/scan-input.mjs", "504e6e12909a94710d249a97e56e8f632948cd5f1fe9bec4266bbed809d714df"],
628
628
  ["skills/lit-pptx/NOTICE", "3b8ee9072906635575aded1e2d2eed51dec1284af9f6ccd47151de376f541153"],
629
629
  ["skills/lit-pptx/SKILL.md", "7a7f7dadc4bf44ce9008a0b8120c8147b79859b1eb42b5861a249c3d45714521"],
630
630
  ["skills/lit-pptx/assets/azure/circle-lg.png", "20c10807b0bda06766bb82ab257d2a10f815337c828ee9c6d74852569779fee0"],
@@ -181,13 +181,15 @@ const runScanner = (artifacts, pluginRoot, execute = spawnSync) => {
181
181
  try {
182
182
  const parsed = JSON.parse(run.stdout);
183
183
  if (!Array.isArray(parsed.findings)) return { failure: "returned an invalid result" };
184
- return { findings: parsed.findings };
184
+ const total = Number.isInteger(parsed.total) && parsed.total >= parsed.findings.length ? parsed.total : parsed.findings.length;
185
+ return { findings: parsed.findings, total };
185
186
  } catch {
186
187
  return { failure: "returned an invalid result" };
187
188
  }
188
189
  };
189
190
 
190
- const resultFor = (findings, paths) => ({
191
+ const resultFor = (findings, paths, total = findings.length) => ({
192
+ total,
191
193
  status: findings.some((finding) => finding.severity === "block")
192
194
  ? "block"
193
195
  : findings.some((finding) => finding.severity === "warn") ? "warn" : "clean",
@@ -231,7 +233,7 @@ const evaluateDeliverableHedgeGuardInternal = ({ input, pluginRoot, phase = "pre
231
233
  }
232
234
  const scanned = runScanner(artifacts, pluginRoot, execute);
233
235
  if (scanned.failure) return { status: "unavailable", reason: `detector ${scanned.failure}`, findings: [], paths: [] };
234
- return { ...resultFor(scanned.findings, paths), phase };
236
+ return { ...resultFor(scanned.findings, paths, scanned.total), phase };
235
237
  }
236
238
 
237
239
  const { artifacts: candidates, skipped } = toolArtifacts(input);
@@ -272,7 +274,7 @@ const evaluateDeliverableHedgeGuardInternal = ({ input, pluginRoot, phase = "pre
272
274
  const artifacts = [...byPath].map(([path, text]) => ({ path, text }));
273
275
  const scanned = runScanner(artifacts, pluginRoot, execute);
274
276
  if (scanned.failure) return { status: "unavailable", reason: `detector ${scanned.failure}`, findings: [], paths: [] };
275
- return resultFor(scanned.findings, artifacts.map(({ path }) => path));
277
+ return resultFor(scanned.findings, artifacts.map(({ path }) => path), scanned.total);
276
278
  };
277
279
 
278
280
  export const evaluateDeliverableHedgeGuard = (options) => {
@@ -286,7 +288,8 @@ export const evaluateDeliverableHedgeGuard = (options) => {
286
288
  const findingSummary = (result) => {
287
289
  const findings = Array.isArray(result?.findings) ? result.findings : [];
288
290
  const listed = findings.slice(0, 8).map((finding) => `${finding.rule} line ${finding.line}`).join(", ");
289
- const omitted = findings.length > 8 ? `; ${findings.length - 8} more` : "";
291
+ const total = Number.isInteger(result?.total) ? Math.max(result.total, findings.length) : findings.length;
292
+ const omitted = total > 8 ? `; ${total - 8} more` : "";
290
293
  return listed ? `${listed}${omitted}` : "pattern review needed";
291
294
  };
292
295
 
@@ -0,0 +1,133 @@
1
+ // The LIT card in the desktop app's own elements: a bordered Box of flex rows and monospaced Text
2
+ // spans, laid out by the app rather than by counted cells. The app draws a Box as a flex div and a
3
+ // Text as a styled span (the host's StyledElement), so a new tree updates the page in place. The Svg
4
+ // card it replaces was a whole document the app loaded into a fresh sandboxed frame on every real
5
+ // change (the stage, the tool, a skill), which blanked the card and restarted its animations.
6
+ //
7
+ // It shows the same five rows and colours as the Svg card (svg-card.mjs): the brand pill, the
8
+ // discipline and the stage indicator; the workflow map or the phase track with the lanes and
9
+ // progress; the detail line with the running tool; the skill chips, the notice or the finish; the
10
+ // usage figures. Box and Text carry no animation props, so this card is still; like the Svg card
11
+ // it carries no step clock and no loop countdown, so time alone never changes the tree.
12
+
13
+ import { barCells } from "./motion.mjs";
14
+ import { C, LABELS, PHASES, TONES, VERDICT_FILL, borderColor, indicatorOf, levelColor, mapColor, rightOfPhases } from "./svg-card.mjs";
15
+
16
+ const MAX_CHIPS = 6;
17
+
18
+ /**
19
+ * The card as one element tree from a surface's `{ Box, Text }`. `accent` is the HUD accent.
20
+ */
21
+ export const composeNativeCard = ({ Box, Text }, model, { accent = "#5fd7ff" } = {}) => {
22
+ const span = (body, { fill = C.white, bold = false, bg = null } = {}) => Text({
23
+ children: body,
24
+ color: fill,
25
+ mono: true,
26
+ wrap: "truncate-end",
27
+ ...(bg ? { backgroundColor: bg } : {}),
28
+ ...(bold ? { weight: "semibold" } : {}),
29
+ });
30
+ const pill = (label, fill, ink = C.ink, bold = true) => span(` ${label} `, { fill: ink, bg: fill, bold });
31
+ const pieces = (list) => list.map(([body, style]) => span(body, style));
32
+ const group = (children, gap = "xs") => Box({ flexDirection: "row", alignItems: "center", gap, flexShrink: 1, overflow: "hidden", children });
33
+ const joined = (groups) => groups.flatMap((list, index) => (index > 0 ? [span("·", { fill: C.faint }), ...pieces(list)] : pieces(list)));
34
+ const row = (left, right = []) => Box({
35
+ flexDirection: "row",
36
+ justifyContent: "space-between",
37
+ alignItems: "center",
38
+ gap: "md",
39
+ children: [group(left.length ? left : [span(" ")]), ...(right.length ? [group(right)] : [])],
40
+ });
41
+
42
+ const indicator = indicatorOf(model, accent);
43
+ const top = row(
44
+ [pill("🔥 LIT", C.pink), span("·", { fill: C.dim }), span(model.title ?? "ready", model.idle ? { fill: C.dim } : { fill: accent, bold: true })],
45
+ [span(indicator.label, { fill: indicator.fill, bold: !indicator.plain })],
46
+ );
47
+
48
+ const nowFill = (isWork = true) => (model.idle ? C.muted : model.ctxWarning && isWork ? C.amber : accent);
49
+ const nowInk = model.idle ? C.mutedText : C.ink;
50
+ let track;
51
+ if (model.workflow) {
52
+ const { stages } = model.workflow;
53
+ const last = Math.max(1, stages.length - 1);
54
+ const left = [];
55
+ stages.forEach((stage, index) => {
56
+ if (index > 0) left.push(span("──", { fill: stage.state === "todo" ? C.rail : mapColor(accent, (index - 0.5) / last) }));
57
+ if (stage.state === "now") left.push(pill(stage.label, nowFill(), nowInk));
58
+ else left.push(span(`${stage.label} ${stage.state === "done" ? "✓" : "·"}`, { fill: stage.state === "done" ? mapColor(accent, index / last) : C.dim }));
59
+ });
60
+ track = row(left, joined(rightOfPhases(model, false)));
61
+ } else {
62
+ const left = [];
63
+ PHASES.forEach((id, index) => {
64
+ const state = model.phases[id];
65
+ if (index > 0) left.push(span("──", { fill: model.phases[PHASES[index - 1]] === "done" ? accent : C.rail }));
66
+ if (state === "now") left.push(pill(LABELS[id], nowFill(id === "work"), nowInk));
67
+ else left.push(span(`${state === "done" ? "●" : "○"} ${LABELS[id]}`, { fill: state === "done" ? accent : C.dim }));
68
+ });
69
+ track = row(left, joined(rightOfPhases(model)));
70
+ }
71
+
72
+ const flow = model.workflow;
73
+ const detailLeft = [];
74
+ if (flow) {
75
+ detailLeft.push(span("└", { fill: C.dim }));
76
+ const parts = [];
77
+ if (flow.count) {
78
+ const { done, total, unit } = flow.count;
79
+ const bar = barCells(total > 0 ? done / total : 0, 8).map(({ glyph, color }) => span("▮", { fill: glyph === "█" ? color : C.empty }));
80
+ parts.push([span(unit, { fill: C.dim }), Box({ flexDirection: "row", children: [span(`${done}`, { bold: true }), span(`/${total}`, { fill: C.dim })] }), Box({ flexDirection: "row", children: bar })]);
81
+ }
82
+ if (flow.verdicts) parts.push(flow.verdicts.map(({ name, state }) => span(`${name} ${VERDICT_FILL[state][0]}`, { fill: VERDICT_FILL[state][1] })));
83
+ for (const item of flow.items) parts.push([span(item)]);
84
+ if (flow.next) parts.push([span("next", { fill: C.dim }), span("▸", { fill: accent }), span(flow.next)]);
85
+ parts.forEach((part, index) => detailLeft.push(...(index > 0 ? [span("·", { fill: C.faint })] : []), ...part));
86
+ }
87
+ const detail = row(detailLeft, model.step ? [span("◌", { fill: accent }), span(model.step.name)] : []);
88
+
89
+ let middle;
90
+ if (model.completion) {
91
+ middle = row([span("✦", { fill: C.pink }), span("COMPLETE", { bold: true }), span(`· ${model.completion.detail ?? ""}`, { fill: C.green })]);
92
+ } else if (model.notice) {
93
+ const [bar, title, icon] = TONES[model.notice.tone] ?? TONES.pink;
94
+ middle = row([span("▌", { fill: bar }), span(`${icon} ${model.notice.title}`, { fill: title, bold: true }), ...(model.notice.detail ? [span(`· ${model.notice.detail}`, { fill: C.dim })] : [])]);
95
+ } else if (model.skills.length === 0) {
96
+ if (!model.idle && model.next && !model.workflow?.next) middle = row([span("▸", { fill: accent }), span(model.next)]);
97
+ else if (!model.idle) middle = row([span("no LitFamily skill yet in this request", { fill: C.dim })]);
98
+ else middle = row([span("no LitFamily skill yet · type", { fill: C.dim }), span("lit <task>", { fill: accent }), span("to start one", { fill: C.dim })]);
99
+ } else {
100
+ const shown = model.skills.slice(0, MAX_CHIPS).map((chip) => (chip.state === "current"
101
+ ? pill(`⚡ ${chip.id}`, accent)
102
+ : pill(`↯ ${chip.id}`, C.muted, C.mutedText, false)));
103
+ const rest = model.skills.length - MAX_CHIPS;
104
+ middle = row([span("skills", { fill: C.dim }), group(rest > 0 ? [...shown, pill(`+${rest}`, C.muted, C.dim, false)] : shown, "sm")]);
105
+ }
106
+
107
+ const bottomGroups = [];
108
+ const warning = model.warnings[0];
109
+ if (warning) {
110
+ const color = warning.tone === "red" ? C.red : C.amber;
111
+ bottomGroups.push([span(`${warning.tone === "red" ? "✗" : "⚠"} ${warning.subject}`, { fill: color, bold: true }), span(warning.text, { fill: color })]);
112
+ }
113
+ const { context, rates, git } = model.figures;
114
+ if (context !== null) {
115
+ const filled = Math.max(0, Math.min(5, Math.round(context / 20)));
116
+ const bar = Array.from({ length: 5 }, (_, i) => span("▮", { fill: i < filled ? accent : C.empty }));
117
+ bottomGroups.push([span("ctx", { fill: C.dim }), span(`${context}%`, { fill: accent }), Box({ flexDirection: "row", children: bar })]);
118
+ }
119
+ for (const rate of rates) bottomGroups.push([span(rate.label, { fill: C.dim }), span(`${rate.percent}%`, { fill: levelColor(rate.percent) })]);
120
+ if (git) bottomGroups.push([span("git", { fill: C.dim }), span(git, { fill: accent })]);
121
+ const bottom = row(bottomGroups.flatMap((list, index) => (index > 0 ? [span("·", { fill: C.faint }), ...list] : list)));
122
+
123
+ return Box({
124
+ flexDirection: "column",
125
+ gap: "xs",
126
+ paddingX: "md",
127
+ paddingY: "sm",
128
+ borderStyle: "round",
129
+ borderColor: borderColor(model, accent),
130
+ backgroundColor: C.bg,
131
+ children: [top, track, detail, middle, bottom],
132
+ });
133
+ };
@@ -1,27 +1,27 @@
1
- // The LIT card as one SVG drawing, for the surfaces that are not a terminal grid. The Claude desktop
2
- // app draws a mod's Text in a proportional font and cuts each span to the cells it was given, so the
3
- // terminal card's text border and its padded pills come apart there. Those surfaces carry an `Svg`
4
- // element instead, and this module draws the same card into it: the brand pill, the discipline and
5
- // the stage indicator on top, the workflow map (or the phase track and progress when no family
6
- // places the session), the detail line with the live step, the skill chips (or the notice or the
7
- // finish), and the live figures at the bottom.
1
+ // The LIT card as one SVG drawing, for the surfaces that are neither a terminal grid nor the desktop
2
+ // app (which gets native-card.mjs). They draw a mod's Text in a proportional font and cut each span
3
+ // to the cells it was given, so the terminal card's text border and its padded pills come apart
4
+ // there. Those surfaces carry an `Svg` element instead, and this module draws the same card into
5
+ // it: the brand pill, the discipline and the stage indicator on top, the workflow map (or the phase
6
+ // track and progress when no family places the session), the detail line with the live step, the
7
+ // skill chips (or the notice or the finish), and the live figures at the bottom.
8
8
  //
9
9
  // It draws the settled state of the model; the terminal's entrances and crossfades stay terminal
10
10
  // only. While LitClaude works and motion allows, SMIL animation in the drawing keeps it moving: the
11
11
  // brand pill and the border breathe (full and subtle), a light crosses the current stage and this
12
12
  // request's chips, a bright head runs along the rail leaving the current stage, the count bar's
13
13
  // head shimmers, the step spinner turns and a comet runs around the border (full only). Idle, it is
14
- // still. The step's clock moves with each new drawing the mod hands the surface.
14
+ // still. The drawing carries no step clock and no loop countdown: the surface loads each new
15
+ // drawing afresh, restarting every animation, so a figure that moves with time alone would make the
16
+ // card blink on every poll.
15
17
  //
16
18
  // Text is set in a monospace face and placed by counted cells (Hangul and emoji as two), so the
17
19
  // layout does not depend on measuring a font the drawing cannot see.
18
20
 
19
- import { formatClock } from "./frame.mjs";
20
21
  import { textWidth, truncateToWidth } from "./layout.mjs";
21
22
  import { TIMINGS, barCells, gradientAt } from "./motion.mjs";
22
- import { formatDuration } from "./view.mjs";
23
23
 
24
- const C = Object.freeze({
24
+ export const C = Object.freeze({
25
25
  bg: "#0d1117",
26
26
  pink: "#ff2d95",
27
27
  orange: "#ff6337",
@@ -41,8 +41,8 @@ const C = Object.freeze({
41
41
  hudYellow: "#d7af00",
42
42
  hudRed: "#d75f5f",
43
43
  });
44
- const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
45
- const PHASES = Object.freeze(["plan", "work", "review"]);
44
+ export const LABELS = Object.freeze({ plan: "PLAN", work: "WORK", review: "REVIEW" });
45
+ export const PHASES = Object.freeze(["plan", "work", "review"]);
46
46
 
47
47
  // Drawing units: CSS pixels at the card's natural size; the surface draws it at that width, up to its slot.
48
48
  export const SVG_CARD = Object.freeze({ width: 880, height: 160, font: 13, cell: 7.8, pad: 18 });
@@ -52,7 +52,7 @@ const FONT = "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, 'JetBrains Mono',
52
52
 
53
53
  const esc = (text) => String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
54
54
  const widthOf = (text) => textWidth(text) * SVG_CARD.cell;
55
- const levelColor = (percent) => (percent >= 90 ? C.hudRed : percent >= 70 ? C.hudYellow : C.hudGreen);
55
+ export const levelColor = (percent) => (percent >= 90 ? C.hudRed : percent >= 70 ? C.hudYellow : C.hudGreen);
56
56
 
57
57
  // Edge spaces become offsets: an SVG text element drops leading and trailing whitespace.
58
58
  const text = (x, y, body, { fill = C.white, bold = false, anchor = "start" } = {}) => {
@@ -85,7 +85,7 @@ const pill = (x, y, label, { fill, ink, bold = true, sweep = false, breath = nul
85
85
  return { markup: parts.join(""), end: x + w };
86
86
  };
87
87
 
88
- const indicatorOf = (model, accent) => {
88
+ export const indicatorOf = (model, accent) => {
89
89
  if (model.completion) return { label: "DONE ✓", fill: C.green };
90
90
  if (model.idle) return { label: "idle", fill: C.dim, plain: true };
91
91
  const flow = model.workflow;
@@ -109,11 +109,11 @@ const topRow = (model, accent, animate) => {
109
109
  return brand.markup + title.markup + right;
110
110
  };
111
111
 
112
- const rightOfPhases = (model, withProgress = true) => {
112
+ export const rightOfPhases = (model, withProgress = true) => {
113
113
  const pieces = [];
114
114
  if (model.ctxWarning) pieces.push([[`ctx ${model.ctxWarning.percent}% → ${model.ctxWarning.threshold}%`, { fill: C.amber, bold: true }]]);
115
115
  if (model.lanes.count > 0) pieces.push([[`● ${model.lanes.count} ${model.lanes.count === 1 ? "lane" : "lanes"}`, { fill: C.green }]]);
116
- if (model.autoloop) pieces.push([[`↻ ${model.autoloop.n}/${model.autoloop.max}`, { fill: C.violet }], [` · ${formatDuration(model.autoloop.leftMs)}`, { fill: C.dim }]]);
116
+ if (model.autoloop) pieces.push([[`↻ ${model.autoloop.n}/${model.autoloop.max}`, { fill: C.violet }]]);
117
117
  if (!withProgress) return pieces;
118
118
  if (model.progress) {
119
119
  pieces.push([[`${model.progress.unit === "tasks" ? "tasks" : "goal"} `, { fill: C.dim }], [`${model.progress.done}`, { fill: C.white, bold: true }], [`/${model.progress.total}`, { fill: C.dim }]]);
@@ -158,7 +158,7 @@ const phaseRow = (model, accent, animate) => {
158
158
  return out.join("");
159
159
  };
160
160
 
161
- const mapColor = (accent, position) => gradientAt([C.orange, C.pink, accent], position);
161
+ export const mapColor = (accent, position) => gradientAt([C.orange, C.pink, accent], position);
162
162
  const shortLabel = (label) => label.replace(/^[A-Za-z]+/u, (word) => word.slice(0, 3));
163
163
 
164
164
  // Right-aligned groups after `x`, the leftmost dropped first when the row is short of room.
@@ -218,9 +218,9 @@ const mapRowSvg = (model, accent, animate) => {
218
218
  return out.join("");
219
219
  };
220
220
 
221
- const VERDICT_FILL = Object.freeze({ pass: ["✓", C.green], fail: ["✗", C.red], pending: ["·", C.dim], running: ["●", C.amber] });
221
+ export const VERDICT_FILL = Object.freeze({ pass: ["✓", C.green], fail: ["✗", C.red], pending: ["·", C.dim], running: ["●", C.amber] });
222
222
 
223
- // └ task 3/7 ▮▮▮▯▯▯▯▯ · next ▸ <task> on the left, the spinner, tool and clock on the right.
223
+ // └ task 3/7 ▮▮▮▯▯▯▯▯ · next ▸ <task> on the left, the spinner and the tool on the right.
224
224
  const detailRowSvg = (model, accent, animate) => {
225
225
  const { width, pad, cell } = SVG_CARD;
226
226
  const y = ROW_Y[2];
@@ -228,10 +228,7 @@ const detailRowSvg = (model, accent, animate) => {
228
228
  const out = [];
229
229
  let right = pad;
230
230
  if (model.step) {
231
- const clock = ` · ${formatClock(model.step.elapsedMs)}`;
232
- const end = width - pad;
233
- out.push(text(end, y, clock, { fill: C.dim, anchor: "end" }));
234
- const nameEnd = end - widthOf(clock);
231
+ const nameEnd = width - pad;
235
232
  out.push(text(nameEnd, y, model.step.name, { fill: C.white, anchor: "end" }));
236
233
  const cx = nameEnd - widthOf(model.step.name) - 10;
237
234
  out.push(`<circle cx="${cx.toFixed(1)}" cy="${y - 4}" r="4.5" fill="none" stroke="${accent}" stroke-width="1.8" stroke-dasharray="18 10.3">`
@@ -279,7 +276,7 @@ const detailRowSvg = (model, accent, animate) => {
279
276
  return out.join("");
280
277
  };
281
278
 
282
- const TONES = Object.freeze({ pink: [C.pink, "#ff6fae", "🔥"], amber: [C.amber, C.amber, "⚠"], red: [C.red, C.red, "✗"] });
279
+ export const TONES = Object.freeze({ pink: [C.pink, "#ff6fae", "🔥"], amber: [C.amber, C.amber, "⚠"], red: [C.red, C.red, "✗"] });
283
280
 
284
281
  // Skill chips, or the notice or the finish while one is up, or the hint while nothing has fired.
285
282
  const middleRow = (model, accent, animate) => {
@@ -354,7 +351,7 @@ const bottomRow = (model, accent) => {
354
351
  return out.join("");
355
352
  };
356
353
 
357
- const borderColor = (model, accent) => {
354
+ export const borderColor = (model, accent) => {
358
355
  if (model.warnings.some(({ tone }) => tone === "red")) return C.red;
359
356
  if (model.warnings.length) return C.amber;
360
357
  return model.idle ? C.faint : accent;
@@ -373,7 +370,7 @@ export const svgCardAlt = (model) => {
373
370
  } else if (current && !model.idle) {
374
371
  parts.push(`phase ${LABELS[current]}`);
375
372
  }
376
- if (model.step) parts.push(`running ${model.step.name} ${formatClock(model.step.elapsedMs)}`);
373
+ if (model.step) parts.push(`running ${model.step.name}`);
377
374
  if (model.skills.length) parts.push(`skills ${model.skills.map(({ id }) => id).join(", ")}`);
378
375
  const { context, rates } = model.figures;
379
376
  if (context !== null) parts.push(`context ${context}%`);
@@ -158,7 +158,7 @@ Review comments, README text, changelogs, commits, and pull-request prose as pro
158
158
 
159
159
  - **Block** means a high-confidence drafting label, stacked disclaimer, or model self-reference needs a deliberate rewrite before the hook allows a supported reader-facing write.
160
160
  - **Warn** means inspect tone, structure, or rhythm and decide from context. A warning never denies a write by itself.
161
- - Fenced and inline code, Markdown blockquotes, internal records/paths, and unchanged replacement context are excluded. Tool adapters pass only new Write content or Edit/MultiEdit replacement text.
161
+ - Fenced and inline code, Markdown blockquotes, internal records/paths, and unchanged replacement context are excluded. One exception: a quote line that opens with a bold label (`> **주의:** ...`) is the author's own callout, and the three pledge rules read it as a single line; a plain quote stays excluded. Tool adapters pass only new Write content or Edit/MultiEdit replacement text.
162
162
  - DOCX/PPTX and PDFs are read only after creation through bounded local extractors. If an extractor is missing, a file is unreadable, or the check times out, the guard allows the operation and shows one short note that the check did not run.
163
163
 
164
164
  When a pattern would remove a correct fact, meaningful scope, required attribution, or the user's voice, keep the text and explain the decision only if the user needs it.
@@ -16,7 +16,7 @@ This original category comes from the 26 real lines in fixtures/pos-real.txt and
16
16
  - en-demo-self-disclaimer — en, block. A demonstration label attached to a synthesis, assumption or recommendation is process framing. Name what is proposed and what was measured.
17
17
  - ko-bracket-status-tag and en-bracket-status-tag — ko/en, block. Replace bracket keys with a sentence describing the status.
18
18
  - ko-standalone-scope-disclaimer and en-standalone-scope-disclaimer — ko/en, block for stock disclaimers. Preserve a user-requested legal, medical or safety notice and place it once where it applies.
19
- - ko-negative-pledge, ko-negative-pledge-repeat and en-negative-pledge — ko/en, block. A line that tells the reader what the document, table or number is not for (`동일 조건의 독립 성능 비교로 쓰지 않음`, `외부 공개용 수치가 아님`, `This is not intended as a head-to-head benchmark`) is a promise about the document, not a finding. ko-negative-pledge reads one line at a time and fires on three shapes only: a self-reference topic (이/본/해당/위/아래 plus a document noun) with a negated claim verb or claim noun, a comparison or ranking noun with a negated use verb, and a purpose-용 data noun with 아님. ko-negative-pledge-repeat reads the whole text and fires when two such sentences end in 해석하지/주장하지/대표하지 않음 or a claim noun plus 아님. en-negative-pledge needs a comparison, benchmark or ranking noun, or `makes no claim`. Say it once as a fact beside the number it affects (`샘플은 한 공정 조건만 포함한다`), or move it to the reply. Operating rules (`방폭 구역에서 사용하지 않습니다`), negative findings (`첨가제 B는 수율을 개선하지 않았다`), and legal, financial or safety notices (대체하, 보장하, 권유 and `substitute for professional advice`) stay clean on purpose. The guard scans only the text a write adds, so a second pledge appended with an Edit to a file that already holds one is not counted by the repeat rule.
19
+ - ko-negative-pledge, ko-negative-pledge-repeat and en-negative-pledge — ko/en, block. A line that tells the reader what the document, table or number is not for (`동일 조건의 독립 성능 비교로 쓰지 않음`, `외부 공개용 수치가 아님`, `This is not intended as a head-to-head benchmark`) is a promise about the document, not a finding. ko-negative-pledge reads one line at a time and fires on a short list of shapes: a self-reference topic (이/본/해당/위/아래 plus a document noun) with a negated claim verb, a claim noun or a `비교하려는 것이 아님` intent clause, a comparison or ranking noun with a negated use or reading verb (`순위처럼 읽지 말아 주세요`), a purpose-용 data noun with 아님, numbers said to be incomparable or forbidden a speed verdict, ranking refused for this data, and a table's row order said not to be a ranking. A conditional method fact (`숫자와 문자열은 서로 비교할 수 없습니다`, `Do not compare these values directly; convert to SI first.`) is not one of them. ko-negative-pledge-repeat reads the whole text and fires when two such sentences end in 해석하지/주장하지/대표하지 않음 or a claim noun plus 아님. en-negative-pledge needs a comparison, benchmark or ranking noun, or `makes no claim`. Say it once as a fact beside the number it affects (`샘플은 한 공정 조건만 포함한다`), or move it to the reply. Operating rules (`방폭 구역에서 사용하지 않습니다`), negative findings (`첨가제 B는 수율을 개선하지 않았다`), and legal, financial or safety notices (대체하, 보장하, 권유 and `substitute for professional advice`) stay clean on purpose. The three rules also read a quote line that opens with a bold label (`> **주의:** 이 표는 순위가 아닙니다.`) as its own line, with the `>` blanked; a plain quote line stays unread. The guard scans only the text a write adds, so a second pledge appended with an Edit to a file that already holds one is not counted by the repeat rule.
20
20
  - ko-status-negation, ko-mi-status, en-epistemic-negation and en-not-performed — warnings. ko-status-negation also reads `쓰지 않음` and the 않습니다/않았습니다 endings, with or without bold around the ending, so a method line such as `실제 운영 데이터로 검증하지 않았습니다` is flagged for a context check and never refused. A negative result or unperformed action may be the finding. Write the actor, scope and verb plainly; never turn unknown into false or an estimate into a fact.
21
21
  - ko-plain-meta-label-warn and en-plain-caveat-label — warnings for boundary headings. They may be useful in internal or technical packets.
22
22
  - en-knowledge-cutoff, en-ai-self-disclaimer, en-hope-helps and ko-chatbot-signoff — block. Remove the model wrapper or generic sign-off, then retain the answer.
@@ -884,14 +884,15 @@
884
884
  "id": "ko-negative-pledge",
885
885
  "lang": "ko",
886
886
  "severity": "block",
887
- "pattern": "(?<![가-힣])(?:이|본|해당|위|아래)\\s+(?:자료|문서|보고서|슬라이드|덱|표|그림|그래프|차트|수치|결과|비교|분석|내용|예시)(?:은|는)\\s[^.!?\\n]{0,40}?(?:(?:해석하|의미하|주장하|대표하)(?:지\\s*(?:[*_]{1,2}\\s*)?(?:않(?:음|는다|습니다)|말\\s*것|마세요|마십시오)|(?:으)?면\\s*(?:[*_]{1,2}\\s*)?안\\s*(?:됨|된다|됩니다))|(?:(?:비교|결론|벤치마크|순위|대표값|일반화)(?:이|가)|목적이|위한\\s*것이)\\s*(?:[*_]{1,2}\\s*)?(?:아님|아니다|아닙니다)(?![가-힣]))|(?:비교|결론|벤치마크|순위|대표값|일반화)(?:으로|로)\\s*(?:[*_]{1,2}\\s*)?(?:쓰|사용하|해석하|읽|간주하|인용하)(?:지\\s*(?:[*_]{1,2}\\s*)?(?:않(?:음|는다|습니다)|말\\s*것|마세요|마십시오)|(?:으)?면\\s*(?:[*_]{1,2}\\s*)?안\\s*(?:됨|된다|됩니다))|(?:공개|배포|대외|외부|홍보|발표|보고|비교|인용|공식)용\\s*(?:자료|문서|수치|표|그래프)(?:이|가)\\s*(?:[*_]{1,2}\\s*)?(?:아님|아니다|아닙니다)(?![가-힣])",
887
+ "pattern": "(?<![가-힣])(?:이|본|해당|위|아래)\\s+(?:자료|문서|보고서|슬라이드|덱|표|그림|그래프|차트|수치|결과|비교|분석|내용|예시)(?:은|는)\\s[^.!?\\n]{0,40}?(?:(?:해석하|의미하|주장하|대표하)(?:지\\s*(?:[*_]{1,2}\\s*)?(?:않(?:음|는다|습니다)|말\\s*것|마세요|마십시오|말아\\s*주(?:세요|십시오))|(?:으)?면\\s*(?:[*_]{1,2}\\s*)?안\\s*(?:됨|된다|됩니다)(?![가-힣]))|(?:(?:비교|결론|벤치마크|순위|대표값|일반화)(?:이|가)|목적이|(?:위한|려는)\\s*것이)\\s*(?:[*_]{1,2}\\s*)?(?:아님|아니다|아닙니다)(?![가-힣])|순위표(?:이|가)\\s*(?:[*_]{1,2}\\s*)?(?:아님|아니다|아닙니다|아니고)(?![가-힣]))|(?:비교|결론|벤치마크|순위|대표값|일반화)(?:처럼|으로|로)\\s*(?:[*_]{1,2}\\s*)?(?:쓰|사용하|해석하|읽|간주하|인용하)(?:지\\s*(?:[*_]{1,2}\\s*)?(?:않(?:음|는다|습니다)|말\\s*것|마세요|마십시오|말아\\s*주(?:세요|십시오))|(?:으)?면\\s*(?:[*_]{1,2}\\s*)?안\\s*(?:됨|된다|됩니다)(?![가-힣]))|(?:공개|배포|대외|외부|홍보|발표|보고|비교|인용|공식)용\\s*(?:자료|문서|수치|표|그래프)(?:이|가)\\s*(?:[*_]{1,2}\\s*)?(?:아님|아니다|아닙니다)(?![가-힣])|(?<![가-힣])(?:숫자|수치)(?:들)?(?:은|는|이|가|을|를|끼리|도)(?![가-힣])(?:(?!면\\s)[^.!?\\n]){0,40}?(?<![와과]\\s)(?<![와과])비교할\\s*수\\s*(?:는\\s*)?없(?:다|습니다|음|나)(?![가-힣])|(?<![가-힣])(?:숫자|수치)(?:들)?끼리\\s*(?:서로\\s*|직접\\s*)?비교(?:해서|하여|해)\\s[^.!?\\n]{0,30}?[\"”’']\\s*(?:는|라는)\\s*결론[^.!?\\n]{0,8}?(?:수\\s*없(?:다|습니다)(?![가-힣])|(?:으)?면\\s*(?:[*_]{1,2}\\s*)?안\\s*(?:됨|된다|됩니다)(?![가-힣]))|(?:(?<![가-힣])(?:숫자|수치)(?:들)?|(?<![가-힣])(?:이|위|아래|본)\\s+(?:표|값|자료|그래프|차트))[^.!?\\n]{0,40}?(?:빠르|느리)다고\\s*판단하(?:지\\s*(?:[*_]{1,2}\\s*)?(?:말\\s*것|마세요|마십시오|말아\\s*주(?:세요|십시오))|(?:으)?면\\s*(?:[*_]{1,2}\\s*)?안\\s*(?:됨|된다|됩니다)(?![가-힣]))|(?:빠르|느리)다[\"”’']?\\s*(?:는|라는)\\s*식으로\\s*읽(?:지\\s*(?:[*_]{1,2}\\s*)?(?:말\\s*것|마세요|마십시오|말아\\s*주(?:세요|십시오))|(?:으)?면\\s*(?:[*_]{1,2}\\s*)?안\\s*(?:됨|된다|됩니다)(?![가-힣]))|만으로\\s[^.!?\\n]{0,40}?(?<![가-힣])순위를\\s*정하[^.!?\\n]{0,25}?(?:수\\s*없(?:다|습니다|음)(?![가-힣])|(?:해|하)서는\\s*안\\s*(?:됨|된다|됩니다)(?![가-힣]))|(?<![가-힣])순위[^.!?\\n]{0,20}?근거로\\s*(?:[*_]{1,2}\\s*)?사용할\\s*수\\s*(?:는\\s*)?없(?:다|습니다|음)(?![가-힣])|할\\s*수\\s*없는\\s*것\\s{0,3}:\\s{0,3}(?:[*_]{1,2}\\s{0,3})?[^.!?\\n]{0,30}?(?<![가-힣])순위를?\\s*매기거나|(?<![가-힣])표의\\s+(?:나열\\s+)?순서(?:는|가)\\s[^.!?\\n]{0,20}?(?<![가-힣])순위(?:(?:를|을)\\s*뜻하지\\s*(?:[*_]{1,2}\\s*)?않(?:음|는다|습니다)|(?:가|이)\\s*(?:[*_]{1,2}\\s*)?(?:아님|아니다|아닙니다)(?![가-힣]))",
888
888
  "flags": "m",
889
889
  "scope": "line",
890
890
  "context": [
891
891
  "fenced_code",
892
- "quoted_user_text"
892
+ "quoted_user_text",
893
+ "blockquote_callout"
893
894
  ],
894
- "rationale": "A sentence that tells the reader what the document, table or number is not for belongs in the reply or becomes a data fact. Three shapes: a self-reference topic (이/본/해당/위/아래 + document noun + 은/는) with a negated claim verb or a claim noun + 아님 within one bullet; a comparison or ranking noun + (으)로 + a negated use verb; a purpose-용 data noun (공개용 수치) + 아님. Use verbs after a self-reference, 근거/평가/증거/추천, and legal or financial notices (대체하, 보장하, 권유) stay outside the pattern because operating rules, method lines and standard notices use them.",
895
+ "rationale": "A sentence that tells the reader what the document, table or number is not for belongs in the reply or becomes a data fact. Shapes: a self-reference topic (이/본/해당/위/아래 + document noun + 은/는) with a negated claim verb, a claim noun + 아님 (순위표 also + 아니고) or 위한/-려는 것이 아님, within one bullet; a comparison or ranking noun + (으)로/처럼 + a negated use or reading verb (순위처럼 읽지 말아 주세요); a purpose-용 data noun (공개용 수치) + 아님; 숫자/수치 + particle said to be incomparable (서로 비교할 수 없다) or compared among themselves toward a quoted verdict (숫자끼리 비교해 \"…빠르다\"는 결론을 내리면 안 됩니다); a speed judgment forbidden for these numbers or a quoted verdict forbidden as a reading (이 숫자로 … 느리다고 판단하면 안 됩니다); ranking refused for this data (…만으로 순위를 정할 수 없다, 순위의 근거로 사용할 수 없다, 할 수 없는 것: 순위를 매기거나); and a table's row order said not to be a ranking (표의 순서는 순위가 아니다). Use verbs after a self-reference, 근거/평가/증거/추천, inference limits (결론 내릴 수 없다, 근거가 되지 않는다), operating rules (…근거로 사용하지 마십시오), conditional method facts (…면 비교할 수 없다, 합계와 비교하면 안 됩니다), type-system rules (숫자 문자열을 그대로 비교하지 마십시오), reported speech and explainers (…면 안 된다는 반론, …면 안 되는 이유), order notices without a table anchor, and legal or financial notices (대체하, 보장하, 권유) stay outside the pattern because operating rules, method lines, manuals, discussions and standard notices use them.",
895
896
  "fixture_ids": [
896
897
  "ko-negative-pledge:positive",
897
898
  "ko-negative-pledge:negative"
@@ -906,7 +907,8 @@
906
907
  "scope": "document",
907
908
  "context": [
908
909
  "fenced_code",
909
- "quoted_user_text"
910
+ "quoted_user_text",
911
+ "blockquote_callout"
910
912
  ],
911
913
  "rationale": "Two or more sentences that end in 해석하지/주장하지/대표하지 않음 or a claim noun + 이/가 + 아님 turn a document into a list of disclaimers. 의미하, 간주하, 인용하 and the use verbs are left out because repeated definitions, data-processing rules, method lines and operating rules end that way; a single such sentence stays with the warn-only ko-status-negation.",
912
914
  "fixture_ids": [
@@ -918,14 +920,15 @@
918
920
  "id": "en-negative-pledge",
919
921
  "lang": "en",
920
922
  "severity": "block",
921
- "pattern": "\\b(?:this|these)\\s+(?:deck|report|document|analysis|table|chart|figure|figures|numbers|results|slide|comparison)\\s+(?:(?:is|are)\\s+not\\s+(?:intended|meant|to\\s+be\\s+(?:read|used|taken|interpreted))(?:\\s+to\\s+be\\s+(?:read|used|taken|interpreted))?\\s+as\\b(?!\\s*(?:a\\s+substitute|a\\s+replacement|(?:legal|investment|medical|professional)\\s+advice))\\s+(?:an?\\s+|the\\s+)?(?:[A-Za-z0-9-]+\\s+){0,4}?(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)|(?:should|must)\\s+not\\s+be\\s+(?:read|used|taken|interpreted)\\s+as\\s+(?:an?\\s+|the\\s+)?(?:[A-Za-z0-9-]+\\s+){0,4}?(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)|makes?\\s+no\\s+claims?\\s+(?:about|that|regarding|as\\s+to)\\b)|\\b(?:this|these)\\s+(?:is|are)\\s+not\\s+(?:intended|meant)\\s+as\\b(?!\\s*(?:a\\s+substitute|a\\s+replacement|(?:legal|investment|medical|professional)\\s+advice))\\s+(?:an?\\s+|the\\s+)?(?:[A-Za-z0-9-]+\\s+){0,4}?(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)|\\bthe\\s+(?:deck|report|document|analysis)\\s+makes?\\s+no\\s+claims?\\s+(?:about|that|regarding|as\\s+to)\\b|(?:^|\\s[-*]\\s)\\s*(?:[-*]\\s+|\\d+\\.\\s+)?(?:[*_]{1,2})?not\\s+(?:intended|meant|to\\s+be\\s+read|to\\s+be\\s+used)\\s+as\\b(?!\\s*(?:a\\s+substitute|a\\s+replacement|(?:legal|investment|medical|professional)\\s+advice))\\s+(?:an?\\s+|the\\s+)?(?:[A-Za-z0-9-]+\\s+){0,4}?(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)|\\b(?:this|these)\\s+(?:table|chart|figure|figures|numbers|results|comparison|analysis)\\s+(?:does|do)\\s+not\\s+constitute\\s+(?:an?\\s+)?[^.!?]{0,60}?\\b(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)",
923
+ "pattern": "\\b(?:this|these)\\s+(?:deck|report|document|analysis|table|chart|figure|figures|numbers|results|slide|comparison)\\s+(?:(?:is|are)\\s+not\\s+(?:intended|meant|to\\s+be\\s+(?:read|used|taken|interpreted))(?:\\s+to\\s+be\\s+(?:read|used|taken|interpreted))?\\s+as\\b(?!\\s*(?:a\\s+substitute|a\\s+replacement|(?:legal|investment|medical|professional)\\s+advice))\\s+(?:an?\\s+|the\\s+)?(?:[A-Za-z0-9-]+\\s+){0,4}?(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)|(?:should|must)\\s+not\\s+be\\s+(?:read|used|taken|interpreted)\\s+as\\s+(?:an?\\s+|the\\s+)?(?:[A-Za-z0-9-]+\\s+){0,4}?(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)|makes?\\s+no\\s+claims?\\s+(?:about|that|regarding|as\\s+to)\\b)|\\b(?:this|these)\\s+(?:is|are)\\s+not\\s+(?:intended|meant)\\s+as\\b(?!\\s*(?:a\\s+substitute|a\\s+replacement|(?:legal|investment|medical|professional)\\s+advice))\\s+(?:an?\\s+|the\\s+)?(?:[A-Za-z0-9-]+\\s+){0,4}?(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)|\\bthe\\s+(?:deck|report|document|analysis)\\s+makes?\\s+no\\s+claims?\\s+(?:about|that|regarding|as\\s+to)\\b|(?:^|\\s[-*]\\s)\\s*(?:[-*]\\s+|\\d+\\.\\s+)?(?:[*_]{1,2})?not\\s+(?:intended|meant|to\\s+be\\s+read|to\\s+be\\s+used)\\s+as\\b(?!\\s*(?:a\\s+substitute|a\\s+replacement|(?:legal|investment|medical|professional)\\s+advice))\\s+(?:an?\\s+|the\\s+)?(?:[A-Za-z0-9-]+\\s+){0,4}?(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)|\\b(?:this|these)\\s+(?:table|chart|figure|figures|numbers|results|comparison|analysis)\\s+(?:does|do)\\s+not\\s+constitute\\s+(?:an?\\s+)?[^.!?]{0,60}?\\b(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)|\\bthey\\s+are\\s+not\\s+(?:an?\\s+)?(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)(?=\\s*(?:[.!?*_]|$))|\\b(?:do\\s+not|don[’']t)\\s+compare\\s+(?:these|those)\\s+(?:numbers|figures|results|values|scores|latencies)(?:\\s+(?:with|to)\\s+(?:each\\s+other|one\\s+another)\\b|(?=\\s*(?:[.!?*_]|$)))|\\b(?:these|those|the)\\s+(?:numbers|figures)\\s+are\\s+not\\s+comparable(?:\\s+(?:with|to)\\s+(?:each\\s+other|one\\s+another)\\b|(?=\\s*(?:[.!?*_]|$)))|\\b(?:this|the|these|our)\\s+(?:table|chart|figure|graph|list|report|document|deck|slide|page|summary|overview|analysis)s?\\s+(?:is|are)\\s+not\\s+(?:(?:meant|intended)\\s+)?(?:(?:to\\s+be\\s+)?(?:an?\\s+)?(?:ranking|benchmark|comparison|leaderboard|endorsement)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)(?=\\s*(?:[.!?*_]|$))|to\\s+(?:rank|compare|benchmark)\\b)|\\b(?:do\\s+not|don[’']t)\\s+read\\s+(?:this|the|these|our)\\s+(?:table|chart|figure|graph|list|report|document|deck|slide|page|summary|overview|analysis)s?\\s+as\\s+(?:an?\\s+)?(?:comparison|benchmark|ranking)s?\\b(?!-)(?!\\s+(?:arm|arms|group|groups|standard|standards|class|baseline)\\b)",
922
924
  "flags": "i",
923
925
  "scope": "sentence",
924
926
  "context": [
925
927
  "fenced_code",
926
- "quoted_user_text"
928
+ "quoted_user_text",
929
+ "blockquote_callout"
927
930
  ],
928
- "rationale": "A sentence that says the deck, report or numbers are not intended, meant or to be read as a comparison, benchmark or ranking, or make no claim about something belongs in the reply, and so does a table, chart or set of numbers that does not constitute one. Requiring one of those nouns keeps safety labels and advice, offer or diagnosis notices clean; \"does not constitute\" for a report or document stays a notice form, and \"the\" is not a subject except with \"makes no claim\".",
931
+ "rationale": "A sentence that says the deck, report or numbers are not intended, meant or to be read as a comparison, benchmark or ranking, or make no claim about something belongs in the reply, and so does a table, chart or set of numbers that does not constitute one; \"they are not a ranking.\", \"do not compare these numbers (with each other).\" and \"the numbers are not comparable.\" are the same refusal when they close the sentence. Requiring one of those nouns keeps safety labels and advice, offer or diagnosis notices clean; a refusal that continues (a ranking of schools, comparable with the previous release, compare these values directly; …, this is not a benchmark, it is …) describes or instructs and stays clean; a deliverable subject (this table, the report, our deck) that is not a ranking, benchmark, comparison, leaderboard or endorsement, is not meant to rank or compare, or is not to be read as a ranking is the same refusal, while a bare \"this\" is not a subject; \"does not constitute\" for a report or document stays a notice form, and \"the\" is not a subject except with \"makes no claim\" or \"numbers are not comparable\".",
929
932
  "fixture_ids": [
930
933
  "en-negative-pledge:positive",
931
934
  "en-negative-pledge:negative"
@@ -81,7 +81,12 @@ function sourceRows(text) {
81
81
  if (/^\s{0,3}>\s?/.test(line)) {
82
82
  captionPending = 0;
83
83
  figureActive = false;
84
- return { line, number: index + 1, candidate: '', skipped: true, caption: false, captionAttribution: false };
84
+ // A quote that opens with a bold label (> **주의:** ...) is the author's own callout rather than
85
+ // quoted text. Only rules that list blockquote_callout read it, with the marker blanked.
86
+ const callout = /^\s{0,3}>\s?(?:\*\*|__)[^*_\n]{1,24}(?:\*\*|__)/.test(line)
87
+ ? inlineCodeAsSpaces(line.replace(/^\s{0,3}>\s?/, (prefix) => ' '.repeat(prefix.length)))
88
+ : '';
89
+ return { line, number: index + 1, candidate: '', callout, skipped: true, caption: false, captionAttribution: false };
85
90
  }
86
91
  const candidate = inlineCodeAsSpaces(line);
87
92
  const caption = isCaption(line);
@@ -148,7 +153,8 @@ function splitSentences(span) {
148
153
  }
149
154
 
150
155
  function ruleSegments(rows, rule, cache) {
151
- const cacheKey = `${rule.scope}/${rule.context.includes('table_figure_caption')}`;
156
+ const readCallouts = rule.context.includes('blockquote_callout');
157
+ const cacheKey = `${rule.scope}/${rule.context.includes('table_figure_caption')}/${readCallouts}`;
152
158
  if (cache.has(cacheKey)) return cache.get(cacheKey);
153
159
  const skipCaptions = rule.context.includes('table_figure_caption');
154
160
  const segments = [];
@@ -161,6 +167,11 @@ function ruleSegments(rows, rule, cache) {
161
167
  };
162
168
 
163
169
  for (const row of rows) {
170
+ if (readCallouts && row.callout) {
171
+ flush();
172
+ segments.push({ text: row.callout, line: row.number });
173
+ continue;
174
+ }
164
175
  const captionExempt = skipCaptions && (row.caption || row.captionAttribution);
165
176
  if (row.skipped || captionExempt || (rule.scope === 'sentence' && !row.candidate.trim())) {
166
177
  flush();
@@ -7,6 +7,18 @@ import { parseRules, scanText } from "./core.mjs";
7
7
 
8
8
  const here = dirname(fileURLToPath(import.meta.url));
9
9
  const MAX_INPUT_BYTES = 2 * 1024 * 1024;
10
+ // The caller reads this output through a bounded pipe. A flood of repeated hits must not grow the
11
+ // output past that bound, because an overflow is read as a scanner failure and the write goes through.
12
+ const MAX_FINDINGS = 64;
13
+
14
+ // Keep every block finding ahead of warnings so a block can never be trimmed away, then restore scan order.
15
+ const boundFindings = (findings) => {
16
+ if (findings.length <= MAX_FINDINGS) return findings;
17
+ const indexed = findings.map((finding, index) => ({ finding, index }));
18
+ const blocks = indexed.filter(({ finding }) => finding.severity === "block");
19
+ const others = indexed.filter(({ finding }) => finding.severity !== "block");
20
+ return [...blocks, ...others].slice(0, MAX_FINDINGS).sort((a, b) => a.index - b.index).map(({ finding }) => finding);
21
+ };
10
22
 
11
23
  try {
12
24
  const raw = readFileSync(0);
@@ -22,7 +34,7 @@ try {
22
34
  if (totalBytes > MAX_INPUT_BYTES) throw new Error("aggregate content exceeds the bounded scan size");
23
35
  findings.push(...scanText(artifact.text, rules, artifact.path));
24
36
  }
25
- process.stdout.write(`${JSON.stringify({ findings })}\n`);
37
+ process.stdout.write(`${JSON.stringify({ findings: boundFindings(findings), total: findings.length })}\n`);
26
38
  } catch {
27
39
  process.stderr.write("lit-humanizer scanner failed\n");
28
40
  process.exitCode = 1;