@wrongstack/core 0.302.2 → 0.305.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.
Files changed (97) hide show
  1. package/README.md +1 -1
  2. package/dist/chronicle/index.js +117 -30
  3. package/dist/chronicle/project-server.js +12 -5
  4. package/dist/coordination/agents/index.js +4645 -3448
  5. package/dist/coordination/agents/project-agent-auto-optimize.d.ts +116 -0
  6. package/dist/coordination/agents/project-agent-capture-window.d.ts +29 -0
  7. package/dist/coordination/agents/project-agent-config-io.d.ts +11 -0
  8. package/dist/coordination/agents/project-agent-consolidation.d.ts +34 -2
  9. package/dist/coordination/agents/project-agent-directive-outcome.d.ts +57 -0
  10. package/dist/coordination/agents/project-agent-files.d.ts +12 -3
  11. package/dist/coordination/agents/project-agent-identity-types.d.ts +4 -0
  12. package/dist/coordination/agents/project-agent-identity.d.ts +38 -11
  13. package/dist/coordination/agents/project-agent-learning-entries.d.ts +8 -2
  14. package/dist/coordination/agents/project-agent-learning-policy.d.ts +22 -1
  15. package/dist/coordination/agents/project-agent-learning-structured.d.ts +72 -1
  16. package/dist/coordination/agents/project-agent-optimizer.d.ts +49 -0
  17. package/dist/coordination/agents/project-agent-quarantine.d.ts +63 -0
  18. package/dist/coordination/agents/project-agent-skill-layer.d.ts +146 -0
  19. package/dist/coordination/agents/role-skills.d.ts +11 -1
  20. package/dist/coordination/agents/types.d.ts +10 -2
  21. package/dist/coordination/director-prompts.d.ts +19 -6
  22. package/dist/coordination/director-tools.d.ts +2 -2
  23. package/dist/coordination/fleet.d.ts +0 -6
  24. package/dist/coordination/index.d.ts +1 -1
  25. package/dist/coordination/index.js +5746 -4326
  26. package/dist/coordination/mail-tools.d.ts +1 -1
  27. package/dist/core/agent-types.d.ts +4 -2
  28. package/dist/core/agent.d.ts +1 -0
  29. package/dist/core/context.d.ts +19 -0
  30. package/dist/core/conversation-state.d.ts +14 -0
  31. package/dist/core/fallback-profile-manager.d.ts +70 -2
  32. package/dist/core/index.js +317 -108
  33. package/dist/core/system-prompt-blocks.d.ts +1 -1
  34. package/dist/core/system-prompt-builder.d.ts +13 -1
  35. package/dist/core/system-prompt-glossary.d.ts +73 -0
  36. package/dist/core/system-prompt-memory-skills.d.ts +2 -2
  37. package/dist/defaults/index.js +1635 -1120
  38. package/dist/execution/council-orchestrator.d.ts +3 -13
  39. package/dist/execution/index.js +3136 -2706
  40. package/dist/execution/one-shot-llm.d.ts +5 -0
  41. package/dist/goal/index.js +7 -0
  42. package/dist/hq/index.js +17 -7
  43. package/dist/hq/protocol/kanban.d.ts +21 -0
  44. package/dist/hq/protocol.js +5 -1
  45. package/dist/hq/redaction.d.ts +14 -0
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.js +5502 -3483
  48. package/dist/infrastructure/index.js +247 -122
  49. package/dist/kernel/events/agent-events.d.ts +28 -0
  50. package/dist/plugin/index.js +111 -7
  51. package/dist/registry/index.js +11 -0
  52. package/dist/registry/tool-registry.d.ts +8 -0
  53. package/dist/replay/hash.d.ts +9 -0
  54. package/dist/replay/index.js +14 -4
  55. package/dist/replay/replay-provider-runner.d.ts +31 -1
  56. package/dist/security/index.js +92 -21
  57. package/dist/security/kanban-boundary.d.ts +5 -1
  58. package/dist/security/secret-vault.d.ts +2 -0
  59. package/dist/session-catalog/index.js +86 -10
  60. package/dist/session-catalog/project-server.js +100 -17
  61. package/dist/session-catalog/protocol.d.ts +20 -4
  62. package/dist/session-catalog/store.d.ts +19 -3
  63. package/dist/storage/index.js +305 -69
  64. package/dist/storage/memory-consolidator.d.ts +4 -2
  65. package/dist/storage/plan-store.d.ts +1 -1
  66. package/dist/storage/session-resume-validation.d.ts +24 -0
  67. package/dist/storage/session-store/directory-scan.d.ts +5 -1
  68. package/dist/storage/session-store/fork-session.d.ts +13 -1
  69. package/dist/storage/session-store/load-cache.d.ts +11 -0
  70. package/dist/storage/session-store/prune-helpers.d.ts +5 -0
  71. package/dist/storage/session-store.d.ts +18 -0
  72. package/dist/tasking/index.js +5 -0
  73. package/dist/tools/index.js +1151 -831
  74. package/dist/types/config/mcp-features.d.ts +31 -1
  75. package/dist/types/config/root.d.ts +23 -1
  76. package/dist/types/config/skills-fleet-brain.d.ts +34 -0
  77. package/dist/types/config/tools.d.ts +22 -0
  78. package/dist/types/config/ui.d.ts +14 -0
  79. package/dist/types/config.d.ts +1 -0
  80. package/dist/types/default-config.d.ts +1 -0
  81. package/dist/types/index.d.ts +2 -2
  82. package/dist/types/index.js +23 -0
  83. package/dist/types/multi-agent.d.ts +7 -0
  84. package/dist/types/session.d.ts +9 -1
  85. package/dist/types/task-graph.d.ts +2 -0
  86. package/dist/types/tool-executor.d.ts +2 -0
  87. package/dist/utils/index.d.ts +1 -0
  88. package/dist/utils/index.js +217 -76
  89. package/dist/utils/project-state-guard.d.ts +21 -0
  90. package/dist/utils/session-scoped-path.d.ts +17 -0
  91. package/dist/utils/todos-format.d.ts +20 -0
  92. package/instructions/leader-after-task.md +3 -4
  93. package/instructions/system-lite.md +17 -14
  94. package/instructions/system-pro.md +29 -30
  95. package/instructions/system.md +29 -28
  96. package/package.json +3 -3
  97. package/skills/wrongstack-kanban/SKILL.md +95 -93
