@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.4

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 (98) hide show
  1. package/bin/__tests__/codex-hooks.test.js +794 -21
  2. package/bin/__tests__/codex-native.test.js +228 -7
  3. package/bin/__tests__/develop-contract.test.js +1838 -112
  4. package/bin/__tests__/installer-safety.test.js +82 -29
  5. package/bin/__tests__/opencode-spec-gate.test.js +799 -0
  6. package/bin/__tests__/package-inventory.test.js +841 -10
  7. package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
  8. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
  9. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
  10. package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
  11. package/bin/__tests__/validator-grounding.test.js +2469 -71
  12. package/bin/lib/managed-writer.js +12 -3
  13. package/bin/phases/codex-runtime.js +17 -1
  14. package/package.json +1 -1
  15. package/src/claude/CLAUDE.md +6 -0
  16. package/src/claude/agents/code-auditor.md +20 -0
  17. package/src/claude/agents/spec-maker.md +153 -202
  18. package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
  19. package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
  20. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
  21. package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
  22. package/src/claude/hooks/completion-authority-check.cjs +56 -0
  23. package/src/claude/hooks/completion-authority-state.cjs +527 -0
  24. package/src/claude/hooks/completion-authority.cjs +154 -0
  25. package/src/claude/hooks/docs-sync.cjs +5 -2
  26. package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
  27. package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
  28. package/src/claude/hooks/privacy-block.cjs +77 -30
  29. package/src/claude/hooks/semantic-review-authority.cjs +152 -0
  30. package/src/claude/hooks/session.cjs +33 -19
  31. package/src/claude/hooks/spec-gate.cjs +181 -139
  32. package/src/claude/hooks/spec-state.cjs +112 -47
  33. package/src/claude/migration-manifest.json +15 -2
  34. package/src/claude/rules/state-sync.md +52 -21
  35. package/src/claude/rules/workflow.md +17 -9
  36. package/src/claude/scripts/provenance.cjs +315 -0
  37. package/src/claude/scripts/spec-final-state.cjs +244 -0
  38. package/src/claude/scripts/spec-ground.cjs +535 -173
  39. package/src/claude/scripts/spec-readiness.cjs +118 -0
  40. package/src/claude/scripts/spec-receipt.cjs +162 -0
  41. package/src/claude/scripts/spec-resolver.cjs +501 -0
  42. package/src/claude/scripts/spec-scaffold.cjs +1945 -55
  43. package/src/claude/scripts/spec-semantic-model.cjs +396 -0
  44. package/src/claude/scripts/validate-spec-output.cjs +3204 -262
  45. package/src/claude/scripts/workflow-policy.cjs +1888 -203
  46. package/src/claude/settings/settings.json +12 -0
  47. package/src/claude/skills/code-review/SKILL.md +94 -66
  48. package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
  49. package/src/claude/skills/code-review/references/verification-gate.md +13 -0
  50. package/src/claude/skills/develop/SKILL.md +138 -283
  51. package/src/claude/skills/develop/references/parallel-waves.md +9 -6
  52. package/src/claude/skills/develop/references/quality-gate.md +123 -148
  53. package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
  54. package/src/claude/skills/specs/SKILL.md +192 -397
  55. package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
  56. package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
  57. package/src/claude/skills/specs/references/research-strategy.md +4 -1
  58. package/src/claude/skills/specs/references/review.md +200 -260
  59. package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
  60. package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
  61. package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
  62. package/src/claude/skills/specs/rules/design-principles.md +136 -180
  63. package/src/claude/skills/specs/rules/design-review.md +42 -97
  64. package/src/claude/skills/specs/rules/ears-format.md +42 -50
  65. package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
  66. package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
  67. package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
  68. package/src/claude/skills/specs/templates/design.md +49 -263
  69. package/src/claude/skills/specs/templates/requirements.md +26 -36
  70. package/src/claude/skills/specs/templates/research.md +9 -98
  71. package/src/claude/skills/specs/templates/spec-state.json +26 -67
  72. package/src/claude/skills/specs/templates/task.md +30 -82
  73. package/src/claude/skills/sync/SKILL.md +45 -15
  74. package/src/claude/skills/sync/references/sync-protocols.md +34 -16
  75. package/src/claude/skills/test/SKILL.md +151 -167
  76. package/src/codex/AGENTS.md +6 -0
  77. package/src/codex/hooks/completion-authority-check.cjs +63 -0
  78. package/src/codex/hooks/completion-authority-state.cjs +527 -0
  79. package/src/codex/hooks/completion-authority.cjs +106 -0
  80. package/src/codex/hooks/lib/hook-context.cjs +58 -4
  81. package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
  82. package/src/codex/hooks/lib/privacy-state.cjs +38 -16
  83. package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
  84. package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
  85. package/src/codex/hooks/lib/spec-utils.cjs +104 -27
  86. package/src/codex/hooks/privacy-approval.cjs +1 -1
  87. package/src/codex/hooks/privacy-block.cjs +67 -11
  88. package/src/codex/hooks/semantic-review-authority.cjs +130 -0
  89. package/src/codex/hooks/session.cjs +2 -0
  90. package/src/codex/hooks/spec-gate.cjs +151 -24
  91. package/src/codex/hooks/spec-state.cjs +65 -12
  92. package/src/codex/hooks/state.cjs +2 -0
  93. package/src/codex/hooks.json +16 -1
  94. package/src/codex/rules/state-sync.md +47 -27
  95. package/src/common/AGENTS.md +1 -1
  96. package/src/opencode/AGENTS.md +4 -2
  97. package/src/opencode/plugins/spec-gate.ts +296 -122
  98. package/src/opencode/plugins/spec-state.ts +183 -116
