razzou-harness 0.2.0 → 0.3.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/CHANGELOG.md +140 -4
- package/README.md +78 -40
- package/dist/agents/claude-code.js +9 -2
- package/dist/anthropic/usage.js +2 -1
- package/dist/checks/runner.js +2 -1
- package/dist/ci/harness-ci.js +115 -32
- package/dist/cli.js +60 -30
- package/dist/daemon/architects.js +2 -0
- package/dist/daemon/channel.js +7 -1
- package/dist/daemon/design.js +16 -2
- package/dist/daemon/items.js +6 -3
- package/dist/daemon/main.js +84 -12
- package/dist/daemon/manager.js +25 -19
- package/dist/daemon/orchestrator.js +366 -157
- package/dist/daemon/requests.js +21 -19
- package/dist/daemon/review.js +14 -8
- package/dist/daemon/web-read.js +143 -33
- package/dist/dashboard/server.js +27 -10
- package/dist/dashboard/terminals.js +22 -5
- package/dist/design/owner-time.js +8 -1
- package/dist/design/schemas.js +14 -4
- package/dist/git/repo.js +52 -19
- package/dist/github/app.js +7 -5
- package/dist/github/forge.js +17 -6
- package/dist/github/setup.js +10 -3
- package/dist/review/paid-calls.js +39 -14
- package/dist/roles.js +1 -1
- package/dist/sandbox/openshell.js +74 -13
- package/dist/sandbox/policy.js +2 -1
- package/dist/setup/doctor.js +74 -12
- package/dist/setup/init.js +5 -4
- package/dist/setup/recover.js +11 -6
- package/dist/setup/setup.js +163 -70
- package/dist/state/state.js +31 -11
- package/dist/util.js +2 -2
- package/dist/web/assets/{index-DLttwGM6.js → index-CPJJsPL6.js} +1 -1
- package/dist/web/index.html +1 -1
- package/images/agent/Dockerfile +11 -2
- package/package.json +1 -1
- package/roles/architect.md +5 -2
- package/roles/manager.md +2 -1
- package/shim/harness-mcp.mjs +11 -2
- package/src/ci/harness-ci.ts +105 -32
- package/templates/harness.yml +5 -2
package/dist/ci/harness-ci.js
CHANGED
|
@@ -13,34 +13,50 @@ import { appendFileSync, existsSync, readdirSync, readFileSync, realpathSync, st
|
|
|
13
13
|
import { join, relative } from 'node:path';
|
|
14
14
|
import { parseArgs } from 'node:util';
|
|
15
15
|
export function directoryReader(root) {
|
|
16
|
+
const isFile = (file) => existsSync(file) && statSync(file).isFile();
|
|
17
|
+
const git = (args) => spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 1 << 30 });
|
|
16
18
|
return {
|
|
17
19
|
async read(path) {
|
|
18
20
|
const file = join(root, path);
|
|
19
|
-
return
|
|
21
|
+
return isFile(file) ? readFileSync(file, 'utf8') : undefined;
|
|
20
22
|
},
|
|
21
23
|
async list(folder) {
|
|
24
|
+
// In a work tree, git's view: what it ignores, such as node_modules, isn't in a checkout either.
|
|
25
|
+
if (git(['rev-parse', '--is-inside-work-tree']).stdout?.trim() === 'true') {
|
|
26
|
+
const listed = git(['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', folder || '.']);
|
|
27
|
+
if (listed.status !== 0)
|
|
28
|
+
throw new Error(`git ls-files failed in ${root}: ${listed.stderr}`);
|
|
29
|
+
return listed.stdout.split('\0').filter((path) => path !== '' && isFile(join(root, path)));
|
|
30
|
+
}
|
|
22
31
|
const start = join(root, folder);
|
|
23
32
|
if (!existsSync(start))
|
|
24
33
|
return [];
|
|
25
34
|
return readdirSync(start, { recursive: true, encoding: 'utf8' })
|
|
26
35
|
.map((entry) => join(start, entry))
|
|
27
|
-
.filter((file) =>
|
|
36
|
+
.filter((file) => isFile(file) && !relative(root, file).startsWith('.git/'))
|
|
28
37
|
.map((file) => relative(root, file));
|
|
29
38
|
},
|
|
30
39
|
};
|
|
31
40
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Paths no Plan or Fix PR may change, in every project. Claude Code reads CLAUDE.md,
|
|
43
|
+
* CLAUDE.local.md, AGENTS.md and `.claude/` in every folder, not only the root's; the
|
|
44
|
+
* root's CLAUDE.md and AGENTS.md are also named, so the lock lists them even when absent.
|
|
45
|
+
*/
|
|
46
|
+
export const ALWAYS_PROTECTED = [
|
|
47
|
+
'harness/**', '.github/**', 'CLAUDE.md', 'AGENTS.md', '**/.claude/**', '**/CLAUDE.md', '**/CLAUDE.local.md', '**/AGENTS.md',
|
|
48
|
+
'ARCHITECTURE.md', 'init.sh', '.npmrc',
|
|
49
|
+
];
|
|
50
|
+
/** Configs that decide what a tool or a test runner runs, written as code: protected whole when they exist. */
|
|
35
51
|
const TOOL_FILES = [
|
|
36
|
-
'eslint.config.
|
|
37
|
-
'.
|
|
38
|
-
'
|
|
39
|
-
'.prettierrc', '.prettierrc.json', '.prettierrc.js', 'prettier.config.js', 'prettier.config.mjs',
|
|
52
|
+
'eslint.config.*', '.eslintrc', '.eslintrc.*', 'biome.json', 'biome.jsonc', '.prettierrc', '.prettierrc.*', 'prettier.config.*',
|
|
53
|
+
'vitest.config.*', 'vitest.workspace.*', 'vite.config.*', 'jest.config.*', 'babel.config.*', '**/.babelrc', '**/.babelrc.*',
|
|
54
|
+
'playwright.config.*', '.mocharc.*', '**/pytest.ini', '**/.pytest.ini', '**/tox.ini', '**/conftest.py',
|
|
40
55
|
];
|
|
41
56
|
/** JSON configs: protected by field, so ordinary changes stay possible. */
|
|
42
57
|
const JSON_FIELDS = {
|
|
43
58
|
'tsconfig.json': ['/compilerOptions/strict', '/compilerOptions/noImplicitAny', '/compilerOptions/skipLibCheck', '/exclude'],
|
|
59
|
+
'package.json': ['/jest', '/babel', '/mocha'],
|
|
44
60
|
};
|
|
45
61
|
/** The rules a tree's own `harness/config.json` and plans set. The base of a change decides them. */
|
|
46
62
|
export async function protectionRules(tree) {
|
|
@@ -58,18 +74,65 @@ export async function protectionRules(tree) {
|
|
|
58
74
|
for (const script of new Set(checks.flatMap((c) => npmScripts(c.run)))) {
|
|
59
75
|
add('package.json', [`/scripts/${script}`, `/scripts/pre${script}`, `/scripts/post${script}`]);
|
|
60
76
|
}
|
|
61
|
-
return { paths: [...ALWAYS_PROTECTED, ...TOOL_FILES, ...(config?.protect?.files ?? [])], fields };
|
|
77
|
+
return { paths: [...ALWAYS_PROTECTED, ...TOOL_FILES, ...(config?.protect?.files ?? [])].map(normalisePattern), fields };
|
|
62
78
|
}
|
|
63
|
-
/** npm
|
|
79
|
+
/** npm's options that take the next word as their value. */
|
|
80
|
+
const NPM_VALUE_OPTIONS = ['--prefix', '-C', '--workspace', '-w', '--userconfig', '--globalconfig', '--loglevel'];
|
|
81
|
+
/** npm scripts a command line runs: `npm run lint`, `npm -s test`, `npm --prefix . start`, `node --run lint`. */
|
|
64
82
|
export function npmScripts(command) {
|
|
65
83
|
const scripts = [];
|
|
66
|
-
|
|
67
|
-
|
|
84
|
+
const words = command.split(/[\s;&|()<>'"`]+/);
|
|
85
|
+
// The next word that isn't one of npm's options, or an option's value.
|
|
86
|
+
const next = (i) => {
|
|
87
|
+
while (words[++i]?.startsWith('-'))
|
|
88
|
+
if (NPM_VALUE_OPTIONS.includes(words[i]))
|
|
89
|
+
i++;
|
|
90
|
+
return i;
|
|
91
|
+
};
|
|
92
|
+
for (let i = 0; i < words.length; i++) {
|
|
93
|
+
if (words[i] === 'node' && words[i + 1] === '--run' && words[i + 2])
|
|
94
|
+
scripts.push(words[i + 2]);
|
|
95
|
+
if (words[i] === 'node' && words[i + 1]?.startsWith('--run='))
|
|
96
|
+
scripts.push(words[i + 1].slice('--run='.length));
|
|
97
|
+
if (words[i] !== 'npm')
|
|
98
|
+
continue;
|
|
99
|
+
const at = next(i);
|
|
100
|
+
if (words[at] === 'run' || words[at] === 'run-script') {
|
|
101
|
+
const script = words[next(at)];
|
|
102
|
+
if (script)
|
|
103
|
+
scripts.push(script);
|
|
104
|
+
}
|
|
105
|
+
else if (words[at] === 'test' || words[at] === 't')
|
|
106
|
+
scripts.push('test');
|
|
107
|
+
else if (words[at] === 'start' || words[at] === 'stop' || words[at] === 'restart')
|
|
108
|
+
scripts.push(words[at]);
|
|
68
109
|
}
|
|
69
110
|
return scripts;
|
|
70
111
|
}
|
|
112
|
+
/** A pattern as written in a config: `./x` is `x`, and `dir/` is `dir/**`. */
|
|
113
|
+
function normalisePattern(pattern) {
|
|
114
|
+
const path = pattern.replace(/^(?:\.?\/)+/, '');
|
|
115
|
+
// Trailing slashes are counted by hand: /\/+$/ takes quadratic time on a run of slashes before the end.
|
|
116
|
+
let end = path.length;
|
|
117
|
+
while (end > 0 && path[end - 1] === '/')
|
|
118
|
+
end--;
|
|
119
|
+
return end === path.length ? path : `${path.slice(0, end)}/**`;
|
|
120
|
+
}
|
|
121
|
+
/** Each pattern's regular expression, made once. */
|
|
122
|
+
const compiled = new Map();
|
|
123
|
+
// Whether a path matches a protected pattern: `*` and `?` within a segment, `**` across
|
|
124
|
+
// segments. `**/x` is x in any folder, the root included; `dir/**` is dir and all it holds.
|
|
71
125
|
export function matches(path, pattern) {
|
|
72
|
-
|
|
126
|
+
let regexp = compiled.get(pattern);
|
|
127
|
+
if (!regexp) {
|
|
128
|
+
const source = normalisePattern(pattern).replace(/(^|\/)\*\*(\/|$)|\*|\?|[.+^${}()|[\]\\]/g, (token, before, after) => {
|
|
129
|
+
if (before === undefined)
|
|
130
|
+
return token === '*' ? '[^/]*' : token === '?' ? '[^/]' : `\\${token}`;
|
|
131
|
+
return before && after ? '/(?:.*/)?' : before ? '(?:/.*)?' : after ? '(?:.*/)?' : '.*';
|
|
132
|
+
});
|
|
133
|
+
compiled.set(pattern, (regexp = new RegExp(`^${source}$`, 's')));
|
|
134
|
+
}
|
|
135
|
+
return regexp.test(path);
|
|
73
136
|
}
|
|
74
137
|
/**
|
|
75
138
|
* What an Arch PR may change (§11.3): the design, shared knowledge, what agents and tools
|
|
@@ -104,15 +167,25 @@ export async function protectedChanges(base, head, changedPaths, options) {
|
|
|
104
167
|
}
|
|
105
168
|
return problems;
|
|
106
169
|
}
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Packages in the head's package-lock.json that don't come from the public npm registry, and
|
|
172
|
+
* links to a local folder that the base's doesn't have as they are: workspaces already on
|
|
173
|
+
* the base keep working, and a new link comes through an Arch PR.
|
|
174
|
+
*/
|
|
175
|
+
export async function lockfileProblems(base, head) {
|
|
176
|
+
const packages = async (tree) => parseJson(await tree.read('package-lock.json'))?.packages ?? {};
|
|
177
|
+
const [before, after] = [await packages(base), await packages(head)];
|
|
110
178
|
const problems = [];
|
|
111
|
-
for (const [name, entry] of Object.entries(
|
|
112
|
-
if (name === ''
|
|
179
|
+
for (const [name, entry] of Object.entries(after)) {
|
|
180
|
+
if (name === '')
|
|
113
181
|
continue;
|
|
114
|
-
if (
|
|
182
|
+
if (entry.link) {
|
|
183
|
+
if (before[name]?.link !== true || before[name].resolved !== entry.resolved)
|
|
184
|
+
problems.push(`${name} links to ${entry.resolved}, which the base branch doesn't`);
|
|
185
|
+
}
|
|
186
|
+
else if (entry.resolved !== undefined && !entry.resolved.startsWith('https://registry.npmjs.org/')) {
|
|
115
187
|
problems.push(`${name} resolves from ${entry.resolved}`);
|
|
188
|
+
}
|
|
116
189
|
}
|
|
117
190
|
return problems;
|
|
118
191
|
}
|
|
@@ -120,10 +193,15 @@ export async function lockfileProblems(tree) {
|
|
|
120
193
|
export async function computeLock(tree) {
|
|
121
194
|
const rules = await protectionRules(tree);
|
|
122
195
|
const files = new Set();
|
|
196
|
+
let all;
|
|
123
197
|
for (const pattern of rules.paths) {
|
|
124
|
-
if (pattern
|
|
125
|
-
|
|
126
|
-
|
|
198
|
+
if (/[*?]/.test(pattern)) {
|
|
199
|
+
// A glob is matched against every file of the tree, listed once.
|
|
200
|
+
all ??= await tree.list('');
|
|
201
|
+
for (const file of all)
|
|
202
|
+
if (matches(file, pattern))
|
|
203
|
+
files.add(file);
|
|
204
|
+
}
|
|
127
205
|
else if ((await tree.read(pattern)) !== undefined || ALWAYS_PROTECTED.includes(pattern))
|
|
128
206
|
files.add(pattern);
|
|
129
207
|
}
|
|
@@ -133,7 +211,7 @@ export async function computeLock(tree) {
|
|
|
133
211
|
const text = await tree.read(path);
|
|
134
212
|
entries.push({ path, sha256: text === undefined ? 'absent' : sha256(text) });
|
|
135
213
|
}
|
|
136
|
-
for (const [file, pointers] of Object.entries(rules.fields).sort(([a], [b]) => a.localeCompare(b))) {
|
|
214
|
+
for (const [file, pointers] of Object.entries(rules.fields).sort(([a], [b]) => a.localeCompare(b, 'en'))) {
|
|
137
215
|
const text = await tree.read(file);
|
|
138
216
|
entries.push({ path: file, fields: Object.fromEntries(pointers.map((pointer) => [pointer, fieldHash(text, pointer)])) });
|
|
139
217
|
}
|
|
@@ -210,10 +288,12 @@ async function planKindProblems(input, config) {
|
|
|
210
288
|
if (tag && tag !== kind)
|
|
211
289
|
problems.push(`the title says -${tag}-, but ${module}/${plan} is a ${kind} plan on the base branch: its features' kinds decide`);
|
|
212
290
|
if (kind === 'CODE' && config?.terraform && typeof config.terraform === 'object') {
|
|
213
|
-
|
|
214
|
-
const
|
|
291
|
+
// Terraform at the root, `.` or empty, is every .tf or .tf.json file.
|
|
292
|
+
const dir = (config.terraform.dir ?? 'iac').replace(/^(?:\.?\/)+/, '').replace(/\/+$/, '');
|
|
293
|
+
const root = dir === '' || dir === '.';
|
|
294
|
+
const touched = input.changedPaths.filter((p) => (root ? /\.tf(?:\.json)?$/.test(p) : p === dir || p.startsWith(`${dir}/`)));
|
|
215
295
|
if (touched.length > 0)
|
|
216
|
-
problems.push(`a CODE plan may not change ${dir}
|
|
296
|
+
problems.push(`a CODE plan may not change ${root ? 'Terraform files' : `${dir}/`}: infrastructure gets plans of its own (v3 §3.1); changed: ${touched.join(', ')}`);
|
|
217
297
|
}
|
|
218
298
|
return problems;
|
|
219
299
|
}
|
|
@@ -228,13 +308,14 @@ export async function verify(input) {
|
|
|
228
308
|
for (const change of await protectedChanges(input.base, input.head, input.changedPaths, { progress: 'append' })) {
|
|
229
309
|
problems.push(`changes a protected file: ${change}`);
|
|
230
310
|
}
|
|
231
|
-
for (const problem of await lockfileProblems(input.head))
|
|
311
|
+
for (const problem of await lockfileProblems(input.base, input.head))
|
|
232
312
|
problems.push(`package-lock.json: ${problem}`);
|
|
233
313
|
}
|
|
234
314
|
if (kind === 'Plan')
|
|
235
315
|
problems.push(...(await planKindProblems(input, config)));
|
|
236
316
|
if (kind === 'Arch') {
|
|
237
|
-
|
|
317
|
+
// The base's rules, as for every PR: what its head protects opens nothing to the PR itself.
|
|
318
|
+
const rules = await protectionRules(input.base);
|
|
238
319
|
for (const path of input.changedPaths.filter((p) => !archMayChange(p, rules))) {
|
|
239
320
|
problems.push(`an Arch PR may not change ${path}: only the design, shared knowledge and tool configs`);
|
|
240
321
|
}
|
|
@@ -298,9 +379,10 @@ export function resolvePointer(value, pointer) {
|
|
|
298
379
|
}
|
|
299
380
|
return current;
|
|
300
381
|
}
|
|
382
|
+
/** JSON with object keys sorted at every level. The order is English's, whatever the machine's locale, so CI and the owner's machine agree. */
|
|
301
383
|
function canonicalJson(value) {
|
|
302
384
|
return JSON.stringify(value, (_key, v) => v && typeof v === 'object' && !Array.isArray(v)
|
|
303
|
-
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)))
|
|
385
|
+
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b, 'en')))
|
|
304
386
|
: v);
|
|
305
387
|
}
|
|
306
388
|
/** JSON, or JSON with comments and trailing commas as tsconfig allows. Undefined if absent or unreadable. */
|
|
@@ -361,13 +443,14 @@ async function main() {
|
|
|
361
443
|
};
|
|
362
444
|
switch (positionals[0]) {
|
|
363
445
|
case 'verify': {
|
|
364
|
-
|
|
446
|
+
// NUL-separated: otherwise git quotes a path with unusual bytes, and no pattern matches the quoted form.
|
|
447
|
+
const diff = spawnSync('git', ['-C', values.head, 'diff', '-z', '--name-only', '--no-renames', `${values['base-sha']}...${values['head-sha']}`], { encoding: 'utf8', maxBuffer: 1 << 30 });
|
|
365
448
|
if (diff.status !== 0)
|
|
366
449
|
throw new Error(`git diff failed: ${diff.stderr}`);
|
|
367
450
|
const problems = await verify({
|
|
368
451
|
base: directoryReader(values.base),
|
|
369
452
|
head: directoryReader(values.head),
|
|
370
|
-
changedPaths: diff.stdout.split('\
|
|
453
|
+
changedPaths: diff.stdout.split('\0').filter(Boolean),
|
|
371
454
|
author: values.author,
|
|
372
455
|
branch: values.branch,
|
|
373
456
|
title: values.title ?? '',
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join, resolve } from 'node:path';
|
|
|
5
5
|
import { createInterface } from 'node:readline/promises';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { computeLock, directoryReader } from './ci/harness-ci.js';
|
|
8
|
-
import {
|
|
8
|
+
import { isRunning, packageRoot, runDaemon, runningDaemon, version } from './daemon/main.js';
|
|
9
9
|
import { ConfigSchema, requiredChecks } from './design/schemas.js';
|
|
10
10
|
import { loadAppCredentials } from './github/app.js';
|
|
11
11
|
import { applyRepositoryRules, checkApp, createApp, installCi } from './github/setup.js';
|
|
@@ -94,21 +94,30 @@ async function main() {
|
|
|
94
94
|
if (!argument) {
|
|
95
95
|
const file = join(harnessDir, 'state.json');
|
|
96
96
|
const state = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
|
|
97
|
-
|
|
97
|
+
const pendingFile = join(harnessDir, 'setup.json');
|
|
98
|
+
const pending = existsSync(pendingFile) ? JSON.parse(readFileSync(pendingFile, 'utf8')).mode : undefined;
|
|
99
|
+
console.log(`${state?.mode ?? 'manual'}${pending && pending !== (state?.mode ?? 'manual') ? `, and ${pending} once the daemon starts` : ''}`);
|
|
98
100
|
return;
|
|
99
101
|
}
|
|
100
102
|
if (argument !== 'manual' && argument !== 'swarm')
|
|
101
103
|
throw new Error('usage: harness mode [manual|swarm]');
|
|
102
|
-
const
|
|
104
|
+
const running = runningDaemon(harnessDir) !== undefined;
|
|
105
|
+
if (running && (await ownerAction(harnessDir, '/api/mode', { mode: argument }, `already ${argument}`)).error)
|
|
106
|
+
return;
|
|
103
107
|
// GitHub's rules on main differ per mode, and only the owner's gh may change them (v3 §2.1): done here, in the same command.
|
|
104
108
|
const repo = projectConfig(projectDir)?.project.repo;
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
if (repo) {
|
|
110
|
+
try {
|
|
111
|
+
await applyRules(projectDir, harnessDir, repo, argument);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
console.log(`GitHub's rules on main were not changed: ${error.message}\nRun \`harness github-setup --repo ${repo}\` with gh logged in as an admin of the repository.`);
|
|
115
|
+
}
|
|
109
116
|
}
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
if (!running) {
|
|
118
|
+
// The daemon is down: the switch waits for it in .harness/setup.json (v4 §5).
|
|
119
|
+
recordForDaemon(harnessDir, { mode: argument });
|
|
120
|
+
console.log(`${argument}: the daemon switches when it starts (harness up).`);
|
|
112
121
|
}
|
|
113
122
|
return;
|
|
114
123
|
}
|
|
@@ -118,16 +127,12 @@ async function main() {
|
|
|
118
127
|
if ((budgets.ownerHours === undefined && budgets.agentHours === undefined) || [budgets.ownerHours, budgets.agentHours].some((h) => h !== undefined && (!Number.isFinite(h) || h <= 0))) {
|
|
119
128
|
throw new Error('usage: harness budget --owner-hours <n> | --agent-hours <n>, with positive numbers');
|
|
120
129
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// The daemon is down: the hours wait for it in .harness/setup.json (v4 §5).
|
|
130
|
+
if (runningDaemon(harnessDir))
|
|
131
|
+
return void (await ownerAction(harnessDir, '/api/budgets', budgets, 'already set'));
|
|
132
|
+
// The daemon is down: the hours wait for it in .harness/setup.json (v4 §5), and replace any it has, as they would if it ran.
|
|
125
133
|
if (budgets.agentHours !== undefined)
|
|
126
134
|
throw new Error('the agent-hours threshold needs the daemon running: `harness up`');
|
|
127
|
-
|
|
128
|
-
const pending = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
129
|
-
mkdirSync(harnessDir, { recursive: true });
|
|
130
|
-
writeFileSync(file, `${JSON.stringify({ ...pending, ownerHours: budgets.ownerHours }, null, 2)}\n`);
|
|
135
|
+
recordForDaemon(harnessDir, { ownerHours: budgets.ownerHours, ownerHoursFrom: 'budget' });
|
|
131
136
|
console.log(`${budgets.ownerHours} hours of your time: the daemon records it when it starts (harness up).`);
|
|
132
137
|
return;
|
|
133
138
|
}
|
|
@@ -226,8 +231,13 @@ async function main() {
|
|
|
226
231
|
case 'version':
|
|
227
232
|
console.log(version);
|
|
228
233
|
return;
|
|
229
|
-
|
|
234
|
+
case 'help':
|
|
235
|
+
case undefined:
|
|
230
236
|
console.log(HELP);
|
|
237
|
+
return;
|
|
238
|
+
default:
|
|
239
|
+
console.error(`unknown command: ${command}\n\n${HELP}`);
|
|
240
|
+
process.exitCode = 1;
|
|
231
241
|
}
|
|
232
242
|
}
|
|
233
243
|
async function ask(question) {
|
|
@@ -239,6 +249,13 @@ async function ask(question) {
|
|
|
239
249
|
rl.close();
|
|
240
250
|
}
|
|
241
251
|
}
|
|
252
|
+
/** Records what the daemon takes from .harness/setup.json when it starts, while it is down (v4 §5). */
|
|
253
|
+
function recordForDaemon(harnessDir, values) {
|
|
254
|
+
const file = join(harnessDir, 'setup.json');
|
|
255
|
+
const pending = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
256
|
+
mkdirSync(harnessDir, { recursive: true });
|
|
257
|
+
writeFileSync(file, `${JSON.stringify({ ...pending, ...values }, null, 2)}\n`);
|
|
258
|
+
}
|
|
242
259
|
/** The project's configuration, when the directory has one. */
|
|
243
260
|
function projectConfig(projectDir) {
|
|
244
261
|
const configFile = join(projectDir, 'harness', 'config.json');
|
|
@@ -309,21 +326,21 @@ async function runDoctor(projectDir, values) {
|
|
|
309
326
|
process.exitCode = 1;
|
|
310
327
|
}
|
|
311
328
|
async function down(harnessDir) {
|
|
312
|
-
const info =
|
|
313
|
-
if (!info
|
|
329
|
+
const info = runningDaemon(harnessDir);
|
|
330
|
+
if (!info)
|
|
314
331
|
return console.log('no daemon is running');
|
|
315
332
|
process.kill(info.pid, 'SIGTERM');
|
|
316
|
-
for (let i = 0; i < 50 &&
|
|
333
|
+
for (let i = 0; i < 50 && isRunning(info); i++)
|
|
317
334
|
await sleep(200);
|
|
318
|
-
console.log(
|
|
335
|
+
console.log(isRunning(info) ? `pid ${info.pid} is still stopping` : 'stopped');
|
|
319
336
|
}
|
|
320
337
|
function status(harnessDir) {
|
|
321
338
|
const file = join(harnessDir, 'state.json');
|
|
322
339
|
if (!existsSync(file))
|
|
323
340
|
return console.log('no state yet: run `harness up` first');
|
|
324
341
|
const state = JSON.parse(readFileSync(file, 'utf8'));
|
|
325
|
-
const info =
|
|
326
|
-
console.log(info
|
|
342
|
+
const info = runningDaemon(harnessDir);
|
|
343
|
+
console.log(info ? `daemon: running, http://127.0.0.1:${info.port}` : 'daemon: stopped');
|
|
327
344
|
console.log(`pipeline: ${state.pipeline.paused ? `paused (${state.pipeline.paused.reason})` : 'running'}`);
|
|
328
345
|
for (const [key, plan] of Object.entries(state.plans)) {
|
|
329
346
|
console.log(`\n${key} ${plan.status} ${plan.branch} @ ${plan.head.slice(0, 8)}`);
|
|
@@ -346,17 +363,30 @@ function status(harnessDir) {
|
|
|
346
363
|
if (open.length > 0)
|
|
347
364
|
console.log('\nAnswer with: harness answer <request-id> <text>');
|
|
348
365
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
366
|
+
/**
|
|
367
|
+
* An owner action, through the running daemon. When the daemon refuses it, the command
|
|
368
|
+
* fails; `unchanged` is what to say instead when it was already so, such as a mode.
|
|
369
|
+
*/
|
|
370
|
+
async function ownerAction(harnessDir, path, body, unchanged) {
|
|
371
|
+
const info = runningDaemon(harnessDir);
|
|
372
|
+
if (!info)
|
|
352
373
|
throw new Error('no daemon is running');
|
|
353
374
|
const response = await fetch(`http://127.0.0.1:${info.port}${path}`, {
|
|
354
375
|
method: 'POST',
|
|
355
376
|
headers: { authorization: `Bearer ${info.token}`, 'content-type': 'application/json' },
|
|
356
377
|
body: JSON.stringify(body),
|
|
357
378
|
});
|
|
379
|
+
if (!response.ok)
|
|
380
|
+
throw new Error(`the daemon answered ${response.status}: ${(await response.text()).trim()}`);
|
|
358
381
|
const result = (await response.json());
|
|
359
|
-
|
|
382
|
+
if (result.done)
|
|
383
|
+
console.log('done');
|
|
384
|
+
else if (unchanged !== undefined && result.error === undefined)
|
|
385
|
+
console.log(unchanged);
|
|
386
|
+
else {
|
|
387
|
+
console.error(result.error ?? 'nothing to do');
|
|
388
|
+
process.exitCode = 1;
|
|
389
|
+
}
|
|
360
390
|
return result;
|
|
361
391
|
}
|
|
362
392
|
/** The Architect's terminal, attached in this one. Detaching leaves the Architect running. */
|
|
@@ -372,8 +402,8 @@ function attach(harnessDir, module) {
|
|
|
372
402
|
spawnSync('openshell', ['sandbox', 'connect', sandbox], { stdio: 'inherit' });
|
|
373
403
|
}
|
|
374
404
|
async function listActions(harnessDir) {
|
|
375
|
-
const info =
|
|
376
|
-
if (!info
|
|
405
|
+
const info = runningDaemon(harnessDir);
|
|
406
|
+
if (!info)
|
|
377
407
|
throw new Error('no daemon is running');
|
|
378
408
|
const response = await fetch(`http://127.0.0.1:${info.port}/api/state`, { headers: { authorization: `Bearer ${info.token}` } });
|
|
379
409
|
const { state, design } = (await response.json());
|
|
@@ -226,6 +226,8 @@ export class Architects {
|
|
|
226
226
|
o.record({ type: 'ARCH_PR_CLOSED', payload: { module: module.module } });
|
|
227
227
|
return;
|
|
228
228
|
}
|
|
229
|
+
// In Swarm, main's rules require the reviewers' statuses: an Arch PR, which the owner reviews, gets them as "not reviewed".
|
|
230
|
+
await o.markUnreviewed(pr.head, 'an Arch PR, which the owner reviews and merges');
|
|
229
231
|
if (module.design.ciNoted === pr.head)
|
|
230
232
|
return;
|
|
231
233
|
const required = requiredChecks(o.design.config);
|
package/dist/daemon/channel.js
CHANGED
|
@@ -57,8 +57,14 @@ export class AgentChannel {
|
|
|
57
57
|
receive(line) {
|
|
58
58
|
if (line.trim() === '')
|
|
59
59
|
return;
|
|
60
|
-
if (line.length > MAX_LINE)
|
|
60
|
+
if (line.length > MAX_LINE) {
|
|
61
|
+
// Not parsed, but its id is near the start: the agent hears why, instead of waiting out its call.
|
|
62
|
+
const id = /"id"\s*:\s*"([A-Za-z0-9-]{1,64})"/.exec(line.slice(0, 300))?.[1];
|
|
63
|
+
if (id && !this.replied.has(id)) {
|
|
64
|
+
void this.reply(id, false, `the request is too large: ${line.length} characters, where the limit is ${MAX_LINE}`).catch(() => { });
|
|
65
|
+
}
|
|
61
66
|
return this.options.onInvalid(`a request of ${line.length} characters (the limit is ${MAX_LINE})`);
|
|
67
|
+
}
|
|
62
68
|
let json;
|
|
63
69
|
try {
|
|
64
70
|
json = JSON.parse(line);
|
package/dist/daemon/design.js
CHANGED
|
@@ -18,7 +18,8 @@ export async function submitDesign(o, module, summary, head) {
|
|
|
18
18
|
const tree = o.repo.treeReader(head);
|
|
19
19
|
const changed = await o.repo.changedFiles(base, head);
|
|
20
20
|
const problems = [];
|
|
21
|
-
|
|
21
|
+
// Judged by the rules it starts from, as CI judges it: a design can't widen the protected set to change application code with it.
|
|
22
|
+
const rules = await protectionRules(o.repo.treeReader(base));
|
|
22
23
|
for (const path of changed) {
|
|
23
24
|
if (!archMayChange(path, rules))
|
|
24
25
|
problems.push(`${path}: a design may change only harness/, shared knowledge, agents' instructions and tool configs`);
|
|
@@ -35,6 +36,20 @@ export async function submitDesign(o, module, summary, head) {
|
|
|
35
36
|
}
|
|
36
37
|
if (mine && mine.plans.length === 0)
|
|
37
38
|
problems.push(`there is no plan: write harness/modules/${module.module}/plans/P1.json`);
|
|
39
|
+
// A feature is frozen with its plan once the plan is past its build: a change to it goes in a new plan.
|
|
40
|
+
const previous = await loadDesign(o.repo, base).catch(() => undefined);
|
|
41
|
+
for (const plan of previous?.modules.find((m) => m.id === module.module)?.plans ?? []) {
|
|
42
|
+
const status = o.state.plans[`${module.module}/${plan.id}`]?.status;
|
|
43
|
+
if (status === undefined || status === 'active' || !mine)
|
|
44
|
+
continue;
|
|
45
|
+
const now = mine.plans.find((p) => p.id === plan.id);
|
|
46
|
+
for (const feature of plan.features) {
|
|
47
|
+
const after = now?.features.find((f) => f.id === feature.id);
|
|
48
|
+
if (!after || definitionHash(after) !== definitionHash(feature)) {
|
|
49
|
+
problems.push(`${plan.id}/${feature.id}: plan ${plan.id} is past its build (${status.replaceAll('_', ' ')}), so its features are frozen: put the change in a new plan`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
38
53
|
if (!mine && problems.length === 0)
|
|
39
54
|
problems.push(`there is no design for ${module.module}: write harness/modules/${module.module}/plans/P1.json`);
|
|
40
55
|
for (const plan of mine?.plans ?? []) {
|
|
@@ -61,7 +76,6 @@ export async function submitDesign(o, module, summary, head) {
|
|
|
61
76
|
if (problems.length > 0 || !mine)
|
|
62
77
|
return { ok: false, problems };
|
|
63
78
|
// The new checks, run once on the repository as it is: nothing is built yet, so they should fail.
|
|
64
|
-
const previous = await loadDesign(o.repo, base).catch(() => undefined);
|
|
65
79
|
const known = new Set(previous?.modules.flatMap((m) => m.plans.flatMap((p) => p.features.map((f) => `${m.id}/${p.id}/${f.id}:${definitionHash(f)}`))) ?? []);
|
|
66
80
|
const fresh = mine.plans.flatMap((p) => p.features.filter((f) => !known.has(`${module.module}/${p.id}/${f.id}:${definitionHash(f)}`)).flatMap((f) => preMerge(f.checks)));
|
|
67
81
|
const results = fresh.length === 0 ? [] : await o.runChecksIn(sandboxName('chk', o.design.config.project.name, module.module, 'design'), architectRef(module.module), o.moduleDir(module.module), fresh);
|
package/dist/daemon/items.js
CHANGED
|
@@ -9,7 +9,8 @@ export function pendingItems(o, scope) {
|
|
|
9
9
|
const others = [];
|
|
10
10
|
for (const feature of Object.values(o.state.features).filter((f) => f.module === module && (only === undefined || f.plan === only))) {
|
|
11
11
|
const last = feature.attempts.at(-1);
|
|
12
|
-
|
|
12
|
+
// A feature a design change dropped waits for nobody: an attempt under way stops (v3 §6.6).
|
|
13
|
+
if (!last || !o.featureDefinition(feature))
|
|
13
14
|
continue;
|
|
14
15
|
if (last.status === 'running') {
|
|
15
16
|
for (const question of (last.questions ?? []).filter((q) => q.answer === undefined)) {
|
|
@@ -233,8 +234,10 @@ export function mailText(o, manager, mail) {
|
|
|
233
234
|
].join('\n');
|
|
234
235
|
}
|
|
235
236
|
export function attemptsLeft(o, feature) {
|
|
236
|
-
const
|
|
237
|
-
|
|
237
|
+
const definition = o.featureDefinition(feature);
|
|
238
|
+
if (!definition)
|
|
239
|
+
return 0;
|
|
240
|
+
return o.design.config.roles.developer.maxAttempts + feature.extraAttempts - countedAttempts(feature, definitionHash(definition));
|
|
238
241
|
}
|
|
239
242
|
function questionsAsked(attempt) {
|
|
240
243
|
const asked = attempt.questions ?? [];
|