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.
Files changed (44) hide show
  1. package/CHANGELOG.md +140 -4
  2. package/README.md +78 -40
  3. package/dist/agents/claude-code.js +9 -2
  4. package/dist/anthropic/usage.js +2 -1
  5. package/dist/checks/runner.js +2 -1
  6. package/dist/ci/harness-ci.js +115 -32
  7. package/dist/cli.js +60 -30
  8. package/dist/daemon/architects.js +2 -0
  9. package/dist/daemon/channel.js +7 -1
  10. package/dist/daemon/design.js +16 -2
  11. package/dist/daemon/items.js +6 -3
  12. package/dist/daemon/main.js +84 -12
  13. package/dist/daemon/manager.js +25 -19
  14. package/dist/daemon/orchestrator.js +366 -157
  15. package/dist/daemon/requests.js +21 -19
  16. package/dist/daemon/review.js +14 -8
  17. package/dist/daemon/web-read.js +143 -33
  18. package/dist/dashboard/server.js +27 -10
  19. package/dist/dashboard/terminals.js +22 -5
  20. package/dist/design/owner-time.js +8 -1
  21. package/dist/design/schemas.js +14 -4
  22. package/dist/git/repo.js +52 -19
  23. package/dist/github/app.js +7 -5
  24. package/dist/github/forge.js +17 -6
  25. package/dist/github/setup.js +10 -3
  26. package/dist/review/paid-calls.js +39 -14
  27. package/dist/roles.js +1 -1
  28. package/dist/sandbox/openshell.js +74 -13
  29. package/dist/sandbox/policy.js +2 -1
  30. package/dist/setup/doctor.js +74 -12
  31. package/dist/setup/init.js +5 -4
  32. package/dist/setup/recover.js +11 -6
  33. package/dist/setup/setup.js +163 -70
  34. package/dist/state/state.js +31 -11
  35. package/dist/util.js +2 -2
  36. package/dist/web/assets/{index-DLttwGM6.js → index-CPJJsPL6.js} +1 -1
  37. package/dist/web/index.html +1 -1
  38. package/images/agent/Dockerfile +11 -2
  39. package/package.json +1 -1
  40. package/roles/architect.md +5 -2
  41. package/roles/manager.md +2 -1
  42. package/shim/harness-mcp.mjs +11 -2
  43. package/src/ci/harness-ci.ts +105 -32
  44. package/templates/harness.yml +5 -2
@@ -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 existsSync(file) && statSync(file).isFile() ? readFileSync(file, 'utf8') : undefined;
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) => statSync(file).isFile() && !relative(root, file).startsWith('.git/'))
36
+ .filter((file) => isFile(file) && !relative(root, file).startsWith('.git/'))
28
37
  .map((file) => relative(root, file));
29
38
  },
30
39
  };
31
40
  }
32
- /** Paths no Plan or Fix PR may change, in every project. */
33
- export const ALWAYS_PROTECTED = ['harness/**', '.github/**', '.claude/**', 'CLAUDE.md', 'AGENTS.md', 'ARCHITECTURE.md', 'init.sh', '.npmrc'];
34
- /** Tool configs written as code: protected whole when they exist. */
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.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts', '.eslintrc', '.eslintrc.js', '.eslintrc.cjs',
37
- '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml', 'vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs', 'vitest.workspace.ts',
38
- 'jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'playwright.config.ts', 'playwright.config.js', 'biome.json', 'biome.jsonc',
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 scripts a command line runs: `npm run lint`, `npm test`, `npm start`. */
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
- for (const match of command.matchAll(/\bnpm\s+(?:run(?:-script)?\s+([\w:.-]+)|(test|t|start|stop|restart)\b)/g)) {
67
- scripts.push(match[1] ?? (match[2] === 't' ? 'test' : match[2]));
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
- return pattern.endsWith('/**') ? path.startsWith(pattern.slice(0, -2)) : path === pattern;
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
- /** Packages in package-lock.json that don't come from the public npm registry. */
108
- export async function lockfileProblems(tree) {
109
- const lock = parseJson(await tree.read('package-lock.json'));
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(lock?.packages ?? {})) {
112
- if (name === '' || entry.link || entry.resolved === undefined)
179
+ for (const [name, entry] of Object.entries(after)) {
180
+ if (name === '')
113
181
  continue;
114
- if (!entry.resolved.startsWith('https://registry.npmjs.org/'))
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.endsWith('/**'))
125
- for (const file of await tree.list(pattern.slice(0, -3)))
126
- files.add(file);
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
- const dir = (config.terraform.dir || 'iac').replace(/\/+$/, '');
214
- const touched = input.changedPaths.filter((p) => p === dir || p.startsWith(`${dir}/`));
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}/: infrastructure gets plans of its own (v3 §3.1); changed: ${touched.join(', ')}`);
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
- const rules = await protectionRules(input.head);
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
- const diff = spawnSync('git', ['-C', values.head, 'diff', '--name-only', '--no-renames', `${values['base-sha']}...${values['head-sha']}`], { encoding: 'utf8' });
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('\n').filter(Boolean),
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 { isAlive, packageRoot, readDaemonInfo, runDaemon, version } from './daemon/main.js';
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
- console.log(state?.mode ?? 'manual');
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 result = await ownerAction(harnessDir, '/api/mode', { mode: argument });
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 (result.error || !repo)
106
- return;
107
- try {
108
- await applyRules(projectDir, harnessDir, repo, argument);
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
- catch (error) {
111
- 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.`);
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
- const info = readDaemonInfo(harnessDir);
122
- if (info && isAlive(info.pid))
123
- return void (await ownerAction(harnessDir, '/api/budgets', budgets));
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
- const file = join(harnessDir, 'setup.json');
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
- default:
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 = readDaemonInfo(harnessDir);
313
- if (!info || !isAlive(info.pid))
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 && isAlive(info.pid); i++)
333
+ for (let i = 0; i < 50 && isRunning(info); i++)
317
334
  await sleep(200);
318
- console.log(isAlive(info.pid) ? `pid ${info.pid} is still stopping` : 'stopped');
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 = readDaemonInfo(harnessDir);
326
- console.log(info && isAlive(info.pid) ? `daemon: running, http://127.0.0.1:${info.port}` : 'daemon: stopped');
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
- async function ownerAction(harnessDir, path, body) {
350
- const info = readDaemonInfo(harnessDir);
351
- if (!info || !isAlive(info.pid))
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
- console.log(result.done ? 'done' : result.error ?? 'nothing to do');
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 = readDaemonInfo(harnessDir);
376
- if (!info || !isAlive(info.pid))
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);
@@ -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);
@@ -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
- const rules = await protectionRules(tree);
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);
@@ -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
- if (!last)
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 hash = definitionHash(o.lookup(feature).feature);
237
- return o.design.config.roles.developer.maxAttempts + feature.extraAttempts - countedAttempts(feature, hash);
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 ?? [];