@@ -3,18 +3,14 @@
3
3
  *
4
4
  * OpenCode Plugin — spec-state.ts
5
5
  *
6
- * Port of .claude/hooks/spec-state.cjs to OpenCode plugin protocol.
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.
7
10
  *
8
- * Behavior:
9
- * - On chat.message (before the model processes the user turn), scan
10
- * project specs dirs for the first in_progress / in-progress spec.json.
11
- * - Inject a tollgate reminder into the turn by pushing a text part onto
12
- * output.parts (OpenCode equivalent of Claude UserPromptSubmit stdout inject).
13
- * - State-change gate: fingerprint phase|done/total in plugins/.logs/tollgate-last.txt;
14
- * unchanged -> one-line reminder; changed -> full URGENT block + refresh fingerprint.
15
- *
16
- * Disable via `.opencode/runtime.json` { "spec": { "tollgate": false } }.
17
- * Fail-open: never throw — a tollgate crash must not break the turn.
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.
18
14
  */
19
15
 
20
16
  import type { Plugin } from "@opencode-ai/plugin";
@@ -24,27 +20,39 @@ import {
24
20
  writeFileSync,
25
21
  mkdirSync,
26
22
  appendFileSync,
27
- readdirSync,
23
+ realpathSync,
28
24
  } from "node:fs";
29
- import { join, dirname } from "node:path";
25
+ import { join, dirname, resolve } from "node:path";
30
26
  import { fileURLToPath } from "node:url";
27
+ import { createRequire } from "node:module";
31
28
 
29
+ const require = createRequire(import.meta.url);
32
30
  const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
33
31
  const CACHE_FILE = join(PLUGIN_DIR, ".logs", "tollgate-last.txt");
32
+ const SHARED_RESOLVER_FILE = "spec-resolver.cjs";
34
33
 
35
34
  type SpecJson = {
36
- status?: string;
37
35
  current_phase?: string;
38
36
  phase?: string;
39
- task_registry?: Record<
40
- string,
41
- {
42
- status?: string;
43
- dependencies?: string[];
44
- }
45
- >;
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;
46
52
  };
47
53
 
