@lifeaitools/rdc-skills 0.30.1 → 0.31.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdc",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code — plan, build, review, overnight unattended builds with work-item tracking and TDD enforcement.",
5
5
  "author": {
6
6
  "name": "LIFEAI",
@@ -0,0 +1,24 @@
1
+ ---
2
+ id: 2026-08-17-fixit-codex-mcp-only-skills
3
+ date: "2026-08-17"
4
+ skill: fixit
5
+ session: codex-mcp-startup-20260817
6
+ scope: simple
7
+ status: applied
8
+ area: skill
9
+ links:
10
+ commits:
11
+ - 263231a
12
+ memory: []
13
+ work_items:
14
+ - dc076f35-236b-4f8d-be3f-646a66514ba5
15
+ ---
16
+
17
+ ## What happened
18
+ Codex's real numbered-lane startup completed MCP initialization but emitted a red skill-budget error on every launch. The machine had 36 RDC skill directories in each of the global `.codex/skills` and `.agents/skills` roots while the same 36-skill catalog was already registered through the RDC-Skills MCP endpoint.
19
+
20
+ ## Root cause
21
+ `scripts/install-rdc-skills.js` registered the MCP endpoint and then copied the full RDC catalog into every detected Codex skill directory. Codex loaded those duplicate surfaces together, inflating startup metadata to 113 skills. The same install also left a legacy RDC marketplace block that Codex rejected as an unsupported plugin source.
22
+
23
+ ## The fix / rule
24
+ Codex receives RDC skills exclusively through MCP. The installer now verifies the MCP config before purging legacy file copies, fails closed if the replacement cannot be established, removes the obsolete marketplace/plugin blocks with CRLF coverage, and verifies that live Codex skill roots contain zero RDC duplicates. Implemented in commit `263231a`.
package/README.md CHANGED
@@ -209,6 +209,14 @@ Located in `guides/agents/` — plain markdown playbooks spawned as sub-agents:
209
209
  ### Reporting
210
210
  - rdc:help, rdc:housekeeping, rdc:report, rdc:status, rdc:watch
211
211
 
212
+ ### Operating mode
213
+ - rdc:mode — show or set the operating mode. `HotFix` relaxes ceremony gates
214
+ (full-build, polling-loops, persistent-cd, truth-gate, completion-gate, …) for
215
+ a bounded, reasoned window. It **cannot** relax a safety guard: push-main,
216
+ push-force, rm-rf, credential exposure, cross-lane writes and the authority
217
+ check stay armed in every mode, enforced as an allowlist so a guard added
218
+ tomorrow is essential by default.
219
+
212
220
  ### Tooling
213
221
  - rdc:channel-formatter — channel-native formatting and content repurposing for LinkedIn, Twitter/X, Slack/Teams, email, decks, Word/PDF structure, web copy, and content packs
214
222
  - rdc:brochure — HTML/folder/zip/URL/Markdown to print-quality PDF
package/commands/build.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:build
2
+ name: build
3
3
  description: >-
4
4
  Usage `rdc:build <epic-id|topic> [--unattended]` — dispatch typed agents from an epic, commit, push, update work items. The build engine. Use after rdc:plan or when the project lead says "build it".
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:collab
2
+ name: collab
3
3
  description: >-
4
4
  Usage `rdc:collab --session <id>` — bidirectional relay with claude.ai. Read inbox, do work, write outbox, loop. Dave watches terminal and can interject.
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:deploy
2
+ name: deploy
3
3
  description: >-
4
4
  Coolify ops. Usage `rdc:deploy <slug> [build-id]`, `rdc:deploy new <slug>`, `rdc:deploy maintenance <service>`, `rdc:deploy diagnose <slug>`, or `rdc:deploy audit [--fix]` — typed checklists, private-service controls, DNS decision tree, and mandatory post-deploy gates. Checklist-only output.
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:design
2
+ name: design
3
3
  description: >-
4
4
  Usage `rdc:design <command|brief>` — RDC-owned design skill for Studio, Palette Library, token-aware UI work, and Rampa CLI-assisted color systems.
5
5
  ---
package/commands/edit.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:edit
2
+ name: edit
3
3
  description: >-
4
4
  Usage `rdc:edit <site|brand|route|file>` — open a target in the local website editor host on port 3015. Resolves the site, launches or reuses the editor, and opens the target URL when available.
5
5
  ---
package/commands/fixit.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:fixit
2
+ name: fixit
3
3
  description: >-
4
4
  Usage `rdc:fixit <description>` — sanctioned bypass for quick fixes under 5 files / 30 min. Creates minimal work item, makes fix, commits, DELIVERS it to where it is consumed (publish/deploy/land) and verifies, then closes. The ONLY alternative to rdc:build. For typos, config patches, hotfixes, dep bumps.
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:handoff
2
+ name: handoff
3
3
  description: >-
4
4
  Usage `rdc:handoff <topic>` — structured handoff from planning session to CLI agents. Produces plan doc in .rdc/plans/, work items in DB, prototype registry entry if applicable. Use when plan/prototype is finalized and ready for implementation.
5
5
  ---
package/commands/help.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:help
2
+ name: help
3
3
  description: >-
4
4
  Usage `rdc:help` or `rdc` — manifest-driven menu of all MCP skills with
5
5
  argument syntax, requirements, decision hints, and direct MCP/curl access.
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: mode
3
+ description: >-
4
+ Usage `rdc:mode [status|hotfix "<reason>" [minutes]|normal]` — show or set the operating mode. HotFix relaxes ceremony gates for a bounded window; it never relaxes a safety guard.
5
+ ---
6
+
7
+ > **⚠️ OUTPUT CONTRACT (READ FIRST):** `guides/output-contract.md`
8
+ > Checklist-only output. No tool-call narration. No raw MCP/JSON/log dumps.
9
+ > One checklist upfront, updated in place, shown again at end with a 1-line verdict.
10
+
11
+ # rdc:mode — operating mode
12
+
13
+ ## When to Use
14
+ - A production incident where process ceremony is costing minutes that matter
15
+ - Checking whether a permissive window is currently open (and when it closes)
16
+ - Closing one early, the moment the incident is over
17
+
18
+ ## The one thing to understand first
19
+
20
+ **HotFix relaxes CEREMONY. It cannot relax SAFETY.**
21
+
22
+ That is enforced in code as an allowlist, not a promise in a doc: only the eight
23
+ ceremony rules named in `$LIFEAI_ENV/hooks/lib/rdc-mode.mjs` (`HOTFIX_DISABLES`)
24
+ can ever be skipped, and the filtering happens at one place —
25
+ `ALL_RULES()` in `guard-rules.mjs` — which all four of `evaluateGuard`'s passes
26
+ iterate. A guard added tomorrow is **essential by default**.
27
+
28
+ | relaxed in HotFix | never relaxed, in any mode |
29
+ |---|---|
30
+ | `full-build` · `polling-loops` · `persistent-cd` · `powershell-5.1` · `build-shortcircuit` · `codex-work-item-wrapper` · `codex-supabase-rpc-wrapper` · `codex-ssh-preflight` | `push-main` · `push-force` · `no-verify` · `rm-rf-danger` · `remove-item-danger` · `reset-hard-pool` · `clauth-exposure` · `ssh-key-raw-fetch` · `cross-tree-bash` · `lane-push-develop` · `git-add-all-standalone` · `coolify-direct` · `execution-scope` · `env-repo-maintainer-only` · `settings-json-direct-edit` · `node-modules-write` · `neo4j-outside-codeflow` · `resurrect-deleted-path` · `onramp-*` · `terminal-window-mutation` |
31
+
32
+ Everything in the left column costs **time or tidiness**, and fails immediately,
33
+ locally and visibly. Everything in the right column is irreversible, destroys
34
+ another session's work, leaks a secret into a transcript forever, or fabricates
35
+ governance evidence.
36
+
37
+ Ceremony **gates** are relaxed alongside the guards: `truth-gate`,
38
+ `completion-gate`, `stop-test-nudge`, `rdc-commit-gate`.
39
+
40
+ ## Why this exists
41
+
42
+ Five unrelated escape hatches had already accumulated — `ALLOW_RESURRECT`,
43
+ `LIFEAI_ENV_MAINTAINER`, `RDC_TEST`, the `RDC-Bypass:` commit trailer, and a
44
+ CodeFlow break-glass file. Each was added when one guard got in the way, each has
45
+ its own spelling, **none expires**, and none is audited with the others. Somebody
46
+ in a hurry cannot find the right one, so they reach for the one they remember —
47
+ usually the biggest.
48
+
49
+ One named state, with a reason and a deadline, is easier to find *and* smaller in
50
+ blast radius than any of them.
51
+
52
+ ## Usage
53
+
54
+ ```
55
+ rdc:mode # status — the default
56
+ rdc:mode status
57
+ rdc:mode hotfix "prod checkout 500s" # open a 60-minute window
58
+ rdc:mode hotfix "prod down" 120 # explicit minutes, capped at 240
59
+ rdc:mode normal # close it early
60
+ ```
61
+
62
+ ## Steps
63
+
64
+ ### 1. Read the current mode
65
+
66
+ ```bash
67
+ node -e "import('file:///C:/Dev/lifeai-env/hooks/lib/rdc-mode.mjs').then(m=>console.log(JSON.stringify(m.currentMode(),null,2)))"
68
+ ```
69
+
70
+ Report `mode`, and when not normal also `reason`, `setBy`, and `minutesLeft`.
71
+ A mode with no minutes left is already normal — the state expires on read, not on
72
+ a timer, so there is nothing to clean up.
73
+
74
+ ### 2. Setting hotfix — a reason is REQUIRED
75
+
76
+ Refuse to proceed without one. `setMode` throws on an empty reason by design: an
77
+ unexplained disarm is how a temporary state becomes permanent, and the reason is
78
+ the entire content of the audit line.
79
+
80
+ ```bash
81
+ node -e "import('file:///C:/Dev/lifeai-env/hooks/lib/rdc-mode.mjs').then(m=>console.log(JSON.stringify(m.setMode('hotfix',{reason:process.argv[1],minutes:Number(process.argv[2]||60),setBy:process.argv[3]}),null,2)))" "<reason>" "<minutes>" "<session-id>"
82
+ ```
83
+
84
+ Then state plainly, in the checklist: **which guards are now off, that the safety
85
+ set is still armed, and the exact wall-clock time the window closes.**
86
+
87
+ ### 3. Returning to normal
88
+
89
+ ```bash
90
+ node -e "import('file:///C:/Dev/lifeai-env/hooks/lib/rdc-mode.mjs').then(m=>{m.clearMode();console.log('normal')})"
91
+ ```
92
+
93
+ Do this **as soon as the incident is over**. The window expiring on its own is
94
+ the backstop, not the plan.
95
+
96
+ ## Rules
97
+
98
+ - **A reason is mandatory.** No reason, no mode change.
99
+ - **Bounded.** Default 60 minutes, hard cap 240. A hotfix is an incident, not a
100
+ working style. If the work needs longer than 240 minutes it is not a hotfix and
101
+ wants `rdc:fixit` or `rdc:build`.
102
+ - **Fail-closed.** Absent, malformed, unknown-mode or expired state all read as
103
+ `normal`. The hurry that motivates a hotfix is exactly the state in which a
104
+ silently-stuck permissive mode does the most damage.
105
+ - **Never widen the disable set to unblock yourself.** A guard blocking a hotfix
106
+ is information. If it is genuinely wrong, that is its own change with its own
107
+ review — editing `HOTFIX_DISABLES` to get past a red guard is the same
108
+ violation as editing code to satisfy a failing gate
109
+ (`.claude/rules/debugging-protocol.md` Rule 10). The safety list is asserted by
110
+ name in `tests/rdc-mode.test.mjs`, so that edit fails a test.
111
+ - **HotFix is not a bypass of review.** Work done in a hotfix window still needs
112
+ its work item, its evidence, and its close. The window buys speed on ceremony,
113
+ not permission to skip the record.
114
+
115
+ ## Verification
116
+
117
+ ```bash
118
+ node --test $LIFEAI_ENV/tests/rdc-mode.test.mjs
119
+ ```
120
+
121
+ Seven cases, including the load-bearing one: every safety guard is asserted **by
122
+ name** to remain armed while a hotfix window is open.
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:overnight
2
+ name: overnight
3
3
  description: >-
4
4
  Usage `rdc:overnight [epic-id|label=X]` — unattended overnight supervisor, chains preplan → plan → build → review → report across all high-priority epics in --unattended mode. Use for "run overnight", "build while I sleep".
5
5
  ---
package/commands/plan.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:plan
2
+ name: plan
3
3
  description: >-
4
4
  Usage `rdc:plan <topic> [--unattended]` — architecture doc with design decisions, tradeoffs, work packages. Creates Supabase epics/tasks. Use after rdc:preplan or when given clear architectural direction.
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:preplan
2
+ name: preplan
3
3
  description: >-
4
4
  Usage `rdc:preplan <topic> [--unattended]` — research best practices, analyze codebase, compare approaches, surface unknowns before committing to a plan. Produces a research doc. No decisions, no code.
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:prototype
2
+ name: prototype
3
3
  description: >-
4
4
  Usage `rdc:prototype <description>` — build JSX/TSX prototype for review before CLI handoff. Saves to docs/source/, registers in prototype_registry. Use for "mock this up", "show me what it looks like". Reference material only, not production.
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:report
2
+ name: report
3
3
  description: >-
4
4
  Usage `rdc:report [--unattended]` — nightly markdown report, writes to .rdc/reports/YYYY-MM-DD.md. Covers completed/open work items, per-project progress, infra status, git stats, test counts.
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:review
2
+ name: review
3
3
  description: >-
4
4
  Usage `rdc:review [--unattended]` — tests, typecheck, stale docs, export conflicts across modified packages. Fixes issues found. Use after a build session or before merging to main.
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:self-test
2
+ name: self-test
3
3
  description: >-
4
4
  Usage `rdc:self-test [--strict] [--skill <name>] [--json] [--fix] [--tier2] [--parallel <n>] [--quick]` — validates every rdc-*.md skill: frontmatter, Usage marker, name↔filename match, referenced guides/rules/hooks exist, output contract banner, plugin manifest, duplicate-name + collision checks. Tier 1 static lint; Tier 2 behavioral runs via headless Claude or Codex in isolated worktrees + Supabase branch. Run before every release.
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:status
2
+ name: status
3
3
  description: >-
4
4
  Usage `rdc:status` — open epics, work items by project, Coolify health, blockers, next recommended action. Read-only situational awareness.
5
5
  ---
package/commands/watch.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:watch
2
+ name: watch
3
3
  description: >-
4
4
  Usage `rdc:watch` — initialize a session-log viewer and report its path; open it only during attended, non-test use. Zero infra, pure filesystem + static HTML.
5
5
  ---
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: rdc:workitems
2
+ name: workitems
3
3
  description: >-
4
4
  Usage `rdc:workitems <action> [args]` — create, update, query work items via Supabase RPCs. Actions: add, update, done, list, epics. Use for "add to backlog", "mark done", "show open epics", any work item CRUD.
5
5
  ---
@@ -17,10 +17,23 @@
17
17
  * fabrication class (FMEA #1) at the source — WP-2's closure gate then asserts a
18
18
  * report's codeflow_post.commit is one of these captured, session-authored SHAs.
19
19
  *
20
- * PreToolUse (intent) behavior is unchanged:
20
+ * PreToolUse (intent) behavior (2026-08-16 — real teeth, flow-gated):
21
+ * - flow does NOT require a work item (rdc-flow.mjs: plan/design/collab,
22
+ * or a relaxed mode — hotfix/maintenance) -> pass silently, no nag
21
23
  * - fixit.marker present -> pass
22
24
  * - conventional type / UUID / #issue ref -> pass
23
- * - otherwise -> WARN (proceed anyway)
25
+ * - otherwise, flow DOES require one (build/refactor/overnight, or no
26
+ * flow declared at all — fail-closed default)
27
+ * -> BLOCK (was: warn, proceed anyway)
28
+ *
29
+ * Was advisory-only unconditionally ("never hard-block commits... proceeding
30
+ * anyway") — Dave, 2026-08-16, on seeing that exact text fire with no
31
+ * enforcement behind it: the model declares its own flow (rdc-flow.mjs,
32
+ * lifeai-env), and THIS is where that declaration gets teeth. A
33
+ * conversational flow (plan/design/collab) never needed a work item and
34
+ * still doesn't — silently, not even a warning. A code-shipping flow
35
+ * (build/refactor/overnight) now actually stops the commit instead of
36
+ * printing a sentence nobody reads.
24
37
  *
25
38
  * PostToolUse (capture) behavior:
26
39
  * - only fires on a `git commit` whose tool result indicates success
@@ -220,14 +233,42 @@ async function captureCommit(raw) {
220
233
  }
221
234
 
222
235
  // ---------------------------------------------------------------------------
223
- // PreToolUse intent check (unchanged legacy behavior)
236
+ // PreToolUse intent check flow-gated (2026-08-16)
224
237
  // ---------------------------------------------------------------------------
225
238
 
226
- function preToolUse(raw) {
239
+ /**
240
+ * Does the CURRENT model-declared flow (rdc-flow.mjs, lifeai-env) require a
241
+ * work item right now? Dynamic import because this file is CommonJS and
242
+ * rdc-flow.mjs is ESM; resolved via $LIFEAI_ENV so this works identically on
243
+ * whichever machine/lane the hook actually runs from.
244
+ *
245
+ * FAILS CLOSED: any error (lifeai-env not on this box, module not found,
246
+ * whatever) returns true — "requires a work item" — the exact behavior this
247
+ * file had before flow-state existed. Introducing this mechanism can only
248
+ * EXEMPT a case that used to warn, never silently add a requirement that
249
+ * was not already effectively there.
250
+ */
251
+ async function requiresWorkItemNow() {
252
+ const hub = process.env.LIFEAI_ENV || 'C:/Dev/lifeai-env';
253
+ try {
254
+ const mod = await import(`file://${path.join(hub, 'hooks', 'lib', 'rdc-flow.mjs').replace(/\\/g, '/')}`);
255
+ return mod.requiresWorkItem();
256
+ } catch (e) {
257
+ hookLog('require-work-item', 'PreToolUse', 'flow-check-error-fail-closed', { error: e.message });
258
+ return true;
259
+ }
260
+ }
261
+
262
+ async function preToolUse(raw) {
227
263
  if (raw.tool_name !== 'Bash') return process.exit(0);
228
264
  const command = raw.tool_input?.command || '';
229
265
  if (!command.includes('git commit')) return process.exit(0);
230
266
 
267
+ if (!(await requiresWorkItemNow())) {
268
+ hookLog('require-work-item', 'PreToolUse', 'pass-flow-exempt', {});
269
+ return process.exit(0);
270
+ }
271
+
231
272
  if (fs.existsSync(MARKER_FILE)) {
232
273
  hookLog('require-work-item', 'PreToolUse', 'pass-fixit', {});
233
274
  return process.exit(0);
@@ -239,12 +280,16 @@ function preToolUse(raw) {
239
280
  return process.exit(0);
240
281
  }
241
282
 
242
- hookLog('require-work-item', 'PreToolUse', 'warn', { msg: msg.slice(0, 80) });
243
- // Warn only never hard-block commits. Conventional commit format is
244
- // sufficient self-documentation.
283
+ // BLOCK, not warn (2026-08-16). The current flow (build/refactor/overnight,
284
+ // or none declared at all) requires a work item and none was referenced —
285
+ // this used to print a sentence and let the commit through regardless.
286
+ hookLog('require-work-item', 'PreToolUse', 'block', { msg: msg.slice(0, 80) });
245
287
  process.stdout.write(JSON.stringify({
246
- systemMessage: `⚠️ Commit has no work item reference or conventional commit type.\n` +
247
- `Preferred format: fix(<scope>): <message> proceeding anyway.`,
288
+ decision: 'block',
289
+ reason: '⛔ [require-work-item] No work item reference or conventional commit type, and the current '
290
+ + 'flow requires one (build/refactor/overnight, or no flow declared). Add a Work-Item UUID or '
291
+ + '#issue ref to the message, use fix(<scope>): ..., or declare a flow that does not need one '
292
+ + '(rdc-flow.mjs: plan/design/collab) if this genuinely ships no trackable work.',
248
293
  }));
249
294
  return process.exit(0);
250
295
  }
@@ -276,7 +321,7 @@ async function main() {
276
321
  }
277
322
 
278
323
  // Default / PreToolUse intent path.
279
- preToolUse(raw);
324
+ await preToolUse(raw);
280
325
  }
281
326
 
282
327
  // Run when executed as a hook; export pure pieces when required by a test.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -8,8 +8,8 @@
8
8
  * node scripts/install-rdc-skills.js --profile core ← clean-box portable hooks
9
9
  * node scripts/install-rdc-skills.js --profile lifeai ← LIFEAI/regen-root hooks
10
10
  * node scripts/install-rdc-skills.js --claude-home <path> ← custom CLI home
11
- * node scripts/install-rdc-skills.js --codex-root <path> ← also install to .agents/skills/user/
12
- * node scripts/install-rdc-skills.js --codex-skill-dir <path> ← also install to a Codex skill dir
11
+ * node scripts/install-rdc-skills.js --codex-root <path> ← purge legacy project-local rdc skill copies
12
+ * node scripts/install-rdc-skills.js --codex-skill-dir <path> ← purge one legacy Codex skill dir
13
13
  * node scripts/install-rdc-skills.js --project-root <path> --write-startup-blocks
14
14
  * node scripts/install-rdc-skills.js --migrate <path> ← migrate docs/ → .rdc/
15
15
  *
@@ -17,7 +17,7 @@
17
17
  * 1. git pull (latest commands + guides)
18
18
  * 2. CLI plugin — registers in ~/.claude/plugins/ + settings.json
19
19
  * 3. Cowork — registers in Desktop cowork_plugins/ + cowork_settings.json
20
- * 3.5 Codex — copies skills to detected Codex skill dirs
20
+ * 3.5 Codex — removes stale file-based copies; Codex loads RDC skills through MCP
21
21
  * 4. Hook files — copies hooks/*.js → ~/.claude/hooks/
22
22
  * 5. Hook wiring — wires hooks into ~/.claude/settings.json
23
23
  * 6. Zip — builds dist/rdc-skills-plugin.zip for claude.ai / distribution
@@ -101,26 +101,36 @@ function run(cmd, options = {}) {
101
101
  }
102
102
 
103
103
  function updateCodexMcpToml(toml, mcpUrl) {
104
+ const eol = toml.includes('\r\n') ? '\r\n' : '\n';
105
+ toml = toml.replace(/\r\n/g, '\n');
106
+ // RDC skills are MCP-only in Codex. Older installs also registered an RDC
107
+ // marketplace/plugin, which Codex now rejects as an unsupported source and
108
+ // reports during every startup. Remove only those exact legacy blocks.
109
+ toml = toml
110
+ .replace(/(^|\n)\[marketplaces\.rdc-skills\]\n[\s\S]*?(?=\n\[|\s*$)/g, '$1')
111
+ .replace(/(^|\n)\[plugins\."rdc-skills@rdc-skills"\]\n[\s\S]*?(?=\n\[|\s*$)/g, '$1');
104
112
  const blockRe = /(^|\n)(\[mcp_servers\.rdc-skills\]\n)([\s\S]*?)(?=\n\[|\s*$)/;
105
113
  const desiredLine = `url = '${mcpUrl}'`;
106
114
  if (blockRe.test(toml)) {
107
- return toml.replace(blockRe, (match, prefix, header, body) => {
115
+ const updated = toml.replace(blockRe, (match, prefix, header, body) => {
108
116
  if (/^url\s*=.*$/m.test(body)) {
109
117
  return `${prefix}${header}${body.replace(/^url\s*=.*$/m, desiredLine)}`;
110
118
  }
111
119
  const trimmed = body.replace(/\s*$/, '');
112
120
  return `${prefix}${header}${trimmed}${trimmed ? '\n' : ''}${desiredLine}\n`;
113
121
  });
122
+ return updated.replace(/\n/g, eol);
114
123
  }
115
- return toml.replace(/\s*$/, '\n') + `\n[mcp_servers.rdc-skills]\n${desiredLine}\n`;
124
+ return (toml.replace(/\s*$/, '\n') + `\n[mcp_servers.rdc-skills]\n${desiredLine}\n`).replace(/\n/g, eol);
116
125
  }
117
126
 
118
127
  function selfTestCodexMcpToml() {
119
128
  const url = 'https://rdc-skills.regendevcorp.com/mcp';
120
- const stale = "[mcp_servers.clauth]\nurl = 'https://clauth.regendevcorp.com/mcp'\n\n[mcp_servers.rdc-skills]\nurl = 'https://rdc-skills.dev.regendevcorp.com/mcp'\n\n[mcp_servers.web-research]\nurl = 'https://research.regendevcorp.com/mcp'\n";
129
+ const stale = "[mcp_servers.clauth]\nurl = 'https://clauth.regendevcorp.com/mcp'\n\n[mcp_servers.rdc-skills]\nurl = 'https://rdc-skills.dev.regendevcorp.com/mcp'\n\n[marketplaces.rdc-skills]\nsource_type = 'git'\nsource = 'https://github.com/LIFEAI/rdc-skills.git'\n\n[plugins.\"rdc-skills@rdc-skills\"]\nenabled = true\n\n[mcp_servers.web-research]\nurl = 'https://research.regendevcorp.com/mcp'\n";
121
130
  const updated = updateCodexMcpToml(stale, url);
122
131
  if (!updated.includes(`url = '${url}'`)) throw new Error('did not write production rdc-skills URL');
123
132
  if (updated.includes('rdc-skills.dev.regendevcorp.com')) throw new Error('stale dev URL survived');
133
+ if (updated.includes('[marketplaces.rdc-skills]') || updated.includes('[plugins."rdc-skills@rdc-skills"]')) throw new Error('legacy Codex plugin state survived');
124
134
  if (!updated.includes('[mcp_servers.clauth]') || !updated.includes('[mcp_servers.web-research]')) throw new Error('neighbor MCP blocks were damaged');
125
135
 
126
136
  const missing = updateCodexMcpToml("[mcp_servers.clauth]\nurl = 'https://clauth.regendevcorp.com/mcp'\n", url);
@@ -129,6 +139,10 @@ function selfTestCodexMcpToml() {
129
139
  const noUrl = updateCodexMcpToml('[mcp_servers.rdc-skills]\nstartup_timeout_sec = 30\n', url);
130
140
  if (!noUrl.includes("startup_timeout_sec = 30") || !noUrl.includes(`url = '${url}'`)) throw new Error('url-less block was not repaired');
131
141
 
142
+ const crlf = updateCodexMcpToml(stale.replace(/\n/g, '\r\n'), url);
143
+ if (crlf.includes('[marketplaces.rdc-skills]') || crlf.includes('[plugins."rdc-skills@rdc-skills"]')) throw new Error('CRLF legacy Codex plugin state survived');
144
+ if (!crlf.includes(`url = '${url}'`) || !crlf.includes('\r\n')) throw new Error('CRLF MCP config was not preserved');
145
+
132
146
  console.log('install-rdc-skills codex MCP TOML self-test — PASS');
133
147
  }
134
148
 
@@ -474,6 +488,42 @@ function flushOldCaches(cacheBase /* keepVersion intentionally unused */) {
474
488
  return flushed;
475
489
  }
476
490
 
491
+ // ── Marketplace checkout sync ────────────────────────────────────────────────
492
+ // The marketplace directory is a git CLONE, and this installer historically only
493
+ // rewrote `.claude-plugin/marketplace.json` inside it. The rest of the working
494
+ // tree stayed at whatever commit it was cloned at — and since the plugin loader
495
+ // registers every directory it finds, that stale tree is a second, older copy of
496
+ // every command sitting in the menu beside the current one.
497
+ //
498
+ // Fetching is not installing. On 2026-08-16 this clone had a same-day FETCH_HEAD
499
+ // and a working tree 77 commits behind, still serving the pre-rename
500
+ // `name: rdc:<x>` commands that rendered /rdc:rdc:plan next to /rdc:plan.
501
+ //
502
+ // PRESERVE-DIRTY: a clone with local modifications is never reset. It is a
503
+ // consumer copy, so uncommitted content there is unexpected — which is exactly
504
+ // why it gets reported rather than destroyed.
505
+ function syncMarketplaceCheckout(mktDir) {
506
+ const gitDir = path.join(mktDir, '.git');
507
+ if (!fs.existsSync(gitDir)) return; // manifest-only directory; nothing to sync
508
+ const git = (...args) =>
509
+ execSync(`git -C "${mktDir}" ${args.join(' ')}`, { encoding: 'utf8', stdio: 'pipe' }).trim();
510
+ try {
511
+ if (git('status', '--porcelain')) {
512
+ warn(`marketplace clone has local changes at ${mktDir} — left untouched; it may serve stale commands`);
513
+ return;
514
+ }
515
+ const branch = git('rev-parse', '--abbrev-ref', 'HEAD') || 'master';
516
+ const ref = branch === 'HEAD' ? 'master' : branch;
517
+ git('fetch', 'origin', ref, '--quiet');
518
+ const behind = Number(git('rev-list', '--count', `HEAD..origin/${ref}`)) || 0;
519
+ if (behind === 0) return;
520
+ git('reset', '--hard', `origin/${ref}`, '--quiet');
521
+ info(` marketplace: advanced ${behind} commit(s) -> ${git('rev-parse', '--short', 'HEAD')}`);
522
+ } catch (e) {
523
+ warn(`marketplace clone could not be synced (${String(e.message).split('\n')[0]}) — it may serve stale commands`);
524
+ }
525
+ }
526
+
477
527
  // ── Step 2: CLI plugin registration (→ ~/.claude/plugins/) ───────────────────
478
528
  function registerCLI(version, gitSha) {
479
529
  const pluginDir = path.join(claudeHome, 'plugins');
@@ -486,6 +536,24 @@ function registerCLI(version, gitSha) {
486
536
  fs.mkdirSync(mktPlugDir, { recursive: true });
487
537
  fs.copyFileSync(path.join(repoRoot, '.claude-plugin', 'marketplace.json'), path.join(mktPlugDir, 'marketplace.json'));
488
538
 
539
+ // 1b. The marketplace dir is a CLONE, and this installer only ever rewrote the
540
+ // manifest inside it. Everything else — commands/, skills/, hooks/ — stayed at
541
+ // whatever commit it was cloned at, and the plugin loader registers every dir
542
+ // it finds (see the note on cache flushing below). So a stale clone is a
543
+ // second, older copy of every command, live in the menu next to the current
544
+ // one.
545
+ //
546
+ // Observed 2026-08-16: this clone sat at v0.24.36, SEVENTY-SEVEN commits
547
+ // behind master, with a FETCH_HEAD from the same day. Fetched and never
548
+ // checked out — the identical failure the environment harness had, where a
549
+ // bare hub advanced its ref while the files consumers actually execute never
550
+ // moved. Its stale commands still declared the old `name: rdc:<x>`, so the
551
+ // menu showed /rdc:rdc:plan beside /rdc:plan, one entry per source.
552
+ //
553
+ // A fetch is not an install. Bring the working tree to the ref, or do not
554
+ // treat the directory as installed.
555
+ syncMarketplaceCheckout(mktDir);
556
+
489
557
  // 2. known_marketplaces.json
490
558
  const kmpPath = path.join(pluginDir, 'known_marketplaces.json');
491
559
  const knownMp = readJson(kmpPath);
@@ -637,6 +705,8 @@ function addCodexTarget(targets, label, targetDir) {
637
705
  // connector add in its UI (no programmatic API).
638
706
  function registerMcpEndpoints() {
639
707
  const out = [];
708
+ let codexReady = false;
709
+ let codexError = null;
640
710
 
641
711
  // Claude Code — user-level ~/.claude.json mcpServers (covers EVERY project).
642
712
  try {
@@ -656,17 +726,33 @@ function registerMcpEndpoints() {
656
726
  // Codex — ensure [mcp_servers.rdc-skills] exists and points at the live shared endpoint.
657
727
  try {
658
728
  const codexToml = path.join(os.homedir(), '.codex', 'config.toml');
659
- if (fs.existsSync(codexToml)) {
660
- const toml = fs.readFileSync(codexToml, 'utf8');
661
- const next = updateCodexMcpToml(toml, PUBLIC_MCP_URL);
662
- if (next !== toml) {
663
- fs.writeFileSync(codexToml, next);
664
- out.push('codex(~/.codex/config.toml)');
729
+ fs.mkdirSync(path.dirname(codexToml), { recursive: true });
730
+ const toml = fs.existsSync(codexToml) ? fs.readFileSync(codexToml, 'utf8') : '';
731
+ const next = updateCodexMcpToml(toml, PUBLIC_MCP_URL);
732
+ if (next !== toml) {
733
+ const tmp = `${codexToml}.tmp-${process.pid}`;
734
+ const mode = fs.existsSync(codexToml) ? (fs.statSync(codexToml).mode & 0o777) : 0o600;
735
+ try {
736
+ fs.writeFileSync(tmp, next, { mode });
737
+ fs.chmodSync(tmp, mode);
738
+ fs.renameSync(tmp, codexToml);
739
+ } finally {
740
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
665
741
  }
742
+ out.push('codex(~/.codex/config.toml)');
666
743
  }
667
- } catch (e) { out.push(`codex WARN:${e.message}`); }
744
+ const verified = fs.readFileSync(codexToml, 'utf8').replace(/\r\n/g, '\n');
745
+ const block = verified.match(/\[mcp_servers\.rdc-skills\]\n([\s\S]*?)(?=\n\[|\s*$)/)?.[1] || '';
746
+ codexReady = new RegExp(`^url\\s*=\\s*['\"]${PUBLIC_MCP_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['\"]$`, 'm').test(block)
747
+ && !verified.includes('[marketplaces.rdc-skills]')
748
+ && !verified.includes('[plugins."rdc-skills@rdc-skills"]');
749
+ if (!codexReady) throw new Error('Codex MCP endpoint did not verify after write');
750
+ } catch (e) {
751
+ codexError = e.message;
752
+ out.push(`codex ERROR:${e.message}`);
753
+ }
668
754
 
669
- return out;
755
+ return { updates: out, codexReady, codexError };
670
756
  }
671
757
 
672
758
  function findCodexTargets() {
@@ -711,19 +797,11 @@ function registerCodexTarget(targetDir) {
711
797
  }
712
798
  }
713
799
 
714
- // Copy: each source skill dir that has a SKILL.md rdc-<name>/
715
- const skillsSrc = path.join(repoRoot, 'skills');
716
- let copied = 0;
717
- for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
718
- if (!entry.isDirectory()) continue;
719
- const skillFile = path.join(skillsSrc, entry.name, 'SKILL.md');
720
- if (!fs.existsSync(skillFile)) continue;
721
- const dst = path.join(targetDir, `rdc-${entry.name}`);
722
- copyDirRecursive(path.join(skillsSrc, entry.name), dst);
723
- copied++;
724
- }
725
-
726
- return { removed, copied };
800
+ // Do not reinstall file-based copies. Codex discovers the live catalog via
801
+ // [mcp_servers.rdc-skills]. Keeping the same skills in ~/.codex and ~/.agents
802
+ // registers every skill two or three times, exhausts Codex's skill metadata
803
+ // budget, and emits a red startup error on every session.
804
+ return { removed, copied: 0 };
727
805
  }
728
806
 
729
807
  // ── Step 6: Zip for claude.ai / distribution ─────────────────────────────────
@@ -874,6 +952,32 @@ function reportMcpConnector() {
874
952
  info(` claude.ai / MCP clients: ${PUBLIC_MCP_URL} (Auth: none)`);
875
953
  }
876
954
 
955
+ /**
956
+ * Live cross-surface proof, run at the end of every install.
957
+ *
958
+ * Everything above this line WRITES config files and TRUSTS they took effect.
959
+ * On 2026-08-16 that trust was wrong for one whole surface — the production
960
+ * MCP endpoint answered {"skills":0} while origin/master carried 36 — and
961
+ * nothing in this installer had ever asked it. This is the ask: ADVISORY
962
+ * only (never blocks a Claude CLI / Codex install that IS correct just
963
+ * because a shared remote service is stale), but it prints in every run so
964
+ * the gap cannot go quiet again.
965
+ */
966
+ function runLiveProof() {
967
+ const script = path.join(repoRoot, 'scripts', 'verify-live-install.mjs');
968
+ if (!fs.existsSync(script)) return;
969
+ console.log('');
970
+ console.log(' \x1b[36mLive cross-surface proof:\x1b[0m');
971
+ try {
972
+ const codexRootArg = codexRoot ? ` --codex-root "${codexRoot}"` : '';
973
+ execSync(`node "${script}"${codexRootArg}`, { stdio: 'inherit', cwd: repoRoot });
974
+ } catch {
975
+ // Non-zero = a surface is stale. Already printed by the script itself;
976
+ // advisory here on purpose — see docstring.
977
+ warn(' one or more live surfaces do not match origin/master — see table above');
978
+ }
979
+ }
980
+
877
981
  // ── Preflight ─────────────────────────────────────────────────────────────────
878
982
  function runPreflight() {
879
983
  const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
@@ -1051,7 +1155,18 @@ async function main() {
1051
1155
  warn('[2/6] Cowork — no Desktop workspaces found (open Claude Desktop once to create them)');
1052
1156
  }
1053
1157
 
1054
- // 2.5. Codex registration
1158
+ // 2.5. Establish and verify the MCP replacement BEFORE purging file copies.
1159
+ const mcpReg = registerMcpEndpoints();
1160
+ if (!mcpReg.codexReady) {
1161
+ throw new Error(`Codex MCP registration failed; retaining file-based skills: ${mcpReg.codexError || 'unknown error'}`);
1162
+ }
1163
+ if (mcpReg.updates.length > 0) {
1164
+ ok(`[2.5] MCP — registered rdc-skills endpoint: ${mcpReg.updates.join(', ')}`);
1165
+ } else {
1166
+ info('[2.5] MCP — rdc-skills endpoint verified (claude + codex)');
1167
+ }
1168
+
1169
+ // 2.6. Codex migration: MCP is authoritative; purge legacy file copies.
1055
1170
  const codexTargets = findCodexTargets();
1056
1171
  if (codexTargets.length > 0) {
1057
1172
  let copiedTotal = 0;
@@ -1060,20 +1175,11 @@ async function main() {
1060
1175
  const { removed, copied } = registerCodexTarget(target.targetDir);
1061
1176
  copiedTotal += copied;
1062
1177
  removedTotal += removed;
1063
- info(` ${target.label.padEnd(15)}: ${target.targetDir} (${copied} installed, ${removed} stale removed)`);
1178
+ info(` ${target.label.padEnd(15)}: ${target.targetDir} (${removed} stale removed; MCP authoritative)`);
1064
1179
  }
1065
- ok(`[2.5] Codex — ${copiedTotal} skill install(s), ${removedTotal} stale removed across ${codexTargets.length} target(s)`);
1066
- } else {
1067
- info('[2.5] Codex — skipped (no Codex skill dirs found; use --codex-root or --codex-skill-dir)');
1068
- }
1069
-
1070
- // 2.6. Register the rdc-skills MCP endpoint globally (Claude Code + Codex) so
1071
- // EVERY agent can reach the skills via MCP, not only where a project .mcp.json exists.
1072
- const mcpReg = registerMcpEndpoints();
1073
- if (mcpReg.length > 0) {
1074
- ok(`[2.6] MCP — registered rdc-skills endpoint: ${mcpReg.join(', ')}`);
1180
+ ok(`[2.6] Codex — ${removedTotal} stale file-based skill(s) removed across ${codexTargets.length} target(s); MCP authoritative`);
1075
1181
  } else {
1076
- info('[2.6] MCP rdc-skills endpoint already registered (claude + codex)');
1182
+ info('[2.6] Codex skipped (no Codex skill dirs found; use --codex-root or --codex-skill-dir)');
1077
1183
  }
1078
1184
 
1079
1185
  // 2.7. Symlinks in regen-root/.claude/skills/ (FS MCP + claude.ai access)
@@ -1209,6 +1315,9 @@ async function main() {
1209
1315
  console.log(' \x1b[36mMCP connector:\x1b[0m');
1210
1316
  reportMcpConnector();
1211
1317
 
1318
+ // 7.5 Live cross-surface proof — advisory, never blocks install exit code
1319
+ runLiveProof();
1320
+
1212
1321
  // Done
1213
1322
  console.log('');
1214
1323
  console.log(' \x1b[32mDone!\x1b[0m');
@@ -1238,7 +1347,7 @@ async function main() {
1238
1347
  // scripts/probe-installed-hooks.mjs verify the real install path instead of
1239
1348
  // re-implementing it — a probe that copies its own way proves nothing about what
1240
1349
  // ships. Without this guard, requiring the module would run a full install.
1241
- module.exports = { copyHookFiles, assertHooksLoadable, RDC_ENV_HOOK_TIMEOUT_SEC };
1350
+ module.exports = { copyHookFiles, assertHooksLoadable, registerCodexTarget, RDC_ENV_HOOK_TIMEOUT_SEC };
1242
1351
 
1243
1352
  if (require.main === module) {
1244
1353
  main().catch(e => { fail(e.message); process.exit(1); });
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * verify-live-install.mjs — proof, not hope.
4
+ *
5
+ * install-rdc-skills.js's "post-install verification" step only checks LOCAL
6
+ * structural counts (cache dir count, installed_plugins entry count, orphan
7
+ * cleanup) — it never asks any of the four surfaces a Claude/Codex session
8
+ * actually reads from whether they are serving the CURRENT catalog. On
9
+ * 2026-08-16 that gap was live: the production MCP endpoint answered
10
+ * `{"skills":0,"version":"0.0.0"}` while origin/master carried 37 skills, and
11
+ * nothing had ever noticed. This script is the missing check: it names a
12
+ * SOURCE OF TRUTH (origin/master, fetched fresh — never local disk, which is
13
+ * exactly what was stale) and asks each live surface to prove it matches.
14
+ *
15
+ * Surfaces checked (all four the installer registers):
16
+ * 1. Claude CLI — ~/.claude/plugins/installed_plugins.json + cache/latest/skills/
17
+ * 2. Codex — every skill dir the installer's findCodexTargets() would touch
18
+ * 3. Claude MCP — live GET <mcp-url>/health (shared endpoint, also what claude.ai uses)
19
+ * 4. Codex MCP — ~/.codex/config.toml points at the SAME endpoint checked in (3)
20
+ *
21
+ * Usage:
22
+ * node scripts/verify-live-install.mjs human table, exit 1 on any FAIL
23
+ * node scripts/verify-live-install.mjs --json machine-readable
24
+ * node scripts/verify-live-install.mjs --mcp-url <url> override (default: production)
25
+ * node scripts/verify-live-install.mjs --codex-root <dir> consuming project root
26
+ */
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import os from 'node:os';
30
+ import { execSync } from 'node:child_process';
31
+ import https from 'node:https';
32
+ import http from 'node:http';
33
+
34
+ const args = process.argv.slice(2);
35
+ const asJson = args.includes('--json');
36
+ const mcpUrlIdx = args.indexOf('--mcp-url');
37
+ const MCP_URL = mcpUrlIdx >= 0 ? args[mcpUrlIdx + 1] : 'https://rdc-skills.regendevcorp.com';
38
+ const codexRootIdx = args.indexOf('--codex-root');
39
+ const CODEX_ROOT = codexRootIdx >= 0 ? path.resolve(args[codexRootIdx + 1]) : null;
40
+
41
+ const repoRoot = path.resolve(new URL('.', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'), '..');
42
+ const claudeHome = path.join(os.homedir(), '.claude');
43
+ const PLUGIN_KEY = 'rdc-skills@rdc-skills';
44
+
45
+ function readJson(p, fallback = {}) {
46
+ if (!fs.existsSync(p)) return fallback;
47
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; }
48
+ }
49
+
50
+ function readFrontmatterName(filePath) {
51
+ try {
52
+ const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
53
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
54
+ if (!m) return null;
55
+ const nm = m[1].match(/^name:\s*(.+)$/m);
56
+ return nm ? nm[1].trim() : null;
57
+ } catch { return null; }
58
+ }
59
+
60
+ function httpGetJson(url, timeoutMs = 8000) {
61
+ return new Promise((resolve) => {
62
+ const lib = url.startsWith('https') ? https : http;
63
+ const req = lib.get(url, { timeout: timeoutMs }, (res) => {
64
+ let body = '';
65
+ res.on('data', (c) => { body += c; });
66
+ res.on('end', () => {
67
+ try { resolve({ ok: true, status: res.statusCode, json: JSON.parse(body) }); }
68
+ catch (e) { resolve({ ok: false, error: `bad JSON: ${e.message}`, raw: body.slice(0, 200) }); }
69
+ });
70
+ });
71
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
72
+ req.on('error', (e) => resolve({ ok: false, error: e.message }));
73
+ });
74
+ }
75
+
76
+ // ── Source of truth — origin/master, FRESHLY FETCHED. Never local disk: a
77
+ // checkout can silently sit behind origin (this is the exact failure the
78
+ // "canonical fallback" repo had this session — 31 commits behind while being
79
+ // treated as the source of truth). ──────────────────────────────────────────
80
+ function sourceOfTruth() {
81
+ execSync('git fetch origin master', { cwd: repoRoot, stdio: 'pipe' });
82
+ const sha = execSync('git rev-parse origin/master', { cwd: repoRoot, encoding: 'utf8' }).trim();
83
+ const pkgRaw = execSync('git show origin/master:package.json', { cwd: repoRoot, encoding: 'utf8' });
84
+ const version = JSON.parse(pkgRaw).version;
85
+ const skillDirs = execSync('git ls-tree -d --name-only origin/master:skills', { cwd: repoRoot, encoding: 'utf8' })
86
+ .split('\n').map((s) => s.trim()).filter(Boolean);
87
+ // Only count dirs that actually carry a SKILL.md at that ref — matches what
88
+ // the installer/MCP catalog treat as a real skill, not a stray directory.
89
+ let skillCount = 0;
90
+ for (const d of skillDirs) {
91
+ try {
92
+ execSync(`git cat-file -e origin/master:skills/${d}/SKILL.md`, { cwd: repoRoot, stdio: 'pipe' });
93
+ skillCount++;
94
+ } catch { /* not a real skill dir */ }
95
+ }
96
+ return { sha, shortSha: sha.slice(0, 7), version, skillCount };
97
+ }
98
+
99
+ // ── Surface 1: Claude CLI ────────────────────────────────────────────────────
100
+ function checkClaudeCli(truth) {
101
+ const ipPath = path.join(claudeHome, 'plugins', 'installed_plugins.json');
102
+ const installed = readJson(ipPath, { plugins: {} });
103
+ const entries = installed.plugins[PLUGIN_KEY] || [];
104
+ if (entries.length !== 1) {
105
+ return { surface: 'Claude CLI', pass: false, detail: `expected 1 installed_plugins entry, found ${entries.length}` };
106
+ }
107
+ const entry = entries[0];
108
+ const cacheSkillsDir = entry.installPath ? path.join(entry.installPath, 'skills') : null;
109
+ const cacheCount = cacheSkillsDir && fs.existsSync(cacheSkillsDir)
110
+ ? fs.readdirSync(cacheSkillsDir, { withFileTypes: true })
111
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(cacheSkillsDir, e.name, 'SKILL.md'))).length
112
+ : -1;
113
+ const shaMatch = entry.gitCommitSha === truth.sha;
114
+ const versionMatch = entry.version === truth.version;
115
+ const countMatch = cacheCount === truth.skillCount;
116
+ const pass = shaMatch && versionMatch && countMatch;
117
+ return {
118
+ surface: 'Claude CLI',
119
+ pass,
120
+ detail: pass
121
+ ? `v${entry.version} @ ${truth.shortSha}, ${cacheCount} skills — matches origin/master`
122
+ : `v${entry.version || '?'} @ ${(entry.gitCommitSha || '?').slice(0, 7)}, ${cacheCount} skills`
123
+ + ` — expected v${truth.version} @ ${truth.shortSha}, ${truth.skillCount} skills`
124
+ + `${shaMatch ? '' : ' [SHA MISMATCH]'}${versionMatch ? '' : ' [VERSION MISMATCH]'}${countMatch ? '' : ' [COUNT MISMATCH]'}`,
125
+ };
126
+ }
127
+
128
+ // ── Surface 2: Codex (MCP-only; no stale file-based skill dirs) ─────────────
129
+ function findCodexTargets(codexRoot) {
130
+ const targets = [];
131
+ const add = (label, dir) => {
132
+ if (dir && fs.existsSync(dir) && !targets.some((t) => t.dir.toLowerCase() === dir.toLowerCase())) {
133
+ targets.push({ label, dir });
134
+ }
135
+ };
136
+ if (codexRoot) add('project .agents', path.join(codexRoot, '.agents', 'skills', 'user'));
137
+ add('global .codex', path.join(os.homedir(), '.codex', 'skills'));
138
+ add('global .agents', path.join(os.homedir(), '.agents', 'skills'));
139
+ return targets;
140
+ }
141
+
142
+ function checkCodex(truth) {
143
+ const codexRootCandidates = [
144
+ CODEX_ROOT,
145
+ process.env.REGEN_ROOT ? path.resolve(process.env.REGEN_ROOT) : null,
146
+ path.resolve(repoRoot, '..', 'regen-root'),
147
+ process.cwd(),
148
+ ].filter(Boolean);
149
+ const codexRoot = codexRootCandidates.find((c) => fs.existsSync(path.join(c, '.agents')));
150
+ const targets = findCodexTargets(codexRoot);
151
+ if (targets.length === 0) {
152
+ return { surface: 'Codex', pass: false, detail: 'no Codex skill directories found on this machine — install never ran with a Codex target' };
153
+ }
154
+ const results = [];
155
+ for (const t of targets) {
156
+ const dirs = fs.readdirSync(t.dir, { withFileTypes: true }).filter((e) => e.isDirectory());
157
+ const rdcDirs = dirs.filter((e) => /^rdc-/.test(e.name) || readFrontmatterName(path.join(t.dir, e.name, 'SKILL.md'))?.startsWith('rdc:'));
158
+ results.push({ label: t.label, dir: t.dir, count: rdcDirs.length });
159
+ }
160
+ const pass = results.every((r) => r.count === 0);
161
+ const detail = results.map((r) =>
162
+ `${r.label}: ${r.count} legacy file-based rdc skill(s)${r.count === 0 ? '' : ' [MCP DUPLICATES PRESENT]'}`
163
+ ).join('; ');
164
+ return { surface: 'Codex', pass, detail };
165
+ }
166
+
167
+ // ── Surface 3: Claude MCP (live) ─────────────────────────────────────────────
168
+ async function checkClaudeMcp(truth) {
169
+ const res = await httpGetJson(`${MCP_URL}/health`);
170
+ if (!res.ok) {
171
+ return { surface: 'Claude MCP', pass: false, detail: `${MCP_URL}/health unreachable — ${res.error}` };
172
+ }
173
+ const { skills, git_sha, version, status } = res.json;
174
+ const shaMatch = git_sha === truth.sha;
175
+ const countMatch = skills === truth.skillCount;
176
+ const pass = status === 'ok' && shaMatch && countMatch;
177
+ return {
178
+ surface: 'Claude MCP',
179
+ pass,
180
+ detail: pass
181
+ ? `${MCP_URL}/health — v${version}, ${skills} skills @ ${truth.shortSha} — matches origin/master`
182
+ : `${MCP_URL}/health — status=${status} v${version} skills=${skills} sha=${(git_sha || '?').slice(0, 7)}`
183
+ + ` — expected ${truth.skillCount} skills @ ${truth.shortSha}`
184
+ + `${shaMatch ? '' : ' [SHA MISMATCH]'}${countMatch ? '' : ' [COUNT MISMATCH]'}`,
185
+ };
186
+ }
187
+
188
+ // ── Surface 4: Codex MCP config points at the same live endpoint ───────────
189
+ function checkCodexMcpConfig() {
190
+ const codexToml = path.join(os.homedir(), '.codex', 'config.toml');
191
+ if (!fs.existsSync(codexToml)) {
192
+ return { surface: 'Codex MCP', pass: false, detail: `${codexToml} does not exist` };
193
+ }
194
+ const toml = fs.readFileSync(codexToml, 'utf8').replace(/\r\n/g, '\n');
195
+ const blockRe = /\[mcp_servers\.rdc-skills\]\n([\s\S]*?)(?=\n\[|\s*$)/;
196
+ const m = toml.match(blockRe);
197
+ if (!m) return { surface: 'Codex MCP', pass: false, detail: 'no [mcp_servers.rdc-skills] block in config.toml' };
198
+ const urlM = m[1].match(/^url\s*=\s*'([^']+)'/m);
199
+ const configuredUrl = urlM ? urlM[1] : null;
200
+ const pass = configuredUrl === `${MCP_URL}/mcp`;
201
+ return {
202
+ surface: 'Codex MCP',
203
+ pass,
204
+ detail: pass
205
+ ? `config.toml points at ${configuredUrl} — same live endpoint verified above`
206
+ : `config.toml url=${configuredUrl || 'MISSING'} — expected ${MCP_URL}/mcp`,
207
+ };
208
+ }
209
+
210
+ async function main() {
211
+ const truth = sourceOfTruth();
212
+ const rows = [];
213
+ rows.push(checkClaudeCli(truth));
214
+ rows.push(checkCodex(truth));
215
+ rows.push(await checkClaudeMcp(truth));
216
+ rows.push(checkCodexMcpConfig());
217
+
218
+ const allPass = rows.every((r) => r.pass);
219
+
220
+ if (asJson) {
221
+ console.log(JSON.stringify({ truth, rows, allPass }, null, 2));
222
+ } else {
223
+ console.log('');
224
+ console.log(` Source of truth: origin/master @ ${truth.shortSha}, v${truth.version}, ${truth.skillCount} skills`);
225
+ console.log('');
226
+ for (const r of rows) {
227
+ const mark = r.pass ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
228
+ console.log(` ${mark} ${r.surface.padEnd(12)} ${r.detail}`);
229
+ }
230
+ console.log('');
231
+ console.log(allPass
232
+ ? ' \x1b[32mAll surfaces verified live — proof, not assumption.\x1b[0m'
233
+ : ' \x1b[31mAt least one surface does NOT match origin/master. This is the exact blind spot install-rdc-skills.js does not check.\x1b[0m');
234
+ console.log('');
235
+ }
236
+ process.exit(allPass ? 0 : 1);
237
+ }
238
+
239
+ main().catch((e) => { console.error(e.stack || e.message); process.exit(2); });
@@ -127,11 +127,29 @@ is not helpful — it is what the format exists to remove.
127
127
  **One block per point:**
128
128
 
129
129
  ```
130
- POINT <n>: AGREE | AGREE-WITH-AMENDMENT | DISAGREE
130
+ POINT <n>: AGREE | AGREE-WITH-AMENDMENT | DISAGREE | UNKNOWN | DEFER
131
131
  EDIT: <the exact section and change that SHOULD be made, or NONE — describe it, do not make it>
132
- REASON: <one sentence — only if AMENDMENT or DISAGREE>
132
+ REASON: <one sentence — required for AMENDMENT, DISAGREE, UNKNOWN and DEFER>
133
133
  ```
134
134
 
135
+ **`UNKNOWN` and `DEFER` are answers, not failures.** If you lack the evidence to
136
+ hold a position, say so. A three-value contract forces you to invent one, and an
137
+ invented position is indistinguishable from a settled one in the next round —
138
+ which is how a negotiation converges on something nobody checked. `UNKNOWN` = you
139
+ cannot determine it. `DEFER` = it is determinable but belongs to another owner or
140
+ another round.
141
+
142
+ **Always carry `AS_OF` and `PROBE_STATE`:**
143
+
144
+ ```
145
+ AS_OF: <timestamp of your last observation of what you are describing>
146
+ PROBE_STATE: live | cached | absent
147
+ ```
148
+
149
+ A claim with no as-of is a claim about an unknown moment. `live` = you probed it
150
+ this turn; `cached` = you are reporting an earlier observation; `absent` = you
151
+ could not observe it and are reasoning from something else. Say which.
152
+
135
153
  **For a factual question rather than a proposal:**
136
154
 
137
155
  ```
@@ -147,6 +165,43 @@ OWNER: <who lands the resulting Decision or work item — exactly one agent>
147
165
  BLOCKED: <what you cannot do from where you are, or NONE>
148
166
  ```
149
167
 
168
+ ### 1b. A broken briefing is the HOST's defect. Report it; do not repair it.
169
+
170
+ **You are a guest in this workspace.** Checking that everything you were handed is
171
+ actually there is welcome — one quick pass, seconds, before you start reading. It
172
+ catches the host's mistakes early, which is worth doing.
173
+
174
+ **What is not yours is the RECOVERY.** If a path does not exist, a service is
175
+ down, or the tree is not in the state the dispatch claims — say so, in one line,
176
+ and stop. Do not go find the file. Do not search the filesystem, do not search git
177
+ objects, do not resolve which branch really has it, do not reconcile the
178
+ initiator's checkouts. Verifying takes seconds and helps; recovering takes minutes
179
+ and hides the defect.
180
+
181
+ This is not laziness, and it is the opposite of unhelpful. Measured 2026-08-15: a
182
+ peer was handed six paths, two of which did not exist where stated. It did
183
+ everything a capable agent would do — searched the tree, searched hidden files,
184
+ searched `git ls-tree` across refs, found the real versions in `origin/develop`
185
+ and a `v0.30.1` tag, read them from immutable git objects without touching either
186
+ worktree, and reported the mismatch precisely. **The answer was correct and it
187
+ cost 4m23s instead of 60s.** Worse, the initiator's two drifted checkouts stayed
188
+ drifted, because the peer had quietly worked around them — the defect was
189
+ absorbed instead of returned, so nothing got fixed and the next session hit it
190
+ again.
191
+
192
+ ```
193
+ BLOCKED: path 3 does not exist: C:/path/as/given. Not searching — fix the briefing.
194
+ ```
195
+
196
+ One line, immediately, before reading anything else. The host can fix a wrong path
197
+ in seconds; you cannot fix it at all, because you cannot know whether the path was
198
+ mistyped, the file was renamed, or their checkout is stale. **Guessing which,
199
+ correctly, still leaves their room broken.**
200
+
201
+ The same applies to a service the dispatch told you to use: if CodeFlow or an MCP
202
+ is unreachable, report it and stop. Do not restart it, do not route around it, do
203
+ not substitute a different tool and carry on as though nothing happened.
204
+
150
205
  ### 2. Declare `BLOCKED` loudly. Silence is the worst answer.
151
206
 
152
207
  If you cannot write, cannot reach a service, or lack the credential — **say it in
@@ -296,9 +296,22 @@ Two rules that override the table:
296
296
  Every dispatch carries an explicit answer format. The contract is not politeness —
297
297
  it is what makes the reply *checkable*.
298
298
 
299
- **Every field returns information. No field asks the peer what it changed** — if a
300
- contract invites `CHANGED: <file paths>`, it has invited the peer to mutate the
301
- repo mid-conversation, which is the v0.27.0 defect this version removes.
299
+ **During a NEGOTIATION, the contract returns information no field asks the peer
300
+ what it changed.** A `CHANGED: <file paths>` field invites both sides to mutate the
301
+ same artifact mid-argument, which is the v0.27.0 defect this version removes.
302
+
303
+ **This is not a read-only peer, and never say it is.** The constraint is
304
+ SINGLE-WRITER-PER-ARTIFACT (Step 4), not "the guest may not touch anything". A
305
+ peer that is the named writer for an artifact writes it — that is the whole
306
+ mechanism by which an agreed change gets made. What it must not do is edit the
307
+ artifact under negotiation while the negotiation is still open, or edit one it
308
+ does not own.
309
+
310
+ Telling a capable peer it is read-only when the workspace imposes no such rule is
311
+ an invented constraint, and inventing constraints on someone else's behalf costs
312
+ exactly what a wrong constraint always costs: work that could have been done in
313
+ the same turn gets deferred to another round, or gets done anyway and now
314
+ contradicts what you told them. In `delegate` mode the peer is expected to write.
302
315
 
303
316
  **The contract — one block per open point:**
304
317
 
@@ -417,6 +430,16 @@ dispatch.
417
430
  > Two active writers on one surface is forbidden — the same rule the fleet plans
418
431
  > state as *"never run two active writers for one effect."*
419
432
 
433
+ > **The single-writer rule is a stopgap, and it is worth knowing what it stands
434
+ > in for.** Per-artifact version numbers solved this in the 1970s — VMS gave every
435
+ > file a generation (`FILE.TXT;1`, `;2`), so two writers produced two versions and
436
+ > the collision was *detected* instead of silently losing one. Git is that
437
+ > mechanism, better: content-addressed versions plus a merge base that says
438
+ > exactly where two lines of work diverged. A rule that forbids concurrent writing
439
+ > is a rule that declines to use it. Keep single-writer for now — it is cheap and
440
+ > it holds — but do not mistake it for the right answer to two agents on one file.
441
+ > GitHub already handles most of it.
442
+
420
443
  **Scope ownership to a named effect or artifact, never to an agent in general** —
421
444
  "Claude owns `fleet-throughput-remediation.md`", not "Claude is the writer".
422
445
  Transfer is explicit and acknowledged; **silence never transfers ownership**. A
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import assert from 'node:assert/strict';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { readFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
5
7
  import { dirname, join, resolve } from 'node:path';
6
8
  import { fileURLToPath } from 'node:url';
7
9
 
8
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
11
  const REPO_ROOT = resolve(__dirname, '..');
10
12
  const script = join(REPO_ROOT, 'scripts', 'install-rdc-skills.js');
13
+ const require = createRequire(import.meta.url);
11
14
 
12
15
  const syntax = spawnSync(process.execPath, ['--check', script], { encoding: 'utf8' });
13
16
  assert.equal(syntax.status, 0, syntax.stderr);
@@ -21,6 +24,12 @@ assert.match(toml.stdout, /PASS/);
21
24
 
22
25
  const source = readFileSync(script, 'utf8');
23
26
  const plugin = JSON.parse(readFileSync(join(REPO_ROOT, '.claude-plugin', 'plugin.json'), 'utf8'));
27
+ const packageJson = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'));
28
+ assert.equal(
29
+ plugin.version,
30
+ packageJson.version,
31
+ 'plugin manifest version must match the published package version',
32
+ );
24
33
  const skillCount = Array.isArray(plugin.skills_meta)
25
34
  ? plugin.skills_meta.length
26
35
  : Object.keys(plugin.skills_meta || {}).length;
@@ -45,5 +54,36 @@ assert.match(
45
54
  /no plugin upload needed for MCP/,
46
55
  'installer should not imply claude.ai MCP usage requires uploading an artifact',
47
56
  );
57
+ assert.ok(
58
+ source.indexOf('const mcpReg = registerMcpEndpoints();') < source.indexOf('const codexTargets = findCodexTargets();'),
59
+ 'installer must establish the Codex MCP endpoint before removing file-based skills',
60
+ );
61
+ assert.match(
62
+ source,
63
+ /if \(!mcpReg\.codexReady\)[\s\S]*retaining file-based skills/,
64
+ 'installer must fail closed and retain file-based skills when MCP registration fails',
65
+ );
66
+ assert.match(
67
+ source,
68
+ /const mode = fs\.existsSync\(codexToml\)[\s\S]*fs\.chmodSync\(tmp, mode\)[\s\S]*finally[\s\S]*fs\.unlinkSync\(tmp\)/,
69
+ 'Codex config replacement must preserve permissions and remove failed temporary copies',
70
+ );
71
+
72
+ const { registerCodexTarget } = require(script);
73
+ const codexSkills = mkdtempSync(join(tmpdir(), 'rdc-codex-skills-'));
74
+ try {
75
+ mkdirSync(join(codexSkills, 'rdc-build'), { recursive: true });
76
+ writeFileSync(join(codexSkills, 'rdc-build', 'SKILL.md'), '---\nname: rdc:build\n---\n');
77
+ mkdirSync(join(codexSkills, 'legacy-name'), { recursive: true });
78
+ writeFileSync(join(codexSkills, 'legacy-name', 'SKILL.md'), '---\nname: rdc:plan\n---\n');
79
+ mkdirSync(join(codexSkills, 'keep-me'), { recursive: true });
80
+ writeFileSync(join(codexSkills, 'keep-me', 'SKILL.md'), '---\nname: local:keep\n---\n');
81
+
82
+ const migrated = registerCodexTarget(codexSkills);
83
+ assert.deepEqual(migrated, { removed: 2, copied: 0 });
84
+ assert.equal(readFileSync(join(codexSkills, 'keep-me', 'SKILL.md'), 'utf8').includes('local:keep'), true);
85
+ } finally {
86
+ rmSync(codexSkills, { recursive: true, force: true });
87
+ }
48
88
 
49
89
  console.log('install-rdc-skills tests — PASS');
@@ -125,29 +125,45 @@ function runHook(payload, extraEnv = {}) {
125
125
  }
126
126
 
127
127
  // ---------------------------------------------------------------------------
128
- // 4. PreToolUse legacy behavior preserved (warn-only, never blocks)
129
- // Uses a fresh temp dir as HOME/USERPROFILE so no fixit.marker is visible
130
- // regardless of real machine state makes the warn-path assertion deterministic.
128
+ // 4. PreToolUse flow-gated (2026-08-16): BLOCKS when the current flow
129
+ // requires a work item, exempt SILENTLY when it doesn't. Uses a fresh temp
130
+ // dir as HOME/USERPROFILE so no fixit.marker is visible regardless of real
131
+ // machine state, and real RDC_FLOW_STATE/RDC_MODE_STATE temp files so the
132
+ // test exercises the ACTUAL cross-repo dynamic-import path against
133
+ // whatever $LIFEAI_ENV resolves to on this box — not a mock.
131
134
  // ---------------------------------------------------------------------------
132
135
  {
133
136
  const fakeHome = mkdtempSync(join(tmpdir(), 'wic-home-'));
137
+ const flowState = join(fakeHome, 'flow.json');
138
+ const modeState = join(fakeHome, 'mode.json');
134
139
  try {
135
- const preEnv = { HOME: fakeHome, USERPROFILE: fakeHome };
140
+ const preEnv = { HOME: fakeHome, USERPROFILE: fakeHome, RDC_FLOW_STATE: flowState, RDC_MODE_STATE: modeState };
136
141
 
137
- const res = runHook({
142
+ // No flow declared -> fail-closed default -> requires a work item -> BLOCK.
143
+ const blocked = runHook({
138
144
  hook_event_name: 'PreToolUse',
139
145
  tool_name: 'Bash',
140
146
  tool_input: { command: 'git commit -m "no convention and no uuid"' },
141
147
  }, preEnv);
142
- assert('pre: warn exits zero (never blocks)', res.status === 0, res.stderr);
143
- assert('pre: emits warn systemMessage', /no work item reference/.test(res.stdout), res.stdout);
148
+ assert('pre: no-flow-declared BLOCKS (was: warn, never blocked)',
149
+ JSON.parse(blocked.stdout || '{}').decision === 'block', blocked.stdout || blocked.stderr);
144
150
 
151
+ // Conventional format still passes even when a work item IS required.
145
152
  const ok = runHook({
146
153
  hook_event_name: 'PreToolUse',
147
154
  tool_name: 'Bash',
148
155
  tool_input: { command: 'git commit -m "feat(x): conventional"' },
149
156
  }, preEnv);
150
- assert('pre: conventional passes silently', ok.status === 0 && ok.stdout.trim() === '', ok.stdout);
157
+ assert('pre: conventional passes silently even when required', ok.status === 0 && ok.stdout.trim() === '', ok.stdout);
158
+
159
+ // A conversational flow (plan) exempts — silently, no message at all.
160
+ writeFileSync(flowState, JSON.stringify({ flow: 'plan', setBy: 'test', setAt: new Date().toISOString() }));
161
+ const exempt = runHook({
162
+ hook_event_name: 'PreToolUse',
163
+ tool_name: 'Bash',
164
+ tool_input: { command: 'git commit -m "no convention and no uuid"' },
165
+ }, preEnv);
166
+ assert('pre: plan flow exempts silently', exempt.status === 0 && exempt.stdout.trim() === '', exempt.stdout);
151
167
  } finally {
152
168
  rmSync(fakeHome, { recursive: true, force: true });
153
169
  }