toga-ai 1.0.922 → 1.0.923

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.
@@ -90,6 +90,16 @@
90
90
  "timeout": 3000
91
91
  }
92
92
  ]
93
+ },
94
+ {
95
+ "matcher": "Write|Edit",
96
+ "hooks": [
97
+ {
98
+ "type": "command",
99
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/toga/platform-reuse-reminder.js\"",
100
+ "timeout": 3000
101
+ }
102
+ ]
93
103
  }
94
104
  ],
95
105
  "PostToolUse": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.922",
3
+ "version": "1.0.923",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",
@@ -29,7 +29,8 @@ not a script. When a principle and a real constraint collide, reason it out and
29
29
  - **Data-drive the UI.** Labels, colors, options, config come from Surface/settings and
30
30
  `labelRecordFieldId` — not hardcoded. Model anything data-drivable through those layers.
31
31
  - **Reuse before build.** A fundamental shared component (the table first) belongs in `blox`,
32
- themed per app — not copied.
32
+ themed per app — not copied. Nudged by `scripts/hooks/platform-reuse-reminder.js` on every
33
+ new `src/` file in a platform app or blox (silence: `TOGA_REUSE_REMINDER_DISABLED=1`).
33
34
  - **Standard before custom.** Discuss standard-vs-custom before adding any custom field.
34
35
 
