@haposoft/cafekit 0.16.0-rc.5 → 0.16.0-rc.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 (86) hide show
  1. package/README.md +21 -58
  2. package/bin/__tests__/change-firewall.semantic-firewall.test.js +4 -5
  3. package/bin/__tests__/codex-hooks.test.js +137 -0
  4. package/bin/__tests__/codex-native.test.js +130 -74
  5. package/bin/__tests__/develop-contract.test.js +155 -76
  6. package/bin/__tests__/installer-safety.test.js +19 -154
  7. package/bin/__tests__/package-inventory.test.js +10 -20
  8. package/bin/__tests__/release-preflight.semantic-firewall.test.js +13 -5
  9. package/bin/__tests__/validator-grounding.test.js +11 -11
  10. package/bin/install.js +2 -7
  11. package/bin/lib/context.js +0 -45
  12. package/bin/lib/copy-utils.js +1 -1
  13. package/bin/lib/i18n.js +0 -3
  14. package/bin/lib/manifest.js +1 -1
  15. package/bin/phases/claude-runtime.js +1 -3
  16. package/bin/phases/codex-runtime.js +4 -2
  17. package/bin/phases/copy-payload.js +3 -15
  18. package/bin/phases/post-install.js +5 -17
  19. package/bin/phases/root-config.js +2 -3
  20. package/bin/phases/select-platform.js +2 -5
  21. package/bin/phases/summary.js +0 -1
  22. package/package.json +3 -8
  23. package/src/claude/CLAUDE.md +19 -6
  24. package/src/claude/agents/project-manager.md +41 -9
  25. package/src/claude/agents/spec-maker.md +76 -155
  26. package/src/claude/gitignore +1 -1
  27. package/src/claude/hooks/__tests__/spec-gate.test.js +162 -0
  28. package/src/claude/hooks/lib/context.cjs +1 -1
  29. package/src/claude/hooks/lib/detect.cjs +1 -1
  30. package/src/claude/hooks/spec-gate.cjs +44 -9
  31. package/src/claude/hooks/spec-state.cjs +22 -11
  32. package/src/claude/hooks/task-scaffold-guard.cjs +1 -1
  33. package/src/claude/migration-manifest.json +17 -3
  34. package/src/claude/rules/manage-docs.md +43 -24
  35. package/src/claude/rules/state-sync.md +57 -62
  36. package/src/claude/rules/workflow.md +30 -13
  37. package/src/claude/scripts/change-firewall.cjs +2 -2
  38. package/src/claude/scripts/generate-skill-catalog.cjs +0 -1
  39. package/src/claude/scripts/spec-receipt.cjs +141 -1
  40. package/src/claude/scripts/spec-resolver.cjs +288 -0
  41. package/src/claude/skills/develop/SKILL.md +89 -125
  42. package/src/claude/skills/develop/references/parallel-waves.md +75 -162
  43. package/src/claude/skills/develop/references/quality-gate.md +61 -121
  44. package/src/claude/skills/develop/references/subagent-patterns.md +31 -38
  45. package/src/claude/skills/question/SKILL.md +1 -1
  46. package/src/claude/skills/specs/SKILL.md +142 -204
  47. package/src/claude/skills/specs/references/review.md +109 -243
  48. package/src/claude/skills/specs/references/templates.md +173 -0
  49. package/src/claude/skills/sync/SKILL.md +66 -90
  50. package/src/claude/skills/sync/references/sync-protocols.md +62 -83
  51. package/src/codex/AGENTS.md +20 -7
  52. package/src/codex/hooks/lib/spec-receipt.cjs +18 -0
  53. package/src/codex/hooks/lib/spec-utils.cjs +25 -0
  54. package/src/codex/hooks/spec-gate.cjs +46 -16
  55. package/src/codex/hooks/spec-state.cjs +22 -12
  56. package/src/codex/rules/state-sync.md +57 -61
  57. package/src/common/AGENTS.md +8 -1
  58. package/bin/__tests__/opencode-spec-gate.test.js +0 -799
  59. package/bin/lib/opencode-install.js +0 -899
  60. package/bin/phases/opencode-runtime.js +0 -38
  61. package/src/claude/skills/specs/references/archive-workflow.md +0 -74
  62. package/src/claude/skills/specs/references/ask-user-question-gates.md +0 -70
  63. package/src/claude/skills/specs/references/codebase-analysis.md +0 -102
  64. package/src/claude/skills/specs/references/cross-spec-dependency.md +0 -76
  65. package/src/claude/skills/specs/references/research-strategy.md +0 -107
  66. package/src/claude/skills/specs/references/scope-inquiry.md +0 -107
  67. package/src/claude/skills/specs/references/translation-mirror.md +0 -83
  68. package/src/claude/skills/specs/rules/design-discovery-full.md +0 -45
  69. package/src/claude/skills/specs/rules/design-discovery-light.md +0 -28
  70. package/src/claude/skills/specs/rules/design-principles.md +0 -138
  71. package/src/claude/skills/specs/rules/design-review.md +0 -55
  72. package/src/claude/skills/specs/rules/ears-format.md +0 -42
  73. package/src/claude/skills/specs/rules/phase-decision-matrix.md +0 -56
  74. package/src/claude/skills/specs/rules/task-scoring-rubric.md +0 -52
  75. package/src/claude/skills/specs/rules/tasks-generation.md +0 -189
  76. package/src/opencode/AGENTS.md +0 -41
  77. package/src/opencode/plugins/docs-sync.ts +0 -175
  78. package/src/opencode/plugins/inspect-block.ts +0 -164
  79. package/src/opencode/plugins/privacy-block.ts +0 -182
  80. package/src/opencode/plugins/rules.ts +0 -155
  81. package/src/opencode/plugins/session.ts +0 -179
  82. package/src/opencode/plugins/spec-gate.ts +0 -457
  83. package/src/opencode/plugins/spec-state.ts +0 -311
  84. package/src/opencode/plugins/state.ts +0 -253
  85. package/src/opencode/plugins/task-scaffold-guard.ts +0 -161
  86. package/src/opencode/plugins/usage.ts +0 -178
