pan-wizard 3.19.0 → 3.21.0

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.
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PAN memory rebuild (A2) — regenerate DERIVED memory from source, idempotently.
5
+ *
6
+ * "Rebuild" is a projection, not a mutation: it re-emits only the regions PAN
7
+ * owns and can reproduce from source, and leaves everything the user wrote
8
+ * alone. Per the memory-management research, a rebuild must be an idempotent
9
+ * projection touching only derived regions — running it twice changes nothing.
10
+ *
11
+ * Three derived targets:
12
+ * 1. AGENTS.md — the universal, cross-runtime tools memory. PAN owns exactly
13
+ * the marker-fenced `<!-- BEGIN/END PAN WIZARD -->` section (every runtime,
14
+ * including Copilot/.github, reads AGENTS.md natively). User content
15
+ * outside the markers is preserved byte-for-byte.
16
+ * 2. CLAUDE.md — the Claude bridge (`@AGENTS.md` import), regenerated only
17
+ * when the Claude runtime is installed here.
18
+ * 3. state.md — its YAML frontmatter is re-derived from the body (phase
19
+ * progress, status). The body prose is never touched.
20
+ *
21
+ * Dry-run by default (`--apply` to write). Refuses to run inside the PAN source
22
+ * repository, mirroring the installer and experiment guards.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const { output, safeReadFile } = require('./core.cjs');
28
+ const { planningPath } = require('./utils.cjs');
29
+ const { syncStateFrontmatter } = require('./state.cjs');
30
+ const {
31
+ buildAgentsMdSection,
32
+ upsertAgentsMdSection,
33
+ ensureClaudeMdImport,
34
+ } = require('./agents-md.cjs');
35
+
36
+ // Source repo root — mirrors experiment.cjs / install.js. __dirname is
37
+ // .../pan-wizard-core/bin/lib, so three levels up is the repo (or install) root.
38
+ const PAN_SOURCE_ROOT = path.resolve(__dirname, '..', '..', '..');
39
+
40
+ // Runtime → config directory. A runtime is "installed here" when its dir exists
41
+ // in the project. AGENTS.md is shared by all; only Claude gets a bridge file.
42
+ const RUNTIME_DIRS = {
43
+ claude: '.claude',
44
+ codex: '.codex',
45
+ gemini: '.gemini',
46
+ opencode: '.opencode',
47
+ copilot: '.github',
48
+ };
49
+
50
+ function normPath(p) {
51
+ return process.platform === 'win32' ? p.toLowerCase() : p;
52
+ }
53
+
54
+ /**
55
+ * True only when `cwd` is the genuine PAN source repository — inside
56
+ * PAN_SOURCE_ROOT *and* that root actually looks like the source checkout
57
+ * (has bin/install.js). In an install layout PAN_SOURCE_ROOT resolves to the
58
+ * runtime config dir, which has no bin/install.js, so this stays false.
59
+ */
60
+ function isInsideSourceRepo(cwd) {
61
+ const abs = normPath(path.resolve(cwd));
62
+ const src = normPath(PAN_SOURCE_ROOT);
63
+ const inside = abs === src || abs.startsWith(src + path.sep) || abs.startsWith(src + '/');
64
+ if (!inside) return false;
65
+ return fs.existsSync(path.join(PAN_SOURCE_ROOT, 'bin', 'install.js')) &&
66
+ fs.existsSync(path.join(PAN_SOURCE_ROOT, 'pan-wizard-core'));
67
+ }
68
+
69
+ /** Which PAN runtimes are installed in this project (by config-dir presence). */
70
+ function detectRuntimes(cwd) {
71
+ return Object.entries(RUNTIME_DIRS)
72
+ .filter(([, dir]) => {
73
+ try { return fs.statSync(path.join(cwd, dir)).isDirectory(); }
74
+ catch { return false; }
75
+ })
76
+ .map(([name]) => name);
77
+ }
78
+
79
+ /**
80
+ * Rebuild one file: compute the desired content from `existing`, compare, and
81
+ * (on apply) write only when it differs. Returns a per-target status.
82
+ */
83
+ function rebuildFile(filePath, existing, desired, apply) {
84
+ const absent = existing == null;
85
+ if (existing === desired) return { action: 'unchanged', wrote: false };
86
+ const action = absent ? 'create' : 'update';
87
+ if (apply) fs.writeFileSync(filePath, desired, 'utf-8');
88
+ return { action, wrote: apply };
89
+ }
90
+
91
+ /**
92
+ * `memory rebuild [--apply]` — regenerate derived tools-memory + state.md
93
+ * frontmatter. Dry-run by default: reports what WOULD change.
94
+ */
95
+ function cmdMemoryRebuild(cwd, opts = {}, raw) {
96
+ const apply = !!opts.apply;
97
+
98
+ if (isInsideSourceRepo(cwd)) {
99
+ output(
100
+ { error: 'source_repo', rebuilt: [] },
101
+ raw,
102
+ `refusing to rebuild memory inside the PAN source repository (${PAN_SOURCE_ROOT})`,
103
+ );
104
+ return;
105
+ }
106
+
107
+ const runtimes = detectRuntimes(cwd);
108
+ const targets = [];
109
+
110
+ // 1. AGENTS.md — universal PAN section (all runtimes read it natively).
111
+ {
112
+ const p = path.join(cwd, 'AGENTS.md');
113
+ const existing = safeReadFile(p);
114
+ const desired = upsertAgentsMdSection(existing, buildAgentsMdSection());
115
+ targets.push({ file: 'AGENTS.md', ...rebuildFile(p, existing, desired, apply) });
116
+ }
117
+
118
+ // 2. CLAUDE.md — Claude bridge, only when the Claude runtime is installed.
119
+ if (runtimes.includes('claude')) {
120
+ const p = path.join(cwd, 'CLAUDE.md');
121
+ const existing = safeReadFile(p);
122
+ const desired = ensureClaudeMdImport(existing);
123
+ targets.push({ file: 'CLAUDE.md', ...rebuildFile(p, existing, desired, apply) });
124
+ }
125
+
126
+ // 3. state.md — re-derive YAML frontmatter from the body (progress/status).
127
+ {
128
+ const p = path.join(planningPath(cwd), 'state.md');
129
+ const existing = safeReadFile(p);
130
+ if (existing != null) {
131
+ const desired = syncStateFrontmatter(existing, cwd);
132
+ targets.push({ file: '.planning/state.md', ...rebuildFile(p, existing, desired, apply) });
133
+ }
134
+ }
135
+
136
+ const changed = targets.filter((t) => t.action !== 'unchanged');
137
+ const result = {
138
+ apply,
139
+ runtimes,
140
+ rebuilt: targets,
141
+ changed_count: changed.length,
142
+ };
143
+ const summary = changed.length === 0
144
+ ? `tools memory already current (${targets.map((t) => t.file).join(', ')}) — nothing to do`
145
+ : `${apply ? 'rebuilt' : 'would rebuild'} ${changed.map((t) => `${t.file} (${t.action})`).join(', ')}`;
146
+ output(result, raw, summary);
147
+ }
148
+
149
+ module.exports = {
150
+ cmdMemoryRebuild,
151
+ detectRuntimes,
152
+ isInsideSourceRepo,
153
+ rebuildFile,
154
+ PAN_SOURCE_ROOT,
155
+ RUNTIME_DIRS,
156
+ };
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { output, escapeRegex } = require('./core.cjs');
11
+ const { output, escapeRegex, execGit } = require('./core.cjs');
12
12
  const { PLANNING_DIR } = require('./constants.cjs');
