harness-everything 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +35 -0
- package/PRIVACY.md +9 -0
- package/benchmarks/fixtures/system-one-intent-holdout-draft.json +2228 -0
- package/benchmarks/fixtures/system-one-intent-holdout.json +16509 -0
- package/benchmarks/fixtures/system-one-intent-reviews.json +2723 -0
- package/benchmarks/results/system-one/intent-ngram-v1-2026-09-24/README.md +64 -0
- package/benchmarks/results/system-one/intent-ngram-v1-2026-09-24/report.json +15177 -0
- package/benchmarks/results/system-one/labeler-holdout-check-intent-2026-09-24/report.json +2976 -0
- package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report-005-steps.json +6125 -0
- package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report-haiku-sonnet-escalation.json +6344 -0
- package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report-scaled-margin-escalation.json +6206 -0
- package/benchmarks/results/system-one/labeler-holdout-check-intent-scores-haiku-2026-09-24/report.json +6103 -0
- package/benchmarks/results/system-one/laya-ft-2026-09-25/README.md +83 -0
- package/benchmarks/results/system-one/laya-ft-2026-09-25/report.json +71 -0
- package/benchmarks/results/system-one/laya-ft-2026-09-25/shadow-report.json +44 -0
- package/benchmarks/results/system-one/laya-zs-2026-09-25/README.md +68 -0
- package/benchmarks/results/system-one/laya-zs-2026-09-25/report.json +173 -0
- package/ci/mechanism-34-observations.test.js +550 -0
- package/ci/mechanism-system-one-collect.test.js +16 -0
- package/ci/mechanism-system-one-intent.test.js +406 -0
- package/ci/mechanism-system-one-laya.test.js +45 -0
- package/ci/mechanism-system-one-relevance.test.js +112 -0
- package/ci/mechanism-system-one-resident.test.js +16 -0
- package/ci/system-one-laya-calibrate-test.py +65 -0
- package/ci/system-one-laya-export-test.py +155 -0
- package/ci/system-one-laya-label-test.py +201 -0
- package/ci/system-one-laya-perintent-test.py +60 -0
- package/ci/system-one-laya-provider-test.py +115 -0
- package/ci/system-one-laya-train-test.py +93 -0
- package/docs/routing.md +25 -0
- package/docs/system-one-intent-gates.md +102 -0
- package/docs/system-one-intent.md +173 -0
- package/docs/system-one-laya-training.md +38 -0
- package/docs/system-one-observations.md +206 -0
- package/docs/system-one-routing.md +25 -10
- package/docs/system-one-suggestion-gates.md +292 -0
- package/docs/system-one-training.md +56 -6
- package/harness-everything/SKILL.md +1 -1
- package/harness-everything/scripts/kernel-router.js +10 -0
- package/harness-everything/scripts/system-one/catalogs.js +35 -0
- package/harness-everything/scripts/system-one/contract.js +45 -1
- package/harness-everything/scripts/system-one/evaluate.js +115 -19
- package/harness-everything/scripts/system-one/intent.js +22 -0
- package/harness-everything/scripts/system-one/laya_adapter.py +141 -0
- package/harness-everything/scripts/system-one/provider.js +10 -3
- package/harness-everything/scripts/system-one/resident.js +8 -3
- package/harness-everything/scripts/system-one/router.js +1 -1
- package/hooks/hooks.json +40 -0
- package/hooks/scripts/codex-user-prompt.js +39 -0
- package/hooks/scripts/lib/observations.js +581 -0
- package/hooks/scripts/observation-hook.js +25 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/hooks.json +35 -2
- package/plugins/harness-everything/hooks/scripts/codex-user-prompt.js +39 -0
- package/plugins/harness-everything/hooks/scripts/lib/observations.js +581 -0
- package/plugins/harness-everything/hooks/scripts/observation-hook.js +25 -0
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +10 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/catalogs.js +35 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/contract.js +45 -1
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/evaluate.js +115 -19
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/intent.js +22 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/laya_adapter.py +141 -0
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/provider.js +10 -3
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/resident.js +8 -3
- package/plugins/harness-everything/skills/harness-everything/scripts/system-one/router.js +1 -1
- package/sbom.cdx.json +7 -7
- package/scripts/evaluate-system-one.js +17 -5
- package/scripts/system-one-calibrate-laya.py +165 -0
- package/scripts/system-one-calibrate.js +42 -13
- package/scripts/system-one-collect.js +7 -3
- package/scripts/system-one-compare-laya.py +156 -0
- package/scripts/system-one-corpus.js +22 -15
- package/scripts/system-one-eval-laya.js +112 -0
- package/scripts/system-one-export-laya.py +197 -0
- package/scripts/system-one-label-laya.py +243 -0
- package/scripts/system-one-label.js +163 -37
- package/scripts/system-one-observations-export.js +106 -0
- package/scripts/system-one-observations-review.js +86 -0
- package/scripts/system-one-perintent-laya.py +134 -0
- package/scripts/system-one-train-laya.py +344 -0
- package/scripts/system-one-train-ngram.py +66 -14
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// System One turn observations (docs/system-one-observations.md).
|
|
3
|
+
// Local-only, fail-open collection of one record per turn: the prompt and the
|
|
4
|
+
// previous assistant message (private text store), the router's proposal,
|
|
5
|
+
// behavior counters, and the agent's own harness-label line. Telemetry stays
|
|
6
|
+
// content-free; this is a separate channel that never leaves the machine.
|
|
7
|
+
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const INTENTS = ['explain', 'discuss', 'git', 'fix', 'edit', 'feature', 'refactor', 'review', 'test', 'docs', 'plan', 'investigate'];
|
|
14
|
+
const TIERS = ['tier1', 'tier2', 'tier3'];
|
|
15
|
+
const VALIDITY = ['actionable', 'invalid'];
|
|
16
|
+
const STRATEGIES = ['direct-single', 'iterative-single', 'fable-staged', 'fable-parallel', 'fable-multi-agent-workspace'];
|
|
17
|
+
const RETENTION_MS = 180 * 24 * 60 * 60 * 1000;
|
|
18
|
+
const PREVIOUS_BYTES = 2048;
|
|
19
|
+
const TRANSCRIPT_TAIL_BYTES = 512 * 1024;
|
|
20
|
+
const LABEL_RE = /<!--\s*harness-label\s+(\{[\s\S]*?\})\s*-->/g;
|
|
21
|
+
// A final label line whose closing --> was dropped (seen live on Codex).
|
|
22
|
+
const OPEN_LABEL_RE = /<!--\s*harness-label\s+(\{[^\n]*\})\s*$/;
|
|
23
|
+
const WRITE_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
|
|
24
|
+
const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'shell', 'exec_command', 'local_shell', 'container.exec']);
|
|
25
|
+
|
|
26
|
+
function enabled(env = process.env) {
|
|
27
|
+
const value = String(env.HARNESS_OBSERVATIONS || 'on').toLowerCase();
|
|
28
|
+
return !['0', 'off', 'false', 'disabled', 'none'].includes(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function storeRoot(env = process.env) {
|
|
32
|
+
return env.HARNESS_OBSERVATIONS_DIR || path.join(os.homedir(), '.agents', 'harness-everything', 'system-one', 'observations');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sha(value, length = 64) {
|
|
36
|
+
return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, length);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function escapeRegExp(value) {
|
|
40
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function redact(text) {
|
|
44
|
+
let out = String(text || '');
|
|
45
|
+
out = out.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '<email>');
|
|
46
|
+
const home = os.homedir();
|
|
47
|
+
if (home && home.length > 1) out = out.replace(new RegExp(escapeRegExp(home), 'g'), '<home>');
|
|
48
|
+
out = out.replace(/[A-Za-z]:\\+Users\\+[^\\\s"'<>|]+/gi, '<home>');
|
|
49
|
+
out = out.replace(/\/(?:Users|home)\/[^/\s"'<>|]+/g, '<home>');
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tailBytes(text, bytes) {
|
|
54
|
+
const buffer = Buffer.from(String(text || ''), 'utf8');
|
|
55
|
+
if (buffer.length <= bytes) return buffer.toString('utf8');
|
|
56
|
+
return buffer.subarray(buffer.length - bytes).toString('utf8').replace(/^�+/, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validLabel(raw) {
|
|
60
|
+
if (!raw || typeof raw !== 'object' || raw.v !== 1) return false;
|
|
61
|
+
if (!VALIDITY.includes(raw.validity) || typeof raw.contextDependent !== 'boolean') return false;
|
|
62
|
+
if (!(raw.tier === null || TIERS.includes(raw.tier))) return false;
|
|
63
|
+
if (!Array.isArray(raw.intents) || raw.intents.length > 6 || !raw.intents.every(i => typeof i === 'string')) return false;
|
|
64
|
+
const known = raw.intents.filter(i => INTENTS.includes(i));
|
|
65
|
+
if (known.length > 3 || new Set(known).size !== known.length) return false;
|
|
66
|
+
if (!(raw.workflow === null || STRATEGIES.includes(raw.workflow))) return false;
|
|
67
|
+
if (!Array.isArray(raw.skills) || raw.skills.length > 12
|
|
68
|
+
|| !raw.skills.every(s => typeof s === 'string' && /^[A-Za-z0-9:_.-]{1,80}$/.test(s))) return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseLabelLine(text) {
|
|
73
|
+
const body = String(text || '');
|
|
74
|
+
const matches = [...body.matchAll(LABEL_RE)];
|
|
75
|
+
const open = matches.length ? null : body.trimEnd().match(OPEN_LABEL_RE);
|
|
76
|
+
if (!matches.length && !open) return { label: null, reason: 'label-missing' };
|
|
77
|
+
let raw;
|
|
78
|
+
try { raw = JSON.parse(open ? open[1] : matches[matches.length - 1][1]); } catch (_) { return { label: null, reason: 'label-unparseable' }; }
|
|
79
|
+
if (!validLabel(raw)) return { label: null, reason: 'label-invalid' };
|
|
80
|
+
// Unknown intent ids are dropped, not fatal: the rest of the label is still the agent's judgement.
|
|
81
|
+
const unknownIntents = raw.intents.filter(i => !INTENTS.includes(i));
|
|
82
|
+
const label = { v: 1, validity: raw.validity, contextDependent: raw.contextDependent, tier: raw.tier,
|
|
83
|
+
intents: raw.intents.filter(i => INTENTS.includes(i)), workflow: raw.workflow, skills: raw.skills.map(skillName) };
|
|
84
|
+
if (unknownIntents.length) label.unknownIntents = unknownIntents;
|
|
85
|
+
return { label, reason: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stripLabels(text) {
|
|
89
|
+
return String(text || '').replace(LABEL_RE, '').trimEnd().replace(OPEN_LABEL_RE, '').trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function skillName(value) {
|
|
93
|
+
const name = String(value || '').trim();
|
|
94
|
+
return name.includes(':') ? name.slice(name.lastIndexOf(':') + 1) : name;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const COMMAND_CLASSES = [
|
|
98
|
+
['git', /^git$/],
|
|
99
|
+
['gh', /^gh$/],
|
|
100
|
+
['test', /^(pytest|py\.test|unittest|jest|vitest|mocha|tox|nox|phpunit|rspec)$/],
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// Runner prefixes that execute another command: classify what they run.
|
|
104
|
+
function unwrapRunner(tokens) {
|
|
105
|
+
const [cmd, sub] = tokens;
|
|
106
|
+
if (/^python(3(\.\d+)?)?$/.test(cmd || '') && sub === '-m' && tokens[2]) return tokens.slice(2);
|
|
107
|
+
if (/^(npx|bunx|uvx|pipx)$/.test(cmd || '')) {
|
|
108
|
+
const rest = tokens.slice(1);
|
|
109
|
+
while (rest.length && rest[0].startsWith('-')) rest.shift();
|
|
110
|
+
if (rest.length) return rest;
|
|
111
|
+
}
|
|
112
|
+
if (/^(uv|poetry|pdm|hatch)$/.test(cmd || '') && sub === 'run' && tokens[2]) return tokens.slice(2);
|
|
113
|
+
if (/^(pnpm|yarn|bun)$/.test(cmd || '') && (sub === 'exec' || sub === 'dlx' || sub === 'x') && tokens[2]) return tokens.slice(2);
|
|
114
|
+
return tokens;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function classifySegment(tokens) {
|
|
118
|
+
for (let i = 0; i < 3; i++) {
|
|
119
|
+
const next = unwrapRunner(tokens);
|
|
120
|
+
if (next === tokens) break;
|
|
121
|
+
tokens = next;
|
|
122
|
+
}
|
|
123
|
+
const [cmd, sub, third] = tokens;
|
|
124
|
+
if (!cmd) return null;
|
|
125
|
+
for (const [name, re] of COMMAND_CLASSES) if (re.test(cmd)) return name;
|
|
126
|
+
if (/^(npm|pnpm|yarn|bun)$/.test(cmd)) {
|
|
127
|
+
if (sub === 'test' || sub === 't' || (sub === 'run' && /^test/.test(third || ''))) return 'test';
|
|
128
|
+
if (sub === 'run' && /^(build|compile)/.test(third || '')) return 'build';
|
|
129
|
+
if (/^(install|i|ci|add|remove|uninstall|update|upgrade)$/.test(sub || '')) return 'package';
|
|
130
|
+
return 'shell';
|
|
131
|
+
}
|
|
132
|
+
if (/^(go|cargo|dotnet)$/.test(cmd)) {
|
|
133
|
+
if (sub === 'test') return 'test';
|
|
134
|
+
if (sub === 'build') return 'build';
|
|
135
|
+
if (/^(add|get|install|restore)$/.test(sub || '')) return 'package';
|
|
136
|
+
}
|
|
137
|
+
if (/^(make|msbuild|msbuild\.exe|tsc|gradle|mvn|cmake|ninja)$/i.test(cmd)) return 'build';
|
|
138
|
+
if (/^(pip|pip3|uv|poetry|brew|apt|apt-get|choco|winget|conda)$/.test(cmd)) return 'package';
|
|
139
|
+
if (cmd === 'node' && /(^|\/)(ci|test|tests)\//.test(sub || '') && /test/.test(sub || '')) return 'test';
|
|
140
|
+
return 'shell';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function classifyCommand(command) {
|
|
144
|
+
const text = Array.isArray(command) ? command.join(' ') : String(command || '');
|
|
145
|
+
const segments = text.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean);
|
|
146
|
+
let fallback = null;
|
|
147
|
+
for (const segment of segments) {
|
|
148
|
+
const tokens = segment.split(/\s+/).filter(t => !/^[A-Z_][A-Z0-9_]*=/.test(t));
|
|
149
|
+
while (tokens[0] === 'sudo' || tokens[0] === 'env' || tokens[0] === 'time') tokens.shift();
|
|
150
|
+
if (!tokens.length || tokens[0] === 'cd' || tokens[0] === 'pushd') continue;
|
|
151
|
+
const kind = classifySegment(tokens);
|
|
152
|
+
if (kind && kind !== 'shell') return kind;
|
|
153
|
+
fallback = fallback || kind;
|
|
154
|
+
}
|
|
155
|
+
return fallback || 'shell';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The tier label is the agent's own judgement; the owner's review overrides it.
|
|
159
|
+
// There is no file-count rule (docs/system-one-observations.md#labels-and-how-much-to-trust-them).
|
|
160
|
+
function labelTier(self, owner) {
|
|
161
|
+
if (owner && TIERS.includes(owner.tier)) return { tier: owner.tier, source: 'owner' };
|
|
162
|
+
if (self && TIERS.includes(self.tier)) return { tier: self.tier, source: 'self-report' };
|
|
163
|
+
return { tier: null, source: null };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Behavior is evidence, not a label: it only flags records that contradict themselves.
|
|
167
|
+
function contradictions(self, behavior) {
|
|
168
|
+
const out = [];
|
|
169
|
+
if (self && self.validity === 'actionable' && self.tier === 'tier1' && behavior && behavior.filesWritten > 0) {
|
|
170
|
+
out.push('tier1-with-writes');
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Mirrors normalizeTerms in multi-agent-workspace/scripts/index_memory.js.
|
|
176
|
+
function normalizeTerms(value) {
|
|
177
|
+
const stop = new Set(['the', 'and', 'for', 'with', 'from', 'this', 'that', 'into', 'when', 'then', 'before', 'after', 'always', 'never', 'should', 'must', 'use', 'using', 'verify', 'check']);
|
|
178
|
+
const matches = String(value || '').toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}._-]{1,}/gu) || [];
|
|
179
|
+
return [...new Set(matches.filter(term => !stop.has(term) && !term.startsWith('<')))].slice(0, 64);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readJson(file, fallback) {
|
|
183
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return fallback; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function writeJsonAtomic(file, value) {
|
|
187
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
188
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
189
|
+
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
190
|
+
fs.renameSync(tmp, file);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sessionFile(root, sessionId) {
|
|
194
|
+
return path.join(root, 'sessions', `${sha(sessionId || 'unknown', 32)}.json`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function repoRoot(file, cache) {
|
|
198
|
+
let dir = path.dirname(path.resolve(file));
|
|
199
|
+
for (let i = 0; i < 40; i++) {
|
|
200
|
+
if (cache[dir] !== undefined) return cache[dir];
|
|
201
|
+
if (fs.existsSync(path.join(dir, '.git'))) { cache[dir] = dir; return dir; }
|
|
202
|
+
const parent = path.dirname(dir);
|
|
203
|
+
if (parent === dir) break;
|
|
204
|
+
dir = parent;
|
|
205
|
+
}
|
|
206
|
+
return path.dirname(path.resolve(file));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function emptyBehavior() {
|
|
210
|
+
return { filesWritten: 0, reposWritten: 0, filesCreated: 0, commands: { git: 0, gh: 0, test: 0, build: 0, package: 0, shell: 0 },
|
|
211
|
+
skills: [], subagents: 0, toolCalls: 0, toolFailures: 0, failedWrites: 0, failedSkillLoads: 0, _files: [], _repos: [], _created: [] };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Adds one tool event's counters into a turn's behavior; sets are unioned.
|
|
215
|
+
function mergeBehavior(into, delta) {
|
|
216
|
+
for (const key of ['toolCalls', 'toolFailures', 'failedWrites', 'failedSkillLoads', 'subagents']) into[key] += delta[key] || 0;
|
|
217
|
+
for (const key of Object.keys(into.commands)) into.commands[key] += (delta.commands || {})[key] || 0;
|
|
218
|
+
for (const key of ['_files', '_repos', '_created', 'skills']) {
|
|
219
|
+
for (const value of delta[key] || []) if (!into[key].includes(value)) into[key].push(value);
|
|
220
|
+
}
|
|
221
|
+
if (delta._codex) into._codex = true;
|
|
222
|
+
into.filesWritten = into._files.length;
|
|
223
|
+
into.reposWritten = into._repos.length;
|
|
224
|
+
into.filesCreated = into._created.length;
|
|
225
|
+
return into;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Claude Code reports failures as PostToolUseFailure; Codex reports them in the tool response.
|
|
229
|
+
function toolFailed(payload, event) {
|
|
230
|
+
if (event === 'PostToolUseFailure' || payload.error) return true;
|
|
231
|
+
const response = payload.tool_response ?? payload.toolResponse;
|
|
232
|
+
if (typeof response === 'string') return /^\s*(error|failed|failure|apply_patch verification failed)\b/i.test(response);
|
|
233
|
+
if (!response || typeof response !== 'object') return false;
|
|
234
|
+
if (response.is_error === true || response.isError === true || response.success === false || response.ok === false) return true;
|
|
235
|
+
const code = response.exitCode ?? response.exit_code;
|
|
236
|
+
return typeof code === 'number' && code !== 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function patchFiles(text) {
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const match of String(text || '').matchAll(/^\*\*\* (Add|Update|Delete) File: (.+)$/gm)) out.push({ op: match[1], file: match[2].trim() });
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function noteFile(behavior, file, created, cwd, cache) {
|
|
246
|
+
if (!file) return;
|
|
247
|
+
const absolute = path.isAbsolute(file) ? file : path.resolve(cwd || process.cwd(), file);
|
|
248
|
+
const fileKey = sha(absolute, 16);
|
|
249
|
+
const repoKey = sha(repoRoot(absolute, cache), 16);
|
|
250
|
+
if (!behavior._files.includes(fileKey)) behavior._files.push(fileKey);
|
|
251
|
+
if (!behavior._repos.includes(repoKey)) behavior._repos.push(repoKey);
|
|
252
|
+
if (created && !behavior._created.includes(fileKey)) behavior._created.push(fileKey);
|
|
253
|
+
behavior.filesWritten = behavior._files.length;
|
|
254
|
+
behavior.reposWritten = behavior._repos.length;
|
|
255
|
+
behavior.filesCreated = behavior._created.length;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function recordTool(behavior, payload, failed) {
|
|
259
|
+
const tool = String(payload.tool_name || payload.toolName || '');
|
|
260
|
+
const input = payload.tool_input || payload.toolInput || {};
|
|
261
|
+
const cwd = payload.cwd;
|
|
262
|
+
const cache = {};
|
|
263
|
+
behavior.toolCalls++;
|
|
264
|
+
if (failed) behavior.toolFailures++;
|
|
265
|
+
// A failed attempt is counted, but it is never evidence of a write or a load.
|
|
266
|
+
const write = (file, created) => { if (failed) behavior.failedWrites++; else noteFile(behavior, file, created, cwd, cache); };
|
|
267
|
+
const skill = name => {
|
|
268
|
+
if (!name) return;
|
|
269
|
+
if (failed) behavior.failedSkillLoads++;
|
|
270
|
+
else if (!behavior.skills.includes(name)) behavior.skills.push(name);
|
|
271
|
+
};
|
|
272
|
+
if (WRITE_TOOLS.has(tool)) {
|
|
273
|
+
const created = tool === 'Write' && payload.tool_response && payload.tool_response.type === 'create';
|
|
274
|
+
write(input.file_path || input.notebook_path || input.path, created);
|
|
275
|
+
} else if (tool === 'apply_patch' || tool === 'ApplyPatch') {
|
|
276
|
+
const text = typeof input === 'string' ? input : (input.command || input.input || input.patch || '');
|
|
277
|
+
for (const { op, file } of patchFiles(Array.isArray(text) ? text.join('\n') : text)) write(file, op === 'Add');
|
|
278
|
+
} else if (SHELL_TOOLS.has(tool)) {
|
|
279
|
+
const command = typeof input === 'string' ? input : (input.command || input.cmd || '');
|
|
280
|
+
const patched = patchFiles(Array.isArray(command) ? command.join('\n') : command);
|
|
281
|
+
if (patched.length) for (const { op, file } of patched) write(file, op === 'Add');
|
|
282
|
+
else behavior.commands[classifyCommand(command)]++;
|
|
283
|
+
} else if (tool === 'Skill') {
|
|
284
|
+
skill(skillName(input.skill || input.name || input.command));
|
|
285
|
+
} else if (tool === 'Read') {
|
|
286
|
+
const file = String(input.file_path || '');
|
|
287
|
+
if (/[\\/]SKILL\.md$/.test(file)) skill(path.basename(path.dirname(file)));
|
|
288
|
+
} else if (tool === 'Task' || tool === 'Agent') {
|
|
289
|
+
behavior.subagents++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A Claude user entry that starts a turn: a typed prompt, not a tool result or a meta note.
|
|
294
|
+
function claudePrompt(row) {
|
|
295
|
+
if (row.type !== 'user' || row.isMeta || !row.message) return false;
|
|
296
|
+
const content = row.message.content;
|
|
297
|
+
if (typeof content === 'string') return true;
|
|
298
|
+
return Array.isArray(content) && content.some(c => c && c.type === 'text') && !content.some(c => c && c.type === 'tool_result');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The current turn's final message from a transcript tail. The reverse scan
|
|
302
|
+
// stops at the start of the current turn, so an earlier turn's answer (and its
|
|
303
|
+
// label) is never returned; a Codex task_complete must match turnId when both are known.
|
|
304
|
+
function transcriptMessage(file, turnId) {
|
|
305
|
+
if (!file || !fs.existsSync(file)) return { text: null, codex: false };
|
|
306
|
+
let text;
|
|
307
|
+
try {
|
|
308
|
+
const size = fs.statSync(file).size;
|
|
309
|
+
const fd = fs.openSync(file, 'r');
|
|
310
|
+
const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
|
|
311
|
+
const buffer = Buffer.alloc(length);
|
|
312
|
+
fs.readSync(fd, buffer, 0, length, size - length);
|
|
313
|
+
fs.closeSync(fd);
|
|
314
|
+
text = buffer.toString('utf8');
|
|
315
|
+
} catch (_) { return { text: null, codex: false }; }
|
|
316
|
+
const lines = text.split('\n').filter(Boolean).reverse();
|
|
317
|
+
let codex = false;
|
|
318
|
+
let fallback = null;
|
|
319
|
+
for (const line of lines) {
|
|
320
|
+
let row;
|
|
321
|
+
try { row = JSON.parse(line); } catch (_) { continue; }
|
|
322
|
+
if (row.type === 'event_msg' || row.type === 'response_item' || row.type === 'turn_context') codex = true;
|
|
323
|
+
const payload = row.payload || {};
|
|
324
|
+
const otherTurn = Boolean(turnId && payload.turn_id && payload.turn_id !== turnId);
|
|
325
|
+
if (row.type === 'event_msg' && payload.type === 'task_complete') {
|
|
326
|
+
if (otherTurn) break;
|
|
327
|
+
if (typeof payload.last_agent_message === 'string') return { text: payload.last_agent_message, codex: true };
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (row.type === 'event_msg' && (payload.type === 'task_started' || payload.type === 'user_message')) break;
|
|
331
|
+
if (row.type === 'response_item' && payload.type === 'message' && payload.role === 'user') break;
|
|
332
|
+
if (row.type === 'turn_context' && otherTurn) break;
|
|
333
|
+
if (claudePrompt(row)) break;
|
|
334
|
+
if (!fallback && row.type === 'response_item' && payload.type === 'message' && payload.role === 'assistant'
|
|
335
|
+
&& payload.phase !== 'commentary') {
|
|
336
|
+
fallback = (payload.content || []).map(c => c.text || '').join('\n').trim() || null;
|
|
337
|
+
}
|
|
338
|
+
if (!fallback && row.type === 'assistant' && row.message && Array.isArray(row.message.content)) {
|
|
339
|
+
const parts = row.message.content.filter(c => c.type === 'text').map(c => c.text);
|
|
340
|
+
if (parts.length) fallback = parts.join('\n');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { text: fallback, codex };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function detectHost(payload, hints) {
|
|
347
|
+
const direct = String(payload.host || payload.runtime || '').toLowerCase();
|
|
348
|
+
if (['claude', 'codex'].includes(direct)) return direct;
|
|
349
|
+
if (hints.codex || payload.turn_id || payload.last_agent_message) return 'codex';
|
|
350
|
+
if (process.env.CLAUDE_PLUGIN_ROOT || process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT || process.env.CLAUDE) return 'claude';
|
|
351
|
+
if (process.env.CODEX_HOME || process.env.CODEX_THREAD_ID) return 'codex';
|
|
352
|
+
return 'unknown';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function routerState(payload) {
|
|
356
|
+
try {
|
|
357
|
+
const state = require('./harness-state');
|
|
358
|
+
const root = state.getWorkspaceRoot(payload);
|
|
359
|
+
const dir = state.getSessionDir(root, state.getSessionId(payload), payload);
|
|
360
|
+
const file = path.join(dir, 'workflow-run.json');
|
|
361
|
+
if (!fs.existsSync(file)) return null;
|
|
362
|
+
const run = readJson(file, null);
|
|
363
|
+
if (!run) return null;
|
|
364
|
+
const plan = run.workflowPlan && typeof run.workflowPlan === 'object' ? run.workflowPlan : run;
|
|
365
|
+
return { tier: plan.tier || run.tier || null, strategy: plan.strategy || run.strategy || null,
|
|
366
|
+
strategySelection: plan.strategySelection || run.strategySelection || null,
|
|
367
|
+
suggestedSkills: Array.isArray(plan.suggestedSkills) ? plan.suggestedSkills.slice(0, 12) : [] };
|
|
368
|
+
} catch (_) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function withLock(root, fn) {
|
|
374
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
375
|
+
const lock = path.join(root, '.index.lock');
|
|
376
|
+
const deadline = Date.now() + 2000;
|
|
377
|
+
let fd = null;
|
|
378
|
+
while (fd === null) {
|
|
379
|
+
try { fd = fs.openSync(lock, 'wx'); } catch (_) {
|
|
380
|
+
try { if (Date.now() - fs.statSync(lock).mtimeMs > 5000) fs.unlinkSync(lock); } catch (__) { /* raced */ }
|
|
381
|
+
if (Date.now() > deadline) return false;
|
|
382
|
+
const until = Date.now() + 10;
|
|
383
|
+
while (Date.now() < until) { /* short spin; hooks are short-lived */ }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
try { fn(); return true; } finally { fs.closeSync(fd); try { fs.unlinkSync(lock); } catch (_) { /* gone */ } }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function expired(record, now = Date.now()) {
|
|
390
|
+
return Boolean(record.textDeleted || (record.validUntil && Date.parse(record.validUntil) <= now));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Text of a record that is still inside its retention window, else null.
|
|
394
|
+
function recordText(root, record, now = Date.now()) {
|
|
395
|
+
if (expired(record, now)) return null;
|
|
396
|
+
return readJson(path.join(root, 'text', `${record.contentSha256}.json`), null);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Retention sweep; the caller holds the lock. Text blobs are content-addressed,
|
|
400
|
+
// so a blob is deleted only when no active record references it. Session files
|
|
401
|
+
// (previous-message tail, unfinished prompt) older than the window are deleted.
|
|
402
|
+
function sweepLocked(root, index, now) {
|
|
403
|
+
const textDir = path.join(root, 'text');
|
|
404
|
+
let changed = false;
|
|
405
|
+
for (const r of index.records) {
|
|
406
|
+
if (r.status !== 'expired' && expired(r, now)) { r.textDeleted = true; r.status = 'expired'; changed = true; }
|
|
407
|
+
}
|
|
408
|
+
const active = new Set(index.records.filter(r => !expired(r, now)).map(r => r.contentSha256));
|
|
409
|
+
for (const r of index.records) {
|
|
410
|
+
if (r.textDeleted && !active.has(r.contentSha256)) {
|
|
411
|
+
try { fs.unlinkSync(path.join(textDir, `${r.contentSha256}.json`)); } catch (_) { /* already gone */ }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const sessions = path.join(root, 'sessions');
|
|
415
|
+
let entries = [];
|
|
416
|
+
try { entries = fs.readdirSync(sessions); } catch (_) { /* none yet */ }
|
|
417
|
+
for (const name of entries) {
|
|
418
|
+
const file = path.join(sessions, name);
|
|
419
|
+
try {
|
|
420
|
+
if (now - fs.statSync(file).mtimeMs > RETENTION_MS) fs.rmSync(file, { recursive: true, force: true });
|
|
421
|
+
} catch (_) { /* raced */ }
|
|
422
|
+
}
|
|
423
|
+
return changed;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function sweep(root, now = Date.now()) {
|
|
427
|
+
const file = path.join(root, 'observations-index.json');
|
|
428
|
+
if (!fs.existsSync(file)) return false;
|
|
429
|
+
return withLock(root, () => {
|
|
430
|
+
const index = readJson(file, null);
|
|
431
|
+
if (index && Array.isArray(index.records) && sweepLocked(root, index, now)) writeJsonAtomic(file, index);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function appendRecord(root, record, text) {
|
|
436
|
+
return withLock(root, () => {
|
|
437
|
+
// The blob is written under the lock, so a concurrent sweep cannot remove it before its record lands.
|
|
438
|
+
const textDir = path.join(root, 'text');
|
|
439
|
+
fs.mkdirSync(textDir, { recursive: true, mode: 0o700 });
|
|
440
|
+
if (text) fs.writeFileSync(path.join(textDir, `${record.contentSha256}.json`), JSON.stringify(text), { mode: 0o600 });
|
|
441
|
+
const file = path.join(root, 'observations-index.json');
|
|
442
|
+
const index = readJson(file, null) || { schemaVersion: 1, kind: 'system-one-observations', records: [] };
|
|
443
|
+
index.records.push(record);
|
|
444
|
+
sweepLocked(root, index, Date.now());
|
|
445
|
+
writeJsonAtomic(file, index);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Tool events are immutable files, one per event, so parallel hooks never
|
|
450
|
+
// overwrite each other; Stop adds them up.
|
|
451
|
+
function eventsDir(root, sessionId, turn) {
|
|
452
|
+
return path.join(root, 'sessions', `${sha(sessionId || 'unknown', 32)}.events`, String(turn));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function writeEvent(root, sessionId, turn, delta) {
|
|
456
|
+
const dir = eventsDir(root, sessionId, turn);
|
|
457
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
458
|
+
const name = `${process.pid}-${process.hrtime.bigint()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
459
|
+
const tmp = path.join(dir, `.${name}.tmp`);
|
|
460
|
+
fs.writeFileSync(tmp, JSON.stringify(delta), { mode: 0o600 });
|
|
461
|
+
fs.renameSync(tmp, path.join(dir, `${name}.json`));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function collectEvents(root, sessionId, turn) {
|
|
465
|
+
const dir = eventsDir(root, sessionId, turn);
|
|
466
|
+
const behavior = emptyBehavior();
|
|
467
|
+
let names = [];
|
|
468
|
+
try { names = fs.readdirSync(dir); } catch (_) { return behavior; }
|
|
469
|
+
for (const name of names) {
|
|
470
|
+
if (!name.endsWith('.json') || name.startsWith('.')) continue;
|
|
471
|
+
const delta = readJson(path.join(dir, name), null);
|
|
472
|
+
if (delta) mergeBehavior(behavior, delta);
|
|
473
|
+
}
|
|
474
|
+
return behavior;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function startTurn(payload, root) {
|
|
478
|
+
const file = sessionFile(root, payload.session_id);
|
|
479
|
+
const session = readJson(file, null) || { turn: 0, lastFinal: '', pending: null };
|
|
480
|
+
if (session.pending) finishPending(session, payload, root, { text: null, reason: 'stop-missing' });
|
|
481
|
+
if (!session.lastFinalAt || Date.now() - Date.parse(session.lastFinalAt) > RETENTION_MS) session.lastFinal = '';
|
|
482
|
+
// Events left from an earlier turn arrived after its Stop; they belong to no record.
|
|
483
|
+
fs.rmSync(path.dirname(eventsDir(root, payload.session_id, 0)), { recursive: true, force: true });
|
|
484
|
+
session.turn++;
|
|
485
|
+
session.pending = {
|
|
486
|
+
turn: session.turn,
|
|
487
|
+
startedAt: new Date().toISOString(),
|
|
488
|
+
prompt: redact(typeof payload.prompt === 'string' ? payload.prompt : ''),
|
|
489
|
+
previous: session.lastFinal || '',
|
|
490
|
+
previousAt: session.lastFinal ? session.lastFinalAt : null,
|
|
491
|
+
cwd: payload.cwd ? sha(payload.cwd, 16) : null,
|
|
492
|
+
};
|
|
493
|
+
writeJsonAtomic(file, session);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Tool hooks only read the session file; they never rewrite it.
|
|
497
|
+
function toolTurn(payload, root, failed, codex) {
|
|
498
|
+
const session = readJson(sessionFile(root, payload.session_id), null);
|
|
499
|
+
if (!session || !session.pending) return;
|
|
500
|
+
const delta = emptyBehavior();
|
|
501
|
+
recordTool(delta, payload, failed);
|
|
502
|
+
if (codex) delta._codex = true;
|
|
503
|
+
writeEvent(root, payload.session_id, session.pending.turn, delta);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function finishPending(session, payload, root, final) {
|
|
507
|
+
const pending = session.pending;
|
|
508
|
+
const { label, reason } = final.text === null && final.reason ? { label: null, reason: final.reason } : parseLabelLine(final.text);
|
|
509
|
+
const collected = collectEvents(root, payload.session_id, pending.turn);
|
|
510
|
+
const behavior = { ...collected };
|
|
511
|
+
delete behavior._files;
|
|
512
|
+
delete behavior._repos;
|
|
513
|
+
delete behavior._created;
|
|
514
|
+
delete behavior._codex;
|
|
515
|
+
const text = { prompt: pending.prompt, previous: pending.previous };
|
|
516
|
+
const contentSha256 = sha(JSON.stringify(text));
|
|
517
|
+
const now = Date.now();
|
|
518
|
+
const observedAt = new Date(now).toISOString();
|
|
519
|
+
// Retention counts from when the text was captured; finishing an old turn never renews it.
|
|
520
|
+
const captured = [pending.startedAt, pending.previous ? (pending.previousAt || pending.startedAt) : null]
|
|
521
|
+
.map(t => Date.parse(t)).filter(Number.isFinite);
|
|
522
|
+
const validUntil = (captured.length ? Math.min(...captured) : now) + RETENTION_MS;
|
|
523
|
+
const textExpired = validUntil <= now;
|
|
524
|
+
const record = {
|
|
525
|
+
id: sha(`${payload.session_id || 'unknown'}:${pending.turn}:${pending.startedAt}`, 24),
|
|
526
|
+
source: 'observation',
|
|
527
|
+
status: textExpired ? 'expired' : 'active',
|
|
528
|
+
...(textExpired ? { textDeleted: true } : {}),
|
|
529
|
+
observedAt,
|
|
530
|
+
validUntil: new Date(validUntil).toISOString(),
|
|
531
|
+
contentSha256,
|
|
532
|
+
host: detectHost(payload, { codex: final.codex || collected._codex }),
|
|
533
|
+
turn: pending.turn,
|
|
534
|
+
writer: { sessionId: String(payload.session_id || 'unknown') },
|
|
535
|
+
scope: { taskTerms: normalizeTerms(pending.prompt), requirementTerms: [], roles: [] },
|
|
536
|
+
promptBytes: Buffer.byteLength(pending.prompt, 'utf8'),
|
|
537
|
+
router: routerState(payload),
|
|
538
|
+
behavior,
|
|
539
|
+
selfReport: label,
|
|
540
|
+
selfReportReason: reason,
|
|
541
|
+
};
|
|
542
|
+
appendRecord(root, record, textExpired ? null : text);
|
|
543
|
+
fs.rmSync(eventsDir(root, payload.session_id, pending.turn), { recursive: true, force: true });
|
|
544
|
+
session.lastFinal = final.text ? redact(tailBytes(stripLabels(final.text), PREVIOUS_BYTES)) : '';
|
|
545
|
+
session.lastFinalAt = observedAt;
|
|
546
|
+
session.pending = null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function stopTurn(payload, root) {
|
|
550
|
+
const file = sessionFile(root, payload.session_id);
|
|
551
|
+
const session = readJson(file, null);
|
|
552
|
+
if (!session || !session.pending) return;
|
|
553
|
+
let message = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message
|
|
554
|
+
: (typeof payload.last_agent_message === 'string' ? payload.last_agent_message : null);
|
|
555
|
+
let codex = Boolean(payload.last_agent_message);
|
|
556
|
+
if (message === null) {
|
|
557
|
+
const found = transcriptMessage(payload.transcript_path, payload.turn_id);
|
|
558
|
+
message = found.text;
|
|
559
|
+
codex = codex || found.codex;
|
|
560
|
+
} else if (payload.transcript_path) {
|
|
561
|
+
codex = codex || transcriptMessage(payload.transcript_path, payload.turn_id).codex;
|
|
562
|
+
}
|
|
563
|
+
finishPending(session, payload, root, { text: message, codex, reason: message === null ? 'final-message-unavailable' : null });
|
|
564
|
+
writeJsonAtomic(file, session);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function handle(payload, env = process.env) {
|
|
568
|
+
if (!enabled(env) || !payload || typeof payload !== 'object') return;
|
|
569
|
+
const root = storeRoot(env);
|
|
570
|
+
const event = String(payload.hook_event_name || payload.hookEventName || '');
|
|
571
|
+
if (event === 'UserPromptSubmit') startTurn(payload, root);
|
|
572
|
+
else if (event === 'PostToolUse' || event === 'PostToolUseFailure') {
|
|
573
|
+
toolTurn(payload, root, toolFailed(payload, event), String(payload.tool_name || '') === 'apply_patch');
|
|
574
|
+
} else if (event === 'Stop') stopTurn(payload, root);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
module.exports = {
|
|
578
|
+
INTENTS, TIERS, STRATEGIES, enabled, storeRoot, redact, parseLabelLine, classifyCommand, labelTier, contradictions,
|
|
579
|
+
normalizeTerms, transcriptMessage, recordTool, emptyBehavior, mergeBehavior, toolFailed, handle, readJson,
|
|
580
|
+
expired, recordText, sweep,
|
|
581
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Records System One turn observations (docs/system-one-observations.md).
|
|
4
|
+
// Fail-open and silent: it never writes to stdout, never blocks, and exits 0.
|
|
5
|
+
|
|
6
|
+
let input = '';
|
|
7
|
+
let finished = false;
|
|
8
|
+
|
|
9
|
+
function finish() {
|
|
10
|
+
if (finished) return;
|
|
11
|
+
finished = true;
|
|
12
|
+
try {
|
|
13
|
+
const payload = input.trim() ? JSON.parse(input) : null;
|
|
14
|
+
if (payload) require('./lib/observations').handle(payload);
|
|
15
|
+
} catch (_) {
|
|
16
|
+
// Observation must never affect the turn.
|
|
17
|
+
}
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const timer = setTimeout(finish, 1500);
|
|
22
|
+
process.stdin.setEncoding('utf8');
|
|
23
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
24
|
+
process.stdin.on('end', () => { clearTimeout(timer); finish(); });
|
|
25
|
+
process.stdin.on('error', () => { clearTimeout(timer); finish(); });
|
package/package.json
CHANGED