@litfamily/litclaude 1.0.21 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +34 -92
  3. package/README_ko-KR.md +42 -95
  4. package/bin/icon-font.mjs +264 -0
  5. package/bin/litclaude-ai.js +155 -18
  6. package/docs/assets/readme/badge-version.svg +1 -1
  7. package/docs/hooks.md +100 -5
  8. package/docs/privacy.md +2 -2
  9. package/package.json +13 -3
  10. package/plugins/litclaude/.claude-plugin/plugin.json +22 -2
  11. package/plugins/litclaude/bin/litclaude-hook.js +13 -7
  12. package/plugins/litclaude/bin/litclaude-hud.js +36 -19
  13. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  14. package/plugins/litclaude/hooks/hooks.json +1 -0
  15. package/plugins/litclaude/hooks/lit-band-client.mjs +44 -0
  16. package/plugins/litclaude/hooks/lit-mod.mjs +269 -0
  17. package/plugins/litclaude/lib/auto-handoff.mjs +2 -0
  18. package/plugins/litclaude/lib/automatic-update-refresh.mjs +4 -0
  19. package/plugins/litclaude/lib/automatic-update.mjs +148 -1
  20. package/plugins/litclaude/lib/cache-measurement.mjs +62 -1
  21. package/plugins/litclaude/lib/canonical-skill-resources.mjs +9 -9
  22. package/plugins/litclaude/lib/hud-accent-palette.mjs +2 -2
  23. package/plugins/litclaude/lib/interface-probe-gate.mjs +2 -1
  24. package/plugins/litclaude/lib/jev-skill-hint.mjs +2 -0
  25. package/plugins/litclaude/lib/lit-plan-persistence.mjs +2 -1
  26. package/plugins/litclaude/lib/litgoal/autoloop.mjs +2 -0
  27. package/plugins/litclaude/lib/litgoal/cli.mjs +2 -0
  28. package/plugins/litclaude/lib/litgoal/ledger.mjs +3 -0
  29. package/plugins/litclaude/lib/litgoal/state.mjs +4 -0
  30. package/plugins/litclaude/lib/mod/frame.mjs +378 -0
  31. package/plugins/litclaude/lib/mod/layout.mjs +76 -0
  32. package/plugins/litclaude/lib/mod/motion.mjs +101 -0
  33. package/plugins/litclaude/lib/mod/render.mjs +24 -0
  34. package/plugins/litclaude/lib/mod/settings.mjs +57 -0
  35. package/plugins/litclaude/lib/mod/state.mjs +374 -0
  36. package/plugins/litclaude/lib/mod/timeline.mjs +124 -0
  37. package/plugins/litclaude/lib/mod/view.mjs +232 -0
  38. package/plugins/litclaude/lib/motion-render-gate.mjs +2 -1
  39. package/plugins/litclaude/lib/office-runtime.mjs +6 -0
  40. package/plugins/litclaude/lib/project-state-root.mjs +34 -2
  41. package/plugins/litclaude/lib/rules/engine.mjs +2 -1
  42. package/plugins/litclaude/lib/rules/format.mjs +24 -3
  43. package/plugins/litclaude/lib/rules/session-state.mjs +2 -1
  44. package/plugins/litclaude/lib/secure-path-read.mjs +46 -0
  45. package/plugins/litclaude/lib/start-work-lifecycle.mjs +4 -0
  46. package/plugins/litclaude/lib/wikify-knowledge.mjs +2 -1
  47. package/plugins/litclaude/scripts/scaffold-plan.mjs +2 -1
  48. package/plugins/litclaude/skills/lit-team/scripts/team.mjs +2 -1
  49. package/plugins/litclaude/skills/lit-typographic-motion/scripts/motion-runtime.mjs +7 -0
