@bububuger/spanory 0.1.15 → 0.1.18
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/dist/alert/evaluate.js +151 -0
- package/dist/env.d.ts +4 -0
- package/dist/env.js +40 -12
- package/dist/index.js +364 -38
- package/dist/issue/state.d.ts +39 -0
- package/dist/issue/state.js +151 -0
- package/dist/otlp.d.ts +2 -2
- package/dist/report/aggregate.d.ts +1 -0
- package/dist/report/aggregate.js +128 -2
- package/dist/runtime/claude/adapter.js +151 -2
- package/dist/runtime/codex/adapter.js +39 -3
- package/dist/runtime/codex/proxy.js +4 -1
- package/dist/runtime/openclaw/adapter.js +140 -2
- package/dist/runtime/shared/file-settle.d.ts +19 -0
- package/dist/runtime/shared/file-settle.js +44 -0
- package/dist/runtime/shared/normalize.js +464 -18
- package/package.json +8 -6
package/dist/index.js
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ts-nocheck
|
|
3
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
4
|
import { chmod, copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { createHash } from 'node:crypto';
|
|
6
7
|
import { spawnSync } from 'node:child_process';
|
|
7
|
-
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
8
10
|
import { Command } from 'commander';
|
|
9
11
|
import { claudeCodeAdapter } from './runtime/claude/adapter.js';
|
|
10
12
|
import { codexAdapter } from './runtime/codex/adapter.js';
|
|
11
13
|
import { createCodexProxyServer } from './runtime/codex/proxy.js';
|
|
12
14
|
import { openclawAdapter } from './runtime/openclaw/adapter.js';
|
|
13
15
|
import { compileOtlp, parseHeaders, sendOtlp } from './otlp.js';
|
|
14
|
-
import { loadUserEnv } from './env.js';
|
|
16
|
+
import { loadUserEnv, resolveSpanoryEnvPath, resolveSpanoryHome } from './env.js';
|
|
17
|
+
import { waitForFileMtimeToSettle } from './runtime/shared/file-settle.js';
|
|
15
18
|
import { langfuseBackendAdapter } from '../../backend-langfuse/dist/index.js';
|
|
16
19
|
import { evaluateRules, loadAlertRules, sendAlertWebhook } from './alert/evaluate.js';
|
|
17
|
-
import { summarizeCache, loadExportedEvents, summarizeAgents, summarizeCommands, summarizeMcp, summarizeSessions, summarizeTools, summarizeTurnDiff, } from './report/aggregate.js';
|
|
20
|
+
import { summarizeCache, loadExportedEvents, summarizeAgents, summarizeCommands, summarizeMcp, summarizeContext, summarizeSessions, summarizeTools, summarizeTurnDiff, } from './report/aggregate.js';
|
|
21
|
+
import { loadIssueState, parsePendingTodoItems, resolveIssueStatePath, resolveTodoPath, saveIssueState, setIssueStatus, syncIssueState, } from './issue/state.js';
|
|
18
22
|
const runtimeAdapters = {
|
|
19
23
|
'claude-code': claudeCodeAdapter,
|
|
20
24
|
codex: codexAdapter,
|
|
@@ -28,12 +32,61 @@ const OPENCODE_SPANORY_PLUGIN_ID = 'spanory-opencode-plugin';
|
|
|
28
32
|
const DEFAULT_SETUP_RUNTIMES = ['claude-code', 'codex', 'openclaw', 'opencode'];
|
|
29
33
|
const EMPTY_OUTPUT_RETRY_WINDOW_MS = 1000;
|
|
30
34
|
const EMPTY_OUTPUT_RETRY_INTERVAL_MS = 120;
|
|
35
|
+
const SPANORY_NPM_PACKAGE = '@bububuger/spanory';
|
|
36
|
+
const HOOK_STDIN_IDLE_MS = 200;
|
|
37
|
+
const HOOK_STDIN_TIMEOUT_MS = 1500;
|
|
31
38
|
const CODEX_WATCH_DEFAULT_POLL_MS = 1200;
|
|
32
39
|
const CODEX_WATCH_DEFAULT_SETTLE_MS = 250;
|
|
40
|
+
const EXECUTION_ENTRY = (() => {
|
|
41
|
+
if (!('pkg' in process)) {
|
|
42
|
+
return fileURLToPath(import.meta.url);
|
|
43
|
+
}
|
|
44
|
+
const candidate = path.resolve(process.argv[1] ?? process.cwd());
|
|
45
|
+
try {
|
|
46
|
+
return realpathSync(candidate);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
const requireFromHere = createRequire(EXECUTION_ENTRY);
|
|
53
|
+
const CLI_FILE_DIR = path.dirname(EXECUTION_ENTRY);
|
|
54
|
+
const CLI_PACKAGE_DIR = path.resolve(CLI_FILE_DIR, '..');
|
|
55
|
+
const DEFAULT_VERSION = '0.1.1';
|
|
56
|
+
function readVersionFromPackageJson() {
|
|
57
|
+
const packageNameCandidates = ['@bububuger/spanory', '@spanory/cli'];
|
|
58
|
+
for (const packageName of packageNameCandidates) {
|
|
59
|
+
try {
|
|
60
|
+
const pkgJsonPath = requireFromHere.resolve(`${packageName}/package.json`);
|
|
61
|
+
const parsed = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
|
62
|
+
const version = String(parsed?.version ?? '').trim();
|
|
63
|
+
if (version)
|
|
64
|
+
return version;
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
}
|
|
68
|
+
const candidates = [
|
|
69
|
+
path.join(CLI_PACKAGE_DIR, 'package.json'),
|
|
70
|
+
path.resolve(process.cwd(), 'packages', 'cli', 'package.json'),
|
|
71
|
+
];
|
|
72
|
+
for (const file of candidates) {
|
|
73
|
+
if (!existsSync(file))
|
|
74
|
+
continue;
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
77
|
+
const version = String(parsed?.version ?? '').trim();
|
|
78
|
+
if (version)
|
|
79
|
+
return version;
|
|
80
|
+
}
|
|
81
|
+
catch { }
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const CLI_VERSION = process.env.SPANORY_VERSION ?? readVersionFromPackageJson() ?? DEFAULT_VERSION;
|
|
33
86
|
function getResource() {
|
|
34
87
|
return {
|
|
35
88
|
serviceName: 'spanory',
|
|
36
|
-
serviceVersion:
|
|
89
|
+
serviceVersion: CLI_VERSION,
|
|
37
90
|
environment: process.env.SPANORY_ENV ?? 'development',
|
|
38
91
|
};
|
|
39
92
|
}
|
|
@@ -64,11 +117,73 @@ function parseHookPayload(raw) {
|
|
|
64
117
|
throw new Error('hook payload is not valid JSON');
|
|
65
118
|
}
|
|
66
119
|
}
|
|
120
|
+
function redactSecretText(value) {
|
|
121
|
+
return String(value ?? '')
|
|
122
|
+
.replace(/(authorization\s*[:=]\s*)(basic|bearer)\s+[^\s"']+/ig, '$1[REDACTED]')
|
|
123
|
+
.replace(/\b(sk|pk)_[a-z0-9_-]{8,}\b/ig, '[REDACTED]');
|
|
124
|
+
}
|
|
125
|
+
function maskEndpoint(endpoint) {
|
|
126
|
+
const raw = String(endpoint ?? '').trim();
|
|
127
|
+
if (!raw)
|
|
128
|
+
return '';
|
|
129
|
+
try {
|
|
130
|
+
const url = new URL(raw);
|
|
131
|
+
return `${url.protocol}//${url.host}${url.pathname}`;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return redactSecretText(raw);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
67
137
|
async function readStdinText() {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
138
|
+
if (process.stdin.isTTY)
|
|
139
|
+
return '';
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const chunks = [];
|
|
142
|
+
let lastDataAt = Date.now();
|
|
143
|
+
let settled = false;
|
|
144
|
+
function done(error, value = '') {
|
|
145
|
+
if (settled)
|
|
146
|
+
return;
|
|
147
|
+
settled = true;
|
|
148
|
+
clearInterval(idleTimer);
|
|
149
|
+
clearTimeout(hardTimeout);
|
|
150
|
+
process.stdin.off('data', onData);
|
|
151
|
+
process.stdin.off('end', onEnd);
|
|
152
|
+
process.stdin.off('error', onError);
|
|
153
|
+
process.stdin.pause();
|
|
154
|
+
if (typeof process.stdin.unref === 'function') {
|
|
155
|
+
process.stdin.unref();
|
|
156
|
+
}
|
|
157
|
+
if (error)
|
|
158
|
+
reject(error);
|
|
159
|
+
else
|
|
160
|
+
resolve(value);
|
|
161
|
+
}
|
|
162
|
+
function onData(chunk) {
|
|
163
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
164
|
+
lastDataAt = Date.now();
|
|
165
|
+
}
|
|
166
|
+
function onEnd() {
|
|
167
|
+
done(null, Buffer.concat(chunks).toString('utf-8'));
|
|
168
|
+
}
|
|
169
|
+
function onError(error) {
|
|
170
|
+
done(error);
|
|
171
|
+
}
|
|
172
|
+
const idleTimer = setInterval(() => {
|
|
173
|
+
if (chunks.length === 0)
|
|
174
|
+
return;
|
|
175
|
+
if (Date.now() - lastDataAt >= HOOK_STDIN_IDLE_MS) {
|
|
176
|
+
done(null, Buffer.concat(chunks).toString('utf-8'));
|
|
177
|
+
}
|
|
178
|
+
}, 50);
|
|
179
|
+
const hardTimeout = setTimeout(() => {
|
|
180
|
+
done(new Error(`hook stdin read timeout after ${HOOK_STDIN_TIMEOUT_MS}ms`));
|
|
181
|
+
}, HOOK_STDIN_TIMEOUT_MS);
|
|
182
|
+
process.stdin.on('data', onData);
|
|
183
|
+
process.stdin.on('end', onEnd);
|
|
184
|
+
process.stdin.on('error', onError);
|
|
185
|
+
process.stdin.resume();
|
|
186
|
+
});
|
|
72
187
|
}
|
|
73
188
|
function fingerprintSession(context, events) {
|
|
74
189
|
const hash = createHash('sha256');
|
|
@@ -177,6 +292,14 @@ function resolveRuntimeProjectRoot(runtimeName, explicitRuntimeHome) {
|
|
|
177
292
|
function resolveRuntimeStateRoot(runtimeName, explicitRuntimeHome) {
|
|
178
293
|
return path.join(resolveRuntimeHome(runtimeName, explicitRuntimeHome), 'state');
|
|
179
294
|
}
|
|
295
|
+
function resolveOpencodePluginStateRoot(runtimeHome) {
|
|
296
|
+
if (process.env.SPANORY_OPENCODE_STATE_DIR)
|
|
297
|
+
return process.env.SPANORY_OPENCODE_STATE_DIR;
|
|
298
|
+
if (runtimeHome || process.env.SPANORY_OPENCODE_HOME) {
|
|
299
|
+
return path.join(runtimeHome ?? resolveRuntimeHome('opencode'), 'state', 'spanory');
|
|
300
|
+
}
|
|
301
|
+
return path.join(resolveSpanoryHome(), 'opencode');
|
|
302
|
+
}
|
|
180
303
|
function resolveRuntimeExportDir(runtimeName, explicitRuntimeHome) {
|
|
181
304
|
return path.join(resolveRuntimeStateRoot(runtimeName, explicitRuntimeHome), 'spanory-json');
|
|
182
305
|
}
|
|
@@ -216,7 +339,7 @@ async function emitSession({ runtimeName, context, events, endpoint, headers, ex
|
|
|
216
339
|
console.log(`runtime=${runtimeName} projectId=${context.projectId} sessionId=${context.sessionId} events=${events.length}`);
|
|
217
340
|
if (endpoint) {
|
|
218
341
|
await sendOtlp(endpoint, payload, headers);
|
|
219
|
-
console.log(`otlp=sent endpoint=${endpoint}`);
|
|
342
|
+
console.log(`otlp=sent endpoint=${maskEndpoint(endpoint)}`);
|
|
220
343
|
}
|
|
221
344
|
else {
|
|
222
345
|
console.log('otlp=skipped endpoint=unset');
|
|
@@ -242,6 +365,23 @@ async function runHookMode(options) {
|
|
|
242
365
|
if (!resolvedContext) {
|
|
243
366
|
throw new Error('cannot resolve runtime context from hook payload; require session_id (or thread_id)');
|
|
244
367
|
}
|
|
368
|
+
if (runtimeName === 'codex') {
|
|
369
|
+
const runtimeHome = resolveRuntimeHome(runtimeName, options.runtimeHome);
|
|
370
|
+
const transcriptPath = resolvedContext.transcriptPath
|
|
371
|
+
?? (await listCodexSessions(runtimeHome)).find((session) => session.sessionId === resolvedContext.sessionId)?.transcriptPath;
|
|
372
|
+
if (transcriptPath) {
|
|
373
|
+
const settle = await waitForFileMtimeToSettle({
|
|
374
|
+
filePath: transcriptPath,
|
|
375
|
+
stableWindowMs: 350,
|
|
376
|
+
timeoutMs: 2500,
|
|
377
|
+
pollMs: 120,
|
|
378
|
+
});
|
|
379
|
+
resolvedContext.transcriptPath = transcriptPath;
|
|
380
|
+
if (!settle.settled) {
|
|
381
|
+
console.log(`hook=settle-timeout sessionId=${resolvedContext.sessionId} waitedMs=${settle.waitedMs}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
245
385
|
await runContextExportMode({
|
|
246
386
|
runtimeName,
|
|
247
387
|
context: resolvedContext,
|
|
@@ -565,13 +705,48 @@ function resolveOpenclawPluginDir() {
|
|
|
565
705
|
if (process.env.SPANORY_OPENCLAW_PLUGIN_DIR) {
|
|
566
706
|
return process.env.SPANORY_OPENCLAW_PLUGIN_DIR;
|
|
567
707
|
}
|
|
568
|
-
|
|
708
|
+
const candidates = [
|
|
709
|
+
resolveInstalledPackageDir('@bububuger/spanory-openclaw-plugin'),
|
|
710
|
+
path.resolve(CLI_PACKAGE_DIR, '..', 'openclaw-plugin'),
|
|
711
|
+
path.resolve(process.cwd(), 'packages/openclaw-plugin'),
|
|
712
|
+
].filter(Boolean);
|
|
713
|
+
const hit = candidates.find((dir) => existsSync(path.join(dir, 'package.json')));
|
|
714
|
+
return hit ?? candidates[0] ?? path.resolve(process.cwd(), 'packages/openclaw-plugin');
|
|
569
715
|
}
|
|
570
716
|
function resolveOpencodePluginDir() {
|
|
571
717
|
if (process.env.SPANORY_OPENCODE_PLUGIN_DIR) {
|
|
572
718
|
return process.env.SPANORY_OPENCODE_PLUGIN_DIR;
|
|
573
719
|
}
|
|
574
|
-
|
|
720
|
+
const pkgCandidate = process.pkg
|
|
721
|
+
? path.resolve(path.dirname(process.execPath), '..', 'opencode-plugin')
|
|
722
|
+
: undefined;
|
|
723
|
+
const candidates = [
|
|
724
|
+
resolveInstalledPackageDir('@bububuger/spanory-opencode-plugin'),
|
|
725
|
+
pkgCandidate,
|
|
726
|
+
path.resolve(CLI_PACKAGE_DIR, '..', 'opencode-plugin'),
|
|
727
|
+
path.resolve(process.cwd(), 'packages/opencode-plugin'),
|
|
728
|
+
].filter(Boolean);
|
|
729
|
+
const hit = candidates.find((dir) => existsSync(path.join(dir, 'package.json')));
|
|
730
|
+
return hit ?? candidates[0] ?? path.resolve(process.cwd(), 'packages/opencode-plugin');
|
|
731
|
+
}
|
|
732
|
+
function resolveInstalledPackageDir(packageName) {
|
|
733
|
+
try {
|
|
734
|
+
const pkgJsonPath = requireFromHere.resolve(`${packageName}/package.json`);
|
|
735
|
+
return path.dirname(pkgJsonPath);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async function resolveOpencodePluginEntry(pluginDir) {
|
|
742
|
+
const explicitEntry = process.env.SPANORY_OPENCODE_PLUGIN_ENTRY;
|
|
743
|
+
if (explicitEntry) {
|
|
744
|
+
await stat(explicitEntry);
|
|
745
|
+
return explicitEntry;
|
|
746
|
+
}
|
|
747
|
+
const pluginEntry = path.join(pluginDir, 'dist', 'index.js');
|
|
748
|
+
await stat(pluginEntry);
|
|
749
|
+
return pluginEntry;
|
|
575
750
|
}
|
|
576
751
|
function resolveOpencodePluginInstallDir(runtimeHome) {
|
|
577
752
|
return path.join(resolveRuntimeHome('opencode', runtimeHome), 'plugin');
|
|
@@ -613,7 +788,7 @@ async function runOpenclawPluginDoctor(runtimeHome) {
|
|
|
613
788
|
id: 'otlp_endpoint',
|
|
614
789
|
ok: Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
|
|
615
790
|
detail: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
616
|
-
? process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
791
|
+
? maskEndpoint(process.env.OTEL_EXPORTER_OTLP_ENDPOINT)
|
|
617
792
|
: 'OTEL_EXPORTER_OTLP_ENDPOINT is unset',
|
|
618
793
|
});
|
|
619
794
|
const spoolDir = process.env.SPANORY_OPENCLAW_SPOOL_DIR
|
|
@@ -650,15 +825,32 @@ async function runOpencodePluginDoctor(runtimeHome) {
|
|
|
650
825
|
catch {
|
|
651
826
|
checks.push({ id: 'plugin_installed', ok: false, detail: `plugin loader missing: ${loaderFile}` });
|
|
652
827
|
}
|
|
828
|
+
try {
|
|
829
|
+
const loaderUrl = pathToFileURL(loaderFile).href;
|
|
830
|
+
const mod = await import(loaderUrl);
|
|
831
|
+
const hasDefault = typeof mod.default === 'function' || typeof mod.SpanoryOpencodePlugin === 'function';
|
|
832
|
+
checks.push({
|
|
833
|
+
id: 'plugin_loadable',
|
|
834
|
+
ok: hasDefault,
|
|
835
|
+
detail: hasDefault ? 'plugin module loaded and exports a register function' : 'plugin module loaded but missing default export function',
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
checks.push({
|
|
840
|
+
id: 'plugin_loadable',
|
|
841
|
+
ok: false,
|
|
842
|
+
detail: `plugin import failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
653
845
|
checks.push({
|
|
654
846
|
id: 'otlp_endpoint',
|
|
655
847
|
ok: Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
|
|
656
848
|
detail: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
657
|
-
? process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
849
|
+
? maskEndpoint(process.env.OTEL_EXPORTER_OTLP_ENDPOINT)
|
|
658
850
|
: 'OTEL_EXPORTER_OTLP_ENDPOINT is unset',
|
|
659
851
|
});
|
|
660
852
|
const spoolDir = process.env.SPANORY_OPENCODE_SPOOL_DIR
|
|
661
|
-
?? path.join(
|
|
853
|
+
?? path.join(resolveOpencodePluginStateRoot(runtimeHome), 'spool');
|
|
662
854
|
try {
|
|
663
855
|
await mkdir(spoolDir, { recursive: true });
|
|
664
856
|
checks.push({ id: 'spool_writable', ok: true, detail: spoolDir });
|
|
@@ -666,8 +858,8 @@ async function runOpencodePluginDoctor(runtimeHome) {
|
|
|
666
858
|
catch (err) {
|
|
667
859
|
checks.push({ id: 'spool_writable', ok: false, detail: String(err) });
|
|
668
860
|
}
|
|
669
|
-
const statusFile = path.join(
|
|
670
|
-
const logFile = path.join(
|
|
861
|
+
const statusFile = path.join(resolveOpencodePluginStateRoot(runtimeHome), 'plugin-status.json');
|
|
862
|
+
const logFile = path.join(resolveOpencodePluginStateRoot(runtimeHome), 'plugin.log');
|
|
671
863
|
try {
|
|
672
864
|
await mkdir(path.dirname(logFile), { recursive: true });
|
|
673
865
|
checks.push({ id: 'opencode_plugin_log', ok: true, detail: logFile });
|
|
@@ -685,7 +877,7 @@ async function runOpencodePluginDoctor(runtimeHome) {
|
|
|
685
877
|
checks.push({
|
|
686
878
|
id: 'last_send_endpoint_configured',
|
|
687
879
|
ok: false,
|
|
688
|
-
detail:
|
|
880
|
+
detail: `last send skipped or failed to resolve OTLP endpoint in opencode process; check ${resolveSpanoryEnvPath()} and restart opencode`,
|
|
689
881
|
});
|
|
690
882
|
}
|
|
691
883
|
else if (endpointConfigured === true) {
|
|
@@ -815,24 +1007,37 @@ function codexNotifyScriptContent({ spanoryBin, codexHome, exportDir, logFile })
|
|
|
815
1007
|
+ 'set -euo pipefail\n'
|
|
816
1008
|
+ 'payload="${1:-}"\n'
|
|
817
1009
|
+ 'if [[ -z "$payload" ]] && [[ ! -t 0 ]]; then\n'
|
|
818
|
-
+ '
|
|
1010
|
+
+ ' IFS= read -r -t 0 payload || true\n'
|
|
819
1011
|
+ 'fi\n'
|
|
820
1012
|
+ 'if [[ -z "${payload//[$\'\\t\\r\\n \']/}" ]]; then\n'
|
|
821
1013
|
+ ` echo "skip=empty-payload source=codex-notify args=$#" >> "${logFile}"\n`
|
|
822
1014
|
+ ' exit 0\n'
|
|
823
1015
|
+ 'fi\n'
|
|
1016
|
+
+ 'payload_file="$(mktemp "${TMPDIR:-/tmp}/spanory-codex-notify.XXXXXX")"\n'
|
|
1017
|
+
+ 'printf \'%s\' "$payload" > "$payload_file"\n'
|
|
824
1018
|
+ `echo "$payload" | "${spanoryBin}" runtime codex hook \\\n`
|
|
825
1019
|
+ ' --last-turn-only \\\n'
|
|
826
1020
|
+ ` --runtime-home "${codexHome}" \\\n`
|
|
827
1021
|
+ ` --export-json-dir "${exportDir}" \\\n`
|
|
828
|
-
+ ` >> "${logFile}" 2>&1 || true\n
|
|
1022
|
+
+ ` >> "${logFile}" 2>&1 || true\n`
|
|
1023
|
+
+ '(\n'
|
|
1024
|
+
+ ' sleep 2\n'
|
|
1025
|
+
+ ` cat "$payload_file" | "${spanoryBin}" runtime codex hook \\\n`
|
|
1026
|
+
+ ' --last-turn-only \\\n'
|
|
1027
|
+
+ ' --force \\\n'
|
|
1028
|
+
+ ` --runtime-home "${codexHome}" \\\n`
|
|
1029
|
+
+ ` --export-json-dir "${exportDir}" \\\n`
|
|
1030
|
+
+ ` >> "${logFile}" 2>&1 || true\n`
|
|
1031
|
+
+ ' rm -f "$payload_file"\n'
|
|
1032
|
+
+ ') >/dev/null 2>&1 &\n';
|
|
829
1033
|
}
|
|
830
1034
|
async function applyCodexSetup({ homeRoot, spanoryBin, dryRun }) {
|
|
831
1035
|
const codexHome = path.join(homeRoot, '.codex');
|
|
1036
|
+
const spanoryHome = process.env.SPANORY_HOME ?? path.join(homeRoot, '.spanory');
|
|
832
1037
|
const binDir = path.join(codexHome, 'bin');
|
|
833
1038
|
const stateDir = path.join(codexHome, 'state', 'spanory');
|
|
834
1039
|
const scriptPath = path.join(binDir, 'spanory-codex-notify.sh');
|
|
835
|
-
const logFile = path.join(
|
|
1040
|
+
const logFile = path.join(spanoryHome, 'logs', 'codex-notify.log');
|
|
836
1041
|
const configPath = path.join(codexHome, 'config.toml');
|
|
837
1042
|
const notifyScriptRef = scriptPath;
|
|
838
1043
|
const scriptContent = codexNotifyScriptContent({
|
|
@@ -865,6 +1070,7 @@ async function applyCodexSetup({ homeRoot, spanoryBin, dryRun }) {
|
|
|
865
1070
|
if (changed && !dryRun) {
|
|
866
1071
|
await mkdir(binDir, { recursive: true });
|
|
867
1072
|
await mkdir(stateDir, { recursive: true });
|
|
1073
|
+
await mkdir(path.dirname(logFile), { recursive: true });
|
|
868
1074
|
if (configChanged) {
|
|
869
1075
|
configBackup = await backupIfExists(configPath);
|
|
870
1076
|
await writeFile(configPath, nextConfig, 'utf-8');
|
|
@@ -888,6 +1094,38 @@ function commandExists(command) {
|
|
|
888
1094
|
const result = runSystemCommand('which', [command], { env: process.env });
|
|
889
1095
|
return result.code === 0;
|
|
890
1096
|
}
|
|
1097
|
+
function detectUpgradePackageManager(userAgent = process.env.npm_config_user_agent) {
|
|
1098
|
+
const token = String(userAgent ?? '').trim().split(/\s+/)[0] ?? '';
|
|
1099
|
+
if (token.startsWith('tnpm/'))
|
|
1100
|
+
return 'tnpm';
|
|
1101
|
+
return 'npm';
|
|
1102
|
+
}
|
|
1103
|
+
function detectUpgradeScope() {
|
|
1104
|
+
if (String(process.env.npm_config_global ?? '').trim() === 'true') {
|
|
1105
|
+
return 'global';
|
|
1106
|
+
}
|
|
1107
|
+
const normalizedEntry = String(EXECUTION_ENTRY ?? '').replaceAll('\\', '/');
|
|
1108
|
+
if (normalizedEntry.includes('/lib/node_modules/') || normalizedEntry.includes('/npm-global/')) {
|
|
1109
|
+
return 'global';
|
|
1110
|
+
}
|
|
1111
|
+
if (normalizedEntry.includes('/node_modules/')) {
|
|
1112
|
+
return 'local';
|
|
1113
|
+
}
|
|
1114
|
+
return 'global';
|
|
1115
|
+
}
|
|
1116
|
+
function resolveUpgradeInvocation(scope, manager) {
|
|
1117
|
+
const selectedManager = manager === 'tnpm' ? 'tnpm' : 'npm';
|
|
1118
|
+
const args = ['install'];
|
|
1119
|
+
if (scope === 'global')
|
|
1120
|
+
args.push('-g');
|
|
1121
|
+
args.push(`${SPANORY_NPM_PACKAGE}@latest`);
|
|
1122
|
+
return {
|
|
1123
|
+
manager: selectedManager,
|
|
1124
|
+
command: selectedManager,
|
|
1125
|
+
args,
|
|
1126
|
+
scope,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
891
1129
|
function openclawRuntimeHomeForSetup(homeRoot, explicitRuntimeHome) {
|
|
892
1130
|
return explicitRuntimeHome || path.join(homeRoot, '.openclaw');
|
|
893
1131
|
}
|
|
@@ -919,10 +1157,16 @@ function installOpenclawPlugin(runtimeHome) {
|
|
|
919
1157
|
enableStdout: enableResult.stdout.trim(),
|
|
920
1158
|
};
|
|
921
1159
|
}
|
|
922
|
-
async function installOpencodePlugin(runtimeHome) {
|
|
923
|
-
const pluginDir = path.resolve(resolveOpencodePluginDir());
|
|
924
|
-
|
|
925
|
-
|
|
1160
|
+
async function installOpencodePlugin(runtimeHome, pluginDirOverride) {
|
|
1161
|
+
const pluginDir = path.resolve(pluginDirOverride ?? resolveOpencodePluginDir());
|
|
1162
|
+
let pluginEntry;
|
|
1163
|
+
try {
|
|
1164
|
+
pluginEntry = await resolveOpencodePluginEntry(pluginDir);
|
|
1165
|
+
}
|
|
1166
|
+
catch {
|
|
1167
|
+
throw new Error(`opencode plugin entry not found at ${path.join(pluginDir, 'dist', 'index.js')}. `
|
|
1168
|
+
+ 'build plugin first: npm run --workspace @bububuger/spanory-opencode-plugin build');
|
|
1169
|
+
}
|
|
926
1170
|
const installDir = resolveOpencodePluginInstallDir(runtimeHome);
|
|
927
1171
|
const loaderFile = opencodePluginLoaderPath(runtimeHome);
|
|
928
1172
|
await mkdir(installDir, { recursive: true });
|
|
@@ -1477,18 +1721,8 @@ function registerRuntimeCommands(runtimeRoot, runtimeName) {
|
|
|
1477
1721
|
.option('--plugin-dir <path>', 'Plugin directory path (default: packages/opencode-plugin)')
|
|
1478
1722
|
.option('--runtime-home <path>', 'Override OpenCode runtime home (default: ~/.config/opencode)')
|
|
1479
1723
|
.action(async (options) => {
|
|
1480
|
-
const
|
|
1481
|
-
|
|
1482
|
-
await stat(pluginEntry);
|
|
1483
|
-
const installDir = resolveOpencodePluginInstallDir(options.runtimeHome);
|
|
1484
|
-
const loaderFile = opencodePluginLoaderPath(options.runtimeHome);
|
|
1485
|
-
await mkdir(installDir, { recursive: true });
|
|
1486
|
-
const importUrl = pathToFileURL(pluginEntry).href;
|
|
1487
|
-
const loader = `import plugin from ${JSON.stringify(importUrl)};\n`
|
|
1488
|
-
+ 'export const SpanoryOpencodePlugin = plugin;\n'
|
|
1489
|
-
+ 'export default SpanoryOpencodePlugin;\n';
|
|
1490
|
-
await writeFile(loaderFile, loader, 'utf-8');
|
|
1491
|
-
console.log(`installed=${loaderFile}`);
|
|
1724
|
+
const result = await installOpencodePlugin(options.runtimeHome, options.pluginDir);
|
|
1725
|
+
console.log(`installed=${result.loaderFile}`);
|
|
1492
1726
|
});
|
|
1493
1727
|
plugin
|
|
1494
1728
|
.command('uninstall')
|
|
@@ -1517,7 +1751,7 @@ program
|
|
|
1517
1751
|
.description('Cross-runtime observability CLI for agent sessions')
|
|
1518
1752
|
.showHelpAfterError()
|
|
1519
1753
|
.showSuggestionAfterError(true)
|
|
1520
|
-
.version('
|
|
1754
|
+
.version(CLI_VERSION, '-v, --version');
|
|
1521
1755
|
const runtime = program.command('runtime').description('Runtime-specific parsers and exporters');
|
|
1522
1756
|
for (const runtimeName of ['claude-code', 'codex', 'openclaw', 'opencode']) {
|
|
1523
1757
|
registerRuntimeCommands(runtime, runtimeName);
|
|
@@ -1571,6 +1805,14 @@ report
|
|
|
1571
1805
|
const sessions = await loadExportedEvents(options.inputJson);
|
|
1572
1806
|
console.log(JSON.stringify({ view: 'tool-summary', rows: summarizeTools(sessions) }, null, 2));
|
|
1573
1807
|
});
|
|
1808
|
+
report
|
|
1809
|
+
.command('context')
|
|
1810
|
+
.description('Context observability summary per session')
|
|
1811
|
+
.requiredOption('--input-json <path>', 'Path to exported JSON file or directory of JSON files')
|
|
1812
|
+
.action(async (options) => {
|
|
1813
|
+
const sessions = await loadExportedEvents(options.inputJson);
|
|
1814
|
+
console.log(JSON.stringify({ view: 'context-summary', rows: summarizeContext(sessions) }, null, 2));
|
|
1815
|
+
});
|
|
1574
1816
|
report
|
|
1575
1817
|
.command('turn-diff')
|
|
1576
1818
|
.description('Turn input diff summary view')
|
|
@@ -1613,9 +1855,66 @@ alert
|
|
|
1613
1855
|
process.exitCode = 2;
|
|
1614
1856
|
}
|
|
1615
1857
|
});
|
|
1858
|
+
const issue = program.command('issue').description('Manage local issue status for automation巡检');
|
|
1859
|
+
issue
|
|
1860
|
+
.command('sync')
|
|
1861
|
+
.description('Sync pending items from todo.md into issue state file')
|
|
1862
|
+
.option('--todo-file <path>', 'Path to todo markdown file (default: ./todo.md)')
|
|
1863
|
+
.option('--state-file <path>', 'Path to issue state json file (default: ./docs/issues/state.json)')
|
|
1864
|
+
.action(async (options) => {
|
|
1865
|
+
const todoFile = resolveTodoPath(options.todoFile);
|
|
1866
|
+
const stateFile = resolveIssueStatePath(options.stateFile);
|
|
1867
|
+
const todoRaw = await readFile(todoFile, 'utf-8');
|
|
1868
|
+
const pending = parsePendingTodoItems(todoRaw, 'todo.md');
|
|
1869
|
+
const prev = await loadIssueState(stateFile);
|
|
1870
|
+
const result = syncIssueState(prev, pending);
|
|
1871
|
+
await saveIssueState(stateFile, result.state);
|
|
1872
|
+
console.log(JSON.stringify({
|
|
1873
|
+
stateFile,
|
|
1874
|
+
todoFile,
|
|
1875
|
+
pending: pending.length,
|
|
1876
|
+
added: result.added,
|
|
1877
|
+
reopened: result.reopened,
|
|
1878
|
+
autoClosed: result.autoClosed,
|
|
1879
|
+
total: result.state.issues.length,
|
|
1880
|
+
}, null, 2));
|
|
1881
|
+
});
|
|
1882
|
+
issue
|
|
1883
|
+
.command('list')
|
|
1884
|
+
.description('List issues from state file')
|
|
1885
|
+
.option('--state-file <path>', 'Path to issue state json file (default: ./docs/issues/state.json)')
|
|
1886
|
+
.option('--status <status>', 'Filter by status: open,in_progress,blocked,done')
|
|
1887
|
+
.action(async (options) => {
|
|
1888
|
+
const stateFile = resolveIssueStatePath(options.stateFile);
|
|
1889
|
+
const state = await loadIssueState(stateFile);
|
|
1890
|
+
const statusFilter = options.status ? String(options.status).trim() : '';
|
|
1891
|
+
const rows = statusFilter
|
|
1892
|
+
? state.issues.filter((item) => item.status === statusFilter)
|
|
1893
|
+
: state.issues;
|
|
1894
|
+
console.log(JSON.stringify({ stateFile, total: rows.length, issues: rows }, null, 2));
|
|
1895
|
+
});
|
|
1896
|
+
issue
|
|
1897
|
+
.command('set-status')
|
|
1898
|
+
.description('Update one issue status in state file')
|
|
1899
|
+
.requiredOption('--id <id>', 'Issue id, e.g. T2')
|
|
1900
|
+
.requiredOption('--status <status>', 'Target status: open|in_progress|blocked|done')
|
|
1901
|
+
.option('--note <text>', 'Optional status note')
|
|
1902
|
+
.option('--state-file <path>', 'Path to issue state json file (default: ./docs/issues/state.json)')
|
|
1903
|
+
.action(async (options) => {
|
|
1904
|
+
const stateFile = resolveIssueStatePath(options.stateFile);
|
|
1905
|
+
const prev = await loadIssueState(stateFile);
|
|
1906
|
+
const next = setIssueStatus(prev, {
|
|
1907
|
+
id: options.id,
|
|
1908
|
+
status: options.status,
|
|
1909
|
+
note: options.note,
|
|
1910
|
+
});
|
|
1911
|
+
await saveIssueState(stateFile, next);
|
|
1912
|
+
const issueItem = next.issues.find((item) => item.id === options.id);
|
|
1913
|
+
console.log(JSON.stringify({ stateFile, issue: issueItem }, null, 2));
|
|
1914
|
+
});
|
|
1616
1915
|
program
|
|
1617
1916
|
.command('hook')
|
|
1618
|
-
.description('Minimal hook entrypoint (defaults to runtime payload + ~/.env + default export dir)')
|
|
1917
|
+
.description('Minimal hook entrypoint (defaults to runtime payload + ~/.spanory/.env + default export dir)')
|
|
1619
1918
|
.option('--runtime <name>', 'Runtime name (default: SPANORY_HOOK_RUNTIME or claude-code)')
|
|
1620
1919
|
.option('--runtime-home <path>', 'Override runtime home directory')
|
|
1621
1920
|
.option('--endpoint <url>', 'OTLP HTTP endpoint (fallback: OTEL_EXPORTER_OTLP_ENDPOINT)')
|
|
@@ -1680,6 +1979,33 @@ setup
|
|
|
1680
1979
|
if (!report.ok)
|
|
1681
1980
|
process.exitCode = 2;
|
|
1682
1981
|
});
|
|
1982
|
+
program
|
|
1983
|
+
.command('upgrade')
|
|
1984
|
+
.description('Upgrade spanory CLI from npm registry')
|
|
1985
|
+
.option('--dry-run', 'Print upgrade command without executing', false)
|
|
1986
|
+
.option('--manager <name>', 'Package manager override: npm|tnpm')
|
|
1987
|
+
.option('--scope <scope>', 'Install scope override: global|local')
|
|
1988
|
+
.action(async (options) => {
|
|
1989
|
+
const manager = options.manager === 'tnpm' ? 'tnpm' : detectUpgradePackageManager();
|
|
1990
|
+
const scope = options.scope === 'local' ? 'local' : options.scope === 'global' ? 'global' : detectUpgradeScope();
|
|
1991
|
+
const invocation = resolveUpgradeInvocation(scope, manager);
|
|
1992
|
+
if (options.dryRun) {
|
|
1993
|
+
console.log(JSON.stringify({ dryRun: true, ...invocation }, null, 2));
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
const result = runSystemCommand(invocation.command, invocation.args, { env: process.env });
|
|
1997
|
+
const output = (result.stdout || result.stderr || '').trim();
|
|
1998
|
+
if (result.code !== 0) {
|
|
1999
|
+
console.error(output || result.error || 'upgrade failed');
|
|
2000
|
+
process.exitCode = 2;
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
console.log(JSON.stringify({
|
|
2004
|
+
ok: true,
|
|
2005
|
+
...invocation,
|
|
2006
|
+
output,
|
|
2007
|
+
}, null, 2));
|
|
2008
|
+
});
|
|
1683
2009
|
loadUserEnv()
|
|
1684
2010
|
.then(() => program.parseAsync(process.argv))
|
|
1685
2011
|
.catch((error) => {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare const ISSUE_STATUSES: readonly ["open", "in_progress", "blocked", "done"];
|
|
2
|
+
export type IssueStatus = (typeof ISSUE_STATUSES)[number];
|
|
3
|
+
export interface IssueItem {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
source: string;
|
|
7
|
+
status: IssueStatus;
|
|
8
|
+
notes: string[];
|
|
9
|
+
createdAt: string;
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
closedAt?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface IssueState {
|
|
14
|
+
version: 1;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
issues: IssueItem[];
|
|
17
|
+
}
|
|
18
|
+
export interface PendingTodoItem {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
source: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function parsePendingTodoItems(todoContent: string, source?: string): PendingTodoItem[];
|
|
24
|
+
export declare function syncIssueState(prev: IssueState, pending: PendingTodoItem[], now?: string): {
|
|
25
|
+
state: IssueState;
|
|
26
|
+
added: number;
|
|
27
|
+
reopened: number;
|
|
28
|
+
autoClosed: number;
|
|
29
|
+
};
|
|
30
|
+
export declare function setIssueStatus(prev: IssueState, input: {
|
|
31
|
+
id: string;
|
|
32
|
+
status: string;
|
|
33
|
+
note?: string;
|
|
34
|
+
}, now?: string): IssueState;
|
|
35
|
+
export declare function createEmptyIssueState(now?: string): IssueState;
|
|
36
|
+
export declare function loadIssueState(filePath: string): Promise<IssueState>;
|
|
37
|
+
export declare function saveIssueState(filePath: string, state: IssueState): Promise<void>;
|
|
38
|
+
export declare function resolveIssueStatePath(input?: string): string;
|
|
39
|
+
export declare function resolveTodoPath(input?: string): string;
|