@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/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: process.env.SPANORY_VERSION ?? '0.1.1',
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
- const chunks = [];
69
- for await (const chunk of process.stdin)
70
- chunks.push(chunk);
71
- return Buffer.concat(chunks).toString('utf-8');
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
- return path.resolve(process.cwd(), 'packages/openclaw-plugin');
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
- return path.resolve(process.cwd(), 'packages/opencode-plugin');
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(resolveRuntimeStateRoot('opencode', runtimeHome), 'spanory', 'spool');
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(resolveRuntimeStateRoot('opencode', runtimeHome), 'spanory', 'plugin-status.json');
670
- const logFile = path.join(resolveRuntimeStateRoot('opencode', runtimeHome), 'spanory', 'plugin.log');
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: 'last send skipped or failed to resolve OTLP endpoint in opencode process; check ~/.env and restart opencode',
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
- + ' payload="$(cat || true)"\n'
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(codexHome, 'state', 'spanory-codex-hook.log');
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
- const pluginEntry = path.join(pluginDir, 'src', 'index.js');
925
- await stat(pluginEntry);
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 pluginDir = path.resolve(options.pluginDir ?? resolveOpencodePluginDir());
1481
- const pluginEntry = path.join(pluginDir, 'src', 'index.js');
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('0.1.1');
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;