54
+ type ResolveResult = ActiveSpec | ResolverIssue | null;
55
+
48
56
  function readRuntime(cwd: string): Record<string, unknown> {
49
57
  try {
50
58
  const file = join(cwd, ".opencode", "runtime.json");
@@ -54,56 +62,130 @@ function readRuntime(cwd: string): Record<string, unknown> {
54
62
  }
55
63
  }
56
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
+
57
84
  function logCrash(error: unknown): void {
58
85
  try {
59
86
  const logDir = join(PLUGIN_DIR, ".logs");
60
87
  if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
61
88
  appendFileSync(
62
89
  join(logDir, "hook-log.jsonl"),
63
- JSON.stringify({
90
+ `${JSON.stringify({
64
91
  ts: new Date().toISOString(),
65
92
  hook: "spec-state",
66
93
  status: "crash",
67
94
  error: error instanceof Error ? error.message : String(error),
68
- }) + "\n",
95
+ })}\n`,
69
96
  );
70
97
  } catch {
71
- // fail-open
98
+ // Logging errors are intentionally ignored.
72
99
  }
73
100
  }
74
101
 
75
- function findActiveSpec(
76
- specsPath: string,
77
- ): { featureName: string; activeSpec: SpecJson } | null {
78
- if (!existsSync(specsPath)) return null;
102
+ function isIssue(resolved: ResolveResult): resolved is ResolverIssue {
103
+ return Boolean(resolved && typeof resolved === "object" && "error" in resolved);
104
+ }
79
105
 
80
- let entries;
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;
81
121
  try {
82
- entries = readdirSync(specsPath, { withFileTypes: true });
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);
83
138
  } catch {
84
139
  return null;
85
140
  }
141
+ }
86
142
 
87
- for (const entry of entries) {
88
- if (!entry.isDirectory()) continue;
89
- const specFile = join(specsPath, entry.name, "spec.json");
90
- if (!existsSync(specFile)) continue;
91
- try {
92
- const specData = JSON.parse(readFileSync(specFile, "utf8")) as SpecJson;
93
- if (specData.status === "in_progress" || specData.status === "in-progress") {
94
- return { featureName: entry.name, activeSpec: specData };
95
- }
96
- } catch {
97
- // skip bad JSON
98
- }
99
- }
100
- return null;
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
+ });
101
182
  }
102
183
 
103
184
  function buildTollgateText(
104
185
  featureName: string,
105
186
  activeSpec: SpecJson,
106
- ): { stateKey: string; fullBlock: string; reminder: string } {
187
+ cacheKey: string | null,
188
+ ): { stateKey: string | null; fullBlock: string; reminder: string } {
107
189
  const phase = activeSpec.current_phase || activeSpec.phase || "unknown";
108
190
  const taskRegistry = activeSpec.task_registry || {};
109
191
  const taskEntries = Object.entries(taskRegistry);
@@ -112,19 +194,15 @@ function buildTollgateText(
112
194
  acc[status] = (acc[status] || 0) + 1;
113
195
  return acc;
114
196
  }, {});
115
- const taskStatusByPath = new Map(
116
- taskEntries.map(([taskPath, task]) => [taskPath, task?.status || "pending"]),
117
- );
197
+ const taskStatusByPath = new Map(taskEntries.map(([taskPath, task]) => [taskPath, task?.status || "pending"]));
118
198
  const nextUnblocked = taskEntries.find(([, task]) => {
119
199
  const status = task?.status || "pending";
120
200
  const deps = Array.isArray(task?.dependencies) ? task.dependencies : [];
121
- return status === "pending" && deps.every((dep) => taskStatusByPath.get(dep) === "done");
201
+ return status === "pending" && deps.every((dependency) => taskStatusByPath.get(dependency) === "done");
122
202
  });
123
203
 
124
204
  const done = taskCounts.done || 0;
125
205
  const total = taskEntries.length;
126
- const stateKey = `${phase}|${done}/${total}`;
127
-
128
206
  const reminder =
129
207
  `\n> 🔵 Spec \`${featureName}\` @ \`${phase}\` (${done}/${total} tasks done). ` +
130
208
  "Tollgate active — sync `spec.json` when state changes.\n";
@@ -139,51 +217,27 @@ function buildTollgateText(
139
217
  lines.push(
140
218
  `- **Task Registry:** \`${total} total | ${done} done | ${taskCounts.in_progress || 0} in_progress | ${taskCounts.blocked || 0} blocked | ${taskCounts.pending || 0} pending\``,
141
219
  );
142
- if (nextUnblocked) {
143
- lines.push(`- **Next Unblocked Task:** \`${nextUnblocked[0]}\``);
144
- }
220
+ if (nextUnblocked) lines.push(`- **Next Unblocked Task:** \`${nextUnblocked[0]}\``);
145
221
  }
146
222
  lines.push("");
147
- lines.push(
148
- "> BẮT BUỘC (MANDATORY): Nếu bạn vừa hoàn thành một bước, bạn KHÔNG ĐƯỢC báo cáo \"Đã xong\" ngay.",
149
- );
150
- lines.push(
151
- "> Bạn PHẢI sử dụng công cụ Edit để cập nhật trạng thái vật lý sau khi đã có bằng chứng verify thật (build/test/runtime/artifact), không phải chỉ vì code đã viết xong.",
152
- );
153
- lines.push(
154
- "> 1. Sửa file `spec.json` (status, phase/current_phase, timestamps, `task_files`, `task_registry`, validation state nếu có thay đổi).",
155
- );
156
- lines.push(
157
- "> 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).",
158
- );
159
- lines.push(
160
- `> 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.`,
161
- );
162
- lines.push(
163
- "> 4. NẾU VỪA HOÀN THÀNH 1 TASK CÓ SỬA SOURCE CODE, BẮT BUỘC cập nhật ngay tài liệu trong `docs/` (`system-architecture.md` hoặc Changelog) cho đồng bộ.",
164
- );
165
- lines.push(
166
- "> CẤM VI PHẠM LUẬT TOLLGATE NÀY NHẰM ĐẢM BẢO TÍNH ĐỒNG BỘ CỦA HỆ THỐNG.",
167
- );
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");
168
229
  lines.push("");
