@voxslopuli/openagentsy 0.0.0-stage → 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 (168) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +21 -2
  3. package/dist/adapter-dependency.test.d.ts +2 -0
  4. package/dist/adapter-dependency.test.d.ts.map +1 -0
  5. package/dist/adapter-dependency.test.js +51 -0
  6. package/dist/adapter-dependency.test.js.map +1 -0
  7. package/dist/commands/approve.d.ts +63 -0
  8. package/dist/commands/approve.d.ts.map +1 -0
  9. package/dist/commands/approve.js +218 -0
  10. package/dist/commands/approve.js.map +1 -0
  11. package/dist/commands/approve.test.d.ts +2 -0
  12. package/dist/commands/approve.test.d.ts.map +1 -0
  13. package/dist/commands/approve.test.js +219 -0
  14. package/dist/commands/approve.test.js.map +1 -0
  15. package/dist/commands/audit.d.ts +74 -0
  16. package/dist/commands/audit.d.ts.map +1 -0
  17. package/dist/commands/audit.js +230 -0
  18. package/dist/commands/audit.js.map +1 -0
  19. package/dist/commands/audit.test.d.ts +2 -0
  20. package/dist/commands/audit.test.d.ts.map +1 -0
  21. package/dist/commands/audit.test.js +194 -0
  22. package/dist/commands/audit.test.js.map +1 -0
  23. package/dist/commands/board.d.ts +33 -0
  24. package/dist/commands/board.d.ts.map +1 -0
  25. package/dist/commands/board.js +65 -0
  26. package/dist/commands/board.js.map +1 -0
  27. package/dist/commands/build.d.ts +17 -0
  28. package/dist/commands/build.d.ts.map +1 -0
  29. package/dist/commands/build.js +145 -0
  30. package/dist/commands/build.js.map +1 -0
  31. package/dist/commands/conformance.d.ts +31 -0
  32. package/dist/commands/conformance.d.ts.map +1 -0
  33. package/dist/commands/conformance.js +57 -0
  34. package/dist/commands/conformance.js.map +1 -0
  35. package/dist/commands/conformance.test.d.ts +2 -0
  36. package/dist/commands/conformance.test.d.ts.map +1 -0
  37. package/dist/commands/conformance.test.js +74 -0
  38. package/dist/commands/conformance.test.js.map +1 -0
  39. package/dist/commands/doctor.d.ts +132 -0
  40. package/dist/commands/doctor.d.ts.map +1 -0
  41. package/dist/commands/doctor.js +768 -0
  42. package/dist/commands/doctor.js.map +1 -0
  43. package/dist/commands/doctor.test.d.ts +2 -0
  44. package/dist/commands/doctor.test.d.ts.map +1 -0
  45. package/dist/commands/doctor.test.js +329 -0
  46. package/dist/commands/doctor.test.js.map +1 -0
  47. package/dist/commands/ledger.d.ts +109 -0
  48. package/dist/commands/ledger.d.ts.map +1 -0
  49. package/dist/commands/ledger.js +286 -0
  50. package/dist/commands/ledger.js.map +1 -0
  51. package/dist/commands/ledger.test.d.ts +2 -0
  52. package/dist/commands/ledger.test.d.ts.map +1 -0
  53. package/dist/commands/ledger.test.js +238 -0
  54. package/dist/commands/ledger.test.js.map +1 -0
  55. package/dist/commands/lessons.d.ts +73 -0
  56. package/dist/commands/lessons.d.ts.map +1 -0
  57. package/dist/commands/lessons.js +97 -0
  58. package/dist/commands/lessons.js.map +1 -0
  59. package/dist/commands/lessons.test.d.ts +2 -0
  60. package/dist/commands/lessons.test.d.ts.map +1 -0
  61. package/dist/commands/lessons.test.js +146 -0
  62. package/dist/commands/lessons.test.js.map +1 -0
  63. package/dist/commands/models.d.ts +49 -0
  64. package/dist/commands/models.d.ts.map +1 -0
  65. package/dist/commands/models.js +125 -0
  66. package/dist/commands/models.js.map +1 -0
  67. package/dist/commands/report.d.ts +57 -0
  68. package/dist/commands/report.d.ts.map +1 -0
  69. package/dist/commands/report.js +164 -0
  70. package/dist/commands/report.js.map +1 -0
  71. package/dist/commands/report.test.d.ts +2 -0
  72. package/dist/commands/report.test.d.ts.map +1 -0
  73. package/dist/commands/report.test.js +294 -0
  74. package/dist/commands/report.test.js.map +1 -0
  75. package/dist/commands/scope.d.ts +34 -0
  76. package/dist/commands/scope.d.ts.map +1 -0
  77. package/dist/commands/scope.js +78 -0
  78. package/dist/commands/scope.js.map +1 -0
  79. package/dist/commands/scope.test.d.ts +2 -0
  80. package/dist/commands/scope.test.d.ts.map +1 -0
  81. package/dist/commands/scope.test.js +98 -0
  82. package/dist/commands/scope.test.js.map +1 -0
  83. package/dist/commands/setup.d.ts +31 -0
  84. package/dist/commands/setup.d.ts.map +1 -0
  85. package/dist/commands/setup.js +93 -0
  86. package/dist/commands/setup.js.map +1 -0
  87. package/dist/commands/waivers.d.ts +47 -0
  88. package/dist/commands/waivers.d.ts.map +1 -0
  89. package/dist/commands/waivers.js +68 -0
  90. package/dist/commands/waivers.js.map +1 -0
  91. package/dist/commands/waivers.test.d.ts +2 -0
  92. package/dist/commands/waivers.test.d.ts.map +1 -0
  93. package/dist/commands/waivers.test.js +64 -0
  94. package/dist/commands/waivers.test.js.map +1 -0
  95. package/dist/generate/agent.d.ts +19 -0
  96. package/dist/generate/agent.d.ts.map +1 -0
  97. package/dist/generate/agent.js +35 -0
  98. package/dist/generate/agent.js.map +1 -0
  99. package/dist/generate/agent.test.d.ts +2 -0
  100. package/dist/generate/agent.test.d.ts.map +1 -0
  101. package/dist/generate/agent.test.js +245 -0
  102. package/dist/generate/agent.test.js.map +1 -0
  103. package/dist/generate/commands.d.ts +15 -0
  104. package/dist/generate/commands.d.ts.map +1 -0
  105. package/dist/generate/commands.js +59 -0
  106. package/dist/generate/commands.js.map +1 -0
  107. package/dist/generate/lock.d.ts +18 -0
  108. package/dist/generate/lock.d.ts.map +1 -0
  109. package/dist/generate/lock.js +26 -0
  110. package/dist/generate/lock.js.map +1 -0
  111. package/dist/generate/prompt.d.ts +11 -0
  112. package/dist/generate/prompt.d.ts.map +1 -0
  113. package/dist/generate/prompt.js +114 -0
  114. package/dist/generate/prompt.js.map +1 -0
  115. package/dist/generate/prompt.test.d.ts +2 -0
  116. package/dist/generate/prompt.test.d.ts.map +1 -0
  117. package/dist/generate/prompt.test.js +59 -0
  118. package/dist/generate/prompt.test.js.map +1 -0
  119. package/dist/index.d.ts +40 -0
  120. package/dist/index.d.ts.map +1 -0
  121. package/dist/index.js +448 -0
  122. package/dist/index.js.map +1 -0
  123. package/dist/index.test.d.ts +2 -0
  124. package/dist/index.test.d.ts.map +1 -0
  125. package/dist/index.test.js +237 -0
  126. package/dist/index.test.js.map +1 -0
  127. package/dist/opencode.d.ts +29 -0
  128. package/dist/opencode.d.ts.map +1 -0
  129. package/dist/opencode.js +125 -0
  130. package/dist/opencode.js.map +1 -0
  131. package/dist/plugin-bundle.test.d.ts +2 -0
  132. package/dist/plugin-bundle.test.d.ts.map +1 -0
  133. package/dist/plugin-bundle.test.js +43 -0
  134. package/dist/plugin-bundle.test.js.map +1 -0
  135. package/dist/plugin.d.ts +11 -0
  136. package/dist/plugin.d.ts.map +1 -0
  137. package/dist/plugin.js +46 -0
  138. package/dist/plugin.js.map +1 -0
  139. package/dist/quality-skills.test.d.ts +2 -0
  140. package/dist/quality-skills.test.d.ts.map +1 -0
  141. package/dist/quality-skills.test.js +141 -0
  142. package/dist/quality-skills.test.js.map +1 -0
  143. package/dist/report/footprint.d.ts +12 -0
  144. package/dist/report/footprint.d.ts.map +1 -0
  145. package/dist/report/footprint.js +121 -0
  146. package/dist/report/footprint.js.map +1 -0
  147. package/dist/report/ledger.d.ts +27 -0
  148. package/dist/report/ledger.d.ts.map +1 -0
  149. package/dist/report/ledger.js +197 -0
  150. package/dist/report/ledger.js.map +1 -0
  151. package/dist/report/oversight.d.ts +21 -0
  152. package/dist/report/oversight.d.ts.map +1 -0
  153. package/dist/report/oversight.js +146 -0
  154. package/dist/report/oversight.js.map +1 -0
  155. package/dist/report/policy-firing.test.d.ts +2 -0
  156. package/dist/report/policy-firing.test.d.ts.map +1 -0
  157. package/dist/report/policy-firing.test.js +68 -0
  158. package/dist/report/policy-firing.test.js.map +1 -0
  159. package/dist/report/scope.d.ts +64 -0
  160. package/dist/report/scope.d.ts.map +1 -0
  161. package/dist/report/scope.js +146 -0
  162. package/dist/report/scope.js.map +1 -0
  163. package/dist/who.d.ts +9 -0
  164. package/dist/who.d.ts.map +1 -0
  165. package/dist/who.js +11 -0
  166. package/dist/who.js.map +1 -0
  167. package/package.json +54 -4
  168. package/plugin/openagentsy.js +39478 -0
