razzou-harness 0.1.2 → 0.2.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 (40) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +215 -25
  3. package/dist/agents/claude-code.js +64 -14
  4. package/dist/anthropic/usage.js +53 -0
  5. package/dist/ci/harness-ci.js +38 -0
  6. package/dist/cli.js +125 -16
  7. package/dist/daemon/architects.js +6 -2
  8. package/dist/daemon/design.js +4 -0
  9. package/dist/daemon/items.js +24 -4
  10. package/dist/daemon/manager.js +108 -59
  11. package/dist/daemon/orchestrator.js +579 -91
  12. package/dist/daemon/requests.js +10 -3
  13. package/dist/daemon/review.js +334 -0
  14. package/dist/dashboard/server.js +34 -6
  15. package/dist/design/owner-time.js +72 -0
  16. package/dist/design/schemas.js +95 -4
  17. package/dist/github/app.js +28 -4
  18. package/dist/github/artifacts.js +35 -0
  19. package/dist/github/forge.js +95 -0
  20. package/dist/github/setup.js +60 -10
  21. package/dist/review/cost-estimate.js +25 -0
  22. package/dist/review/paid-calls.js +68 -0
  23. package/dist/roles.js +8 -0
  24. package/dist/setup/doctor.js +81 -10
  25. package/dist/setup/init.js +3 -2
  26. package/dist/setup/setup.js +378 -0
  27. package/dist/state/events.js +5 -2
  28. package/dist/state/state.js +158 -29
  29. package/dist/web/THIRD-PARTY-LICENSES.md +1 -1
  30. package/dist/web/assets/{index-D27gAaUJ.css → index-CMViLVQH.css} +1 -1
  31. package/dist/web/assets/index-DLttwGM6.js +96 -0
  32. package/dist/web/index.html +2 -2
  33. package/package.json +11 -4
  34. package/roles/architect.md +24 -5
  35. package/roles/cost.md +43 -0
  36. package/roles/manager.md +4 -3
  37. package/roles/security.md +37 -0
  38. package/src/ci/harness-ci.ts +42 -1
  39. package/templates/harness.yml +64 -0
  40. package/dist/web/assets/index-DtxDOPz5.js +0 -96
@@ -181,6 +181,42 @@ export function prKind(author, branch, bot) {
181
181
  return 'Harness';
182
182
  return 'Owner';
183
183
  }
184
+ /**
185
+ * A plan's kind, from its features' (v3 §3.1): CODE with no `infra` feature, INFRA with
186
+ * only `infra` features, MIXED otherwise. A feature without a kind is an `app` one. The
187
+ * same rule as the daemon's `planKind`, kept here so this file stands alone.
188
+ */
189
+ export function planKindOf(features) {
190
+ const infra = features.filter((f) => f.kind === 'infra').length;
191
+ return infra === 0 ? 'CODE' : infra === features.length ? 'INFRA' : 'MIXED';
192
+ }
193
+ /**
194
+ * The plan-kind rules of a Plan PR (v3 §3.1), from the plan file on the base branch: a
195
+ * tag in the title must be the plan's kind, and a CODE plan stays off the Terraform
196
+ * folder, so infrastructure can't reach main as code. The tag grants nothing, and a
197
+ * title without one, from an older daemon, passes.
198
+ */
199
+ async function planKindProblems(input, config) {
200
+ const [, module, plan] = /^plan\/([^/]+)\/([^/]+)$/.exec(input.branch) ?? [];
201
+ if (!module || !plan)
202
+ return [];
203
+ const file = `harness/modules/${module}/plans/${plan}.json`;
204
+ const definition = parseJson(await input.base.read(file));
205
+ if (!definition || !Array.isArray(definition.features))
206
+ return [`${file} is missing or unreadable on the base branch, so the plan's kind is unknown`];
207
+ const kind = planKindOf(definition.features);
208
+ const problems = [];
209
+ const tag = /(?:^|\s)-(CODE|INFRA|MIXED)-(?:\s|$)/.exec(input.title)?.[1];
210
+ if (tag && tag !== kind)
211
+ problems.push(`the title says -${tag}-, but ${module}/${plan} is a ${kind} plan on the base branch: its features' kinds decide`);
212
+ 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}/`));
215
+ 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(', ')}`);
217
+ }
218
+ return problems;
219
+ }
184
220
  /** The `verify` job: the problems that make a PR fail, or none. */
