company-skill 4.6.9 → 4.6.10

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/README.md CHANGED
@@ -195,6 +195,35 @@ State lives in `./.company/` (relocate with `COMPANY_DIR`):
195
195
  ```
196
196
 
197
197
 
198
+ ## State is per-project, code is global
199
+
200
+ Run state is per-project. Every `.company/` lives inside the project it belongs to
201
+ (or wherever `COMPANY_DIR` points), so two projects never share or leak state.
202
+
203
+ The skill code is global on purpose. One install lives under `~/.claude/skills/company`
204
+ and serves every project. There are no per-project copies, which avoids version drift
205
+ across projects, a separate update path per project, and duplicated disk. Isolation
206
+ comes from per-project state, not per-project code.
207
+
208
+ Per-session resources are keyed on the session id. The dashboard port and the
209
+ statusline render cache derive from `$CLAUDE_CODE_SESSION_ID`, so concurrent sessions
210
+ do not collide and dead sessions release their slot.
211
+
212
+ Stop-guard anchors live outside the project, under `~/.claude/company-guard-state/`,
213
+ keyed by `sha256(realpath(COMPANY_DIR))`. They sit outside the project so an in-run
214
+ actor cannot forge them. The key set grows by one for every distinct `.company` path
215
+ ever used and is never pruned by the default reset. Reclaim old ones by age with:
216
+
217
+ ```bash
218
+ node ~/.claude/skills/company/scripts/reset-company-guard.js --gc --dry-run
219
+ node ~/.claude/skills/company/scripts/reset-company-guard.js --gc --max-age-days 30
220
+ ```
221
+
222
+ `--gc` is opt-in and never auto-run. It removes only anchors older than the cutoff
223
+ (default 30 days), `--dry-run` lists what would go without removing anything, and it
224
+ only ever touches dirs under `~/.claude/company-guard-state/`.
225
+
226
+
198
227
  ## Examples
199
228
 
200
229
  [`startup.md`](examples/startup.md), [`research-lab.md`](examples/research-lab.md), [`dev-team.md`](examples/dev-team.md).
@@ -22,7 +22,19 @@ Additional duties:
22
22
  - **Stall counter.** When you keep a criterion failing, increment (or create) an `attempts` field on its criteria.json entry. At 2+ state in your verdict that the approach is stalled and the next cycle must re-plan, not re-try. Writing `attempts` is required: the stall detector and the high-stakes 3-lens gate both read it from criteria.json.
23
23
  - **Anti-vacuous test check (SKILL.md ANTI-VACUOUS TEST).** For every new test introduced this cycle, confirm it FAILS against the pre-change code. A test that passes before the fix is vacuous and must be rejected.
24
24
  - **Feature reachability check (SKILL.md FEATURE REACHABILITY).** For every new feature that gates on a field in criteria.json (e.g. `stakes: "high"`), confirm the authoring path that sets that field exists in the skill or agent instructions. A gate that is never set is a dead feature; reject it until the wiring is present.
25
- - **Respawn reflection.** For any task that will be respawned, write a 3-line block into your verdict for the orchestrator to paste into the fresh contract: WHAT-WAS-TRIED / WHY-IT-FAILED (cited to the findings file) / DO-DIFFERENTLY. The failed worker's self-report is not a source.
25
+ - **Respawn diagnosis.** For any task that will be respawned, author a structured FAILURE-DIAGNOSIS block into your verdict for the orchestrator to paste into the fresh contract. You author it by reading the shaped FINDINGS file (and re-running the cited command), NEVER the raw transcript and never the failed worker's self-report. The block:
26
+
27
+ ```
28
+ FAILURE-DIAGNOSIS (task {id}, attempt {n})
29
+ ROOT-CAUSE-CLASS: one of [wrong-approach, missing-context, environment/tooling, spec-ambiguity, flaky/transient, scope-too-large, vacuous-or-misdirected-test]
30
+ ROOT-CAUSE: {one line}
31
+ EVIDENCE: {a findings-file:line you read, or a command you re-ran + its output, verbatim}
32
+ WHAT-TO-CHANGE: {concrete, contract-shaped: the next contract's TASK/INPUTS/surfaces}
33
+ KEEP: {what worked and must not be thrown away}
34
+ CONFIDENCE: high|low
35
+ ```
36
+
37
+ EVIDENCE must cite a real findings line or a re-run command and its output, never your memory of the failure. A diagnosis with an empty ROOT-CAUSE, EVIDENCE, or WHAT-TO-CHANGE, or a ROOT-CAUSE-CLASS outside the enum, is not actionable (the anti "try-harder" guard) and the orchestrator falls back to the plain 3-line reflection (WHAT-WAS-TRIED / WHY-IT-FAILED cited to the findings file / DO-DIFFERENTLY). When CONFIDENCE is low, say so in the block so the next THINK varies the decomposition rather than trusting one read.
26
38
 
27
39
  Audit each verdict against a tool result from THIS session. Only mark a criterion MET when you can cite the command you ran and its output from this run.
28
40
 
package/bin/install.js CHANGED
@@ -19,12 +19,14 @@ function copyFile(src, dest) {
19
19
  copyFile(path.join(srcDir, 'skill', 'SKILL.md'), path.join(skillDir, 'SKILL.md'));
20
20
 
21
21
  // Scripts are runtime dependencies referenced from SKILL.md. check.sh, lint-*,
22
- // check-doc-commands.js, check-version.js, and test files stay in the repo only.
22
+ // check-doc-commands.js, check-version.js, check-regression.js, and test files
23
+ // stay in the repo only.
23
24
  const INSTALL_SCRIPTS = [
24
25
  'codegraph.js',
25
26
  'check-contracts.js',
26
27
  'check-findings.js',
27
28
  'check-criteria.js',
29
+ 'diagnosis-block.js',
28
30
  'check-playbook.js',
29
31
  'restart-debate.js',
30
32
  'dashboard.js',
package/install.sh CHANGED
@@ -34,9 +34,9 @@ for agent in lead worker reviewer critic digest; do
34
34
  done
35
35
 
36
36
  # Scripts (runtime dependencies referenced from SKILL.md).
37
- # check.sh, lint-*, check-doc-commands.js, check-version.js, and test files stay in the repo only.
37
+ # check.sh, lint-*, check-doc-commands.js, check-version.js, check-regression.js, and test files stay in the repo only.
38
38
  mkdir -p "$HOME/.claude/skills/company/scripts"
39
- for script in codegraph.js check-contracts.js check-findings.js check-criteria.js check-playbook.js restart-debate.js dashboard.js secret-scan.js reset-company-guard.js cleanup.js statusline.js workspace-guard.js migrate-state.js check-isolation.js check-roster.js clean-stale-dashboards.js; do
39
+ for script in codegraph.js check-contracts.js check-findings.js check-criteria.js diagnosis-block.js check-playbook.js restart-debate.js dashboard.js secret-scan.js reset-company-guard.js cleanup.js statusline.js workspace-guard.js migrate-state.js check-isolation.js check-roster.js clean-stale-dashboards.js; do
40
40
  fetch "$REPO/scripts/$script" "$HOME/.claude/skills/company/scripts/$script" || echo "Warning: failed to download script $script"
41
41
  done
42
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "company-skill",
3
- "version": "4.6.9",
3
+ "version": "4.6.10",
4
4
  "description": "Goal-driven multi-employee company for Claude Code. Give it a goal, it runs until done.",
5
5
  "bin": {
6
6
  "company-skill": "./bin/install.js"
@@ -27,6 +27,7 @@
27
27
  "scripts/check-contracts.js",
28
28
  "scripts/check-findings.js",
29
29
  "scripts/check-criteria.js",
30
+ "scripts/diagnosis-block.js",
30
31
  "scripts/check-playbook.js",
31
32
  "scripts/restart-debate.js",
32
33
  "scripts/company-autoloop.js",
@@ -1784,11 +1784,6 @@ footer { margin-top: 2rem; font-size: 12.5px; color: var(--dim); border-top: 1px
1784
1784
  .table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
1785
1785
  img { max-width: 100%; height: auto; }
1786
1786
  .wrap { width: 100%; }
1787
- /* C102: numbered tier chips for the delegation tree legend, manifest zero-JS style. */
1788
- .tier-legend { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
1789
- .tier-chip { display: inline-flex; align-items: baseline; gap: 0.4rem; background: var(--accent-tint); color: var(--text); border-radius: var(--radius-pill); padding: 0.2rem 0.7rem; font-size: 12px; }
1790
- .tier-chip b { color: var(--accent); font-weight: 700; font-family: var(--font-mono); }
1791
- .tier-chip .role { color: var(--dim); }
1792
1787
  @media (max-width: 768px) {
1793
1788
  .wrap { padding: 1.25rem 1rem 3rem; }
1794
1789
  .stat-row, .cycles-row, .stats { gap: 1.25rem; }
@@ -1872,11 +1867,6 @@ img { max-width: 100%; height: auto; }
1872
1867
  <button class="tree-btn" id="tree-fullscreen" aria-label="Expand" title="Expand"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="1,5 1,1 5,1"/><polyline points="9,1 13,1 13,5"/><polyline points="13,9 13,13 9,13"/><polyline points="5,13 1,13 1,9"/></svg></button>
1873
1868
  </div>
1874
1869
  </div>
1875
- <div class="tier-legend">
1876
- <span class="tier-chip"><b>0</b> CEO <span class="role">orchestrator</span></span>
1877
- <span class="tier-chip"><b>1</b> Lead <span class="role">department head</span></span>
1878
- <span class="tier-chip"><b>2</b> Worker <span class="role">delegated agent</span></span>
1879
- </div>
1880
1870
  <div class="tree-container" id="tree-container">
1881
1871
  <div class="tree-svg-wrap" id="tree-svg-wrap">
1882
1872
  <svg id="tree" preserveAspectRatio="xMidYMin meet"></svg>
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // Parser for the structured FAILURE-DIAGNOSIS block the reviewer authors from
3
+ // the shaped FINDINGS file (never the raw transcript) when a task is respawned.
4
+ // parseDiagnosis(text) extracts the fields, isActionable(diag) is the anti
5
+ // "try-harder" guard: a diagnosis only drives a respawn when it classifies the
6
+ // root cause AND carries a root cause, cited evidence, and a concrete change.
7
+ //
8
+ // Block format the reviewer writes:
9
+ // FAILURE-DIAGNOSIS (task {id}, attempt {n})
10
+ // ROOT-CAUSE-CLASS: one of the enum below
11
+ // ROOT-CAUSE: {one line}
12
+ // EVIDENCE: {findings-file:line or a re-run command + output}
13
+ // WHAT-TO-CHANGE: {concrete, contract-shaped}
14
+ // KEEP: {what worked}
15
+ // CONFIDENCE: high|low
16
+ 'use strict';
17
+
18
+ // The allowed root-cause classes. A class outside this set is not actionable.
19
+ // It cannot drive a structurally different decomposition the orchestrator trusts.
20
+ const ROOT_CAUSE_CLASSES = [
21
+ 'wrong-approach',
22
+ 'missing-context',
23
+ 'environment/tooling',
24
+ 'spec-ambiguity',
25
+ 'flaky/transient',
26
+ 'scope-too-large',
27
+ 'vacuous-or-misdirected-test',
28
+ ];
29
+
30
+ // Pull the value after `LABEL:` on its own line. Returns '' when the field is
31
+ // absent or has only whitespace after the colon (the empty-field case the guard
32
+ // must reject). The label is matched at line start so a label inside prose is
33
+ // not picked up.
34
+ function fieldValue(text, label) {
35
+ const re = new RegExp('^' + label.replace('/', '\\/') + ':[ \\t]*(.*)$', 'm');
36
+ const m = text.match(re);
37
+ if (!m) return '';
38
+ return m[1].trim();
39
+ }
40
+
41
+ // parseDiagnosis(text) -> object|null. Returns null when the block header is
42
+ // absent (no diagnosis to act on). Otherwise returns the six fields plus the
43
+ // parsed task id, attempt, and a confidenceLow flag.
44
+ function parseDiagnosis(text) {
45
+ if (typeof text !== 'string' || text.indexOf('FAILURE-DIAGNOSIS') === -1) return null;
46
+ const header = text.match(/FAILURE-DIAGNOSIS\s*\(task\s*([^,]+),\s*attempt\s*([^)]+)\)/);
47
+ const rootCauseClass = fieldValue(text, 'ROOT-CAUSE-CLASS');
48
+ const confidenceRaw = fieldValue(text, 'CONFIDENCE').toLowerCase();
49
+ return {
50
+ taskId: header ? header[1].trim() : '',
51
+ attempt: header ? header[2].trim() : '',
52
+ rootCauseClass: rootCauseClass,
53
+ rootCause: fieldValue(text, 'ROOT-CAUSE'),
54
+ evidence: fieldValue(text, 'EVIDENCE'),
55
+ whatToChange: fieldValue(text, 'WHAT-TO-CHANGE'),
56
+ keep: fieldValue(text, 'KEEP'),
57
+ confidence: confidenceRaw,
58
+ confidenceLow: confidenceRaw === 'low',
59
+ };
60
+ }
61
+
62
+ // isActionable(diag) -> bool. The anti "try-harder" guard. A diagnosis only
63
+ // earns a respawn when the class is one of the known enum values AND the root
64
+ // cause, the cited evidence, and the concrete change are all present. Mirrors
65
+ // the FINDING-without-SOURCE rule: a claim with no cited basis is not actionable.
66
+ function isActionable(diag) {
67
+ if (!diag || typeof diag !== 'object') return false;
68
+ if (ROOT_CAUSE_CLASSES.indexOf(diag.rootCauseClass) === -1) return false;
69
+ if (!diag.rootCause) return false;
70
+ if (!diag.evidence) return false;
71
+ if (!diag.whatToChange) return false;
72
+ return true;
73
+ }
74
+
75
+ module.exports = { parseDiagnosis, isActionable, ROOT_CAUSE_CLASSES };
@@ -24,11 +24,129 @@
24
24
  // USAGE: node <skill-scripts-dir>/reset-company-guard.js
25
25
  // COMPANY_DIR defaults to ./.company (same as stop-guard).
26
26
  // Override: COMPANY_DIR=/path/to/.company node reset-company-guard.js
27
+ //
28
+ // GARBAGE-COLLECT MODE (opt-in, separate from the default reset above):
29
+ // node <skill-scripts-dir>/reset-company-guard.js --gc [--max-age-days N] [--dry-run]
30
+ // Removes guard-state anchor dirs under ~/.claude/company-guard-state/ whose most
31
+ // recent mtime is older than the cutoff (default 30 days). The anchor key is
32
+ // sha256(realpath(companyDir)), so the key set grows by one for every distinct
33
+ // .company path ever used and is never pruned by the default reset (which only
34
+ // removes one exact key). This bounds that growth by age.
35
+ //
36
+ // --dry-run lists what WOULD be removed and removes nothing.
37
+ // The root is fixed at ~/.claude/company-guard-state/ and re-checked before any
38
+ // removal so a bug cannot rm outside it. GC is OPT-IN and never auto-run by the
39
+ // stop guard: a stale anchor for a long-dead run is safe to drop by age, but the
40
+ // anti-forge property (anchors live OUTSIDE the project so an in-run actor cannot
41
+ // forge or delete a live anchor for a DIFFERENT active run) is preserved because
42
+ // nothing here is reachable from inside a run and recent anchors are always kept.
43
+ // Tests override the root with COMPANY_GUARD_STATE_DIR so they never touch the
44
+ // real anchors.
27
45
 
28
46
  const fs = require('fs');
29
47
  const path = require('path');
30
48
  const crypto = require('crypto');
31
49
 
50
+ const DEFAULT_MAX_AGE_DAYS = 30;
51
+
52
+ // Resolve the guard-state root. Honors COMPANY_GUARD_STATE_DIR for test isolation,
53
+ // otherwise the canonical ~/.claude/company-guard-state.
54
+ function guardStateRoot() {
55
+ if (process.env.COMPANY_GUARD_STATE_DIR) return process.env.COMPANY_GUARD_STATE_DIR;
56
+ const home = process.env.HOME || '';
57
+ return path.join(home, '.claude', 'company-guard-state');
58
+ }
59
+
60
+ // Most-recent mtime across the anchor dir itself and its immediate children
61
+ // (the lock file + optional owners file). Used as the activity timestamp.
62
+ function newestMtimeMs(dir) {
63
+ let newest = 0;
64
+ try { newest = fs.statSync(dir).mtimeMs; } catch (e) { return 0; }
65
+ let entries = [];
66
+ try { entries = fs.readdirSync(dir); } catch (e) { entries = []; }
67
+ for (const name of entries) {
68
+ try {
69
+ const m = fs.statSync(path.join(dir, name)).mtimeMs;
70
+ if (m > newest) newest = m;
71
+ } catch (e) { /* ignore unreadable child */ }
72
+ }
73
+ return newest;
74
+ }
75
+
76
+ function parseGcOpts(argv) {
77
+ const opts = { dryRun: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS };
78
+ for (let i = 0; i < argv.length; i += 1) {
79
+ const a = argv[i];
80
+ if (a === '--dry-run') opts.dryRun = true;
81
+ else if (a === '--max-age-days') {
82
+ const v = Number(argv[i + 1]);
83
+ if (!Number.isFinite(v) || v < 0) {
84
+ console.error('reset-company-guard --gc: --max-age-days needs a non-negative number');
85
+ process.exit(2);
86
+ }
87
+ opts.maxAgeDays = v;
88
+ i += 1;
89
+ }
90
+ }
91
+ return opts;
92
+ }
93
+
94
+ // Bounded, age-based GC of guard-state anchor dirs. Safe by construction:
95
+ // only direct children of the resolved root are candidates, the root is
96
+ // re-resolved per removal, and each rm target must stay inside the root.
97
+ function runGc(argv) {
98
+ const opts = parseGcOpts(argv);
99
+ const root = path.resolve(guardStateRoot());
100
+ if (!fs.existsSync(root)) {
101
+ console.log('reset-company-guard --gc: no guard-state root at ' + root + ' (nothing to GC)');
102
+ return 0;
103
+ }
104
+ const cutoffMs = Date.now() - opts.maxAgeDays * 24 * 60 * 60 * 1000;
105
+ let entries = [];
106
+ try {
107
+ entries = fs.readdirSync(root, { withFileTypes: true });
108
+ } catch (e) {
109
+ console.error('reset-company-guard --gc: cannot read ' + root + ': ' + e.message);
110
+ return 1;
111
+ }
112
+ let scanned = 0, removed = 0, kept = 0, errors = 0;
113
+ for (const ent of entries) {
114
+ if (!ent.isDirectory()) continue;
115
+ scanned += 1;
116
+ const dir = path.resolve(root, ent.name);
117
+ // Hard safety: never touch anything that is not a direct child of root.
118
+ if (path.dirname(dir) !== root) { kept += 1; continue; }
119
+ const mtime = newestMtimeMs(dir);
120
+ if (mtime === 0 || mtime >= cutoffMs) { kept += 1; continue; }
121
+ const ageDays = ((Date.now() - mtime) / (24 * 60 * 60 * 1000)).toFixed(1);
122
+ if (opts.dryRun) {
123
+ console.log('would remove ' + dir + ' (age ' + ageDays + 'd)');
124
+ removed += 1;
125
+ continue;
126
+ }
127
+ try {
128
+ fs.rmSync(dir, { recursive: true, force: true });
129
+ console.log('removed ' + dir + ' (age ' + ageDays + 'd)');
130
+ removed += 1;
131
+ } catch (e) {
132
+ console.error('reset-company-guard --gc: failed to remove ' + dir + ': ' + e.message);
133
+ errors += 1;
134
+ }
135
+ }
136
+ const verb = opts.dryRun ? 'would remove' : 'removed';
137
+ console.log('reset-company-guard --gc: root=' + root +
138
+ ' max-age-days=' + opts.maxAgeDays +
139
+ ' scanned=' + scanned + ' ' + verb + '=' + removed + ' kept=' + kept +
140
+ (errors ? ' errors=' + errors : '') + (opts.dryRun ? ' (dry-run)' : ''));
141
+ return errors ? 1 : 0;
142
+ }
143
+
144
+ // Opt-in GC mode short-circuits the default reset entirely. The default
145
+ // reset behaviour below is unchanged and runs only when --gc is absent.
146
+ if (process.argv.slice(2).indexOf('--gc') !== -1) {
147
+ process.exit(runGc(process.argv.slice(2)));
148
+ }
149
+
32
150
  // Resolve companyDir robustly: COMPANY_DIR env wins; else prefer the dir that holds
33
151
  // a clean OWNER (at least one valid session-id line); fall back to cwd/.company.
34
152
  // A blank/garbled OWNER does NOT qualify a dir as the active run (BLOCKER-1 fix).
@@ -17,6 +17,7 @@
17
17
 
18
18
  'use strict';
19
19
  const { execFileSync } = require('child_process');
20
+ const crypto = require('crypto');
20
21
  const fs = require('fs');
21
22
  const path = require('path');
22
23
  const os = require('os');
@@ -81,6 +82,180 @@ function resolveDir() {
81
82
  }
82
83
  const dir = resolveDir();
83
84
 
85
+ // --- Short-TTL render cache -------------------------------------------------
86
+ // SKILL.md forces this script as the GLOBAL statusLine.command, refreshed on a
87
+ // sub-second cadence, so a render storm spawns a fresh cold node per refresh and
88
+ // slow renders overlap and pile up. The cache collapses that storm to ONE real
89
+ // compute per CACHE_TTL_MS: a re-render whose sid + transcript size + mtime match
90
+ // a recent cache entry writes the cached line and exits immediately.
91
+ //
92
+ // Fail-OPEN by construction: every read/write is wrapped and any error falls
93
+ // through to a fresh render. The cache NEVER throws and NEVER blocks a render.
94
+ const CACHE_TTL_MS = 1500; // one real compute per ~1.5s; collapses the sub-second storm.
95
+ const BASE_CACHE_TTL_MS = 5000; // base output reused for ~5s; spawn at most once per window.
96
+ // Base-spawn timeout. Node cold-start on this box is 0.65s-4.6s under load, so a
97
+ // short cap (the old 500ms) SIGKILLs a legitimate node base on every render and
98
+ // erases the founder's own statusline. The pile-up it was meant to prevent is now
99
+ // stopped structurally by the tail-read + the base-output cache + the single-flight
100
+ // lock below, so a generous timeout that clears interpreter cold-start is safe: the
101
+ // spawn happens at most once per BASE_CACHE_TTL_MS and is single-flighted.
102
+ const BASE_SPAWN_TIMEOUT_MS = 4000;
103
+
104
+ // --- Secure per-user cache dir (CWE-59 hardening) --------------------------
105
+ // The render cache, the base-output cache, and the single-flight lock all live in
106
+ // a PER-USER subdir of os.tmpdir() created mode 0700, so another user on a shared
107
+ // box cannot pre-plant a symlink at a predictable path, clobber a victim-writable
108
+ // file, read the dashboard port, or inject ANSI escapes within the TTL. Every file
109
+ // is written 0600 via O_EXCL to a random temp name then renamed into place, and
110
+ // every read lstats the target and trusts it only when it is a REGULAR file owned
111
+ // by the current uid. Fail-OPEN throughout: any error -> fresh render, never throw.
112
+ function currentUid() {
113
+ try { return typeof process.getuid === 'function' ? process.getuid() : -1; }
114
+ catch (_) { return -1; }
115
+ }
116
+ // Resolve (and lazily create, 0700) the per-user cache dir. Returns null on any
117
+ // failure so callers fall back to a fresh render rather than throwing.
118
+ let _cacheDir = null;
119
+ function cacheDir() {
120
+ if (_cacheDir) return _cacheDir;
121
+ try {
122
+ const uid = currentUid();
123
+ const name = 'company-statusline-' + (uid >= 0 ? uid : 'nouid');
124
+ const d = path.join(os.tmpdir(), name);
125
+ fs.mkdirSync(d, { recursive: true, mode: 0o700 });
126
+ // Re-assert 0700 even if the dir already existed (mkdir's mode is ignored for
127
+ // an existing dir). Only trust a dir we own; otherwise refuse to use it.
128
+ const st = fs.lstatSync(d);
129
+ if (!st.isDirectory() || (uid >= 0 && st.uid !== uid)) return null;
130
+ try { fs.chmodSync(d, 0o700); } catch (_) {}
131
+ _cacheDir = d;
132
+ return d;
133
+ } catch (_) {
134
+ return null;
135
+ }
136
+ }
137
+ // lstat the target and return true only when it is a REGULAR file owned by us and
138
+ // not a symlink. A symlink, a non-regular file, or a file owned by another uid is
139
+ // refused (do not read it). Fail-closed on the trust decision (treat as untrusted).
140
+ function isTrustedFile(p) {
141
+ try {
142
+ const st = fs.lstatSync(p);
143
+ if (!st.isFile()) return false; // isFile() is false for symlinks (lstat)
144
+ const uid = currentUid();
145
+ if (uid >= 0 && st.uid !== uid) return false;
146
+ return true;
147
+ } catch (_) {
148
+ return false;
149
+ }
150
+ }
151
+ // Atomically write content to a trusted path: write to a random sibling temp name
152
+ // with O_EXCL|O_CREAT (flag 'wx', fails if a pre-planted file exists) mode 0600,
153
+ // then rename into place. The rename replaces any symlink at the destination with
154
+ // our regular file rather than following it. Best-effort: errors are swallowed.
155
+ function atomicWrite(p, content) {
156
+ const dir = path.dirname(p);
157
+ const tmp = path.join(dir,
158
+ '.tmp-' + crypto.randomBytes(8).toString('hex'));
159
+ let fd = null;
160
+ try {
161
+ fd = fs.openSync(tmp, 'wx', 0o600); // O_EXCL: never follow/clobber a planted file
162
+ fs.writeSync(fd, content);
163
+ fs.closeSync(fd); fd = null;
164
+ fs.renameSync(tmp, p);
165
+ } catch (_) {
166
+ try { if (fd !== null) fs.closeSync(fd); } catch (__) {}
167
+ try { fs.unlinkSync(tmp); } catch (__) {}
168
+ }
169
+ }
170
+ function cachePathFor(sessionId) {
171
+ // Keyed by session_id so concurrent sessions never share a line. A blank sid
172
+ // gets a stable shared slot; that is harmless (the size+mtime guard still gates).
173
+ const safe = String(sessionId || 'nosid').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
174
+ const d = cacheDir();
175
+ if (!d) return null;
176
+ return path.join(d, 'render-' + safe + '.cache');
177
+ }
178
+ // Fingerprint the transcript cheaply (size + mtime) so a grown/edited transcript
179
+ // invalidates the cache even within the TTL. Returns '0:0' when there is no
180
+ // transcript (the model+ctx still render; the TTL alone bounds those re-renders).
181
+ function transcriptFingerprint(transcriptFile) {
182
+ if (!transcriptFile) return '0:0';
183
+ try {
184
+ const st = fs.statSync(transcriptFile);
185
+ return st.size + ':' + Math.round(st.mtimeMs);
186
+ } catch (_) {
187
+ return '0:0';
188
+ }
189
+ }
190
+ // Fingerprint a small control file (registry / base config) by a hash of its
191
+ // CONTENTS, not mtime. These files are tiny and can be rewritten twice inside one
192
+ // millisecond (mtime would collide and wrongly serve a stale cache); a content hash
193
+ // invalidates correctly on any change. Returns '0' when the file is absent.
194
+ function contentFingerprint(file) {
195
+ try {
196
+ return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex').slice(0, 16);
197
+ } catch (_) {
198
+ return '0';
199
+ }
200
+ }
201
+ // Try to serve from cache. Writes the cached line + returns true on a hit; returns
202
+ // false (no output) on any miss/error so the caller proceeds to a fresh render.
203
+ // Refuses to read a symlinked / foreign-owned cache path (CWE-59).
204
+ function tryServeFromCache(cachePath, fingerprint) {
205
+ if (!cachePath) return false;
206
+ try {
207
+ if (!isTrustedFile(cachePath)) return false;
208
+ const st = fs.statSync(cachePath);
209
+ if (Date.now() - st.mtimeMs >= CACHE_TTL_MS) return false;
210
+ const cached = fs.readFileSync(cachePath, 'utf8');
211
+ const nl = cached.indexOf('\n');
212
+ if (nl < 0) return false;
213
+ const head = cached.slice(0, nl);
214
+ if (head !== fingerprint) return false;
215
+ process.stdout.write(cached.slice(nl + 1));
216
+ return true;
217
+ } catch (_) {
218
+ return false;
219
+ }
220
+ }
221
+ // Store the composed line keyed by fingerprint. Atomic 0600 write via O_EXCL temp +
222
+ // rename so a pre-planted symlink at the cache path is never followed. Best-effort.
223
+ function storeInCache(cachePath, fingerprint, line) {
224
+ if (!cachePath) return;
225
+ atomicWrite(cachePath, fingerprint + '\n' + line);
226
+ }
227
+
228
+ // Parse stdin once for the cache-key inputs and attempt an early cache hit BEFORE
229
+ // the expensive base spawn + registry lookups. A hit exits the process immediately.
230
+ let cacheSid = '';
231
+ let cacheTranscript = '';
232
+ try {
233
+ const parsed = JSON.parse(input) || {};
234
+ cacheSid = parsed.session_id || '';
235
+ cacheTranscript = parsed.transcript_path || '';
236
+ } catch (_) {}
237
+ const CACHE_PATH = cachePathFor(cacheSid);
238
+ // Fingerprint = transcript size+mtime PLUS a hash of the full stdin PLUS the
239
+ // size+mtime of every input the LINK resolution reads (global + per-project
240
+ // registries, base-command config). The output is a pure function of exactly these
241
+ // inputs, so the cached line is valid iff all of them are unchanged. Including the
242
+ // registry fingerprints keeps the cache CORRECT when a dashboard dies or a base is
243
+ // (re)configured between renders, while still collapsing the genuine sub-second
244
+ // storm where every input is byte-identical (the common case).
245
+ const GLOBAL_REGISTRY_FP_PATH =
246
+ path.join(process.env.HOME || os.homedir(), '.claude', 'company-dashboards.json');
247
+ const CACHE_FP = [
248
+ transcriptFingerprint(cacheTranscript),
249
+ crypto.createHash('sha1').update(input || '').digest('hex').slice(0, 16),
250
+ contentFingerprint(GLOBAL_REGISTRY_FP_PATH),
251
+ contentFingerprint(path.join(dir, 'dashboard-registry.json')),
252
+ contentFingerprint(path.join(dir, 'statusline-base.json')),
253
+ ].join('|');
254
+ // Env escape hatch so the test (and any debug run) can force a fresh compute.
255
+ if (!process.env.COMPANY_STATUSLINE_NO_CACHE && tryServeFromCache(CACHE_PATH, CACHE_FP)) {
256
+ return;
257
+ }
258
+
84
259
  // --- Model + context helpers, mirrored from scripts/dashboard.js ---
85
260
  // Keep these in sync with dashboard.js so the statusline matches the dashboard.
86
261
  const KNOWN_1M_SUBSTRINGS = ['[1m]', 'claude-opus-4', 'claude-opus-4-5', 'claude-opus-4-8'];
@@ -130,34 +305,99 @@ function humanizeModel(modelId) {
130
305
  return name + ' (' + win + ')';
131
306
  }
132
307
 
133
- // Read the transcript jsonl and compute fill % from the last assistant usage block.
134
- function contextFillPct(transcriptFile, overrideModel) {
135
- if (!transcriptFile) return null;
136
- let raw;
137
- try { raw = fs.readFileSync(transcriptFile, 'utf8'); } catch (_) { return null; }
138
- if (!raw) return null;
308
+ // Read at most maxBytes from the END of a file, dropping the leading partial line
309
+ // when the window does not start at byte 0. Mirrors dashboard.js readTail (ORCH_TAIL
310
+ // at dashboard.js:416) so the statusline never reads a whole multi-MB transcript on
311
+ // every render. ALWAYS closes the fd (finally). Returns '' on any error (fail-open).
312
+ const TRANSCRIPT_TAIL = 256 * 1024; // start window: covers the common last record.
313
+ const TRANSCRIPT_TAIL_MAX = 8 * 1024 * 1024; // cap: never read more than 8MB.
314
+ function readTail(p, maxBytes) {
315
+ let fd = null;
316
+ try {
317
+ fd = fs.openSync(p, 'r');
318
+ const size = fs.fstatSync(fd).size;
319
+ const start = Math.max(0, size - maxBytes);
320
+ const len = size - start;
321
+ if (len <= 0) return '';
322
+ const buf = Buffer.alloc(len);
323
+ fs.readSync(fd, buf, 0, len, start);
324
+ let text = buf.toString('utf8');
325
+ if (start > 0) {
326
+ // Drop the partial first line of the tail window so JSON.parse never sees a
327
+ // truncated record. If there is no newline the whole window is one partial
328
+ // line and yields nothing.
329
+ const nl = text.indexOf('\n');
330
+ text = nl >= 0 ? text.slice(nl + 1) : '';
331
+ }
332
+ return text;
333
+ } catch (_) {
334
+ return '';
335
+ } finally {
336
+ if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
337
+ }
338
+ }
339
+
340
+ // Scan tail text (newest line first) for the last complete assistant usage record.
341
+ // Returns { usage, modelId, completeLines } where completeLines is how many full
342
+ // newline-delimited records the window held. completeLines distinguishes "the
343
+ // window contained whole records but none had usage" (the usage is genuinely old;
344
+ // do NOT grow) from "the window held one giant partial record and no complete line"
345
+ // (the last record straddles the window's leading edge; grow to capture it).
346
+ function findLastUsage(raw, overrideModel) {
139
347
  const lines = raw.split('\n');
140
- let lastUsage = null;
141
348
  let lastModelId = overrideModel || null;
349
+ let completeLines = 0;
142
350
  for (let i = lines.length - 1; i >= 0; i--) {
143
351
  const line = lines[i].trim();
144
352
  if (!line) continue;
145
353
  let msg;
146
354
  try { msg = JSON.parse(line); } catch (_) { continue; }
355
+ completeLines += 1;
147
356
  const inner = msg.message || msg;
148
357
  if (inner && inner.role === 'assistant' && inner.usage) {
149
- lastUsage = inner.usage;
150
- if (!lastModelId) {
151
- if (typeof inner.model === 'string') lastModelId = inner.model;
152
- else if (typeof msg.model === 'string') lastModelId = msg.model;
358
+ let modelId = lastModelId;
359
+ if (!modelId) {
360
+ if (typeof inner.model === 'string') modelId = inner.model;
361
+ else if (typeof msg.model === 'string') modelId = msg.model;
153
362
  }
154
- break;
363
+ return { usage: inner.usage, modelId, completeLines };
155
364
  }
156
365
  }
157
- if (!lastUsage) return null;
158
- const used = usedTokens(lastUsage);
159
- const win = detectWindow(lastModelId);
160
- return Math.round((used / win) * 100);
366
+ return { usage: null, modelId: null, completeLines };
367
+ }
368
+
369
+ // Read the transcript jsonl and compute fill % from the last assistant usage block.
370
+ // ADAPTIVE tail: start at TRANSCRIPT_TAIL. Grow (double, up to TRANSCRIPT_TAIL_MAX)
371
+ // ONLY when the window parsed NO complete record at all - that is the signature of a
372
+ // single last record larger than the window, dropped as the partial first line (the
373
+ // >64KB-record bug a fixed window mis-rendered as 0%). Once the window holds at least
374
+ // one complete record, the newest usage in it is authoritative: a window full of
375
+ // recent non-usage records means the last usage is genuinely old, so we do NOT keep
376
+ // growing to resurrect an ancient buried usage (that would defeat the tail semantics).
377
+ function contextFillPct(transcriptFile, overrideModel) {
378
+ if (!transcriptFile) return null;
379
+ let win = TRANSCRIPT_TAIL;
380
+ let fileSize = Infinity;
381
+ for (;;) {
382
+ const raw = readTail(transcriptFile, win);
383
+ if (!raw) return null;
384
+ const found = findLastUsage(raw, overrideModel);
385
+ if (found.usage) {
386
+ const used = usedTokens(found.usage);
387
+ const window = detectWindow(found.modelId);
388
+ return Math.round((used / window) * 100);
389
+ }
390
+ // A usage was NOT found. Only grow when the window held NO complete record (one
391
+ // oversized partial record straddling the leading edge). If it held complete
392
+ // records but none with usage, the recent window has no usage -> stop (null).
393
+ if (found.completeLines > 0) return null;
394
+ try {
395
+ const st = fs.statSync(transcriptFile);
396
+ fileSize = st.size;
397
+ } catch (_) {}
398
+ if (win >= TRANSCRIPT_TAIL_MAX || win >= fileSize) return null;
399
+ win = Math.min(win * 2, TRANSCRIPT_TAIL_MAX);
400
+ }
161
401
  }
162
402
 
163
403
  // Pick the context percentage to show. Prefer Claude's native pre-calculated
@@ -212,7 +452,44 @@ function renderSelfSegments(rawInput) {
212
452
  }
213
453
 
214
454
  // --- Chaining: run the prior statusline command if one is stored ---
455
+ // The base spawn is the expensive, pile-up-prone step. Three mechanisms keep it
456
+ // safe WITHOUT a short timeout that would SIGKILL a legitimate cold-starting node
457
+ // base (node cold-start here is 0.65s-4.6s under load):
458
+ // 1. BASE-OUTPUT CACHE: the base stdout is cached in the per-user cache dir with
459
+ // a short TTL (BASE_CACHE_TTL_MS). A render inside the TTL reuses it WITHOUT
460
+ // spawning. So the spawn happens at most once per ~5s, not once per render.
461
+ // 2. SINGLE-FLIGHT LOCK: an atomic mkdir lockdir guards the spawn. If the lock is
462
+ // held (another render is already spawning), this render does NOT spawn a
463
+ // second base; it serves the last cached base (even if slightly stale) or
464
+ // omits the base lead. This is what actually prevents concurrent pile-up.
465
+ // 3. GENEROUS TIMEOUT: because the spawn is rate-limited + single-flighted, a
466
+ // generous timeout (BASE_SPAWN_TIMEOUT_MS, >= node cold-start under load) no
467
+ // longer causes pile-up, so a real node base actually finishes and chains.
215
468
  const baseCfgPath = path.join(dir, 'statusline-base.json');
469
+ // Per-(dir + base-config-content) cache key so a reconfigured base invalidates.
470
+ function baseCacheKey(cmd) {
471
+ return crypto.createHash('sha1')
472
+ .update(String(dir) + '|' + String(cmd || '')).digest('hex').slice(0, 24);
473
+ }
474
+ // Read a fresh cached base output (trusted file, within ttl). Returns string or null.
475
+ function readBaseCache(p, ttlMs) {
476
+ if (!p) return null;
477
+ try {
478
+ if (!isTrustedFile(p)) return null;
479
+ const st = fs.statSync(p);
480
+ if (Date.now() - st.mtimeMs >= ttlMs) return null;
481
+ return fs.readFileSync(p, 'utf8');
482
+ } catch (_) { return null; }
483
+ }
484
+ // Read the last cached base output regardless of age (used when the lock is held by
485
+ // another render so we serve slightly-stale rather than pile up a second spawn).
486
+ function readBaseCacheAny(p) {
487
+ if (!p) return null;
488
+ try {
489
+ if (!isTrustedFile(p)) return null;
490
+ return fs.readFileSync(p, 'utf8');
491
+ } catch (_) { return null; }
492
+ }
216
493
  let base = '';
217
494
  try {
218
495
  const cfg = JSON.parse(fs.readFileSync(baseCfgPath, 'utf8'));
@@ -223,17 +500,76 @@ try {
223
500
  // Strip surrounding quotes from each part.
224
501
  const argv = parts.map(p => p.replace(/^['"]|['"]$/g, ''));
225
502
  // Skip a self-referential base: running it would spawn a child statusline.js
226
- // that pays the 4s timeout for nothing. Treat self-reference as no base.
503
+ // that pays the full timeout for nothing. Treat self-reference as no base.
227
504
  if (argv.length > 0 && !isSelfReferential(argv, __filename)) {
228
- try {
229
- base = execFileSync(argv[0], argv.slice(1), {
230
- input,
231
- encoding: 'utf8',
232
- timeout: 4000,
233
- });
234
- } catch (_) {
235
- // Prior command failed - continue with empty base rather than crashing.
236
- base = '';
505
+ const cdir = cacheDir();
506
+ const key = baseCacheKey(cfg.command);
507
+ const baseCachePath = cdir ? path.join(cdir, 'base-' + key + '.out') : null;
508
+ const lockDir = cdir ? path.join(cdir, 'base-' + key + '.lock') : null;
509
+ // 1. Fresh cached base output -> use it, no spawn.
510
+ const fresh = readBaseCache(baseCachePath, BASE_CACHE_TTL_MS);
511
+ if (fresh !== null) {
512
+ base = fresh;
513
+ } else {
514
+ // 2. Single-flight: try to acquire the lock by atomically creating lockDir.
515
+ let haveLock = false;
516
+ if (lockDir) {
517
+ try {
518
+ fs.mkdirSync(lockDir); // atomic: fails (EEXIST) if another render holds it
519
+ haveLock = true;
520
+ } catch (e) {
521
+ // Lock held. Treat a STALE lock (older than the spawn timeout + slack)
522
+ // as abandoned and reclaim it, so a crashed render never wedges chaining.
523
+ try {
524
+ const lst = fs.statSync(lockDir);
525
+ if (Date.now() - lst.mtimeMs > BASE_SPAWN_TIMEOUT_MS + 2000) {
526
+ try { fs.rmdirSync(lockDir); } catch (__) {}
527
+ try { fs.mkdirSync(lockDir); haveLock = true; } catch (__) {}
528
+ }
529
+ } catch (__) {}
530
+ }
531
+ }
532
+ if (haveLock) {
533
+ try {
534
+ // 3. Generous timeout clears node cold-start. SIGKILL reaps the direct
535
+ // child so a wedged base never lingers. Spawn is rate-limited by the
536
+ // cache TTL + single-flighted by the lock, so the long timeout is safe.
537
+ base = execFileSync(argv[0], argv.slice(1), {
538
+ input,
539
+ encoding: 'utf8',
540
+ timeout: BASE_SPAWN_TIMEOUT_MS,
541
+ killSignal: 'SIGKILL',
542
+ });
543
+ // Cache the fresh output for the next render within the TTL.
544
+ if (baseCachePath) atomicWrite(baseCachePath, base);
545
+ } catch (e) {
546
+ // A timed-out base still SALVAGES whatever it already wrote to stdout
547
+ // before the deadline. Some bases (e.g. the founder's cli-statusline.mjs)
548
+ // print their line immediately, then keep a detached grandchild holding
549
+ // the stdout pipe open, so execFileSync ALWAYS hits ETIMEDOUT even though
550
+ // the useful output landed instantly. Discarding e.stdout there would
551
+ // erase the base on every render (the rejected behavior). Use the captured
552
+ // stdout when present; only fall back to the cache/empty when there is none.
553
+ const partial = e && e.stdout != null ? e.stdout.toString() : '';
554
+ if (partial) {
555
+ base = partial;
556
+ if (baseCachePath) atomicWrite(baseCachePath, base);
557
+ } else {
558
+ // True failure with no output: serve last cached output if any.
559
+ // execFileSync already SIGKILL-reaped the direct child by here.
560
+ const stale = readBaseCacheAny(baseCachePath);
561
+ base = stale !== null ? stale : '';
562
+ }
563
+ } finally {
564
+ // ALWAYS release the lock.
565
+ try { fs.rmdirSync(lockDir); } catch (_) {}
566
+ }
567
+ } else {
568
+ // Lock held by another render: do NOT spawn a second base. Serve the last
569
+ // cached output (slightly stale) or omit the base lead for this render.
570
+ const stale = readBaseCacheAny(baseCachePath);
571
+ base = stale !== null ? stale : '';
572
+ }
237
573
  }
238
574
  }
239
575
  }
@@ -376,4 +712,7 @@ if (url) {
376
712
  link = '\u{1F4CA} ' + label + ' ' + url;
377
713
  }
378
714
  const out = [lead, link].filter(Boolean).join(' | ');
715
+ // Store the freshly composed line so the next sub-second re-render (same sid +
716
+ // transcript fingerprint, within TTL) is served from cache instead of recomputing.
717
+ storeInCache(CACHE_PATH, CACHE_FP, out);
379
718
  process.stdout.write(out);
package/skill/SKILL.md CHANGED
@@ -493,7 +493,7 @@ Novel ideas use "NOVEL - needs validation" and MUST be tested in the same or nex
493
493
 
494
494
  Every findings append ends with a machine-greppable `STATUS: complete`, `STATUS: blocked`, or `STATUS: incomplete` line, and the orchestrator greps that line rather than parsing prose. If a task is blocked or impossible, the worker reports `BLOCKED: reason + what would unblock it` as the finding. Never silently return nothing, never expand scope to compensate. A `NEEDS-SPEC` block is answered by you from the goal and criteria context, and the SAME contract is re-issued with the answer pasted in. No replan, no respawn penalty, no guessing.
495
495
 
496
- **Continuity versus respawn:** for a follow-up question to a HEALTHY finished agent (a re-gate at a new head, a clarification), continue that agent by its id when the harness supports it, an uncorrupted context is an asset. **Retry by respawn:** a worker that fails, stalls, or returns confused output is never coached in place and never continued. Spawn a FRESH worker with the same contract plus the reviewer's 3-line reflection block (WHAT-WAS-TRIED / WHY-IT-FAILED cited to the findings file / DO-DIFFERENTLY), never the failed worker's self-report and never your own memory of the failure. Per-task commits make this safe to repeat. A corrupted context is abandoned, not repaired.
496
+ **Continuity versus respawn:** for a follow-up question to a HEALTHY finished agent (a re-gate at a new head, a clarification), continue that agent by its id when the harness supports it, an uncorrupted context is an asset. **Retry by respawn:** a worker that fails, stalls, or returns confused output is never coached in place and never continued. Spawn a FRESH worker with the same contract plus a FAILURE-DIAGNOSIS block (structured: root-cause-class + cited evidence + what-to-change + keep + confidence) authored by the reviewer FROM THE FINDINGS FILE, never the raw transcript, never the failed worker's self-report, and never your own memory of the failure. The reviewer authors the block per agents/company-reviewer.md and the orchestrator pastes it into the fresh contract. This FAILURE-DIAGNOSIS block is the respawn driver, and it is MANDATORY at attempts >= 2: at the second attempt the next contract MUST carry a parsed-actionable diagnosis (a ROOT-CAUSE-CLASS in the enum plus a non-empty ROOT-CAUSE, EVIDENCE, and WHAT-TO-CHANGE, validated by `scripts/diagnosis-block.js` isActionable). FALLBACK: when an actionable diagnosis is unavailable (the reviewer could not author one, or it parses as not-actionable), degrade to the plain 3-line reflection block (WHAT-WAS-TRIED / WHY-IT-FAILED cited to the findings file / DO-DIFFERENTLY) so a respawn still happens. It degrades, it never bricks. Do NOT read the raw transcript to build a diagnosis, the no-raw-logs rule holds. Per-task commits make this safe to repeat. A corrupted context is abandoned, not repaired.
497
497
 
498
498
  ### VERIFY
499
499
 
@@ -538,7 +538,7 @@ CYCLE {N} VERDICT: {DONE or NOT DONE}
538
538
  ALL criteria pass + critic accepts + `SWEEP-CLEAN-STREAK >= K` = EXIT.
539
539
  Otherwise = loop, re-spawning the FAILING tasks plus any bug the proactive sweep confirmed, with the review feedback in their contracts.
540
540
 
541
- **Stall detector:** the reviewer keeps an `attempts` count on each criterion's entry in criteria.json (increment on every cycle it stays failing - the stop guard ignores extra fields). At `attempts >= 2` with same-shape evidence, the next THINK MUST produce a structurally different decomposition for that criterion: new approach, new surfaces, or HIRE. Re-issuing a near-identical contract after two same-shape failures is a planning bug, not persistence. The reviewer agent file instructs the reviewer to increment `attempts` every cycle a criterion stays failing; that instruction is what puts the value into criteria.json so the stall detector and the high-stakes gate can read it.
541
+ **Stall detector:** the reviewer keeps an `attempts` count on each criterion's entry in criteria.json (increment on every cycle it stays failing - the stop guard ignores extra fields). At `attempts >= 2` with same-shape evidence, the next THINK MUST produce a structurally different decomposition for that criterion: new approach, new surfaces, or HIRE. That structurally-different decomposition is DRIVEN by the FAILURE-DIAGNOSIS block from retry-by-respawn above: the ROOT-CAUSE-CLASS picks the lever (wrong-approach -> new approach, missing-context -> add the inputs, scope-too-large -> split the contract, environment/tooling -> fix or swap the tool, spec-ambiguity -> resolve via NEEDS-SPEC, vacuous-or-misdirected-test -> re-point the test, flaky/transient -> re-run not re-plan) and the WHAT-TO-CHANGE is the contract-shaped delta the new spawn carries. The mandatory diagnosis at attempts >= 2 is what makes the re-decomposition concrete instead of a bare "try a new approach". Re-issuing a near-identical contract after two same-shape failures is a planning bug, not persistence. The reviewer agent file instructs the reviewer to increment `attempts` every cycle a criterion stays failing; that instruction is what puts the value into criteria.json so the stall detector and the high-stakes gate can read it.
542
542
 
543
543
  **ANTI-VACUOUS TEST:** a test must exercise the exact code path it claims to verify, never bypass it (e.g. by setting an env that short-circuits the code under test, or asserting on base state that predates the change). A test that passes against the pre-change code is vacuous and provides no signal. The reviewer confirms that every new test FAILS against the unfixed code before accepting it as evidence. The critic probes for this on every passing criterion.
544
544