@@ -1,311 +0,0 @@
1
- /**
2
- * Copyright (c) 2026 Haposoft. MIT License.
3
- *
4
- * OpenCode Plugin — spec-state.ts
5
- *
6
- * The reminder adapter consumes the same executable spec resolver used by
7
- * Claude and Codex. OpenCode can inject a prompt part, but this hook is not a
8
- * cancellable completion boundary; hard completion enforcement belongs to
9
- * spec-gate's `tool.execute.before` hook where OpenCode supports it.
10
- *
11
- * The reminder cache is scoped by canonical project root, complete session
12
- * identity, canonical spec identity, and current state. Missing identity
13
- * disables the cache for that turn instead of creating a collision-prone key.
14
- */
15
-
16
- import type { Plugin } from "@opencode-ai/plugin";
17
- import {
18
- existsSync,
19
- readFileSync,
20
- writeFileSync,
21
- mkdirSync,
22
- appendFileSync,
23
- realpathSync,
24
- } from "node:fs";
25
- import { join, dirname, resolve } from "node:path";
26
- import { fileURLToPath } from "node:url";
27
- import { createRequire } from "node:module";
28
-
29
- const require = createRequire(import.meta.url);
30
- const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
31
- const CACHE_FILE = join(PLUGIN_DIR, ".logs", "tollgate-last.txt");
32
- const SHARED_RESOLVER_FILE = "spec-resolver.cjs";
33
-
34
- type SpecJson = {
35
- current_phase?: string;
36
- phase?: string;
37
- task_registry?: Record<string, { status?: string; dependencies?: string[] }>;
38
- };
39
-
40
- type ActiveSpec = {
41
- featureName: string;
42
- spec: SpecJson;
43
- specsDir: string;
44
- featureDir: string;
45
- specFile: string;
46
- };
47
-
48
- type ResolverIssue = {
49
- error: "multiple_active" | "invalid_specs" | "explicit_not_found" | "explicit_malformed" | "resolver_unavailable";
50
- candidates?: string[];
51
- reason: string;
52
- };
53
-
54
- type ResolveResult = ActiveSpec | ResolverIssue | null;
55
-
56
- function readRuntime(cwd: string): Record<string, unknown> {
57
- try {
58
- const file = join(cwd, ".opencode", "runtime.json");
59
- return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
60
- } catch {
61
- return {};
62
- }
63
- }
64
-
65
- function loadResolver(): { resolver: any | null; error: Error | null; path: string } {
66
- const installed = join(PLUGIN_DIR, "../scripts", SHARED_RESOLVER_FILE);
67
- const source = join(PLUGIN_DIR, "../../claude/scripts", SHARED_RESOLVER_FILE);
68
- const candidate = existsSync(installed) ? installed : source;
69
- try {
70
- const resolver = require(candidate);
71
- if (!resolver || typeof resolver.resolveActiveSpec !== "function") {
72
- throw new Error("shared spec resolver has no resolveActiveSpec function");
73
- }
74
- return { resolver, error: null, path: candidate };
75
- } catch (error) {
76
- return {
77
- resolver: null,
78
- error: error instanceof Error ? error : new Error(String(error)),
79
- path: candidate,
80
- };
81
- }
82
- }
83
-
84
- function logCrash(error: unknown): void {
85
- try {
86
- const logDir = join(PLUGIN_DIR, ".logs");
87
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
88
- appendFileSync(
89
- join(logDir, "hook-log.jsonl"),
90
- `${JSON.stringify({
91
- ts: new Date().toISOString(),
92
- hook: "spec-state",
93
- status: "crash",
94
- error: error instanceof Error ? error.message : String(error),
95
- })}\n`,
96
- );
97
- } catch {
98
- // Logging errors are intentionally ignored.
99
- }
100
- }
101
-
102
- function isIssue(resolved: ResolveResult): resolved is ResolverIssue {
103
- return Boolean(resolved && typeof resolved === "object" && "error" in resolved);
104
- }
105
-
106
- function resolveActiveSpec(
107
- cwd: string,
108
- runtime: Record<string, unknown>,
109
- targetSources: unknown[],
110
- ): ResolveResult {
111
- const loaded = loadResolver();
112
- if (!loaded.resolver) {
113
- return {
114
- error: "resolver_unavailable",
115
- reason: `${loaded.error?.message || "shared spec resolver is unavailable"} (${loaded.path})`,
116
- };
117
- }
118
- const target = typeof loaded.resolver.extractExplicitTarget === "function"
119
- ? loaded.resolver.extractExplicitTarget(...targetSources)
120
- : null;
121
- try {
122
- return loaded.resolver.resolveActiveSpec({
123
- projectRoot: cwd,
124
- runtime,
125
- ...(target || {}),
126
- }) as ResolveResult;
127
- } catch (error) {
128
- return {
129
- error: "resolver_unavailable",
130
- reason: `${error instanceof Error ? error.message : String(error)} (${loaded.path})`,
131
- };
132
- }
133
- }
134
-
135
- function canonicalPath(value: string): string | null {
136
- try {
137
- return realpathSync(value);
138
- } catch {
139
- return null;
140
- }
141
- }
142
-
143
- function sessionIdentity(value: unknown): string | null {
144
- if (!value || typeof value !== "object") return null;
145
- const record = value as Record<string, any>;
146
- const candidates = [
147
- record.sessionID,
148
- record.sessionId,
149
- record.session_id,
150
- record.message?.sessionID,
151
- record.message?.sessionId,
152
- ];
153
- return candidates.find((candidate) => (
154
- typeof candidate === "string" && candidate.length > 0 && candidate.trim() === candidate
155
- )) || null;
156
- }
157
-
158
- function buildCacheKey(
159
- cwd: string,
160
- featureName: string,
161
- activeSpec: ActiveSpec,
162
- hostInput: unknown,
163
- output: unknown,
164
- ): string | null {
165
- const projectRoot = canonicalPath(cwd);
166
- const specIdentity = canonicalPath(activeSpec.specFile);
167
- const sessionID = sessionIdentity(output) || sessionIdentity(hostInput);
168
- if (!projectRoot || !specIdentity || !sessionID) return null;
169
-
170
- const taskEntries = Object.entries(activeSpec.spec.task_registry || {});
171
- const done = taskEntries.filter(([, task]) => task?.status === "done").length;
172
- const phase = activeSpec.spec.current_phase || activeSpec.spec.phase || "unknown";
173
- return JSON.stringify({
174
- projectRoot,
175
- sessionID,
176
- featureName,
177
- specIdentity,
178
- phase,
179
- done,
180
- total: taskEntries.length,
181
- });
182
- }
183
-
184
- function buildTollgateText(
185
- featureName: string,
186
- activeSpec: SpecJson,
187
- cacheKey: string | null,
188
- ): { stateKey: string | null; fullBlock: string; reminder: string } {
189
- const phase = activeSpec.current_phase || activeSpec.phase || "unknown";
190
- const taskRegistry = activeSpec.task_registry || {};
191
- const taskEntries = Object.entries(taskRegistry);
192
- const taskCounts = taskEntries.reduce<Record<string, number>>((acc, [, task]) => {
193
- const status = task?.status || "pending";
194
- acc[status] = (acc[status] || 0) + 1;
195
- return acc;
196
- }, {});
197
- const taskStatusByPath = new Map(taskEntries.map(([taskPath, task]) => [taskPath, task?.status || "pending"]));
198
- const nextUnblocked = taskEntries.find(([, task]) => {
199
- const status = task?.status || "pending";
200
- const deps = Array.isArray(task?.dependencies) ? task.dependencies : [];
201
- return status === "pending" && deps.every((dependency) => taskStatusByPath.get(dependency) === "done");
202
- });
203
-
204
- const done = taskCounts.done || 0;
205
- const total = taskEntries.length;
206
- const reminder =
207
- `\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${done}/${total} tasks done). ` +
208
- "Tollgate active — sync `spec.json` when state changes.\n";
209
-
210
- const lines: string[] = [
211
- "",
212
- "### 🔴 URGENT SYSTEM TOLLGATE (STATE SYNC) 🔴",
213
- `- **Active Feature:** \`${featureName}\``,
214
- `- **Current Phase:** \`${phase}\``,
215
- ];
216
- if (total > 0) {
217
- lines.push(
218
- `- **Task Registry:** \`${total} total | ${done} done | ${taskCounts.in_progress || 0} in_progress | ${taskCounts.blocked || 0} blocked | ${taskCounts.pending || 0} pending\``,
219
- );
220
- if (nextUnblocked) lines.push(`- **Next Unblocked Task:** \`${nextUnblocked[0]}\``);
221
- }
222
- lines.push("");
223
- lines.push("> BẮT BUỘC (MANDATORY): Nếu bạn vừa hoàn thành một bước, hãy cập nhật state vật lý sau khi có bằng chứng verify thật (build/test/runtime/artifact).\n");
224
- lines.push("> 1. Sửa file `spec.json` (status, phase/current_phase, timestamps, `task_files`, `task_registry`, validation state nếu có thay đổi).\n");
225
- lines.push("> 2. Chỉ khi verify xong mới sửa file `tasks/task-*.md` (status + tick '[x]' các sub-task và completion criteria liên quan).\n");
226
- lines.push(`> 3. Trước khi set \`ready_for_implementation = true\`, PHẢI chạy \`node .opencode/scripts/validate-spec-output.cjs specs/${featureName}\` và sửa mọi lỗi.\n`);
227
- lines.push("> 4. Sau verified source work, đánh giá docs impact thực tế: cập nhật đúng tài liệu bị ảnh hưởng hoặc ghi `Docs impact: none`; không tạo docs update chỉ vì hoàn tất task.\n");
228
- lines.push("> CẤM biến marker hoặc model claim thành bằng chứng verify.\n");
229
- lines.push("");
230
-
231
- return { stateKey: cacheKey, fullBlock: lines.join("\n"), reminder };
232
- }
233
-
234
- function injectText(
235
- output: { message?: { id?: string; sessionID?: string }; parts?: unknown[] },
236
- text: string,
237
- ): void {
238
- const message = output.message ?? {};
239
- if (!Array.isArray(output.parts)) output.parts = [];
240
- output.parts.push({
241
- id: `prt_ck${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`,
242
- sessionID: message.sessionID ?? "",
243
- messageID: message.id ?? "",
244
- type: "text",
245
- text,
246
- synthetic: true,
247
- });
248
- }
249
-
250
- function issueText(issue: ResolverIssue): string {
251
- const candidates = issue.candidates?.join(", ");
252
- if (issue.error === "multiple_active") {
253
- return `\n> ⚠️ Multiple active specs detected: ${candidates}. Provide exact \`featureName\`/\`feature\` or \`specPath\`/\`featurePath\` in the host input; tollgate paused.\n`;
254
- }
255
- if (issue.error === "invalid_specs") {
256
- return `\n> ⚠️ Invalid spec JSON detected: ${candidates || "unknown"}. ${issue.reason}. Fix the malformed spec before continuing.\n`;
257
- }
258
- if (issue.error === "resolver_unavailable") {
259
- return `\n> ⚠️ Spec tollgate unavailable: ${issue.reason}. Repair the shared spec resolver before continuing.\n`;
260
- }
261
- return `\n> ⚠️ Explicit spec target unavailable: ${issue.reason}. Provide an exact target inside the configured specs root.\n`;
262
- }
263
-
264
- export const SpecState: Plugin = async ({ directory }) => ({
265
- "chat.message": async (input, output) => {
266
- try {
267
- const runtime = readRuntime(directory);
268
- const spec = runtime.spec as Record<string, unknown> | undefined;
269
- if (spec?.tollgate === false) return;
270
-
271
- const inputRecord = input as unknown as Record<string, unknown>;
272
- const outputRecord = output as unknown as { message?: { id?: string; sessionID?: string }; parts?: unknown[] };
273
- const resolved = resolveActiveSpec(directory, runtime, [inputRecord, outputRecord, outputRecord.message]);
274
- if (!resolved) return;
275
- if (isIssue(resolved)) {
276
- injectText(outputRecord, issueText(resolved));
277
- return;
278
- }
279
-
280
- const cacheKey = buildCacheKey(directory, resolved.featureName, resolved, inputRecord, outputRecord);
281
- const { stateKey, fullBlock, reminder } = buildTollgateText(resolved.featureName, resolved.spec, cacheKey);
282
- let lastKey = "";
283
- if (stateKey) {
284
- try {
285
- lastKey = readFileSync(CACHE_FILE, "utf8");
286
- } catch {
287
- lastKey = "";
288
- }
289
- }
290
-
291
- if (stateKey && lastKey === stateKey) {
292
- injectText(outputRecord, reminder);
293
- return;
294
- }
295
-
296
- if (stateKey) {
297
- try {
298
- mkdirSync(dirname(CACHE_FILE), { recursive: true });
299
- writeFileSync(CACHE_FILE, stateKey);
300
- } catch {
301
- // Cache persistence is optional; the full block remains truthful.
302
- }
303
- }
304
- injectText(outputRecord, fullBlock);
305
- } catch (error) {
306
- logCrash(error);
307
- }
308
- },
309
- });
310
-
311
- export default SpecState;
@@ -1,253 +0,0 @@
1
- /**
2
- * Copyright (c) 2026 Haposoft. MIT License.
3
- *
4
- * OpenCode Plugin — state.ts
5
- *
6
- * Port of .claude/hooks/state.cjs (partial).
7
- *
8
- * Behavior:
9
- * - On session.created: read .opencode/session-state/latest.md (if fresh)
10
- * and surface it via stderr + banner file so the assistant sees prior
11
- * execution context.
12
- * - On tool.execute.after for state-shaping tools (todowrite, task,
13
- * taskcreate, taskupdate): refresh snapshot.
14
- * - On session.idle (≈ Claude Stop): persist snapshot + archive.
15
- *
16
- * Dropped:
17
- * - SubagentStop branch (no equivalent in OpenCode).
18
- * - Transcript-based todo extraction (OpenCode plugin context lacks
19
- * transcript_path). Snapshot now captures modifiedFiles only.
20
- *
21
- * Storage: .opencode/session-state/latest.md + archive/.
22
- */
23
-
24
- import type { Plugin } from "@opencode-ai/plugin";
25
- import {
26
- existsSync,
27
- readFileSync,
28
- writeFileSync,
29
- mkdirSync,
30
- copyFileSync,
31
- readdirSync,
32
- unlinkSync,
33
- renameSync,
34
- appendFileSync,
35
- } from "node:fs";
36
- import { join, dirname } from "node:path";
37
- import { fileURLToPath } from "node:url";
38
- import { execSync } from "node:child_process";
39
-
40
- const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
41
- const EXPIRY_DAYS = 7;
42
- const MAX_ARCHIVES = 5;
43
- const STATE_REFRESH_TOOLS = new Set(["todowrite", "task", "taskcreate", "taskupdate"]);
44
- const STATE_SECTION_START = "<!-- CAFEKIT STATE START -->";
45
- const STATE_SECTION_END = "<!-- CAFEKIT STATE END -->";
46
-
47
- interface SnapshotData {
48
- timestamp: string;
49
- branch: string;
50
- modifiedFiles: string[];
51
- }
52
-
53
- function stateDir(cwd: string): string | null {
54
- try {
55
- const dir = join(cwd, ".opencode", "session-state");
56
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
57
- return dir;
58
- } catch {
59
- return null;
60
- }
61
- }
62
-
63
- function loadLatest(dir: string): string | null {
64
- try {
65
- const file = join(dir, "latest.md");
66
- if (!existsSync(file)) return null;
67
- const text = readFileSync(file, "utf8");
68
- const match = text.match(/<!-- Generated: (.+?) -->/);
69
- if (match) {
70
- const generatedAt = new Date(match[1]).getTime();
71
- if (Number.isNaN(generatedAt)) return null;
72
- if (Date.now() - generatedAt > EXPIRY_DAYS * 24 * 60 * 60 * 1000) return null;
73
- }
74
- return text;
75
- } catch {
76
- return null;
77
- }
78
- }
79
-
80
- function writeAtomic(filePath: string, content: string): void {
81
- const tempFile = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
82
- writeFileSync(tempFile, content);
83
- renameSync(tempFile, filePath);
84
- }
85
-
86
- function archive(dir: string): void {
87
- try {
88
- const latestFile = join(dir, "latest.md");
89
- if (!existsSync(latestFile)) return;
90
-
91
- const archiveDir = join(dir, "archive");
92
- if (!existsSync(archiveDir)) mkdirSync(archiveDir);
93
-
94
- const now = new Date();
95
- const pad = (value: number) => String(value).padStart(2, "0");
96
- const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
97
-
98
- copyFileSync(latestFile, join(archiveDir, `${stamp}.md`));
99
-
100
- const files = readdirSync(archiveDir)
101
- .filter((file) => file.endsWith(".md"))
102
- .sort();
103
- while (files.length > MAX_ARCHIVES) {
104
- const oldest = files.shift();
105
- if (oldest) {
106
- try {
107
- unlinkSync(join(archiveDir, oldest));
108
- } catch {
109
- // fail-open
110
- }
111
- }
112
- }
113
- } catch {
114
- // fail-open
115
- }
116
- }
117
-
118
- function extractSnapshot(cwd: string): SnapshotData {
119
- const data: SnapshotData = {
120
- timestamp: new Date().toISOString(),
121
- branch: "",
122
- modifiedFiles: [],
123
- };
124
-
125
- try {
126
- data.branch = execSync("git branch --show-current", {
127
- cwd,
128
- encoding: "utf8",
129
- timeout: 3000,
130
- stdio: ["pipe", "pipe", "pipe"],
131
- }).trim();
132
- } catch {
133
- // fail-open
134
- }
135
-
136
- try {
137
- const diff = execSync("git diff --name-only HEAD", {
138
- cwd,
139
- encoding: "utf8",
140
- timeout: 3000,
141
- stdio: ["pipe", "pipe", "pipe"],
142
- }).trim();
143
- if (diff) data.modifiedFiles = diff.split("\n").slice(0, 20);
144
- } catch {
145
- // fail-open
146
- }
147
-
148
- return data;
149
- }
150
-
151
- function buildStateContent(data: SnapshotData): string {
152
- return [
153
- "# Session State",
154
- `<!-- Generated: ${data.timestamp} -->`,
155
- `<!-- Branch: ${data.branch || "unknown"} -->`,
156
- "",
157
- "## Key Files Modified",
158
- ...(data.modifiedFiles.length
159
- ? data.modifiedFiles.map((file) => `- ${file}`)
160
- : ["- (No file changes detected)"]),
161
- "",
162
- ].join("\n");
163
- }
164
-
165
- function writeBannerSection(cwd: string, content: string | null): void {
166
- const target = join(cwd, ".opencode", "session-banner.md");
167
- const dir = dirname(target);
168
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
169
-
170
- const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
171
- const pattern = new RegExp(`${STATE_SECTION_START}[\\s\\S]*?${STATE_SECTION_END}\\n?`);
172
- const cleaned = existing.replace(pattern, "").trimEnd();
173
-
174
- if (!content) {
175
- if (cleaned !== existing.trimEnd()) {
176
- writeFileSync(target, cleaned ? `${cleaned}\n` : "");
177
- }
178
- return;
179
- }
180
-
181
- const block = [STATE_SECTION_START, content, STATE_SECTION_END].join("\n");
182
- const next = cleaned ? `${cleaned}\n\n${block}\n` : `${block}\n`;
183
- writeFileSync(target, next);
184
- }
185
-
186
- function persistSnapshot(dir: string, data: SnapshotData, options: { archive?: boolean } = {}): void {
187
- const file = join(dir, "latest.md");
188
- writeAtomic(file, buildStateContent(data));
189
- if (options.archive) archive(dir);
190
- }
191
-
192
- function logCrash(error: unknown): void {
193
- try {
194
- const logDir = join(PLUGIN_DIR, ".logs");
195
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
196
- appendFileSync(
197
- join(logDir, "hook-log.jsonl"),
198
- JSON.stringify({
199
- ts: new Date().toISOString(),
200
- hook: "state",
201
- status: "crash",
202
- error: error instanceof Error ? error.message : String(error),
203
- }) + "\n",
204
- );
205
- } catch {
206
- // fail-open
207
- }
208
- }
209
-
210
- export const State: Plugin = async ({ directory }) => ({
211
- event: async ({ event }) => {
212
- try {
213
- const dir = stateDir(directory);
214
- if (!dir) return;
215
-
216
- if (event.type === "session.created") {
217
- const previous = loadLatest(dir);
218
- if (previous) {
219
- const banner = [
220
- "=== Prior Execution Context ===",
221
- previous.trim(),
222
- "=== End of Prior Context ===",
223
- ].join("\n");
224
- writeBannerSection(directory, banner);
225
- console.error(banner);
226
- } else {
227
- writeBannerSection(directory, null);
228
- }
229
- return;
230
- }
231
-
232
- if (event.type === "session.idle") {
233
- const snapshot = extractSnapshot(directory);
234
- persistSnapshot(dir, snapshot, { archive: true });
235
- return;
236
- }
237
- } catch (error) {
238
- logCrash(error);
239
- }
240
- },
241
-
242
- "tool.execute.after": async (input) => {
243
- try {
244
- if (!STATE_REFRESH_TOOLS.has(input.tool)) return;
245
- const dir = stateDir(directory);
246
- if (!dir) return;
247
- const snapshot = extractSnapshot(directory);
248
- persistSnapshot(dir, snapshot);
249
- } catch (error) {
250
- logCrash(error);
251
- }
252
- },
253
- });