@@ -0,0 +1,232 @@
1
+ // What the LIT band says, decided from a state snapshot (state.mjs) and what Claude Code reports live
2
+ // (subagents, context usage). Pure: the mod calls these on each refresh and hands the result to the
3
+ // band's surface module, which only draws and animates it.
4
+
5
+ import { AUTOLOOP_LIMITS, GATE_LIMITS } from "./state.mjs";
6
+
7
+ export const STALL_MS = 10 * 60 * 1000;
8
+ // The context warning starts this many points before the auto-handoff percent.
9
+ export const CONTEXT_LEAD = 5;
10
+
11
+ const LIVE_AGENT = new Set(["running", "pending", "waiting", "idle"]);
12
+ const NONTERMINAL_GOAL = new Set(["active", "blocked", "review_blocked", "needs_user_decision"]);
13
+ const WORK_DISCIPLINES = new Set(["lit-loop", "litwork", "litgoal", "start-work", "native-workflow"]);
14
+
15
+ export const formatDuration = (ms) => {
16
+ const seconds = Math.max(0, Math.round(ms / 1000));
17
+ if (seconds < 60) return `${seconds}s`;
18
+ const minutes = Math.floor(seconds / 60);
19
+ if (minutes < 60) return `${minutes}m`;
20
+ const hours = Math.floor(minutes / 60);
21
+ return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`;
22
+ };
23
+
24
+ export const formatAgo = (at, now) => (Number.isFinite(at) ? (now - at < 5000 ? "just now" : `${formatDuration(now - at)} ago`) : "—");
25
+
26
+ // `activity.last` maps an agent id to the last time it was seen doing something (a tool call from its
27
+ // loop, a status change, its first appearance) and `activity.first` to when it first appeared. A live
28
+ // agent quiet for longer than `thresholdMs` is stalled.
29
+ export const EMPTY_ACTIVITY = Object.freeze({ last: {}, first: {} });
30
+
31
+ // Folds one `$.agent.list()` reading into the activity map: a new agent starts both clocks, a changed
32
+ // status counts as activity, and agents that left the list are forgotten.
33
+ export const trackAgents = (activity, agents, previousStatus, now) => {
34
+ const last = {};
35
+ const first = {};
36
+ for (const agent of agents) {
37
+ first[agent.id] = activity.first[agent.id] ?? now;
38
+ const changed = previousStatus[agent.id] !== undefined && previousStatus[agent.id] !== agent.status;
39
+ last[agent.id] = activity.last[agent.id] === undefined || changed ? now : activity.last[agent.id];
40
+ }
41
+ return { last, first };
42
+ };
43
+
44
+ export const noteAgentActivity = (activity, agentId, now) =>
45
+ activity.first[agentId] === undefined ? activity : { ...activity, last: { ...activity.last, [agentId]: now } };
46
+
47
+ export const stalledLanes = (agents, activity, now, thresholdMs = STALL_MS) =>
48
+ agents
49
+ .filter((agent) => LIVE_AGENT.has(agent.status) && Number.isFinite(activity.last[agent.id]) && now - activity.last[agent.id] > thresholdMs)
50
+ .map((agent) => ({ id: agent.id, name: laneName(agent), quietMs: now - activity.last[agent.id] }));
51
+
52
+ export const laneName = (agent) => String(agent.name || agent.description || agent.type || "agent").slice(0, 40);
53
+
54
+ const goalIsOpen = (goal) => Boolean(goal && NONTERMINAL_GOAL.has(goal.status));
55
+
56
+ const progressOf = (snapshot) => {
57
+ if (snapshot.work && snapshot.work.total > 0) return { done: snapshot.work.checked, total: snapshot.work.total, unit: "tasks" };
58
+ const goal = snapshot.goal;
59
+ if (goalIsOpen(goal) && goal.criteria.length > 0) {
60
+ return { done: goal.criteria.filter(({ status }) => status === "pass").length, total: goal.criteria.length, unit: "criteria" };
61
+ }
62
+ return null;
63
+ };
64
+
65
+ const phasesOf = ({ snapshot, discipline, progress, completing }) => {
66
+ const planned = Boolean(snapshot.work) || goalIsOpen(snapshot.goal);
67
+ const allDone = progress !== null && progress.done >= progress.total;
68
+ const plan = planned || completing ? "done" : discipline === "lit-plan" ? "now" : "todo";
69
+ let work = "todo";
70
+ if (completing || allDone) work = "done";
71
+ else if (progress !== null || WORK_DISCIPLINES.has(discipline)) work = "now";
72
+ let review = "todo";
73
+ if (completing || snapshot.goal?.status === "complete") review = "done";
74
+ else if (discipline === "review-work" || allDone) review = "now";
75
+ return { plan, work, review };
76
+ };
77
+
78
+ const gatesOf = (snapshot) => {
79
+ const gates = [];
80
+ if (snapshot.work) gates.push({ id: "plan", state: "ok", label: "plan saved" });
81
+ else if (snapshot.litPlan?.blockCount > 0) gates.push({ id: "plan", state: "fail", label: "plan not saved" });
82
+ else if (snapshot.litPlan) gates.push({ id: "plan", state: "pending", label: "plan pending" });
83
+ const probe = snapshot.probe;
84
+ if (probe?.editAt !== null && probe?.editAt !== undefined) {
85
+ if (probe.probeAt !== null && probe.probeAt >= probe.editAt) gates.push({ id: "probe", state: "ok", label: "ui probe passed" });
86
+ else if (probe.issue && probe.issueAt >= probe.editAt) gates.push({ id: "probe", state: "fail", label: "ui probe failed" });
87
+ else gates.push({ id: "probe", state: "pending", label: "ui probe pending" });
88
+ }
89
+ if (snapshot.motion?.blockCount > 0) gates.push({ id: "motion", state: "pending", label: "motion gate pending" });
90
+ if (snapshot.handoff) {
91
+ gates.push(snapshot.handoff.firedAt !== null
92
+ ? { id: "handoff", state: "ok", label: `handoff written at ${snapshot.handoff.firedAtPercent ?? snapshot.handoff.percent}%` }
93
+ : { id: "handoff", state: "pending", label: `handoff at ${snapshot.handoff.percent}%` });
94
+ }
95
+ return gates;
96
+ };
97
+
98
+ const contextWarningOf = (snapshot, usage) => {
99
+ const percent = usage?.context?.percent;
100
+ const handoff = snapshot.handoff;
101
+ if (!handoff || handoff.firedAt !== null || !Number.isFinite(percent)) return null;
102
+ return percent >= handoff.percent - CONTEXT_LEAD ? { percent: Math.round(percent), threshold: handoff.percent } : null;
103
+ };
104
+
105
+ /**
106
+ * The band's model. `agents` is `$.agent.list()`, `activity` the per-agent last-seen map, `usage`
107
+ * `$.session.usage()`, `notice` and `completion` whatever the mod is currently playing.
108
+ */
109
+ export const buildBandModel = ({ snapshot, agents = [], activity = EMPTY_ACTIVITY, usage = null, now, band = "auto", isWorking = false, notice = null, completion = null }) => {
110
+ const discipline = snapshot.ignition?.discipline ?? null;
111
+ const progress = progressOf(snapshot);
112
+ const completing = completion !== null;
113
+ const phases = phasesOf({ snapshot, discipline, progress, completing });
114
+ const liveAgents = agents.filter((agent) => LIVE_AGENT.has(agent.status));
115
+ const boulderLanes = (snapshot.work?.lanes ?? []).filter(({ status }) => status === "active");
116
+ const stalls = stalledLanes(agents, activity, now);
117
+ const stalledIds = new Set(stalls.map(({ id }) => id));
118
+ const lanes = liveAgents.length > 0
119
+ ? liveAgents.slice(0, 6).map((agent) => ({
120
+ name: laneName(agent),
121
+ status: stalledIds.has(agent.id) ? "stalled" : agent.status,
122
+ elapsedMs: Number.isFinite(activity.first[agent.id]) ? now - activity.first[agent.id] : null,
123
+ }))
124
+ : boulderLanes.slice(0, 6).map((lane) => ({ name: lane.type, status: "running", elapsedMs: Number.isFinite(lane.startedAt) ? now - lane.startedAt : null }));
125
+ const goal = snapshot.goal;
126
+ const autoloop = goal?.autoloop && goal.status === "active" && snapshot.autoloop
127
+ ? { n: snapshot.autoloop.blockCount, max: AUTOLOOP_LIMITS.blocks, leftMs: Math.max(0, snapshot.autoloop.firstBlockAt + AUTOLOOP_LIMITS.ms - now) }
128
+ : null;
129
+ const ctxWarning = contextWarningOf(snapshot, usage);
130
+ const warnings = stalls.map(({ name, quietMs }) => ({ tone: "amber", subject: name, text: `quiet for ${formatDuration(quietMs)}`, hint: "wake it? (ctrl+x tab)" }));
131
+ const probe = snapshot.probe;
132
+ if (probe?.issue && probe.issueAt >= (probe.editAt ?? 0)) warnings.push({ tone: "red", subject: "ui probe", text: probe.issue, hint: "interface check failed" });
133
+ const active = Boolean(snapshot.work) || goalIsOpen(goal) || (discipline !== null && isWorking) || liveAgents.length > 0;
134
+ const visible = band === "off" ? false : band === "always" || active || notice !== null || completion !== null || warnings.length > 0;
135
+ return {
136
+ visible,
137
+ discipline,
138
+ phases,
139
+ progress,
140
+ next: snapshot.work?.nextTask ?? (goalIsOpen(goal) ? goal.criteria.find(({ status }) => status !== "pass")?.description || goal.objective || null : null),
141
+ lanes: { count: lanes.length, list: lanes },
142
+ autoloop,
143
+ ctxWarning,
144
+ warnings,
145
+ gates: gatesOf(snapshot),
146
+ card: lanes.length >= 2 || warnings.length > 0,
147
+ notice,
148
+ completion,
149
+ };
150
+ };
151
+
152
+ /** The spinner's suffix: the ellipsis Claude Code drew, then the discipline and the progress count. */
153
+ export const spinnerSuffix = (model, base = "…") => {
154
+ if (!model?.visible || !model.discipline) return null;
155
+ const count = model.progress ? ` · ${model.progress.done}/${model.progress.total}` : "";
156
+ return `${base} · ${model.discipline}${count}`;
157
+ };
158
+
159
+ /**
160
+ * Gate events worth a notice, found by comparing the previous snapshot with this one. The first
161
+ * snapshot of a session raises nothing: only changes seen while the mod watches are announced.
162
+ */
163
+ export const diffNotices = (previous, next, previousModel = null, nextModel = null) => {
164
+ if (!previous) return [];
165
+ const notices = [];
166
+ const plan = next.litPlan;
167
+ if (plan && plan.blockCount > (previous.litPlan?.blockCount ?? 0)) {
168
+ notices.push({ id: `plan:${plan.promptAt}:${plan.blockCount}`, tone: "pink", title: "lit-plan is not saved yet", detail: `save plans/<slug>.md before working · stop ${plan.blockCount}/${GATE_LIMITS.litPlan}` });
169
+ }
170
+ const probe = next.probe;
171
+ if (probe?.issue && probe.issueAt !== null && probe.issueAt !== previous.probe?.issueAt) {
172
+ notices.push({ id: `probe-fail:${probe.issueAt}`, tone: "red", title: "interface probe failed", detail: probe.issue });
173
+ } else if (probe && probe.blockCount > (previous.probe?.blockCount ?? 0)) {
174
+ notices.push({ id: `probe:${probe.editAt}:${probe.blockCount}`, tone: "amber", title: "interface probe pending", detail: `run the interface probe before finishing · stop ${probe.blockCount}/${GATE_LIMITS.probe}` });
175
+ }
176
+ const motion = next.motion;
177
+ if (motion && motion.blockCount > (previous.motion?.blockCount ?? 0)) {
178
+ notices.push({ id: `motion:${motion.blockCount}:${motion.runs}`, tone: "amber", title: "motion gate: the film is not done", detail: `stop ${motion.blockCount}/${GATE_LIMITS.motion}` });
179
+ }
180
+ const handoff = next.handoff;
181
+ if (handoff?.firedAt !== null && handoff?.firedAt !== undefined && handoff.firedAt !== previous.handoff?.firedAt) {
182
+ notices.push({ id: `handoff:${handoff.firedAt}`, tone: "pink", title: `auto-handoff fired at ${handoff.firedAtPercent ?? handoff.percent}%`, detail: "write the handoff, then run /compact" });
183
+ }
184
+ if (nextModel?.ctxWarning && !previousModel?.ctxWarning) {
185
+ const { percent, threshold } = nextModel.ctxWarning;
186
+ notices.push({ id: `ctx:${threshold}:${percent}`, tone: "amber", title: `context ${percent}%`, detail: `auto-handoff is armed at ${threshold}%` });
187
+ }
188
+ return notices;
189
+ };
190
+
191
+ /** A finish worth the completion moment: all tasks checked, a work completed, or a goal met. */
192
+ export const detectCompletion = (previous, next) => {
193
+ if (!previous) return null;
194
+ const before = previous.work;
195
+ if (before && before.total > 0) {
196
+ const after = next.work?.id === before.id ? next.work : null;
197
+ const finished = next.completedWorkIds.includes(before.id) || (after !== null && before.checked < before.total && after.checked >= after.total);
198
+ if (finished) return { id: `work:${before.id}`, detail: `${before.total}/${before.total} tasks` };
199
+ }
200
+ const goal = next.goal;
201
+ if (goal?.status === "complete" && previous.goal && previous.goal.status !== "complete") {
202
+ const passed = goal.criteria.filter(({ status }) => status === "pass").length;
203
+ return { id: `goal:${goal.createdAt ?? goal.objective}`, detail: `goal met · ${passed}/${goal.criteria.length} criteria` };
204
+ }
205
+ return null;
206
+ };
207
+
208
+ /** The evidence pane: goal criteria, blockers, the litgoal timeline, lanes and gates. */
209
+ export const buildEvidenceModel = ({ snapshot, ledger = [], agents = [], activity = EMPTY_ACTIVITY, now, icons = null }) => {
210
+ const goal = snapshot.goal;
211
+ return {
212
+ goal: goal
213
+ ? {
214
+ objective: goal.objective,
215
+ status: goal.status,
216
+ criteria: goal.criteria.map((criterion) => ({ ...criterion, ago: formatAgo(criterion.lastAt, now) })),
217
+ blockers: goal.blockers,
218
+ reviewBlocked: goal.lastCheckpoint === "review_blocked",
219
+ }
220
+ : null,
221
+ timeline: ledger.map((entry) => ({ ...entry, ago: formatAgo(entry.at, now) })),
222
+ lanes: agents.map((agent) => ({
223
+ name: laneName(agent),
224
+ type: agent.type,
225
+ status: agent.status,
226
+ quiet: Number.isFinite(activity.last[agent.id]) ? formatDuration(now - activity.last[agent.id]) : null,
227
+ })),
228
+ gates: gatesOf(snapshot),
229
+ work: snapshot.work ? { plan: snapshot.work.planName, done: snapshot.work.checked, total: snapshot.work.total, next: snapshot.work.nextTask } : null,
230
+ icons,
231
+ };
232
+ };
@@ -13,7 +13,7 @@
13
13
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
14
14
  import { isAbsolute, join, resolve } from "node:path";
15
15
 
16
- import { resolveProjectStateRoot } from "./project-state-root.mjs";
16
+ import { ensureStateDirIgnored, resolveProjectStateRoot } from "./project-state-root.mjs";
17
17
 
18
18
  export const MOTION_MAX_BLOCKS = 2;
19
19
  export const MOTION_DISCIPLINE = "lit-typographic-motion";
@@ -34,6 +34,7 @@ const readTurn = (cwd) => {
34
34
  const writeTurn = (cwd, state) => {
35
35
  const target = motionTurnPath(cwd);
36
36
  mkdirSync(join(target, ".."), { recursive: true });
37
+ ensureStateDirIgnored(target);
37
38
  const tmp = `${target}.tmp`;
38
39
  writeFileSync(tmp, JSON.stringify(state));
39
40
  renameSync(tmp, target);
@@ -229,6 +229,12 @@ function main(argv) {
229
229
  return 0;
230
230
  }
231
231
  if (command === "ensure") {
232
+ // An unknown flag (a dry-run guess, a typo) stops before anything is installed.
233
+ const unknown = rest.find((arg) => !["--node", "--python", "--json"].includes(arg));
234
+ if (unknown !== undefined) {
235
+ console.error(`unknown ensure option: ${unknown}`);
236
+ return 2;
237
+ }
232
238
  const wantNode = rest.includes("--node") || !rest.includes("--python");
233
239
  const wantPython = rest.includes("--python") || !rest.includes("--node");
234
240
  const result = {};
@@ -1,5 +1,5 @@
1
- import { existsSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
1
+ import { closeSync, constants, existsSync, lstatSync, openSync, writeSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
 
4
4
  import { findProjectRoot } from "./rules/discovery.mjs";
5
5
 
@@ -20,3 +20,35 @@ export const resolveProjectStateRoot = (startDir = process.cwd()) => {
20
20
  const start = resolve(startDir);
21
21
  return outermostGitRoot(start) ?? findProjectRoot(start) ?? start;
22
22
  };
23
+
24
+ export const PROJECT_STATE_DIR = ".litclaude";
25
+ const STATE_GITIGNORE = "# Created by LitClaude: local session state, not for commits.\n*\n";
26
+
27
+ // Git skips the state folder through a `.gitignore` inside it (the pattern pytest and ruff
28
+ // caches use), so the user's own .gitignore and .git/info/exclude stay untouched. Call it with
29
+ // any path inside the project's state folder after creating that path. It never replaces an
30
+ // existing file, never writes through a symlinked folder, and a failure leaves state writes alone.
31
+ export const ensureStateDirIgnored = (pathInsideState) => {
32
+ let stateDir = resolve(pathInsideState);
33
+ while (basename(stateDir) !== PROJECT_STATE_DIR) {
34
+ const parent = dirname(stateDir);
35
+ if (parent === stateDir) return false;
36
+ stateDir = parent;
37
+ }
38
+ let fd;
39
+ try {
40
+ const stats = lstatSync(stateDir);
41
+ if (stats.isSymbolicLink() || !stats.isDirectory()) return false;
42
+ fd = openSync(
43
+ join(stateDir, ".gitignore"),
44
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
45
+ 0o644,
46
+ );
47
+ writeSync(fd, STATE_GITIGNORE);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ } finally {
52
+ if (fd !== undefined) closeSync(fd);
53
+ }
54
+ };
@@ -127,6 +127,7 @@ export const staticRulesBlock = ({
127
127
  skipUserHome = false,
128
128
  maxRuleChars = DEFAULT_MAX_RULE_CHARS,
129
129
  maxResultChars = DEFAULT_STATIC_MAX_RESULT_CHARS,
130
+ maxResultBytes,
130
131
  sessionId,
131
132
  platform,
132
133
  } = {}) => {
@@ -139,7 +140,7 @@ export const staticRulesBlock = ({
139
140
  const unique = dedupeByBody(rules);
140
141
  return claimFormattedBlock({
141
142
  rules: unique,
142
- format: (selected) => formatStaticBlock(selected, { maxRuleChars, maxResultChars }),
143
+ format: (selected) => formatStaticBlock(selected, { maxRuleChars, maxResultChars, maxResultBytes }),
143
144
  projectRoot,
144
145
  sessionId,
145
146
  });
@@ -41,6 +41,23 @@ export const truncateRuleBody = (body, { maxChars, relativePath }) => {
41
41
  return { body: `${body.slice(0, end)}${notice}`, truncated: true, originalLength: body.length };
42
42
  };
43
43
 
44
+ const utf8Bytes = (text) => Buffer.byteLength(text, "utf8");
45
+
46
+ /** Like truncateRuleBody, but counted in UTF-8 bytes; "" when not even one character fits. */
47
+ const truncateRuleBodyToBytes = (body, { maxBytes, relativePath }) => {
48
+ if (utf8Bytes(body) <= maxBytes) return body;
49
+ const notice = truncationNotice(relativePath);
50
+ let room = maxBytes - utf8Bytes(notice);
51
+ let end = 0;
52
+ for (const char of body) {
53
+ const size = utf8Bytes(char);
54
+ if (size > room) break;
55
+ room -= size;
56
+ end += char.length;
57
+ }
58
+ return end === 0 ? "" : `${body.slice(0, end)}${notice}`;
59
+ };
60
+
44
61
  const formatOne = (rule) => {
45
62
  const body = normalizeBody(rule.body);
46
63
  const header = `Instructions from ${inertPathLabel(rule.relativePath)}`;
@@ -65,10 +82,12 @@ const SAFETY_LINE =
65
82
 
66
83
  const emptyBlock = () => ({ text: "", emittedRules: [] });
67
84
 
68
- const formatBudgetedBlock = (rules, title, { maxRuleChars, maxResultChars }) => {
85
+ // `maxResultBytes` is an optional second cap in UTF-8 bytes, for callers that share a byte budget
86
+ // with other context; a char cap alone would have to assume the worst bytes-per-char.
87
+ const formatBudgetedBlock = (rules, title, { maxRuleChars, maxResultChars, maxResultBytes = Infinity }) => {
69
88
  if (rules.length === 0 || maxResultChars <= 0) return emptyBlock();
70
89
  const prefix = [title, "", SAFETY_LINE].join("\n");
71
- if (prefix.length >= maxResultChars) return emptyBlock();
90
+ if (prefix.length >= maxResultChars || utf8Bytes(prefix) >= maxResultBytes) return emptyBlock();
72
91
 
73
92
  let text = prefix;
74
93
  const emittedRules = [];
@@ -86,9 +105,11 @@ const formatBudgetedBlock = (rules, title, { maxRuleChars, maxResultChars }) =>
86
105
  if (availableBodyChars <= notice.length) break;
87
106
  body = truncateRuleBody(body, { maxChars: availableBodyChars, relativePath: rule.relativePath }).body;
88
107
  }
108
+ const availableBodyBytes = maxResultBytes - utf8Bytes(text) - utf8Bytes(header) - 4;
109
+ body = truncateRuleBodyToBytes(body, { maxBytes: availableBodyBytes, relativePath: rule.relativePath });
89
110
  if (body.length === 0) break;
90
111
  const rendered = `\n\n${formatOne({ ...rule, body })}`;
91
- if (text.length + rendered.length > maxResultChars) break;
112
+ if (text.length + rendered.length > maxResultChars || utf8Bytes(text) + utf8Bytes(rendered) > maxResultBytes) break;
92
113
  text += rendered;
93
114
  emittedRules.push(rule);
94
115
  if (body.length < capped.length) break;
@@ -23,7 +23,7 @@ import { isAbsolute, join, relative, resolve } from "node:path";
23
23
  import { acquireOwnerLock, OwnerLockError, releaseOwnerLock } from "../owner-lock.mjs";
24
24
  import { POST_COMPACT_REINJECTION_BUDGET } from "./constants.mjs";
25
25
  import { pathIdentity, samePathIdentity } from "../secure-path-read.mjs";
26
- import { resolveProjectStateRoot } from "../project-state-root.mjs";
26
+ import { ensureStateDirIgnored, resolveProjectStateRoot } from "../project-state-root.mjs";
27
27
 
28
28
  const MAX_TRACKED_KEYS = 500;
29
29
  const MAX_STATE_BYTES = 256 * 1024;
@@ -61,6 +61,7 @@ const safeStateDirectory = (projectRoot, { create }) => {
61
61
  if (stats.isSymbolicLink() || !stats.isDirectory()) throw new Error("unsafe state directory");
62
62
  if (!isSameOrChildPath(realRoot, realpathSync.native(current))) throw new Error("state directory escaped project");
63
63
  }
64
+ if (create) ensureStateDirIgnored(current);
64
65
  return current;
65
66
  };
66
67
 
@@ -175,6 +175,52 @@ function readDescriptorToOpenedSize(descriptor, openedSize) {
175
175
  return { bytes: offset === openedSize ? bytes : bytes.subarray(0, offset), grewDuringRead };
176
176
  }
177
177
 
178
+ /**
179
+ * Read at most `maxBytes` from the end of one regular file: for an append-only log that has
180
+ * outgrown the whole-file bound. The ancestor chain and the file get the same no-symlink checks
181
+ * as readRegularStable. The file may keep growing, so the range is fixed by the size at open.
182
+ */
183
+ export function readRegularTail(root, path, maxBytes) {
184
+ const chain = openDirectoryChain(root, dirname(path), []);
185
+ if (chain.failure) return { failure: chain.failure };
186
+ let descriptor;
187
+ let bytes;
188
+ let truncated;
189
+ try {
190
+ const before = lstatSync(path, { throwIfNoEntry: false });
191
+ if (!before?.isFile() || before.isSymbolicLink()) {
192
+ closeDescriptors(chain.opened);
193
+ return { failure: "NON_REGULAR" };
194
+ }
195
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
196
+ const opened = fstatSync(descriptor);
197
+ if (!opened.isFile() || !sameNode(before, opened)) {
198
+ closeDescriptors(chain.opened);
199
+ return { failure: "FILE_IDENTITY_CHANGED" };
200
+ }
201
+ const length = Math.min(opened.size, maxBytes);
202
+ const start = opened.size - length;
203
+ const buffer = Buffer.allocUnsafe(length);
204
+ let offset = 0;
205
+ while (offset < length) {
206
+ const count = readSync(descriptor, buffer, offset, length - offset, start + offset);
207
+ if (count === 0) break;
208
+ offset += count;
209
+ }
210
+ bytes = buffer.subarray(0, offset);
211
+ truncated = start > 0;
212
+ } catch {
213
+ closeDescriptors(chain.opened);
214
+ return { failure: "UNREADABLE" };
215
+ } finally {
216
+ if (descriptor !== undefined) closeSync(descriptor);
217
+ }
218
+ const ancestorsStable = verifyDirectoryChain(chain, []);
219
+ closeDescriptors(chain.opened);
220
+ if (!ancestorsStable) return { failure: "ANCESTOR_CHANGED" };
221
+ return { bytes, truncated };
222
+ }
223
+
178
224
  export function readRegularStable(root, path, expectedSnapshot, options = {}) {
179
225
  const maxBytes = Number.isSafeInteger(options.maxBytes) && options.maxBytes >= 0
180
226
  ? options.maxBytes
@@ -18,6 +18,7 @@ import {
18
18
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
19
19
 
20
20
  import { parsePlanTaskRows } from "./plan-task-rows.mjs";
21
+ import { ensureStateDirIgnored } from "./project-state-root.mjs";
21
22
  import { pathIdentity, samePathIdentity } from "./secure-path-read.mjs";
22
23
 
23
24
  const SCHEMA_VERSION = 3;
@@ -64,6 +65,7 @@ const startWorkLockOwnerPath = (root) => join(startWorkLockDir(root), "owner.jso
64
65
 
65
66
  const atomicWriteJson = (path, value) => {
66
67
  mkdirSync(dirname(path), { recursive: true });
68
+ ensureStateDirIgnored(path);
67
69
  const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
68
70
  writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
69
71
  const fd = openSync(temporary, "r");
@@ -103,6 +105,7 @@ const readLockOwner = (root) => {
103
105
  export const acquireStartWorkLock = (root, options = {}) => {
104
106
  const lockDir = startWorkLockDir(root);
105
107
  mkdirSync(dirname(lockDir), { recursive: true });
108
+ ensureStateDirIgnored(lockDir);
106
109
  const configured = Number.parseInt(process.env.LITCLAUDE_START_WORK_LOCK_TIMEOUT_MS ?? "5000", 10);
107
110
  const timeout = Number.isFinite(options.timeoutMs)
108
111
  ? Math.max(0, options.timeoutMs)
@@ -312,6 +315,7 @@ const assertLedgerCompatible = (existing, stateEvents) => {
312
315
  const reconcileLedger = (root, state) => {
313
316
  const path = startWorkLedgerPath(root);
314
317
  mkdirSync(dirname(path), { recursive: true });
318
+ ensureStateDirIgnored(path);
315
319
  const existing = ledgerRecords(root);
316
320
  assertLedgerCompatible(existing, state.events);
317
321
  const ids = new Set(existing.map(({ event_id: eventId }) => eventId).filter(Boolean));
@@ -25,7 +25,7 @@ import {
25
25
  } from "./secure-path-read.mjs";
26
26
  import { decodeStrictUtf8, parseStrictJson } from "./strict-json.mjs";
27
27
  import { containsSecret, URI_USERINFO_PATTERN } from "./secret-shapes.mjs";
28
- import { resolveProjectStateRoot } from "./project-state-root.mjs";
28
+ import { ensureStateDirIgnored, resolveProjectStateRoot } from "./project-state-root.mjs";
29
29
 
30
30
  const MAX_LEDGER_BYTES = 4 * 1024 * 1024;
31
31
  const MAX_TEXT_BYTES = 512;
@@ -159,6 +159,7 @@ const prepareKnowledgeDirectory = (root) => {
159
159
  const litDirectory = join(canonical, ".litclaude");
160
160
  const directory = join(litDirectory, "knowledge");
161
161
  ensurePrivateDirectory(litDirectory);
162
+ ensureStateDirIgnored(litDirectory);
162
163
  ensurePrivateDirectory(directory);
163
164
  return directory;
164
165
  };
@@ -30,7 +30,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
31
 
32
32
  import { parsePlanTaskRows, planLinesOutsideFences } from "../lib/plan-task-rows.mjs";
33
- import { resolveProjectStateRoot } from "../lib/project-state-root.mjs";
33
+ import { ensureStateDirIgnored, resolveProjectStateRoot } from "../lib/project-state-root.mjs";
34
34
  import { readRegularStable, samePathIdentity } from "../lib/secure-path-read.mjs";
35
35
 
36
36
  /** Canonical plan section headers, in order. */
@@ -893,6 +893,7 @@ export async function scaffold(cwd, { slug, intent, reset = false, force = false
893
893
  // Finish the symlink-safe directory setup before taking ctime-sensitive pins;
894
894
  // concurrent scaffolders then contend only on the atomic pair lock.
895
895
  await mkdirWithoutSymlinks(dirname(pairLockPath), workspaceRoot);
896
+ ensureStateDirIgnored(pairLockPath);
896
897
  const pairParentState = await assertSafeWriteParent(cwd, pairLockPath);
897
898
  parentStates.add(pairParentState);
898
899
  pairLock = await acquireWriteLock(pairLockPath, pairParentState);
@@ -38,7 +38,7 @@ import { isAbsolute, join, relative, resolve } from "node:path";
38
38
  import { pathToFileURL } from "node:url";
39
39
 
40
40
  import { acquireOwnerLock, OwnerLockError, releaseOwnerLock } from "../../../lib/owner-lock.mjs";
41
- import { resolveProjectStateRoot } from "../../../lib/project-state-root.mjs";
41
+ import { ensureStateDirIgnored, resolveProjectStateRoot } from "../../../lib/project-state-root.mjs";
42
42
  import { pathIdentity, samePathIdentity } from "../../../lib/secure-path-read.mjs";
43
43
 
44
44
  const MIN_MEMBERS = 2;
@@ -109,6 +109,7 @@ const safeDirectory = (cwd, components, { create }) => {
109
109
  throw new TeamError(`team state path escapes the workspace: ${current}`, 65);
110
110
  }
111
111
  }
112
+ if (create) ensureStateDirIgnored(current);
112
113
  return current;
113
114
  };
114
115
 
@@ -25,6 +25,13 @@ export async function main(argv, { env = process.env, stdout = process.stdout, s
25
25
  stdout.write(USAGE);
26
26
  return command ? 0 : 2;
27
27
  }
28
+ // An install flag this script does not know (a dry-run guess, a typo) stops before anything
29
+ // is installed rather than being ignored.
30
+ const unknownInstallFlag = command === "install" ? rest.find((arg) => !["--audio", "--word-timing"].includes(arg)) : undefined;
31
+ if (unknownInstallFlag !== undefined) {
32
+ stderr.write(`unknown install option: ${unknownInstallFlag}\n${USAGE}`);
33
+ return 2;
34
+ }
28
35
  const runtime = await import("../engine/node/runtime.mjs");
29
36
  if (command === "status") {
30
37
  const status = runtime.runtimeStatus({ env });