13
13
 
14
14
  // ─── Storage layout ──────────────────────────────────────────────────────────
@@ -172,6 +172,65 @@ function logTraceEvent(cwd, event, sessionId) {
172
172
  }
173
173
  }
174
174
 
175
+ /**
176
+ * Recompute a session's counters straight from its trace.jsonl — the single
177
+ * source of truth for event_count/agent_count/agents/type_counts, plus a count
178
+ * of malformed (unparseable) rows that would otherwise vanish silently. Pure
179
+ * read; never writes. Used by endTraceSession, the reconcile subcommand, and the
180
+ * reconcile-on-read overlay so the counting logic lives in exactly one place.
181
+ */
182
+ function reconcileSessionMeta(cwd, sessionId) {
183
+ const sessionDir = path.join(getTracesDir(cwd), sessionId);
184
+ let eventCount = 0;
185
+ let malformed = 0;
186
+ const agentNames = new Set();
187
+ const typeCounts = {};
188
+ try {
189
+ const raw = fs.readFileSync(path.join(sessionDir, TRACE_EVENT_FILE), 'utf-8');
190
+ raw.trim().split('\n').filter(Boolean).forEach(line => {
191
+ let e;
192
+ try { e = JSON.parse(line); } catch { malformed++; return; }
193
+ eventCount++;
194
+ if (e.agent) agentNames.add(e.agent);
195
+ typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
196
+ });
197
+ } catch { /* no trace.jsonl yet */ }
198
+ return {
199
+ event_count: eventCount,
200
+ agent_count: agentNames.size,
201
+ agents: Array.from(agentNames),
202
+ type_counts: typeCounts,
203
+ malformed_count: malformed,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Compute the measured dollar cost + commit count for a session's [started_at,
209
+ * ended_at||now] window, folding the authoritative per-agent cost ledger
210
+ * (tokens.jsonl, suspect rows already quarantined) into the session so the
211
+ * autonomous-overhead metrics work. cost_usd is null when the ledger has no
212
+ * in-window rows; commit_count is null when git is unavailable — never a
213
+ * fabricated 0 (0 would make minutes_per_commit Infinity). Best-effort.
214
+ */
215
+ function computeSessionCostAndCommits(cwd, meta) {
216
+ const out = { cost_usd: null, commit_count: null };
217
+ if (!meta || !meta.started_at) return out;
218
+ const since = meta.started_at;
219
+ const until = meta.ended_at || new Date().toISOString();
220
+ try {
221
+ const cost = require('./cost.cjs');
222
+ const agg = cost.aggregate(cwd, { since, until });
223
+ if (agg && agg.totals && agg.totals.calls > 0) out.cost_usd = agg.totals.cost_usd;
224
+ } catch { /* cost is observability, never the critical path */ }
225
+ try {
226
+ const r = execGit(cwd, ['log', '--oneline', '--since', since, '--until', until]);
227
+ if (r && r.exitCode === 0) {
228
+ out.commit_count = r.stdout ? r.stdout.split('\n').filter(Boolean).length : 0;
229
+ }
230
+ } catch { /* non-repo / git absent → leave null */ }
231
+ return out;
232
+ }
233
+
175
234
  function endTraceSession(cwd, sessionId) {
176
235
  const sid = sessionId || getCurrentSessionId(cwd);
177
236
  if (!sid) return { error: 'No active session' };
@@ -183,35 +242,39 @@ function endTraceSession(cwd, sessionId) {
183
242
  let meta = {};
184
243
  try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch {}
185
244
 
186
- let eventCount = 0;
187
- const agentNames = new Set();
188
- const typeCounts = {};
189
-
190
- try {
191
- const raw = fs.readFileSync(path.join(sessionDir, TRACE_EVENT_FILE), 'utf-8');
192
- raw.trim().split('\n').filter(Boolean).forEach(line => {
193
- try {
194
- const e = JSON.parse(line);
195
- eventCount++;
196
- if (e.agent) agentNames.add(e.agent);
197
- typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
198
- } catch {}
199
- });
200
- } catch {}
201
-
245
+ const counts = reconcileSessionMeta(cwd, sid);
202
246
  meta.ended_at = new Date().toISOString();
203
- meta.event_count = eventCount;
204
- meta.agent_count = agentNames.size;
205
- meta.agents = Array.from(agentNames);
206
- meta.type_counts = typeCounts;
247
+ meta.event_count = counts.event_count;
248
+ meta.agent_count = counts.agent_count;
249
+ meta.agents = counts.agents;
250
+ meta.type_counts = counts.type_counts;
251
+ if (counts.malformed_count) meta.malformed_count = counts.malformed_count;
252
+
253
+ // Fold measured cost + commit count into the session so overhead.* metrics
254
+ // and `optimize stats` carry real dollars, not perpetual nulls.
255
+ const cc = computeSessionCostAndCommits(cwd, meta);
256
+ if (cc.cost_usd != null) meta.cost_usd = cc.cost_usd;
257
+ if (cc.commit_count != null) meta.commit_count = cc.commit_count;
207
258
 
208
259
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
209
260
 
261
+ // Clear the active-session pointer so the next SubagentStop opens a fresh
262
+ // session — but ONLY when we ended the session it points at (ending session
263
+ // A explicitly must not orphan a different active session B).
264
+ try {
265
+ if (getCurrentSessionId(cwd) === sid) {
266
+ fs.unlinkSync(path.join(getOptimizeDir(cwd), CURRENT_SESSION_FILE));
267
+ }
268
+ } catch { /* best-effort */ }
269
+
210
270
  return {
211
271
  session_id: sid,
212
- event_count: eventCount,
213
- agent_count: agentNames.size,
214
- type_counts: typeCounts,
272
+ event_count: counts.event_count,
273
+ agent_count: counts.agent_count,
274
+ type_counts: counts.type_counts,
275
+ malformed_count: counts.malformed_count,
276
+ cost_usd: meta.cost_usd != null ? meta.cost_usd : null,
277
+ commit_count: meta.commit_count != null ? meta.commit_count : null,
215
278
  ended_at: meta.ended_at,
216
279
  };
217
280
  } catch (e) {
@@ -219,6 +282,43 @@ function endTraceSession(cwd, sessionId) {
219
282
  }
220
283
  }
221
284
 
285
+ /**
286
+ * Rewrite session.json from trace.jsonl WITHOUT ending the session (ended_at is
287
+ * left untouched). Powers `optimize trace reconcile`, so hook-driven auto-sessions
288
+ * that never call `end` still get accurate counters.
289
+ */
290
+ function reconcileTraceSession(cwd, sessionId) {
291
+ const sid = sessionId || getCurrentSessionId(cwd);
292
+ if (!sid) return { error: 'No session to reconcile' };
293
+ try {
294
+ const metaPath = path.join(getTracesDir(cwd), sid, OPT_SESSION_FILE);
295
+ let meta = {};
296
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch {}
297
+ const counts = reconcileSessionMeta(cwd, sid);
298
+ meta.event_count = counts.event_count;
299
+ meta.agent_count = counts.agent_count;
300
+ meta.agents = counts.agents;
301
+ meta.type_counts = counts.type_counts;
302
+ if (counts.malformed_count) meta.malformed_count = counts.malformed_count;
303
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
304
+ return { session_id: sid, reconciled: true, event_count: counts.event_count, malformed_count: counts.malformed_count };
305
+ } catch (e) {
306
+ return { error: e.message };
307
+ }
308
+ }
309
+
310
+ /** Reconcile every session dir (used by `optimize trace reconcile --all`). */
311
+ function reconcileAllTraceSessions(cwd) {
312
+ const results = [];
313
+ try {
314
+ const tracesDir = getTracesDir(cwd);
315
+ for (const e of fs.readdirSync(tracesDir, { withFileTypes: true })) {
316
+ if (e.isDirectory() && e.name.startsWith('sess_')) results.push(reconcileTraceSession(cwd, e.name));
317
+ }
318
+ } catch { /* no traces dir */ }
319
+ return { reconciled: results.length, sessions: results };
320
+ }
321
+
222
322
  function readTraceSession(cwd, sessionId) {
223
323
  try {
224
324
  const sessionDir = path.join(getTracesDir(cwd), sessionId);
@@ -229,14 +329,25 @@ function readTraceSession(cwd, sessionId) {
229
329
  } catch {}
230
330
 
231
331
  const events = [];
332
+ let malformed = 0;
232
333
  try {
233
334
  const raw = fs.readFileSync(path.join(sessionDir, TRACE_EVENT_FILE), 'utf-8');
234
335
  raw.trim().split('\n').filter(Boolean).forEach(line => {
235
- try { events.push(JSON.parse(line)); } catch {}
336
+ try { events.push(JSON.parse(line)); } catch { malformed++; }
236
337
  });
237
338
  } catch {}
238
339
 
239
- return { session_id: sessionId, metadata, events, event_count: events.length };
340
+ // Reconcile-on-read: an unfinalized meta (no ended_at) or a stale zero
341
+ // event_count is overlaid with the live counts derived from the events just
342
+ // read, so consumers of metadata (e.g. optimize stats) never see a stale 0.
343
+ if (!metadata.ended_at || !metadata.event_count) {
344
+ const typeCounts = {};
345
+ const agents = new Set();
346
+ for (const e of events) { typeCounts[e.type] = (typeCounts[e.type] || 0) + 1; if (e.agent) agents.add(e.agent); }
347
+ metadata = { ...metadata, event_count: events.length, agent_count: agents.size, agents: Array.from(agents), type_counts: typeCounts };
348
+ }
349
+
350
+ return { session_id: sessionId, metadata, events, event_count: events.length, malformed_count: malformed };
240
351
  } catch (e) {
241
352
  return { error: e.message };
242
353
  }
@@ -256,6 +367,14 @@ function listTraceSessions(cwd) {
256
367
  try {
257
368
  meta = JSON.parse(fs.readFileSync(path.join(sessionDir, OPT_SESSION_FILE), 'utf-8'));
258
369
  } catch {}
370
+ // Reconcile-on-read: unfinalized (no ended_at) or stale-zero sessions —
371
+ // every hook-driven auto-session — get live counts from trace.jsonl so
372
+ // getOptimizeStats doesn't sum perpetual zeros. Finalized sessions with a
373
+ // real count stay cheap (session.json read only).
374
+ if (!meta.ended_at || !meta.event_count) {
375
+ const counts = reconcileSessionMeta(cwd, e.name);
376
+ meta = { ...meta, event_count: counts.event_count, agent_count: counts.agent_count, agents: counts.agents, type_counts: counts.type_counts };
377
+ }
259
378
  return meta;
260
379
  }).sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''));
