@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.
- package/CHANGELOG.md +12 -0
- package/README.md +34 -92
- package/README_ko-KR.md +42 -95
- package/bin/icon-font.mjs +264 -0
- package/bin/litclaude-ai.js +155 -18
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +100 -5
- package/docs/privacy.md +2 -2
- package/package.json +13 -3
- package/plugins/litclaude/.claude-plugin/plugin.json +22 -2
- package/plugins/litclaude/bin/litclaude-hook.js +13 -7
- package/plugins/litclaude/bin/litclaude-hud.js +36 -19
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/hooks/hooks.json +1 -0
- package/plugins/litclaude/hooks/lit-band-client.mjs +44 -0
- package/plugins/litclaude/hooks/lit-mod.mjs +269 -0
- package/plugins/litclaude/lib/auto-handoff.mjs +2 -0
- package/plugins/litclaude/lib/automatic-update-refresh.mjs +4 -0
- package/plugins/litclaude/lib/automatic-update.mjs +148 -1
- package/plugins/litclaude/lib/cache-measurement.mjs +62 -1
- package/plugins/litclaude/lib/canonical-skill-resources.mjs +9 -9
- package/plugins/litclaude/lib/hud-accent-palette.mjs +2 -2
- package/plugins/litclaude/lib/interface-probe-gate.mjs +2 -1
- package/plugins/litclaude/lib/jev-skill-hint.mjs +2 -0
- package/plugins/litclaude/lib/lit-plan-persistence.mjs +2 -1
- package/plugins/litclaude/lib/litgoal/autoloop.mjs +2 -0
- package/plugins/litclaude/lib/litgoal/cli.mjs +2 -0
- package/plugins/litclaude/lib/litgoal/ledger.mjs +3 -0
- package/plugins/litclaude/lib/litgoal/state.mjs +4 -0
- package/plugins/litclaude/lib/mod/frame.mjs +378 -0
- package/plugins/litclaude/lib/mod/layout.mjs +76 -0
- package/plugins/litclaude/lib/mod/motion.mjs +101 -0
- package/plugins/litclaude/lib/mod/render.mjs +24 -0
- package/plugins/litclaude/lib/mod/settings.mjs +57 -0
- package/plugins/litclaude/lib/mod/state.mjs +374 -0
- package/plugins/litclaude/lib/mod/timeline.mjs +124 -0
- package/plugins/litclaude/lib/mod/view.mjs +232 -0
- package/plugins/litclaude/lib/motion-render-gate.mjs +2 -1
- package/plugins/litclaude/lib/office-runtime.mjs +6 -0
- package/plugins/litclaude/lib/project-state-root.mjs +34 -2
- package/plugins/litclaude/lib/rules/engine.mjs +2 -1
- package/plugins/litclaude/lib/rules/format.mjs +24 -3
- package/plugins/litclaude/lib/rules/session-state.mjs +2 -1
- package/plugins/litclaude/lib/secure-path-read.mjs +46 -0
- package/plugins/litclaude/lib/start-work-lifecycle.mjs +4 -0
- package/plugins/litclaude/lib/wikify-knowledge.mjs +2 -1
- package/plugins/litclaude/scripts/scaffold-plan.mjs +2 -1
- package/plugins/litclaude/skills/lit-team/scripts/team.mjs +2 -1
- 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
|
-
|
|
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 });
|