@haposoft/cafekit 0.13.4 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +2 -23
  2. package/bin/lib/i18n.js +2 -2
  3. package/bin/lib/manifest.js +15 -0
  4. package/bin/lib/opencode-install.js +0 -14
  5. package/bin/phases/claude-runtime.js +4 -1
  6. package/bin/phases/claude-settings.js +38 -7
  7. package/bin/phases/root-config.js +5 -24
  8. package/package.json +1 -1
  9. package/src/claude/agents/ui-ux-designer.md +4 -4
  10. package/src/claude/gitignore +4 -11
  11. package/src/claude/hooks/__tests__/privacy-block.test.js +79 -0
  12. package/src/claude/hooks/__tests__/session-writeenv.test.js +68 -0
  13. package/src/claude/hooks/__tests__/spec-gate.test.js +270 -0
  14. package/src/claude/hooks/privacy-block.cjs +22 -2
  15. package/src/claude/hooks/session.cjs +5 -1
  16. package/src/claude/hooks/spec-gate.cjs +173 -0
  17. package/src/claude/hooks/spec-state.cjs +10 -17
  18. package/src/claude/hooks/task-scaffold-guard.cjs +4 -2
  19. package/src/claude/migration-manifest.json +5 -5
  20. package/src/claude/rules/skill-domain-routing.md +2 -3
  21. package/src/claude/rules/skill-workflow-routing.md +2 -11
  22. package/src/claude/rules/state-sync.md +4 -0
  23. package/src/claude/runtime.json +4 -0
  24. package/src/claude/scripts/validate-docs.cjs +4 -7
  25. package/src/claude/scripts/validate-spec-output.cjs +29 -9
  26. package/src/claude/settings/settings.json +4 -0
  27. package/src/claude/skills/ai-multimodal/.env.example +11 -13
  28. package/src/claude/skills/ai-multimodal/SKILL.md +1 -1
  29. package/src/claude/skills/ai-multimodal/references/audio-processing.md +25 -27
  30. package/src/claude/skills/ai-multimodal/references/video-analysis.md +23 -27
  31. package/src/claude/skills/ai-multimodal/references/vision-understanding.md +26 -26
  32. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +1 -1
  33. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +7 -9
  34. package/src/claude/skills/brainstorm/SKILL.md +1 -2
  35. package/src/claude/skills/docx/SKILL.md +1 -1
  36. package/src/claude/skills/frontend-design/SKILL.md +1 -1
  37. package/src/claude/skills/frontend-design/references/ai-multimodal-overview.md +4 -4
  38. package/src/claude/skills/frontend-design/references/analysis-techniques.md +3 -3
  39. package/src/claude/skills/frontend-design/references/asset-generation.md +2 -2
  40. package/src/claude/skills/frontend-design/references/design-extraction-overview.md +4 -4
  41. package/src/claude/skills/frontend-design/references/extraction-best-practices.md +3 -3
  42. package/src/claude/skills/frontend-design/references/technical-accessibility.md +1 -1
  43. package/src/claude/skills/frontend-design/references/technical-optimization.md +1 -1
  44. package/src/claude/skills/frontend-design/references/technical-overview.md +2 -2
  45. package/src/claude/skills/frontend-design/references/technical-workflows.md +6 -6
  46. package/src/claude/skills/frontend-design/references/visual-analysis-overview.md +3 -3
  47. package/src/claude/skills/inspect/SKILL.md +3 -3
  48. package/src/claude/skills/inspect/references/external-gemini-inspection.md +9 -9
  49. package/src/claude/skills/pdf/SKILL.md +1 -1
  50. package/src/claude/skills/pptx/SKILL.md +1 -1
  51. package/src/claude/skills/specs/SKILL.md +1 -1
  52. package/src/claude/skills/xlsx/SKILL.md +1 -1
  53. package/src/opencode/AGENTS.md +1 -2
  54. package/src/opencode/plugins/session.ts +2 -2
  55. package/src/opencode/plugins/spec-state.ts +244 -0
  56. package/src/opencode/plugins/task-scaffold-guard.ts +161 -0
  57. package/src/claude/scripts/browser-tool.cjs +0 -138
  58. package/src/claude/skills/delegate/SKILL.md +0 -124
  59. package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
  60. package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
  61. package/src/claude/skills/generate-graph/LICENSE +0 -21
  62. package/src/claude/skills/generate-graph/README.md +0 -523
  63. package/src/claude/skills/generate-graph/SKILL.md +0 -429
  64. package/src/claude/skills/generate-graph/agentloop-core.svg +0 -101
  65. package/src/claude/skills/generate-graph/agents/openai.yaml +0 -4
  66. package/src/claude/skills/generate-graph/assets/samples/sample-style1-flat.png +0 -0
  67. package/src/claude/skills/generate-graph/assets/samples/sample-style2-dark.png +0 -0
  68. package/src/claude/skills/generate-graph/assets/samples/sample-style3-blueprint.png +0 -0
  69. package/src/claude/skills/generate-graph/assets/samples/sample-style4-notion.png +0 -0
  70. package/src/claude/skills/generate-graph/assets/samples/sample-style5-glass.png +0 -0
  71. package/src/claude/skills/generate-graph/assets/samples/sample-style6-claude.png +0 -0
  72. package/src/claude/skills/generate-graph/assets/samples/sample-style7-openai.png +0 -0
  73. package/src/claude/skills/generate-graph/fixtures/agent-memory-types-style4.json +0 -181
  74. package/src/claude/skills/generate-graph/fixtures/api-flow-style7.json +0 -40
  75. package/src/claude/skills/generate-graph/fixtures/mem0-style1.json +0 -297
  76. package/src/claude/skills/generate-graph/fixtures/microservices-style3.json +0 -64
  77. package/src/claude/skills/generate-graph/fixtures/multi-agent-style5.json +0 -45
  78. package/src/claude/skills/generate-graph/fixtures/system-architecture-style6.json +0 -48
  79. package/src/claude/skills/generate-graph/fixtures/tool-call-style2.json +0 -182
  80. package/src/claude/skills/generate-graph/package.json +0 -42
  81. package/src/claude/skills/generate-graph/references/icons.md +0 -281
  82. package/src/claude/skills/generate-graph/references/style-1-flat-icon.md +0 -108
  83. package/src/claude/skills/generate-graph/references/style-2-dark-terminal.md +0 -107
  84. package/src/claude/skills/generate-graph/references/style-3-blueprint.md +0 -113
  85. package/src/claude/skills/generate-graph/references/style-4-notion-clean.md +0 -94
  86. package/src/claude/skills/generate-graph/references/style-5-glassmorphism.md +0 -125
  87. package/src/claude/skills/generate-graph/references/style-6-claude-official.md +0 -209
  88. package/src/claude/skills/generate-graph/references/style-7-openai.md +0 -215
  89. package/src/claude/skills/generate-graph/references/style-diagram-matrix.md +0 -135
  90. package/src/claude/skills/generate-graph/references/svg-layout-best-practices.md +0 -100
  91. package/src/claude/skills/generate-graph/scripts/generate-diagram.sh +0 -157
  92. package/src/claude/skills/generate-graph/scripts/generate-from-template.py +0 -1556
  93. package/src/claude/skills/generate-graph/scripts/test-all-styles.sh +0 -135
  94. package/src/claude/skills/generate-graph/scripts/validate-svg.sh +0 -292
  95. package/src/claude/skills/generate-graph/templates/agent-architecture.svg +0 -28
  96. package/src/claude/skills/generate-graph/templates/architecture.svg +0 -23
  97. package/src/claude/skills/generate-graph/templates/comparison-matrix.svg +0 -14
  98. package/src/claude/skills/generate-graph/templates/data-flow.svg +0 -28
  99. package/src/claude/skills/generate-graph/templates/er-diagram.svg +0 -21
  100. package/src/claude/skills/generate-graph/templates/flowchart.svg +0 -21
  101. package/src/claude/skills/generate-graph/templates/sequence.svg +0 -20
  102. package/src/claude/skills/generate-graph/templates/state-machine.svg +0 -20
  103. package/src/claude/skills/generate-graph/templates/timeline.svg +0 -19
  104. package/src/claude/skills/generate-graph/templates/use-case.svg +0 -21
  105. package/src/claude/skills/impact-analysis/SKILL.md +0 -276
  106. package/src/claude/skills/impact-analysis/references/change-detection.md +0 -270
  107. package/src/claude/skills/impact-analysis/references/dependency-scouting.md +0 -338
  108. package/src/claude/skills/impact-analysis/references/edge-case-identification.md +0 -439
  109. package/src/claude/skills/impact-analysis/references/industry-techniques.md +0 -695
  110. package/src/claude/skills/impact-analysis/references/practical-techniques-guide.md +0 -753
  111. package/src/claude/skills/impact-analysis/references/project-detection.md +0 -704
  112. package/src/claude/skills/impact-analysis/references/react-native-customization.md +0 -508
  113. package/src/claude/skills/impact-analysis/references/report-template.md +0 -604
  114. package/src/claude/skills/impact-analysis/references/test-scenario-generation.md +0 -459
  115. package/src/claude/skills/impact-analysis/scripts/README.md +0 -476
  116. package/src/claude/skills/impact-analysis/scripts/ast-analyze.js +0 -403
  117. package/src/claude/skills/impact-analysis/scripts/calculate-risk.js +0 -475
  118. package/src/claude/skills/impact-analysis/scripts/find-dependencies.sh +0 -202
  119. package/src/claude/skills/impact-analysis/scripts/run-analysis.sh +0 -312
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+
3
+ // Behavioral tests for spec-gate.cjs (Stop completion gate). The hook is run as
4
+ // a real subprocess: a Stop payload is piped to stdin; we assert exit code and
5
+ // whether stdout carries {"decision":"block",...}. Cache lives under the hook's
6
+ // own .logs/ dir (shared), so each test seeds/clears it deliberately.
7
+
8
+ const { test, beforeEach, after } = require('node:test');
9
+ const assert = require('node:assert');
10
+ const { spawnSync } = require('node:child_process');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ const HOOK = path.join(__dirname, '..', 'spec-gate.cjs');
16
+ const CACHE = path.join(__dirname, '..', '.logs', 'spec-gate-last.json');
17
+ const FEATURE = 'demo';
18
+ const TASK_REL = 'tasks/task-R0-01-x.md';
19
+
20
+ function runHook(payload, cwd) {
21
+ const projectRoot = cwd || payload.cwd;
22
+ // PROJECT_ROOT wins over cwd in the hook; pin it to the fixture so a host
23
+ // monorepo's real specs/ cannot leak into the test (e.g. active rtk-* specs).
24
+ const env = { ...process.env, PROJECT_ROOT: projectRoot };
25
+ const res = spawnSync(process.execPath, [HOOK], {
26
+ input: JSON.stringify({
27
+ hook_event_name: 'Stop',
28
+ session_id: 'test',
29
+ transcript_path: '/tmp/t',
30
+ stop_hook_active: false,
31
+ ...payload,
32
+ cwd: projectRoot,
33
+ }),
34
+ encoding: 'utf8',
35
+ env,
36
+ });
37
+ return {
38
+ code: res.status,
39
+ stdout: (res.stdout || '').trim(),
40
+ stderr: res.stderr || '',
41
+ };
42
+ }
43
+
44
+ function tmpDir() {
45
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-'));
46
+ }
47
+
48
+ function clearCache() {
49
+ try { fs.unlinkSync(CACHE); } catch { /* absent */ }
50
+ }
51
+
52
+ function seedCache(featureMap) {
53
+ fs.mkdirSync(path.dirname(CACHE), { recursive: true });
54
+ fs.writeFileSync(CACHE, JSON.stringify(featureMap));
55
+ }
56
+
57
+ function readCache() {
58
+ return JSON.parse(fs.readFileSync(CACHE, 'utf8'));
59
+ }
60
+
61
+ /**
62
+ * Build a minimal active-spec fixture under dir.
63
+ * @param {object} opts
64
+ * @param {string} [opts.taskStatus='done']
65
+ * @param {string|null} [opts.completed_at='2026-07-01T00:00:00Z']
66
+ * @param {string} [opts.mdStatus='done']
67
+ * @param {'valid'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
68
+ * @param {object|null} [opts.runtime] — if set, write .claude/runtime.json
69
+ */
70
+ function makeFixture(opts = {}) {
71
+ const {
72
+ taskStatus = 'done',
73
+ completed_at = '2026-07-01T00:00:00Z',
74
+ mdStatus = 'done',
75
+ evidence = 'valid',
76
+ runtime = null,
77
+ } = opts;
78
+ const dir = tmpDir();
79
+ const featureDir = path.join(dir, 'specs', FEATURE);
80
+ const tasksDir = path.join(featureDir, 'tasks');
81
+ fs.mkdirSync(tasksDir, { recursive: true });
82
+
83
+ const entry = { status: taskStatus };
84
+ if (completed_at !== null) entry.completed_at = completed_at;
85
+
86
+ fs.writeFileSync(
87
+ path.join(featureDir, 'spec.json'),
88
+ JSON.stringify({
89
+ status: 'in_progress',
90
+ current_phase: 'implementation',
91
+ task_registry: { [TASK_REL]: entry },
92
+ }),
93
+ );
94
+
95
+ let evidenceBlock = '';
96
+ if (evidence === 'valid') {
97
+ evidenceBlock = [
98
+ '## Evidence',
99
+ '',
100
+ '```',
101
+ 'npm test',
102
+ 'PASS: 10 tests',
103
+ '```',
104
+ '',
105
+ ].join('\n');
106
+ } else if (evidence === 'placeholder') {
107
+ evidenceBlock = [
108
+ '## Evidence',
109
+ '',
110
+ 'Command: `{{TYPECHECK / TEST COMMAND}}`',
111
+ 'Expected: {{What proves success}}',
112
+ '',
113
+ ].join('\n');
114
+ } else if (evidence === 'missing-evidence') {
115
+ evidenceBlock = '## Risk Assessment\n\nNone.\n';
116
+ } else {
117
+ evidenceBlock = '';
118
+ }
119
+
120
+ fs.writeFileSync(
121
+ path.join(dir, 'specs', FEATURE, TASK_REL),
122
+ [
123
+ `# Task R0-01: example`,
124
+ '',
125
+ `**Status:** ${mdStatus}`,
126
+ '',
127
+ evidenceBlock,
128
+ ].join('\n'),
129
+ );
130
+
131
+ if (runtime) {
132
+ fs.mkdirSync(path.join(dir, '.claude'), { recursive: true });
133
+ fs.writeFileSync(
134
+ path.join(dir, '.claude', 'runtime.json'),
135
+ JSON.stringify(runtime),
136
+ );
137
+ }
138
+
139
+ return dir;
140
+ }
141
+
142
+ function parseBlock(stdout) {
143
+ if (!stdout) return null;
144
+ // Hook may print only the JSON line.
145
+ try {
146
+ return JSON.parse(stdout);
147
+ } catch {
148
+ const line = stdout.split('\n').find((l) => l.includes('"decision"'));
149
+ if (!line) return null;
150
+ return JSON.parse(line);
151
+ }
152
+ }
153
+
154
+ beforeEach(() => {
155
+ clearCache();
156
+ });
157
+
158
+ after(() => {
159
+ clearCache();
160
+ });
161
+
162
+ test('1. newly-done task WITHOUT receipt → block, reason names task path', () => {
163
+ const dir = makeFixture({
164
+ evidence: 'missing-evidence',
165
+ completed_at: '2026-07-01T00:00:00Z',
166
+ });
167
+ try {
168
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
169
+ const { code, stdout } = runHook({}, dir);
170
+ assert.strictEqual(code, 0);
171
+ const body = parseBlock(stdout);
172
+ assert.ok(body, 'expected block JSON on stdout');
173
+ assert.strictEqual(body.decision, 'block');
174
+ assert.ok(
175
+ body.reason.includes(TASK_REL),
176
+ `reason must name task path; got: ${body.reason}`,
177
+ );
178
+ } finally {
179
+ fs.rmSync(dir, { recursive: true, force: true });
180
+ }
181
+ });
182
+
183
+ test('2. newly-done task WITH valid receipt → no block, cache updated', () => {
184
+ const dir = makeFixture({ evidence: 'valid' });
185
+ try {
186
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
187
+ const { code, stdout } = runHook({}, dir);
188
+ assert.strictEqual(code, 0);
189
+ assert.strictEqual(stdout, '', 'valid receipt must not produce block output');
190
+ const cache = readCache();
191
+ assert.strictEqual(cache[FEATURE][TASK_REL], 'done');
192
+ } finally {
193
+ fs.rmSync(dir, { recursive: true, force: true });
194
+ }
195
+ });
196
+
197
+ test('3. no active spec → exit 0 silent', () => {
198
+ const dir = tmpDir();
199
+ try {
200
+ fs.mkdirSync(path.join(dir, 'specs'), { recursive: true });
201
+ // No in_progress spec.
202
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
203
+ const { code, stdout } = runHook({}, dir);
204
+ assert.strictEqual(code, 0);
205
+ assert.strictEqual(stdout, '');
206
+ } finally {
207
+ fs.rmSync(dir, { recursive: true, force: true });
208
+ }
209
+ });
210
+
211
+ test('4. stop_hook_active: true → exit 0 silent even with violating task', () => {
212
+ const dir = makeFixture({ evidence: 'missing-evidence' });
213
+ try {
214
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
215
+ const { code, stdout } = runHook({ stop_hook_active: true }, dir);
216
+ assert.strictEqual(code, 0);
217
+ assert.strictEqual(stdout, '');
218
+ } finally {
219
+ fs.rmSync(dir, { recursive: true, force: true });
220
+ }
221
+ });
222
+
223
+ test('5. first run (no cache) with done-without-receipt → exit 0, cache created', () => {
224
+ const dir = makeFixture({ evidence: 'missing-evidence' });
225
+ try {
226
+ clearCache();
227
+ assert.ok(!fs.existsSync(CACHE), 'cache must be absent for first-run');
228
+ const { code, stdout } = runHook({}, dir);
229
+ assert.strictEqual(code, 0);
230
+ assert.strictEqual(stdout, '', 'first run must never block');
231
+ assert.ok(fs.existsSync(CACHE), 'cache file must be created');
232
+ const cache = readCache();
233
+ assert.strictEqual(cache[FEATURE][TASK_REL], 'done');
234
+ } finally {
235
+ fs.rmSync(dir, { recursive: true, force: true });
236
+ }
237
+ });
238
+
239
+ test('6. runtime.json spec.completion_gate: false → exit 0 silent', () => {
240
+ const dir = makeFixture({
241
+ evidence: 'missing-evidence',
242
+ runtime: { spec: { completion_gate: false } },
243
+ });
244
+ try {
245
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
246
+ const { code, stdout } = runHook({}, dir);
247
+ assert.strictEqual(code, 0);
248
+ assert.strictEqual(stdout, '');
249
+ } finally {
250
+ fs.rmSync(dir, { recursive: true, force: true });
251
+ }
252
+ });
253
+
254
+ test('7. Evidence with {{...}} placeholder → blocked (check c)', () => {
255
+ const dir = makeFixture({ evidence: 'placeholder' });
256
+ try {
257
+ seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
258
+ const { code, stdout } = runHook({}, dir);
259
+ assert.strictEqual(code, 0);
260
+ const body = parseBlock(stdout);
261
+ assert.ok(body, 'expected block JSON on stdout');
262
+ assert.strictEqual(body.decision, 'block');
263
+ assert.ok(
264
+ body.reason.includes(TASK_REL) && /\bc\b/.test(body.reason),
265
+ `reason must cite task path and check c; got: ${body.reason}`,
266
+ );
267
+ } finally {
268
+ fs.rmSync(dir, { recursive: true, force: true });
269
+ }
270
+ });
@@ -60,6 +60,20 @@ try {
60
60
  return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
61
61
  }