@@ -2152,15 +2152,51 @@ var atomicReplaceWithWriter = primitives.atomicReplaceWithWriter;
2152
2152
  var ensureDir = primitives.ensureDir;
2153
2153
  var withFileLock = primitives.withFileLock;
2154
2154
 
2155
+ // src/utils/session-scoped-path.ts
2156
+ import * as path6 from "node:path";
2157
+ function sessionScopedPath(dir, sessionId, suffix) {
2158
+ if (!sessionId || sessionId.includes("\\") || sessionId.includes("..")) {
2159
+ throw invalid(sessionId);
2160
+ }
2161
+ const resolved = path6.resolve(dir, `${sessionId}${suffix}`);
2162
+ const rel = path6.relative(path6.resolve(dir), resolved);
2163
+ if (rel.startsWith("..") || path6.isAbsolute(rel)) {
2164
+ throw invalid(sessionId);
2165
+ }
2166
+ return resolved;
2167
+ }
2168
+ var SESSION_SIDECAR_JSONL_SUFFIXES = [
2169
+ ".replay.jsonl",
2170
+ ".audit.jsonl",
2171
+ ".annotations.jsonl"
2172
+ ];
2173
+ var RESERVED_SESSION_JSONL_NAMES = /* @__PURE__ */ new Set([
2174
+ "_index.jsonl",
2175
+ "_mailbox.jsonl"
2176
+ ]);
2177
+ function isSessionTranscriptFileName(name) {
2178
+ if (!name.endsWith(".jsonl")) return false;
2179
+ if (RESERVED_SESSION_JSONL_NAMES.has(name)) return false;
2180
+ return !SESSION_SIDECAR_JSONL_SUFFIXES.some((suffix) => name.endsWith(suffix));
2181
+ }
2182
+ function invalid(sessionId) {
2183
+ return new FsError({
2184
+ message: `Invalid sessionId: ${sessionId}`,
2185
+ code: ERROR_CODES.FS_DELETE_FAILED,
2186
+ path: sessionId,
2187
+ context: { reason: "path_traversal" }
2188
+ });
2189
+ }
2190
+
2155
2191
  // src/session-registry.ts
2156
2192
  import * as fs6 from "node:fs/promises";
2157
- import * as path7 from "node:path";
2193
+ import * as path8 from "node:path";
2158
2194
 
2159
2195
  // src/session-registry-atomic-file.ts
2160
2196
  import { randomUUID as randomUUID2 } from "node:crypto";
2161
2197
  import * as fs5 from "node:fs/promises";
2162
2198
  import { hostname } from "node:os";
2163
- import * as path6 from "node:path";
2199
+ import * as path7 from "node:path";
2164
2200
  var STALE_LOCK_MS = 1e4;
2165
2201
  var SAME_HOST_STALE_MS = 2 * STALE_LOCK_MS;
2166
2202
  var STALE_TMP_MS = 6e4;
@@ -2240,9 +2276,9 @@ async function breakLockAtomically(lockPath) {
2240
2276
  return true;
2241
2277
  }
