@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.
- package/bin/__tests__/codex-hooks.test.js +794 -21
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +1838 -112
- package/bin/__tests__/installer-safety.test.js +82 -29
- package/bin/__tests__/opencode-spec-gate.test.js +799 -0
- package/bin/__tests__/package-inventory.test.js +841 -10
- package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2469 -71
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +153 -202
- package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
- package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
- package/src/claude/hooks/completion-authority-check.cjs +56 -0
- package/src/claude/hooks/completion-authority-state.cjs +527 -0
- package/src/claude/hooks/completion-authority.cjs +154 -0
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +77 -30
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +181 -139
- package/src/claude/hooks/spec-state.cjs +112 -47
- package/src/claude/migration-manifest.json +15 -2
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-final-state.cjs +244 -0
- package/src/claude/scripts/spec-ground.cjs +535 -173
- package/src/claude/scripts/spec-readiness.cjs +118 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +501 -0
- package/src/claude/scripts/spec-scaffold.cjs +1945 -55
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3204 -262
- package/src/claude/scripts/workflow-policy.cjs +1888 -203
- package/src/claude/settings/settings.json +12 -0
- package/src/claude/skills/code-review/SKILL.md +94 -66
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +138 -283
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +123 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +192 -397
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +200 -260
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +26 -67
- package/src/claude/skills/specs/templates/task.md +30 -82
- package/src/claude/skills/sync/SKILL.md +45 -15
- package/src/claude/skills/sync/references/sync-protocols.md +34 -16
- package/src/claude/skills/test/SKILL.md +151 -167
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +63 -0
- package/src/codex/hooks/completion-authority-state.cjs +527 -0
- package/src/codex/hooks/completion-authority.cjs +106 -0
- package/src/codex/hooks/lib/hook-context.cjs +58 -4
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +38 -16
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
- package/src/codex/hooks/lib/spec-utils.cjs +104 -27
- package/src/codex/hooks/privacy-approval.cjs +1 -1
- package/src/codex/hooks/privacy-block.cjs +67 -11
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +151 -24
- package/src/codex/hooks/spec-state.cjs +65 -12
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +16 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- 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
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
})
|
|
95
|
+
})}\n`,
|
|
69
96
|
);
|
|
70
97
|
} catch {
|
|
71
|
-
//
|
|
98
|
+
// Logging errors are intentionally ignored.
|
|
72
99
|
}
|
|
73
100
|
}
|
|
74
101
|
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
149
|
-
);
|
|
150
|
-
lines.push(
|
|
151
|
-
|
|
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?:
|
|
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
|
-
|
|
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 (
|
|
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
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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(
|
|
291
|
+
if (stateKey && lastKey === stateKey) {
|
|
292
|
+
injectText(outputRecord, reminder);
|
|
228
293
|
return;
|
|
229
294
|
}
|
|
230
295
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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;
|