62
62
 
63
+ /**
64
+ * Resolve a symlink to its real target so a harmless-looking name that points
65
+ * at a sensitive file cannot slip through the basename check. Returns null
66
+ * when the path cannot be resolved (missing file, broken link) — fail-open to
67
+ * the original-path check in that case.
68
+ */
69
+ function resolveTarget(filePath) {
70
+ try {
71
+ return fs.realpathSync(filePath);
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
63
77
  function extractBashPaths(command) {
64
78
  const paths = [];
65
79
  const regex = /(?:cat|less|more|head|tail|source|\.)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
@@ -146,8 +160,14 @@ try {
146
160
  if (!paths.length) process.exit(0);
147
161
 
148
162
  for (const filePath of paths) {
149
- if (isSafe(filePath)) continue;
150
- if (!isSensitive(filePath)) continue;
163
+ // Check the requested path and, when it is a symlink, its real target.
164
+ const target = resolveTarget(filePath);
165
+ const candidates = target && target !== filePath ? [filePath, target] : [filePath];
166
+
167
+ // An exemption on either name wins (e.g. a symlink to .env.example).
168
+ if (candidates.some(isSafe)) continue;
169
+ // Sensitive if the requested path OR its symlink target is sensitive.
170
+ if (!candidates.some(isSensitive)) continue;
151
171
 
152
172
  if (toolName === 'Bash') {
153
173
  console.error(`WARN: Privacy-sensitive file access via bash allowed for approved follow-up: ${path.basename(filePath)}`);
@@ -31,7 +31,11 @@ try {
31
31
  function writeEnv(file, key, value) {
32
32
  if (!file) return;
33
33
  try {
34
- const safe = String(value ?? '').replace(/"/g, '\\"');
34
+ // Escape every char that stays special inside a double-quoted shell
35
+ // string: backslash, dollar, backtick, and the closing quote. Escaping
36
+ // only `"` left `$(...)`/backticks live, so a value sourced from repo
37
+ // content (branch, package name) could execute when the env file is sourced.
38
+ const safe = String(value ?? '').replace(/([\\$`"])/g, '\\$1');
35
39
  fs.appendFileSync(file, `export ${key}="${safe}"\n`);
36
40
  } catch { /* fail-open */ }
37
41
  }
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copyright (c) 2026 Haposoft. MIT License.
4
+ *
5
+ * Stop Hook — spec-gate.cjs
6
+ * Implements: https://docs.anthropic.com/en/docs/claude-code/hooks
7
+ *
8
+ * Blocks turn end when *newly-done* tasks lack a verification receipt.
9
+ * Block contract (exit 0 + stdout): {"decision":"block","reason":"..."}.
10
+ *
11
+ * Safety: stop_hook_active loop guard; runtime.spec.completion_gate=false
12
+ * escape hatch (missing key → ON); first-run seeds cache without blocking;
13
+ * crash → fail-open + hooks/.logs/hook-log.jsonl. Exit: 0 always.
14
+ */
15
+
16
+ try {
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const stdin = fs.readFileSync(0, 'utf8').trim();
21
+ if (!stdin) process.exit(0);
22
+
23
+ const payload = JSON.parse(stdin);
24
+ const cwd = payload.cwd || process.cwd();
25
+
26
+ // Never re-block a continuation caused by our own block (infinite-loop guard).
27
+ if (payload.stop_hook_active === true) process.exit(0);
28
+
29
+ // Missing/malformed runtime.json keeps the gate ON (fail-closed, valve 3 style).
30
+ let runtime = {};
31
+ try {
32
+ const rp = path.join(cwd, '.claude', 'runtime.json');
33
+ if (fs.existsSync(rp)) runtime = JSON.parse(fs.readFileSync(rp, 'utf8'));
34
+ } catch { /* gate stays on */ }
35
+ if (runtime.spec && runtime.spec.completion_gate === false) process.exit(0);
36
+
37
+ // Active-spec discovery — same rules as spec-state.cjs.
38
+ const baseDir = process.env.PROJECT_ROOT || cwd;
39
+ const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
40
+ if (!fs.existsSync(specsPath)) process.exit(0);
41
+
42
+ let activeSpec = null;
43
+ let featureName = null;
44
+ for (const entry of fs.readdirSync(specsPath, { withFileTypes: true })) {
45
+ if (!entry.isDirectory()) continue;
46
+ const specFile = path.join(specsPath, entry.name, 'spec.json');
47
+ if (!fs.existsSync(specFile)) continue;
48
+ try {
49
+ const specData = JSON.parse(fs.readFileSync(specFile, 'utf8'));
50
+ if (specData.status === 'in_progress' || specData.status === 'in-progress') {
51
+ activeSpec = specData;
52
+ featureName = entry.name;
53
+ break;
54
+ }
55
+ } catch { /* skip bad JSON */ }
56
+ }
57
+ if (!activeSpec) process.exit(0);
58
+
59
+ const taskRegistry = activeSpec.task_registry || {};
60
+ const cacheFile = path.join(__dirname, '.logs', 'spec-gate-last.json');
61
+ const cacheExists = fs.existsSync(cacheFile);
62
+ let cache = {};
63
+ if (cacheExists) {
64
+ try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); } catch { cache = {}; }
65
+ }
66
+
67
+ const currentStatuses = {};
68
+ for (const [tp, task] of Object.entries(taskRegistry)) {
69
+ currentStatuses[tp] = task?.status || 'pending';
70
+ }
71
+
72
+ // First run: treat all current done as historical — seed cache, never block.
73
+ if (!cacheExists) {
74
+ try {
75
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
76
+ cache[featureName] = currentStatuses;
77
+ fs.writeFileSync(cacheFile, JSON.stringify(cache));
78
+ } catch { /* fail-open */ }
79
+ process.exit(0);
80
+ }
81
+
82
+ const featureCache = cache[featureName] || {};
83
+ // Newly done: status is done now AND cached status differs or is absent.
84
+ const newlyDone = Object.keys(taskRegistry).filter((tp) =>
85
+ (taskRegistry[tp]?.status || 'pending') === 'done' && featureCache[tp] !== 'done'
86
+ );
87
+ if (newlyDone.length === 0) process.exit(0);
88
+
89
+ // /m so ^ matches line starts (Evidence is never at byte 0 of the file).
90
+ const EVID_RE = /^#{2,3}\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/m;
91
+ const PROOF_RE = /(PASS|FAIL|exit code|passed|✓)/;
92
+
93
+ /** Body of first Evidence heading until next same-or-higher heading. */
94
+ function evidenceBody(text) {
95
+ const lines = text.split('\n');
96
+ let start = -1, level = 0;
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const m = lines[i].match(/^(#{2,3})\s+(Evidence|Task Test Plan & Verification Evidence|Verification & Evidence)\b/);
99
+ if (m) { start = i + 1; level = m[1].length; break; }
100
+ }
101
+ if (start < 0) return null;
102
+ let end = lines.length;
103
+ for (let i = start; i < lines.length; i++) {
104
+ const hm = lines[i].match(/^(#{1,6})\s+/);
105
+ if (hm && hm[1].length <= level) { end = i; break; }
106
+ }
107
+ return lines.slice(start, end).join('\n');
108
+ }
109
+
110
+ // Receipt checks a–d → failed letter list.
111
+ // a: file + Status header has "done"; b: Evidence heading; c: no {{...}} + (fence|proof); d: completed_at
112
+ function checkReceipt(taskPath) {
113
+ const fails = [];
114
+ const abs = path.join(specsPath, featureName, taskPath);
115
+ if (!fs.existsSync(abs)) return ['a'];
116
+ let text = '';
117
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { return ['a']; }
118
+
119
+ const statusLine = text.split('\n').find((l) => /^\s*(\*\*)?Status(\*\*)?\s*:/i.test(l));
120
+ if (!statusLine || !/\bdone\b/i.test(statusLine)) fails.push('a');
121
+
122
+ const body = evidenceBody(text);
123
+ if (body === null || !EVID_RE.test(text)) {
124
+ fails.push('b');
125
+ } else if (/\{\{[^}]+\}\}/.test(body) || !(/```/.test(body) || PROOF_RE.test(body))) {
126
+ fails.push('c');
127
+ }
128
+
129
+ const at = taskRegistry[taskPath]?.completed_at;
130
+ if (typeof at !== 'string' || at.trim() === '') fails.push('d');
131
+ return fails;
132
+ }
133
+
134
+ const failures = newlyDone
135
+ .map((tp) => ({ taskPath: tp, fails: checkReceipt(tp) }))
136
+ .filter((f) => f.fails.length > 0);
137
+
138
+ // Leave failing newly-done at old cache status so the gate re-fires next Stop.
139
+ try {
140
+ const nextFeature = { ...featureCache, ...currentStatuses };
141
+ for (const { taskPath } of failures) {
142
+ if (featureCache[taskPath] === undefined) delete nextFeature[taskPath];
143
+ else nextFeature[taskPath] = featureCache[taskPath];
144
+ }
145
+ cache[featureName] = nextFeature;
146
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
147
+ fs.writeFileSync(cacheFile, JSON.stringify(cache));
148
+ } catch { /* fail-open */ }
149
+
150
+ if (failures.length === 0) process.exit(0);
151
+
152
+ const lines = [
153
+ `⚠️ Completion gate: ${failures.length} newly-done task(s) lack a verification receipt.`,
154
+ ];
155
+ for (const { taskPath, fails } of failures) {
156
+ lines.push(`- \`${taskPath}\`: failed check(s) ${fails.join(', ')}`);
157
+ lines.push(
158
+ ` Fix: add a verification receipt to \`## Evidence\` in \`specs/${featureName}/${taskPath}\`: commands run + outcomes, then re-sync spec.json`
159
+ );
160
+ }
161
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: lines.slice(0, 8).join('\n') }) + '\n');
162
+ process.exit(0);
163
+
164
+ } catch (e) {
165
+ try {
166
+ const fs = require('fs'), p = require('path');
167
+ const d = p.join(__dirname, '.logs');
168
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
169
+ fs.appendFileSync(p.join(d, 'hook-log.jsonl'),
170
+ JSON.stringify({ ts: new Date().toISOString(), hook: 'spec-gate', status: 'crash', error: e.message }) + '\n');
171
+ } catch (_) {}
172
+ process.exit(0);
173
+ }
@@ -98,26 +98,19 @@ try {
98
98
  fs.writeFileSync(cacheFile, stateKey);
99
99
  } catch { /* fail-open: if the write fails we still print the full block */ }
100
100
 
101
- // Format the output
101
+ // Compact state-change block (enforcement lives on Stop via spec-gate.cjs).
102
102
  const lines = [];
103
103
  lines.push('');
104
- lines.push('### 🔴 URGENT SYSTEM TOLLGATE (STATE SYNC) 🔴');
105
- lines.push(`- **Active Feature:** \`${featureName}\``);
106
- lines.push(`- **Current Phase:** \`${phase}\``);
107
- if (taskEntries.length > 0) {
108
- lines.push(`- **Task Registry:** \`${taskEntries.length} total | ${(taskCounts.done || 0)} done | ${(taskCounts.in_progress || 0)} in_progress | ${(taskCounts.blocked || 0)} blocked | ${(taskCounts.pending || 0)} pending\``);
109
- if (nextUnblocked) {
110
- lines.push(`- **Next Unblocked Task:** \`${nextUnblocked[0]}\``);
111
- }
104
+ lines.push(`### Spec state changed: \`${featureName}\``);
105
+ lines.push(`- Phase: \`${phase}\` | Tasks: ${(taskCounts.done || 0)} done / ${taskEntries.length} total` +
106
+ (taskEntries.length > 0
107
+ ? ` (${(taskCounts.in_progress || 0)} in_progress, ${(taskCounts.pending || 0)} pending, ${(taskCounts.blocked || 0)} blocked)`
108
+ : ''));
109
+ if (nextUnblocked) {
110
+ lines.push(`- Next unblocked: \`${nextUnblocked[0]}\``);
112
111
  }
113
- lines.push('');
114
- lines.push(`> 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.`);
115
- lines.push(`> 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.`);
116
- lines.push(`> 1. Sửa file \`spec.json\` (status, phase/current_phase, timestamps, \`task_files\`, \`task_registry\`, validation state nếu có thay đổi).`);
117
- 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).`);
118
- lines.push(`> 3. Trước khi set \`ready_for_implementation = true\`, PHẢI chạy \`node .claude/scripts/validate-spec-output.cjs specs/${featureName}\` và sửa mọi lỗi.`);
119
- lines.push(`> 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ộ.`);
120
- lines.push(`> CẤM VI PHẠM LUẬT TOLLGATE NÀY NHẰM ĐẢM BẢO TÍNH ĐỒNG BỘ CỦA HỆ THỐNG.`);
112
+ lines.push(`- Sync \`spec.json\` + task file after verified work; run \`node .claude/scripts/validate-spec-output.cjs specs/${featureName}\` before \`ready_for_implementation=true\`.`);
113
+ lines.push(`- A completion gate verifies receipts when you end a turn with newly-done tasks.`);
121
114
  lines.push('');
122
115
 
123
116
  console.log(lines.join('\n'));
@@ -66,6 +66,9 @@ try {
66
66
  if (!fs.existsSync(scaffold)) process.exit(0);
67
67
 
68
68
  // Valve 2: block with an actionable message carrying the exact command.
69
+ // NOTE: deliberately does NOT mention the runtime.json escape hatch — an
70
+ // OpenCode smoke test proved the model reads the advertised override and
71
+ // simply disables the guard itself. The hatch stays functional for humans.
69
72
  const m = norm.match(/(^|\/)specs\/([^/]+)\/tasks\//);
70
73
  const feature = m ? m[2] : '<feature>';
71
74
  console.log(
@@ -73,8 +76,7 @@ try {
73
76
  `Blocked Write: ${filePath}\n\n` +
74
77
  `Generate the stub(s), then Edit-fill the {{...}} placeholders:\n` +
75
78
  ` node .claude/scripts/spec-scaffold.cjs ${feature} --tasks "R0-01-slug,R1-01-slug,..." --tasks-only\n` +
76
- `Then use Edit (not Write) on each tasks/task-*.md stub.\n` +
77
- `Override: set "spec": { "scaffold_guard": false } in .claude/runtime.json`
79
+ `Then use Edit (not Write) on each tasks/task-*.md stub.`
78
80
  );
79
81
  process.exit(2);
80
82
 
@@ -13,17 +13,14 @@
13
13
  "chrome-devtools",
14
14
  "code-review",
15
15
  "debug",
16
- "delegate",
17
16
  "develop",
18
17
  "devops",
19
18
  "docs",
20
19
  "docx",
21
20
  "frontend-design",
22
21
  "frontend-development",
23
- "generate-graph",
24
22
  "git",
25
23
  "hotfix",
26
- "impact-analysis",
27
24
  "inspect",
28
25
  "mobile-development",
29
26
  "pdf",
@@ -61,7 +58,6 @@
61
58
  "validate-docs.cjs",
62
59
  "validate-docs-reconstruct.cjs",
63
60
  "generate-skill-catalog.cjs",
64
- "browser-tool.cjs",
65
61
  "validate-spec-output.cjs"
66
62
  ]
67
63
  },
@@ -74,7 +70,10 @@
74
70
  "obsolete": {
75
71
  "runtimeFiles": [
76
72
  "hooks/skill-router.cjs",
77
- "hooks/lib/skill-router-routes.cjs"
73
+ "hooks/lib/skill-router-routes.cjs",
74
+ "scripts/browser-tool.cjs",
75
+ "skills/generate-graph",
76
+ "skills/impact-analysis"
78
77
  ],
79
78
  "settingsHookCommandSubstrings": [
80
79
  "hooks/skill-router.cjs"
@@ -94,6 +93,7 @@
94
93
  "hooks/task-scaffold-guard.cjs",
95
94
  "hooks/rules.cjs",
96
95
  "hooks/spec-state.cjs",
96
+ "hooks/spec-gate.cjs",
97
97
  "hooks/state.cjs",
98
98
  "hooks/lib/color.cjs",
99
99
  "hooks/lib/parser.cjs",
@@ -21,8 +21,7 @@ Use `/hapo:react-best-practices` for performance/re-render patterns. Use `/hapo:
21
21
  User wants to...
22
22
  ├── API, service, auth, database-backed feature -> /hapo:backend-development
23
23
  ├── deployment, Docker, Kubernetes, Cloudflare, CI/CD -> /hapo:devops
24
- ├── mobile apps, iOS, Android, React Native, Flutter -> /hapo:mobile-development
25
- └── impact of changing existing behavior -> /hapo:impact-analysis
24
+ └── mobile apps, iOS, Android, React Native, Flutter -> /hapo:mobile-development
26
25
  ```
27
26
 
28
27
  ## Codebase Understanding
@@ -32,7 +31,7 @@ User wants to...
32
31
  ├── answer questions from source code/docs/specs/config -> /hapo:question
33
32
  ├── find files, locate code, scan project structure -> /hapo:inspect
34
33
  ├── diagnose an error or failing check -> /hapo:debug
35
- ├── understand possible side effects -> /hapo:impact-analysis
34
+ ├── understand possible side effects -> /hapo:inspect
36
35
  └── compare external approaches or best practices -> /hapo:research
37
36
  ```
38
37
 
@@ -55,30 +55,21 @@ Do not turn reconstructed docs directly into implementation. Human review must a
55
55
  ## Research And Analysis Workflow
56
56
 
57
57
  ```text
58
- /hapo:inspect -> /hapo:impact-analysis -> /hapo:research
58
+ /hapo:inspect -> /hapo:research
59
59
  ```
60
60
 
61
61
  | User intent | Suggested start |
62
62
  |---|---|
63
63
  | file discovery, codebase structure, source scouting | `/hapo:inspect` |
64
- | affected files, blast radius, dependency impact, side effects | `/hapo:impact-analysis` |
64
+ | affected files, blast radius, dependency impact, side effects | `/hapo:inspect` |
65
65
  | external best practices, tool comparison, technical investigation | `/hapo:research` |
66
66
 
67
67
  Use `/hapo:research` for external knowledge. Use `/hapo:inspect` for local source truth.
68
68
 
69
- ## Delegation Workflow
70
-
71
- | User intent | Suggested skill |
72
- |---|---|
73
- | assign/offload an implementation task to an external agent CLI (codex, grok) | `/hapo:delegate` |
74
-
75
- Claude Code remains the orchestrator: it writes the task brief, dispatches, monitors, and independently verifies the returned work.
76
-
77
69
  ## Output Artifact Workflow
78
70
 
79
71
  | User intent | Suggested skill |
80
72
  |---|---|
81
- | diagrams, architecture maps, Mermaid/SVG/PNG graph output | `/hapo:generate-graph` |
82
73
  | PowerPoint or slide deck creation/editing | `/hapo:pptx` |
83
74
  | Word documents | `/hapo:docx` |
84
75
  | PDF processing | `/hapo:pdf` |
@@ -25,3 +25,7 @@ Before returning control to the user or orchestrator, the agent **MUST**:
25
25
  **Canonical state values:** New specs MUST use `status: "in_progress"` for active work. Legacy `in-progress` may be read for compatibility, but must not be emitted in new files.
26
26
 
27
27
  **Golden Rule:** If the current phase changes, or a task completes, the agent must update the physical files. Never mark a task completed before there is execution proof, and never let `task_registry` disagree with the matching markdown task file. The context is intentionally NOT persisted in the chat to save tokens. An injected Hook (`spec-state.cjs`) constantly enforces and validates this state.
28
+
29
+ ### Completion gate (Stop)
30
+
31
+ Marking a task `done` is now machine-gated on the **Stop** hook (`spec-gate.cjs`), not only reminded on each prompt. When a turn ends with *newly-done* tasks, the gate requires a verification receipt in each task markdown: `Status: done`, an Evidence section (or legacy `Task Test Plan & Verification Evidence` / `Verification & Evidence`) with real proof (no `{{...}}` placeholders; at least one fenced command block or PASS/FAIL line), and a non-empty `task_registry[path].completed_at`. First adoption (no cache yet) seeds history without blocking. Escape hatch for humans: `"spec": { "completion_gate": false }` in `.claude/runtime.json` (missing key keeps the gate ON).
@@ -15,6 +15,10 @@
15
15
  "docs": "docs",
16
16
  "specs": "specs"
17
17
  },
18
+ "spec": {
19
+ "scaffold_guard": true,
20
+ "completion_gate": true
21
+ },
18
22
  "locale": {
19
23
  "thinkingLanguage": null,
20
24
  "responseLanguage": null