@@ -0,0 +1,768 @@
1
+ import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, relative } from 'node:path';
4
+ import { agentIdOf, closeDatabase, listLeases, loadRoles, openDatabase, } from '@openagentsy/core';
5
+ import { parse as parseJsonc } from 'jsonc-parser';
6
+ import { parse as parseYaml } from 'yaml';
7
+ import { agentFileName } from '../generate/agent.js';
8
+ import { lockEntryFor, readModelLock } from '../generate/lock.js';
9
+ import { detectOpenCode, findConfigPaths, findSkills, } from '../opencode.js';
10
+ import { findPluginBundle } from '../plugin.js';
11
+ import { runBuild } from './build.js';
12
+ function asRecord(value) {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
14
+ ? value
15
+ : {};
16
+ }
17
+ function listMcpServers(config) {
18
+ const mcp = asRecord(config.mcp);
19
+ const servers = asRecord(mcp.servers);
20
+ const names = new Set([
21
+ ...Object.keys(servers),
22
+ ...Object.keys(mcp).filter((key) => key !== 'servers'),
23
+ ]);
24
+ return [...names].sort();
25
+ }
26
+ async function readProjectConfig(root) {
27
+ const paths = await findConfigPaths(root);
28
+ for (const path of paths) {
29
+ let text;
30
+ try {
31
+ text = await readFile(path, 'utf8');
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ const config = asRecord(parseJsonc(text));
37
+ const compaction = asRecord(config.compaction);
38
+ const plugins = config.plugin ?? config.plugins;
39
+ return {
40
+ path,
41
+ paths,
42
+ exists: true,
43
+ compactionAuto: compaction.auto,
44
+ compactionPrune: compaction.prune,
45
+ mcpServers: listMcpServers(config),
46
+ plugins: Array.isArray(plugins) ? plugins.map(String) : [],
47
+ };
48
+ }
49
+ return {
50
+ path: undefined,
51
+ paths,
52
+ exists: false,
53
+ compactionAuto: undefined,
54
+ compactionPrune: undefined,
55
+ mcpServers: [],
56
+ plugins: [],
57
+ };
58
+ }
59
+ /**
60
+ * The five files that decide how this project is configured.
61
+ *
62
+ * The order is the order of authority: a person's roles.yaml outranks the
63
+ * host's config, and .beans.yml outranks the Phase 2 keys, so the two tools
64
+ * write the same task ids.
65
+ */
66
+ const AUTHORITIES = [
67
+ {
68
+ file: 'roles.yaml',
69
+ authority: 'roles, permission maps, guidance',
70
+ writtenBy: 'a person; the generator consumes it',
71
+ protected: true,
72
+ },
73
+ {
74
+ file: 'opencode.json',
75
+ authority: 'the host: plugins, permissions, compaction',
76
+ writtenBy: 'a person; setup warns on an overlap',
77
+ protected: true,
78
+ },
79
+ {
80
+ file: 'openagentsy.jsonc',
81
+ authority: 'the Phase 2 keys',
82
+ writtenBy: 'a person',
83
+ protected: true,
84
+ },
85
+ {
86
+ file: '.beans.yml',
87
+ authority: 'beans interop: prefix, id length, path',
88
+ writtenBy: 'a person',
89
+ protected: false,
90
+ },
91
+ {
92
+ file: 'openagentsy.models.lock.json',
93
+ authority: 'the model lock',
94
+ writtenBy: 'openagentsy models init',
95
+ protected: true,
96
+ },
97
+ ];
98
+ /**
99
+ * The first-party documents, and what each one is for.
100
+ *
101
+ * They live at the root of the project because they are the project's own:
102
+ * the stance, the controls and the process the framework is held to. The
103
+ * doctor reports whether the project carries them and which version they are
104
+ * at, so a project that has adopted them can show that it has.
105
+ */
106
+ const FIRST_PARTY_DOCUMENTS = [
107
+ { file: 'ETHICS.md', what: 'the ethical stance' },
108
+ { file: 'SAFETY.md', what: 'the safety controls' },
109
+ { file: 'GOVERNANCE.md', what: 'the governance process' },
110
+ { file: 'CHANGELOG-SAFETY.md', what: 'the safety changelog' },
111
+ ];
112
+ /** The Phase 2 keys, as they are when nobody says otherwise. */
113
+ const PHASE2_DEFAULTS = {
114
+ tasks: { path: '.beans', prefix: 'oa-', idLength: 4 },
115
+ memory: { maxTextChars: 280, inboxLimit: 50 },
116
+ footprint: {
117
+ showWarming: true,
118
+ prefill: false,
119
+ gridRegion: 'auto',
120
+ localDeviceWatts: [25, 60],
121
+ },
122
+ };
123
+ function stringOr(value, fallback) {
124
+ return typeof value === 'string' ? value : fallback;
125
+ }
126
+ function booleanOr(value, fallback) {
127
+ return typeof value === 'boolean' ? value : fallback;
128
+ }
129
+ function numberOr(value, fallback) {
130
+ const found = firstNumber(value);
131
+ return found ?? fallback;
132
+ }
133
+ function firstNumber(...values) {
134
+ for (const value of values) {
135
+ if (typeof value === 'number' && Number.isFinite(value))
136
+ return value;
137
+ }
138
+ return undefined;
139
+ }
140
+ function rangeOr(value, fallback) {
141
+ if (!Array.isArray(value) || value.length !== 2)
142
+ return fallback;
143
+ const lo = value[0];
144
+ const hi = value[1];
145
+ if (typeof lo !== 'number' || typeof hi !== 'number')
146
+ return fallback;
147
+ return [lo, hi];
148
+ }
149
+ async function fileExists(path) {
150
+ return stat(path).then(() => true, () => false);
151
+ }
152
+ /** A JSONC file, read the way a person writes one: with comments. */
153
+ async function readJsoncFile(path) {
154
+ try {
155
+ const text = await readFile(path, 'utf8');
156
+ return asRecord(parseJsonc(text));
157
+ }
158
+ catch {
159
+ return undefined;
160
+ }
161
+ }
162
+ async function readYamlFile(path) {
163
+ try {
164
+ const text = await readFile(path, 'utf8');
165
+ return asRecord(parseYaml(text));
166
+ }
167
+ catch {
168
+ return undefined;
169
+ }
170
+ }
171
+ /**
172
+ * The Phase 2 keys, with the defaults filled in.
173
+ *
174
+ * .beans.yml decides the task ids, so it outranks the tasks.* keys of
175
+ * openagentsy.jsonc. The two tools then write the same ids.
176
+ */
177
+ function effectiveConfig(agentsy, beans) {
178
+ const tasks = asRecord(agentsy.tasks);
179
+ const beansKeys = asRecord(beans.beans);
180
+ const memory = asRecord(agentsy.memory);
181
+ const footprint = asRecord(agentsy.footprint);
182
+ return {
183
+ tasks: {
184
+ path: stringOr(beansKeys.path, stringOr(tasks.path, PHASE2_DEFAULTS.tasks.path)),
185
+ prefix: stringOr(beansKeys.prefix, stringOr(tasks.prefix, PHASE2_DEFAULTS.tasks.prefix)),
186
+ idLength: firstNumber(beansKeys.id_length, beansKeys.idLength, tasks.idLength) ??
187
+ PHASE2_DEFAULTS.tasks.idLength,
188
+ },
189
+ memory: {
190
+ maxTextChars: numberOr(memory.maxTextChars, PHASE2_DEFAULTS.memory.maxTextChars),
191
+ inboxLimit: numberOr(memory.inboxLimit, PHASE2_DEFAULTS.memory.inboxLimit),
192
+ },
193
+ footprint: {
194
+ showWarming: booleanOr(footprint.showWarming, PHASE2_DEFAULTS.footprint.showWarming),
195
+ prefill: booleanOr(footprint.prefill, PHASE2_DEFAULTS.footprint.prefill),
196
+ gridRegion: stringOr(footprint.gridRegion, PHASE2_DEFAULTS.footprint.gridRegion),
197
+ localDeviceWatts: rangeOr(footprint.localDeviceWatts, PHASE2_DEFAULTS.footprint.localDeviceWatts),
198
+ models: Object.keys(asRecord(footprint.models)).sort(),
199
+ },
200
+ };
201
+ }
202
+ /** The places where two files answer the same question. */
203
+ function configOverlaps(authorities, hasAgentsy, hasBeans, opencodePaths) {
204
+ const overlaps = [];
205
+ const rolesExist = authorities.some((authority) => authority.path === 'roles.yaml' && authority.exists);
206
+ if (opencodePaths.length > 1) {
207
+ overlaps.push(`OpenCode merges ${opencodePaths.length} config files: ${opencodePaths.join(', ')}.`);
208
+ }
209
+ if (rolesExist && opencodePaths.length > 0) {
210
+ overlaps.push('roles.yaml and the OpenCode config both carry permissions; the roles in roles.yaml are the ones this project generates.');
211
+ }
212
+ if (hasAgentsy && hasBeans) {
213
+ overlaps.push('openagentsy.jsonc and .beans.yml both decide the task ids; .beans.yml wins.');
214
+ }
215
+ return overlaps;
216
+ }
217
+ /**
218
+ * The map of the five authorities, and the Phase 2 keys in force.
219
+ *
220
+ * A file that is not there is still part of the system, because a person
221
+ * may write it later, so the map says so.
222
+ */
223
+ async function readConfigMap(root, opencodePaths) {
224
+ const agentsy = await readJsoncFile(join(root, 'openagentsy.jsonc'));
225
+ const beans = await readYamlFile(join(root, '.beans.yml'));
226
+ const authorities = [];
227
+ for (const entry of AUTHORITIES) {
228
+ const isHost = entry.file === 'opencode.json';
229
+ authorities.push({
230
+ path: isHost && opencodePaths.length > 0 ? opencodePaths.join(', ') : entry.file,
231
+ exists: isHost ? opencodePaths.length > 0 : await fileExists(join(root, entry.file)),
232
+ authority: entry.authority,
233
+ writtenBy: entry.writtenBy,
234
+ protected: entry.protected,
235
+ });
236
+ }
237
+ return {
238
+ authorities,
239
+ effective: effectiveConfig(agentsy ?? {}, beans ?? {}),
240
+ overlaps: configOverlaps(authorities, agentsy !== undefined, beans !== undefined, opencodePaths),
241
+ };
242
+ }
243
+ /**
244
+ * The version a document declares.
245
+ *
246
+ * The marker is the same in all four documents, so the doctor reads one
247
+ * line and does not have to know what each document is.
248
+ */
249
+ function documentVersion(content) {
250
+ const match = /^\*\*Version:\*\*\s+(\S+)\s*$/m.exec(content);
251
+ return match?.[1];
252
+ }
253
+ /**
254
+ * The first-party documents of a project.
255
+ *
256
+ * A document that is not there is reported as missing, and a document that
257
+ * cannot be read is reported as missing too: the doctor describes what it
258
+ * can see, and never presents a file it could not read as present.
259
+ */
260
+ async function readDocuments(root) {
261
+ const documents = [];
262
+ for (const entry of FIRST_PARTY_DOCUMENTS) {
263
+ const path = join(root, entry.file);
264
+ const content = await readFile(path, 'utf8').catch(() => undefined);
265
+ documents.push({
266
+ file: entry.file,
267
+ what: entry.what,
268
+ path,
269
+ exists: content !== undefined,
270
+ version: content === undefined ? undefined : documentVersion(content),
271
+ });
272
+ }
273
+ return { documents, version: sharedVersion(documents) };
274
+ }
275
+ /**
276
+ * The version the documents agree on, when they all declare the same one.
277
+ *
278
+ * A document without a version breaks the agreement: one file that does not
279
+ * say which version it is at means the project cannot be said to be at a
280
+ * version at all.
281
+ */
282
+ function sharedVersion(documents) {
283
+ const present = documents.filter((document) => document.exists);
284
+ if (present.length === 0)
285
+ return undefined;
286
+ const declared = present.map((document) => document.version);
287
+ if (declared.some((version) => version === undefined))
288
+ return undefined;
289
+ const unique = new Set(declared);
290
+ return unique.size === 1 ? [...unique][0] : undefined;
291
+ }
292
+ /**
293
+ * What is wrong with the first-party documents of a project.
294
+ *
295
+ * A project that carries none of them has not adopted the framework's
296
+ * documents, which is a fact about the project and not a fault in it. A
297
+ * project that carries some of them and is missing another has an incomplete
298
+ * set, and that is a finding.
299
+ */
300
+ function documentWarnings(report) {
301
+ const warnings = [];
302
+ const present = report.documents.filter((document) => document.exists);
303
+ if (present.length === 0)
304
+ return warnings;
305
+ for (const document of report.documents) {
306
+ if (!document.exists) {
307
+ warnings.push(`${document.file} is missing, and the project carries the other first-party documents`);
308
+ }
309
+ else if (document.version === undefined) {
310
+ warnings.push(`${document.file} has no version line, so the doctor cannot say which version it is at`);
311
+ }
312
+ }
313
+ const versions = new Set(present.map((document) => document.version).filter((version) => version !== undefined));
314
+ if (versions.size > 1) {
315
+ const detail = present
316
+ .map((document) => `${document.file} ${document.version ?? 'none'}`)
317
+ .join(', ');
318
+ warnings.push(`the first-party documents are at different versions: ${detail}`);
319
+ }
320
+ return warnings;
321
+ }
322
+ async function countAuditLines(path) {
323
+ try {
324
+ const text = await readFile(path, 'utf8');
325
+ return text.split('\n').filter((line) => line.trim().length > 0).length;
326
+ }
327
+ catch {
328
+ return 0;
329
+ }
330
+ }
331
+ /**
332
+ * What the compaction settings say.
333
+ *
334
+ * Compaction off is right for a memory integration that shrinks the context
335
+ * itself, and there is no such integration yet, so a long Lead session grows
336
+ * without a bound.
337
+ */
338
+ function compactionWarnings(config) {
339
+ const warnings = [];
340
+ if (config.compactionAuto === true) {
341
+ warnings.push('compaction.auto is true. Magic Context requires compaction to be off.');
342
+ }
343
+ if (config.compactionPrune === true) {
344
+ warnings.push('compaction.prune is true, it removes tool output the Lead still needs.');
345
+ }
346
+ if (config.compactionAuto === false) {
347
+ warnings.push('compaction is off and no memory integration is installed, so a long Lead session grows without a bound. Run: openagentsy setup (without --magic-context), or accept the tradeoff.');
348
+ }
349
+ return warnings;
350
+ }
351
+ /**
352
+ * The leases that may belong to a session that is gone.
353
+ *
354
+ * A lease is only ever released by the agent that holds it, so a session
355
+ * that died holding one holds its files until the Lead revokes it. A lease
356
+ * that is a week old is worth saying out loud.
357
+ */
358
+ function staleLeaseWarnings(held) {
359
+ const week = 7 * 24 * 60 * 60 * 1000;
360
+ const warnings = [];
361
+ for (const lease of held) {
362
+ const age = Date.now() - new Date(lease.grantedAt).getTime();
363
+ if (age <= week)
364
+ continue;
365
+ const days = Math.floor(age / (24 * 60 * 60 * 1000));
366
+ warnings.push(`a lease on ${lease.glob} was taken ${days} days ago and may be a dead session's. The Lead can take it back with board_lease_revoke.`);
367
+ }
368
+ return warnings;
369
+ }
370
+ /** The command that reconciles the generated agent files. */
371
+ const RECONCILE_BUILD = 'openagentsy build';
372
+ /**
373
+ * The agent files of a directory, by name.
374
+ *
375
+ * A directory that cannot be read is a failure and not an empty tree: an
376
+ * empty tree would say every generated file is missing.
377
+ */
378
+ async function agentFiles(dir) {
379
+ const files = new Map();
380
+ for (const name of await readdir(dir)) {
381
+ if (!name.endsWith('.md'))
382
+ continue;
383
+ files.set(name, await readFile(join(dir, name), 'utf8'));
384
+ }
385
+ return files;
386
+ }
387
+ /** The target one agent file was built for, when its frontmatter says. */
388
+ function targetOfAgentFile(content) {
389
+ const match = /^---\n([\s\S]*?)\n---/.exec(content);
390
+ if (match === null)
391
+ return undefined;
392
+ const frontmatter = asRecord(parseYaml(match[1] ?? ''));
393
+ if (frontmatter.permissions !== undefined)
394
+ return 'v2';
395
+ return undefined;
396
+ }
397
+ /**
398
+ * The targets the installed files say they were built for.
399
+ *
400
+ * The doctor cannot recover the target a project was built for from the
401
+ * OpenCode it finds on PATH: a project built for v2 on a machine with v1
402
+ * installed is not a drifted tree, it is a v2 tree, and rendering it for the
403
+ * wrong target would flag every file. The installed files say which target
404
+ * they are, so the files are what the check asks.
405
+ */
406
+ function installedTargets(files) {
407
+ const targets = new Set();
408
+ for (const content of files.values()) {
409
+ const target = targetOfAgentFile(content);
410
+ if (target !== undefined)
411
+ targets.add(target);
412
+ }
413
+ return targets;
414
+ }
415
+ /**
416
+ * The target the installed files name, and the reason when they do not.
417
+ *
418
+ * A tree whose target cannot be read is a tree the check cannot judge: the
419
+ * skip is reported, never presented as a clean tree.
420
+ */
421
+ function judgeTarget(files) {
422
+ const targets = installedTargets(files);
423
+ if (targets.size === 1) {
424
+ const [target] = targets;
425
+ return { target };
426
+ }
427
+ return {
428
+ target: undefined,
429
+ error: 'no installed agent file says which target it was built for',
430
+ };
431
+ }
432
+ /**
433
+ * The generated files that disagree with a fresh render of the roles.
434
+ *
435
+ * The render happens in a temporary directory: the doctor never writes into
436
+ * the project it is describing, because the person who runs it may be about
437
+ * to change that project.
438
+ */
439
+ async function regenerationDiff(roots, ours) {
440
+ const installedDir = join(roots.workspace, '.opencode', 'agents');
441
+ // A tree that was never built has nothing to drift from, and the setup
442
+ // warning already says the plugin is not installed.
443
+ if (!(await fileExists(installedDir)))
444
+ return { drifted: [], target: undefined };
445
+ const installed = await agentFiles(installedDir);
446
+ // A tree with no agent files has nothing to drift from.
447
+ if (installed.size === 0)
448
+ return { drifted: [], target: undefined };
449
+ // The check cannot judge a tree whose target cannot be read: rendering it
450
+ // for the wrong target would flag every file, and saying nothing would say
451
+ // the tree is clean. A skip is reported as a failure.
452
+ const verdict = judgeTarget(installed);
453
+ if (verdict.target === undefined) {
454
+ return { drifted: [], target: undefined, error: verdict.error };
455
+ }
456
+ const target = verdict.target;
457
+ const temp = await mkdtemp(join(tmpdir(), 'openagentsy-regeneration-'));
458
+ try {
459
+ await runBuild({ workspace: roots.workspace, stateRoot: roots.stateRoot, outDir: temp });
460
+ const fresh = await agentFiles(join(temp, 'agents'));
461
+ const drifted = [];
462
+ for (const [name, content] of fresh) {
463
+ const current = installed.get(name);
464
+ if (current === undefined)
465
+ drifted.push(`${name} is missing`);
466
+ else if (current !== content)
467
+ drifted.push(`${name} differs from a fresh render`);
468
+ }
469
+ for (const name of installed.keys()) {
470
+ if (fresh.has(name))
471
+ continue;
472
+ // A file the generator does not write is only a drift when it is one
473
+ // of ours: a person may keep their own agent file beside ours.
474
+ if (!ours.has(name) && !name.startsWith('oa-'))
475
+ continue;
476
+ drifted.push(`${name} is stale: the generator does not write it any more`);
477
+ }
478
+ return { drifted, target };
479
+ }
480
+ finally {
481
+ await rm(temp, { recursive: true, force: true });
482
+ }
483
+ }
484
+ /**
485
+ * The comparison, and what a failure of it costs.
486
+ *
487
+ * A doctor that cannot run the comparison says so rather than pretending
488
+ * the tree is clean.
489
+ */
490
+ async function regenerationReport(roots, roles) {
491
+ const ours = new Set(Object.values(roles.roles).map((role) => agentFileName(role)));
492
+ try {
493
+ const diff = await regenerationDiff(roots, ours);
494
+ return {
495
+ drifted: diff.drifted,
496
+ reconcile: diff.target === undefined ? RECONCILE_BUILD : `${RECONCILE_BUILD} --target ${diff.target}`,
497
+ ...(diff.error === undefined ? {} : { error: diff.error }),
498
+ };
499
+ }
500
+ catch (error) {
501
+ return {
502
+ drifted: [],
503
+ reconcile: RECONCILE_BUILD,
504
+ error: error instanceof Error ? error.message : String(error),
505
+ };
506
+ }
507
+ }
508
+ /**
509
+ * The doctor, as a person reads it.
510
+ *
511
+ * The report has two halves and they have two roots. The project half — the
512
+ * config, the roles, the skills and the installed plugin — is the
513
+ * workspace's, because those are the files of the project. The state half —
514
+ * the model lock, the board and the audit log — is the state root's, because
515
+ * those are the files of the project as a whole.
516
+ */
517
+ export async function runDoctor(roots) {
518
+ const { workspace, stateRoot } = roots;
519
+ const warnings = [];
520
+ const version = await detectOpenCode();
521
+ const target = 'v2';
522
+ if (!version) {
523
+ warnings.push('opencode binary not found on PATH, assuming v2 layout');
524
+ }
525
+ const config = await readProjectConfig(workspace);
526
+ const configMap = await readConfigMap(workspace, config.paths);
527
+ const documents = await readDocuments(workspace);
528
+ warnings.push(...compactionWarnings(config));
529
+ warnings.push(...documentWarnings(documents));
530
+ if (!config.exists) {
531
+ warnings.push('no project config found, run: openagentsy setup');
532
+ }
533
+ if (!config.mcpServers.includes('openagentsy')) {
534
+ warnings.push('the openagentsy MCP server is not registered in the project config');
535
+ }
536
+ const roles = await loadRoles(join(workspace, 'roles.yaml'));
537
+ // The model lock is the state root's: it is the choice of models the
538
+ // project made, and a worktree of the project has the same lock.
539
+ const lock = await readModelLock(join(stateRoot, 'openagentsy.models.lock.json'));
540
+ const regeneration = await regenerationReport(roots, roles);
541
+ for (const entry of regeneration.drifted) {
542
+ warnings.push(`the generated agent file ${entry}, run: ${regeneration.reconcile} to reconcile`);
543
+ }
544
+ if (regeneration.error !== undefined) {
545
+ warnings.push(`the regeneration check did not run: ${regeneration.error}`);
546
+ }
547
+ const roleReports = Object.values(roles.roles).map((role) => {
548
+ const entry = lockEntryFor(lock, role.name);
549
+ return {
550
+ role: role.name,
551
+ agent: agentIdOf(role),
552
+ model: entry?.model ?? 'default (no lock file)',
553
+ evidence: entry?.evidence
554
+ ? `${entry.evidence.source}${entry.evidence.label ? ` [${entry.evidence.label}]` : ''}`
555
+ : 'none',
556
+ };
557
+ });
558
+ if (!lock) {
559
+ warnings.push('no model lock file, run: openagentsy models init');
560
+ }
561
+ const skills = await findSkills(workspace);
562
+ // The board and the audit log are the state root's.
563
+ const stateDir = join(stateRoot, '.openagentsy');
564
+ const databasePath = join(stateDir, 'board.sqlite');
565
+ const auditPath = join(stateDir, 'audit.jsonl');
566
+ const databaseExists = await stat(databasePath).then(() => true, () => false);
567
+ // The leases live in the database now; the doctor counts them the way the
568
+ // board does, without opening a second connection for long.
569
+ let leaseCount = 0;
570
+ if (databaseExists) {
571
+ const db = openDatabase(databasePath);
572
+ try {
573
+ const held = listLeases(db);
574
+ leaseCount = held.length;
575
+ warnings.push(...staleLeaseWarnings(held));
576
+ }
577
+ finally {
578
+ closeDatabase(db);
579
+ }
580
+ }
581
+ const bundlePath = await findPluginBundle();
582
+ const pluginPath = join(workspace, '.opencode', 'plugins', 'openagentsy.js');
583
+ const pluginInstalled = await stat(pluginPath).then(() => true, () => false);
584
+ const bundleBuilt = bundlePath !== undefined;
585
+ if (!pluginInstalled) {
586
+ warnings.push('the OpenAgentsy plugin is not installed, run: openagentsy setup');
587
+ }
588
+ return {
589
+ opencode: { version, target },
590
+ config,
591
+ configMap,
592
+ plugin: {
593
+ path: pluginPath,
594
+ installed: pluginInstalled,
595
+ bundlePath: bundlePath ?? '',
596
+ bundleBuilt,
597
+ },
598
+ roles: roleReports,
599
+ skills,
600
+ board: {
601
+ root: relative(stateRoot, stateDir) || '.openagentsy',
602
+ databasePath,
603
+ databaseExists,
604
+ leases: leaseCount,
605
+ auditPath,
606
+ auditExists: await stat(auditPath).then(() => true, () => false),
607
+ auditEntries: await countAuditLines(auditPath),
608
+ },
609
+ regeneration,
610
+ documents,
611
+ warnings,
612
+ };
613
+ }
614
+ function field(label, value) {
615
+ return ` ${label.padEnd(12)}${value}`;
616
+ }
617
+ /**
618
+ * The lines about the project config.
619
+ *
620
+ * OpenCode merges every config it finds, so the ones the doctor did not
621
+ * read are part of the system too.
622
+ */
623
+ function configLines(config) {
624
+ const lines = [field('path', config.path ?? 'not found')];
625
+ if (config.paths.length > 1) {
626
+ lines.push(field('merged', config.paths.join(', ')));
627
+ }
628
+ lines.push(field('compaction', `auto=${String(config.compactionAuto ?? 'unset')} prune=${String(config.compactionPrune ?? 'unset')}`));
629
+ lines.push(field('mcp', config.mcpServers.join(', ') || 'none'));
630
+ lines.push(field('plugins', config.plugins.join(', ') || 'none'));
631
+ return lines;
632
+ }
633
+ /**
634
+ * The lines about the config map.
635
+ *
636
+ * A file that is not there is still part of the system, so the map says
637
+ * "missing" instead of leaving the reader to guess.
638
+ */
639
+ function configMapLines(map) {
640
+ const lines = [];
641
+ for (const authority of map.authorities) {
642
+ lines.push(` ${authority.path}`);
643
+ lines.push(` ${authority.exists ? 'found' : 'missing'} - ${authority.authority} (written by ${authority.writtenBy}${authority.protected ? ', protected' : ''})`);
644
+ }
645
+ return lines;
646
+ }
647
+ /** The lines about the Phase 2 keys in force. */
648
+ function phase2Lines(effective) {
649
+ const key = (label, value) => ` ${label.padEnd(24)}${value}`;
650
+ const watts = effective.footprint.localDeviceWatts;
651
+ return [
652
+ key('tasks.path', effective.tasks.path),
653
+ key('tasks.prefix', effective.tasks.prefix),
654
+ key('tasks.idLength', String(effective.tasks.idLength)),
655
+ key('memory.maxTextChars', String(effective.memory.maxTextChars)),
656
+ key('memory.inboxLimit', String(effective.memory.inboxLimit)),
657
+ key('footprint.showWarming', String(effective.footprint.showWarming)),
658
+ key('footprint.prefill', String(effective.footprint.prefill)),
659
+ key('footprint.gridRegion', effective.footprint.gridRegion),
660
+ key('footprint.localDeviceWatts', `${watts[0]}-${watts[1]} W`),
661
+ key('footprint.models', effective.footprint.models.join(', ') || 'none'),
662
+ ];
663
+ }
664
+ /**
665
+ * The lines about the first-party documents.
666
+ *
667
+ * A document that is not there says so, because a reader who is told a
668
+ * document is present must be able to rely on it.
669
+ */
670
+ function documentLines(report) {
671
+ const lines = ['First-party documents'];
672
+ for (const document of report.documents) {
673
+ const state = !document.exists
674
+ ? 'missing'
675
+ : document.version === undefined
676
+ ? 'found, no version'
677
+ : `found, version ${document.version}`;
678
+ lines.push(` ${document.file.padEnd(20)}${state} - ${document.what}`);
679
+ }
680
+ if (report.documents.every((document) => !document.exists)) {
681
+ lines.push(' none of the first-party documents are in this project');
682
+ }
683
+ return lines;
684
+ }
685
+ /**
686
+ * The lines about the generated files that disagree with a fresh render.
687
+ *
688
+ * A tree the generator wrote has nothing to say here, so the section is
689
+ * only there when something drifted or the check could not run.
690
+ */
691
+ function regenerationLines(report) {
692
+ const lines = ['Regeneration'];
693
+ lines.push(field('drifted', `${report.drifted.length} file(s), run: ${report.reconcile}`));
694
+ for (const entry of report.drifted) {
695
+ lines.push(` - ${entry}`);
696
+ }
697
+ if (report.error !== undefined) {
698
+ lines.push(field('error', report.error));
699
+ }
700
+ return lines;
701
+ }
702
+ /** Whether the report has something to say about the generated files. */
703
+ function hasRegenerationFinding(report) {
704
+ return report.drifted.length > 0 || report.error !== undefined;
705
+ }
706
+ export function formatDoctorReport(report) {
707
+ const lines = [];
708
+ lines.push('OpenCode');
709
+ if (report.opencode.version) {
710
+ lines.push(field('version', report.opencode.version.raw));
711
+ lines.push(field('target', report.opencode.target));
712
+ }
713
+ else {
714
+ lines.push(field('version', 'not found'));
715
+ }
716
+ lines.push('');
717
+ lines.push('Project config');
718
+ lines.push(...configLines(report.config));
719
+ lines.push('');
720
+ lines.push('Config map');
721
+ lines.push(...configMapLines(report.configMap));
722
+ lines.push('');
723
+ lines.push('Phase 2 keys');
724
+ lines.push(...phase2Lines(report.configMap.effective));
725
+ if (report.configMap.overlaps.length > 0) {
726
+ lines.push('');
727
+ lines.push('Overlaps');
728
+ for (const overlap of report.configMap.overlaps) {
729
+ lines.push(` - ${overlap}`);
730
+ }
731
+ }
732
+ lines.push('');
733
+ lines.push(...documentLines(report.documents));
734
+ if (hasRegenerationFinding(report.regeneration)) {
735
+ lines.push('');
736
+ lines.push(...regenerationLines(report.regeneration));
737
+ }
738
+ if (report.warnings.length > 0) {
739
+ lines.push('');
740
+ lines.push('Warnings');
741
+ for (const warning of report.warnings) {
742
+ lines.push(` - ${warning}`);
743
+ }
744
+ }
745
+ lines.push('');
746
+ lines.push('Plugin');
747
+ lines.push(field('installed', report.plugin.installed ? report.plugin.path : 'not installed'));
748
+ if (!report.plugin.bundleBuilt) {
749
+ lines.push(field('bundle', 'not built, run: bun run build'));
750
+ }
751
+ lines.push('');
752
+ lines.push('Roles');
753
+ for (const role of report.roles) {
754
+ lines.push(` ${role.agent.padEnd(14)}${role.model} (${role.evidence})`);
755
+ }
756
+ lines.push('');
757
+ lines.push('Board');
758
+ lines.push(field('database', `${report.board.databasePath} ${report.board.databaseExists ? 'found' : 'missing'}`));
759
+ lines.push(field('leases', `${report.board.leases} lease(s)`));
760
+ lines.push(field('audit', `${report.board.auditPath} ${report.board.auditEntries} entries`));
761
+ lines.push('');
762
+ lines.push(`Skills (${report.skills.length} found)`);
763
+ for (const skill of report.skills) {
764
+ lines.push(` ${skill.name.padEnd(28)}${skill.path}`);
765
+ }
766
+ return `${lines.join('\n')}\n`;
767
+ }
768
+ //# sourceMappingURL=doctor.js.map