wendkeep 0.37.0 → 0.38.1

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.
@@ -1,209 +1,212 @@
1
- #!/usr/bin/env node
2
- import { existsSync, readFileSync } from 'fs';
3
- import { join } from 'path';
4
- import { pathToFileURL } from 'url';
5
- import {
6
- controlPath,
7
- getVaultBase,
8
- listMarkdownFiles,
9
- readControl,
10
- readSessionRegistry,
11
- wikilinkFromRel,
12
- } from './obsidian-common.mjs';
13
- import { getLocale } from './locale.mjs';
14
-
15
- const DEFAULT_PENDING_PATTERNS = [
16
- /^- \[ \] Revisar resumo da sessão$/i,
17
- /^- \[ \] Verificar se houve decisões a registrar$/i,
18
- /^- \[ \] Verificar se houve bugs a registrar$/i,
19
- /^- \[ \] Verificar se houve aprendizados a registrar$/i,
20
- ];
21
-
22
- function parseArgs(argv) {
23
- const args = {};
24
- for (let i = 0; i < argv.length; i += 1) {
25
- const item = argv[i];
26
- if (!item.startsWith('--')) continue;
27
- const key = item.slice(2);
28
- const next = argv[i + 1];
29
- if (!next || next.startsWith('--')) {
30
- args[key] = true;
31
- } else {
32
- args[key] = next;
33
- i += 1;
34
- }
35
- }
36
- return args;
37
- }
38
-
39
- function findDuplicateTurnMarkers(content) {
40
- const seen = new Set();
41
- const duplicated = new Set();
42
- const regex = /<!-- (?:wk-turn|codex-turn): ([^>]+) -->/g;
43
- let match;
44
- while ((match = regex.exec(content)) !== null) {
45
- const turnId = match[1].trim();
46
- if (seen.has(turnId)) duplicated.add(turnId);
47
- seen.add(turnId);
48
- }
49
- return [...duplicated];
50
- }
51
-
52
- function hasHeadingAfterClosing(content) {
53
- const closing = content.indexOf('\n## Encerramento');
54
- if (closing === -1) return false;
55
- return /\n#{2,3} /.test(content.slice(closing + '\n## Encerramento'.length));
56
- }
57
-
58
- function sectionBody(content, heading) {
59
- const marker = `\n## ${heading}\n`;
60
- const start = content.indexOf(marker);
61
- if (start === -1) return '';
62
- const bodyStart = start + marker.length;
63
- const next = content.slice(bodyStart).search(/\n## /);
64
- const bodyEnd = next === -1 ? content.length : bodyStart + next;
65
- return content.slice(bodyStart, bodyEnd);
66
- }
67
-
68
- function hasDefaultPending(content) {
69
- return sectionBody(content, 'Pendências')
70
- .split('\n')
71
- .some((line) => DEFAULT_PENDING_PATTERNS.some((pattern) => pattern.test(line.trim())));
72
- }
73
-
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import {
6
+ controlPath,
7
+ getVaultBase,
8
+ listMarkdownFiles,
9
+ readControl,
10
+ readSessionRegistry,
11
+ wikilinkFromRel,
12
+ } from './obsidian-common.mjs';
13
+ import { getLocale } from './locale.mjs';
14
+
15
+ const DEFAULT_PENDING_PATTERNS = [
16
+ /^- \[ \] Revisar resumo da sessão$/i,
17
+ /^- \[ \] Verificar se houve decisões a registrar$/i,
18
+ /^- \[ \] Verificar se houve bugs a registrar$/i,
19
+ /^- \[ \] Verificar se houve aprendizados a registrar$/i,
20
+ ];
21
+
22
+ function parseArgs(argv) {
23
+ const args = {};
24
+ for (let i = 0; i < argv.length; i += 1) {
25
+ const item = argv[i];
26
+ if (!item.startsWith('--')) continue;
27
+ const key = item.slice(2);
28
+ const next = argv[i + 1];
29
+ if (!next || next.startsWith('--')) {
30
+ args[key] = true;
31
+ } else {
32
+ args[key] = next;
33
+ i += 1;
34
+ }
35
+ }
36
+ return args;
37
+ }
38
+
39
+ function findDuplicateTurnMarkers(content) {
40
+ const seen = new Set();
41
+ const duplicated = new Set();
42
+ const regex = /<!-- (?:wk-turn|codex-turn): ([^>]+) -->/g;
43
+ let match;
44
+ while ((match = regex.exec(content)) !== null) {
45
+ const turnId = match[1].trim();
46
+ if (seen.has(turnId)) duplicated.add(turnId);
47
+ seen.add(turnId);
48
+ }
49
+ return [...duplicated];
50
+ }
51
+
52
+ function hasHeadingAfterClosing(content) {
53
+ const closing = content.indexOf('\n## Encerramento');
54
+ if (closing === -1) return false;
55
+ return /\n#{2,3} /.test(content.slice(closing + '\n## Encerramento'.length));
56
+ }
57
+
58
+ function sectionBody(content, heading) {
59
+ const marker = `\n## ${heading}\n`;
60
+ const start = content.indexOf(marker);
61
+ if (start === -1) return '';
62
+ const bodyStart = start + marker.length;
63
+ const next = content.slice(bodyStart).search(/\n## /);
64
+ const bodyEnd = next === -1 ? content.length : bodyStart + next;
65
+ return content.slice(bodyStart, bodyEnd);
66
+ }
67
+
68
+ function hasDefaultPending(content) {
69
+ return sectionBody(content, 'Pendências')
70
+ .split('\n')
71
+ .some((line) => DEFAULT_PENDING_PATTERNS.some((pattern) => pattern.test(line.trim())));
72
+ }
73
+
74
74
  function usageSectionIsPlaced(content, { active = false } = {}) {
75
75
  const unified = content.indexOf('\n## Agentes, tokens e custos');
76
76
  const legacy = content.indexOf('\n## Uso de tokens e custos');
77
77
  const usage = unified !== -1 ? unified : legacy;
78
- if (usage === -1) return true;
79
- const changed = content.indexOf('\n## Arquivos criados ou alterados');
80
- const pending = content.indexOf('\n## Pendências');
81
- const closing = content.indexOf('\n## Encerramento');
78
+ if (usage === -1) return true;
79
+ const changed = content.indexOf('\n## Arquivos criados ou alterados');
80
+ const pending = content.indexOf('\n## Pendências');
81
+ const closing = content.indexOf('\n## Encerramento');
82
82
  if (pending === -1 || (!active && closing === -1)) return false;
83
83
  return usage < pending && (active || usage < closing) && (changed === -1 || usage > changed);
84
- }
85
-
86
- function linkedNotesFromSession(content) {
87
- const notes = [];
88
- const regex = /\[\[((?:04-Decisões|05-Bugs|06-Aprendizados)\/[^\]]+)\]\]/g;
89
- let match;
90
- while ((match = regex.exec(content)) !== null) {
91
- if (!notes.includes(match[1])) notes.push(match[1]);
92
- }
93
- return notes;
94
- }
95
-
96
- function checkSession({ vaultBase, sessionRel, control, registry }) {
97
- const failures = [];
98
- const warnings = [];
99
- const metrics = {};
100
- const sessionPath = join(vaultBase, sessionRel);
101
-
102
- if (!existsSync(sessionPath)) {
103
- failures.push(`Sessão não encontrada: ${sessionRel}`);
104
- return { failures, warnings, metrics };
105
- }
106
-
84
+ }
85
+
86
+ function linkedNotesFromSession(content) {
87
+ const notes = [];
88
+ const regex = /\[\[((?:04-Decisões|05-Bugs|06-Aprendizados)\/[^\]]+)\]\]/g;
89
+ let match;
90
+ while ((match = regex.exec(content)) !== null) {
91
+ if (!notes.includes(match[1])) notes.push(match[1]);
92
+ }
93
+ return notes;
94
+ }
95
+
96
+ function checkSession({ vaultBase, sessionRel, control, registry }) {
97
+ const failures = [];
98
+ const warnings = [];
99
+ const metrics = {};
100
+ const sessionPath = join(vaultBase, sessionRel);
101
+
102
+ if (!existsSync(sessionPath)) {
103
+ failures.push(`Sessão não encontrada: ${sessionRel}`);
104
+ return { failures, warnings, metrics };
105
+ }
106
+
107
107
  const content = readFileSync(sessionPath, 'utf-8');
108
108
  const activeSession = control.status === 'active' && control.session_file === sessionRel;
109
- const duplicates = findDuplicateTurnMarkers(content);
110
- metrics.turnMarkers = (content.match(/<!-- (?:wk-turn|codex-turn):/g) || []).length;
111
- metrics.duplicateTurnMarkers = duplicates.length;
112
-
109
+ const duplicates = findDuplicateTurnMarkers(content);
110
+ metrics.turnMarkers = (content.match(/<!-- (?:wk-turn|codex-turn):/g) || []).length;
111
+ metrics.duplicateTurnMarkers = duplicates.length;
112
+
113
113
  if (duplicates.length) failures.push(`Marcadores de turno duplicados: ${duplicates.join(', ')}`);
114
114
  if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
115
115
  if (!usageSectionIsPlaced(content, { active: activeSession })) failures.push('A seção de agentes, tokens e custos está fora da posição esperada.');
116
116
  if (content.includes('\n## Agentes, tokens e custos') && (content.includes('\n## Uso de tokens e custos') || content.includes('\n## Subagents & Workflows'))) {
117
117
  failures.push('A sessão mistura observabilidade consolidada e seções legadas.');
118
118
  }
119
- if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
120
-
121
- const registryEntry = registry.sessions?.[control.session_id];
122
- if (control.session_id && !registryEntry) {
123
- failures.push(`SESSION_REGISTRY não possui session_id ativo: ${control.session_id}`);
124
- } else if (registryEntry) {
125
- if (registryEntry.session_file !== sessionRel) {
126
- failures.push('SESSION_REGISTRY diverge do CURRENT_SESSION.md para a sessão ativa.');
127
- }
128
- if (!registryEntry.transcript_path) {
129
- warnings.push('SESSION_REGISTRY não possui transcript_path para a sessão ativa.');
130
- } else if (!existsSync(registryEntry.transcript_path)) {
131
- warnings.push(`Transcript da sessão ativa não encontrado: ${registryEntry.transcript_path}`);
132
- }
133
- }
134
-
135
- const sessionLink = wikilinkFromRel(sessionRel);
136
- for (const noteRel of linkedNotesFromSession(content)) {
137
- const notePath = join(vaultBase, noteRel.endsWith('.md') ? noteRel : `${noteRel}.md`);
138
- if (!existsSync(notePath)) {
139
- failures.push(`Nota derivada linkada não existe: ${noteRel}`);
140
- continue;
141
- }
142
- const noteContent = readFileSync(notePath, 'utf-8');
143
- if (!noteContent.includes(sessionLink) && !noteContent.includes(sessionRel)) {
144
- failures.push(`Nota derivada sem backlink para a sessão: ${noteRel}`);
145
- }
146
- }
147
-
148
- return { failures, warnings, metrics };
149
- }
150
-
151
- export function runVaultHealth({ vaultBase, session = '' }) {
152
- const control = readControl(vaultBase);
153
- const registry = readSessionRegistry(vaultBase);
154
- const sessionRel = session || control.session_file || control.last_session_file || '';
155
- const failures = [];
156
- const warnings = [];
157
-
158
- if (!existsSync(controlPath(vaultBase))) {
159
- failures.push('CURRENT_SESSION.md não encontrado.');
160
- }
161
- if (!sessionRel) failures.push('Nenhuma sessão ativa ou última sessão encontrada no controle.');
162
-
163
- const sessionResult = sessionRel
164
- ? checkSession({ vaultBase, sessionRel, control, registry })
165
- : { failures: [], warnings: [], metrics: {} };
166
- failures.push(...sessionResult.failures);
167
- warnings.push(...sessionResult.warnings);
168
-
169
- const staleDone = Object.values(registry.sessions || {})
170
- .filter((item) => item.status === 'active' && item.ended_at)
171
- .length;
172
- if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
173
-
174
- const locF = getLocale(vaultBase).folders;
175
- const derivedFolders = [locF.decisions, locF.bugs, locF.learnings];
176
- const derivedCount = derivedFolders.reduce((total, folder) => {
177
- const dir = join(vaultBase, folder);
178
- return total + (existsSync(dir) ? listMarkdownFiles(dir).length : 0);
179
- }, 0);
180
-
181
- return {
182
- ok: failures.length === 0,
183
- session: sessionRel,
184
- failures,
185
- warnings,
186
- metrics: {
187
- ...sessionResult.metrics,
188
- registrySessions: Object.keys(registry.sessions || {}).length,
189
- derivedNotes: derivedCount,
190
- },
191
- };
192
- }
193
-
194
- function main() {
195
- const args = parseArgs(process.argv.slice(2));
196
- const vaultBase = getVaultBase({ obsidian_vault_path: args.vault });
197
- const result = runVaultHealth({ vaultBase, session: args.session || '' });
198
- console.log(JSON.stringify(result, null, 2));
199
- if (!result.ok) process.exitCode = 1;
200
- }
201
-
202
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
203
- try {
204
- main();
205
- } catch (error) {
206
- process.stderr.write(`[wendkeep] Vault health falhou: ${error.message}\n`);
207
- process.exitCode = 1;
208
- }
209
- }
119
+ if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
120
+
121
+ const registryPair = Object.entries(registry.sessions || {}).find(([, entry]) => entry?.session_file === sessionRel);
122
+ const registryEntry = registryPair?.[1];
123
+ if (!registryEntry) {
124
+ failures.push(`SESSION_REGISTRY não possui a sessão: ${sessionRel}`);
125
+ } else {
126
+ if (!registryEntry.transcript_path) {
127
+ warnings.push('SESSION_REGISTRY não possui transcript_path para a sessão ativa.');
128
+ } else if (!existsSync(registryEntry.transcript_path)) {
129
+ warnings.push(`Transcript da sessão ativa não encontrado: ${registryEntry.transcript_path}`);
130
+ }
131
+ }
132
+
133
+ const sessionLink = wikilinkFromRel(sessionRel);
134
+ for (const noteRel of linkedNotesFromSession(content)) {
135
+ const notePath = join(vaultBase, noteRel.endsWith('.md') ? noteRel : `${noteRel}.md`);
136
+ if (!existsSync(notePath)) {
137
+ failures.push(`Nota derivada linkada não existe: ${noteRel}`);
138
+ continue;
139
+ }
140
+ const noteContent = readFileSync(notePath, 'utf-8');
141
+ if (!noteContent.includes(sessionLink) && !noteContent.includes(sessionRel)) {
142
+ failures.push(`Nota derivada sem backlink para a sessão: ${noteRel}`);
143
+ }
144
+ }
145
+
146
+ return { failures, warnings, metrics };
147
+ }
148
+
149
+ export function runVaultHealth({ vaultBase, session = '' }) {
150
+ const control = readControl(vaultBase);
151
+ const registry = readSessionRegistry(vaultBase);
152
+ const sessionRel = session || control.session_file || control.last_session_file || '';
153
+ const failures = [];
154
+ const warnings = [];
155
+
156
+ if (!existsSync(controlPath(vaultBase))) {
157
+ failures.push('CURRENT_SESSION.md não encontrado.');
158
+ }
159
+ if (!sessionRel) failures.push('Nenhuma sessão ativa ou última sessão encontrada no controle.');
160
+
161
+ const sessionResult = sessionRel
162
+ ? checkSession({ vaultBase, sessionRel, control, registry })
163
+ : { failures: [], warnings: [], metrics: {} };
164
+ failures.push(...sessionResult.failures);
165
+ warnings.push(...sessionResult.warnings);
166
+
167
+ const staleDone = Object.values(registry.sessions || {})
168
+ .filter((item) => item.status === 'active' && item.ended_at)
169
+ .length;
170
+ if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
171
+ const activeEntries = Object.values(registry.sessions || {}).filter((item) => item?.status === 'active');
172
+ for (const entry of activeEntries) {
173
+ if (!entry.session_file) failures.push('SESSION_REGISTRY possui sessão ativa sem session_file.');
174
+ if (!entry.transcript_path) warnings.push(`Sessão ativa sem transcript_path: ${entry.session_file || '(sem arquivo)'}`);
175
+ }
176
+
177
+ const locF = getLocale(vaultBase).folders;
178
+ const derivedFolders = [locF.decisions, locF.bugs, locF.learnings];
179
+ const derivedCount = derivedFolders.reduce((total, folder) => {
180
+ const dir = join(vaultBase, folder);
181
+ return total + (existsSync(dir) ? listMarkdownFiles(dir).length : 0);
182
+ }, 0);
183
+
184
+ return {
185
+ ok: failures.length === 0,
186
+ session: sessionRel,
187
+ failures,
188
+ warnings,
189
+ metrics: {
190
+ ...sessionResult.metrics,
191
+ registrySessions: Object.keys(registry.sessions || {}).length,
192
+ derivedNotes: derivedCount,
193
+ },
194
+ };
195
+ }
196
+
197
+ function main() {
198
+ const args = parseArgs(process.argv.slice(2));
199
+ const vaultBase = getVaultBase({ obsidian_vault_path: args.vault });
200
+ const result = runVaultHealth({ vaultBase, session: args.session || '' });
201
+ console.log(JSON.stringify(result, null, 2));
202
+ if (!result.ok) process.exitCode = 1;
203
+ }
204
+
205
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
206
+ try {
207
+ main();
208
+ } catch (error) {
209
+ process.stderr.write(`[wendkeep] Vault health falhou: ${error.message}\n`);
210
+ process.exitCode = 1;
211
+ }
212
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.37.0",
3
+ "version": "0.38.1",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/change.mjs CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  } from '../hooks/change-core.mjs';
19
19
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
20
20
  import { buildEffectiveRequirementPackage, evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
21
- import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
21
+ import { getNextAdrNumber, readControl, readSessionRegistry, upsertSessionRegistry } from '../hooks/obsidian-common.mjs';
22
22
  import { getLocale } from '../hooks/locale.mjs';
23
23
 
24
24
  function resolveVault(argv) {
@@ -51,7 +51,7 @@ function today() {
51
51
  export function runChange(argv) {
52
52
  const [sub, ...rest] = argv;
53
53
  const vaultBase = resolveVault(rest);
54
- const VALUE_FLAGS = new Set(['--vault', '--change', '--project']);
54
+ const VALUE_FLAGS = new Set(['--vault', '--change', '--project', '--session']);
55
55
  const slugArg = () => rest.find((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
56
56
 
57
57
  if (sub === 'new') {
@@ -74,6 +74,18 @@ export function runChange(argv) {
74
74
  process.exit(0);
75
75
  }
76
76
 
77
+ if (sub === 'bind') {
78
+ const slug = slugArg();
79
+ const sessionId = opt(rest, '--session');
80
+ if (!slug || !sessionId) { process.stderr.write('wendkeep change bind: use <slug> --session <id>\n'); process.exit(2); }
81
+ const state = allChangesState(vaultBase);
82
+ if (!state.changes.some((item) => item.slug === slug)) { process.stderr.write(`wendkeep change bind: open change not found: ${slug}\n`); process.exit(2); }
83
+ if (!readSessionRegistry(vaultBase).sessions?.[sessionId]) { process.stderr.write(`wendkeep change bind: session not found: ${sessionId}\n`); process.exit(2); }
84
+ upsertSessionRegistry(vaultBase, sessionId, { change_slug: slug });
85
+ process.stdout.write(`session ${sessionId} -> change ${slug}\n`);
86
+ process.exit(0);
87
+ }
88
+
77
89
  if (sub === 'continue') {
78
90
  const positionals = rest.filter((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
79
91
  const [archivedSlug, newSlug] = positionals;
@@ -9,19 +9,19 @@ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', li
9
9
  const registry = readSessionRegistry(vaultBase);
10
10
  const report = { version: 1, generatedAt: new Date().toISOString(), mode: apply ? 'apply' : 'dry-run', scanned: 0, changed: 0, unchanged: 0, missing: [], errors: [], sessions: [] };
11
11
  const entries = Object.entries(registry.sessions || {}).map(([sessionId, value]) => ({ sessionId, ...value }))
12
- .filter((e) => e.session_file && e.transcript_path)
12
+ .filter((e) => e.session_file)
13
13
  .filter((e) => !session || e.sessionId === session || e.session_file === session);
14
14
  for (const entry of entries) {
15
15
  if (limit && report.scanned >= limit) break;
16
16
  report.scanned += 1;
17
17
  const note = join(vaultBase, entry.session_file);
18
- if (!existsSync(note) || !existsSync(entry.transcript_path)) {
19
- report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: existsSync(entry.transcript_path) });
18
+ if (!entry.transcript_path || !existsSync(note) || !existsSync(entry.transcript_path)) {
19
+ report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: !!entry.transcript_path && existsSync(entry.transcript_path), transcriptPath: entry.transcript_path || '' });
20
20
  continue;
21
21
  }
22
22
  const before = readFileSync(note, 'utf8');
23
23
  try {
24
- updateSessionObservability({ sessionPath: note, transcriptPath: entry.transcript_path });
24
+ updateSessionObservability({ sessionPath: note, transcriptPath: entry.transcript_path, caller: 'cost-rebuild', canonicalConversationId: entry.sessionId });
25
25
  const after = readFileSync(note, 'utf8');
26
26
  const changed = before !== after;
27
27
  if (changed) report.changed += 1; else report.unchanged += 1;
@@ -32,7 +32,7 @@ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', li
32
32
  report.errors.push({ sessionId: entry.sessionId, session: entry.session_file, error: error.message });
33
33
  }
34
34
  }
35
- report.ok = report.errors.length === 0;
35
+ report.ok = report.errors.length === 0 && report.missing.length === 0;
36
36
  if (apply) writeFileSync(join(vaultBase, '.brain', 'COST_REBUILD.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
37
37
  return report;
38
38
  }
@@ -0,0 +1,37 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ import { readControl, readSessionRegistry, writeControl } from '../hooks/obsidian-common.mjs';
3
+
4
+ function vaultOf(argv) {
5
+ const i = argv.indexOf('--vault');
6
+ const raw = i >= 0 ? argv[i + 1] : argv.find((a) => a.startsWith('--vault='))?.slice(8) || process.env.OBSIDIAN_VAULT_PATH;
7
+ if (!raw) throw new Error('pass --vault <path> or set OBSIDIAN_VAULT_PATH');
8
+ return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
9
+ }
10
+
11
+ function positionals(argv) {
12
+ return argv.filter((arg, index) => !arg.startsWith('-') && argv[index - 1] !== '--vault');
13
+ }
14
+
15
+ export function runSession(argv) {
16
+ const vault = vaultOf(argv);
17
+ const [sub, id] = positionals(argv);
18
+ const registry = readSessionRegistry(vault);
19
+ const rows = Object.entries(registry.sessions || {}).sort((a, b) => String(b[1].last_seen || '').localeCompare(String(a[1].last_seen || '')));
20
+ if (sub === 'list') {
21
+ for (const [sessionId, item] of rows) process.stdout.write(`${sessionId}\t${item.status || 'unknown'}\t${item.provider || 'unknown'}\t${item.change_slug || '-'}\t${item.session_file || '-'}\n`);
22
+ return;
23
+ }
24
+ const entry = registry.sessions?.[id];
25
+ if (!entry) throw new Error(`session not found: ${id || '(missing id)'}`);
26
+ if (sub === 'show') {
27
+ process.stdout.write(`${JSON.stringify({ session_id: id, ...entry }, null, 2)}\n`);
28
+ return;
29
+ }
30
+ if (sub === 'use') {
31
+ const control = readControl(vault);
32
+ writeControl(vault, { ...control, status: entry.status || 'active', session_id: id, session_file: entry.session_file || '', started_at: entry.started_at || '' });
33
+ process.stdout.write(`session focus: ${id}\n`);
34
+ return;
35
+ }
36
+ throw new Error('use: wendkeep session list | show <id> | use <id>');
37
+ }