169
230
 
170
- return { stateKey, fullBlock: lines.join("\n"), reminder };
231
+ return { stateKey: cacheKey, fullBlock: lines.join("\n"), reminder };
171
232
  }
172
233
 
173
- type MessageRef = { id?: string; sessionID?: string };
174
-
175
234
  function injectText(
176
- output: { message?: MessageRef; parts?: unknown[] },
235
+ output: { message?: { id?: string; sessionID?: string }; parts?: unknown[] },
177
236
  text: string,
178
237
  ): void {
179
- // OpenCode persists user parts and validates their schema: a part MUST carry
180
- // id + sessionID + messageID or the whole turn fails with
181
- // "invalid user part before save" (verified on opencode 1.17.15).
182
238
  const message = output.message ?? {};
183
- if (!Array.isArray(output.parts)) {
184
- (output as { parts: unknown[] }).parts = [];
185
- }
186
- output.parts!.push({
239
+ if (!Array.isArray(output.parts)) output.parts = [];
240
+ output.parts.push({
187
241
  id: `prt_ck${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`,
188
242
  sessionID: message.sessionID ?? "",
189
243
  messageID: message.id ?? "",
@@ -193,52 +247,65 @@ function injectText(
193
247
  });
194
248
  }
195
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
+
196
264
  export const SpecState: Plugin = async ({ directory }) => ({
197
- "chat.message": async (_input, output) => {
265
+ "chat.message": async (input, output) => {
198
266
  try {
199
267
  const runtime = readRuntime(directory);
200
268
  const spec = runtime.spec as Record<string, unknown> | undefined;
201
269
  if (spec?.tollgate === false) return;
202
270
 
203
- const paths = runtime.paths as Record<string, unknown> | undefined;
204
- const specsRel =
205
- typeof paths?.specs === "string" && paths.specs.trim()
206
- ? paths.specs.trim()
207
- : "specs";
208
- const specsPath = join(directory, specsRel);
209
-
210
- const found = findActiveSpec(specsPath);
211
- if (!found) return;
212
-
213
- const { featureName, activeSpec } = found;
214
- const { stateKey, fullBlock, reminder } = buildTollgateText(
215
- featureName,
216
- activeSpec,
217
- );
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
+ }
218
279
 
280
+ const cacheKey = buildCacheKey(directory, resolved.featureName, resolved, inputRecord, outputRecord);
281
+ const { stateKey, fullBlock, reminder } = buildTollgateText(resolved.featureName, resolved.spec, cacheKey);
219
282
  let lastKey = "";
220
- try {
221
- lastKey = readFileSync(CACHE_FILE, "utf8").trim();
222
- } catch {
223
- // first run
283
+ if (stateKey) {
284
+ try {
285
+ lastKey = readFileSync(CACHE_FILE, "utf8");
286
+ } catch {
287
+ lastKey = "";
288
+ }
224
289
  }
225
290
 
226
- if (lastKey === stateKey) {
227
- injectText(output as { message?: MessageRef; parts?: unknown[] }, reminder);
291
+ if (stateKey && lastKey === stateKey) {
292
+ injectText(outputRecord, reminder);
228
293
  return;
229
294
  }
230
295
 
231
- try {
232
- mkdirSync(dirname(CACHE_FILE), { recursive: true });
233
- writeFileSync(CACHE_FILE, stateKey);
234
- } catch {
235
- // fail-open: still inject full block
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
+ }
236
303
  }
237
-
238
- injectText(output as { message?: MessageRef; parts?: unknown[] }, fullBlock);
304
+ injectText(outputRecord, fullBlock);
239
305
  } catch (error) {
240
306
  logCrash(error);
241
- // never throw — tollgate must not break the turn
242
307
  }
243
308
  },
244
309
  });
310
+
311
+ export default SpecState;