@lifeaitools/rdc-skills 0.9.32 → 0.9.34

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 (40) hide show
  1. package/.claude-plugin/plugin.json +25 -2
  2. package/MANIFEST.md +4 -0
  3. package/commands/build.md +2 -2
  4. package/commands/fixit.md +14 -2
  5. package/commands/workitems.md +6 -1
  6. package/guides/agent-bootstrap.md +71 -8
  7. package/guides/agents/backend.md +2 -2
  8. package/guides/agents/data.md +1 -1
  9. package/guides/agents/frontend.md +1 -1
  10. package/guides/agents/setup.md +4 -2
  11. package/guides/engineering-behavior.md +43 -0
  12. package/hooks/foreground-process-gate.js +109 -0
  13. package/hooks/hook-logger.js +25 -0
  14. package/hooks/run-hidden-hook.ps1 +47 -0
  15. package/hooks/work-item-exit-gate.js +297 -0
  16. package/package.json +3 -3
  17. package/rules/work-items-rpc.md +56 -7
  18. package/scripts/install-rdc-skills.js +75 -9
  19. package/scripts/install.ps1 +17 -11
  20. package/scripts/self-test.mjs +14 -0
  21. package/skills/build/SKILL.md +13 -9
  22. package/skills/co-develop/SKILL.md +182 -0
  23. package/skills/collab/SKILL.md +1 -1
  24. package/skills/deploy/SKILL.md +1 -1
  25. package/skills/design/SKILL.md +3 -3
  26. package/skills/fixit/SKILL.md +19 -5
  27. package/skills/fs-mcp/SKILL.md +131 -0
  28. package/skills/handoff/SKILL.md +1 -1
  29. package/skills/help/SKILL.md +4 -2
  30. package/skills/overnight/SKILL.md +3 -3
  31. package/skills/plan/SKILL.md +4 -3
  32. package/skills/preplan/SKILL.md +1 -1
  33. package/skills/prototype/SKILL.md +1 -1
  34. package/skills/release/SKILL.md +2 -2
  35. package/skills/report/SKILL.md +1 -1
  36. package/skills/review/SKILL.md +5 -3
  37. package/skills/self-test/SKILL.md +1 -1
  38. package/skills/status/SKILL.md +3 -1
  39. package/skills/watch/SKILL.md +1 -1
  40. package/skills/workitems/SKILL.md +7 -2
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse hook — hard-stop unsafe work item exit transitions.
4
+ *
5
+ * This gate runs before tool calls and blocks legacy or supervisor-mutated
6
+ * CodeFlow/work-item close patterns before they reach Supabase. The database
7
+ * remains the final authority; this hook gives agents an immediate stop sign.
8
+ */
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const hookLog = require('./hook-logger');
15
+
16
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
17
+ const DEFAULT_SUPABASE_URL = 'https://uvojezuorjgqzmhhgluu.supabase.co';
18
+ const EVENT_LOG = path.join(os.homedir(), '.claude', 'work-item-checklist-events.jsonl');
19
+
20
+ function readStdin() {
21
+ return new Promise((resolve) => {
22
+ let input = '';
23
+ process.stdin.setEncoding('utf8');
24
+ process.stdin.on('data', (chunk) => { input += chunk; });
25
+ process.stdin.on('end', () => resolve(input));
26
+ process.stdin.resume();
27
+ });
28
+ }
29
+
30
+ function block(message, details = {}) {
31
+ hookLog('work-item-exit-gate', 'PreToolUse', 'block', details);
32
+ process.stdout.write(JSON.stringify({
33
+ systemMessage: `HARD BLOCK — Work item exit gate rejected this tool call.\n\n${message}`,
34
+ }));
35
+ process.exit(1);
36
+ }
37
+
38
+ function pass(details = {}) {
39
+ hookLog('work-item-exit-gate', 'PreToolUse', 'pass', details);
40
+ process.exit(0);
41
+ }
42
+
43
+ function stringifyTool(toolInput) {
44
+ if (typeof toolInput === 'string') return toolInput;
45
+ try { return JSON.stringify(toolInput); } catch { return ''; }
46
+ }
47
+
48
+ function stripCasts(value) {
49
+ return String(value || '')
50
+ .replace(/::\s*[a-z_][\w.]*/gi, '')
51
+ .trim();
52
+ }
53
+
54
+ function unquote(value) {
55
+ const v = stripCasts(value);
56
+ const m = v.match(/^'(.*)'$/s) || v.match(/^"(.*)"$/s);
57
+ return (m ? m[1] : v).replace(/''/g, "'").trim();
58
+ }
59
+
60
+ function getNamedArg(text, name) {
61
+ const re = new RegExp(`${name}\\s*:?=\\s*('(?:''|[^'])*'|"(?:\\\\"|[^"])*"|true|false|null|[0-9a-f-]{36})`, 'i');
62
+ const m = text.match(re);
63
+ return m ? unquote(m[1]) : null;
64
+ }
65
+
66
+ function splitArgs(argsText) {
67
+ const args = [];
68
+ let current = '';
69
+ let quote = null;
70
+ let depth = 0;
71
+ for (let i = 0; i < argsText.length; i++) {
72
+ const ch = argsText[i];
73
+ const next = argsText[i + 1];
74
+ if (quote) {
75
+ current += ch;
76
+ if (ch === quote && !(quote === "'" && next === "'")) quote = null;
77
+ else if (ch === "'" && next === "'") current += argsText[++i];
78
+ continue;
79
+ }
80
+ if (ch === "'" || ch === '"') {
81
+ quote = ch;
82
+ current += ch;
83
+ continue;
84
+ }
85
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
86
+ if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
87
+ if (ch === ',' && depth === 0) {
88
+ args.push(current.trim());
89
+ current = '';
90
+ continue;
91
+ }
92
+ current += ch;
93
+ }
94
+ if (current.trim()) args.push(current.trim());
95
+ return args;
96
+ }
97
+
98
+ function findCall(text, name) {
99
+ const idx = text.toLowerCase().indexOf(name.toLowerCase());
100
+ if (idx < 0) return null;
101
+ const open = text.indexOf('(', idx + name.length);
102
+ if (open < 0) return null;
103
+ let quote = null;
104
+ let depth = 0;
105
+ for (let i = open; i < text.length; i++) {
106
+ const ch = text[i];
107
+ const next = text[i + 1];
108
+ if (quote) {
109
+ if (ch === quote && !(quote === "'" && next === "'")) quote = null;
110
+ else if (ch === "'" && next === "'") i++;
111
+ continue;
112
+ }
113
+ if (ch === "'" || ch === '"') {
114
+ quote = ch;
115
+ continue;
116
+ }
117
+ if (ch === '(') depth++;
118
+ if (ch === ')') {
119
+ depth--;
120
+ if (depth === 0) return text.slice(open + 1, i);
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ function objectArg(blob, name) {
127
+ const re = new RegExp(`"${name}"\\s*:\\s*("([^"]*)"|true|false|null)`, 'i');
128
+ const m = blob.match(re);
129
+ if (!m) return null;
130
+ if (m[1] === 'true' || m[1] === 'false' || m[1] === 'null') return m[1];
131
+ return m[2] || null;
132
+ }
133
+
134
+ function extractStatusCall(blob) {
135
+ if (!/update_work_item_status/i.test(blob)) return null;
136
+ const call = findCall(blob, 'update_work_item_status');
137
+ const args = call ? splitArgs(call) : [];
138
+ return {
139
+ id: objectArg(blob, 'p_id') || getNamedArg(blob, 'p_id') || unquote(args[0] || '').match(UUID_RE)?.[0] || blob.match(UUID_RE)?.[0] || null,
140
+ status: objectArg(blob, 'p_status') || getNamedArg(blob, 'p_status') || unquote(args[1] || ''),
141
+ actorSessionId: objectArg(blob, 'p_actor_session_id') || getNamedArg(blob, 'p_actor_session_id') || unquote(args[3] || ''),
142
+ actorRole: objectArg(blob, 'p_actor_role') || getNamedArg(blob, 'p_actor_role') || unquote(args[4] || ''),
143
+ };
144
+ }
145
+
146
+ function extractTickCall(blob) {
147
+ if (!/update_checklist_item/i.test(blob)) return null;
148
+ const call = findCall(blob, 'update_checklist_item');
149
+ const args = call ? splitArgs(call) : [];
150
+ return {
151
+ workItemId: objectArg(blob, 'p_work_item_id') || getNamedArg(blob, 'p_work_item_id') || unquote(args[0] || '').match(UUID_RE)?.[0] || blob.match(UUID_RE)?.[0] || null,
152
+ itemId: objectArg(blob, 'p_item_id') || getNamedArg(blob, 'p_item_id') || unquote(args[1] || ''),
153
+ checked: (objectArg(blob, 'p_checked') || getNamedArg(blob, 'p_checked') || unquote(args[2] || '')).toLowerCase() === 'true',
154
+ actorSessionId: objectArg(blob, 'p_actor_session_id') || getNamedArg(blob, 'p_actor_session_id') || unquote(args[3] || ''),
155
+ actorRole: objectArg(blob, 'p_actor_role') || getNamedArg(blob, 'p_actor_role') || unquote(args[4] || ''),
156
+ };
157
+ }
158
+
159
+ function logTick(tick, rawTool) {
160
+ try {
161
+ fs.mkdirSync(path.dirname(EVENT_LOG), { recursive: true });
162
+ fs.appendFileSync(EVENT_LOG, JSON.stringify({
163
+ ts: new Date().toISOString(),
164
+ work_item_id: tick.workItemId || null,
165
+ item_id: tick.itemId || null,
166
+ checked: tick.checked,
167
+ actor_session_id: tick.actorSessionId || null,
168
+ actor_role: tick.actorRole || null,
169
+ tool_name: rawTool.tool_name || null,
170
+ }) + '\n');
171
+ } catch (_) {}
172
+ }
173
+
174
+ async function getServiceKey() {
175
+ if (process.env.SUPABASE_SERVICE_ROLE_KEY) return process.env.SUPABASE_SERVICE_ROLE_KEY;
176
+ if (process.env.SUPABASE_SERVICE_KEY) return process.env.SUPABASE_SERVICE_KEY;
177
+ for (const endpoint of ['supabase-service', 'supabase-service-role']) {
178
+ try {
179
+ const res = await fetch(`http://127.0.0.1:52437/v/${endpoint}`, { signal: AbortSignal.timeout(2000) });
180
+ if (res.ok) {
181
+ const text = (await res.text()).trim();
182
+ if (text && !text.startsWith('{')) return text;
183
+ }
184
+ } catch (_) {}
185
+ }
186
+ return null;
187
+ }
188
+
189
+ async function supabaseGet(pathname) {
190
+ const key = await getServiceKey();
191
+ if (!key) throw new Error('clauth/env did not provide a Supabase service key');
192
+ const base = (process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL).replace(/\/$/, '');
193
+ const res = await fetch(`${base}/rest/v1/${pathname}`, {
194
+ headers: {
195
+ apikey: key,
196
+ Authorization: `Bearer ${key}`,
197
+ Accept: 'application/json',
198
+ },
199
+ signal: AbortSignal.timeout(3500),
200
+ });
201
+ if (!res.ok) throw new Error(`Supabase verification HTTP ${res.status}`);
202
+ return res.json();
203
+ }
204
+
205
+ function requiredItems(checklist) {
206
+ return Array.isArray(checklist)
207
+ ? checklist.filter((item) => item && item.required === true)
208
+ : [];
209
+ }
210
+
211
+ async function verifyDone(statusCall, blob) {
212
+ if (!statusCall.id) block('`update_work_item_status(..., done)` must include a work item UUID.', statusCall);
213
+ if (!statusCall.actorSessionId || !statusCall.actorRole) {
214
+ block('`done` requires the 5-argument RPC shape with `p_actor_session_id` and `p_actor_role`.', statusCall);
215
+ }
216
+ if (statusCall.actorRole !== 'validator') {
217
+ block('Only a validator may transition a non-epic work item to `done`; implementation agents move work to `review`.', statusCall);
218
+ }
219
+ if (/submit_implementation_report/i.test(blob)) {
220
+ block('Submit the implementation report in a separate tool call before `done`; the gate verifies committed DB state.', statusCall);
221
+ }
222
+
223
+ let rows;
224
+ let events;
225
+ try {
226
+ rows = await supabaseGet(`work_items?id=eq.${encodeURIComponent(statusCall.id)}&select=id,status,item_type,implementation_report,checklist`);
227
+ events = await supabaseGet(`work_item_checklist_events?work_item_id=eq.${encodeURIComponent(statusCall.id)}&select=item_id,checked,actor_session_id,actor_role,created_at&order=created_at.desc&limit=200`);
228
+ } catch (e) {
229
+ block(`Cannot live-verify work item exit gate: ${e.message}. Do not close the item until clauth/Supabase verification is available.`, statusCall);
230
+ }
231
+
232
+ const item = rows && rows[0];
233
+ if (!item) block(`Work item ${statusCall.id} was not found.`, statusCall);
234
+ if (item.item_type === 'epic') return;
235
+ if (item.status !== 'review') block('Non-epic work items must be in `review` before a validator may mark them `done`.', { ...statusCall, currentStatus: item.status });
236
+
237
+ const report = item.implementation_report;
238
+ if (!report || typeof report !== 'object') block('`done` rejected because `implementation_report` is null or not an object.', statusCall);
239
+ if (!report.codeflow_post || typeof report.codeflow_post !== 'object') block('`done` rejected because `implementation_report.codeflow_post` is missing.', statusCall);
240
+
241
+ const missing = requiredItems(item.checklist).filter((ci) => ci.checked !== true);
242
+ if (missing.length > 0) {
243
+ block(`Required checklist items are unchecked: ${missing.map((ci) => ci.id || ci.text || '<unknown>').join(', ')}`, statusCall);
244
+ }
245
+
246
+ const latestByItem = new Map();
247
+ for (const event of Array.isArray(events) ? events : []) {
248
+ if (!latestByItem.has(event.item_id)) latestByItem.set(event.item_id, event);
249
+ }
250
+ const supervisorReticks = requiredItems(item.checklist)
251
+ .map((ci) => latestByItem.get(ci.id))
252
+ .filter((event) => event && event.checked === true && event.actor_role !== 'agent');
253
+ if (supervisorReticks.length > 0) {
254
+ block(
255
+ `Required checklist items were last ticked by a non-agent session: ${supervisorReticks.map((e) => e.item_id).join(', ')}`,
256
+ { ...statusCall, supervisorReticks: supervisorReticks.map((e) => e.item_id) },
257
+ );
258
+ }
259
+ }
260
+
261
+ function validateTick(tick, rawTool) {
262
+ logTick(tick, rawTool);
263
+ if (!tick.checked) return;
264
+ if (!tick.workItemId || !tick.itemId) {
265
+ block('Checklist ticks must include `p_work_item_id` and `p_item_id`.', tick);
266
+ }
267
+ if (!tick.actorSessionId) {
268
+ block('Checklist ticks must include `p_actor_session_id`; unaudited ticks are rejected.', tick);
269
+ }
270
+ if (tick.actorRole !== 'agent') {
271
+ block('Only the originating implementation agent may tick required checklist evidence. Supervisors/validators reopen or review; they do not re-tick.', tick);
272
+ }
273
+ }
274
+
275
+ async function main() {
276
+ let raw;
277
+ try { raw = JSON.parse(await readStdin()); } catch { process.exit(0); }
278
+
279
+ const blob = stringifyTool(raw.tool_input || raw);
280
+ const tick = extractTickCall(blob);
281
+ if (tick) validateTick(tick, raw);
282
+
283
+ const statusCall = extractStatusCall(blob);
284
+ if (!statusCall) pass({ reason: 'no-status-call' });
285
+
286
+ const status = String(statusCall.status || '').toLowerCase();
287
+ if (status === 'review' && (!statusCall.actorSessionId || statusCall.actorRole !== 'agent')) {
288
+ block('Implementation agents must move completed work to `review` with `p_actor_session_id` and `p_actor_role := agent`.', statusCall);
289
+ }
290
+ if (status === 'done') {
291
+ await verifyDone(statusCall, blob);
292
+ }
293
+
294
+ pass({ status });
295
+ }
296
+
297
+ main().catch((e) => block(`Exit gate crashed: ${e.message}`));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.9.32",
4
- "description": "RDC typed-agent dispatch skill suite for Claude Code — plan, build, review, overnight builds",
3
+ "version": "0.9.34",
4
+ "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "claude-code-plugin",
@@ -19,7 +19,7 @@
19
19
  "type": "plugin",
20
20
  "skills": "skills/",
21
21
  "guides": "guides/",
22
- "version": "0.9.26",
22
+ "version": "0.9.34",
23
23
  "commands": "commands/"
24
24
  },
25
25
  "scripts": {
@@ -135,6 +135,10 @@ const { data } = await supabase.rpc('insert_work_item', {
135
135
 
136
136
  Transition a work item's status. Auto-manages `completed_at`.
137
137
 
138
+ **Hard gate:** implementation agents do not set non-epic work directly to `done`.
139
+ They submit a report and move the item to `review`. A validator closes it as
140
+ `done` only after fresh verification.
141
+
138
142
  **Supabase SQL:**
139
143
  ```sql
140
144
  SELECT update_work_item_status(
@@ -142,10 +146,20 @@ SELECT update_work_item_status(
142
146
  'in_progress'
143
147
  );
144
148
 
149
+ SELECT update_work_item_status(
150
+ 'work-item-uuid'::uuid,
151
+ 'review',
152
+ '["Implementation complete; ready for validator"]'::jsonb,
153
+ 'agent-session-id',
154
+ 'agent'
155
+ );
156
+
145
157
  SELECT update_work_item_status(
146
158
  'work-item-uuid'::uuid,
147
159
  'done',
148
- '["Completed by agent session xyz"]'::jsonb
160
+ '["Validator verified implementation report, CodeFlow post, and checklist evidence"]'::jsonb,
161
+ 'validator-session-id',
162
+ 'validator'
149
163
  );
150
164
  ```
151
165
 
@@ -156,10 +170,20 @@ const { data } = await supabase.rpc('update_work_item_status', {
156
170
  p_status: 'in_progress'
157
171
  });
158
172
 
173
+ const { data } = await supabase.rpc('update_work_item_status', {
174
+ p_id: itemId,
175
+ p_status: 'review',
176
+ p_notes_append: ['Implementation complete; ready for validator'],
177
+ p_actor_session_id: agentSessionId,
178
+ p_actor_role: 'agent'
179
+ });
180
+
159
181
  const { data } = await supabase.rpc('update_work_item_status', {
160
182
  p_id: itemId,
161
183
  p_status: 'done',
162
- p_notes_append: ['Completed by agent session xyz']
184
+ p_notes_append: ['Validator verified implementation report, CodeFlow post, and checklist evidence'],
185
+ p_actor_session_id: validatorSessionId,
186
+ p_actor_role: 'validator'
163
187
  });
164
188
  ```
165
189
 
@@ -170,10 +194,13 @@ const { data } = await supabase.rpc('update_work_item_status', {
170
194
  | `p_id` | uuid | yes | work_item ID |
171
195
  | `p_status` | text | yes | One of: `todo`, `in_progress`, `blocked`, `review`, `done`, `archived` |
172
196
  | `p_notes_append` | jsonb | no | Array of strings appended to existing notes |
197
+ | `p_actor_session_id` | text | review/done | Current agent or validator session ID |
198
+ | `p_actor_role` | text | review/done | `agent` for `review`, `validator` for `done` |
173
199
 
174
200
  **Behavior:**
175
201
  - Setting `done` auto-sets `completed_at = now()`
176
- - Setting `done` raises EXCEPTION if any `required: true` checklist item has `checked: false` — DoD gate
202
+ - Setting non-epic work to `review` raises EXCEPTION unless `implementation_report.codeflow_post` exists and caller role is `agent`
203
+ - Setting non-epic work to `done` raises EXCEPTION unless current status is `review`, caller role is `validator`, `implementation_report.codeflow_post` exists, every required checklist item is checked, and required checklist evidence was ticked by the originating agent session
177
204
  - Setting `todo`, `in_progress`, `blocked`, or `review` clears `completed_at`
178
205
  - `updated_at` always refreshed to current timestamp
179
206
  - Raises exception if ID not found
@@ -190,7 +217,10 @@ const { data } = await supabase.rpc('update_work_item_status', {
190
217
  SELECT update_checklist_item(
191
218
  'work-item-uuid'::uuid,
192
219
  'tsc-clean', -- string id of the checklist item
193
- true -- checked state
220
+ true, -- checked state
221
+ 'agent-session-id',
222
+ 'agent',
223
+ 'rpc'
194
224
  );
195
225
  ```
196
226
 
@@ -201,8 +231,13 @@ SELECT update_checklist_item(
201
231
  | `p_work_item_id` | uuid | yes | the work item ID |
202
232
  | `p_item_id` | text | yes | the `id` field of the checklist item |
203
233
  | `p_checked` | boolean | yes | `true` = checked, `false` = unchecked |
234
+ | `p_actor_session_id` | text | yes for checked=true | originating implementation agent session ID |
235
+ | `p_actor_role` | text | yes for checked=true | must be `agent` for completion evidence ticks |
236
+ | `p_event_source` | text | no | source label, default `rpc` |
204
237
 
205
238
  Call this as each checklist item is completed — not all at once at the end.
239
+ The tick is written to `work_item_checklist_events`; supervisor or validator
240
+ re-ticks are rejected by the exit gate.
206
241
 
207
242
  **Returns:** updated checklist JSONB array.
208
243
 
@@ -210,7 +245,7 @@ Call this as each checklist item is completed — not all at once at the end.
210
245
 
211
246
  ### 5. submit_implementation_report — File agent completion narrative
212
247
 
213
- MUST be called BEFORE `update_work_item_status('done')`.
248
+ MUST be called BEFORE `update_work_item_status(..., 'review', ...)`.
214
249
 
215
250
  **Supabase SQL:**
216
251
  ```sql
@@ -222,7 +257,14 @@ SELECT submit_implementation_report(
222
257
  "deviations": [],
223
258
  "uncertainty": [],
224
259
  "detail": "Full narrative",
225
- "flags": []
260
+ "flags": [],
261
+ "codeflow_post": {
262
+ "agent_session_id": "agent-session-id",
263
+ "summary": "What changed and why",
264
+ "files_changed": ["path/to/file"],
265
+ "verification": ["command or evidence"],
266
+ "commit": "optional commit hash"
267
+ }
226
268
  }'::jsonb
227
269
  );
228
270
  ```
@@ -244,7 +286,14 @@ SELECT submit_implementation_report(
244
286
  "deviations": ["string — deviation from plan", "..."],
245
287
  "uncertainty": ["string — anything unclear", "..."],
246
288
  "detail": "string — full narrative",
247
- "flags": ["string — supervisor attention needed", "..."]
289
+ "flags": ["string — supervisor attention needed", "..."],
290
+ "codeflow_post": {
291
+ "agent_session_id": "string — originating implementation session",
292
+ "summary": "string — agent-authored CodeFlow post",
293
+ "files_changed": ["string", "..."],
294
+ "verification": ["string", "..."],
295
+ "commit": "string — optional"
296
+ }
248
297
  }
249
298
  ```
250
299
 
@@ -77,6 +77,18 @@ function copyDir(src, dst, ext) {
77
77
  return count;
78
78
  }
79
79
 
80
+ function copyHookFiles(src, dst) {
81
+ if (!fs.existsSync(src)) { warn(`Source not found: ${src}`); return 0; }
82
+ fs.mkdirSync(dst, { recursive: true });
83
+ const files = fs.readdirSync(src).filter(f => /\.(?:js|ps1)$/i.test(f));
84
+ let count = 0;
85
+ for (const f of files) {
86
+ const s = path.join(src, f);
87
+ if (fs.statSync(s).isFile()) { fs.copyFileSync(s, path.join(dst, f)); count++; }
88
+ }
89
+ return count;
90
+ }
91
+
80
92
  function copyDirRecursive(src, dst) {
81
93
  if (!fs.existsSync(src)) return;
82
94
  fs.mkdirSync(dst, { recursive: true });
@@ -88,6 +100,22 @@ function copyDirRecursive(src, dst) {
88
100
  }
89
101
  }
90
102
 
103
+ function copyMissingProjectGuides(projectRoot) {
104
+ if (!projectRoot) return 0;
105
+ const src = path.join(repoRoot, 'guides');
106
+ const dst = path.join(projectRoot, '.rdc', 'guides');
107
+ if (!fs.existsSync(src) || !fs.existsSync(dst)) return 0;
108
+ let copied = 0;
109
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
110
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
111
+ const target = path.join(dst, entry.name);
112
+ if (fs.existsSync(target)) continue;
113
+ fs.copyFileSync(path.join(src, entry.name), target);
114
+ copied++;
115
+ }
116
+ return copied;
117
+ }
118
+
91
119
  function readJson(p, fallback = {}) {
92
120
  if (!fs.existsSync(p)) return fallback;
93
121
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; }
@@ -214,6 +242,29 @@ function cleanStaleHooks(hooksDstDir) {
214
242
  return removed;
215
243
  }
216
244
 
245
+ // Project-local hookify docs were a temporary workaround for work item exit
246
+ // enforcement. The plugin hook is now authoritative, so installs clean those
247
+ // local shims from known RDC workspaces instead of leaving two gates to drift.
248
+ function cleanProjectHookifyShims(projectRoot) {
249
+ if (!projectRoot) return 0;
250
+ const hookifyDir = path.join(projectRoot, '.claude');
251
+ if (!fs.existsSync(hookifyDir)) return 0;
252
+ const stale = [
253
+ 'hookify.work-item-done-gate-bash.local.md',
254
+ 'hookify.work-item-done-gate-mcp.local.md',
255
+ 'hookify.work-item-review-gate-bash.local.md',
256
+ 'hookify.work-item-review-gate-mcp.local.md',
257
+ ];
258
+ let removed = 0;
259
+ for (const f of stale) {
260
+ const p = path.join(hookifyDir, f);
261
+ if (fs.existsSync(p)) {
262
+ try { fs.unlinkSync(p); removed++; } catch {}
263
+ }
264
+ }
265
+ return removed;
266
+ }
267
+
217
268
  // ── Cache flush helper ────────────────────────────────────────────────────────
218
269
  // Only `latest/` is ever kept. Earlier versions wrote BOTH `<version>/` and
219
270
  // `latest/`, which caused the plugin loader to scan and register every rdc:*
@@ -479,7 +530,7 @@ function buildZip(version) {
479
530
  ? 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
480
531
  : 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
481
532
  execSync(
482
- `"${psExe}" -NoProfile -Command "Compress-Archive -Path '${tmp}\\*' -DestinationPath '${zipPath}' -Force"`,
533
+ `"${psExe}" -NoProfile -NonInteractive -WindowStyle Hidden -Command "Compress-Archive -Path '${tmp}\\*' -DestinationPath '${zipPath}' -Force"`,
483
534
  { stdio: 'pipe' }
484
535
  );
485
536
  fs.rmSync(tmp, { recursive: true, force: true });
@@ -498,7 +549,10 @@ function buildZip(version) {
498
549
  function buildHooksConfig(hooksDir) {
499
550
  const base = hooksDir.replace(/\\/g, '/');
500
551
  const cmd = (file, msg) => {
501
- const entry = { type: 'command', command: `node "${base}/${file}"` };
552
+ const command = process.platform === 'win32'
553
+ ? `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${base}/run-hidden-hook.ps1" "${base}/${file}"`
554
+ : `node "${base}/${file}"`;
555
+ const entry = { type: 'command', command };
502
556
  if (msg) entry.statusMessage = msg;
503
557
  return entry;
504
558
  };
@@ -507,9 +561,15 @@ function buildHooksConfig(hooksDir) {
507
561
  cmd('check-cwd.js'),
508
562
  cmd('check-stale-work-items.js', 'Checking for stale work items...'),
509
563
  ]}],
510
- PreToolUse: [{ matcher: 'Bash', hooks: [
511
- cmd('require-work-item-on-commit.js'),
512
- ]}],
564
+ PreToolUse: [
565
+ { hooks: [
566
+ cmd('foreground-process-gate.js', 'Checking foreground process policy...'),
567
+ cmd('work-item-exit-gate.js', 'Checking work item exit gates...'),
568
+ ]},
569
+ { matcher: 'Bash', hooks: [
570
+ cmd('require-work-item-on-commit.js'),
571
+ ]},
572
+ ],
513
573
  PostToolUse: [{ hooks: [
514
574
  cmd('check-services.js'),
515
575
  ]}],
@@ -654,6 +714,8 @@ async function main() {
654
714
  {
655
715
  const staleRemoved = cleanStaleHooks(hooksDst);
656
716
  if (staleRemoved > 0) ok(`[0.5b] Hook cleanup — removed ${staleRemoved} orphaned hook file(s)`);
717
+ const shimRemoved = cleanProjectHookifyShims(codexRoot);
718
+ if (shimRemoved > 0) ok(`[0.5b] Hook cleanup — removed ${shimRemoved} project-local work-item hookify shim(s)`);
657
719
  }
658
720
 
659
721
  // 0.5. Legacy cleanup — remove old commands/rdc/ (pre-plugin-system format)
@@ -722,9 +784,7 @@ async function main() {
722
784
  } catch {}
723
785
  try {
724
786
  if (process.platform === 'win32') {
725
- const winLink = linkPath.replace(/\//g, '\\');
726
- const winTarget = target.replace(/\//g, '\\');
727
- execSync(`cmd /c mklink /J "${winLink}" "${winTarget}"`, { stdio: 'pipe' });
787
+ fs.symlinkSync(target, linkPath, 'junction');
728
788
  } else {
729
789
  fs.symlinkSync(target, linkPath, 'dir');
730
790
  }
@@ -736,12 +796,18 @@ async function main() {
736
796
  } else {
737
797
  info('[2.7] Symlinks — no rdc skill links created (skills may already be linked)');
738
798
  }
799
+ const projectGuideCount = copyMissingProjectGuides(codexRoot);
800
+ if (projectGuideCount > 0) {
801
+ ok(`[2.8] Guides — ${projectGuideCount} missing guide(s) copied to ${path.join(codexRoot, '.rdc', 'guides')}`);
802
+ } else {
803
+ info('[2.8] Guides — project .rdc/guides already has base guide files or is absent');
804
+ }
739
805
  } else {
740
806
  info('[2.7] Symlinks — skipped (no codex root found)');
741
807
  }
742
808
 
743
809
  // 3. Hook files
744
- const hookCount = copyDir(hooksSrc, hooksDst, '.js');
810
+ const hookCount = copyHookFiles(hooksSrc, hooksDst);
745
811
  ok(`[3/6] Hook files — ${hookCount} file(s) → ${hooksDst}`);
746
812
 
747
813
  // 4. Hook wiring
@@ -58,7 +58,7 @@ if (-not (Test-Path $hooksDir)) {
58
58
  }
59
59
 
60
60
  $srcHooks = Join-Path $repoRoot "hooks"
61
- $hookFiles = Get-ChildItem -Path $srcHooks -Filter "*.js" -ErrorAction SilentlyContinue
61
+ $hookFiles = Get-ChildItem -Path $srcHooks -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in @(".js", ".ps1") }
62
62
  foreach ($f in $hookFiles) {
63
63
  Copy-Item -Path $f.FullName -Destination (Join-Path $hooksDir $f.Name) -Force
64
64
  }
@@ -85,47 +85,53 @@ if ($SkipHooks) {
85
85
  SessionStart = @(
86
86
  [PSCustomObject]@{
87
87
  hooks = @(
88
- [PSCustomObject]@{ type = "command"; command = "node `"$hooksBase/check-cwd.js`"" },
89
- [PSCustomObject]@{ type = "command"; command = "node `"$hooksBase/check-stale-work-items.js`""; statusMessage = "Checking for stale work items..." }
88
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/check-cwd.js`"" },
89
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/check-stale-work-items.js`""; statusMessage = "Checking for stale work items..." }
90
90
  )
91
91
  }
92
92
  )
93
93
  PreToolUse = @(
94
+ [PSCustomObject]@{
95
+ hooks = @(
96
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/foreground-process-gate.js`""; statusMessage = "Checking foreground process policy..." },
97
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/work-item-exit-gate.js`""; statusMessage = "Checking work item exit gates..." }
98
+ )
99
+ },
94
100
  [PSCustomObject]@{
95
101
  matcher = "Bash"
96
102
  hooks = @(
97
- [PSCustomObject]@{ type = "command"; command = "node `"$hooksBase/require-work-item-on-commit.js`"" }
103
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/require-work-item-on-commit.js`"" }
98
104
  )
99
105
  }
100
106
  )
101
107
  PostToolUse = @(
102
108
  [PSCustomObject]@{
103
109
  hooks = @(
104
- [PSCustomObject]@{ type = "command"; command = "node `"$hooksBase/check-services.js`"" }
110
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/check-services.js`"" }
105
111
  )
106
112
  }
107
113
  )
108
114
  PreCompact = @(
109
115
  [PSCustomObject]@{
110
116
  hooks = @(
111
- [PSCustomObject]@{ type = "command"; command = "node `"$hooksBase/precompact-log.js`"" }
117
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/precompact-log.js`"" }
112
118
  )
113
119
  }
114
120
  )
115
121
  PostCompact = @(
116
122
  [PSCustomObject]@{
117
123
  hooks = @(
118
- [PSCustomObject]@{ type = "command"; command = "node `"$hooksBase/postcompact-log.js`"" },
119
- [PSCustomObject]@{ type = "command"; command = "node `"$hooksBase/restart-brief.js`""; statusMessage = "Writing restart brief..." }
124
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/postcompact-log.js`"" },
125
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/restart-brief.js`""; statusMessage = "Writing restart brief..." }
120
126
  )
121
127
  }
122
128
  )
123
129
  Stop = @(
124
130
  [PSCustomObject]@{
125
131
  hooks = @(
126
- [PSCustomObject]@{ type = "command"; command = "node `"$hooksBase/rate-limit-retry.js`""; statusMessage = "Checking for rate limits..." },
127
- [PSCustomObject]@{ type = "command"; command = "node `"$hooksBase/post-work-check.js`""; statusMessage = "Checking for undocumented work..." },
128
- [PSCustomObject]@{ type = "command"; command = "node `"$hooksBase/no-stop-open-epics.js`""; statusMessage = "Checking for open epics..." }
132
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/rate-limit-retry.js`""; statusMessage = "Checking for rate limits..." },
133
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/post-work-check.js`""; statusMessage = "Checking for undocumented work..." },
134
+ [PSCustomObject]@{ type = "command"; command = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$hooksBase/run-hidden-hook.ps1`" `"$hooksBase/no-stop-open-epics.js`""; statusMessage = "Checking for open epics..." }
129
135
  )
130
136
  }
131
137
  )