@lifeaitools/rdc-skills 0.37.4 → 0.37.7

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.37.4",
3
+ "version": "0.37.7",
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",
package/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## v0.37.5 — lesson drain: skills say what the gates check
11
+
12
+ ### Fixed
13
+ - `rdc:fixit` seeds the required `decomp-fix` / `test-fix-proof` rows at insert and ticks them before review; `done` refused every fixit item without them. Recovery via `append_checklist_item` is documented. (regen-root#194)
14
+ - `rdc:fixit` reviews `git diff --cached --stat` before committing and uses `git add -p` on a file that was already dirty. (#195)
15
+ - `rdc:release` and `rdc:fixit` verify a publish with an exact-version registry fetch, not `npm view`. (#231)
16
+ - `rdc:release` reads `<repo>/RELEASE.md` first, and treats E403/E409 right after a tag as "CI already published". (#196)
17
+ - `rdc:flow hotfix|maintenance` writes the mode store as well as the flow store; `setFlow` alone relaxed no guard. `rdc:flow normal` clears both.
18
+ - `rdc:status` reads PM2 through `ssh-clauth.sh`; the raw key fetch it documented is guard-blocked. (#216)
19
+ - `require-work-item-on-commit`: `git` must be the command word; prose containing "git" and "commit" no longer trips it. (#243)
20
+ - `validate-publish-manifests`: a DEPLOY block whose `staging_gates` is a list or inline value fails. (#210)
21
+ - Installer warns when run from a checkout behind `origin/master`. (#230)
22
+ - `scripts/acceptance.mjs` compared its URL to a hand-built one; under a Windows 8.3 path (`NAME~1`) they never matched, `main()` never ran and every call exited 0. It compares paths now.
23
+
24
+ ### Added
25
+ - Notes: `rdc:build` holds a human-only blocker with `rdc-work hold` (#199); `rdc:plan` keeps unapproved architecture drafts in `.rdc/research/<topic>-draft.md` (#198); `rdc:collab` says `-verify` sessions close after ready (#193); `rdc:terminal-config` stale-environment diagnosis and `rdc:edit` target-URL probe from PR #13 (#192).
26
+ - Regression tests for `truthGateWiredForEvent` (#212 — fix shipped in 83fd6f9), `isGitCommit`, `staging_gates` shape, and the stale-checkout warning.
27
+
28
+ ### Removed
29
+ - `hooks/task-completed-gate.js` and `hooks/post-tool-batch-gate.js`: both sat behind env flags nothing set, so every call was a no-op. Unregistered, and the installer deletes installed copies. (#237)
30
+
31
+ ---
32
+
10
33
  ## v0.37.2 — the release doc says what actually happens
11
34
 
12
35
  ### Fixed
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": "lifeai.plugin.v1",
3
3
  "id": "rdc-skills",
4
- "version": "0.37.4",
4
+ "version": "0.37.7",
5
5
  "publisher": "LIFEAI",
6
6
  "core": false,
7
7
  "documentation": {
package/commands/flow.md CHANGED
@@ -61,20 +61,29 @@ node -e "import(require('url').pathToFileURL(process.env.LIFEAI_ENV + '/hooks/li
61
61
 
62
62
  ### 3. Setting hotfix or maintenance — a reason is REQUIRED
63
63
 
64
- Refuse to proceed without one — `setFlow` throws on an empty reason for these two values, by design, same as `setMode` always did: an unexplained disarm is how a temporary state becomes permanent.
64
+ Refuse to proceed without one — both setters throw on an empty reason for these two values, by design: an unexplained disarm is how a temporary state becomes permanent.
65
+
66
+ Guard relaxation reads the **mode** store (`rdc-mode.mjs` `isRelaxedFor()`); the work-item
67
+ exemption reads the **flow** store. `setFlow` alone relaxes no guard, so this one command
68
+ writes both — `setFlowOrMode` routes `hotfix`/`maintenance` to `setMode`, then `setFlow`
69
+ records the flow:
65
70
 
66
71
  ```bash
67
- node -e "import(require('url').pathToFileURL(process.env.LIFEAI_ENV + '/hooks/lib/rdc-flow.mjs').href).then(m=>console.log(JSON.stringify(m.setFlow('hotfix',{reason:process.argv[1],setBy:process.argv[2]}),null,2)))" "<reason>" "<session-id>"
72
+ node -e "import(require('url').pathToFileURL(process.env.LIFEAI_ENV + '/hooks/lib/rdc-flow.mjs').href).then(m=>{const [n,reason,setBy]=process.argv.slice(1);const o={reason,setBy};m.setFlowOrMode(n,o);m.setFlow(n,o);console.log(JSON.stringify(m.currentState(),null,2))})" "<hotfix|maintenance>" "<reason>" "<session-id>"
68
73
  ```
69
74
 
75
+ Read the printed state: `flow` and `mode` must both show the name. If `mode` is still `normal`, no guard is relaxed — stop and say so.
76
+
70
77
  Then state plainly, in the checklist: **which guards are now relaxed, that the safety set is still armed, and that this stays active until explicitly cleared (no TTL — active management, not a silent timer).**
71
78
 
72
79
  ### 4. Returning to normal
73
80
 
74
81
  ```bash
75
- node -e "import(require('url').pathToFileURL(process.env.LIFEAI_ENV + '/hooks/lib/rdc-flow.mjs').href).then(m=>{m.clearFlow();console.log('cleared')})"
82
+ node -e "import(require('url').pathToFileURL(process.env.LIFEAI_ENV + '/hooks/lib/rdc-flow.mjs').href).then(m=>{m.clearFlow();m.setFlowOrMode('normal');console.log(JSON.stringify(m.currentState(),null,2))})"
76
83
  ```
77
84
 
85
+ This clears both stores; a hotfix mode left open after the flow is cleared would keep guards relaxed.
86
+
78
87
  Do this **as soon as the incident is over**, or the moment the declared work-shape changes.
79
88
 
80
89
  ## Rules
package/git-sha.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "sha": "7174da1ee88b03b186bfc102a951ac2f770aec6c"
2
+ "sha": "9621389d4e4724ae260e0d4e91651c10b3ac0e2c"
3
3
  }
@@ -13,7 +13,12 @@
13
13
  * 1. work-item-exit-gate.js — exists in the installed rdc-skills hooks AND is
14
14
  * referenced by a registered hook command (PreToolUse).
15
15
  * 2. truth-gate.mjs — exists in the repo's .claude/hooks AND is
16
- * registered on Stop and SubagentStop in .claude/settings.json.
16
+ * reachable from Stop and SubagentStop: either registered directly in
17
+ * .claude/settings.json, or invoked by the stage dispatcher
18
+ * (hooks/stages/stop.mjs / subagent-stop.mjs) that event registers, per
19
+ * the post-2026-09-02 stage-merge architecture (AGENTS.md "Hook parity").
20
+ * repoRoot is resolved by walking up from cwd to the nearest
21
+ * .claude/settings.json (worktree-safe), never a hardcoded main-tree path.
17
22
  * 3. the LIVE hookify plugin hooks.json — the plugin's OWN hooks manifest under
18
23
  * ~/.claude/plugins/cache/.../hookify/<hash>/hooks/hooks.json.
19
24
  * ⛔ It deliberately does NOT trust the dead ~/.claude/hooks/hookify-*.js
@@ -34,13 +39,88 @@ const fs = require('fs');
34
39
  const os = require('os');
35
40
  const path = require('path');
36
41
 
37
- const REPO_ROOT = process.env.RDC_TRUTH_GATE_REPO || 'C:/Dev/regen-root';
38
42
  const HOOKS_DIR = __dirname; // installed rdc-skills hooks dir (where this file lives)
39
43
 
40
44
  function readJsonSafe(p) {
41
45
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
42
46
  }
43
47
 
48
+ /**
49
+ * Walk up from startCwd looking for a `.claude/settings.json` — the same
50
+ * convention codeflow-hooks-selfcheck.js already uses. This is what makes the
51
+ * watchdog worktree-safe: a lane session's cwd is its own worktree
52
+ * (C:/Dev/regen-root.wt/<lane>), never the main tree, and hardcoding
53
+ * C:/Dev/regen-root here would silently check a DIFFERENT checkout's files —
54
+ * exactly the cross-tree-hardcode anti-pattern the repo's own CLAUDE.md warns
55
+ * every lane session about. RDC_TRUTH_GATE_REPO remains a manual override for
56
+ * the rare case cwd isn't inside a worktree at all (e.g. a bare probe).
57
+ */
58
+ function findRepoRoot(startCwd) {
59
+ let dir = startCwd || process.cwd();
60
+ for (let i = 0; i < 8; i++) {
61
+ if (fs.existsSync(path.join(dir, '.claude', 'settings.json'))) return dir;
62
+ const parent = path.dirname(dir);
63
+ if (parent === dir) break;
64
+ dir = parent;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** Expand ${VAR:-default} / ${VAR} / $VAR references against process.env. */
70
+ function expandEnvRefs(str) {
71
+ return String(str)
72
+ .replace(/\$\{([A-Z_][A-Z0-9_]*):-([^}]*)\}/gi, (_, name, dflt) => process.env[name] || dflt)
73
+ .replace(/\$\{([A-Z_][A-Z0-9_]*)\}/gi, (_, name) => process.env[name] || '')
74
+ .replace(/\$([A-Z_][A-Z0-9_]*)/gi, (_, name) => process.env[name] || '');
75
+ }
76
+
77
+ /** Pull every quoted/bare .mjs or .js path out of a hook command string. */
78
+ function extractScriptPaths(command) {
79
+ const out = [];
80
+ const re = /["']([^"']+\.(?:mjs|js))["']|(\S+\.(?:mjs|js))/gi;
81
+ let m;
82
+ while ((m = re.exec(command))) out.push(expandEnvRefs(m[1] || m[2]));
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * Is `needle` reachable from this event's registered hook commands, either
88
+ * directly in the command text (old one-hook-per-registration style) or via a
89
+ * stage dispatcher (hooks/stages/stop.mjs, subagent-stop.mjs, ...) that the
90
+ * command invokes and whose OWN source references the needle?
91
+ *
92
+ * Since the 2026-09-02 stage merge (see AGENTS.md "Hook parity"), Claude Code
93
+ * registers ONE dispatcher per event that fans out internally — the literal
94
+ * hook filename never appears in settings.json's command text anymore, only
95
+ * inside the dispatcher script it points at. A plain substring check against
96
+ * commandText alone is a false positive under that architecture; this walks
97
+ * one hop into each referenced script before concluding "not registered".
98
+ */
99
+ function truthGateWiredForEvent(commandText, event, needle, { maxDepth = 2 } = {}) {
100
+ const lines = commandText.split('\n').filter((l) => l.startsWith(`${event}::`));
101
+ if (lines.some((l) => l.includes(needle))) return true;
102
+ const seen = new Set();
103
+ const visit = (scriptPath, depth) => {
104
+ if (depth > maxDepth) return false;
105
+ const norm = path.normalize(scriptPath).toLowerCase();
106
+ if (seen.has(norm)) return false;
107
+ seen.add(norm);
108
+ let src;
109
+ try { src = fs.readFileSync(scriptPath, 'utf8'); } catch (_) { return false; }
110
+ if (src.includes(needle)) return true;
111
+ for (const p of extractScriptPaths(src)) {
112
+ if (fs.existsSync(p) && visit(p, depth + 1)) return true;
113
+ }
114
+ return false;
115
+ };
116
+ for (const line of lines) {
117
+ for (const p of extractScriptPaths(line)) {
118
+ if (fs.existsSync(p) && visit(p, 1)) return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
44
124
  /** Serialize all hook command strings in a settings.json hooks object. */
45
125
  function settingsCommandText(settings) {
46
126
  if (!settings || !settings.hooks || typeof settings.hooks !== 'object') return '';
@@ -103,7 +183,11 @@ function deadWrapperPluginRoot(home) {
103
183
  * object consumed by the pure evaluator. Kept separate from evaluateWatchdog so
104
184
  * the decision logic is unit-testable without a filesystem.
105
185
  */
106
- function gatherFacts({ home = os.homedir(), repoRoot = REPO_ROOT, hooksDir = HOOKS_DIR } = {}) {
186
+ function gatherFacts({
187
+ home = os.homedir(),
188
+ repoRoot = findRepoRoot(process.cwd()) || process.env.RDC_TRUTH_GATE_REPO || 'C:/Dev/regen-root',
189
+ hooksDir = HOOKS_DIR,
190
+ } = {}) {
107
191
  const exitGatePath = path.join(hooksDir, 'work-item-exit-gate.js');
108
192
  const truthGatePath = path.join(repoRoot, '.claude', 'hooks', 'truth-gate.mjs');
109
193
  // Claude Code enforces the MERGED user+project hook set. A gate registered in
@@ -131,8 +215,8 @@ function gatherFacts({ home = os.homedir(), repoRoot = REPO_ROOT, hooksDir = HOO
131
215
  exitGateFileExists: fs.existsSync(exitGatePath),
132
216
  exitGateRegistered: registeredForEvent(commandText, 'PreToolUse', 'work-item-exit-gate.js'),
133
217
  truthGateFileExists: fs.existsSync(truthGatePath),
134
- truthGateOnStop: registeredForEvent(commandText, 'Stop', 'truth-gate.mjs'),
135
- truthGateOnSubagentStop: registeredForEvent(commandText, 'SubagentStop', 'truth-gate.mjs'),
218
+ truthGateOnStop: truthGateWiredForEvent(commandText, 'Stop', 'truth-gate.mjs'),
219
+ truthGateOnSubagentStop: truthGateWiredForEvent(commandText, 'SubagentStop', 'truth-gate.mjs'),
136
220
  liveHookifyManifestExists: Boolean(liveHookifyManifest),
137
221
  liveHookifyManifestPath: liveHookifyManifest,
138
222
  // Drift: a dead wrapper that points at a different cache hash than the live one.
@@ -253,5 +337,9 @@ if (require.main === module) {
253
337
  findLiveHookifyManifest,
254
338
  settingsCommandText,
255
339
  registeredForEvent,
340
+ findRepoRoot,
341
+ truthGateWiredForEvent,
342
+ extractScriptPaths,
343
+ expandEnvRefs,
256
344
  };
257
345
  }
@@ -119,9 +119,23 @@ const DEFAULT_SUPABASE_URL = 'https://uvojezuorjgqzmhhgluu.supabase.co';
119
119
  // ---------------------------------------------------------------------------
120
120
 
121
121
  /** True when the Bash command is a real `git commit` (not e.g. `git commit --help`). */
122
+ // `git` must be the command word: start of the text or after a shell separator
123
+ // (`;` `&&` `||` `|` `(` `$(` newline), optionally behind VAR=value assignments,
124
+ // with only git's own global options (`-C <dir>`, `-c k=v`, `--no-pager`, …)
125
+ // between it and `commit`. The old test — the words "git" and "commit" anywhere
126
+ // on one line — fired on prose inside a quoted argument (an issue body, a lesson
127
+ // description) and on `git log --grep commit`.
128
+ const GIT_COMMIT_INVOCATION = new RegExp(
129
+ String.raw`(?:^|[\n;&|(]|\$\()\s*` + // command position
130
+ String.raw`(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)*` + // VAR=value prefixes
131
+ 'git' +
132
+ String.raw`(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--?[A-Za-z][\w-]*(?:=(?:"[^"]*"|'[^']*'|\S+))?))*` + // global options
133
+ String.raw`\s+commit\b`,
134
+ );
135
+
122
136
  function isGitCommit(command) {
123
137
  if (typeof command !== 'string') return false;
124
- if (!/\bgit\b[^\n]*\bcommit\b/.test(command)) return false;
138
+ if (!GIT_COMMIT_INVOCATION.test(command)) return false;
125
139
  if (/\bcommit\b[^\n]*--help/.test(command) || /\bcommit\b[^\n]*\s-h\b/.test(command)) return false;
126
140
  return true;
127
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.37.4",
3
+ "version": "0.37.7",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -19,7 +19,7 @@
19
19
  "type": "plugin",
20
20
  "skills": "skills/",
21
21
  "guides": "guides/",
22
- "version": "0.37.4",
22
+ "version": "0.37.7",
23
23
  "commands": "commands/"
24
24
  },
25
25
  "bin": {
@@ -45,7 +45,7 @@
45
45
  "rdc-design": "node scripts/rdc-design-cli.mjs",
46
46
  "test:hooks": "node scripts/test-rdc-hooks.mjs",
47
47
  "test:truth-gate": "node tests/run-evidence-gate.test.mjs && node tests/work-item-exit-gate-l2.test.mjs && node tests/work-item-exit-gate-l3.test.mjs && node tests/require-work-item-on-commit.test.mjs && node tests/harness-gates.test.mjs && node tests/foreground-process-gate.test.mjs",
48
- "test:acceptance": "node tests/acceptance.test.mjs && node tests/install-rdc-skills.test.mjs && node tests/help-surface.test.mjs && node tests/manifest-contract-fields.test.mjs && node --test tests/manifest-version-parity.test.mjs && node tests/plugin-namespace-names.test.mjs && node tests/skill-test-matrix.test.mjs && node tests/completion-gate-supervisor-admission.test.mjs && node tests/curl-surface.test.mjs && node tests/clauth-plugin-postinstall.test.mjs && node tests/install-duplicate-purge.test.mjs",
48
+ "test:acceptance": "node tests/acceptance.test.mjs && node tests/install-rdc-skills.test.mjs && node tests/help-surface.test.mjs && node tests/manifest-contract-fields.test.mjs && node --test tests/manifest-version-parity.test.mjs && node tests/plugin-namespace-names.test.mjs && node tests/skill-test-matrix.test.mjs && node tests/completion-gate-supervisor-admission.test.mjs && node tests/curl-surface.test.mjs && node tests/clauth-plugin-postinstall.test.mjs && node tests/install-duplicate-purge.test.mjs && node --test tests/install-stale-checkout.test.mjs tests/publish-manifest-staging-gates.test.mjs",
49
49
  "acceptance": "node scripts/acceptance.mjs --changed",
50
50
  "test:mcp": "node tests/mcp.test.mjs",
51
51
  "test:mcp:remote": "node tests/mcp.test.mjs --remote",
@@ -30,7 +30,7 @@ const ENGINE = arg('--engine', process.env.RDC_ACCEPTANCE_ENGINE || 'claude').to
30
30
  const BASE = arg('--base', process.env.RDC_ACCEPTANCE_BASE || 'HEAD~1');
31
31
  const PROJECT_CWD = resolve(arg('--project-root', process.env.REGEN_ROOT || process.cwd()));
32
32
  const RUN_ID = arg('--run-id', `acceptance-${new Date().toISOString().replace(/[:.]/g, '-')}`);
33
- const PARALLEL = Math.max(1, parseInt(arg('--parallel', '1'), 10) || 1);
33
+ const PARALLEL = Math.max(1, Number.parseInt(arg('--parallel', '1'), 10) || 1);
34
34
  const CHANGED = has('--changed');
35
35
  const STRICT_RECORDING = has('--strict-recording');
36
36
  const ONLY_SKILLS = args
@@ -488,7 +488,10 @@ export { assistantText, codexToolCalls, normalizeSkillName };
488
488
 
489
489
  const isMain = (() => {
490
490
  try {
491
- return import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href;
491
+ // Compare paths, not URLs: import.meta.url percent-encodes `~` (%7E) and a
492
+ // hand-built URL keeps it, so under a Windows 8.3 path (C:\Users\NAME~1\...)
493
+ // the URL comparison was never equal and main() silently never ran (exit 0).
494
+ return fileURLToPath(import.meta.url) === resolve(process.argv[1]);
492
495
  } catch {
493
496
  return false;
494
497
  }
@@ -30,7 +30,7 @@ const fs = require('fs');
30
30
  const path = require('path');
31
31
  const os = require('os');
32
32
  const readline = require('readline');
33
- const { execSync } = require('child_process');
33
+ const { execSync, execFileSync } = require('child_process');
34
34
 
35
35
  // ── Args ──────────────────────────────────────────────────────────────────────
36
36
  const args = process.argv.slice(2);
@@ -568,6 +568,26 @@ function cleanGlobalSkillsRoot(skillsDir) {
568
568
  return removed;
569
569
  }
570
570
 
571
+ // ── Stale checkout warning (LIFEAI/regen-root#230) ────────────────────────────
572
+ // The installer links .claude/skills and copies hooks from ITS OWN package root.
573
+ // Run from a dev clone parked on an old branch, it serves that branch's skills to
574
+ // every session. `git pull` above only fast-forwards the current branch, so a
575
+ // feature branch stays behind master after it. Warn, never refuse — installing
576
+ // from a branch is sometimes the point. Returns { behind, branch } or null when
577
+ // root is not a git checkout (an npm install) or origin/master is unknown.
578
+ function checkoutBehindMaster(root) {
579
+ if (!fs.existsSync(path.join(root, '.git'))) return null;
580
+ const run = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' }).trim();
581
+ try {
582
+ const behind = Number(run(['rev-list', '--count', 'HEAD..origin/master']));
583
+ let branch = '';
584
+ try { branch = run(['branch', '--show-current']); } catch {}
585
+ return { behind, branch: branch || '(detached)' };
586
+ } catch {
587
+ return null;
588
+ }
589
+ }
590
+
571
591
  // ── Stale hook cleanup ────────────────────────────────────────────────────────
572
592
  // Remove ONLY explicitly orphaned hook files — hooks that were previously shipped
573
593
  // by rdc-skills and have since been removed from the project.
@@ -579,6 +599,10 @@ function cleanStaleHooks(hooksDstDir) {
579
599
  // Format: filename that should be deleted if it still exists.
580
600
  const ORPHANED_HOOKS = [
581
601
  'verify-rdc-skills.js', // removed in v0.9.7 — was checking for old flat-file format
602
+ // removed in v0.37.5 — shipped behind env flags nothing ever set, so every
603
+ // call was a no-op (LIFEAI/regen-root#237).
604
+ 'task-completed-gate.js',
605
+ 'post-tool-batch-gate.js',
582
606
  ];
583
607
  let removed = 0;
584
608
  for (const f of ORPHANED_HOOKS) {
@@ -927,7 +951,7 @@ function findCodexTargets() {
927
951
  return targets;
928
952
  }
929
953
 
930
- function registerCodexTarget(targetDir) {
954
+ function registerCodexTarget(targetDir, { install = false } = {}) {
931
955
  fs.mkdirSync(targetDir, { recursive: true });
932
956
 
933
957
  // Clean: remove dirs that are rdc skills — by prefix OR by frontmatter name
@@ -943,11 +967,21 @@ function registerCodexTarget(targetDir) {
943
967
  }
944
968
  }
945
969
 
946
- // Do not reinstall file-based copies. Codex discovers the live catalog via
947
- // [mcp_servers.rdc-skills]. Keeping the same skills in ~/.codex and ~/.agents
948
- // registers every skill two or three times, exhausts Codex's skill metadata
949
- // budget, and emits a red startup error on every session.
950
- return { removed, copied: 0 };
970
+ if (!install) return { removed, copied: 0 };
971
+
972
+ // Install into exactly ONE Codex skill dir. Copies in both ~/.codex and
973
+ // ~/.agents registered every skill twice and exhausted Codex's skill metadata
974
+ // budget; bf88292 answered that by installing none, which left Codex with
975
+ // rdc: skills only while the remote MCP was reachable.
976
+ const skillsSrc = path.join(repoRoot, 'skills');
977
+ let copied = 0;
978
+ for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
979
+ if (!entry.isDirectory()) continue;
980
+ if (!fs.existsSync(path.join(skillsSrc, entry.name, 'SKILL.md'))) continue;
981
+ copyDirRecursive(path.join(skillsSrc, entry.name), path.join(targetDir, `rdc-${entry.name}`));
982
+ copied++;
983
+ }
984
+ return { removed, copied };
951
985
  }
952
986
 
953
987
  // ── Step 6: Zip for claude.ai / distribution ─────────────────────────────────
@@ -1063,15 +1097,6 @@ function buildHooksConfig(hooksDir, profile = 'core') {
1063
1097
  config.PreToolUse[0].hooks.push(
1064
1098
  cmd('work-item-exit-gate.js', 'Checking work item exit gates...'),
1065
1099
  );
1066
- // Truth Gate 3.0 Layer 5 — harness completion gates. Both blocking hooks are
1067
- // FLAG-GATED, default OFF (no-op until the env/DB flag is flipped at deploy),
1068
- // so registering them does NOT disrupt the in-flight build session.
1069
- config.TaskCompleted = [{ hooks: [
1070
- cmd('task-completed-gate.js', 'Truth Gate: verifying task closure...'),
1071
- ]}];
1072
- config.PostToolBatch = [{ hooks: [
1073
- cmd('post-tool-batch-gate.js', 'Truth Gate: checking build-wave worktree bases...'),
1074
- ]}];
1075
1100
  config.PreCompact = [{ hooks: [
1076
1101
  cmd('precompact-log.js'),
1077
1102
  ]}];
@@ -1271,6 +1296,14 @@ async function main() {
1271
1296
  } catch {
1272
1297
  warn('[0/6] git pull failed — installing from local copy');
1273
1298
  }
1299
+ {
1300
+ const stale = checkoutBehindMaster(repoRoot);
1301
+ if (stale && stale.behind > 0) {
1302
+ warn(`[0/6] this checkout (${stale.branch}) is ${stale.behind} commit(s) behind origin/master — `
1303
+ + 'every session will get these older skills and hooks.');
1304
+ warn(' Install from the published package instead: npm i -g @lifeaitools/rdc-skills && rdc-skills-install');
1305
+ }
1306
+ }
1274
1307
 
1275
1308
  // Read version + git SHA after pull so plugin caches and the MCP install do
1276
1309
  // not stamp the pre-update checkout.
@@ -1349,21 +1382,24 @@ async function main() {
1349
1382
  info('[2.5] MCP — rdc-skills endpoint verified (claude + codex)');
1350
1383
  }
1351
1384
 
1352
- // 2.6. Codex migration: MCP is authoritative; purge legacy file copies.
1385
+ // 2.6. Codex: rdc skill files in ONE dir (the explicit one, else ~/.codex/skills);
1386
+ // every other Codex skill dir is only cleaned, so nothing registers twice.
1353
1387
  const codexTargets = findCodexTargets();
1354
- if (codexTargets.length > 0) {
1355
- let copiedTotal = 0;
1356
- let removedTotal = 0;
1357
- for (const target of codexTargets) {
1358
- const { removed, copied } = registerCodexTarget(target.targetDir);
1359
- copiedTotal += copied;
1360
- removedTotal += removed;
1361
- info(` ${target.label.padEnd(15)}: ${target.targetDir} (${removed} stale removed; MCP authoritative)`);
1362
- }
1363
- ok(`[2.6] Codex — ${removedTotal} stale file-based skill(s) removed across ${codexTargets.length} target(s); MCP authoritative`);
1364
- } else {
1365
- info('[2.6] Codex — skipped (no Codex skill dirs found; use --codex-root or --codex-skill-dir)');
1388
+ const primaryCodexDir = explicitCodexSkillDir || path.join(os.homedir(), '.codex', 'skills');
1389
+ if (!codexTargets.some((t) => t.targetDir === primaryCodexDir)) {
1390
+ codexTargets.unshift({ label: 'global .codex', targetDir: primaryCodexDir });
1391
+ }
1392
+ let copiedTotal = 0;
1393
+ let removedTotal = 0;
1394
+ for (const target of codexTargets) {
1395
+ const install = target.targetDir === primaryCodexDir;
1396
+ const { removed, copied } = registerCodexTarget(target.targetDir, { install });
1397
+ copiedTotal += copied;
1398
+ removedTotal += removed;
1399
+ info(` ${target.label.padEnd(15)}: ${target.targetDir} (${install ? `${copied} installed` : 'cleaned only'}, ${removed} stale removed)`);
1366
1400
  }
1401
+ if (copiedTotal === 0) throw new Error(`Codex skill install wrote nothing to ${primaryCodexDir}`);
1402
+ ok(`[2.6] Codex — ${copiedTotal} skill(s) installed in ${primaryCodexDir}; ${removedTotal} stale copies removed across ${codexTargets.length} dir(s)`);
1367
1403
 
1368
1404
  // 2.7. Symlinks in regen-root/.claude/skills/ (FS MCP + claude.ai access)
1369
1405
  if (codexRoot) {
@@ -1533,7 +1569,7 @@ async function main() {
1533
1569
  // scripts/probe-installed-hooks.mjs verify the real install path instead of
1534
1570
  // re-implementing it — a probe that copies its own way proves nothing about what
1535
1571
  // ships. Without this guard, requiring the module would run a full install.
1536
- module.exports = { copyHookFiles, assertHooksLoadable, registerCodexTarget, syncMarketplaceCheckout, RDC_ENV_HOOK_TIMEOUT_SEC, isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames, isProvablyOurRdcSkill, disposeRdcDuplicate, rdcQuarantineDir };
1572
+ module.exports = { checkoutBehindMaster, copyHookFiles, assertHooksLoadable, registerCodexTarget, syncMarketplaceCheckout, RDC_ENV_HOOK_TIMEOUT_SEC, isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames, isProvablyOurRdcSkill, disposeRdcDuplicate, rdcQuarantineDir };
1537
1573
 
1538
1574
  if (require.main === module) {
1539
1575
  main().catch(e => { fail(e.message); process.exit(1); });
@@ -240,10 +240,7 @@ function parseFrontmatter(content) {
240
240
  function parseSurfaceIds(content) {
241
241
  const ids = [];
242
242
  const re = /<!--\s*SURFACE:([a-zA-Z0-9_-]+)\s*-->/g;
243
- let m;
244
- while ((m = re.exec(content)) !== null) {
245
- ids.push(m[1]);
246
- }
243
+ for (const m of content.matchAll(re)) ids.push(m[1]);
247
244
  return ids;
248
245
  }
249
246
 
@@ -392,6 +389,32 @@ function validateApp(slug) {
392
389
  return { fm, surfaceIds, publishPath };
393
390
  }
394
391
 
392
+ /**
393
+ * `staging_gates:` must be a MAPPING (` tsc_clean: true`), never a list
394
+ * (` - tsc_clean`) — the shape scripts/gen-deploy-blocks.mjs emits. A list was
395
+ * once hand-written and parsed as the wrong type downstream. Returns an error
396
+ * string, or null when the key is absent or correctly shaped.
397
+ */
398
+ function stagingGatesShapeError(body) {
399
+ const lines = String(body).split(/\r?\n/);
400
+ const at = lines.findIndex((l) => /^staging_gates:\s*(#.*)?$/.test(l));
401
+ if (at === -1) {
402
+ const inline = lines.find((l) => /^staging_gates:\s*\S/.test(l) && !/^staging_gates:\s*#/.test(l));
403
+ return inline ? `DEPLOY block staging_gates must be a mapping on its own lines, not inline: '${inline.trim()}'` : null;
404
+ }
405
+ const entries = [];
406
+ for (const l of lines.slice(at + 1)) {
407
+ if (!l.trim() || /^\s*#/.test(l)) continue;
408
+ if (!/^\s/.test(l)) break; // next top-level key
409
+ entries.push(l.trim());
410
+ }
411
+ const listItem = entries.find((e) => e.startsWith('-'));
412
+ if (listItem) return `DEPLOY block staging_gates must be a mapping (key: value), not a list item: '${listItem}'`;
413
+ const bad = entries.find((e) => !/^[A-Za-z0-9_]+:\s*\S/.test(e));
414
+ if (bad) return `DEPLOY block staging_gates entry is not 'key: value': '${bad}'`;
415
+ return null;
416
+ }
417
+
395
418
  /**
396
419
  * Validate the <!-- DEPLOY --> block. Returns true iff valid.
397
420
  * Contract: .claude/rules/app-deploy-manifest.md (port-from-registry, Approved 2026-07-05).
@@ -412,6 +435,12 @@ function validateDeployBlock(slug, content, publishPath) {
412
435
  }
413
436
  }
414
437
 
438
+ const gatesError = stagingGatesShapeError(body);
439
+ if (gatesError) {
440
+ emit('FAIL', slug, gatesError, publishPath);
441
+ ok = false;
442
+ }
443
+
415
444
  const portLine = body.match(/^port:\s*([^\s#]+)/m);
416
445
  if (portLine) {
417
446
  const val = portLine[1].trim();
@@ -525,7 +554,11 @@ async function main() {
525
554
  process.exit(hasHardFail ? 1 : 0);
526
555
  }
527
556
 
528
- main().catch((err) => {
529
- console.error(`FATAL: ${err.message}`);
530
- process.exit(1);
531
- });
557
+ if (require.main === module) {
558
+ main().catch((err) => {
559
+ console.error(`FATAL: ${err.message}`);
560
+ process.exit(1);
561
+ });
562
+ } else {
563
+ module.exports = { stagingGatesShapeError };
564
+ }
@@ -178,10 +178,16 @@ function checkCodex(truth) {
178
178
  const rdcDirs = dirs.filter((e) => /^rdc-/.test(e.name) || readFrontmatterName(path.join(t.dir, e.name, 'SKILL.md'))?.startsWith('rdc:'));
179
179
  results.push({ label: t.label, dir: t.dir, count: rdcDirs.length });
180
180
  }
181
- const pass = results.every((r) => r.count === 0);
182
- const detail = results.map((r) =>
183
- `${r.label}: ${r.count} legacy file-based rdc skill(s)${r.count === 0 ? '' : ' [MCP DUPLICATES PRESENT]'}`
184
- ).join('; ');
181
+ // Codex keeps rdc skills as files in exactly one dir (~/.codex/skills), so it
182
+ // works without the network; any copy elsewhere registers the skill twice.
183
+ const skillsSrc = path.join(repoRoot, 'skills');
184
+ const expected = fs.readdirSync(skillsSrc, { withFileTypes: true })
185
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(skillsSrc, e.name, 'SKILL.md'))).length;
186
+ const isPrimary = (r) => r.label === 'global .codex';
187
+ const pass = results.some(isPrimary) && results.every((r) => (isPrimary(r) ? r.count === expected : r.count === 0));
188
+ const detail = results.map((r) => (isPrimary(r)
189
+ ? `${r.label}: ${r.count}/${expected} rdc skill(s) installed`
190
+ : `${r.label}: ${r.count} rdc skill(s)${r.count === 0 ? '' : ' [DUPLICATES — would register twice]'}`)).join('; ');
185
191
  return { surface: 'Codex', pass, detail };
186
192
  }
187
193
 
@@ -747,6 +747,10 @@ NEVER run pnpm build or pnpm turbo. Use npx vitest run only.
747
747
  - Unattended: max 2 retries per task before escalating to advisor
748
748
  - Every Agent() dispatch: `model: <routed>` + `max_turns: 70` — non-negotiable. Every dispatched writer uses `isolation: "worktree"` after the HARD GATE; non-isolated agents are read-only. Model is chosen per task per the routing table in Step 7. Exception: the validator omits isolation because it is read-only and must inspect merged source.
749
749
  - Finding an existing file is NOT task completion — verify it satisfies the spec
750
+ - **A blocker only a human can clear** (a secret to set, an approval, a manual prod step):
751
+ file it as a `human_items` row, then `node "$LIFEAI_ENV/bin/rdc-work.mjs" hold --work-item <leaf-uuid> --reason "<blocker> — human_items <id>"`
752
+ (and the same for the epic), and end the turn. `hold` sets `blocked` and releases the
753
+ Stop gate; leaving the item `in_progress` keeps the completion gate firing on work nobody can do.
750
754
 
751
755
  ## Capture lessons (exit step)
752
756
 
@@ -502,6 +502,8 @@ chitchat MCP + SSE; you are the build half of a live session.
502
502
  or a non-200 is a real failure (lesson `2026-06-08-collab-sse-exit-28-is-success`).
503
503
  - Poll fallback: `chitchat_poll` at 2s — `{status:"idle"}` keep polling,
504
504
  `{status:"ready", message}` consume.
505
+ - A `build-*-verify` session closes itself after the ready handshake. A later
506
+ `not_found` on poll there is expected, not a failure.
505
507
  - `type: stop` ends the session; send a final summary, then `chitchat_stop`.
506
508
  - Stream progress mid-work with `chitchat_reply` on long tasks.
507
509
 
@@ -27,6 +27,20 @@ Run it from the regen-root checkout you are working in. That is the whole
27
27
  instruction. There is no checklist to follow, no mode to pick, and no decision
28
28
  for you to make on the success path.
29
29
 
30
+ ## Local build contract
31
+
32
+ Standard dev deployment builds on the workstation first. `DEPLOY_MGR_URL`
33
+ must name a loopback manager whose health response reports `host.role: local`.
34
+ The program requests `builder: local` explicitly; Docker or build failure stops
35
+ the deployment. It never falls back to a remote builder.
36
+
37
+ The target must have a registered image route. Legacy remote build/reload routes
38
+ are refused until a proper local artifact build-and-ship route is implemented.
39
+ Do not enable an image route without its runtime and build configuration.
40
+
41
+ The manager's explicitly requested remote maintenance operations remain separate
42
+ from this standard command. `promote` remains build-free and approval-gated.
43
+
30
44
  ## Exit 0 — you are done
31
45
 
32
46
  Print the URL the program reports, and stop.
@@ -46,8 +60,8 @@ followed by the same record as one line of JSON.
46
60
  | exit | meaning | first move |
47
61
  |---|---|---|
48
62
  | 2 | usage — bad or missing arguments | read the usage line; this program takes a slug and optionally `promote` |
49
- | 3 | deploy manager unreachable | `pm2 restart regen-deploy-mgr` (it is a local PM2 process), then re-run |
50
- | 4 | slug not resolvable or not routable | the block names what is missing — a registry row, or an allowlist entry |
63
+ | 3 | deploy manager unavailable or unsuitable for local building | read the block; restore the workstation manager and its local host identity, then re-run |
64
+ | 4 | slug not resolvable or no local artifact route | the block names the missing registry or image route; repair that route before deployment |
51
65
  | 5 | a submitted job failed or was rejected | the block carries the manager's own step, command, exit code and stderr tail |
52
66
  | 6 | the job succeeded but the health probe did not | bytes shipped, app did not come up — read the job the block names |
53
67
  | 7 | polling exceeded the deadline | the job is NOT cancelled; the block gives the URL to read it |
@@ -110,9 +124,10 @@ manager, where it runs, rather than in a document that hopes to be read.
110
124
 
111
125
  A dev deploy needs no approval. `promote` puts bytes on production and needs
112
126
  explicit go-ahead for that call. The program itself refuses to promote anything
113
- that is not an image-model app, because promote retags the digest dev already
114
- runs — **a promote never builds**, and one that fell back to building would ship
115
- bytes production has never seen under a name claiming it had.
127
+ that is not an image-model app. **A promote never builds**; it retags an existing
128
+ image through the manager. The current manager resolves the configured dev tag
129
+ (normally `develop`); a successful dev deploy alone does not prove that this
130
+ mutable tag still identifies the running dev image when promotion begins.
116
131
 
117
132
  ## Verification
118
133