runlist 0.0.0 → 0.94.2
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/LICENSE +21 -0
- package/README.md +1112 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +296 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/public-inspection.mjs +19 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +795 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
package/src/journal.mjs
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, appendFileSync, statSync, renameSync, readFileSync, unlinkSync, openSync, readSync, closeSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { currentSessionId } from './util.mjs';
|
|
5
|
+
import { readEnv, stateDir } from './naming.mjs';
|
|
6
|
+
|
|
7
|
+
const JOURNAL_FILE = 'journal.jsonl';
|
|
8
|
+
const JOURNAL_BACKUP = 'journal.jsonl.1';
|
|
9
|
+
const ROTATE_SIZE_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
const ROTATE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
|
+
const BACKUP_RETENTION_MS = ROTATE_AGE_MS;
|
|
12
|
+
export const TELEMETRY_BACKUPS = 8;
|
|
13
|
+
|
|
14
|
+
// New writes go to the runlist-* names. The dotmd-* names are what dotmd-cli
|
|
15
|
+
// 0.78.0 and earlier wrote, and what an older CLI still installed elsewhere on
|
|
16
|
+
// the machine keeps writing, so readers merge both and never rename or delete
|
|
17
|
+
// the legacy files (beyond the usual stale-schema and stale-backup pruning).
|
|
18
|
+
const ERROR_LOG_FILE = 'runlist-errors.log';
|
|
19
|
+
const ERROR_LOG_BACKUP = 'runlist-errors.log.1';
|
|
20
|
+
|
|
21
|
+
const MISUSE_LOG_FILE = 'runlist-misuse.log';
|
|
22
|
+
const MISUSE_LOG_BACKUP = 'runlist-misuse.log.1';
|
|
23
|
+
const LEGACY_MISUSE_LOG_FILE = 'dotmd-misuse.log';
|
|
24
|
+
const LEGACY_MISUSE_LOG_BACKUP = 'dotmd-misuse.log.1';
|
|
25
|
+
const TELEMETRY_SCHEMA = 2;
|
|
26
|
+
const REDACTED = '[redacted]';
|
|
27
|
+
const SENSITIVE_VALUE_FLAGS = new Set([
|
|
28
|
+
'--body', '--message', '--note', '--reason', '--question', '--answers', '--choice', '--title',
|
|
29
|
+
'--token', '--password', '--passphrase', '--secret', '--api-key', '--apikey',
|
|
30
|
+
'--auth', '--authorization', '--cookie', '--header',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function sanitizeCredentialShapes(value) {
|
|
34
|
+
return String(value)
|
|
35
|
+
.replace(/\b(Bearer\s+)[^\s'"`]+/gi, `$1${REDACTED}`)
|
|
36
|
+
.replace(/\b([A-Z0-9_]*(?:TOKEN|PASSWORD|PASSPHRASE|SECRET|API_KEY|APIKEY|AUTH|DATABASE_URL|DB_URL|CONNECTION_STRING|DSN)[A-Z0-9_]*)=([^\s]+)/gi, `$1=${REDACTED}`)
|
|
37
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, `$1${REDACTED}@`)
|
|
38
|
+
.replace(/(-----BEGIN [^-]*PRIVATE KEY-----)[\s\S]*?(-----END [^-]*PRIVATE KEY-----)/g, `$1${REDACTED}$2`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sanitizeArgvWithSecrets(args) {
|
|
42
|
+
const input = Array.isArray(args) ? args.map(value => String(value)) : [];
|
|
43
|
+
const output = [...input];
|
|
44
|
+
const secrets = [];
|
|
45
|
+
const redactAt = (index) => {
|
|
46
|
+
if (index < 0 || index >= output.length) return;
|
|
47
|
+
if (output[index] && output[index] !== '-' && !output[index].startsWith('@')) secrets.push(output[index]);
|
|
48
|
+
output[index] = REDACTED;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < output.length; i++) {
|
|
52
|
+
const arg = output[i];
|
|
53
|
+
const sensitiveFlag = SENSITIVE_VALUE_FLAGS.has(arg)
|
|
54
|
+
|| /^--[^=]*(?:token|password|passphrase|secret|api[-_]?key|auth(?:orization)?|cookie)$/i.test(arg);
|
|
55
|
+
if (sensitiveFlag) {
|
|
56
|
+
redactAt(i + 1);
|
|
57
|
+
i += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const eq = arg.indexOf('=');
|
|
61
|
+
const flagName = eq > 0 ? arg.slice(0, eq) : '';
|
|
62
|
+
if (eq > 0 && (SENSITIVE_VALUE_FLAGS.has(flagName)
|
|
63
|
+
|| /^--.*(?:token|password|passphrase|secret|api[-_]?key|auth(?:orization)?|cookie)$/i.test(flagName))) {
|
|
64
|
+
const value = arg.slice(eq + 1);
|
|
65
|
+
if (value) secrets.push(value);
|
|
66
|
+
output[i] = `${arg.slice(0, eq)}=${REDACTED}`;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
output[i] = sanitizeCredentialShapes(arg);
|
|
70
|
+
if (output[i] !== arg) secrets.push(arg);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const globalValueFlags = new Set(['--config', '--root', '--type']);
|
|
74
|
+
let commandIndex = -1;
|
|
75
|
+
for (let i = 0; i < input.length; i++) {
|
|
76
|
+
if (globalValueFlags.has(input[i])) { i += 1; continue; }
|
|
77
|
+
if (input[i].startsWith('-')) continue;
|
|
78
|
+
commandIndex = i;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
const command = input[commandIndex];
|
|
82
|
+
const commandArgs = commandIndex >= 0 ? input.slice(commandIndex + 1) : [];
|
|
83
|
+
const commandOffset = commandIndex + 1;
|
|
84
|
+
const collectPositionals = (valueFlags, dashLeadingAfter = null) => {
|
|
85
|
+
const positions = [];
|
|
86
|
+
for (let i = 0; i < commandArgs.length; i++) {
|
|
87
|
+
const arg = commandArgs[i];
|
|
88
|
+
if (valueFlags.has(arg) || globalValueFlags.has(arg)) { i += 1; continue; }
|
|
89
|
+
if (arg.startsWith('-') && arg !== '-'
|
|
90
|
+
&& !(dashLeadingAfter !== null && positions.length >= dashLeadingAfter)) continue;
|
|
91
|
+
positions.push(commandOffset + i);
|
|
92
|
+
}
|
|
93
|
+
return positions;
|
|
94
|
+
};
|
|
95
|
+
if (command === 'new') {
|
|
96
|
+
const positions = collectPositionals(new Set(['--status', '--title', '--question', '--answers', '--runlist', '--body', '--message', '--root']), 2);
|
|
97
|
+
if (positions.length >= 3) positions.slice(2).forEach(redactAt);
|
|
98
|
+
else if (positions.length === 2 && !['doc','plan','prompt','hub','decision','research','rfc','adr'].includes(input[positions[0]])) redactAt(positions[1]);
|
|
99
|
+
} else if (command === 'prompts' || command === 'prompt') {
|
|
100
|
+
const positions = collectPositionals(new Set(['--status', '--body', '--message', '--title']), 2);
|
|
101
|
+
if (positions.length >= 3 && input[positions[0]] === 'new') positions.slice(2).forEach(redactAt);
|
|
102
|
+
} else if (command === 'baton') {
|
|
103
|
+
const positions = collectPositionals(new Set(['--status', '--note', '--body', '--message']));
|
|
104
|
+
if (positions.length >= 2) positions.slice(1).forEach(redactAt);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const argv = commandIndex > 0
|
|
108
|
+
? [output[commandIndex], ...output.slice(commandIndex + 1), ...output.slice(0, commandIndex)]
|
|
109
|
+
: output;
|
|
110
|
+
return { argv, secrets };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function sanitizeTelemetryArgv(args) {
|
|
114
|
+
return sanitizeArgvWithSecrets(args).argv;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function sanitizeTelemetryText(value, secrets = []) {
|
|
118
|
+
let text = String(value ?? '');
|
|
119
|
+
for (const secret of [...new Set(secrets.filter(Boolean))].sort((a, b) => b.length - a.length)) {
|
|
120
|
+
text = text.split(secret).join(REDACTED);
|
|
121
|
+
}
|
|
122
|
+
return sanitizeCredentialShapes(text)
|
|
123
|
+
.replace(/((?:--body|--message|--note|--reason|--token|--password|--passphrase|--secret|--api-key|--apikey|--auth|--authorization|--cookie|--header)(?:=|\s+))([^\s]+)/gi, `$1${REDACTED}`)
|
|
124
|
+
.replace(/(git\s+commit\b[^\n]*?(?:\s-m|\s--message)(?:=|\s+))((?:"[^"]*")|(?:'[^']*')|[^\s]+)/gi, `$1${REDACTED}`)
|
|
125
|
+
.replace(/((?:sed|perl|g?awk)\b[^\n]*?\s(?:-e\s+)?)((?:"[^"]*")|(?:'[^']*'))/gi, `$1${REDACTED}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isJournalEnabled(config) {
|
|
129
|
+
if (readEnv('JOURNAL') === '1') return true;
|
|
130
|
+
if (readEnv('JOURNAL') === '0') return false;
|
|
131
|
+
return config?.journal === true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function journalFilePath(config) {
|
|
135
|
+
return path.join(stateDir(config.repoRoot), JOURNAL_FILE);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function journalBackupPath(config) {
|
|
139
|
+
return path.join(stateDir(config.repoRoot), JOURNAL_BACKUP);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function firstEntry(file) {
|
|
143
|
+
let fd;
|
|
144
|
+
try {
|
|
145
|
+
fd = openSync(file, 'r');
|
|
146
|
+
const chunks = [];
|
|
147
|
+
const buffer = Buffer.alloc(4096);
|
|
148
|
+
for (;;) {
|
|
149
|
+
const bytes = readSync(fd, buffer, 0, buffer.length, null);
|
|
150
|
+
if (!bytes) break;
|
|
151
|
+
const chunk = buffer.subarray(0, bytes);
|
|
152
|
+
const nl = chunk.indexOf(10);
|
|
153
|
+
chunks.push(Buffer.from(nl >= 0 ? chunk.subarray(0, nl) : chunk));
|
|
154
|
+
if (nl >= 0) break;
|
|
155
|
+
}
|
|
156
|
+
const first = Buffer.concat(chunks).toString('utf8');
|
|
157
|
+
if (!first.trim()) return null;
|
|
158
|
+
const obj = JSON.parse(first);
|
|
159
|
+
return obj;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
} finally {
|
|
163
|
+
if (fd !== undefined) closeSync(fd);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function logSources(file, backup) {
|
|
168
|
+
return [file, backup, ...Array.from({ length: TELEMETRY_BACKUPS - 1 }, (_, i) => `${file}.${i + 2}`)];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function rotateLog(file, backup) {
|
|
172
|
+
const sources = logSources(file, backup);
|
|
173
|
+
try { unlinkSync(sources.at(-1)); } catch {}
|
|
174
|
+
for (let i = sources.length - 2; i >= 0; i--) {
|
|
175
|
+
if (existsSync(sources[i])) renameSync(sources[i], sources[i + 1]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function maybeRotate(file, backup, nextEntry = null) {
|
|
180
|
+
for (const candidate of logSources(file, backup).slice(1)) pruneStaleBackup(candidate);
|
|
181
|
+
if (nextEntry?.schema != null) {
|
|
182
|
+
for (const candidate of logSources(file, backup)) {
|
|
183
|
+
if (!existsSync(candidate)) continue;
|
|
184
|
+
const existingSchema = firstEntry(candidate)?.schema;
|
|
185
|
+
if (existingSchema !== nextEntry.schema) {
|
|
186
|
+
try { unlinkSync(candidate); } catch {}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!existsSync(file)) return;
|
|
191
|
+
let st;
|
|
192
|
+
try { st = statSync(file); } catch { return; }
|
|
193
|
+
if (nextEntry?.v) {
|
|
194
|
+
const existingVersion = firstEntry(file)?.v;
|
|
195
|
+
if (existingVersion !== String(nextEntry.v)) {
|
|
196
|
+
try { rotateLog(file, backup); } catch {}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (st.size > ROTATE_SIZE_BYTES) {
|
|
201
|
+
try { rotateLog(file, backup); } catch {}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (st.size === 0) return;
|
|
205
|
+
// Age check: only the first line's ts matters for "oldest entry" — cheap
|
|
206
|
+
// peek instead of streaming the whole file.
|
|
207
|
+
try {
|
|
208
|
+
const t = new Date(firstEntry(file)?.ts).getTime();
|
|
209
|
+
if (!Number.isNaN(t) && (Date.now() - t) > ROTATE_AGE_MS) {
|
|
210
|
+
try { rotateLog(file, backup); } catch {}
|
|
211
|
+
}
|
|
212
|
+
} catch {}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pruneStaleBackup(backup) {
|
|
216
|
+
if (!existsSync(backup)) return;
|
|
217
|
+
try {
|
|
218
|
+
const st = statSync(backup);
|
|
219
|
+
if ((Date.now() - st.mtimeMs) > BACKUP_RETENTION_MS) {
|
|
220
|
+
try { unlinkSync(backup); } catch {}
|
|
221
|
+
}
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function appendJournalEntry(config, entry) {
|
|
226
|
+
if (!isJournalEnabled(config)) return;
|
|
227
|
+
if (!config?.repoRoot) return;
|
|
228
|
+
try {
|
|
229
|
+
const sanitized = sanitizeArgvWithSecrets(entry?.argv);
|
|
230
|
+
const safeEntry = {
|
|
231
|
+
...entry,
|
|
232
|
+
schema: TELEMETRY_SCHEMA,
|
|
233
|
+
...(Array.isArray(entry?.argv) ? { argv: sanitized.argv } : {}),
|
|
234
|
+
...(entry?.err ? { err: sanitizeTelemetryText(entry.err, sanitized.secrets) } : {}),
|
|
235
|
+
};
|
|
236
|
+
const dir = stateDir(config.repoRoot);
|
|
237
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
238
|
+
const file = journalFilePath(config);
|
|
239
|
+
maybeRotate(file, journalBackupPath(config), safeEntry);
|
|
240
|
+
// O_APPEND is atomic for writes under PIPE_BUF (4KB on Linux, 512B on
|
|
241
|
+
// macOS). Entries are well under either threshold, so concurrent CLI
|
|
242
|
+
// invocations interleave cleanly without locking.
|
|
243
|
+
appendFileSync(file, JSON.stringify(safeEntry) + '\n', { flag: 'a' });
|
|
244
|
+
} catch {
|
|
245
|
+
// Journal write must never break a command.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function purgeLegacyTelemetry(file, backup) {
|
|
250
|
+
for (const candidate of logSources(file, backup)) {
|
|
251
|
+
if (!existsSync(candidate)) continue;
|
|
252
|
+
if (firstEntry(candidate)?.schema !== TELEMETRY_SCHEMA) {
|
|
253
|
+
try { unlinkSync(candidate); } catch {}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function readJournalEntries(config) {
|
|
259
|
+
const file = journalFilePath(config);
|
|
260
|
+
purgeLegacyTelemetry(file, journalBackupPath(config));
|
|
261
|
+
const sources = logSources(file, journalBackupPath(config));
|
|
262
|
+
for (const candidate of sources.slice(1)) pruneStaleBackup(candidate);
|
|
263
|
+
return sources.reverse().flatMap(readLogLines);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function classifyFailure({ args = [], err } = {}) {
|
|
267
|
+
if (!err) return null;
|
|
268
|
+
const message = String(err.message ?? err);
|
|
269
|
+
const command = sanitizeTelemetryArgv(args)[0];
|
|
270
|
+
if (err.family) return err.family;
|
|
271
|
+
if (/^Unknown command:/i.test(message)) return 'unknown-command';
|
|
272
|
+
if (/pending handoff|pending prompt.*already|handoff.*already|already.*pending (?:handoff|prompt)/i.test(message)) return 'handoff-conflict';
|
|
273
|
+
if (/owned by|another session|claimed by|ownership|plan is busy/i.test(message)) return 'ownership-conflict';
|
|
274
|
+
if ((err.name === 'ExitStatus' && (command === 'check' || (['xref','decisions'].includes(command) && args.includes('--check'))))
|
|
275
|
+
|| /^\d+ (?:check error|decision defect|cross-reference finding)/.test(message)) return 'validation';
|
|
276
|
+
if (err.name && !['DotmdError','ExitStatus'].includes(err.name)) return 'unexpected-exception';
|
|
277
|
+
return 'command-error';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function recordCliInvocation({ config, startMs, args, err, version, helpTopic = null }) {
|
|
281
|
+
if (!config) return;
|
|
282
|
+
const sanitized = sanitizeArgvWithSecrets(args);
|
|
283
|
+
const entry = {
|
|
284
|
+
schema: TELEMETRY_SCHEMA,
|
|
285
|
+
ts: new Date().toISOString(),
|
|
286
|
+
sid: currentSessionId(),
|
|
287
|
+
pid: process.pid,
|
|
288
|
+
argv: helpTopic ? (helpTopic === '_main' ? ['--help'] : sanitized.argv[0] === 'help' ? ['help', helpTopic] : [helpTopic, '--help']) : sanitized.argv,
|
|
289
|
+
exit: process.exitCode ?? 0,
|
|
290
|
+
ms: Date.now() - startMs,
|
|
291
|
+
v: version,
|
|
292
|
+
};
|
|
293
|
+
if (helpTopic) entry.helpTopic = helpTopic;
|
|
294
|
+
entry.outcome = helpTopic && entry.exit === 0 ? 'help' : (err ? classifyFailure({ args, err }) : 'success');
|
|
295
|
+
if (err) {
|
|
296
|
+
// Normalize whitespace so multi-line error messages (e.g. unknown-command
|
|
297
|
+
// hints) render as a single line in `runlist journal --tail`. Cap at 200
|
|
298
|
+
// chars so a stray stack trace can't bloat the journal.
|
|
299
|
+
const flat = sanitizeTelemetryText(err.message ?? err, sanitized.secrets).replace(/\s+/g, ' ').trim();
|
|
300
|
+
entry.err = flat.length > 200 ? flat.slice(0, 197) + '...' : flat;
|
|
301
|
+
}
|
|
302
|
+
appendJournalEntry(config, entry);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Global error log: always-on, cross-repo, captured per failed invocation.
|
|
306
|
+
// Independent of `isJournalEnabled` so silent failures stop disappearing.
|
|
307
|
+
// RUNLIST_ERROR_LOG_DIR overrides the default location (for tests, or for
|
|
308
|
+
// users who want the log somewhere other than ~/.claude/logs).
|
|
309
|
+
|
|
310
|
+
export function globalErrorLogDir() {
|
|
311
|
+
return readEnv('ERROR_LOG_DIR') || path.join(os.homedir(), '.claude', 'logs');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function globalErrorLogPath() {
|
|
315
|
+
return path.join(globalErrorLogDir(), ERROR_LOG_FILE);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function globalErrorLogBackupPath() {
|
|
319
|
+
return path.join(globalErrorLogDir(), ERROR_LOG_BACKUP);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function recordGlobalError({ config, startMs, args, err, version }) {
|
|
323
|
+
if (!err) return;
|
|
324
|
+
const sanitized = sanitizeArgvWithSecrets(args);
|
|
325
|
+
const flatMsg = sanitizeTelemetryText(err.message ?? err, sanitized.secrets).replace(/\s+/g, ' ').trim();
|
|
326
|
+
const entry = {
|
|
327
|
+
schema: TELEMETRY_SCHEMA,
|
|
328
|
+
ts: new Date().toISOString(),
|
|
329
|
+
repo: config?.repoRoot || process.cwd(),
|
|
330
|
+
sid: currentSessionId(),
|
|
331
|
+
pid: process.pid,
|
|
332
|
+
argv: sanitized.argv,
|
|
333
|
+
exit: process.exitCode ?? 1,
|
|
334
|
+
ms: typeof startMs === 'number' ? Date.now() - startMs : null,
|
|
335
|
+
v: version,
|
|
336
|
+
err: flatMsg.length > 500 ? flatMsg.slice(0, 497) + '...' : flatMsg,
|
|
337
|
+
family: classifyFailure({ args, err }),
|
|
338
|
+
};
|
|
339
|
+
if (err && err.name) entry.errName = err.name;
|
|
340
|
+
if (err && err.stack) {
|
|
341
|
+
// Keep the first few frames; stacks for DotmdError are short anyway and
|
|
342
|
+
// for unexpected exceptions five frames is usually enough to localize.
|
|
343
|
+
const stack = sanitizeTelemetryText(err.stack, sanitized.secrets).split('\n').slice(0, 6).join('\n');
|
|
344
|
+
entry.stack = stack.length > 1000 ? stack.slice(0, 997) + '...' : stack;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const dir = globalErrorLogDir();
|
|
348
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
349
|
+
const file = globalErrorLogPath();
|
|
350
|
+
maybeRotate(file, globalErrorLogBackupPath(), entry);
|
|
351
|
+
appendFileSync(file, JSON.stringify(entry) + '\n', { flag: 'a' });
|
|
352
|
+
} catch {
|
|
353
|
+
// Logging must never break exit.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The newest failures first, as `{ at, command, message, repo, exit }`: the
|
|
358
|
+
// current file, then its retained rollovers. Reads what `recordGlobalError` wrote
|
|
359
|
+
// (argv and message already sanitized there); nothing else is kept per line.
|
|
360
|
+
export function readGlobalErrors({ limit = 20, repo = null, includeFamily = false } = {}) {
|
|
361
|
+
const file = globalErrorLogPath();
|
|
362
|
+
const backup = globalErrorLogBackupPath();
|
|
363
|
+
purgeLegacyTelemetry(file, backup);
|
|
364
|
+
const sources = logSources(file, backup);
|
|
365
|
+
for (const candidate of sources.slice(1)) pruneStaleBackup(candidate);
|
|
366
|
+
const out = [];
|
|
367
|
+
for (const source of sources) {
|
|
368
|
+
const lines = readLogLines(source).reverse();
|
|
369
|
+
for (const e of lines) {
|
|
370
|
+
if (repo && !String(e.repo ?? '').includes(repo)) continue;
|
|
371
|
+
out.push({
|
|
372
|
+
at: e.ts ?? null,
|
|
373
|
+
command: ['runlist', ...(Array.isArray(e.argv) ? e.argv : [])].join(' '),
|
|
374
|
+
message: e.err ?? null,
|
|
375
|
+
repo: e.repo ?? null,
|
|
376
|
+
exit: e.exit ?? null,
|
|
377
|
+
...(includeFamily ? {
|
|
378
|
+
family: e.family ?? classifyFailure({ args: e.argv, err: { name: e.errName, message: e.err } }),
|
|
379
|
+
session: e.sid ?? null,
|
|
380
|
+
version: e.v ?? null,
|
|
381
|
+
} : {}),
|
|
382
|
+
});
|
|
383
|
+
if (limit && out.length >= limit) return out;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Misuse log: always-on, cross-repo, append-only record of every wrong-move the
|
|
390
|
+
// PreToolUse guard intercepts (committing a gitignored prompt, `cat`-ing a
|
|
391
|
+
// prompt instead of `runlist use`, hand-editing a `status:` field, …). This is
|
|
392
|
+
// the ONLY place those mistakes become visible — they never invoke runlist, so
|
|
393
|
+
// neither the per-repo journal nor the global error log would otherwise see
|
|
394
|
+
// them. Shares the error log's directory and rotation so `~/.claude/logs` is
|
|
395
|
+
// the single home for "what went wrong." Read it with `runlist misuse`.
|
|
396
|
+
export function globalMisuseLogPath() {
|
|
397
|
+
return path.join(globalErrorLogDir(), MISUSE_LOG_FILE);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function globalMisuseLogBackupPath() {
|
|
401
|
+
return path.join(globalErrorLogDir(), MISUSE_LOG_BACKUP);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function recordGuardEvent(event) {
|
|
405
|
+
if (!event) return;
|
|
406
|
+
const entry = {
|
|
407
|
+
schema: TELEMETRY_SCHEMA,
|
|
408
|
+
ts: new Date().toISOString(),
|
|
409
|
+
repo: event.repo || process.cwd(),
|
|
410
|
+
sid: currentSessionId(),
|
|
411
|
+
pid: process.pid,
|
|
412
|
+
tool: event.tool ?? null,
|
|
413
|
+
rule: event.rule ?? null,
|
|
414
|
+
decision: event.decision ?? null,
|
|
415
|
+
detail: typeof event.detail === 'string'
|
|
416
|
+
? (sanitizeTelemetryText(event.detail).length > 300 ? sanitizeTelemetryText(event.detail).slice(0, 297) + '...' : sanitizeTelemetryText(event.detail))
|
|
417
|
+
: null,
|
|
418
|
+
v: event.version ?? null,
|
|
419
|
+
};
|
|
420
|
+
try {
|
|
421
|
+
const dir = globalErrorLogDir();
|
|
422
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
423
|
+
const file = globalMisuseLogPath();
|
|
424
|
+
maybeRotate(file, globalMisuseLogBackupPath(), entry);
|
|
425
|
+
appendFileSync(file, JSON.stringify(entry) + '\n', { flag: 'a' });
|
|
426
|
+
} catch {
|
|
427
|
+
// Logging must never break the hook.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Every misuse log a reader should see: the current file, then the legacy one.
|
|
432
|
+
export function globalMisuseLogPaths() {
|
|
433
|
+
return [globalMisuseLogPath(), path.join(globalErrorLogDir(), LEGACY_MISUSE_LOG_FILE)];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function readLogLines(file) {
|
|
437
|
+
if (!existsSync(file)) return [];
|
|
438
|
+
let raw;
|
|
439
|
+
try { raw = readFileSync(file, 'utf8'); } catch { return []; }
|
|
440
|
+
const out = [];
|
|
441
|
+
for (const line of raw.split('\n')) {
|
|
442
|
+
if (!line) continue;
|
|
443
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Current and legacy entries, merged oldest-first by timestamp. The sort is
|
|
449
|
+
// stable, so entries with equal or unparseable timestamps keep file order.
|
|
450
|
+
export function readMisuseEntries() {
|
|
451
|
+
const dir = globalErrorLogDir();
|
|
452
|
+
const pairs = [
|
|
453
|
+
[globalMisuseLogPath(), globalMisuseLogBackupPath()],
|
|
454
|
+
[path.join(dir, LEGACY_MISUSE_LOG_FILE), path.join(dir, LEGACY_MISUSE_LOG_BACKUP)],
|
|
455
|
+
];
|
|
456
|
+
const entries = [];
|
|
457
|
+
for (const [file, backup] of pairs) {
|
|
458
|
+
purgeLegacyTelemetry(file, backup);
|
|
459
|
+
for (const source of logSources(file, backup).reverse()) {
|
|
460
|
+
if (source !== file) pruneStaleBackup(source);
|
|
461
|
+
entries.push(...readLogLines(source));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const time = (entry) => {
|
|
465
|
+
const t = Date.parse(entry?.ts);
|
|
466
|
+
return Number.isNaN(t) ? 0 : t;
|
|
467
|
+
};
|
|
468
|
+
return entries
|
|
469
|
+
.map((entry, index) => ({ entry, index, t: time(entry) }))
|
|
470
|
+
.sort((a, b) => a.t - b.t || a.index - b.index)
|
|
471
|
+
.map(({ entry }) => entry);
|
|
472
|
+
}
|