261
380
 
@@ -291,11 +410,18 @@ function analyzeEvents(events, sessionMeta) {
291
410
  const agentStats = {};
292
411
  events.forEach(e => {
293
412
  if (!e.agent) return;
294
- if (!agentStats[e.agent]) agentStats[e.agent] = { total: 0, errors: 0, gaps: 0, corrections: 0 };
413
+ if (!agentStats[e.agent]) agentStats[e.agent] = { total: 0, errors: 0, gaps: 0, corrections: 0, input_tokens: 0, output_tokens: 0, total_tokens: 0 };
295
414
  agentStats[e.agent].total++;
296
415
  if (e.type === 'error') agentStats[e.agent].errors++;
297
416
  if (e.type === 'gap') agentStats[e.agent].gaps++;
298
417
  if (e.type === 'correction') agentStats[e.agent].corrections++;
418
+ // Sum the per-call tokens the trace logger now writes — ONLY on completion
419
+ // events, so the redundancy event that mirrors output_tokens isn't counted twice.
420
+ if (e.category === 'agent_completion' && e.context) {
421
+ agentStats[e.agent].input_tokens += e.context.input_tokens || 0;
422
+ agentStats[e.agent].output_tokens += e.context.output_tokens || 0;
423
+ agentStats[e.agent].total_tokens += e.context.total_tokens || ((e.context.input_tokens || 0) + (e.context.output_tokens || 0));
424
+ }
299
425
  });
300
426
 
301
427
  Object.keys(agentStats).forEach(a => {
@@ -305,9 +431,20 @@ function analyzeEvents(events, sessionMeta) {
305
431
 
306
432
  const wastedTokens = redundancies.reduce((sum, e) => sum + (e.tokens_wasted || 0), 0);
307
433
 
308
- // ── Timing analysis from wall-clock timestamps ────────────────────────────
309
- // Token data is unavailable (Claude Code SubagentStop doesn't populate usage).
310
- // Use event timestamps + session start/end for meaningful timing analysis.
434
+ // Sum the authoritative per-call tokens (written by the SubagentStop hooks
435
+ // since v3.20.0) across completion events — the redundancy events mirror
436
+ // output_tokens, so restrict to agent_completion to avoid double-counting.
437
+ const completions = events.filter(e => e.category === 'agent_completion' && e.context);
438
+ const tokenTotals = completions.reduce((acc, e) => {
439
+ acc.input += e.context.input_tokens || 0;
440
+ acc.output += e.context.output_tokens || 0;
441
+ acc.cache_read += e.context.cache_read_tokens || 0;
442
+ return acc;
443
+ }, { input: 0, output: 0, cache_read: 0 });
444
+
445
+ // ── Timing analysis ───────────────────────────────────────────────────────
446
+ // Prefer measured per-agent duration_ms (hooks derive it from the transcript
447
+ // slice); fall back to the inter-event wall-clock gap when it's absent.
311
448
  const timing = {};
312
449
 
313
450
  // Session total duration
@@ -381,6 +518,10 @@ function analyzeEvents(events, sessionMeta) {
381
518
  wasted_tokens: wastedTokens,
382
519
  reviewer_corrections: reviewerCorrections.length,
383
520
  memory_primed_count: memoryPrimed.length,
521
+ total_input_tokens: tokenTotals.input,
522
+ total_output_tokens: tokenTotals.output,
523
+ total_cache_read_tokens: tokenTotals.cache_read,
524
+ total_tokens: tokenTotals.input + tokenTotals.output,
384
525
  },
385
526
  timing,
386
527
  overhead,
@@ -405,11 +546,21 @@ function generateLocalReport(cwd, sessionId) {
405
546
  const session = readTraceSession(cwd, sessionId);
406
547
  if (session.error) return session;
407
548
 
549
+ // Fold measured cost + commit count into the metadata before analysis so the
550
+ // autonomous-overhead metrics populate even for hook-driven auto-sessions that
551
+ // never call `optimize trace end`. Only fill fields a producer didn't set.
552
+ const metadata = session.metadata || {};
553
+ if (typeof metadata.cost_usd !== 'number' || typeof metadata.commit_count !== 'number') {
554
+ const cc = computeSessionCostAndCommits(cwd, metadata);
555
+ if (typeof metadata.cost_usd !== 'number' && cc.cost_usd != null) metadata.cost_usd = cc.cost_usd;
556
+ if (typeof metadata.commit_count !== 'number' && cc.commit_count != null) metadata.commit_count = cc.commit_count;
557
+ }
558
+
408
559
  return {
409
560
  session_id: sessionId,
410
561
  generated_at: new Date().toISOString(),
411
- metadata: session.metadata,
412
- ...analyzeEvents(session.events, session.metadata),
562
+ metadata,
563
+ ...analyzeEvents(session.events, metadata),
413
564
  raw_events: session.events,
414
565
  };
415
566
  }
@@ -622,8 +773,12 @@ function cmdOptimizeTrace(cwd, sub, opts, raw) {
622
773
  } else if (sub === 'show') {
623
774
  if (!opts.sessionId) { output({ error: 'Session ID required (--session <id>)' }, raw); return; }
624
775
  output(readTraceSession(cwd, opts.sessionId), raw);
776
+ } else if (sub === 'reconcile') {
777
+ // Rewrite session.json counters from trace.jsonl without ending the session,
778
+ // so hook-driven auto-sessions that never call `end` still report real numbers.
779
+ output(opts.all ? reconcileAllTraceSessions(cwd) : reconcileTraceSession(cwd, opts.sessionId), raw);
625
780
  } else {
626
- output({ error: 'Unknown trace subcommand. Available: init, log, end, current, list, show' }, raw);
781
+ output({ error: 'Unknown trace subcommand. Available: init, log, end, current, list, show, reconcile' }, raw);
627
782
  }
628
783
  }
629
784
 
@@ -1087,6 +1242,10 @@ module.exports = {
1087
1242
  endTraceSession,
1088
1243
  readTraceSession,
1089
1244
  listTraceSessions,
1245
+ reconcileSessionMeta,
1246
+ reconcileTraceSession,
1247
+ reconcileAllTraceSessions,
1248
+ computeSessionCostAndCommits,
1090
1249
  // Analysis
1091
1250
  analyzeEvents,
1092
1251
  generateLocalReport,
@@ -505,6 +505,9 @@ function cmdStateRecordSession(cwd, options, raw) {
505
505
 
506
506
  if (updated.length > 0) {
507
507
  writeStateMd(statePath, content, cwd);
508
+ // Normal-flow checkpoint: reconcile the always-loaded project memory once
509
+ // the session is recorded (no-op when already lean; never throws).
510
+ try { require('./memory-optimize.cjs').maybeAutoOptimizeMemory(cwd); } catch { /* best-effort */ }
508
511
  output({ recorded: true, updated }, raw, 'true');
509
512
  } else {
510
513
  output({ recorded: false, reason: 'No session fields found in state.md' }, raw, 'false');
@@ -1021,6 +1024,7 @@ module.exports = {
1021
1024
  stateExtractField,
1022
1025
  stateReplaceField,
1023
1026
  writeStateMd,
1027
+ syncStateFrontmatter,
1024
1028
  cmdStateLoad,
1025
1029
  cmdStateGet,
1026
1030
  cmdStatePatch,
@@ -951,8 +951,18 @@ async function main() {
951
951
  }, raw);
952
952
  } else if (subcommand === 'budget') {
953
953
  memory.cmdMemoryBudget(cwd, raw);
954
+ } else if (subcommand === 'optimize') {
955
+ const keepArg = getArgValue(args, '--keep');
956
+ require('./lib/memory-optimize.cjs').cmdMemoryOptimize(cwd, {
957
+ apply: args.includes('--apply'),
958
+ keep: keepArg ? Number(keepArg) : undefined,
959
+ }, raw);
960
+ } else if (subcommand === 'rebuild') {
961
+ require('./lib/memory-rebuild.cjs').cmdMemoryRebuild(cwd, {
962
+ apply: args.includes('--apply'),
963
+ }, raw);
954
964
  } else {
955
- error('Unknown memory subcommand. Available: read, append, list, compact, select, budget');
965
+ error('Unknown memory subcommand. Available: read, append, list, compact, select, budget, optimize, rebuild');
956
966
  }
957
967
  break;
958
968
  }
@@ -1287,6 +1297,7 @@ async function main() {
1287
1297
  const traceSub = args[2];
1288
1298
  optimize.cmdOptimizeTrace(cwd, traceSub, {
1289
1299
  sessionId: getArgValue(args, '--session'),
1300
+ all: args.includes('--all'),
1290
1301
  description: getArgValue(args, '--description'),
1291
1302
  command: getArgValue(args, '--command'),
1292
1303
  phase: getArgValue(args, '--phase'),