@lifeaitools/rdc-skills 0.37.4 → 0.37.5
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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +23 -0
- package/clauth-plugin.json +1 -1
- package/commands/flow.md +12 -3
- package/git-sha.json +1 -1
- package/hooks/gate-watchdog-selfcheck.js +93 -5
- package/hooks/require-work-item-on-commit.js +15 -1
- package/package.json +3 -3
- package/scripts/acceptance.mjs +5 -2
- package/scripts/install-rdc-skills.js +34 -11
- package/scripts/validate-publish-manifests.js +41 -8
- package/skills/build/SKILL.md +4 -0
- package/skills/collab/SKILL.md +2 -0
- package/skills/edit/SKILL.md +8 -0
- package/skills/fixit/SKILL.md +24 -4
- package/skills/plan/SKILL.md +2 -0
- package/skills/release/SKILL.md +12 -3
- package/skills/status/SKILL.md +3 -5
- package/skills/terminal-config/SKILL.md +5 -0
- package/tests/harness-gates.test.mjs +24 -172
- package/tests/install-stale-checkout.test.mjs +42 -0
- package/tests/publish-manifest-staging-gates.test.mjs +30 -0
- package/tests/require-work-item-on-commit.test.mjs +13 -0
- package/hooks/post-tool-batch-gate.js +0 -203
- package/hooks/task-completed-gate.js +0 -274
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
|
package/clauth-plugin.json
CHANGED
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 —
|
|
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.
|
|
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();
|
|
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
|
@@ -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
|
-
*
|
|
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({
|
|
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:
|
|
135
|
-
truthGateOnSubagentStop:
|
|
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 (
|
|
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.
|
|
3
|
+
"version": "0.37.5",
|
|
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.
|
|
22
|
+
"version": "0.37.5",
|
|
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",
|
package/scripts/acceptance.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
@@ -1063,15 +1087,6 @@ function buildHooksConfig(hooksDir, profile = 'core') {
|
|
|
1063
1087
|
config.PreToolUse[0].hooks.push(
|
|
1064
1088
|
cmd('work-item-exit-gate.js', 'Checking work item exit gates...'),
|
|
1065
1089
|
);
|
|
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
1090
|
config.PreCompact = [{ hooks: [
|
|
1076
1091
|
cmd('precompact-log.js'),
|
|
1077
1092
|
]}];
|
|
@@ -1271,6 +1286,14 @@ async function main() {
|
|
|
1271
1286
|
} catch {
|
|
1272
1287
|
warn('[0/6] git pull failed — installing from local copy');
|
|
1273
1288
|
}
|
|
1289
|
+
{
|
|
1290
|
+
const stale = checkoutBehindMaster(repoRoot);
|
|
1291
|
+
if (stale && stale.behind > 0) {
|
|
1292
|
+
warn(`[0/6] this checkout (${stale.branch}) is ${stale.behind} commit(s) behind origin/master — `
|
|
1293
|
+
+ 'every session will get these older skills and hooks.');
|
|
1294
|
+
warn(' Install from the published package instead: npm i -g @lifeaitools/rdc-skills && rdc-skills-install');
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1274
1297
|
|
|
1275
1298
|
// Read version + git SHA after pull so plugin caches and the MCP install do
|
|
1276
1299
|
// not stamp the pre-update checkout.
|
|
@@ -1533,7 +1556,7 @@ async function main() {
|
|
|
1533
1556
|
// scripts/probe-installed-hooks.mjs verify the real install path instead of
|
|
1534
1557
|
// re-implementing it — a probe that copies its own way proves nothing about what
|
|
1535
1558
|
// 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 };
|
|
1559
|
+
module.exports = { checkoutBehindMaster, copyHookFiles, assertHooksLoadable, registerCodexTarget, syncMarketplaceCheckout, RDC_ENV_HOOK_TIMEOUT_SEC, isRdcSkillDuplicate, cleanUserSkills, cleanGlobalSkillsRoot, shippedSkillNames, isProvablyOurRdcSkill, disposeRdcDuplicate, rdcQuarantineDir };
|
|
1537
1560
|
|
|
1538
1561
|
if (require.main === module) {
|
|
1539
1562
|
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
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
+
}
|
package/skills/build/SKILL.md
CHANGED
|
@@ -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
|
|
package/skills/collab/SKILL.md
CHANGED
|
@@ -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
|
|
package/skills/edit/SKILL.md
CHANGED
|
@@ -93,6 +93,14 @@ launch if it is dead.
|
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
### 4. Open the page
|
|
96
|
+
- **Probe the resolved `targetUrl` before calling the preview open** (skip for
|
|
97
|
+
`brandSlug=test`). The editor host on 3015 can be up while the iframe target
|
|
98
|
+
returns a 500 (e.g. a Supabase-gated app with no local `.env.local`):
|
|
99
|
+
```bash
|
|
100
|
+
curl -s -o /dev/null -w "%{http_code}" <targetUrl>/
|
|
101
|
+
```
|
|
102
|
+
Non-2xx: do not report the preview as open. Report the status code and the first
|
|
103
|
+
error line from the target's dev-server log, and stop.
|
|
96
104
|
- Normal use: open the URL in the browser and confirm the editor loaded.
|
|
97
105
|
Open via PowerShell `Start-Process`, NOT `cmd start` — `cmd` treats the `&`
|
|
98
106
|
between query params as a command separator and truncates the URL:
|
package/skills/fixit/SKILL.md
CHANGED
|
@@ -50,11 +50,18 @@ SELECT insert_work_item(
|
|
|
50
50
|
p_item_type := 'bug',
|
|
51
51
|
p_priority := 'urgent',
|
|
52
52
|
p_status := 'in_progress',
|
|
53
|
-
p_source := 'fixit'
|
|
53
|
+
p_source := 'fixit',
|
|
54
|
+
p_session_id := '<agent-session-id>',
|
|
55
|
+
p_checklist := '[
|
|
56
|
+
{"id":"decomp-fix","text":"<the fix, one line>","required":true,"checked":false},
|
|
57
|
+
{"id":"test-fix-proof","text":"<command that proves the defect is gone> exits 0","required":true,"checked":false}
|
|
58
|
+
]'::jsonb
|
|
54
59
|
);
|
|
55
60
|
```
|
|
56
61
|
|
|
57
|
-
Note the returned `id`.
|
|
62
|
+
Note the returned `id`. The two rows are not optional: `update_work_item_status(...,'done')`
|
|
63
|
+
refuses any non-epic item without a required `decomp-*` AND a required `test-*` row.
|
|
64
|
+
`p_session_id` must be this session — only it may tick them.
|
|
58
65
|
|
|
59
66
|
### 2a. Declare the fix and its proof (before touching any code)
|
|
60
67
|
|
|
@@ -99,6 +106,7 @@ files, and verify behavior before moving the work item to `review`.
|
|
|
99
106
|
|
|
100
107
|
```bash
|
|
101
108
|
git add <specific files only — never git add -A for a fixit>
|
|
109
|
+
git diff --cached --stat # every staged file and hunk must be yours
|
|
102
110
|
git commit -m "fix(<scope>): <description>"
|
|
103
111
|
if [ "$RDC_TEST" != "1" ]; then
|
|
104
112
|
git push origin {development-branch}
|
|
@@ -107,6 +115,10 @@ else
|
|
|
107
115
|
fi
|
|
108
116
|
```
|
|
109
117
|
|
|
118
|
+
`git add <file>` stages every hunk in that file, including another session's
|
|
119
|
+
uncommitted edit. If a file was already dirty before you touched it, stage with
|
|
120
|
+
`git add -p <file>` and take only your hunks.
|
|
121
|
+
|
|
110
122
|
### 5.5 Mandatory code-review gate (before submitting implementation report)
|
|
111
123
|
|
|
112
124
|
⛔ **No fixit closes without a code-review pass.** Even single-file changes go through review.
|
|
@@ -144,7 +156,7 @@ complete it:
|
|
|
144
156
|
|
|
145
157
|
| Target shape | "Done" = delivered means | How + structural proof |
|
|
146
158
|
|---|---|---|
|
|
147
|
-
| Published package (npm / PyPI) | new version live on the registry | run the repo's release/publish path (e.g. `npm publish --access public`); verify `
|
|
159
|
+
| Published package (npm / PyPI) | new version live on the registry | run the repo's release/publish path (e.g. `npm publish --access public`); verify `curl -fsS https://registry.npmjs.org/<pkg>/<version>` returns 200 (exact version; `npm view` lags the registry) |
|
|
148
160
|
| Deployed app (`apps/*`, sites) | change live on the running host | `/rdc:deploy <slug>` (dev) / `promote` (prod); verify HTTP 200 + content probe |
|
|
149
161
|
| Shared lib consumed in-repo | landed on the integration branch | `node scripts/land.mjs` (or repo equivalent); verify branch contains the SHA |
|
|
150
162
|
| Standalone repo | its own release ritual completed | follow the repo's release script/tag; verify the artifact/tag exists |
|
|
@@ -172,11 +184,15 @@ is fleet-wide yet — its absence is not itself a fixit failure.
|
|
|
172
184
|
|
|
173
185
|
### 6. Close and clean up
|
|
174
186
|
|
|
175
|
-
|
|
187
|
+
Tick the two checklist rows from Step 2 as the originating session (after the proof
|
|
188
|
+
command passes), submit the implementation report, move to review, then close as validator.
|
|
176
189
|
**Precondition: Step 5.7 delivery is verified.** If delivery is blocked, set status
|
|
177
190
|
`blocked` (not `done`) with the remaining step — never mark `done` on an undelivered change:
|
|
178
191
|
|
|
179
192
|
```sql
|
|
193
|
+
SELECT update_checklist_item('<id>'::uuid, 'decomp-fix', true, '<agent-session-id>', 'agent');
|
|
194
|
+
SELECT update_checklist_item('<id>'::uuid, 'test-fix-proof', true, '<agent-session-id>', 'agent');
|
|
195
|
+
|
|
180
196
|
SELECT submit_implementation_report('<id>'::uuid,
|
|
181
197
|
'{"tldr":"<one sentence>","assumptions":[],"deviations":[],"uncertainty":[],"detail":"<what was fixed>","flags":[],"transactional":false,"memory_records":[],"codeflow_post":{"agent_session_id":"<agent-session-id>","summary":"<what changed and why>","files_changed":["<path>"],"verification":["<command/evidence>"],"commit":"<hash optional>"}}'::jsonb
|
|
182
198
|
);
|
|
@@ -194,6 +210,10 @@ SELECT update_work_item_status('<id>'::uuid, 'done',
|
|
|
194
210
|
);
|
|
195
211
|
```
|
|
196
212
|
|
|
213
|
+
If `done` is rejected as `close_blocked_uninstrumented` (an item created without the
|
|
214
|
+
rows), add them and tick them, then retry:
|
|
215
|
+
`SELECT append_checklist_item('<id>'::uuid, 'test-fix-proof', '<proof>', true, '<agent-session-id>', 'agent');`
|
|
216
|
+
|
|
197
217
|
If the fix touched a transactional flow, API boundary, or package contract, set `"transactional": true` and populate `memory_records` (see `agent-bootstrap.md`), then run:
|
|
198
218
|
```bash
|
|
199
219
|
node scripts/work-item-memory.mjs <work-item-id>
|
package/skills/plan/SKILL.md
CHANGED
|
@@ -317,6 +317,8 @@ choose the most conservative/reversible approach and document the decision.
|
|
|
317
317
|
- Interactive: ALWAYS get approval before proceeding to build
|
|
318
318
|
- Unattended: proceed immediately without approval
|
|
319
319
|
- Plan doc goes in `.rdc/plans/` (fallback: `.rdc/plans/` if `.rdc/` does not exist) — not `.planning/`
|
|
320
|
+
- An architecture or spec draft that has not passed its approval interview goes in
|
|
321
|
+
`.rdc/research/<topic>-draft.md`. Nothing is written under `docs/systems/**` until it is approved.
|
|
320
322
|
- Each work package must be independently executable by an agent
|
|
321
323
|
- No file overlap between work packages
|
|
322
324
|
- Include test requirements in every work package
|
package/skills/release/SKILL.md
CHANGED
|
@@ -34,6 +34,7 @@ If `<repo>` is not resolvable from the current workspace, ask for its local path
|
|
|
34
34
|
rdc:release: <repo> vX.Y.Z -> vA.B.C
|
|
35
35
|
[ ] PUBLISH.md status gate: status=active AND prod in environments (block if not)
|
|
36
36
|
[ ] Source path resolved
|
|
37
|
+
[ ] <repo>/RELEASE.md read (if present) — its steps override the generic flow below
|
|
37
38
|
[ ] Release metadata read
|
|
38
39
|
[ ] Working tree clean or user-approved dirty scope identified
|
|
39
40
|
[ ] Current version detected
|
|
@@ -81,8 +82,13 @@ these was in the command and NOT in this file, which is precisely the drift that
|
|
|
81
82
|
shipping one verb as two documents produces.
|
|
82
83
|
|
|
83
84
|
- Do not release without explicit user authorization.
|
|
84
|
-
-
|
|
85
|
-
|
|
85
|
+
- **Read `<repo>/RELEASE.md` first.** When it exists it is the release procedure
|
|
86
|
+
and overrides everything generic in this skill (branch, who publishes — a tag
|
|
87
|
+
workflow, a push hook or you — and post-publish gates). Otherwise prefer
|
|
88
|
+
`.rdc/release.json`, README, package scripts, or CI workflows.
|
|
89
|
+
- **`E403`/`E409` "cannot publish over the previously published version" right
|
|
90
|
+
after a push or tag usually means CI already published it.** Verify the exact
|
|
91
|
+
version on the registry; never bump again to get past it.
|
|
86
92
|
- **Never force push or bypass hooks.**
|
|
87
93
|
- **Never declare success without verifying the installed or deployed version.**
|
|
88
94
|
A publish that exits 0 is not a published package — the registry can lag
|
|
@@ -123,7 +129,10 @@ node scripts/rdc-release.mjs --repo <repo-path> \
|
|
|
123
129
|
--message "chore(release): vA.B.C" --tag vA.B.C
|
|
124
130
|
git push origin HEAD
|
|
125
131
|
git push origin vA.B.C
|
|
126
|
-
npm view
|
|
132
|
+
# exact-version fetch: 200 = published. `npm view` reads a cache that can 404
|
|
133
|
+
# for minutes after a successful publish.
|
|
134
|
+
curl -fsS -o /dev/null -w "%{http_code}
|
|
135
|
+
" https://registry.npmjs.org/<package-name>/A.B.C
|
|
127
136
|
```
|
|
128
137
|
|
|
129
138
|
The wrapper owns this documented commit-and-tag step. It compares the exact
|
package/skills/status/SKILL.md
CHANGED
|
@@ -57,12 +57,10 @@ description: rdc:status () — snapshot open epics, health, blockers and next ac
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
6. **Dev server health (PM2):**
|
|
60
|
-
|
|
60
|
+
Read PM2 process state on the dev host through the sanctioned wrapper (it loads
|
|
61
|
+
the clauth-held key into ssh-agent; a raw key fetch is guard-blocked):
|
|
61
62
|
```bash
|
|
62
|
-
|
|
63
|
-
# Check available keys with: curl -s http://127.0.0.1:52437/list-services
|
|
64
|
-
SSH_KEY=$(curl -s http://127.0.0.1:52437/v/ssh-key-path)
|
|
65
|
-
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no root@64.237.54.189 "pm2 jlist 2>/dev/null"
|
|
63
|
+
bash "$LIFEAI_ENV/services/ssh-clauth.sh" "pm2 jlist 2>/dev/null"
|
|
66
64
|
```
|
|
67
65
|
Parse the JSON array — for each process note `name`, `pm2_env.status` (online/stopped/errored), and `pm2_env.pm_uptime`.
|
|
68
66
|
If SSH fails or PM2 returns nothing, note "PM2 unreachable" and continue.
|