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 +29 -0
- package/agents/company-reviewer.md +13 -1
- package/bin/install.js +3 -1
- package/install.sh +2 -2
- package/package.json +2 -1
- package/scripts/dashboard.js +0 -10
- package/scripts/diagnosis-block.js +75 -0
- package/scripts/reset-company-guard.js +118 -0
- package/scripts/statusline.js +365 -26
- package/skill/SKILL.md +2 -2
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
|
|
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
|
|
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.
|
|
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",
|
package/scripts/dashboard.js
CHANGED
|
@@ -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).
|
package/scripts/statusline.js
CHANGED
|
@@ -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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
150
|
-
if (!
|
|
151
|
-
if (typeof inner.model === 'string')
|
|
152
|
-
else if (typeof msg.model === 'string')
|
|
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
|
-
|
|
363
|
+
return { usage: inner.usage, modelId, completeLines };
|
|
155
364
|
}
|
|
156
365
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
503
|
+
// that pays the full timeout for nothing. Treat self-reference as no base.
|
|
227
504
|
if (argv.length > 0 && !isSelfReferential(argv, __filename)) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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
|
|