aiwcli 0.12.3 → 0.12.7

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 (125) hide show
  1. package/bin/dev.cmd +3 -3
  2. package/bin/dev.js +16 -16
  3. package/bin/run.cmd +3 -3
  4. package/bin/run.js +21 -21
  5. package/dist/commands/branch.js +7 -2
  6. package/dist/lib/bmad-installer.js +37 -37
  7. package/dist/lib/terminal.d.ts +2 -0
  8. package/dist/lib/terminal.js +57 -7
  9. package/dist/templates/CLAUDE.md +205 -205
  10. package/dist/templates/_shared/.claude/commands/handoff-resume.md +12 -64
  11. package/dist/templates/_shared/.claude/commands/handoff.md +12 -198
  12. package/dist/templates/_shared/.claude/settings.json +65 -65
  13. package/dist/templates/_shared/.codex/workflows/handoff.md +226 -226
  14. package/dist/templates/_shared/.windsurf/workflows/handoff.md +226 -226
  15. package/dist/templates/_shared/handoff-system/CLAUDE.md +421 -0
  16. package/dist/templates/_shared/{lib-ts/handoff → handoff-system/lib}/document-generator.ts +215 -216
  17. package/dist/templates/_shared/{lib-ts/handoff → handoff-system/lib}/handoff-reader.ts +157 -158
  18. package/dist/templates/_shared/{scripts → handoff-system/scripts}/resume_handoff.ts +373 -373
  19. package/dist/templates/_shared/{scripts → handoff-system/scripts}/save_handoff.ts +469 -358
  20. package/dist/templates/_shared/handoff-system/workflows/handoff-resume.md +66 -0
  21. package/dist/templates/_shared/{workflows → handoff-system/workflows}/handoff.md +254 -254
  22. package/dist/templates/_shared/hooks-ts/_utils/git-state.ts +2 -2
  23. package/dist/templates/_shared/hooks-ts/archive_plan.ts +159 -159
  24. package/dist/templates/_shared/hooks-ts/context_monitor.ts +147 -147
  25. package/dist/templates/_shared/hooks-ts/file-suggestion.ts +128 -128
  26. package/dist/templates/_shared/hooks-ts/pre_compact.ts +49 -49
  27. package/dist/templates/_shared/hooks-ts/session_end.ts +196 -183
  28. package/dist/templates/_shared/hooks-ts/session_start.ts +163 -151
  29. package/dist/templates/_shared/hooks-ts/task_create_capture.ts +48 -48
  30. package/dist/templates/_shared/hooks-ts/task_update_capture.ts +74 -74
  31. package/dist/templates/_shared/hooks-ts/user_prompt_submit.ts +93 -93
  32. package/dist/templates/_shared/lib-ts/CLAUDE.md +367 -367
  33. package/dist/templates/_shared/lib-ts/base/atomic-write.ts +138 -138
  34. package/dist/templates/_shared/lib-ts/base/constants.ts +303 -303
  35. package/dist/templates/_shared/lib-ts/base/git-state.ts +58 -58
  36. package/dist/templates/_shared/lib-ts/base/hook-utils.ts +582 -582
  37. package/dist/templates/_shared/lib-ts/base/inference.ts +301 -301
  38. package/dist/templates/_shared/lib-ts/base/logger.ts +247 -247
  39. package/dist/templates/_shared/lib-ts/base/state-io.ts +202 -130
  40. package/dist/templates/_shared/lib-ts/base/stop-words.ts +184 -184
  41. package/dist/templates/_shared/lib-ts/base/subprocess-utils.ts +56 -0
  42. package/dist/templates/_shared/lib-ts/base/utils.ts +184 -184
  43. package/dist/templates/_shared/lib-ts/context/context-formatter.ts +566 -560
  44. package/dist/templates/_shared/lib-ts/context/context-selector.ts +524 -515
  45. package/dist/templates/_shared/lib-ts/context/context-store.ts +712 -668
  46. package/dist/templates/_shared/lib-ts/context/plan-manager.ts +312 -312
  47. package/dist/templates/_shared/lib-ts/context/task-tracker.ts +185 -185
  48. package/dist/templates/_shared/lib-ts/package.json +20 -20
  49. package/dist/templates/_shared/lib-ts/templates/formatters.ts +102 -102
  50. package/dist/templates/_shared/lib-ts/templates/plan-context.ts +58 -58
  51. package/dist/templates/_shared/lib-ts/tsconfig.json +13 -13
  52. package/dist/templates/_shared/lib-ts/types.ts +186 -180
  53. package/dist/templates/_shared/scripts/resolve_context.ts +33 -33
  54. package/dist/templates/_shared/scripts/status_line.ts +690 -690
  55. package/dist/templates/cc-native/.claude/commands/{rlm → cc-native/rlm}/ask.md +136 -136
  56. package/dist/templates/cc-native/.claude/commands/{rlm → cc-native/rlm}/index.md +21 -21
  57. package/dist/templates/cc-native/.claude/commands/{rlm → cc-native/rlm}/overview.md +56 -56
  58. package/dist/templates/cc-native/.claude/commands/cc-native/specdev.md +10 -10
  59. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fix.md +8 -8
  60. package/dist/templates/cc-native/.windsurf/workflows/cc-native/implement.md +8 -8
  61. package/dist/templates/cc-native/.windsurf/workflows/cc-native/research.md +8 -8
  62. package/dist/templates/cc-native/CC-NATIVE-README.md +189 -189
  63. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +304 -304
  64. package/dist/templates/cc-native/_cc-native/agents/CLAUDE.md +143 -143
  65. package/dist/templates/cc-native/_cc-native/agents/PLAN-ORCHESTRATOR.md +213 -213
  66. package/dist/templates/cc-native/_cc-native/agents/plan-questions/PLAN-QUESTIONER.md +70 -70
  67. package/dist/templates/cc-native/_cc-native/cc-native.config.json +96 -96
  68. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +247 -247
  69. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.ts +76 -76
  70. package/dist/templates/cc-native/_cc-native/hooks/enhance_plan_post_subagent.ts +54 -54
  71. package/dist/templates/cc-native/_cc-native/hooks/enhance_plan_post_write.ts +51 -51
  72. package/dist/templates/cc-native/_cc-native/hooks/mark_questions_asked.ts +53 -53
  73. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.ts +61 -61
  74. package/dist/templates/cc-native/_cc-native/lib-ts/agent-selection.ts +163 -163
  75. package/dist/templates/cc-native/_cc-native/lib-ts/aggregate-agents.ts +156 -156
  76. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts/format.ts +597 -597
  77. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts/index.ts +26 -26
  78. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts/tracker.ts +107 -107
  79. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts/write.ts +119 -119
  80. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts.ts +21 -21
  81. package/dist/templates/cc-native/_cc-native/lib-ts/cc-native-state.ts +319 -319
  82. package/dist/templates/cc-native/_cc-native/lib-ts/cli-output-parser.ts +144 -144
  83. package/dist/templates/cc-native/_cc-native/lib-ts/config.ts +57 -57
  84. package/dist/templates/cc-native/_cc-native/lib-ts/constants.ts +83 -83
  85. package/dist/templates/cc-native/_cc-native/lib-ts/corroboration.ts +119 -119
  86. package/dist/templates/cc-native/_cc-native/lib-ts/debug.ts +79 -79
  87. package/dist/templates/cc-native/_cc-native/lib-ts/graduation.ts +132 -132
  88. package/dist/templates/cc-native/_cc-native/lib-ts/index.ts +116 -116
  89. package/dist/templates/cc-native/_cc-native/lib-ts/json-parser.ts +168 -168
  90. package/dist/templates/cc-native/_cc-native/lib-ts/orchestrator.ts +70 -70
  91. package/dist/templates/cc-native/_cc-native/lib-ts/output-builder.ts +130 -130
  92. package/dist/templates/cc-native/_cc-native/lib-ts/plan-discovery.ts +80 -80
  93. package/dist/templates/cc-native/_cc-native/lib-ts/plan-enhancement.ts +41 -41
  94. package/dist/templates/cc-native/_cc-native/lib-ts/plan-questions.ts +101 -101
  95. package/dist/templates/cc-native/_cc-native/lib-ts/review-pipeline.ts +511 -511
  96. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/agent.ts +71 -71
  97. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/base/base-agent.ts +217 -217
  98. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/index.ts +12 -12
  99. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/providers/claude-agent.ts +66 -65
  100. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/providers/codex-agent.ts +184 -184
  101. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/providers/gemini-agent.ts +39 -39
  102. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/providers/orchestrator-claude-agent.ts +196 -195
  103. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/schemas.ts +201 -201
  104. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/types.ts +21 -21
  105. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/CLAUDE.md +480 -480
  106. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/embedding-indexer.ts +287 -287
  107. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/hyde.ts +148 -148
  108. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/index.ts +54 -54
  109. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/logger.ts +58 -58
  110. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/ollama-client.ts +208 -208
  111. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/retrieval-pipeline.ts +460 -460
  112. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/transcript-indexer.ts +446 -447
  113. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/transcript-loader.ts +280 -280
  114. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/transcript-searcher.ts +274 -274
  115. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/types.ts +201 -201
  116. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/vector-store.ts +278 -278
  117. package/dist/templates/cc-native/_cc-native/lib-ts/settings.ts +184 -184
  118. package/dist/templates/cc-native/_cc-native/lib-ts/state.ts +275 -275
  119. package/dist/templates/cc-native/_cc-native/lib-ts/tsconfig.json +18 -18
  120. package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +329 -329
  121. package/dist/templates/cc-native/_cc-native/lib-ts/verdict.ts +72 -72
  122. package/dist/templates/cc-native/_cc-native/workflows/specdev.md +9 -9
  123. package/oclif.manifest.json +1 -1
  124. package/package.json +108 -108
  125. package/dist/templates/cc-native/_cc-native/lib-ts/nul +0 -3