185
221
  export async function verify(input) {
186
222
  const config = parseJson(await input.base.read('harness/config.json'));
@@ -195,6 +231,8 @@ export async function verify(input) {
195
231
  for (const problem of await lockfileProblems(input.head))
196
232
  problems.push(`package-lock.json: ${problem}`);
197
233
  }
234
+ if (kind === 'Plan')
235
+ problems.push(...(await planKindProblems(input, config)));
198
236
  if (kind === 'Arch') {
199
237
  const rules = await protectionRules(input.head);
200
238
  for (const path of input.changedPaths.filter((p) => !archMayChange(p, rules))) {
package/dist/cli.js CHANGED
@@ -1,26 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
5
6
  import { parseArgs } from 'node:util';
6
7
  import { computeLock, directoryReader } from './ci/harness-ci.js';
7
8
  import { isAlive, packageRoot, readDaemonInfo, runDaemon, version } from './daemon/main.js';
8
9
  import { ConfigSchema, requiredChecks } from './design/schemas.js';
10
+ import { loadAppCredentials } from './github/app.js';
9
11
  import { applyRepositoryRules, checkApp, createApp, installCi } from './github/setup.js';
10
12
  import { doctor } from './setup/doctor.js';
11
13
  import { CHECKLIST, init } from './setup/init.js';
12
14
  import { recover } from './setup/recover.js';
15
+ import { setup } from './setup/setup.js';
13
16
  import { sleep } from './util.js';
14
17
  const HELP = `harness ${version}
15
18
 
16
19
  Usage: harness <command> [--dir <project>]
17
20
 
21
+ setup [--repo <owner/name>] [--owner <login>]
22
+ Set up this machine and the project: every step the harness can do, asking only for
23
+ what is yours (a login, a token, a click on GitHub); run it again any time
18
24
  init [--repo <owner/name>] [--owner <login>]
19
25
  Write the harness's files into this repository, and print the setup checklist
20
26
  up Run the daemon for the project (foreground) and open the dashboard
21
27
  down Stop the running daemon; sandboxes keep their state
22
28
  status Show plans, features and the pipeline
23
29
  resume Lift a pause of the pipeline
30
+ mode [manual|swarm]
31
+ Show the mode, or switch it and apply the mode's rules on main with your gh:
32
+ in Swarm the daemon merges plans (docs/architecture/proposal-v3.md)
33
+ budget --owner-hours <n> | --agent-hours <n>
34
+ Set the hours of your time for the whole project, or the agent-hours threshold
24
35
  retry <m/p/f> Grant one more attempt to an escalated feature (--note: guidance)
25
36
  module new <id> <title>
26
37
  Start a module: its Architect's sandbox and conversation
@@ -38,8 +49,8 @@ Usage: harness <command> [--dir <project>]
38
49
  Check that the App is installed on the repository and can get a token
39
50
  install-ci [--owner <login>]
40
51
  Write the harness workflow, its CI script and CODEOWNERS into the project
41
- github-setup --repo <owner/repo>
42
- Apply merge settings and the main ruleset, with your own gh login
52
+ github-setup --repo <owner/repo> [--mode manual|swarm]
53
+ Apply merge settings and the main ruleset of the mode (the daemon's, by default), with your own gh login
43
54
  lock Write harness/lock.json, the hashes of every protected file and field
44
55
  doctor [--probe] [--build-images]
45
56
  Check this machine and the project, and set up the providers;
@@ -58,6 +69,9 @@ async function main() {
58
69
  name: { type: 'string' },
59
70
  repo: { type: 'string' },
60
71
  owner: { type: 'string' },
72
+ mode: { type: 'string' },
73
+ 'owner-hours': { type: 'string' },
74
+ 'agent-hours': { type: 'string' },
61
75
  probe: { type: 'boolean' },
62
76
  'build-images': { type: 'boolean' },
63
77
  help: { type: 'boolean', short: 'h' },
@@ -75,24 +89,65 @@ async function main() {
75
89
  case 'status':
76
90
  return status(harnessDir);
77
91
  case 'resume':
78
- return ownerAction(harnessDir, '/api/resume', {});
92
+ return void await ownerAction(harnessDir, '/api/resume', {});
93
+ case 'mode': {
94
+ if (!argument) {
95
+ const file = join(harnessDir, 'state.json');
96
+ const state = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
97
+ console.log(state?.mode ?? 'manual');
98
+ return;
99
+ }
100
+ if (argument !== 'manual' && argument !== 'swarm')
101
+ throw new Error('usage: harness mode [manual|swarm]');
102
+ const result = await ownerAction(harnessDir, '/api/mode', { mode: argument });
103
+ // 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
+ const repo = projectConfig(projectDir)?.project.repo;
105
+ if (result.error || !repo)
106
+ return;
107
+ try {
108
+ await applyRules(projectDir, harnessDir, repo, argument);
109
+ }
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.`);
112
+ }
113
+ return;
114
+ }
115
+ case 'budget': {
116
+ const hours = (key) => (values[key] === undefined ? undefined : Number(values[key]));
117
+ const budgets = { ownerHours: hours('owner-hours'), agentHours: hours('agent-hours') };
118
+ if ((budgets.ownerHours === undefined && budgets.agentHours === undefined) || [budgets.ownerHours, budgets.agentHours].some((h) => h !== undefined && (!Number.isFinite(h) || h <= 0))) {
119
+ throw new Error('usage: harness budget --owner-hours <n> | --agent-hours <n>, with positive numbers');
120
+ }
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).
125
+ if (budgets.agentHours !== undefined)
126
+ 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`);
131
+ console.log(`${budgets.ownerHours} hours of your time: the daemon records it when it starts (harness up).`);
132
+ return;
133
+ }
79
134
  case 'retry':
80
135
  if (!argument)
81
136
  throw new Error('usage: harness retry <module/plan/feature>');
82
- return ownerAction(harnessDir, '/api/retry', { feature: argument, note: values.note });
137
+ return void await ownerAction(harnessDir, '/api/retry', { feature: argument, note: values.note });
83
138
  case 'message':
84
139
  if (!argument)
85
140
  throw new Error('usage: harness message <text>');
86
- return ownerAction(harnessDir, '/api/message', { text: positionals.slice(1).join(' ') });
141
+ return void await ownerAction(harnessDir, '/api/message', { text: positionals.slice(1).join(' ') });
87
142
  case 'answer':
88
143
  if (!argument || positionals.length < 3)
89
144
  throw new Error('usage: harness answer <request-id> <text>');
90
- return ownerAction(harnessDir, '/api/answer', { id: argument, text: positionals.slice(2).join(' ') });
145
+ return void await ownerAction(harnessDir, '/api/answer', { id: argument, text: positionals.slice(2).join(' ') });
91
146
  case 'module': {
92
147
  const [, id, ...title] = positionals.slice(1);
93
148
  if (argument !== 'new' || !id)
94
149
  throw new Error('usage: harness module new <id> <title>');
95
- return ownerAction(harnessDir, '/api/modules', { id, title: title.join(' ') });
150
+ return void await ownerAction(harnessDir, '/api/modules', { id, title: title.join(' ') });
96
151
  }
97
152
  case 'attach':
98
153
  return attach(harnessDir, argument);
@@ -100,7 +155,7 @@ async function main() {
100
155
  const version = Number((positionals[2] ?? '').replace(/^v/, ''));
101
156
  if (!argument || !Number.isInteger(version) || version < 1)
102
157
  throw new Error('usage: harness accept <module> <vN>');
103
- return ownerAction(harnessDir, '/api/accept', { module: argument, version });
158
+ return void await ownerAction(harnessDir, '/api/accept', { module: argument, version });
104
159
  }
105
160
  case 'actions':
106
161
  return listActions(harnessDir);
@@ -108,7 +163,7 @@ async function main() {
108
163
  const [module, action] = (argument ?? '').split('/');
109
164
  if (!module || !action)
110
165
  throw new Error('usage: harness done <module>/<action> [--note <text>]');
111
- return ownerAction(harnessDir, '/api/actions/done', { module, action, note: values.note });
166
+ return void await ownerAction(harnessDir, '/api/actions/done', { module, action, note: values.note });
112
167
  }
113
168
  case 'github-app':
114
169
  return githubApp(argument, values);
@@ -120,12 +175,29 @@ async function main() {
120
175
  }
121
176
  case 'github-setup': {
122
177
  if (!values.repo)
123
- throw new Error('usage: harness github-setup --repo <owner/repo>');
124
- const configFile = join(projectDir, 'harness', 'config.json');
125
- const required = existsSync(configFile) ? requiredChecks(ConfigSchema.parse(JSON.parse(readFileSync(configFile, 'utf8')))) : ['verify', 'checks'];
126
- for (const line of await applyRepositoryRules(values.repo, required))
127
- console.log(`set: ${line}`);
128
- return;
178
+ throw new Error('usage: harness github-setup --repo <owner/repo> [--mode manual|swarm]');
179
+ if (values.mode !== undefined && values.mode !== 'manual' && values.mode !== 'swarm')
180
+ throw new Error('--mode is manual or swarm');
181
+ return applyRules(projectDir, harnessDir, values.repo, values.mode);
182
+ }
183
+ case 'setup': {
184
+ if (process.stdin.isTTY !== true)
185
+ throw new Error('harness setup needs a terminal: it asks for what only you can give. `harness doctor` lists what is missing.');
186
+ return setup({
187
+ projectDir,
188
+ packageDir: packageRoot,
189
+ version,
190
+ ...(values.repo ? { repo: values.repo } : {}),
191
+ ...(values.owner ? { owner: values.owner } : {}),
192
+ say: (line) => console.log(line),
193
+ ask: (question) => ask(question),
194
+ open: (url) => spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).on('error', () => { }).unref(),
195
+ interactive: (file, args) => spawnSync(file, args, { stdio: 'inherit' }).status ?? 1,
196
+ up: async () => {
197
+ await runDaemon(projectDir);
198
+ process.exit(0);
199
+ },
200
+ });
129
201
  }
130
202
  case 'doctor':
131
203
  return runDoctor(projectDir, values);
@@ -158,6 +230,42 @@ async function main() {
158
230
  console.log(HELP);
159
231
  }
160
232
  }
233
+ async function ask(question) {
234
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
235
+ try {
236
+ return (await rl.question(question)).trim();
237
+ }
238
+ finally {
239
+ rl.close();
240
+ }
241
+ }
242
+ /** The project's configuration, when the directory has one. */
243
+ function projectConfig(projectDir) {
244
+ const configFile = join(projectDir, 'harness', 'config.json');
245
+ return existsSync(configFile) ? ConfigSchema.parse(JSON.parse(readFileSync(configFile, 'utf8'))) : undefined;
246
+ }
247
+ /** Merge settings and the ruleset of the mode on main, with the owner's own gh: the daemon's mode, unless `mode` says otherwise (v3 §2.1). */
248
+ async function applyRules(projectDir, harnessDir, repo, mode) {
249
+ const config = projectConfig(projectDir);
250
+ const required = config ? requiredChecks(config) : ['verify', 'checks'];
251
+ const stateFile = join(harnessDir, 'state.json');
252
+ const snapshot = existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, 'utf8')) : undefined;
253
+ const effective = mode ?? snapshot?.mode ?? 'manual';
254
+ let options = { mode: effective };
255
+ if (effective === 'swarm') {
256
+ const statuses = (config?.swarm ?? ConfigSchema.shape.swarm.parse(undefined)).requiredStatuses;
257
+ let appId;
258
+ try {
259
+ appId = loadAppCredentials().appId;
260
+ }
261
+ catch {
262
+ appId = undefined;
263
+ }
264
+ options = { mode: effective, statuses, ...(appId !== undefined ? { appId } : {}) };
265
+ }
266
+ for (const line of await applyRepositoryRules(repo, required, options))
267
+ console.log(`set: ${line}`);
268
+ }
161
269
  async function githubApp(action, values) {
162
270
  if (action === 'create') {
163
271
  if (!values.org)
@@ -249,6 +357,7 @@ async function ownerAction(harnessDir, path, body) {
249
357
  });
250
358
  const result = (await response.json());
251
359
  console.log(result.done ? 'done' : result.error ?? 'nothing to do');
360
+ return result;
252
361
  }
253
362
  /** The Architect's terminal, attached in this one. Detaching leaves the Architect running. */
254
363
  function attach(harnessDir, module) {
@@ -116,7 +116,7 @@ export class Architects {
116
116
  return folder;
117
117
  };
118
118
  copyFileSync(join(o.options.packageDir, 'roles', 'architect.md'), join(staging('harness'), 'architect.md'));
119
- writeFileSync(join(staging('harness'), 'welcome.md'), welcome(module));
119
+ writeFileSync(join(staging('harness'), 'welcome.md'), welcome(module, o.state.budgets.ownerHours !== undefined));
120
120
  for (const file of ['architect.md', 'welcome.md'])
121
121
  await o.driver.upload(sandbox, join(staging('harness'), file), p.harness);
122
122
  await o.uploadAgentFiles(sandbox, 'architect', dir);
@@ -264,13 +264,16 @@ export class Architects {
264
264
  }
265
265
  }
266
266
  /** The first prompt of a session: a new module, or one whose sandbox was recreated without its conversation. */
267
- function welcome(module) {
267
+ function welcome(module, budgeted) {
268
268
  const latest = module.proposals.at(-1);
269
+ // The owner's time is budgeted: the Architect designs owner actions within it (v3 §4.1).
270
+ const budget = budgeted ? ['The owner budgets their time: call get_owner_time before you propose, and estimate every owner action.'] : [];
269
271
  if (!latest) {
270
272
  return clip([
271
273
  `[harness] You are the Architect of the new module "${module.module}": ${module.title}.`,
272
274
  'Read README.md, ARCHITECTURE.md and the other modules\' designs in harness/modules/ first.',
273
275
  'Then greet the owner in two lines and wait: they will tell you what this module is for. Then use the grill-me skill.',
276
+ ...budget,
274
277
  ].join('\n'), 2000);
275
278
  }
276
279
  const where = {
@@ -285,5 +288,6 @@ function welcome(module) {
285
288
  'Your sandbox was recreated, and your earlier conversation is gone.',
286
289
  `Your proposals are in harness/modules/${module.module}/proposals/. ${where}`,
287
290
  'Read them, README.md and ARCHITECTURE.md, then greet the owner in two lines and wait.',
291
+ ...budget,
288
292
  ].join('\n'), 2000);
289
293
  }
@@ -1,4 +1,5 @@
1
1
  import { archMayChange, computeLock, protectionRules } from '../ci/harness-ci.js';
2
+ import { moduleOwnerTime, ownerTimeProblems } from '../design/owner-time.js';
2
3
  import { checkScripts, definitionHash, loadDesign } from '../design/schemas.js';
3
4
  import { HARNESS } from '../git/repo.js';
4
5
  import { clip } from '../util.js';
@@ -54,6 +55,9 @@ export async function submitDesign(o, module, summary, head) {
54
55
  }
55
56
  }
56
57
  }
58
+ // Once the owner budgets their time, every owner action is estimated, and the module fits what is available (v3 §4.1).
59
+ if (mine)
60
+ problems.push(...ownerTimeProblems(await o.ownerTime(), moduleOwnerTime(mine, o.state)));
57
61
  if (problems.length > 0 || !mine)
58
62
  return { ok: false, problems };
59
63
  // The new checks, run once on the repository as it is: nothing is built yet, so they should fail.
@@ -2,10 +2,12 @@ import { definitionHash } from '../design/schemas.js';
2
2
  import { countedAttempts, featureKey, planKey } from '../state/state.js';
3
3
  import { clip, tail } from '../util.js';
4
4
  import { attemptRefName, refOf } from './refs.js';
5
- export function pendingItems(o, module) {
5
+ /** What waits for a Manager: the items of its module, or of its plan alone for a plan's Manager (v3 §6.4). */
6
+ export function pendingItems(o, scope) {
7
+ const { module, plan: only } = scope;
6
8
  const questions = [];
7
9
  const others = [];
8
- for (const feature of Object.values(o.state.features).filter((f) => f.module === module)) {
10
+ for (const feature of Object.values(o.state.features).filter((f) => f.module === module && (only === undefined || f.plan === only))) {
9
11
  const last = feature.attempts.at(-1);
10
12
  if (!last)
11
13
  continue;
@@ -22,7 +24,7 @@ export function pendingItems(o, module) {
22
24
  }
23
25
  const rounds = o.design.config.brakes.ciFixRounds;
24
26
  const deployRounds = o.design.config.brakes.deployFixRounds;
25
- for (const plan of Object.values(o.state.plans).filter((p) => p.module === module)) {
27
+ for (const plan of Object.values(o.state.plans).filter((p) => p.module === module && (only === undefined || p.plan === only))) {
26
28
  if ((plan.status === 'deploy_failed' || plan.status === 'verify_failed') && (plan.deployFixRounds ?? 0) < deployRounds) {
27
29
  const key = planKey(plan);
28
30
  const what = plan.status === 'deploy_failed' ? 'DEPLOY FAILED' : 'POST-DEPLOY CHECKS FAILED';
@@ -202,7 +204,25 @@ export function mailText(o, manager, mail) {
202
204
  return [`[harness] ANSWER from the owner (request ${mail.requestId})`, '', `You asked: ${clip(question, 800)}`, '', `Answer: ${mail.text}`].join('\n');
203
205
  }
204
206
  const module = o.design.modules.find((m) => m.id === manager.module);
205
- const plans = (module?.plans ?? []).map((p) => `- ${p.id} "${p.title}": ${p.features.map((f) => `${f.id} "${f.title}"`).join(', ')}`);
207
+ const features = (p) => p.features.map((f) => `${f.id} "${f.title}"`).join(', ');
208
+ if (manager.plan) {
209
+ // A plan's Manager, in Swarm (v3 §6.4): its plan, and where the module's other plans stand.
210
+ const mine = module?.plans.find((p) => p.id === manager.plan);
211
+ const others = (module?.plans ?? []).filter((p) => p.id !== manager.plan).map((p) => {
212
+ const status = o.state.plans[`${manager.module}/${p.id}`]?.status;
213
+ const where = status === 'completed' ? 'complete' : status ? `${status.replace(/_/g, ' ')}, with its own Manager` : 'not started';
214
+ return `- ${p.id} "${p.title}": ${where}`;
215
+ });
216
+ return [
217
+ `[harness] WELCOME: you are the Manager of plan ${manager.plan} "${mine?.title ?? ''}" of module "${manager.module}".`,
218
+ '',
219
+ `Its features: ${mine ? features(mine) : '(none)'}.`,
220
+ ...(others.length > 0 ? ['', "The module's other plans, which are not yours:", ...others] : []),
221
+ '',
222
+ `The plan is harness/modules/${manager.module}/plans/${manager.plan}.json in your clone, next to the others. Read it now, with ARCHITECTURE.md and README.md if they exist, so you're ready for questions and reviews. Then reply in one line and wait for the next item.`,
223
+ ].join('\n');
224
+ }
225
+ const plans = (module?.plans ?? []).map((p) => `- ${p.id} "${p.title}": ${features(p)}`);
206
226
  return [
207
227
  `[harness] WELCOME: you are the Manager of module "${manager.module}".`,
208
228
  '',