2242
2278
  async function writeAtomicFile(filePath, registry) {
2243
- const tmp = path6.join(
2244
- path6.dirname(filePath),
2245
- `.${path6.basename(filePath)}.${randomUUID2().slice(0, 8)}.tmp`
2279
+ const tmp = path7.join(
2280
+ path7.dirname(filePath),
2281
+ `.${path7.basename(filePath)}.${randomUUID2().slice(0, 8)}.tmp`
2246
2282
  );
2247
2283
  let tmpPersisted = false;
2248
2284
  try {
@@ -2283,21 +2319,21 @@ async function writeAtomicFile(filePath, registry) {
2283
2319
  }
2284
2320
  async function pruneStaleTempFiles(filePath) {
2285
2321
  try {
2286
- const dir = path6.dirname(filePath);
2287
- const base = path6.basename(filePath);
2322
+ const dir = path7.dirname(filePath);
2323
+ const base = path7.basename(filePath);
2288
2324
  const now = Date.now();
2289
2325
  const stale = [];
2290
2326
  for (const name of await fs5.readdir(dir)) {
2291
2327
  const isTemp = (name.startsWith(`${base}.`) || name.startsWith(`.${base}.`)) && name.endsWith(".tmp");
2292
2328
  if (!isTemp) continue;
2293
- const stat15 = await fs5.stat(path6.join(dir, name)).catch(() => null);
2329
+ const stat15 = await fs5.stat(path7.join(dir, name)).catch(() => null);
2294
2330
  if (!stat15) continue;
2295
2331
  if (now - stat15.mtimeMs > STALE_TMP_MS) stale.push({ name, mtimeMs: stat15.mtimeMs });
2296
2332
  }
2297
2333
  stale.sort((a, b) => b.mtimeMs - a.mtimeMs);
2298
2334
  await Promise.all(
2299
2335
  stale.slice(MAX_STALE_TMP_FILES).map(async ({ name }) => {
2300
- await fs5.unlink(path6.join(dir, name)).catch(() => void 0);
2336
+ await fs5.unlink(path7.join(dir, name)).catch(() => void 0);
2301
2337
  })
2302
2338
  );
2303
2339
  } catch {
@@ -2361,7 +2397,7 @@ var SessionRegistry = class {
2361
2397
  lastEntry = null;
2362
2398
  ownershipLockWaitMs;
2363
2399
  constructor(globalRoot, options = {}) {
2364
- this.filePath = path7.join(globalRoot, REGISTRY_FILE);
2400
+ this.filePath = path8.join(globalRoot, REGISTRY_FILE);
2365
2401
  this.ownershipLockWaitMs = Math.max(0, options.ownershipLockWaitMs ?? OWNERSHIP_LOCK_WAIT_MS);
2366
2402
  }
2367
2403
  // ── Public API ──────────────────────────────────────────────────────────
@@ -2671,7 +2707,7 @@ var SessionRegistry = class {
2671
2707
  const deadline = Date.now() + waitBudgetMs;
2672
2708
  let attempt = 0;
2673
2709
  try {
2674
- await fs6.mkdir(path7.dirname(this.filePath), { recursive: true });
2710
+ await fs6.mkdir(path8.dirname(this.filePath), { recursive: true });
2675
2711
  await this.maybePruneStaleTempFiles();
2676
2712
  } catch (err) {
2677
2713
  if (required) {
@@ -2815,28 +2851,6 @@ function expectDefined(value, label) {
2815
2851
  return value;
2816
2852
  }
2817
2853
 
2818
- // src/utils/session-scoped-path.ts
2819
- import * as path8 from "node:path";
2820
- function sessionScopedPath(dir, sessionId, suffix) {
2821
- if (!sessionId || sessionId.includes("\\") || sessionId.includes("..")) {
2822
- throw invalid(sessionId);
2823
- }
2824
- const resolved = path8.resolve(dir, `${sessionId}${suffix}`);
2825
- const rel = path8.relative(path8.resolve(dir), resolved);
2826
- if (rel.startsWith("..") || path8.isAbsolute(rel)) {
2827
- throw invalid(sessionId);
2828
- }
2829
- return resolved;
2830
- }
2831
- function invalid(sessionId) {
2832
- return new FsError({
2833
- message: `Invalid sessionId: ${sessionId}`,
2834
- code: ERROR_CODES.FS_DELETE_FAILED,
2835
- path: sessionId,
2836
- context: { reason: "path_traversal" }
2837
- });
2838
- }
2839
-
2840
2854
  // src/storage/annotations-store.ts
2841
2855
  var FILE_VERSION = 1;
2842
2856
  var MAX_TEXT_LENGTH = 2e3;
@@ -4628,6 +4642,9 @@ var DEFAULT_TOOLS_CONFIG = Object.freeze({
4628
4642
  disabledTools: Object.freeze([]),
4629
4643
  autoExtendLimit: true,
4630
4644
  restrictToProjectRoot: true,
4645
+ // Off by default: the board is a record of the work, not a permit for it.
4646
+ // See ToolsConfig.kanbanGovernance for what turning it on costs and gates.
4647
+ kanbanGovernance: false,
4631
4648
  loopDetection: Object.freeze({
4632
4649
  mode: "steer-then-cut",
4633
4650
  steerThreshold: 3,
@@ -4685,6 +4702,7 @@ var CONFIG_BEHAVIOR_DEFAULTS = {
4685
4702
  disabledTools: DEFAULT_TOOLS_CONFIG.disabledTools,
4686
4703
  autoExtendLimit: DEFAULT_TOOLS_CONFIG.autoExtendLimit,
4687
4704
  restrictToProjectRoot: DEFAULT_TOOLS_CONFIG.restrictToProjectRoot,
4705
+ kanbanGovernance: DEFAULT_TOOLS_CONFIG.kanbanGovernance,
4688
4706
  loopDetection: DEFAULT_TOOLS_CONFIG.loopDetection
4689
4707
  },
4690
4708
  log: { level: "info" },
@@ -5109,6 +5127,14 @@ var IN_PROJECT_DENIED_PATHS = [
5109
5127
  {
5110
5128
  path: "tools.restrictToProjectRoot",
5111
5129
  reason: "The other half of the filesystem confinement switch."
5130
+ },
5131
+ {
5132
+ // Denied for the direction that loosens: the flag defaults to false, so a
5133
+ // repo can only ever use it to turn OFF a gate the user deliberately
5134
+ // enabled. Same class as `tools.restrictToProjectRoot` — a control the
5135
+ // operator owns, not the checked-out repository.
5136
+ path: "tools.kanbanGovernance",
5137
+ reason: "Repo-committed config could disable a Kanban governance gate the operator switched on, letting product mutations run outside any managed card."
5112
5138
  }
5113
5139
  ];
5114
5140
  function deleteNestedPath(target, path34) {
@@ -7682,10 +7708,38 @@ var SessionMemoryConsolidator = class {
7682
7708
  process.stderr.write(`[memory] Session consolidation: ${added} added
7683
7709
  `);
7684
7710
  }
7711
+ if (this.Sage?.rememberSage) {
7712
+ try {
7713
+ const digestBody = buildSessionDigestText(_finalText, _iterations, added);
7714
+ if (digestBody) {
7715
+ const expires = new Date(Date.now() + 14 * 24 * 60 * 6e4).toISOString();
7716
+ await this.Sage.rememberSage({
7717
+ text: digestBody,
7718
+ scope: "session",
7719
+ kind: "session_digest",
7720
+ importance: 0.4,
7721
+ confidence: 0.65,
7722
+ persistence: "short_lived",
7723
+ tags: ["session_digest", "auto"],
7724
+ anchors: [],
7725
+ sources: [{ type: "session", sessionId: _sessionId }],
7726
+ ownerSessionId: _sessionId,
7727
+ expiresAt: expires
7728
+ });
7729
+ }
7730
+ } catch {
7731
+ }
7732
+ }
7685
7733
  } catch {
7686
7734
  }
7687
7735
  };
7688
7736
  };
7737
+ function buildSessionDigestText(finalText, iterations, factsAdded) {
7738
+ const cleaned = finalText.replace(/\s+/g, " ").trim();
7739
+ if (cleaned.length < 40) return void 0;
7740
+ const summary2 = cleaned.slice(0, 400);
7741
+ return `Session digest (${iterations} iter${factsAdded > 0 ? `, ${factsAdded} facts added` : ""}): ` + summary2 + (cleaned.length > 400 ? "\u2026" : "");
7742
+ }
7689
7743
 
7690
7744
  // src/storage/memory-graph-backend.ts
7691
7745
  import * as fs14 from "node:fs/promises";
@@ -8039,8 +8093,31 @@ function simpleHash(s) {
8039
8093
  }
8040
8094
 
8041
8095
  // src/storage/plan-store.ts
8042
- import * as fsp5 from "node:fs/promises";
8043
8096
  import { randomUUID as randomUUID5 } from "node:crypto";
8097
+ import * as fsp5 from "node:fs/promises";
8098
+ function assertPlanMutationInvariants(previous, updated) {
8099
+ const ids = updated.items.map((item) => item.id);
8100
+ const uniqueIds = new Set(ids);
8101
+ if (uniqueIds.size !== ids.length) {
8102
+ throw new SessionError({
8103
+ message: "Plan mutation rejected: plan item IDs must be unique.",
8104
+ code: "SESSION_WRITE_FAILED",
8105
+ sessionId: updated.sessionId,
8106
+ context: { operation: "mutatePlan", invariant: "unique_plan_item_ids" }
8107
+ });
8108
+ }
8109
+ const omittedUnfinished = previous.items.filter(
8110
+ (item) => item.status !== "done" && !uniqueIds.has(item.id)
8111
+ );
8112
+ if (omittedUnfinished.length > 0) {
8113
+ throw new SessionError({
8114
+ message: `Plan mutation rejected: unfinished items cannot be omitted: ${omittedUnfinished.map((item) => item.id).join(", ")}. Complete them first.`,
8115
+ code: "SESSION_WRITE_FAILED",
8116
+ sessionId: updated.sessionId,
8117
+ context: { operation: "mutatePlan", invariant: "unfinished_plan_coverage" }
8118
+ });
8119
+ }
8120
+ }
8044
8121
  async function loadPlan(filePath, events) {
8045
8122
  const t0 = Date.now();
8046
8123
  let raw;
@@ -8222,7 +8299,9 @@ function deriveTodosFromPlanItem(plan, idOrIndex, subtasks) {
8222
8299
  async function mutatePlan(filePath, sessionId, fn) {
8223
8300
  return withFileLock(filePath, async () => {
8224
8301
  const plan = await loadPlan(filePath) ?? emptyPlan(sessionId);
8302
+ const previous = structuredClone(plan);
8225
8303
  const updated = await fn(plan);
8304
+ assertPlanMutationInvariants(previous, updated);
8226
8305
  const persisted = await savePlan(filePath, updated);
8227
8306
  if (!persisted) {
8228
8307
  throw new SessionError({
@@ -9252,11 +9331,15 @@ function sortKeys(value) {
9252
9331
  }
9253
9332
  return value;
9254
9333
  }
9334
+ function semanticMessage(message) {
9335
+ const { ts: _ts, _estTokens: _estimate, origin: _origin, ...semantic } = message;
9336
+ return semantic;
9337
+ }
9255
9338
  function hashRequest(request) {
9256
9339
  const payload = {
9257
9340
  model: request.model,
9258
9341
  system: request.system,
9259
- messages: request.messages,
9342
+ messages: request.messages.map(semanticMessage),
9260
9343
  tools: request.tools,
9261
9344
  maxTokens: request.maxTokens,
9262
9345
  temperature: request.temperature,
@@ -11005,9 +11088,6 @@ import * as path25 from "node:path";
11005
11088
  function shouldSkipSessionDirectoryEntry(name) {
11006
11089
  return name.startsWith(".") && name !== ".wrongstack" || name === "shared" || name === "subagents" || name === "attachments";
11007
11090
  }
11008
- function isSessionJsonlFileName(name) {
11009
- return name.endsWith(".jsonl") && name !== "_index.jsonl";
11010
- }
11011
11091
 
11012
11092
  // src/storage/session-store/directory-session-files.ts
11013
11093
  function sessionIdForFile(prefix, name) {
@@ -11030,7 +11110,7 @@ async function collectSessionFiles(dir, prefix = "", depth = 0) {
11030
11110
  if (shouldSkipSessionDirectoryEntry(entry.name)) continue;
11031
11111
  if (entry.isDirectory()) {
11032
11112
  dirEntries.push(entry);
11033
- } else if (entry.isFile() && isSessionJsonlFileName(entry.name)) {
11113
+ } else if (entry.isFile() && isSessionTranscriptFileName(entry.name)) {
11034
11114
  files.push({ id: sessionIdForFile(prefix, entry.name), filePath: path25.join(dir, entry.name) });
11035
11115
  }
11036
11116
  }
@@ -11054,7 +11134,7 @@ async function collectSessionIds(dir, prefix = "", depth = 0) {
11054
11134
  if (shouldSkipSessionDirectoryEntry(entry.name)) continue;
11055
11135
  if (entry.isDirectory()) {
11056
11136
  dirEntries.push(entry);
11057
- } else if (entry.isFile() && isSessionJsonlFileName(entry.name)) {
11137
+ } else if (entry.isFile() && isSessionTranscriptFileName(entry.name)) {
11058
11138
  fileIds.push(sessionIdForFile(prefix, entry.name));
11059
11139
  }
11060
11140
  }
@@ -11218,11 +11298,8 @@ var SessionRecovery = class _SessionRecovery {
11218
11298
  }
11219
11299
  continue;
11220
11300
  }
11221
- if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
11222
- if (entry.name === "_index.jsonl" || entry.name === "_mailbox.jsonl") continue;
11301
+ if (!entry.isFile() || !isSessionTranscriptFileName(entry.name)) continue;
11223
11302
  const base = entry.name.slice(0, -".jsonl".length);
11224
- if (base.includes(".replay") || base.includes(".annotations") || base.includes(".audit"))
11225
- continue;
11226
11303
  const sessionId = prefix ? `${prefix}/${base}` : base;
11227
11304
  const stale = await this.detectStaleExact(sessionId);
11228
11305
  if (stale) out.push(stale);
@@ -12580,6 +12657,15 @@ async function validateResumeFileObservations(events, projectRoot) {
12580
12657
  staleFiles: results.filter((entry) => entry !== null)
12581
12658
  };
12582
12659
  }
12660
+ var RESUME_NOTICE_HEADERS = [
12661
+ "[SESSION RESUME FILE VALIDATION]",
12662
+ "[SESSION RESUME INTERRUPTED WORK]"
12663
+ ];
12664
+ function isResumeNoticeMessage(message) {
12665
+ if (message.role !== "system" || typeof message.content !== "string") return false;
12666
+ return RESUME_NOTICE_HEADERS.some((header) => message.content === header || message.content.startsWith(`${header}
12667
+ `));
12668
+ }
12583
12669
  function formatResumeValidationNotice(validation, projectRoot) {
12584
12670
  if (validation.staleFiles.length === 0) return null;
12585
12671
  const root = path28.resolve(projectRoot);
@@ -12776,13 +12862,13 @@ function inheritsIntoFork(event) {
12776
12862
 
12777
12863
  // src/storage/session-store/fork-session.ts
12778
12864
  async function forkSession(host, id, opts = {}) {
12779
- const parent = await host.load(id);
12780
- let boundary = parent.events.length - 1;
12865
+ const parentEvents = await host.readRawEvents(id);
12866
+ let boundary = parentEvents.length - 1;
12781
12867
  let targetCheckpoint;
12782
12868
  if (opts.checkpointPromptIndex !== void 0) {
12783
12869
  boundary = -1;
12784
- for (let i = 0; i < parent.events.length; i++) {
12785
- const event = parent.events[i];
12870
+ for (let i = 0; i < parentEvents.length; i++) {
12871
+ const event = parentEvents[i];
12786
12872
  if (event?.type === "checkpoint" && event.promptIndex === opts.checkpointPromptIndex) {
12787
12873
  boundary = i;
12788
12874
  targetCheckpoint = event;
@@ -12792,15 +12878,18 @@ async function forkSession(host, id, opts = {}) {
12792
12878
  throw new Error(`Checkpoint ${opts.checkpointPromptIndex} not found in session "${id}"`);
12793
12879
  }
12794
12880
  }
12795
- const parentPrefix = parent.events.slice(0, boundary + 1);
12881
+ const parentPrefix = parentEvents.slice(0, boundary + 1);
12796
12882
  const workspaceCheckpoint = targetCheckpoint?.workspaceCheckpoint;
12797
12883
  const checkpointHash = createHash9("sha256").update(parentPrefix.map((event) => JSON.stringify(event)).join("\n") + "\n", "utf8").digest("hex");
12798
12884
  const inherited = parentPrefix.filter(inheritsIntoFork);
12885
+ const start = parentEvents.find(
12886
+ (event) => event.type === "session_start"
12887
+ );
12799
12888
  const writer = await host.create({
12800
12889
  id: "",
12801
- title: parent.metadata.title,
12802
- model: parent.metadata.model,
12803
- provider: parent.metadata.provider
12890
+ title: "",
12891
+ model: start?.model,
12892
+ provider: start?.provider
12804
12893
  });
12805
12894
  try {
12806
12895
  await writer.append({
@@ -12906,6 +12995,17 @@ var SessionLoadCache = class {
12906
12995
  this.entries.clear();
12907
12996
  this.bytes = 0;
12908
12997
  }
12998
+ /**
12999
+ * A hit hands back fresh `messages` / `events` arrays over the cached
13000
+ * contents.
13001
+ *
13002
+ * The entry outlives every caller, and callers treat what they get as their
13003
+ * own: `resume()` passes `messages` straight into a live conversation, and
13004
+ * anything walking `events` may splice it. Returning the cached arrays
13005
+ * themselves let one caller's edit rewrite what the next one loads. The
13006
+ * elements are still shared — copying them would defeat the cache — so
13007
+ * entries remain read-only *contents* behind private containers.
13008
+ */
12909
13009
  getFresh(id, stat15, full) {
12910
13010
  const cached = this.entries.get(id);
12911
13011
  if (!cached || cached.mtimeMs !== stat15.mtimeMs || cached.size !== stat15.size) {
@@ -12913,8 +13013,11 @@ var SessionLoadCache = class {
12913
13013
  }
12914
13014
  this.entries.delete(id);
12915
13015
  this.entries.set(id, cached);
12916
- if (full) return cached.data;
12917
- return { ...cached.data, messages: [] };
13016
+ return {
13017
+ ...cached.data,
13018
+ messages: full ? [...cached.data.messages] : [],
13019
+ events: [...cached.data.events]
13020
+ };
12918
13021
  }
12919
13022
  set(id, stat15, data) {
12920
13023
  this.delete(id);
@@ -13103,6 +13206,9 @@ function stripSnapshotPayload(event) {
13103
13206
  event.messagesOmitted = event.messages.length;
13104
13207
  event.messages = [];
13105
13208
  }
13209
+ function isStrippedSnapshot(event) {
13210
+ return event.messages.length === 0 && typeof event.messagesOmitted === "number" && event.messagesOmitted > 0;
13211
+ }
13106
13212
  function isSessionEventLike(value) {
13107
13213
  return value !== null && typeof value === "object" && typeof value.type === "string" && typeof value.ts === "string";
13108
13214
  }
@@ -13132,7 +13238,12 @@ function replaySessionEvent(params) {
13132
13238
  emitDamaged(params, `Ignored malformed message_updated event at index ${ev.index}`);
13133
13239
  }
13134
13240
  } else if (ev.type === "messages_replaced" && ev.version === 1) {
13135
- if (applyContextSnapshot(messages, openToolUses, ev.messages)) {
13241
+ if (isStrippedSnapshot(ev)) {
13242
+ emitDamaged(
13243
+ params,
13244
+ `Ignored messages_replaced event whose payload was stripped before persistence (${String(ev.messagesOmitted)} messages)`
13245
+ );
13246
+ } else if (applyContextSnapshot(messages, openToolUses, ev.messages)) {
13136
13247
  exactJournalActive = true;
13137
13248
  } else {
13138
13249
  emitDamaged(params, "Ignored malformed messages_replaced event");
@@ -13148,9 +13259,16 @@ function replaySessionEvent(params) {
13148
13259
  emitDamaged(params, `Ignored malformed messages_dropped event (count ${String(ev.count)})`);
13149
13260
  }
13150
13261
  } else if (ev.type === "context_snapshot") {
13151
- if (!applyContextSnapshot(messages, openToolUses, ev.messages)) {
13262
+ if (isStrippedSnapshot(ev)) {
13263
+ emitDamaged(
13264
+ params,
13265
+ `Ignored context_snapshot event whose payload was stripped before persistence (${String(ev.messagesOmitted)} messages)`
13266
+ );
13267
+ } else if (!applyContextSnapshot(messages, openToolUses, ev.messages)) {
13152
13268
  emitDamaged(params, "Ignored malformed context_snapshot event");
13153
13269
  }
13270
+ } else if (ev.type === "messages_replaced" || ev.type === "message_appended" || ev.type === "message_updated" || ev.type === "messages_dropped") {
13271
+ emitDamaged(params, `Ignored ${ev.type} event with unsupported version`);
13154
13272
  } else if (!exactJournalActive && ev.type === "user_input") {
13155
13273
  openToolUses.clear();
13156
13274
  messages.push({ role: "user", content: ev.content, ts: ev.ts });
@@ -13203,7 +13321,7 @@ function emitDamaged(params, detail) {
13203
13321
  import * as fsp15 from "node:fs/promises";
13204
13322
  import * as path31 from "node:path";
13205
13323
  function isPrunableSessionJsonl(name) {
13206
- return name.endsWith(".jsonl") && name !== "_index.jsonl" && name !== "_mailbox.jsonl" && !name.endsWith(".replay.jsonl") && !name.endsWith(".audit.jsonl");
13324
+ return isSessionTranscriptFileName(name);
13207
13325
  }
13208
13326
  async function pruneSessionFiles(storeDir, maxAgeDays, deleteSession) {
13209
13327
  const cutoff = Date.now() - maxAgeDays * 864e5;
@@ -13726,6 +13844,21 @@ var DefaultSessionStore = class _DefaultSessionStore {
13726
13844
  async fork(id, opts = {}) {
13727
13845
  return forkSession(this, id, opts);
13728
13846
  }
13847
+ /**
13848
+ * Implements {@link SessionForkHost.readRawEvents} — the parent stream a fork
13849
+ * inherits, unmodified.
13850
+ *
13851
+ * Deliberately NOT `load()`: that loader empties superseded snapshot payloads
13852
+ * in place and front-drops events past its retention budget, both of which
13853
+ * are correct for reconstructing a conversation and wrong for copying a
13854
+ * journal prefix into a child. Streaming with an accept-everything predicate
13855
+ * keeps the scrubbing contract (`searchEvents` scrubs each line the same way
13856
+ * `load()` does) without either transformation.
13857
+ */
13858
+ async readRawEvents(id) {
13859
+ const hits = await this.searchEvents(id, () => true);
13860
+ return hits.map((hit) => hit.event);
13861
+ }
13729
13862
  /**
13730
13863
  * Capture the deterministic post-tool workspace identity through the store-owned CAS.
13731
13864
  */
@@ -13814,14 +13947,15 @@ var DefaultSessionStore = class _DefaultSessionStore {
13814
13947
  ts: (/* @__PURE__ */ new Date()).toISOString()
13815
13948
  });
13816
13949
  }
13950
+ const carriedMessages = data.messages.filter((message) => !isResumeNoticeMessage(message));
13817
13951
  const resumedData = {
13818
13952
  ...data,
13819
13953
  ...resumeValidation ? { resumeValidation } : {},
13820
- ...noticeMessages.length > 0 ? { messages: [...data.messages, ...noticeMessages] } : {}
13954
+ messages: [...carriedMessages, ...noticeMessages]
13821
13955
  };
13822
13956
  let handle;
13823
13957
  try {
13824
- handle = await fsp19.open(file, "a", 384);
13958
+ handle = await openSessionForAppend(file);
13825
13959
  } catch (err) {
13826
13960
  emitSessionStoreError(this.events, canonicalId, file, "resume", toErrorMessage(err), false);
13827
13961
  throw new Error(
@@ -14000,10 +14134,10 @@ var DefaultSessionStore = class _DefaultSessionStore {
14000
14134
  const limit = criteria.limit ?? 100;
14001
14135
  if (this.catalogClient) {
14002
14136
  const records = await this.catalogClient.call("list_catalog", {
14003
- limit: Math.min(1e3, Math.max(limit, 100)),
14004
- ...criteria.titleContains ? { search: criteria.titleContains } : {}
14137
+ limit,
14138
+ ...criteria
14005
14139
  });
14006
- return this.scrubSummaries(records).filter((summary2) => matchesSessionFilter(summary2, criteria)).slice(0, limit);
14140
+ return this.scrubSummaries(records);
14007
14141
  }
14008
14142
  try {
14009
14143
  const indexed = await this.readIndex();
@@ -14228,11 +14362,11 @@ var DefaultSessionStore = class _DefaultSessionStore {
14228
14362
  return shardKeys;
14229
14363
  }
14230
14364
  async readOrBuildShardManifest(shardKey) {
14231
- const cached = this.shardManifestCache.get(shardKey);
14232
- if (cached) return cached;
14233
14365
  const manifestPath = this.shardManifestPath(shardKey);
14366
+ const cached = await this.freshShardManifestCacheEntry(shardKey, manifestPath);
14367
+ if (cached) return cached;
14234
14368
  return withFileLock(manifestPath, async () => {
14235
- const lockedCached = this.shardManifestCache.get(shardKey);
14369
+ const lockedCached = await this.freshShardManifestCacheEntry(shardKey, manifestPath);
14236
14370
  if (lockedCached) return lockedCached;
14237
14371
  const entry = await readOrBuildShardManifestEntry({
14238
14372
  shardKey,
@@ -14243,10 +14377,38 @@ var DefaultSessionStore = class _DefaultSessionStore {
14243
14377
  summaryHeaderFor: (ref) => this.summaryHeaderFor(ref),
14244
14378
  summaryFor: (id) => this.summaryFor(id)
14245
14379
  });
14246
- this.shardManifestCache.set(shardKey, entry);
14380
+ try {
14381
+ const stat15 = await fsp19.stat(manifestPath);
14382
+ this.shardManifestCache.set(shardKey, {
14383
+ entry,
14384
+ mtimeMs: stat15.mtimeMs,
14385
+ size: stat15.size,
14386
+ ino: stat15.ino
14387
+ });
14388
+ } catch {
14389
+ this.shardManifestCache.delete(shardKey);
14390
+ }
14247
14391
  return entry;
14248
14392
  });
14249
14393
  }
14394
+ /**
14395
+ * Shard manifests are invalidated by other store processes via atomic
14396
+ * delete/rebuild. Validate the in-memory projection against the persisted
14397
+ * file so one long-lived process cannot retain another process's stale view.
14398
+ */
14399
+ async freshShardManifestCacheEntry(shardKey, manifestPath) {
14400
+ const cached = this.shardManifestCache.get(shardKey);
14401
+ if (!cached) return void 0;
14402
+ try {
14403
+ const stat15 = await fsp19.stat(manifestPath);
14404
+ if (stat15.mtimeMs === cached.mtimeMs && stat15.size === cached.size && stat15.ino === cached.ino) {
14405
+ return cached.entry;
14406
+ }
14407
+ } catch {
14408
+ }
14409
+ this.shardManifestCache.delete(shardKey);
14410
+ return void 0;
14411
+ }
14250
14412
  async collectSessionFilesInShard(shardKey) {
14251
14413
  const dir = shardKey ? path32.join(this.dir, shardKey) : this.dir;
14252
14414
  const entries = await this.collectSessionFiles(dir, shardKey);
@@ -14441,7 +14603,11 @@ var DefaultSessionStore = class _DefaultSessionStore {
14441
14603
  const maintenance = this.catalogClient ? await this.catalogClient.call("acquire_maintenance", {
14442
14604
  sessionId: canonical,
14443
14605
  operation: "clear",
14444
- holderId: this.maintenanceHolderId
14606
+ holderId: this.maintenanceHolderId,
14607
+ // The catalog store runs inside the detached project daemon; without
14608
+ // our pid it cannot tell "this TUI clearing its own session" from
14609
+ // "another running wstack" and refuses every /clear as `is live`.
14610
+ holderPid: process.pid
14445
14611
  }) : void 0;
14446
14612
  await this.ensureShardDir(canonical);
14447
14613
  const file = this.sessionPath(canonical, ".jsonl");
@@ -14522,6 +14688,23 @@ var DefaultSessionStore = class _DefaultSessionStore {
14522
14688
  });
14523
14689
  }
14524
14690
  };
14691
+ async function openSessionForAppend(file) {
14692
+ const handle = await fsp19.open(file, "a+", 384);
14693
+ try {
14694
+ const stat15 = await handle.stat();
14695
+ if (stat15.size > 0) {
14696
+ const tail = Buffer.allocUnsafe(1);
14697
+ const { bytesRead } = await handle.read(tail, 0, 1, stat15.size - 1);
14698
+ if (bytesRead === 1 && tail[0] !== 10) {
14699
+ await handle.appendFile("\n", "utf8");
14700
+ }
14701
+ }
14702
+ return handle;
14703
+ } catch (err) {
14704
+ await handle.close().catch(() => void 0);
14705
+ throw err;
14706
+ }
14707
+ }
14525
14708
 
14526
14709
  // src/storage/session-rewind-apply.ts
14527
14710
  async function applyRewindToConversation(opts) {
@@ -14634,17 +14817,20 @@ var DefaultSessionRewinder = class {
14634
14817
  const targetIndex = checkpoints[n - 1]?.promptIndex ?? 0;
14635
14818
  const snapshotsToRevert = [];
14636
14819
  let shouldRevert = false;
14820
+ let removedEvents = 0;
14637
14821
  for await (const event of this.readEvents(file)) {
14638
14822
  if (event.type === "checkpoint" && event.promptIndex === targetIndex) {
14639
14823
  shouldRevert = true;
14640
14824
  continue;
14641
14825
  }
14642
- if (shouldRevert && event.type === "file_snapshot") {
14826
+ if (!shouldRevert) continue;
14827
+ removedEvents++;
14828
+ if (event.type === "file_snapshot") {
14643
14829
  snapshotsToRevert.push({ promptIndex: event.promptIndex, files: event.files });
14644
14830
  }
14645
14831
  }
14646
14832
  const result = await revertSnapshots(snapshotsToRevert, this.projectRoot);
14647
- return { ...result, toPromptIndex: targetIndex, removedEvents: snapshotsToRevert.length };
14833
+ return { ...result, toPromptIndex: targetIndex, removedEvents };
14648
14834
  }
14649
14835
  async rewindToStart(sessionId) {
14650
14836
  const file = this.sessionFile(sessionId);
@@ -14698,6 +14884,54 @@ async function revertSnapshots(snapshots, projectRoot) {
14698
14884
 
14699
14885
  // src/storage/task-store.ts
14700
14886
  import * as fsp21 from "node:fs/promises";
14887
+ function assertTaskMutationInvariants(previous, updated) {
14888
+ const ids = updated.tasks.map((task) => task.id);
14889
+ const uniqueIds = new Set(ids);
14890
+ if (uniqueIds.size !== ids.length) {
14891
+ throw new SessionError({
14892
+ message: "Task mutation rejected: task IDs must be unique.",
14893
+ code: "SESSION_WRITE_FAILED",
14894
+ sessionId: updated.sessionId,
14895
+ context: { operation: "mutateTasks", invariant: "unique_task_ids" }
14896
+ });
14897
+ }
14898
+ const omittedUnfinished = previous.tasks.filter(
14899
+ (task) => task.status !== "completed" && !uniqueIds.has(task.id)
14900
+ );
14901
+ if (omittedUnfinished.length > 0) {
14902
+ throw new SessionError({
14903
+ message: `Task mutation rejected: unfinished tasks cannot be omitted: ${omittedUnfinished.map((task) => task.id).join(", ")}. Complete them first.`,
14904
+ code: "SESSION_WRITE_FAILED",
14905
+ sessionId: updated.sessionId,
14906
+ context: { operation: "mutateTasks", invariant: "unfinished_task_coverage" }
14907
+ });
14908
+ }
14909
+ const byId = new Map(updated.tasks.map((task) => [task.id, task]));
14910
+ for (const task of updated.tasks) {
14911
+ const missing = (task.dependsOn ?? []).filter((dependencyId) => !byId.has(dependencyId));
14912
+ if (missing.length > 0) {
14913
+ throw new SessionError({
14914
+ message: `Task mutation rejected: task "${task.id}" references missing dependencies: ${missing.join(", ")}.`,
14915
+ code: "SESSION_WRITE_FAILED",
14916
+ sessionId: updated.sessionId,
14917
+ context: { operation: "mutateTasks", invariant: "dependency_identity" }
14918
+ });
14919
+ }
14920
+ if (task.status === "in_progress" || task.status === "review" || task.status === "completed") {
14921
+ const unmet = (task.dependsOn ?? []).filter(
14922
+ (dependencyId) => byId.get(dependencyId)?.status !== "completed"
14923
+ );
14924
+ if (unmet.length > 0) {
14925
+ throw new SessionError({
14926
+ message: `Task mutation rejected: task "${task.id}" cannot be ${task.status} before dependencies complete: ${unmet.join(", ")}.`,
14927
+ code: "SESSION_WRITE_FAILED",
14928
+ sessionId: updated.sessionId,
14929
+ context: { operation: "mutateTasks", invariant: "dependency_completion" }
14930
+ });
14931
+ }
14932
+ }
14933
+ }
14934
+ }
14701
14935
  function emptyTaskFile(sessionId) {
14702
14936
  return {
14703
14937
  version: 1,
@@ -14802,7 +15036,9 @@ async function saveTasks(filePath, tasks, events, traceId, warn) {
14802
15036
  async function mutateTasks(filePath, sessionId, fn, events, traceId) {
14803
15037
  return withFileLock(filePath, async () => {
14804
15038
  const file = await loadTasks(filePath, events, traceId) ?? emptyTaskFile(sessionId);
15039
+ const previous = structuredClone(file);
14805
15040
  const updated = await fn(file);
15041
+ assertTaskMutationInvariants(previous, updated);
14806
15042
  const persisted = await saveTasks(filePath, updated, events, traceId);
14807
15043
  if (!persisted) {
14808
15044
  throw new SessionError({