35
36
  ## Reversibility gate
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * platform-reuse-reminder.js — PRE-CREATE nudge to reuse before building in the 2.0
6
+ * platform apps.
7
+ *
8
+ * Why this exists: the platform has ~10 apps sharing one component library
9
+ * (toga25-blox). New pages keep getting fresh components, hooks and styles that already
10
+ * exist in blox or in another app. The "reuse before build" rule
11
+ * (rules/common/architecture-principles.md) is passive; this puts it in context at the
12
+ * moment a new file is about to be created.
13
+ *
14
+ * How it works: registered on PreToolUse for Write|Edit. It fires only when ALL hold:
15
+ * - the target file does NOT exist yet (a new file),
16
+ * - it ends .tsx / .ts / .css (not .test.ts / .d.ts),
17
+ * - it sits under the `src/` of a platform app — a folder whose vite.config.ts calls
18
+ * defineTogaApp — or under the `src/` of the toga25-blox repo itself.
19
+ * Then it adds a short reminder through the PreToolUse additionalContext channel.
20
+ * Once per file path per session (state file in the OS temp dir, keyed on session id).
21
+ *
22
+ * It never blocks. It always exits 0 and fails open on any error.
23
+ *
24
+ * Escape hatch: set TOGA_REUSE_REMINDER_DISABLED=1 to silence the reminder.
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+
31
+ // Single-source platform consolidation rule (lives in toga25-blox).
32
+ const CONSOLIDATION_DOC = 'toga25-blox/docs/consolidation.md';
33
+ const BLOX_INVENTORY = '../toga25-blox/INVENTORY.md';
34
+ const APP_REUSE_DOC = 'docs/architecture/reusable-components.md';
35
+ const BLOX_PACKAGE_NAME = '@toga/blox';
36
+ const BLOX_DIR_NAME = 'toga25-blox';
37
+ const MAX_WALK_UP_LEVELS = 15;
38
+ const MAX_SEEN_PATHS = 500;
39
+
40
+ function readPayload() {
41
+ let raw = '';
42
+ try { raw = fs.readFileSync(0, 'utf8'); } catch (e) { /* no stdin */ }
43
+ let data = {};
44
+ if (raw && raw.trim()) {
45
+ try { data = JSON.parse(raw); } catch (e) { data = {}; }
46
+ }
47
+ if (!data.tool_name && process.env.CLAUDE_TOOL_NAME) data.tool_name = process.env.CLAUDE_TOOL_NAME;
48
+ if (!data.tool_input && process.env.CLAUDE_TOOL_INPUT) {
49
+ try { data.tool_input = JSON.parse(process.env.CLAUDE_TOOL_INPUT); } catch (e) { /* ignore */ }
50
+ }
51
+ if (!data.session_id) data.session_id = process.env.CLAUDE_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID;
52
+ return data;
53
+ }
54
+
55
+ function isWatchedExtension(filePath) {
56
+ const p = filePath.toLowerCase();
57
+ if (p.endsWith('.d.ts') || p.endsWith('.test.ts') || p.endsWith('.test.tsx')) return false;
58
+ return /\.(tsx|ts|css)$/.test(p);
59
+ }
60
+
61
+ function isBloxRoot(dir) {
62
+ if (path.basename(dir).toLowerCase() !== BLOX_DIR_NAME) {
63
+ try {
64
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
65
+ return pkg && pkg.name === BLOX_PACKAGE_NAME;
66
+ } catch (e) { return false; }
67
+ }
68
+ return fs.existsSync(path.join(dir, 'package.json'));
69
+ }
70
+
71
+ function isPlatformAppRoot(dir) {
72
+ try {
73
+ return fs.readFileSync(path.join(dir, 'vite.config.ts'), 'utf8').includes('defineTogaApp');
74
+ } catch (e) { return false; }
75
+ }
76
+
77
+ /* Walk up from the file to the nearest project root. Returns { root, isBlox } when the
78
+ * nearest root is a platform app or blox, else null. The file must be under root/src/. */
79
+ function findPlatformRoot(filePath) {
80
+ let dir = path.dirname(filePath);
81
+ for (let i = 0; i < MAX_WALK_UP_LEVELS; i++) {
82
+ const app = isPlatformAppRoot(dir);
83
+ const blox = !app && isBloxRoot(dir);
84
+ if (app || blox) {
85
+ const rel = path.relative(dir, filePath).replace(/\\/g, '/');
86
+ return rel.startsWith('src/') ? { root: dir, isBlox: blox } : null;
87
+ }
88
+ if (fs.existsSync(path.join(dir, 'package.json'))) return null; // some other project
89
+ const parent = path.dirname(dir);
90
+ if (parent === dir) return null;
91
+ dir = parent;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function sessionKey(data) {
97
+ const sid = (data && data.session_id) || String(process.ppid || process.pid);
98
+ return String(sid).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) || 'x';
99
+ }
100
+
101
+ /* True the first time this path is seen this session; records it. */
102
+ function firstTimeThisSession(data, normPath) {
103
+ const sf = path.join(os.tmpdir(), 'toga-reuse-reminder-' + sessionKey(data) + '.json');
104
+ let seen = [];
105
+ try { seen = JSON.parse(fs.readFileSync(sf, 'utf8')) || []; } catch (e) { seen = []; }
106
+ if (!Array.isArray(seen)) seen = [];
107
+ if (seen.includes(normPath)) return false;
108
+ seen.push(normPath);
109
+ try { fs.writeFileSync(sf, JSON.stringify(seen.slice(-MAX_SEEN_PATHS))); } catch (e) { /* non-fatal */ }
110
+ return true;
111
+ }
112
+
113
+ function reminder(isBlox) {
114
+ const where = isBlox
115
+ ? 'New file in toga25-blox. Before creating it: check INVENTORY.md in this repo'
116
+ : 'New file in a platform app. Before creating it: check `' + BLOX_INVENTORY + '` and this app\'s `' +
117
+ APP_REUSE_DOC + '`';
118
+ return [
119
+ '<system-reminder>',
120
+ where + ' — reuse or extend an existing piece first.',
121
+ 'If this duplicates something in another page or app, consolidate instead',
122
+ '(rule: `' + CONSOLIDATION_DOC + '`).',
123
+ '</system-reminder>',
124
+ ].join('\n');
125
+ }
126
+
127
+ function main() {
128
+ if (process.env.TOGA_REUSE_REMINDER_DISABLED === '1') return;
129
+
130
+ const data = readPayload();
131
+ if (!/^(Write|Edit)$/.test(data.tool_name || '')) return;
132
+
133
+ const input = data.tool_input || {};
134
+ const filePath = typeof input.file_path === 'string' ? input.file_path : '';
135
+ if (!filePath || !isWatchedExtension(filePath)) return;
136
+
137
+ const abs = path.resolve(data.cwd || process.cwd(), filePath);
138
+ if (fs.existsSync(abs)) return; // only new files
139
+
140
+ const hit = findPlatformRoot(abs);
141
+ if (!hit) return;
142
+
143
+ if (!firstTimeThisSession(data, abs.replace(/\\/g, '/').toLowerCase())) return;
144
+
145
+ console.log(JSON.stringify({
146
+ hookSpecificOutput: {
147
+ hookEventName: 'PreToolUse',
148
+ additionalContext: reminder(hit.isBlox),
149
+ },
150
+ }));
151
+ }
152
+
153
+ try { main(); } catch (e) { /* fail-open: never block or brick an edit */ }
154
+ process.exitCode = 0; // always 0; exitCode (not exit()) lets stdout flush on Windows pipes