atris 3.46.1 → 3.48.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/atris/skills/design/SKILL.md +2 -1
- package/atris/skills/youtube/SKILL.md +5 -3
- package/bin/atris.js +3 -1
- package/commands/ci.js +1 -1
- package/commands/close.js +167 -6
- package/commands/mission.js +1 -22
- package/commands/youtube.js +31 -1
- package/lib/ci-runner.js +215 -28
- package/lib/engine-ask.js +15 -1
- package/lib/engine-registry.js +22 -0
- package/lib/fleet.js +15 -0
- package/package.json +2 -1
- package/scripts/det/README.md +162 -0
- package/scripts/det/ax-lane-eval.js +116 -0
- package/scripts/det/changelog.js +148 -0
- package/scripts/det/codex-watchdog.js +217 -0
- package/scripts/det/commit-msg.js +153 -0
- package/scripts/det/data/ax-lane-gold.jsonl +81 -0
- package/scripts/det/data/ax-lane-holdout.jsonl +20 -0
- package/scripts/det/date.js +91 -0
- package/scripts/det/det.js +149 -0
- package/scripts/det/extract.js +93 -0
- package/scripts/det/hash.js +79 -0
- package/scripts/det/hunk-filter.js +73 -0
- package/scripts/det/json.js +120 -0
- package/scripts/det/pr-description.js +213 -0
- package/scripts/det/test.js +296 -0
- package/scripts/det/text.js +102 -0
- package/scripts/det/voice.js +76 -0
- package/scripts/det/ytnotes +196 -0
- package/scripts/det/ytquote-repair.js +181 -0
- package/scripts/det/ytrail-eval.js +124 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Grade the ax auto lane picker against the labeled gold set.
|
|
5
|
+
// Usage: node scripts/det/ax-lane-eval.js [--json] [--min-accuracy <0..1>]
|
|
6
|
+
// Cost-weighted error: routing up-lane work down (quality miss) counts 3x
|
|
7
|
+
// routing down-lane work up (cost miss), because a wrong cheap answer is
|
|
8
|
+
// worse than an overpriced right one.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { pickLane } = require('../../lib/ax-auto-lane');
|
|
13
|
+
|
|
14
|
+
const LANES = ['fast', 'pro', 'max', 'code-fast'];
|
|
15
|
+
// Depth order for miss direction; code-fast sits beside pro in cost.
|
|
16
|
+
const DEPTH = { fast: 0, 'code-fast': 1, pro: 1, max: 2 };
|
|
17
|
+
const QUALITY_MISS_WEIGHT = 3;
|
|
18
|
+
const COST_MISS_WEIGHT = 1;
|
|
19
|
+
|
|
20
|
+
function loadGold() {
|
|
21
|
+
const dataIndex = process.argv.indexOf('--data');
|
|
22
|
+
const file = dataIndex >= 0
|
|
23
|
+
? path.resolve(process.argv[dataIndex + 1])
|
|
24
|
+
: path.join(__dirname, 'data', 'ax-lane-gold.jsonl');
|
|
25
|
+
return fs.readFileSync(file, 'utf8')
|
|
26
|
+
.split(/\r?\n/)
|
|
27
|
+
.filter((line) => line.trim())
|
|
28
|
+
.map((line) => JSON.parse(line));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function evaluate() {
|
|
32
|
+
const gold = loadGold();
|
|
33
|
+
const confusion = {};
|
|
34
|
+
for (const a of LANES) {
|
|
35
|
+
confusion[a] = {};
|
|
36
|
+
for (const b of LANES) confusion[a][b] = 0;
|
|
37
|
+
}
|
|
38
|
+
const misses = [];
|
|
39
|
+
let correct = 0;
|
|
40
|
+
let weightedError = 0;
|
|
41
|
+
let worstWeight = 0;
|
|
42
|
+
for (const row of gold) {
|
|
43
|
+
const picked = pickLane(row.message).lane;
|
|
44
|
+
confusion[row.lane][picked] += 1;
|
|
45
|
+
if (picked === row.lane) {
|
|
46
|
+
correct += 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const qualityMiss = DEPTH[picked] < DEPTH[row.lane];
|
|
50
|
+
const weight = qualityMiss ? QUALITY_MISS_WEIGHT : COST_MISS_WEIGHT;
|
|
51
|
+
weightedError += weight;
|
|
52
|
+
worstWeight += QUALITY_MISS_WEIGHT;
|
|
53
|
+
misses.push({
|
|
54
|
+
message: row.message.slice(0, 70),
|
|
55
|
+
gold: row.lane,
|
|
56
|
+
picked,
|
|
57
|
+
kind: qualityMiss ? 'quality-miss' : 'cost-miss',
|
|
58
|
+
why: row.why,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const perLane = LANES.map((lane) => {
|
|
62
|
+
const truePos = confusion[lane][lane];
|
|
63
|
+
const goldCount = LANES.reduce((sum, other) => sum + confusion[lane][other], 0);
|
|
64
|
+
const pickedCount = LANES.reduce((sum, other) => sum + confusion[other][lane], 0);
|
|
65
|
+
return {
|
|
66
|
+
lane,
|
|
67
|
+
gold: goldCount,
|
|
68
|
+
recall: goldCount ? truePos / goldCount : null,
|
|
69
|
+
precision: pickedCount ? truePos / pickedCount : null,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
const total = gold.length;
|
|
73
|
+
return {
|
|
74
|
+
total,
|
|
75
|
+
correct,
|
|
76
|
+
accuracy: correct / total,
|
|
77
|
+
quality_misses: misses.filter((m) => m.kind === 'quality-miss').length,
|
|
78
|
+
cost_misses: misses.filter((m) => m.kind === 'cost-miss').length,
|
|
79
|
+
weighted_error_rate: worstWeight ? weightedError / (total * QUALITY_MISS_WEIGHT) : 0,
|
|
80
|
+
per_lane: perLane,
|
|
81
|
+
confusion,
|
|
82
|
+
misses,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function main() {
|
|
87
|
+
const args = process.argv.slice(2);
|
|
88
|
+
const json = args.includes('--json');
|
|
89
|
+
const minIndex = args.indexOf('--min-accuracy');
|
|
90
|
+
const minAccuracy = minIndex >= 0 ? Number(args[minIndex + 1]) : null;
|
|
91
|
+
const report = evaluate();
|
|
92
|
+
if (json) {
|
|
93
|
+
console.log(JSON.stringify(report, null, 2));
|
|
94
|
+
} else {
|
|
95
|
+
const pct = (value) => (value === null ? ' n/a' : `${(value * 100).toFixed(0)}%`.padStart(5));
|
|
96
|
+
console.log(`\nax auto lane eval: ${report.correct}/${report.total} correct (${pct(report.accuracy)})`);
|
|
97
|
+
console.log(`quality misses: ${report.quality_misses} cost misses: ${report.cost_misses} weighted error: ${(report.weighted_error_rate * 100).toFixed(1)}%\n`);
|
|
98
|
+
console.log('lane gold recall precision');
|
|
99
|
+
for (const row of report.per_lane) {
|
|
100
|
+
console.log(`${row.lane.padEnd(11)} ${String(row.gold).padStart(4)} ${pct(row.recall)} ${pct(row.precision)}`);
|
|
101
|
+
}
|
|
102
|
+
if (report.misses.length) {
|
|
103
|
+
console.log('\nmisses:');
|
|
104
|
+
for (const miss of report.misses) {
|
|
105
|
+
console.log(` [${miss.kind}] gold=${miss.gold} picked=${miss.picked} "${miss.message}"`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
console.log('');
|
|
109
|
+
}
|
|
110
|
+
if (minAccuracy !== null && report.accuracy < minAccuracy) {
|
|
111
|
+
console.error(`accuracy ${(report.accuracy * 100).toFixed(1)}% is below the ${(minAccuracy * 100).toFixed(0)}% floor`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
main();
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// det/changelog.js — build a grouped changelog from Conventional-Commits history.
|
|
3
|
+
// Replaces the "summarize what changed since the last release" ask: the sections,
|
|
4
|
+
// order, and bullets come straight from the commit subjects, so the changelog is
|
|
5
|
+
// exact and reproducible, not an LLM paraphrase that drops or invents entries.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// node changelog.js # since the last tag (or root) -> markdown
|
|
9
|
+
// node changelog.js v3.34.0 # since a specific ref
|
|
10
|
+
// node changelog.js v3.34.0 HEAD # explicit range
|
|
11
|
+
// node changelog.js --json # structured {sections,counts,...}
|
|
12
|
+
//
|
|
13
|
+
// Reads `git log` itself; no stdin needed. The pure core build(commits) is
|
|
14
|
+
// exported and unit-tested.
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const { execFileSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
// --- pure core (no git, no process) ---------------------------------------
|
|
21
|
+
|
|
22
|
+
// Conventional-Commits types -> human section heading, in display order.
|
|
23
|
+
const SECTIONS = [
|
|
24
|
+
['feat', 'Features'],
|
|
25
|
+
['fix', 'Fixes'],
|
|
26
|
+
['perf', 'Performance'],
|
|
27
|
+
['refactor', 'Refactors'],
|
|
28
|
+
['docs', 'Docs'],
|
|
29
|
+
['test', 'Tests'],
|
|
30
|
+
['build', 'Build'],
|
|
31
|
+
['ci', 'CI'],
|
|
32
|
+
['chore', 'Chores'],
|
|
33
|
+
['other', 'Other'],
|
|
34
|
+
];
|
|
35
|
+
const KNOWN = new Set(SECTIONS.map((s) => s[0]));
|
|
36
|
+
|
|
37
|
+
// "feat(scope): subject" or "fix: subject" -> { type, scope, subject }.
|
|
38
|
+
// Anything that doesn't match the header grammar lands in the "other" bucket
|
|
39
|
+
// with the whole line as the subject, so nothing is silently dropped.
|
|
40
|
+
function parseSubject(line) {
|
|
41
|
+
const m = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/.exec(line.trim());
|
|
42
|
+
if (!m) return { type: 'other', scope: '', breaking: false, subject: line.trim() };
|
|
43
|
+
const type = KNOWN.has(m[1]) ? m[1] : 'other';
|
|
44
|
+
return { type, scope: m[2] || '', breaking: Boolean(m[3]), subject: m[4].trim() };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// commits: [{ hash, subject }] -> { sections, counts, breaking, total }
|
|
48
|
+
function build(commits) {
|
|
49
|
+
if (!Array.isArray(commits)) return { error: 'commits must be an array' };
|
|
50
|
+
const buckets = {};
|
|
51
|
+
const breaking = [];
|
|
52
|
+
for (const c of commits) {
|
|
53
|
+
const p = parseSubject(c.subject || '');
|
|
54
|
+
const entry = { hash: c.hash || '', scope: p.scope, subject: p.subject };
|
|
55
|
+
(buckets[p.type] = buckets[p.type] || []).push(entry);
|
|
56
|
+
if (p.breaking) breaking.push(entry);
|
|
57
|
+
}
|
|
58
|
+
const sections = [];
|
|
59
|
+
const counts = {};
|
|
60
|
+
for (const [type, title] of SECTIONS) {
|
|
61
|
+
const items = buckets[type];
|
|
62
|
+
if (items && items.length) {
|
|
63
|
+
sections.push({ type, title, items });
|
|
64
|
+
counts[type] = items.length;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { sections, counts, breaking, total: commits.length };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// one bullet: "- subject (scope) [hash]" with the optional bits omitted cleanly.
|
|
71
|
+
function fmtItem(it) {
|
|
72
|
+
const scope = it.scope ? ` (${it.scope})` : '';
|
|
73
|
+
const hash = it.hash ? ` [${it.hash}]` : '';
|
|
74
|
+
return `- ${it.subject}${scope}${hash}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function render(res) {
|
|
78
|
+
const lines = [];
|
|
79
|
+
if (res.breaking.length) {
|
|
80
|
+
lines.push('### ⚠ BREAKING CHANGES');
|
|
81
|
+
for (const it of res.breaking) lines.push(fmtItem(it));
|
|
82
|
+
lines.push('');
|
|
83
|
+
}
|
|
84
|
+
for (const s of res.sections) {
|
|
85
|
+
lines.push(`### ${s.title}`);
|
|
86
|
+
for (const it of s.items) lines.push(fmtItem(it));
|
|
87
|
+
lines.push('');
|
|
88
|
+
}
|
|
89
|
+
if (!lines.length) return 'No changes.';
|
|
90
|
+
return lines.join('\n').trimEnd();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- git plumbing (impure, only in main) ----------------------------------
|
|
94
|
+
|
|
95
|
+
function lastTag() {
|
|
96
|
+
try {
|
|
97
|
+
return execFileSync('git', ['describe', '--tags', '--abbrev=0'], {
|
|
98
|
+
encoding: 'utf8',
|
|
99
|
+
}).trim();
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return ''; // no tags yet -> changelog from the root commit
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// range like "v3.34.0..HEAD" (or just "HEAD" when there's no start ref).
|
|
106
|
+
function readCommits(range) {
|
|
107
|
+
const out = execFileSync('git', ['log', '--no-merges', '--pretty=%h%x09%s', range], {
|
|
108
|
+
encoding: 'utf8',
|
|
109
|
+
});
|
|
110
|
+
const commits = [];
|
|
111
|
+
for (const line of out.split('\n')) {
|
|
112
|
+
if (!line.trim()) continue;
|
|
113
|
+
const tab = line.indexOf('\t');
|
|
114
|
+
commits.push({ hash: line.slice(0, tab), subject: line.slice(tab + 1) });
|
|
115
|
+
}
|
|
116
|
+
return commits;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function main() {
|
|
120
|
+
const args = process.argv.slice(2).filter((a) => a !== '--json');
|
|
121
|
+
const wantJson = process.argv.includes('--json');
|
|
122
|
+
const start = args[0] || lastTag();
|
|
123
|
+
const end = args[1] || 'HEAD';
|
|
124
|
+
const range = start ? `${start}..${end}` : end;
|
|
125
|
+
let commits;
|
|
126
|
+
try {
|
|
127
|
+
commits = readCommits(range);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
process.stderr.write(`git failed: ${e.message}\n`);
|
|
130
|
+
process.exit(2);
|
|
131
|
+
}
|
|
132
|
+
const res = build(commits);
|
|
133
|
+
if (res.error) {
|
|
134
|
+
process.stderr.write(res.error + '\n');
|
|
135
|
+
process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
if (wantJson) {
|
|
138
|
+
process.stdout.write(JSON.stringify({ range, ...res }, null, 2) + '\n');
|
|
139
|
+
} else {
|
|
140
|
+
process.stdout.write(render(res) + '\n');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (require.main === module) {
|
|
145
|
+
main();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { build, parseSubject, render, SECTIONS };
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_STARTUP_DEADLINE_SECONDS = 90;
|
|
10
|
+
const DEFAULT_MAX_RUNTIME_SECONDS = 3600;
|
|
11
|
+
const USAGE = 'usage: node scripts/det/codex-watchdog.js ' +
|
|
12
|
+
'[--startup-deadline <sec, default 90>] [--max-runtime <sec, default 3600>] ' +
|
|
13
|
+
'[--receipt <path>] -- <command...>';
|
|
14
|
+
|
|
15
|
+
function positiveSeconds(value, flag) {
|
|
16
|
+
const seconds = Number(value);
|
|
17
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
18
|
+
throw new Error(`${flag} must be a positive number`);
|
|
19
|
+
}
|
|
20
|
+
return seconds;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
let startupDeadlineSeconds = DEFAULT_STARTUP_DEADLINE_SECONDS;
|
|
25
|
+
let maxRuntimeSeconds = DEFAULT_MAX_RUNTIME_SECONDS;
|
|
26
|
+
let receiptPath = '';
|
|
27
|
+
let index = 0;
|
|
28
|
+
|
|
29
|
+
while (index < argv.length && argv[index] !== '--') {
|
|
30
|
+
const flag = argv[index];
|
|
31
|
+
if (flag !== '--startup-deadline' && flag !== '--max-runtime' && flag !== '--receipt') {
|
|
32
|
+
throw new Error(`unknown option: ${flag}`);
|
|
33
|
+
}
|
|
34
|
+
if (index + 1 >= argv.length || argv[index + 1] === '--') {
|
|
35
|
+
throw new Error(`${flag} needs a value`);
|
|
36
|
+
}
|
|
37
|
+
if (flag === '--startup-deadline') {
|
|
38
|
+
startupDeadlineSeconds = positiveSeconds(argv[index + 1], flag);
|
|
39
|
+
} else if (flag === '--max-runtime') {
|
|
40
|
+
maxRuntimeSeconds = positiveSeconds(argv[index + 1], flag);
|
|
41
|
+
} else {
|
|
42
|
+
receiptPath = argv[index + 1];
|
|
43
|
+
}
|
|
44
|
+
index += 2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (argv[index] !== '--' || index + 1 >= argv.length) {
|
|
48
|
+
throw new Error('missing command after --');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
startupDeadlineSeconds,
|
|
53
|
+
maxRuntimeSeconds,
|
|
54
|
+
receiptPath,
|
|
55
|
+
command: argv[index + 1],
|
|
56
|
+
commandArgs: argv.slice(index + 2),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeTimeoutReceipt(config, exitCode, startedAt) {
|
|
61
|
+
if (!config.receiptPath || (exitCode !== 124 && exitCode !== 125)) return;
|
|
62
|
+
const receiptPath = path.resolve(config.receiptPath);
|
|
63
|
+
const dir = path.dirname(receiptPath);
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
const groupResult = spawnSync('ps', ['-o', 'pgid=', '-p', String(process.pid)], { encoding: 'utf8' });
|
|
66
|
+
const parsedGroup = groupResult.status === 0 ? Number(String(groupResult.stdout || '').trim()) : null;
|
|
67
|
+
const receipt = {
|
|
68
|
+
schema: 'atris.codex_watchdog_receipt.v1',
|
|
69
|
+
status: 'timed_out',
|
|
70
|
+
reason: exitCode === 124 ? 'silent_start' : 'max_runtime',
|
|
71
|
+
exit_code: exitCode,
|
|
72
|
+
pid: process.pid,
|
|
73
|
+
pgid: Number.isInteger(parsedGroup) && parsedGroup > 0 ? parsedGroup : null,
|
|
74
|
+
started_at: startedAt,
|
|
75
|
+
finished_at: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
const tmpPath = path.join(dir, `.${path.basename(receiptPath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
78
|
+
try {
|
|
79
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
80
|
+
fs.renameSync(tmpPath, receiptPath);
|
|
81
|
+
} finally {
|
|
82
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function killProcessGroup(child) {
|
|
87
|
+
if (!child.pid) return;
|
|
88
|
+
try {
|
|
89
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error.code === 'ESRCH') return;
|
|
92
|
+
try {
|
|
93
|
+
child.kill('SIGKILL');
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function runAttempt(config, remainingRuntimeMs) {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
let nullFd;
|
|
101
|
+
let child;
|
|
102
|
+
try {
|
|
103
|
+
nullFd = fs.openSync('/dev/null', 'r');
|
|
104
|
+
child = spawn(config.command, config.commandArgs, {
|
|
105
|
+
detached: true,
|
|
106
|
+
stdio: [nullFd, 'pipe', 'pipe'],
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (nullFd !== undefined) fs.closeSync(nullFd);
|
|
110
|
+
resolve({ type: 'spawn-error', error });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
fs.closeSync(nullFd);
|
|
114
|
+
|
|
115
|
+
let sawOutput = false;
|
|
116
|
+
let stopReason = null;
|
|
117
|
+
let spawnError = null;
|
|
118
|
+
|
|
119
|
+
const markStarted = () => {
|
|
120
|
+
sawOutput = true;
|
|
121
|
+
};
|
|
122
|
+
child.stdout.once('data', markStarted);
|
|
123
|
+
child.stderr.once('data', markStarted);
|
|
124
|
+
child.stdout.pipe(process.stdout, { end: false });
|
|
125
|
+
child.stderr.pipe(process.stderr, { end: false });
|
|
126
|
+
|
|
127
|
+
const stop = (reason) => {
|
|
128
|
+
if (stopReason) return;
|
|
129
|
+
stopReason = reason;
|
|
130
|
+
killProcessGroup(child);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const startupTimer = setTimeout(() => {
|
|
134
|
+
if (!sawOutput) stop('silent-start');
|
|
135
|
+
}, Math.ceil(config.startupDeadlineSeconds * 1000));
|
|
136
|
+
const runtimeTimer = setTimeout(() => {
|
|
137
|
+
stop('max-runtime');
|
|
138
|
+
}, remainingRuntimeMs);
|
|
139
|
+
|
|
140
|
+
child.once('error', (error) => {
|
|
141
|
+
spawnError = error;
|
|
142
|
+
if (!stopReason) stopReason = 'spawn-error';
|
|
143
|
+
});
|
|
144
|
+
child.once('close', (code, signal) => {
|
|
145
|
+
clearTimeout(startupTimer);
|
|
146
|
+
clearTimeout(runtimeTimer);
|
|
147
|
+
if (spawnError) {
|
|
148
|
+
resolve({ type: 'spawn-error', error: spawnError });
|
|
149
|
+
} else if (stopReason) {
|
|
150
|
+
resolve({ type: stopReason });
|
|
151
|
+
} else {
|
|
152
|
+
resolve({ type: 'exit', code, signal });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function run(config) {
|
|
159
|
+
const startedAt = Date.now();
|
|
160
|
+
const maxRuntimeMs = Math.ceil(config.maxRuntimeSeconds * 1000);
|
|
161
|
+
|
|
162
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
163
|
+
const remainingRuntimeMs = maxRuntimeMs - (Date.now() - startedAt);
|
|
164
|
+
if (remainingRuntimeMs <= 0) {
|
|
165
|
+
process.stderr.write(`watchdog: max runtime of ${config.maxRuntimeSeconds}s exceeded\n`);
|
|
166
|
+
return 125;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const result = await runAttempt(config, remainingRuntimeMs);
|
|
170
|
+
if (result.type === 'silent-start') {
|
|
171
|
+
if (attempt === 1) {
|
|
172
|
+
process.stderr.write(
|
|
173
|
+
`watchdog: silent start after ${config.startupDeadlineSeconds}s, retrying once\n`
|
|
174
|
+
);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
process.stderr.write('watchdog: silent start twice, giving up\n');
|
|
178
|
+
return 124;
|
|
179
|
+
}
|
|
180
|
+
if (result.type === 'max-runtime') {
|
|
181
|
+
process.stderr.write(`watchdog: max runtime of ${config.maxRuntimeSeconds}s exceeded\n`);
|
|
182
|
+
return 125;
|
|
183
|
+
}
|
|
184
|
+
if (result.type === 'spawn-error') {
|
|
185
|
+
process.stderr.write(`watchdog: could not start command: ${result.error.message}\n`);
|
|
186
|
+
return 127;
|
|
187
|
+
}
|
|
188
|
+
if (result.code !== null) return result.code;
|
|
189
|
+
if (result.signal) {
|
|
190
|
+
process.kill(process.pid, result.signal);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return 124;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function main() {
|
|
200
|
+
const startedAt = new Date().toISOString();
|
|
201
|
+
let config;
|
|
202
|
+
try {
|
|
203
|
+
config = parseArgs(process.argv.slice(2));
|
|
204
|
+
} catch (error) {
|
|
205
|
+
process.stderr.write(`watchdog: ${error.message}\n${USAGE}\n`);
|
|
206
|
+
process.exitCode = 2;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
process.exitCode = await run(config);
|
|
210
|
+
try {
|
|
211
|
+
writeTimeoutReceipt(config, process.exitCode, startedAt);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
process.stderr.write(`watchdog: could not write timeout receipt: ${error.message}\n`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
main();
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// det/commit-msg.js — draft a Conventional-Commits message from the STAGED diff.
|
|
3
|
+
// Replaces the "write me a commit message" ask: the type/scope come from the
|
|
4
|
+
// file paths and the body from the diff stats, so it is exact and reproducible,
|
|
5
|
+
// not an LLM guess about intent.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// git add -A && node commit-msg.js # print the drafted message
|
|
9
|
+
// node commit-msg.js --json # structured {type,scope,subject,body,...}
|
|
10
|
+
//
|
|
11
|
+
// Reads `git diff --cached` itself; no stdin needed. Exit 2 if nothing staged.
|
|
12
|
+
// The pure core draft(files) is exported and unit-tested.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { execFileSync } = require('child_process');
|
|
17
|
+
|
|
18
|
+
// --- pure core (no git, no process) ---------------------------------------
|
|
19
|
+
|
|
20
|
+
const VERB = { A: 'add', M: 'update', D: 'remove', R: 'rename', C: 'copy' };
|
|
21
|
+
|
|
22
|
+
function isMd(p) {
|
|
23
|
+
return /\.md$/i.test(p);
|
|
24
|
+
}
|
|
25
|
+
function isTest(p) {
|
|
26
|
+
return /(^|\/)tests?\//.test(p) || /\.test\.[jt]s$/.test(p);
|
|
27
|
+
}
|
|
28
|
+
function isChore(p) {
|
|
29
|
+
return (
|
|
30
|
+
/^(scripts|\.github)\//.test(p) ||
|
|
31
|
+
/(^|\/)(package(-lock)?\.json|\.eslintrc.*|\.gitignore|\.npmignore)$/.test(p) ||
|
|
32
|
+
/\.ya?ml$/.test(p)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// scope = basename of the deepest directory common to every changed file.
|
|
37
|
+
function commonDirScope(paths) {
|
|
38
|
+
if (paths.length === 0) return '';
|
|
39
|
+
const dirSegs = paths.map((p) => p.split('/').slice(0, -1));
|
|
40
|
+
let common = dirSegs[0];
|
|
41
|
+
for (const segs of dirSegs.slice(1)) {
|
|
42
|
+
let i = 0;
|
|
43
|
+
while (i < common.length && i < segs.length && common[i] === segs[i]) i += 1;
|
|
44
|
+
common = common.slice(0, i);
|
|
45
|
+
}
|
|
46
|
+
return common.length ? common[common.length - 1] : '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pickType(files) {
|
|
50
|
+
const paths = files.map((f) => f.path);
|
|
51
|
+
if (paths.every(isMd)) return 'docs';
|
|
52
|
+
if (paths.every(isTest)) return 'test';
|
|
53
|
+
if (paths.every(isChore)) return 'chore';
|
|
54
|
+
if (files.some((f) => f.status === 'A')) return 'feat';
|
|
55
|
+
return 'fix';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The one file that best represents the change: prefer an added file, then the
|
|
59
|
+
// biggest churn, tie-broken by path so the pick is stable across runs.
|
|
60
|
+
function leadFile(files) {
|
|
61
|
+
return [...files].sort((a, b) => {
|
|
62
|
+
const addedA = a.status === 'A' ? 1 : 0;
|
|
63
|
+
const addedB = b.status === 'A' ? 1 : 0;
|
|
64
|
+
if (addedA !== addedB) return addedB - addedA;
|
|
65
|
+
const churnA = (a.added || 0) + (a.deleted || 0);
|
|
66
|
+
const churnB = (b.added || 0) + (b.deleted || 0);
|
|
67
|
+
if (churnA !== churnB) return churnB - churnA;
|
|
68
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
69
|
+
})[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function summarize(files) {
|
|
73
|
+
const lead = leadFile(files);
|
|
74
|
+
const verb = VERB[lead.status] || 'update';
|
|
75
|
+
const name = lead.path.split('/').pop();
|
|
76
|
+
if (files.length === 1) return `${verb} ${name}`;
|
|
77
|
+
// name the lead file instead of an anonymous count ("update 3 files"):
|
|
78
|
+
// that count is the exact slop this script exists to kill.
|
|
79
|
+
return `${verb} ${name} (+${files.length - 1} more)`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// files: [{ path, status, added, deleted }] -> { type, scope, summary, subject, body, totals }
|
|
83
|
+
function draft(files) {
|
|
84
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
85
|
+
return { error: 'no staged changes' };
|
|
86
|
+
}
|
|
87
|
+
const type = pickType(files);
|
|
88
|
+
let scope = commonDirScope(files.map((f) => f.path));
|
|
89
|
+
if (scope === type) scope = ''; // avoid redundant test(test): / docs(docs):
|
|
90
|
+
const summary = summarize(files);
|
|
91
|
+
const subject = `${type}${scope ? `(${scope})` : ''}: ${summary}`;
|
|
92
|
+
const totals = files.reduce(
|
|
93
|
+
(a, f) => ({ added: a.added + (f.added || 0), deleted: a.deleted + (f.deleted || 0) }),
|
|
94
|
+
{ added: 0, deleted: 0 }
|
|
95
|
+
);
|
|
96
|
+
const lines = files.map(
|
|
97
|
+
(f) => `- ${f.status} ${f.path} (+${f.added || 0}/-${f.deleted || 0})`
|
|
98
|
+
);
|
|
99
|
+
const body = `${lines.join('\n')}\n\n${files.length} file${
|
|
100
|
+
files.length === 1 ? '' : 's'
|
|
101
|
+
} changed, +${totals.added}/-${totals.deleted}`;
|
|
102
|
+
return { type, scope, summary, subject, body, totals, files };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- git plumbing (impure, only in main) ----------------------------------
|
|
106
|
+
|
|
107
|
+
// Merge `--numstat` (added/deleted) with `--name-status` (A/M/D) by path.
|
|
108
|
+
function readStaged() {
|
|
109
|
+
const numstat = execFileSync('git', ['diff', '--cached', '--numstat'], { encoding: 'utf8' });
|
|
110
|
+
const names = execFileSync('git', ['diff', '--cached', '--name-status'], { encoding: 'utf8' });
|
|
111
|
+
const stat = {};
|
|
112
|
+
for (const line of numstat.split('\n')) {
|
|
113
|
+
if (!line.trim()) continue;
|
|
114
|
+
const [added, deleted, path] = line.split('\t');
|
|
115
|
+
stat[path] = { added: added === '-' ? 0 : Number(added), deleted: deleted === '-' ? 0 : Number(deleted) };
|
|
116
|
+
}
|
|
117
|
+
const files = [];
|
|
118
|
+
for (const line of names.split('\n')) {
|
|
119
|
+
if (!line.trim()) continue;
|
|
120
|
+
const parts = line.split('\t');
|
|
121
|
+
const status = parts[0][0]; // R100 -> R
|
|
122
|
+
const path = parts[parts.length - 1];
|
|
123
|
+
files.push({ path, status, added: (stat[path] || {}).added || 0, deleted: (stat[path] || {}).deleted || 0 });
|
|
124
|
+
}
|
|
125
|
+
return files;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function main() {
|
|
129
|
+
const wantJson = process.argv.includes('--json');
|
|
130
|
+
let files;
|
|
131
|
+
try {
|
|
132
|
+
files = readStaged();
|
|
133
|
+
} catch (e) {
|
|
134
|
+
process.stderr.write(`git failed: ${e.message}\n`);
|
|
135
|
+
process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
const res = draft(files);
|
|
138
|
+
if (res.error) {
|
|
139
|
+
process.stderr.write(res.error + '\n');
|
|
140
|
+
process.exit(2);
|
|
141
|
+
}
|
|
142
|
+
if (wantJson) {
|
|
143
|
+
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
|
144
|
+
} else {
|
|
145
|
+
process.stdout.write(`${res.subject}\n\n${res.body}\n`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (require.main === module) {
|
|
150
|
+
main();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { draft, commonDirScope, pickType, summarize, leadFile };
|