@@ -1,668 +1,712 @@
1
- /**
2
- * Context store — 2-layer CRUD for context state management.
3
- * See SPEC.md §7
4
- *
5
- * Replaces context_manager's 3-layer approach with a simpler 2-layer model:
6
- * state.json (per context folder — SOURCE OF TRUTH)
7
- * index.json (at _output/ root — fast session→context lookup)
8
- */
9
-
10
- import * as fs from "node:fs";
11
- import * as path from "node:path";
12
- import { readStateJson, writeStateJson, toDict, dictToState } from "../base/state-io.js";
13
- import { atomicWrite } from "../base/atomic-write.js";
14
- import {
15
- getContextDir,
16
- getContextsDir,
17
- getIndexPath,
18
- getArchiveDir,
19
- getArchiveContextDir,
20
- getArchiveIndexPath,
21
- validateContextId,
22
- } from "../base/constants.js";
23
- import { logDebug, logInfo, logWarn, logError, setContextPath } from "../base/logger.js";
24
- import { nowIso, generateContextId } from "../base/utils.js";
25
- import type { ContextState, IndexFile, IndexEntry, Mode } from "../types.js";
26
-
27
- const INDEX_VERSION = "3.0";
28
-
29
- // ---------------------------------------------------------------------------
30
- // Internal helpers
31
- // ---------------------------------------------------------------------------
32
-
33
- function loadIndex(projectRoot?: string): IndexFile {
34
- const indexPath = getIndexPath(projectRoot);
35
- if (fs.existsSync(indexPath)) {
36
- try {
37
- const raw = fs.readFileSync(indexPath, "utf-8");
38
- return JSON.parse(raw) as IndexFile;
39
- } catch (e: any) {
40
- logWarn("context_store", `Failed to read index, recreating: ${e}`);
41
- }
42
- }
43
- return { version: INDEX_VERSION, updated_at: nowIso(), sessions: {}, contexts: {} };
44
- }
45
-
46
- function saveIndex(index: IndexFile, projectRoot?: string): boolean {
47
- index.updated_at = nowIso();
48
- const content = JSON.stringify(index, null, 2);
49
- const [success, error] = atomicWrite(getIndexPath(projectRoot), content);
50
- if (!success) {
51
- logWarn("context_store", `Failed to write index: ${error}`);
52
- }
53
- return success;
54
- }
55
-
56
- function toIndexEntry(state: ContextState): IndexEntry {
57
- return {
58
- summary: state.summary,
59
- mode: state.mode,
60
- last_active: state.last_active,
61
- };
62
- }
63
-
64
- /**
65
- * Backward compat: read legacy context.json and convert to ContextState.
66
- */
67
- function migrateContextJson(contextId: string, projectRoot?: string): ContextState | null {
68
- const legacyPath = path.join(getContextDir(contextId, projectRoot), "context.json");
69
- if (!fs.existsSync(legacyPath)) return null;
70
-
71
- try {
72
- const data = JSON.parse(fs.readFileSync(legacyPath, "utf-8"));
73
- const inFlight = data.in_flight ?? {};
74
- const oldMode = inFlight.mode ?? "none";
75
- const MODE_MIGRATION: Record<string, string> = {
76
- none: "idle",
77
- planning: "idle",
78
- pending_implementation: "has_plan",
79
- implementing: "active",
80
- };
81
- const mode = (MODE_MIGRATION[oldMode] ?? "idle") as Mode;
82
-
83
- const sessionIds: string[] = inFlight.session_ids ??
84
- (inFlight.session_id ? [inFlight.session_id] : []);
85
-
86
- return {
87
- id: data.id ?? contextId,
88
- status: data.status ?? "active",
89
- summary: data.summary ?? "",
90
- method: data.method ?? "",
91
- tags: data.tags ?? [],
92
- created_at: data.created_at ?? "",
93
- last_active: data.last_active ?? "",
94
- mode,
95
- plan_path: inFlight.artifact_path ?? null,
96
- plan_hash: inFlight.artifact_hash ?? null,
97
- plan_signature: null,
98
- plan_id: null,
99
- plan_anchors: [],
100
- plan_consumed: false,
101
- handoff_path: inFlight.handoff_path ?? null,
102
- handoff_consumed: false,
103
- session_ids: sessionIds,
104
- last_session: null,
105
- tasks: [],
106
- };
107
- } catch (e: any) {
108
- logWarn("context_store", `Failed to migrate context.json for '${contextId}': ${e}`);
109
- return null;
110
- }
111
- }
112
-
113
- // ---------------------------------------------------------------------------
114
- // Core CRUD
115
- // ---------------------------------------------------------------------------
116
-
117
- /**
118
- * Read state.json for a context. Falls back to context.json for migration.
119
- * See SPEC.md §7.2
120
- */
121
- export function loadState(contextId: string, projectRoot?: string): ContextState | null {
122
- const state = readStateJson(contextId, projectRoot);
123
- if (state) return state;
124
-
125
- // Backward compat: migrate from legacy context.json
126
- return migrateContextJson(contextId, projectRoot);
127
- }
128
-
129
- /**
130
- * Atomically write state.json AND update index.json.
131
- * See SPEC.md §7.3
132
- */
133
- export function saveState(
134
- contextId: string,
135
- state: ContextState,
136
- projectRoot?: string,
137
- ): [boolean, string | null] {
138
- // Ensure the state ID matches
139
- state.id = contextId;
140
-
141
- const [success, error] = writeStateJson(contextId, state, projectRoot);
142
- if (!success) {
143
- logWarn("context_store", `Failed to write state.json for '${contextId}': ${error}`);
144
- return [false, error];
145
- }
146
-
147
- // Update index.json
148
- const index = loadIndex(projectRoot);
149
- index.contexts[contextId] = toIndexEntry(state);
150
- // Keep session mappings in sync
151
- for (const sid of state.session_ids) {
152
- if (!index.sessions) index.sessions = {} as Record<string, string>;
153
- index.sessions[sid] = contextId;
154
- }
155
- const indexOk = saveIndex(index, projectRoot);
156
- if (!indexOk) {
157
- return [true, "state.json saved but index.json update failed"];
158
- }
159
- return [true, null];
160
- }
161
-
162
- /**
163
- * Create a new context folder + state.json + index entry.
164
- * Throws ValueError-equivalent if context already exists.
165
- * See SPEC.md §7.4
166
- */
167
- export function createContext(
168
- contextId: string | null,
169
- summary: string,
170
- method = "",
171
- projectRoot?: string,
172
- tags?: string[],
173
- ): ContextState {
174
- // Generate ID if needed
175
- if (!contextId) {
176
- const existingIds = new Set<string>();
177
- const contextsDir = getContextsDir(projectRoot);
178
- if (fs.existsSync(contextsDir)) {
179
- for (const entry of fs.readdirSync(contextsDir)) {
180
- const fullPath = path.join(contextsDir, entry);
181
- try {
182
- if (fs.statSync(fullPath).isDirectory()) {
183
- existingIds.add(entry);
184
- }
185
- } catch { /* ignore */ }
186
- }
187
- }
188
- contextId = generateContextId(summary, existingIds);
189
- }
190
-
191
- contextId = validateContextId(contextId);
192
- const contextDir = getContextDir(contextId, projectRoot);
193
-
194
- if (fs.existsSync(contextDir)) {
195
- throw new Error(`Context '${contextId}' already exists`);
196
- }
197
-
198
- fs.mkdirSync(contextDir, { recursive: true });
199
-
200
- const now = nowIso();
201
- const state: ContextState = {
202
- id: contextId,
203
- status: "active",
204
- summary,
205
- method,
206
- tags: tags ?? [],
207
- created_at: now,
208
- last_active: now,
209
- mode: "idle",
210
- plan_path: null,
211
- plan_hash: null,
212
- plan_signature: null,
213
- plan_id: null,
214
- plan_anchors: [],
215
- plan_consumed: false,
216
- plan_hash_consumed: null,
217
- handoff_path: null,
218
- handoff_consumed: false,
219
- session_ids: [],
220
- last_session: null,
221
- tasks: [],
222
- };
223
-
224
- saveState(contextId, state, projectRoot);
225
- logInfo("context_store", `Created context: ${contextId}`);
226
- return state;
227
- }
228
-
229
- /**
230
- * Load a single context by ID.
231
- * See SPEC.md §7.5
232
- */
233
- export function getContext(contextId: string, projectRoot?: string): ContextState | null {
234
- try {
235
- contextId = validateContextId(contextId);
236
- } catch {
237
- return null;
238
- }
239
- return loadState(contextId, projectRoot);
240
- }
241
-
242
- /**
243
- * List contexts from index.json, loading each state.json.
244
- * Falls back to scanning context folders if index is missing.
245
- * Results sorted by last_active descending.
246
- * See SPEC.md §7.6
247
- */
248
- export function getAllContexts(
249
- status?: string,
250
- projectRoot?: string,
251
- ): ContextState[] {
252
- const results: ContextState[] = [];
253
- const contextsDir = getContextsDir(projectRoot);
254
- if (!fs.existsSync(contextsDir)) return [];
255
-
256
- // Try index-driven path first
257
- const index = loadIndex(projectRoot);
258
- const ctxMap = index.contexts;
259
-
260
- if (ctxMap && typeof ctxMap === "object" && Object.keys(ctxMap).length > 0) {
261
- for (const cid of Object.keys(ctxMap)) {
262
- const state = loadState(cid, projectRoot);
263
- if (state && (!status || state.status === status)) {
264
- results.push(state);
265
- }
266
- }
267
- } else {
268
- // Fallback: scan folders
269
- try {
270
- for (const entry of fs.readdirSync(contextsDir)) {
271
- if (entry.startsWith("_")) continue;
272
- const fullPath = path.join(contextsDir, entry);
273
- try {
274
- if (!fs.statSync(fullPath).isDirectory()) continue;
275
- } catch { continue; }
276
- const state = loadState(entry, projectRoot);
277
- if (state && (!status || state.status === status)) {
278
- results.push(state);
279
- }
280
- }
281
- } catch { /* empty dir */ }
282
- }
283
-
284
- results.sort((a, b) => (b.last_active || "").localeCompare(a.last_active || ""));
285
- return results;
286
- }
287
-
288
- /**
289
- * Update allowed metadata fields (summary, tags, method) on a context.
290
- * See SPEC.md §7.7
291
- */
292
- export function updateContext(
293
- contextId: string,
294
- updates: Partial<Pick<ContextState, "summary" | "tags" | "method">>,
295
- projectRoot?: string,
296
- ): ContextState | null {
297
- const state = getContext(contextId, projectRoot);
298
- if (!state) return null;
299
-
300
- let changed = false;
301
- if (updates.summary !== undefined) { state.summary = updates.summary; changed = true; }
302
- if (updates.tags !== undefined) { state.tags = updates.tags; changed = true; }
303
- if (updates.method !== undefined) { state.method = updates.method; changed = true; }
304
-
305
- if (!changed) return state;
306
-
307
- state.last_active = nowIso();
308
- saveState(contextId, state, projectRoot);
309
- return state;
310
- }
311
-
312
- // ---------------------------------------------------------------------------
313
- // Session binding & mode updates
314
- // ---------------------------------------------------------------------------
315
-
316
- /**
317
- * O(1) lookup: check index.json sessions map first.
318
- * Side effect: sets logger context path for per-context log routing.
319
- * See SPEC.md §7.8
320
- */
321
- export function getContextBySessionId(
322
- sessionId: string,
323
- projectRoot?: string,
324
- ): ContextState | null {
325
- if (!sessionId || sessionId === "unknown") return null;
326
-
327
- const index = loadIndex(projectRoot);
328
- const cid = index.sessions?.[sessionId];
329
- if (cid) {
330
- const state = loadState(cid, projectRoot);
331
- if (state) {
332
- setLoggerContext(state.id, projectRoot);
333
- return state;
334
- }
335
- }
336
-
337
- // Fallback: scan all contexts
338
- for (const state of getAllContexts("active", projectRoot)) {
339
- if (state.session_ids.includes(sessionId)) {
340
- setLoggerContext(state.id, projectRoot);
341
- return state;
342
- }
343
- }
344
- return null;
345
- }
346
-
347
- function setLoggerContext(contextId: string, projectRoot?: string): void {
348
- try {
349
- const ctxDir = getContextDir(contextId, projectRoot);
350
- if (fs.existsSync(ctxDir)) {
351
- setContextPath(ctxDir);
352
- }
353
- } catch {
354
- // Never crash on logging setup
355
- }
356
- }
357
-
358
- /**
359
- * Add session_id to both index.json sessions map and state.json session_ids.
360
- * See SPEC.md §7.9
361
- */
362
- export function bindSession(
363
- contextId: string,
364
- sessionId: string,
365
- projectRoot?: string,
366
- ): boolean {
367
- if (!sessionId || sessionId === "unknown") return false;
368
-
369
- const state = getContext(contextId, projectRoot);
370
- if (!state) return false;
371
-
372
- if (!state.session_ids.includes(sessionId)) {
373
- state.session_ids.push(sessionId);
374
- }
375
- state.last_active = nowIso();
376
-
377
- const [success] = saveState(contextId, state, projectRoot);
378
- return success;
379
- }
380
-
381
- /**
382
- * Change the mode field, optionally setting plan/handoff fields.
383
- * See SPEC.md §7.10
384
- */
385
- export function updateMode(
386
- contextId: string,
387
- mode: Mode,
388
- projectRoot?: string,
389
- opts?: {
390
- plan_path?: string;
391
- plan_hash?: string;
392
- plan_signature?: string;
393
- plan_id?: string;
394
- plan_anchors?: string[];
395
- plan_consumed?: boolean;
396
- plan_hash_consumed?: string;
397
- handoff_consumed?: boolean;
398
- },
399
- ): ContextState | null {
400
- const state = getContext(contextId, projectRoot);
401
- if (!state) return null;
402
-
403
- state.mode = mode;
404
- state.last_active = nowIso();
405
-
406
- if (opts) {
407
- if (opts.plan_path !== undefined) state.plan_path = opts.plan_path;
408
- if (opts.plan_hash !== undefined) state.plan_hash = opts.plan_hash;
409
- if (opts.plan_signature !== undefined) state.plan_signature = opts.plan_signature;
410
- if (opts.plan_id !== undefined) state.plan_id = opts.plan_id;
411
- if (opts.plan_anchors !== undefined) state.plan_anchors = opts.plan_anchors;
412
- if (opts.plan_consumed !== undefined) state.plan_consumed = opts.plan_consumed;
413
- if (opts.plan_hash_consumed !== undefined) state.plan_hash_consumed = opts.plan_hash_consumed;
414
- if (opts.handoff_consumed !== undefined) state.handoff_consumed = opts.handoff_consumed;
415
- }
416
-
417
- // Clear plan/handoff fields when returning to idle
418
- if (mode === "idle") {
419
- state.plan_path = null;
420
- state.plan_hash = null;
421
- state.plan_signature = null;
422
- state.plan_id = null;
423
- state.plan_anchors = [];
424
- state.plan_consumed = false;
425
- state.plan_hash_consumed = null;
426
- state.handoff_consumed = false;
427
- }
428
-
429
- saveState(contextId, state, projectRoot);
430
- return state;
431
- }
432
-
433
- /**
434
- * Transition idle/has_plan/has_handoff → active, unless in plan mode.
435
- * See SPEC.md §7.11
436
- */
437
- export function maybeActivate(
438
- contextId: string,
439
- permissionMode: string,
440
- projectRoot?: string,
441
- caller = "",
442
- ): boolean {
443
- if (permissionMode === "plan") return false;
444
-
445
- const state = getContext(contextId, projectRoot);
446
- if (!state) return false;
447
-
448
- if (state.mode === "idle" || state.mode === "has_plan" || state.mode === "has_handoff") {
449
- const oldMode = state.mode;
450
- const opts: Record<string, any> = {};
451
- if (oldMode === "has_plan") opts.plan_consumed = true;
452
- else if (oldMode === "has_handoff") opts.handoff_consumed = true;
453
- updateMode(contextId, "active", projectRoot, opts);
454
- logInfo("context_store", `maybe_activate (${caller}): ${contextId} ${oldMode} -> active`);
455
- return true;
456
- }
457
-
458
- return false;
459
- }
460
-
461
- // ---------------------------------------------------------------------------
462
- // Lifecycle
463
- // ---------------------------------------------------------------------------
464
-
465
- /**
466
- * Mark context completed and archive it.
467
- * See SPEC.md §7.12
468
- */
469
- export function completeContext(contextId: string, projectRoot?: string): ContextState | null {
470
- const state = getContext(contextId, projectRoot);
471
- if (!state) return null;
472
-
473
- if (state.status === "completed") {
474
- logInfo("context_store", `Context '${contextId}' already completed`);
475
- return state;
476
- }
477
-
478
- state.status = "completed";
479
- state.last_active = nowIso();
480
- saveState(contextId, state, projectRoot);
481
- logInfo("context_store", `Completed context: ${contextId}`);
482
-
483
- const archived = archiveContext(contextId, projectRoot);
484
- return archived ?? state;
485
- }
486
-
487
- /**
488
- * Move completed context folder to _archive/, update indices.
489
- * See SPEC.md §7.13
490
- */
491
- export function archiveContext(contextId: string, projectRoot?: string): ContextState | null {
492
- const state = getContext(contextId, projectRoot);
493
- if (!state) {
494
- logWarn("context_store", `Cannot archive: context '${contextId}' not found`);
495
- return null;
496
- }
497
- if (state.status !== "completed") {
498
- logWarn("context_store", `Cannot archive: context '${contextId}' not completed`);
499
- return null;
500
- }
501
-
502
- const sourceDir = getContextDir(contextId, projectRoot);
503
- const archiveDest = getArchiveContextDir(contextId, projectRoot);
504
-
505
- if (fs.existsSync(archiveDest)) {
506
- logWarn("context_store", `Cannot archive: archive folder already exists for '${contextId}'`);
507
- return null;
508
- }
509
-
510
- const archiveParent = path.dirname(archiveDest);
511
- fs.mkdirSync(archiveParent, { recursive: true });
512
-
513
- try {
514
- fs.renameSync(sourceDir, archiveDest);
515
- } catch (e: any) {
516
- logError("context_store", `Failed to move context to archive: ${e}`);
517
- return null;
518
- }
519
-
520
- // Remove from main index
521
- const index = loadIndex(projectRoot);
522
- delete index.contexts[contextId];
523
- const sessions = index.sessions ?? {};
524
- for (const [sid, cid] of Object.entries(sessions)) {
525
- if (cid === contextId) delete sessions[sid];
526
- }
527
- saveIndex(index, projectRoot);
528
-
529
- // Add to archive index
530
- updateArchiveIndex(state, projectRoot);
531
-
532
- logInfo("context_store", `Archived context: ${contextId}`);
533
- return state;
534
- }
535
-
536
- /**
537
- * Reopen a completed/archived context.
538
- * See SPEC.md §7.14
539
- */
540
- export function reopenContext(contextId: string, projectRoot?: string): ContextState | null {
541
- let state = getContext(contextId, projectRoot);
542
-
543
- if (!state) {
544
- state = restoreFromArchive(contextId, projectRoot);
545
- }
546
- if (!state) return null;
547
-
548
- if (state.status === "active") {
549
- logInfo("context_store", `Context '${contextId}' already active`);
550
- return state;
551
- }
552
-
553
- state.status = "active";
554
- state.last_active = nowIso();
555
- saveState(contextId, state, projectRoot);
556
- logInfo("context_store", `Reopened context: ${contextId}`);
557
- return state;
558
- }
559
-
560
- // ---------------------------------------------------------------------------
561
- // Auto-creation from prompt
562
- // ---------------------------------------------------------------------------
563
-
564
- /**
565
- * Auto-create a context from the user's prompt.
566
- * See SPEC.md §7.15
567
- */
568
- export function createContextFromPrompt(
569
- userPrompt: string,
570
- projectRoot?: string,
571
- ): ContextState {
572
- let summary = userPrompt.trim().slice(0, 2000);
573
- if (userPrompt.trim().length > 2000) {
574
- summary += "...";
575
- }
576
-
577
- return createContext(
578
- null,
579
- summary,
580
- "auto-created",
581
- projectRoot,
582
- ["auto-created"],
583
- );
584
- }
585
-
586
-
587
- // ---------------------------------------------------------------------------
588
- // Archive helpers
589
- // ---------------------------------------------------------------------------
590
-
591
- function updateArchiveIndex(state: ContextState, projectRoot?: string): boolean {
592
- const archiveDir = getArchiveDir(projectRoot);
593
- const archiveIndexPath = getArchiveIndexPath(projectRoot);
594
- fs.mkdirSync(archiveDir, { recursive: true });
595
-
596
- let archiveIndex: IndexFile = {
597
- version: INDEX_VERSION,
598
- updated_at: nowIso(),
599
- sessions: {},
600
- contexts: {},
601
- };
602
-
603
- if (fs.existsSync(archiveIndexPath)) {
604
- try {
605
- archiveIndex = JSON.parse(fs.readFileSync(archiveIndexPath, "utf-8"));
606
- } catch (e: any) {
607
- logWarn("context_store", `Failed to read archive index, recreating: ${e}`);
608
- }
609
- }
610
-
611
- archiveIndex.contexts[state.id] = toIndexEntry(state);
612
- archiveIndex.updated_at = nowIso();
613
-
614
- const content = JSON.stringify(archiveIndex, null, 2);
615
- const [success, error] = atomicWrite(archiveIndexPath, content);
616
- if (!success) {
617
- logWarn("context_store", `Failed to write archive index: ${error}`);
618
- }
619
- return success;
620
- }
621
-
622
- function restoreFromArchive(contextId: string, projectRoot?: string): ContextState | null {
623
- const archiveDir = getArchiveContextDir(contextId, projectRoot);
624
- const activeDir = getContextDir(contextId, projectRoot);
625
-
626
- if (!fs.existsSync(archiveDir)) return null;
627
- if (fs.existsSync(activeDir)) {
628
- logWarn("context_store", `Cannot restore: active folder already exists for '${contextId}'`);
629
- return null;
630
- }
631
-
632
- try {
633
- fs.renameSync(archiveDir, activeDir);
634
- } catch (e: any) {
635
- logError("context_store", `Failed to restore context from archive: ${e}`);
636
- return null;
637
- }
638
-
639
- // Remove from archive index
640
- removeFromArchiveIndex(contextId, projectRoot);
641
-
642
- const state = loadState(contextId, projectRoot);
643
- logInfo("context_store", `Restored context from archive: ${contextId}`);
644
- return state;
645
- }
646
-
647
- function removeFromArchiveIndex(contextId: string, projectRoot?: string): boolean {
648
- const archiveIndexPath = getArchiveIndexPath(projectRoot);
649
- if (!fs.existsSync(archiveIndexPath)) return true;
650
-
651
- try {
652
- const archiveIndex = JSON.parse(fs.readFileSync(archiveIndexPath, "utf-8")) as IndexFile;
653
- if (archiveIndex.contexts[contextId]) {
654
- delete archiveIndex.contexts[contextId];
655
- archiveIndex.updated_at = nowIso();
656
- const content = JSON.stringify(archiveIndex, null, 2);
657
- const [success, error] = atomicWrite(archiveIndexPath, content);
658
- if (!success) {
659
- logWarn("context_store", `Failed to write archive index: ${error}`);
660
- return false;
661
- }
662
- }
663
- return true;
664
- } catch (e: any) {
665
- logWarn("context_store", `Failed to read archive index: ${e}`);
666
- return false;
667
- }
668
- }
1
+ /**
2
+ * Context store — 2-layer CRUD for context state management.
3
+ * See SPEC.md §7
4
+ *
5
+ * Replaces context_manager's 3-layer approach with a simpler 2-layer model:
6
+ * state.json (per context folder — SOURCE OF TRUTH)
7
+ * index.json (at _output/ root — fast session→context lookup)
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { readStateJson, writeStateJson, toDict, dictToState } from "../base/state-io.js";
13
+ import { atomicWrite } from "../base/atomic-write.js";
14
+ import {
15
+ getContextDir,
16
+ getContextsDir,
17
+ getIndexPath,
18
+ getArchiveDir,
19
+ getArchiveContextDir,
20
+ getArchiveIndexPath,
21
+ validateContextId,
22
+ } from "../base/constants.js";
23
+ import { logDebug, logInfo, logWarn, logError, setContextPath } from "../base/logger.js";
24
+ import { nowIso, generateContextId } from "../base/utils.js";
25
+ import type { ContextState, IndexFile, IndexEntry, Mode } from "../types.js";
26
+
27
+ const INDEX_VERSION = "3.0";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Public utilities
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Determine artifact type from context state.
35
+ * Checks explicit next_artifact_type field first, falls back to field detection.
36
+ *
37
+ * Edge cases:
38
+ * - Both artifacts exist: Log warning, return "plan" (deterministic fallback for corrupted state)
39
+ * - No artifacts: Return null (caller handles gracefully)
40
+ */
41
+ export function determineArtifactType(
42
+ state: ContextState,
43
+ ): "plan" | "handoff" | null {
44
+ // Explicit field takes precedence
45
+ if (state.next_artifact_type) {
46
+ return state.next_artifact_type;
47
+ }
48
+
49
+ // Implicit detection
50
+ const hasPlan = Boolean(state.plan_path && state.plan_hash);
51
+ const hasHandoff = Boolean(state.handoff_path);
52
+
53
+ // Edge case: Both exist (shouldn't happen - indicates bug in replacement logic)
54
+ // Fallback: Pick plan (deterministic, no filesystem I/O)
55
+ if (hasPlan && hasHandoff) {
56
+ logWarn(
57
+ "context_store",
58
+ `Context ${state.id} has both plan and handoff - indicates bug in replacement logic`,
59
+ );
60
+ return "plan";
61
+ }
62
+
63
+ if (hasPlan) return "plan";
64
+ if (hasHandoff) return "handoff";
65
+
66
+ // No artifacts present - return null (caller logs warning and skips)
67
+ return null;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Internal helpers
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function loadIndex(projectRoot?: string): IndexFile {
75
+ const indexPath = getIndexPath(projectRoot);
76
+ if (fs.existsSync(indexPath)) {
77
+ try {
78
+ const raw = fs.readFileSync(indexPath, "utf-8");
79
+ return JSON.parse(raw) as IndexFile;
80
+ } catch (e: any) {
81
+ logWarn("context_store", `Failed to read index, recreating: ${e}`);
82
+ }
83
+ }
84
+ return { version: INDEX_VERSION, updated_at: nowIso(), sessions: {}, contexts: {} };
85
+ }
86
+
87
+ function saveIndex(index: IndexFile, projectRoot?: string): boolean {
88
+ index.updated_at = nowIso();
89
+ const content = JSON.stringify(index, null, 2);
90
+ const [success, error] = atomicWrite(getIndexPath(projectRoot), content);
91
+ if (!success) {
92
+ logWarn("context_store", `Failed to write index: ${error}`);
93
+ }
94
+ return success;
95
+ }
96
+
97
+ function toIndexEntry(state: ContextState): IndexEntry {
98
+ return {
99
+ summary: state.summary,
100
+ mode: state.mode,
101
+ last_active: state.last_active,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Backward compat: read legacy context.json and convert to ContextState.
107
+ */
108
+ function migrateContextJson(contextId: string, projectRoot?: string): ContextState | null {
109
+ const legacyPath = path.join(getContextDir(contextId, projectRoot), "context.json");
110
+ if (!fs.existsSync(legacyPath)) return null;
111
+
112
+ try {
113
+ const data = JSON.parse(fs.readFileSync(legacyPath, "utf-8"));
114
+ const inFlight = data.in_flight ?? {};
115
+ const oldMode = inFlight.mode ?? "none";
116
+ const MODE_MIGRATION: Record<string, string> = {
117
+ none: "idle",
118
+ planning: "idle",
119
+ pending_implementation: "has_plan",
120
+ implementing: "active",
121
+ };
122
+ const mode = (MODE_MIGRATION[oldMode] ?? "idle") as Mode;
123
+
124
+ const sessionIds: string[] = inFlight.session_ids ??
125
+ (inFlight.session_id ? [inFlight.session_id] : []);
126
+
127
+ return {
128
+ id: data.id ?? contextId,
129
+ status: data.status ?? "active",
130
+ summary: data.summary ?? "",
131
+ method: data.method ?? "",
132
+ tags: data.tags ?? [],
133
+ created_at: data.created_at ?? "",
134
+ last_active: data.last_active ?? "",
135
+ mode,
136
+ plan_path: inFlight.artifact_path ?? null,
137
+ plan_hash: inFlight.artifact_hash ?? null,
138
+ plan_signature: null,
139
+ plan_id: null,
140
+ plan_anchors: [],
141
+ plan_consumed: false,
142
+ handoff_path: inFlight.handoff_path ?? null,
143
+ handoff_consumed: false,
144
+ session_ids: sessionIds,
145
+ last_session: null,
146
+ tasks: [],
147
+ };
148
+ } catch (e: any) {
149
+ logWarn("context_store", `Failed to migrate context.json for '${contextId}': ${e}`);
150
+ return null;
151
+ }
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Core CRUD
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Read state.json for a context. Falls back to context.json for migration.
160
+ * See SPEC.md §7.2
161
+ */
162
+ export function loadState(contextId: string, projectRoot?: string): ContextState | null {
163
+ const state = readStateJson(contextId, projectRoot);
164
+ if (state) return state;
165
+
166
+ // Backward compat: migrate from legacy context.json
167
+ return migrateContextJson(contextId, projectRoot);
168
+ }
169
+
170
+ /**
171
+ * Atomically write state.json AND update index.json.
172
+ * See SPEC.md §7.3
173
+ */
174
+ export function saveState(
175
+ contextId: string,
176
+ state: ContextState,
177
+ projectRoot?: string,
178
+ ): [boolean, string | null] {
179
+ // Ensure the state ID matches
180
+ state.id = contextId;
181
+
182
+ const [success, error] = writeStateJson(contextId, state, projectRoot);
183
+ if (!success) {
184
+ logWarn("context_store", `Failed to write state.json for '${contextId}': ${error}`);
185
+ return [false, error];
186
+ }
187
+
188
+ // Update index.json
189
+ const index = loadIndex(projectRoot);
190
+ index.contexts[contextId] = toIndexEntry(state);
191
+ // Keep session mappings in sync
192
+ for (const sid of state.session_ids) {
193
+ if (!index.sessions) index.sessions = {} as Record<string, string>;
194
+ index.sessions[sid] = contextId;
195
+ }
196
+ const indexOk = saveIndex(index, projectRoot);
197
+ if (!indexOk) {
198
+ return [true, "state.json saved but index.json update failed"];
199
+ }
200
+ return [true, null];
201
+ }
202
+
203
+ /**
204
+ * Create a new context folder + state.json + index entry.
205
+ * Throws ValueError-equivalent if context already exists.
206
+ * See SPEC.md §7.4
207
+ */
208
+ export function createContext(
209
+ contextId: string | null,
210
+ summary: string,
211
+ method = "",
212
+ projectRoot?: string,
213
+ tags?: string[],
214
+ ): ContextState {
215
+ // Generate ID if needed
216
+ if (!contextId) {
217
+ const existingIds = new Set<string>();
218
+ const contextsDir = getContextsDir(projectRoot);
219
+ if (fs.existsSync(contextsDir)) {
220
+ for (const entry of fs.readdirSync(contextsDir)) {
221
+ const fullPath = path.join(contextsDir, entry);
222
+ try {
223
+ if (fs.statSync(fullPath).isDirectory()) {
224
+ existingIds.add(entry);
225
+ }
226
+ } catch { /* ignore */ }
227
+ }
228
+ }
229
+ contextId = generateContextId(summary, existingIds);
230
+ }
231
+
232
+ contextId = validateContextId(contextId);
233
+ const contextDir = getContextDir(contextId, projectRoot);
234
+
235
+ if (fs.existsSync(contextDir)) {
236
+ throw new Error(`Context '${contextId}' already exists`);
237
+ }
238
+
239
+ fs.mkdirSync(contextDir, { recursive: true });
240
+ fs.mkdirSync(path.join(contextDir, "notes"), { recursive: true });
241
+
242
+ const now = nowIso();
243
+ const state: ContextState = {
244
+ id: contextId,
245
+ status: "active",
246
+ summary,
247
+ method,
248
+ tags: tags ?? [],
249
+ created_at: now,
250
+ last_active: now,
251
+ mode: "idle",
252
+ plan_path: null,
253
+ plan_hash: null,
254
+ plan_signature: null,
255
+ plan_id: null,
256
+ plan_anchors: [],
257
+ plan_hash_consumed: null,
258
+ handoff_path: null,
259
+ work_consumed: false, // CHANGED: unified flag
260
+ next_artifact_type: null,
261
+ session_ids: [],
262
+ last_session: null,
263
+ tasks: [],
264
+ };
265
+
266
+ saveState(contextId, state, projectRoot);
267
+ logInfo("context_store", `Created context: ${contextId}`);
268
+ return state;
269
+ }
270
+
271
+ /**
272
+ * Load a single context by ID.
273
+ * See SPEC.md §7.5
274
+ */
275
+ export function getContext(contextId: string, projectRoot?: string): ContextState | null {
276
+ try {
277
+ contextId = validateContextId(contextId);
278
+ } catch {
279
+ return null;
280
+ }
281
+ return loadState(contextId, projectRoot);
282
+ }
283
+
284
+ /**
285
+ * List contexts from index.json, loading each state.json.
286
+ * Falls back to scanning context folders if index is missing.
287
+ * Results sorted by last_active descending.
288
+ * See SPEC.md §7.6
289
+ */
290
+ export function getAllContexts(
291
+ status?: string,
292
+ projectRoot?: string,
293
+ ): ContextState[] {
294
+ const results: ContextState[] = [];
295
+ const contextsDir = getContextsDir(projectRoot);
296
+ if (!fs.existsSync(contextsDir)) return [];
297
+
298
+ // Try index-driven path first
299
+ const index = loadIndex(projectRoot);
300
+ const ctxMap = index.contexts;
301
+
302
+ if (ctxMap && typeof ctxMap === "object" && Object.keys(ctxMap).length > 0) {
303
+ for (const cid of Object.keys(ctxMap)) {
304
+ const state = loadState(cid, projectRoot);
305
+ if (state && (!status || state.status === status)) {
306
+ results.push(state);
307
+ }
308
+ }
309
+ } else {
310
+ // Fallback: scan folders
311
+ try {
312
+ for (const entry of fs.readdirSync(contextsDir)) {
313
+ if (entry.startsWith("_")) continue;
314
+ const fullPath = path.join(contextsDir, entry);
315
+ try {
316
+ if (!fs.statSync(fullPath).isDirectory()) continue;
317
+ } catch { continue; }
318
+ const state = loadState(entry, projectRoot);
319
+ if (state && (!status || state.status === status)) {
320
+ results.push(state);
321
+ }
322
+ }
323
+ } catch { /* empty dir */ }
324
+ }
325
+
326
+ results.sort((a, b) => (b.last_active || "").localeCompare(a.last_active || ""));
327
+ return results;
328
+ }
329
+
330
+ /**
331
+ * Update allowed metadata fields (summary, tags, method) on a context.
332
+ * See SPEC.md §7.7
333
+ */
334
+ export function updateContext(
335
+ contextId: string,
336
+ updates: Partial<Pick<ContextState, "summary" | "tags" | "method">>,
337
+ projectRoot?: string,
338
+ ): ContextState | null {
339
+ const state = getContext(contextId, projectRoot);
340
+ if (!state) return null;
341
+
342
+ let changed = false;
343
+ if (updates.summary !== undefined) { state.summary = updates.summary; changed = true; }
344
+ if (updates.tags !== undefined) { state.tags = updates.tags; changed = true; }
345
+ if (updates.method !== undefined) { state.method = updates.method; changed = true; }
346
+
347
+ if (!changed) return state;
348
+
349
+ state.last_active = nowIso();
350
+ saveState(contextId, state, projectRoot);
351
+ return state;
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Session binding & mode updates
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * O(1) lookup: check index.json sessions map first.
360
+ * Side effect: sets logger context path for per-context log routing.
361
+ * See SPEC.md §7.8
362
+ */
363
+ export function getContextBySessionId(
364
+ sessionId: string,
365
+ projectRoot?: string,
366
+ ): ContextState | null {
367
+ if (!sessionId || sessionId === "unknown") return null;
368
+
369
+ const index = loadIndex(projectRoot);
370
+ const cid = index.sessions?.[sessionId];
371
+ if (cid) {
372
+ const state = loadState(cid, projectRoot);
373
+ if (state) {
374
+ setLoggerContext(state.id, projectRoot);
375
+ return state;
376
+ }
377
+ }
378
+
379
+ // Fallback: scan all contexts
380
+ for (const state of getAllContexts("active", projectRoot)) {
381
+ if (state.session_ids.includes(sessionId)) {
382
+ setLoggerContext(state.id, projectRoot);
383
+ return state;
384
+ }
385
+ }
386
+ return null;
387
+ }
388
+
389
+ function setLoggerContext(contextId: string, projectRoot?: string): void {
390
+ try {
391
+ const ctxDir = getContextDir(contextId, projectRoot);
392
+ if (fs.existsSync(ctxDir)) {
393
+ setContextPath(ctxDir);
394
+ }
395
+ } catch {
396
+ // Never crash on logging setup
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Add session_id to both index.json sessions map and state.json session_ids.
402
+ * See SPEC.md §7.9
403
+ */
404
+ export function bindSession(
405
+ contextId: string,
406
+ sessionId: string,
407
+ projectRoot?: string,
408
+ ): boolean {
409
+ if (!sessionId || sessionId === "unknown") return false;
410
+
411
+ const state = getContext(contextId, projectRoot);
412
+ if (!state) return false;
413
+
414
+ if (!state.session_ids.includes(sessionId)) {
415
+ state.session_ids.push(sessionId);
416
+ }
417
+ state.last_active = nowIso();
418
+
419
+ const [success] = saveState(contextId, state, projectRoot);
420
+ return success;
421
+ }
422
+
423
+ /**
424
+ * Change the mode field, optionally setting plan/handoff fields.
425
+ * See SPEC.md §7.10
426
+ */
427
+ export function updateMode(
428
+ contextId: string,
429
+ mode: Mode,
430
+ projectRoot?: string,
431
+ opts?: {
432
+ plan_path?: string;
433
+ plan_hash?: string;
434
+ plan_signature?: string;
435
+ plan_id?: string;
436
+ plan_anchors?: string[];
437
+ work_consumed?: boolean; // FIXED: unified flag (was plan_consumed/handoff_consumed)
438
+ plan_hash_consumed?: string;
439
+ },
440
+ ): ContextState | null {
441
+ const state = getContext(contextId, projectRoot);
442
+ if (!state) return null;
443
+
444
+ state.mode = mode;
445
+ state.last_active = nowIso();
446
+
447
+ if (opts) {
448
+ if (opts.plan_path !== undefined) state.plan_path = opts.plan_path;
449
+ if (opts.plan_hash !== undefined) state.plan_hash = opts.plan_hash;
450
+ if (opts.plan_signature !== undefined) state.plan_signature = opts.plan_signature;
451
+ if (opts.plan_id !== undefined) state.plan_id = opts.plan_id;
452
+ if (opts.plan_anchors !== undefined) state.plan_anchors = opts.plan_anchors;
453
+ if (opts.work_consumed !== undefined) state.work_consumed = opts.work_consumed; // CHANGED: unified flag
454
+ if (opts.plan_hash_consumed !== undefined)
455
+ state.plan_hash_consumed = opts.plan_hash_consumed;
456
+ }
457
+
458
+ // Clear plan/handoff fields when returning to idle
459
+ if (mode === "idle") {
460
+ state.plan_path = null;
461
+ state.plan_hash = null;
462
+ state.plan_signature = null;
463
+ state.plan_id = null;
464
+ state.plan_anchors = [];
465
+ state.plan_hash_consumed = null;
466
+ state.handoff_path = null;
467
+ state.work_consumed = false; // CHANGED: unified flag
468
+ state.next_artifact_type = null;
469
+ }
470
+
471
+ saveState(contextId, state, projectRoot);
472
+ return state;
473
+ }
474
+
475
+ /**
476
+ * Transition idle/has_staged_work → active, unless in plan mode.
477
+ * See SPEC.md §7.11
478
+ */
479
+ export function maybeActivate(
480
+ contextId: string,
481
+ permissionMode: string,
482
+ projectRoot?: string,
483
+ caller = "",
484
+ ): boolean {
485
+ if (permissionMode === "plan") return false;
486
+
487
+ const state = getContext(contextId, projectRoot);
488
+ if (!state) return false;
489
+
490
+ if (state.mode === "idle" || state.mode === "has_staged_work") {
491
+ const oldMode = state.mode;
492
+ const opts: Record<string, any> = {};
493
+ if (oldMode === "has_staged_work") opts.work_consumed = true; // CHANGED: unified flag
494
+ updateMode(contextId, "active", projectRoot, opts);
495
+ logInfo(
496
+ "context_store",
497
+ `maybe_activate (${caller}): ${contextId} ${oldMode} -> active`,
498
+ );
499
+ return true;
500
+ }
501
+
502
+ return false;
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Lifecycle
507
+ // ---------------------------------------------------------------------------
508
+
509
+ /**
510
+ * Mark context completed and archive it.
511
+ * See SPEC.md §7.12
512
+ */
513
+ export function completeContext(contextId: string, projectRoot?: string): ContextState | null {
514
+ const state = getContext(contextId, projectRoot);
515
+ if (!state) return null;
516
+
517
+ if (state.status === "completed") {
518
+ logInfo("context_store", `Context '${contextId}' already completed`);
519
+ return state;
520
+ }
521
+
522
+ state.status = "completed";
523
+ state.last_active = nowIso();
524
+ saveState(contextId, state, projectRoot);
525
+ logInfo("context_store", `Completed context: ${contextId}`);
526
+
527
+ const archived = archiveContext(contextId, projectRoot);
528
+ return archived ?? state;
529
+ }
530
+
531
+ /**
532
+ * Move completed context folder to _archive/, update indices.
533
+ * See SPEC.md §7.13
534
+ */
535
+ export function archiveContext(contextId: string, projectRoot?: string): ContextState | null {
536
+ const state = getContext(contextId, projectRoot);
537
+ if (!state) {
538
+ logWarn("context_store", `Cannot archive: context '${contextId}' not found`);
539
+ return null;
540
+ }
541
+ if (state.status !== "completed") {
542
+ logWarn("context_store", `Cannot archive: context '${contextId}' not completed`);
543
+ return null;
544
+ }
545
+
546
+ const sourceDir = getContextDir(contextId, projectRoot);
547
+ const archiveDest = getArchiveContextDir(contextId, projectRoot);
548
+
549
+ if (fs.existsSync(archiveDest)) {
550
+ logWarn("context_store", `Cannot archive: archive folder already exists for '${contextId}'`);
551
+ return null;
552
+ }
553
+
554
+ const archiveParent = path.dirname(archiveDest);
555
+ fs.mkdirSync(archiveParent, { recursive: true });
556
+
557
+ try {
558
+ fs.renameSync(sourceDir, archiveDest);
559
+ } catch (e: any) {
560
+ logError("context_store", `Failed to move context to archive: ${e}`);
561
+ return null;
562
+ }
563
+
564
+ // Remove from main index
565
+ const index = loadIndex(projectRoot);
566
+ delete index.contexts[contextId];
567
+ const sessions = index.sessions ?? {};
568
+ for (const [sid, cid] of Object.entries(sessions)) {
569
+ if (cid === contextId) delete sessions[sid];
570
+ }
571
+ saveIndex(index, projectRoot);
572
+
573
+ // Add to archive index
574
+ updateArchiveIndex(state, projectRoot);
575
+
576
+ logInfo("context_store", `Archived context: ${contextId}`);
577
+ return state;
578
+ }
579
+
580
+ /**
581
+ * Reopen a completed/archived context.
582
+ * See SPEC.md §7.14
583
+ */
584
+ export function reopenContext(contextId: string, projectRoot?: string): ContextState | null {
585
+ let state = getContext(contextId, projectRoot);
586
+
587
+ if (!state) {
588
+ state = restoreFromArchive(contextId, projectRoot);
589
+ }
590
+ if (!state) return null;
591
+
592
+ if (state.status === "active") {
593
+ logInfo("context_store", `Context '${contextId}' already active`);
594
+ return state;
595
+ }
596
+
597
+ state.status = "active";
598
+ state.last_active = nowIso();
599
+ saveState(contextId, state, projectRoot);
600
+ logInfo("context_store", `Reopened context: ${contextId}`);
601
+ return state;
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Auto-creation from prompt
606
+ // ---------------------------------------------------------------------------
607
+
608
+ /**
609
+ * Auto-create a context from the user's prompt.
610
+ * See SPEC.md §7.15
611
+ */
612
+ export function createContextFromPrompt(
613
+ userPrompt: string,
614
+ projectRoot?: string,
615
+ ): ContextState {
616
+ let summary = userPrompt.trim().slice(0, 2000);
617
+ if (userPrompt.trim().length > 2000) {
618
+ summary += "...";
619
+ }
620
+
621
+ return createContext(
622
+ null,
623
+ summary,
624
+ "auto-created",
625
+ projectRoot,
626
+ ["auto-created"],
627
+ );
628
+ }
629
+
630
+
631
+ // ---------------------------------------------------------------------------
632
+ // Archive helpers
633
+ // ---------------------------------------------------------------------------
634
+
635
+ function updateArchiveIndex(state: ContextState, projectRoot?: string): boolean {
636
+ const archiveDir = getArchiveDir(projectRoot);
637
+ const archiveIndexPath = getArchiveIndexPath(projectRoot);
638
+ fs.mkdirSync(archiveDir, { recursive: true });
639
+
640
+ let archiveIndex: IndexFile = {
641
+ version: INDEX_VERSION,
642
+ updated_at: nowIso(),
643
+ sessions: {},
644
+ contexts: {},
645
+ };
646
+
647
+ if (fs.existsSync(archiveIndexPath)) {
648
+ try {
649
+ archiveIndex = JSON.parse(fs.readFileSync(archiveIndexPath, "utf-8"));
650
+ } catch (e: any) {
651
+ logWarn("context_store", `Failed to read archive index, recreating: ${e}`);
652
+ }
653
+ }
654
+
655
+ archiveIndex.contexts[state.id] = toIndexEntry(state);
656
+ archiveIndex.updated_at = nowIso();
657
+
658
+ const content = JSON.stringify(archiveIndex, null, 2);
659
+ const [success, error] = atomicWrite(archiveIndexPath, content);
660
+ if (!success) {
661
+ logWarn("context_store", `Failed to write archive index: ${error}`);
662
+ }
663
+ return success;
664
+ }
665
+
666
+ function restoreFromArchive(contextId: string, projectRoot?: string): ContextState | null {
667
+ const archiveDir = getArchiveContextDir(contextId, projectRoot);
668
+ const activeDir = getContextDir(contextId, projectRoot);
669
+
670
+ if (!fs.existsSync(archiveDir)) return null;
671
+ if (fs.existsSync(activeDir)) {
672
+ logWarn("context_store", `Cannot restore: active folder already exists for '${contextId}'`);
673
+ return null;
674
+ }
675
+
676
+ try {
677
+ fs.renameSync(archiveDir, activeDir);
678
+ } catch (e: any) {
679
+ logError("context_store", `Failed to restore context from archive: ${e}`);
680
+ return null;
681
+ }
682
+
683
+ // Remove from archive index
684
+ removeFromArchiveIndex(contextId, projectRoot);
685
+
686
+ const state = loadState(contextId, projectRoot);
687
+ logInfo("context_store", `Restored context from archive: ${contextId}`);
688
+ return state;
689
+ }
690
+
691
+ function removeFromArchiveIndex(contextId: string, projectRoot?: string): boolean {
692
+ const archiveIndexPath = getArchiveIndexPath(projectRoot);
693
+ if (!fs.existsSync(archiveIndexPath)) return true;
694
+
695
+ try {
696
+ const archiveIndex = JSON.parse(fs.readFileSync(archiveIndexPath, "utf-8")) as IndexFile;
697
+ if (archiveIndex.contexts[contextId]) {
698
+ delete archiveIndex.contexts[contextId];
699
+ archiveIndex.updated_at = nowIso();
700
+ const content = JSON.stringify(archiveIndex, null, 2);
701
+ const [success, error] = atomicWrite(archiveIndexPath, content);
702
+ if (!success) {
703
+ logWarn("context_store", `Failed to write archive index: ${error}`);
704
+ return false;
705
+ }
706
+ }
707
+ return true;
708
+ } catch (e: any) {
709
+ logWarn("context_store", `Failed to read archive index: ${e}`);
710
+ return false;
711
+ }
712
+ }