@b00dea/cockpit 0.1.0 → 0.1.1

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 (196) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +3 -0
  3. package/cockpit.schema.json +73 -3
  4. package/dist/autonomy.d.ts +3 -2
  5. package/dist/autonomy.js +30 -13
  6. package/dist/browser/commands.d.ts +57 -2
  7. package/dist/browser/commands.js +114 -10
  8. package/dist/browser/runtime.d.ts +1 -0
  9. package/dist/browser/runtime.js +4 -1
  10. package/dist/browser/surfaces.d.ts +7 -7
  11. package/dist/browser/surfaces.js +5 -0
  12. package/dist/canonical-json.d.ts +2 -0
  13. package/dist/canonical-json.js +5 -0
  14. package/dist/cli-main.d.ts +40 -3
  15. package/dist/cli-main.js +241 -62
  16. package/dist/command-table.d.ts +19 -11
  17. package/dist/command-table.js +190 -38
  18. package/dist/config/load.d.ts +16 -4
  19. package/dist/config/load.js +25 -3
  20. package/dist/config/unconfirmed-toml.d.ts +9 -0
  21. package/dist/config/unconfirmed-toml.js +29 -0
  22. package/dist/diagnostics.js +10 -1
  23. package/dist/docs/check.d.ts +16 -0
  24. package/dist/docs/check.js +197 -0
  25. package/dist/docs/decision-record.d.ts +34 -0
  26. package/dist/docs/decision-record.js +135 -0
  27. package/dist/docs/decisions.d.ts +16 -0
  28. package/dist/docs/decisions.js +103 -0
  29. package/dist/docs/markdown.d.ts +36 -0
  30. package/dist/docs/markdown.js +88 -0
  31. package/dist/docs/references.d.ts +32 -0
  32. package/dist/docs/references.js +148 -0
  33. package/dist/environment/application/confirm.d.ts +35 -0
  34. package/dist/environment/application/confirm.js +122 -0
  35. package/dist/environment/application/destructive.js +6 -2
  36. package/dist/environment/application/doctor.d.ts +12 -7
  37. package/dist/environment/application/doctor.js +20 -8
  38. package/dist/environment/application/guard.d.ts +12 -6
  39. package/dist/environment/application/guard.js +87 -32
  40. package/dist/environment/application/health.d.ts +14 -5
  41. package/dist/environment/application/health.js +8 -2
  42. package/dist/environment/application/lifecycle-context.d.ts +10 -3
  43. package/dist/environment/application/lifecycle-state.js +4 -3
  44. package/dist/environment/application/lifecycle.d.ts +2 -1
  45. package/dist/environment/application/lifecycle.js +11 -3
  46. package/dist/environment/application/services.d.ts +60 -0
  47. package/dist/environment/application/services.js +89 -0
  48. package/dist/environment/application/siblings.d.ts +43 -0
  49. package/dist/environment/application/siblings.js +269 -0
  50. package/dist/environment/claims.d.ts +10 -10
  51. package/dist/environment/claims.js +3 -11
  52. package/dist/environment/compose-topology.d.ts +15 -7
  53. package/dist/environment/compose-topology.js +100 -23
  54. package/dist/environment/contract.d.ts +42 -9
  55. package/dist/environment/contract.js +33 -0
  56. package/dist/environment/executable.d.ts +12 -0
  57. package/dist/environment/executable.js +46 -23
  58. package/dist/environment/generated-application/check.js +2 -0
  59. package/dist/environment/generated-application/prepare.js +2 -0
  60. package/dist/environment/generated-application/shared.d.ts +10 -2
  61. package/dist/environment/generated-application/shared.js +6 -1
  62. package/dist/environment/generated-application/surface-outputs.js +23 -12
  63. package/dist/environment/generated-application/surfaces.js +16 -11
  64. package/dist/environment/generated-application/sync.js +19 -4
  65. package/dist/environment/git-observation.d.ts +25 -6
  66. package/dist/environment/git-observation.js +37 -28
  67. package/dist/environment/health-composition.d.ts +3 -2
  68. package/dist/environment/health-composition.js +19 -2
  69. package/dist/environment/init-application.d.ts +39 -14
  70. package/dist/environment/init-application.js +39 -10
  71. package/dist/environment/lifecycle-composition.d.ts +4 -2
  72. package/dist/environment/lifecycle-composition.js +84 -17
  73. package/dist/environment/lifecycle-stage.d.ts +8 -3
  74. package/dist/environment/lifecycle-stage.js +11 -1
  75. package/dist/environment/logs.d.ts +1 -1
  76. package/dist/environment/map-limit.d.ts +2 -0
  77. package/dist/environment/map-limit.js +13 -0
  78. package/dist/environment/mcp-observation.d.ts +9 -0
  79. package/dist/environment/mcp-observation.js +46 -0
  80. package/dist/environment/observations.d.ts +1 -1
  81. package/dist/environment/observations.js +2 -0
  82. package/dist/environment/operator-application.d.ts +35 -6
  83. package/dist/environment/operator-application.js +42 -0
  84. package/dist/environment/presence.d.ts +4 -0
  85. package/dist/environment/presence.js +26 -0
  86. package/dist/environment/read-composition.d.ts +8 -3
  87. package/dist/environment/read-composition.js +53 -8
  88. package/dist/environment/readiness-composition.d.ts +20 -1
  89. package/dist/environment/readiness-composition.js +23 -2
  90. package/dist/environment/reclaim.d.ts +56 -19
  91. package/dist/environment/reclaim.js +147 -42
  92. package/dist/environment/runtime.d.ts +64 -18
  93. package/dist/environment/runtime.js +73 -3
  94. package/dist/environment/served-checkout-grants.d.ts +53 -0
  95. package/dist/environment/served-checkout-grants.js +182 -0
  96. package/dist/environment/sibling-checkouts.d.ts +119 -0
  97. package/dist/environment/sibling-checkouts.js +254 -0
  98. package/dist/environment/sibling-observation.d.ts +27 -0
  99. package/dist/environment/sibling-observation.js +45 -0
  100. package/dist/environment/sibling-pull.d.ts +23 -0
  101. package/dist/environment/sibling-pull.js +95 -0
  102. package/dist/environment/stack-inputs.d.ts +18 -0
  103. package/dist/environment/stack-inputs.js +210 -0
  104. package/dist/environment/stack-inventory-prune.d.ts +31 -0
  105. package/dist/environment/stack-inventory-prune.js +129 -0
  106. package/dist/environment/stack-inventory-report.d.ts +44 -0
  107. package/dist/environment/stack-inventory-report.js +78 -0
  108. package/dist/environment/stack-inventory.d.ts +94 -0
  109. package/dist/environment/stack-inventory.js +418 -0
  110. package/dist/environment/state.d.ts +48 -9
  111. package/dist/environment/state.js +142 -2
  112. package/dist/environment/tool-registration.js +3 -7
  113. package/dist/environment-exec/execute.d.ts +6 -3
  114. package/dist/environment-exec/execute.js +4 -0
  115. package/dist/fix-catalogue.d.ts +1 -1
  116. package/dist/generated-environment/adopt.js +4 -8
  117. package/dist/guards/answer-confirmation.d.ts +41 -0
  118. package/dist/guards/answer-confirmation.js +109 -0
  119. package/dist/guards/evaluator.d.ts +20 -3
  120. package/dist/guards/evaluator.js +39 -7
  121. package/dist/guards/local-identity.d.ts +6 -2
  122. package/dist/guards/local-identity.js +10 -4
  123. package/dist/guards/policy-change.d.ts +43 -0
  124. package/dist/guards/policy-change.js +159 -0
  125. package/dist/guards/policy-pin.d.ts +1 -1
  126. package/dist/guards/policy-pin.js +14 -12
  127. package/dist/init/command.d.ts +4 -3
  128. package/dist/init/command.js +36 -16
  129. package/dist/init/derive.d.ts +8 -6
  130. package/dist/init/derive.js +140 -36
  131. package/dist/init/draft.d.ts +13 -11
  132. package/dist/init/draft.js +54 -31
  133. package/dist/init/render.d.ts +18 -6
  134. package/dist/init/render.js +31 -9
  135. package/dist/repository-path.d.ts +9 -0
  136. package/dist/repository-path.js +16 -0
  137. package/dist/schema.d.ts +42 -75
  138. package/dist/schema.js +62 -11
  139. package/dist/surfaces/harness-registry.d.ts +80 -0
  140. package/dist/surfaces/harness-registry.js +29 -0
  141. package/dist/surfaces/mcp.d.ts +67 -17
  142. package/dist/surfaces/mcp.js +194 -31
  143. package/dist/surfaces/projection.d.ts +7 -7
  144. package/dist/surfaces/projection.js +41 -6
  145. package/dist/surfaces/render.d.ts +5 -44
  146. package/dist/surfaces/render.js +5 -2
  147. package/dist/team-metadata.js +2 -1
  148. package/dist/testing/test-protection.d.ts +34 -0
  149. package/dist/testing/test-protection.js +137 -0
  150. package/dist/testing/weakening.d.ts +15 -0
  151. package/dist/testing/weakening.js +30 -0
  152. package/dist/work-orders/application-closure.d.ts +7 -0
  153. package/dist/work-orders/application-closure.js +76 -0
  154. package/dist/work-orders/application-context.d.ts +162 -0
  155. package/dist/work-orders/application-context.js +137 -0
  156. package/dist/work-orders/application-decisions.d.ts +27 -0
  157. package/dist/work-orders/application-decisions.js +121 -0
  158. package/dist/work-orders/application-grading.d.ts +11 -0
  159. package/dist/work-orders/application-grading.js +212 -0
  160. package/dist/work-orders/application.d.ts +4 -97
  161. package/dist/work-orders/application.js +94 -254
  162. package/dist/work-orders/composition.d.ts +35 -58
  163. package/dist/work-orders/composition.js +216 -24
  164. package/dist/work-orders/contract.d.ts +22 -6
  165. package/dist/work-orders/contract.js +34 -16
  166. package/dist/work-orders/decisions.d.ts +23 -0
  167. package/dist/work-orders/decisions.js +55 -0
  168. package/dist/work-orders/store.d.ts +54 -5
  169. package/dist/work-orders/store.js +118 -24
  170. package/docs/README.md +6 -3
  171. package/docs/capability-matrix.md +17 -9
  172. package/docs/cli-reference.md +209 -1
  173. package/docs/command-list.md +12 -1
  174. package/docs/concepts.md +6 -4
  175. package/docs/configuration-essentials.md +7 -2
  176. package/docs/configuration-reference.md +18 -3
  177. package/docs/docs-check.md +76 -0
  178. package/docs/environment.md +194 -6
  179. package/docs/faq.md +5 -3
  180. package/docs/getting-started.md +109 -25
  181. package/docs/harness-surfaces.md +69 -2
  182. package/docs/safety-and-autonomy.md +56 -12
  183. package/docs/troubleshooting.md +29 -8
  184. package/docs/tutorial.md +3 -2
  185. package/docs/work-and-browser.md +180 -14
  186. package/libexec/browser-operation.cjs +86 -2
  187. package/package.json +2 -2
  188. package/surfaces/skills/cockpit-browser.skill.md +7 -2
  189. package/surfaces/skills/cockpit-explain.skill.md +1 -1
  190. package/surfaces/skills/cockpit-fix.skill.md +1 -1
  191. package/surfaces/skills/cockpit-setup.skill.md +3 -3
  192. package/surfaces/skills/cockpit-work.skill.md +1 -1
  193. package/surfaces/skills/procedures/explain.md +9 -4
  194. package/surfaces/skills/procedures/fix.md +25 -11
  195. package/surfaces/skills/procedures/setup.md +113 -40
  196. package/surfaces/skills/procedures/work.md +45 -16
@@ -1,7 +1,9 @@
1
1
  // Proposes interview answers from files a repository already carries. It owns reading those files
2
- // and attributing each proposed value to its source; it must not decide or write anything. Every
3
- // fact is inferred: init surfaces it with its source and the operator confirms it, because a
4
- // config that looks complete but holds guesses is the failure cockpit exists to prevent.
2
+ // and attributing each proposed value to its source; it must not decide or write anything. A fact
3
+ // is "derived" when it is the complete answer to an interview field read exactly from its source,
4
+ // which the setup agent may accept and bring-up then proves; every other fact is "inferred": a hint
5
+ // or a partial value that needs judgement, because a config that looks complete but holds guesses
6
+ // is the failure cockpit exists to prevent.
5
7
  // Deliberately cheap and exact: Dockerfile, env template variable names, Compose files and CI
6
8
  // service containers, test entry points and run scripts from build and package manifests, Node
7
9
  // lockfiles, and each repository's origin remote from .git/config. It starts no process. Orchestrator
@@ -9,7 +11,7 @@
9
11
  import { lstat, readdir, realpath } from 'node:fs/promises';
10
12
  import { basename, isAbsolute, relative as pathRelative, resolve, sep } from 'node:path';
11
13
  import { parse as parseToml } from 'smol-toml';
12
- import { parse as parseYaml } from 'yaml';
14
+ import { LineCounter, parse as parseYaml, parseDocument } from 'yaml';
13
15
  import { readBoundedInput } from '../config/load.js';
14
16
  import { errorCause, hasErrorCode } from '../environment/contract.js';
15
17
  import { assessComposeTopology, bindHolder, composeBindPath, composeProjectDirectory, composePublishedPorts, splitComposeShort } from '../environment/compose-topology.js';
@@ -230,33 +232,43 @@ const PORT_VARIABLE = /^\$\{([A-Z][A-Z0-9_]*)(?:(:?-)(\d{1,5}))?\}$|^\$([A-Z][A-
230
232
  function entries(value) {
231
233
  return Array.isArray(value) ? value : [];
232
234
  }
235
+ const at = (file, path) => {
236
+ const line = file.line(path);
237
+ return line === undefined ? file.source : `${file.source}:${line}`;
238
+ };
239
+ function services(file) {
240
+ const value = file.document?.services;
241
+ if (!value || typeof value !== 'object' || Array.isArray(value))
242
+ return [];
243
+ return Object.entries(value)
244
+ .filter((entry) => !!entry[1] && typeof entry[1] === 'object' && !Array.isArray(entry[1]));
245
+ }
233
246
  /**
234
247
  * Reads the bind sources and published host-port variables of the Compose files init proposes.
235
248
  * Bind sources resolve as `up` resolves them: against the first file's directory, with each
236
249
  * proposed sibling variable set to its directory and any other variable to its own default.
250
+ * The bases are complete only when every published host port was read; otherwise the map is a
251
+ * partial proposal that needs the person.
237
252
  */
238
253
  function composeFacts(files, root, variables, skipped) {
239
254
  const binds = [];
240
- // Each base keeps every file that publishes it, so the proposal names where each value came from.
255
+ // Each base keeps every file and line that publishes it, so the proposal names where each value came from.
241
256
  const bases = new Map();
242
257
  const conflicting = new Set();
243
- for (const { source, document } of files) {
244
- const services = document?.services;
245
- if (!services || typeof services !== 'object' || Array.isArray(services))
246
- continue;
247
- for (const service of Object.values(services)) {
248
- if (!service || typeof service !== 'object')
249
- continue;
258
+ let basesComplete = true;
259
+ for (const file of files) {
260
+ const { source } = file;
261
+ for (const [name, service] of services(file)) {
250
262
  for (const volume of entries(service.volumes)) {
251
263
  const segments = typeof volume === 'string' ? splitComposeShort(volume) : undefined;
252
264
  const raw = segments && segments.length >= 2 ? segments[0]
253
265
  : volume && typeof volume === 'object' && volume.type === 'bind'
254
266
  ? volume.source : undefined;
255
- const path = typeof raw === 'string' ? composeBindPath(raw, composeProjectDirectory(root, files.map(file => file.source)), variables) : undefined;
267
+ const path = typeof raw === 'string' ? composeBindPath(raw, composeProjectDirectory(root, files.map(row => row.source)), variables) : undefined;
256
268
  if (path !== undefined && path !== 'volume')
257
269
  binds.push({ source, path });
258
270
  }
259
- for (const port of entries(service.ports)) {
271
+ for (const [index, port] of entries(service.ports).entries()) {
260
272
  const segments = typeof port === 'string' ? splitComposeShort(port) : undefined;
261
273
  const published = segments && segments.length >= 2 ? segments[segments.length - 2]
262
274
  : port && typeof port === 'object' ? port.published : undefined;
@@ -265,30 +277,101 @@ function composeFacts(files, root, variables, skipped) {
265
277
  continue;
266
278
  const match = typeof published === 'string' ? PORT_VARIABLE.exec(published) : null;
267
279
  if (!match) {
280
+ basesComplete = false;
268
281
  skipped.push({ source, reason: `publishes host port ${String(published)} that is not read from a variable with a default, so every stack would bind the same host port and check refuses it; publish it as \${NAME:-<port>} and declare NAME under [ports.bases]` });
269
282
  continue;
270
283
  }
271
- const name = (match[1] ?? match[4]);
284
+ const variable = (match[1] ?? match[4]);
272
285
  const base = match[3] === undefined ? undefined : Number(match[3]);
273
286
  if (base === undefined || base < 1 || base > 65_535) {
274
- skipped.push({ source, reason: `publishes a host port from \${${name}} with no default, so its base port cannot be read; declare ${name} under [ports.bases]` });
287
+ basesComplete = false;
288
+ skipped.push({ source, reason: `publishes a host port from \${${variable}} with no default, so its base port cannot be read; declare ${variable} under [ports.bases]` });
275
289
  continue;
276
290
  }
277
- const known = bases.get(name);
291
+ const known = bases.get(variable);
278
292
  if (known && known.base !== base)
279
- conflicting.add(name);
293
+ conflicting.add(variable);
294
+ const where = at(file, ['services', name, 'ports', index]);
280
295
  if (!known)
281
- bases.set(name, { base, sources: [source] });
282
- else if (!known.sources.includes(source))
283
- known.sources.push(source);
296
+ bases.set(variable, { base, files: [source], at: [where] });
297
+ else {
298
+ if (!known.files.includes(source))
299
+ known.files.push(source);
300
+ known.at.push(where);
301
+ }
284
302
  }
285
303
  }
286
304
  }
287
- for (const name of conflicting) {
288
- skipped.push({ source: bases.get(name).sources.join(', '), reason: `publishes \${${name}} with different defaults, so its base port cannot be read; declare ${name} under [ports.bases]` });
289
- bases.delete(name);
305
+ for (const variable of conflicting) {
306
+ basesComplete = false;
307
+ skipped.push({ source: bases.get(variable).files.join(', '), reason: `publishes \${${variable}} with different defaults, so its base port cannot be read; declare ${variable} under [ports.bases]` });
308
+ bases.delete(variable);
290
309
  }
291
- return { binds, bases };
310
+ return { binds, bases, basesComplete };
311
+ }
312
+ const BYTE_UNITS = { '': 1, b: 1, k: 1024, kb: 1024, m: 1024 ** 2, mb: 1024 ** 2, g: 1024 ** 3, gb: 1024 ** 3 };
313
+ /** Compose's byte notation, as Docker reads it: a byte count, or a number with a binary b, k, m or g unit. */
314
+ function composeBytes(value) {
315
+ if (typeof value === 'number')
316
+ return Number.isFinite(value) && value > 0 ? value : undefined;
317
+ const match = typeof value === 'string' ? /^(\d+(?:\.\d+)?)\s*([a-z]*)$/i.exec(value.trim()) : null;
318
+ const unit = match ? BYTE_UNITS[match[2].toLowerCase()] : undefined;
319
+ return match && unit !== undefined && Number(match[1]) > 0 ? Number(match[1]) * unit : undefined;
320
+ }
321
+ function composeCpus(value) {
322
+ const cpus = typeof value === 'number' ? value : typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value.trim()) ? Number(value) : NaN;
323
+ return Number.isFinite(cpus) && cpus > 0 ? cpus : undefined;
324
+ }
325
+ /**
326
+ * The memory and CPU floor the one proposed Compose file records: each default service's limit
327
+ * times its replicas, summed, because the runtime must hold every service at its limit at once.
328
+ * A service with no limit leaves that resource unrecorded, and a service whose two spellings of a
329
+ * limit disagree is reported instead of guessed between.
330
+ */
331
+ function composeLimits(file, skipped) {
332
+ // A service behind a profile does not start unless that profile is activated.
333
+ const started = services(file).filter(([, service]) => service.profiles === undefined);
334
+ if (started.length === 0)
335
+ return [];
336
+ const resources = [
337
+ { key: 'runtime_memory_mb', label: 'memory', long: 'memory', short: 'mem_limit', read: composeBytes,
338
+ total: (sum) => Math.ceil(sum / 1024 ** 2) },
339
+ { key: 'runtime_cpus', label: 'CPU', long: 'cpus', short: 'cpus', read: composeCpus,
340
+ total: (sum) => Math.round(sum * 1000) / 1000 },
341
+ ];
342
+ const facts = [];
343
+ for (const resource of resources) {
344
+ let sum = 0;
345
+ const parts = [];
346
+ const lines = [];
347
+ let complete = true;
348
+ for (const [name, service] of started) {
349
+ const deployPath = ['services', name, 'deploy', 'resources', 'limits', resource.long];
350
+ const deployed = field(field(field(service.deploy, 'resources'), 'limits'), resource.long);
351
+ const short = service[resource.short];
352
+ const [declared, path] = deployed !== undefined ? [deployed, deployPath] : [short, ['services', name, resource.short]];
353
+ const amount = resource.read(declared);
354
+ if (deployed !== undefined && short !== undefined && resource.read(short) !== amount) {
355
+ skipped.push({ source: at(file, deployPath), reason: `service ${name} sets its ${resource.label} limit twice, as ${resource.short} and as deploy.resources.limits.${resource.long}, with different values, so no ${resource.key} is proposed; keep one of them` });
356
+ complete = false;
357
+ continue;
358
+ }
359
+ if (amount === undefined) {
360
+ complete = false;
361
+ continue;
362
+ }
363
+ const replicas = field(service.deploy, 'replicas') ?? service.scale ?? 1;
364
+ const count = typeof replicas === 'number' && Number.isInteger(replicas) && replicas >= 0 ? replicas : 1;
365
+ sum += amount * count;
366
+ parts.push(`${name} ${String(declared)}${count === 1 ? '' : ` x${count}`}`);
367
+ lines.push(at(file, path));
368
+ }
369
+ if (complete && sum > 0)
370
+ facts.push({ fields: ['environment.machine'], target: `environment.machine.${resource.key}`, value: resource.total(sum),
371
+ provenance: { kind: 'derived', source: lines.join(', '),
372
+ reason: `the ${resource.label} limits of every service ${file.source} starts by default (${parts.join(', ')}), summed: the runtime must hold each service at its limit at once` } });
373
+ }
374
+ return facts;
292
375
  }
293
376
  async function detectTestEntry(read, readJson, present, node) {
294
377
  for (const name of ['MODULE.bazel', 'WORKSPACE.bazel', 'WORKSPACE']) {
@@ -415,6 +498,20 @@ export async function deriveInferredFacts(input) {
415
498
  }
416
499
  };
417
500
  const composeFiles = [];
501
+ // Compose files are read with their line positions, so a derived value names the line it came from.
502
+ const parseCompose = (source, text) => {
503
+ const lineCounter = new LineCounter();
504
+ const parsed = parseDocument(text, { lineCounter });
505
+ const error = parsed.errors[0];
506
+ if (error) {
507
+ skipped.push({ source, reason: `is not valid YAML (${error.name})` });
508
+ return undefined;
509
+ }
510
+ return { source, document: parsed.toJS(), line: path => {
511
+ const offset = parsed.getIn(path, true)?.range?.[0];
512
+ return offset === undefined ? undefined : lineCounter.linePos(offset).line;
513
+ } };
514
+ };
418
515
  const seenServices = new Set();
419
516
  const readOrigin = async (directory) => {
420
517
  const marker = relative(directory, '.git');
@@ -555,14 +652,14 @@ export async function deriveInferredFacts(input) {
555
652
  const text = await read(directory.path, name);
556
653
  if (text === undefined)
557
654
  continue;
558
- const document = parseYamlText(source, text);
559
- if (document !== undefined) {
655
+ const file = parseCompose(source, text);
656
+ if (file !== undefined) {
560
657
  if (source === '..' || source.startsWith('../') || isAbsolute(source)) {
561
658
  skipped.push({ source,
562
659
  reason: `is outside the cockpit root, so environment.compose_files cannot represent it; write a compose.yml at the cockpit root that reaches each checkout through its sibling variable${slug === undefined ? '' : ` (such as \${${deriveSiblingVariable(slug)}})`} and answer environment.compose_files = ["compose.yml"]; then rerun ${initCommand({ root: configurationRoot })} with the options you ran it with` });
563
660
  }
564
661
  else
565
- composeFiles.push({ source, document });
662
+ composeFiles.push(file);
566
663
  }
567
664
  break;
568
665
  }
@@ -642,19 +739,24 @@ export async function deriveInferredFacts(input) {
642
739
  ? `${placed}; acquire "auto" with its origin remote, so setup clones it where a checkout is absent; add its purpose`
643
740
  : `${placed}; acquire "none" because ${origin.reason}; add its purpose` } });
644
741
  }
645
- if (compose.bases.size > 0) {
646
- const sources = [...new Set([...compose.bases.values()].flatMap(row => row.sources))];
647
- const publishers = sources.map(source => `${source} publishes ${[...compose.bases].filter(([, row]) => row.sources.includes(source)).map(([name]) => name).join(', ')}`);
742
+ if (composeFiles.length > 0 && (compose.bases.size > 0 || compose.basesComplete)) {
743
+ const files = [...new Set([...compose.bases.values()].flatMap(row => row.files))];
744
+ const publishers = files.map(file => `${file} publishes ${[...compose.bases].filter(([, row]) => row.files.includes(file)).map(([name]) => name).join(', ')}`);
648
745
  facts.push({ fields: ['ports.bases'], target: 'ports.bases', value: Object.fromEntries([...compose.bases].map(([name, row]) => [name, row.base])),
649
- provenance: { kind: 'inferred', source: sources.join(', '),
650
- reason: `${publishers.join('; ')} from variables with these defaults; check requires every published host port to come from a [ports.bases] variable, so each stack leases its own` } });
746
+ provenance: compose.bases.size === 0
747
+ ? { kind: 'derived', source: composeFiles.map(file => file.source).join(', '),
748
+ reason: 'no service publishes a host port, so no stack needs a base port' }
749
+ : { kind: compose.basesComplete ? 'derived' : 'inferred', source: [...new Set([...compose.bases.values()].flatMap(row => row.at))].join(', '),
750
+ reason: `${publishers.join('; ')} from variables with these defaults; check requires every published host port to come from a [ports.bases] variable, so each stack leases its own${compose.basesComplete ? '' : '; a published host port could not be read (see inferenceSkipped), so this map is incomplete'}` } });
651
751
  }
652
752
  // compose-first resolves every relative path against the first listed file's directory, so one
653
753
  // product Compose file can be run in place but several cannot be listed together.
654
- if (composeFiles.length === 1)
754
+ if (composeFiles.length === 1) {
655
755
  facts.unshift({ fields: ['environment.compose_files'], target: 'environment.compose_files', value: [composeFiles[0].source],
656
- provenance: { kind: 'inferred', source: composeFiles[0].source,
756
+ provenance: { kind: 'derived', source: composeFiles[0].source,
657
757
  reason: 'a Compose file already exists, so cockpit can run it as written' } });
758
+ facts.push(...composeLimits(composeFiles[0], skipped));
759
+ }
658
760
  else if (composeFiles.length > 1)
659
761
  skipped.push({ source: composeFiles.map(file => file.source).join(', '),
660
762
  reason: `are Compose files in different repositories, and compose-first resolves every relative path against the first listed file's directory, so they cannot be listed together; write one compose.yml at the cockpit root that runs their services, reaching each checkout through its sibling variable (${[...siblingPaths.keys()].map(name => `\${${name}}`).join(', ')}), and answer environment.compose_files = ["compose.yml"]` });
@@ -703,6 +805,7 @@ export async function composeFindings(input) {
703
805
  continue;
704
806
  const health = service.healthcheck;
705
807
  services.set(id, { id, ports, mountSources: [], build: service.build !== undefined,
808
+ containerName: typeof service.container_name === 'string' ? service.container_name : null,
706
809
  image: typeof service.image === 'string' ? service.image : null,
707
810
  healthcheck: health !== undefined && health !== null && health.disable !== true && !(Array.isArray(health.test) && health.test[0] === 'NONE') });
708
811
  }
@@ -710,6 +813,7 @@ export async function composeFindings(input) {
710
813
  const bases = draft.ports?.bases;
711
814
  const found = await assessComposeTopology([...services.values()], { projectDirectory: composeProjectDirectory(input.root, references),
712
815
  portVariables: bases !== null && typeof bases === 'object' ? Object.keys(bases) : [], siblings: [], hostPaths: [] });
713
- return [...unread, ...found.filter(row => row.code === 'COMPOSE_PORT_NOT_LEASED' || row.code === 'COMPOSE_HEALTHCHECK_MISSING')
816
+ return [...unread, ...found.filter(row => row.code === 'COMPOSE_PORT_NOT_LEASED' || row.code === 'COMPOSE_CONTAINER_NAME_FIXED' || row.code === 'COMPOSE_IMAGE_TAG_SHARED'
817
+ || row.code === 'COMPOSE_HEALTHCHECK_MISSING')
714
818
  .map(row => ({ ...row, severity: 'warning' }))];
715
819
  }
@@ -1,5 +1,5 @@
1
1
  import type { Diagnostic } from '../diagnostics.js';
2
- import { type AnswerProvenance, type UnconfirmedAnswer } from './render.js';
2
+ import { type AnswerProvenance, type DerivedAnswer, type UnconfirmedAnswer } from './render.js';
3
3
  /**
4
4
  * The directory holding the repositories, and optionally the direct children to observe. A cockpit
5
5
  * created beside a product repository observes only that repository in their shared parent, which
@@ -11,10 +11,10 @@ export interface EstateObservationInput {
11
11
  readonly repositories?: readonly string[];
12
12
  readonly rootIsRepository?: true;
13
13
  }
14
- export interface EstateObservationAdapters {
14
+ interface EstateObservationAdapters {
15
15
  readonly observe: (input: EstateObservationInput) => Promise<unknown>;
16
16
  }
17
- export type EstateClassification = 'one-sibling' | 'incomplete-multi-repository' | 'no-runnable-product' | 'ready-multi-repository';
17
+ type EstateClassification = 'one-sibling' | 'incomplete-multi-repository' | 'no-runnable-product' | 'ready-multi-repository';
18
18
  export interface EstateClassificationResult {
19
19
  readonly classification: EstateClassification;
20
20
  readonly sourceObservationSha256: string;
@@ -48,32 +48,34 @@ interface OutcomeBase {
48
48
  result: {
49
49
  document: string;
50
50
  unconfirmed?: readonly UnconfirmedAnswer[];
51
+ derived?: readonly DerivedAnswer[];
51
52
  } | null;
52
53
  status: number;
53
54
  question?: DocumentConfigurationQuestion | null;
54
55
  }
55
- export interface CompleteDocumentOutcome extends OutcomeBase {
56
+ interface CompleteDocumentOutcome extends OutcomeBase {
56
57
  kind: 'complete';
57
58
  artifact: string;
58
59
  result: {
59
60
  document: string;
60
61
  unconfirmed?: readonly UnconfirmedAnswer[];
62
+ derived?: readonly DerivedAnswer[];
61
63
  };
62
64
  status: 0;
63
65
  }
64
- export interface GapDraftOutcome extends OutcomeBase {
66
+ interface GapDraftOutcome extends OutcomeBase {
65
67
  kind: 'gap-draft';
66
68
  artifact: string;
67
69
  result: {
68
70
  document: string;
69
71
  };
70
72
  }
71
- export interface SkippedScopeOutcome extends OutcomeBase {
73
+ interface SkippedScopeOutcome extends OutcomeBase {
72
74
  kind: 'skipped-scope';
73
75
  artifact: null;
74
76
  result: null;
75
77
  }
76
- export interface RefusedDocumentOutcome extends OutcomeBase {
78
+ interface RefusedDocumentOutcome extends OutcomeBase {
77
79
  kind: 'refusal';
78
80
  artifact: null;
79
81
  result: null;
@@ -88,15 +90,15 @@ export interface DocumentCommandInput {
88
90
  /** How init observed the estate when it is not the cockpit root's children: a dedicated or an in-repo cockpit. */
89
91
  estate?: EstateObservationInput;
90
92
  }
91
- export type DocumentConfigurationField = 'cockpit.name' | 'sibling' | 'estate.runnable' | 'ports.bases' | 'environment.install_policies' | 'environment.test_data' | 'environment.compose_files' | 'environment.machine' | 'environment.posture' | 'testing' | 'guards' | 'configuration.defaults';
92
- export interface DocumentConfigurationQuestion {
93
+ type DocumentConfigurationField = 'cockpit.name' | 'sibling' | 'estate.runnable' | 'ports.bases' | 'environment.install_policies' | 'environment.test_data' | 'environment.compose_files' | 'environment.machine' | 'environment.posture' | 'testing' | 'guards' | 'configuration.defaults';
94
+ interface DocumentConfigurationQuestion {
93
95
  readonly id: `question:${string}`;
94
96
  readonly field: DocumentConfigurationField;
95
97
  readonly source: string;
96
98
  readonly why: string;
97
99
  readonly expectedSha256?: string;
98
100
  }
99
- export type { AnswerProvenance, UnconfirmedAnswer } from './render.js';
101
+ export type { AnswerProvenance, DerivedAnswer, UnconfirmedAnswer } from './render.js';
100
102
  export declare function isAnswerProvenance(value: unknown): value is AnswerProvenance;
101
103
  export interface DocumentConfigurationAnswer {
102
104
  readonly questionId: string;
@@ -104,7 +106,7 @@ export interface DocumentConfigurationAnswer {
104
106
  readonly sha256: string;
105
107
  readonly provenance: AnswerProvenance;
106
108
  }
107
- export interface DocumentConfigurationInput {
109
+ interface DocumentConfigurationInput {
108
110
  readonly sourceObservationSha256: string;
109
111
  readonly classification?: EstateClassification;
110
112
  readonly answers: readonly DocumentConfigurationAnswer[];
@@ -1,8 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { unconfirmedFix } from '../guards/answer-confirmation.js';
2
3
  import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
3
4
  import { basename, isAbsolute, relative, resolve, sep } from 'node:path';
4
5
  import { parse as parseYaml } from 'yaml';
5
6
  import { initCommand } from '../fix-catalogue.js';
7
+ import { canonicalJson } from '../canonical-json.js';
6
8
  import { readBoundedInput, schemaIssueSummary, STRUCTURED_INPUT_BYTE_LIMIT } from '../config/load.js';
7
9
  import { errorCause, snapshotJson, RefusalError, tryParseJson, hasErrorCode } from '../environment/contract.js';
8
10
  import { judgePostureDeclaration } from '../environment/posture-observation.js';
@@ -10,7 +12,7 @@ import { isSensitiveField, redactSensitiveText } from '../environment/sensitive.
10
12
  import { CockpitConfig, Guards, InstallPolicy, MachineFloor, Testing, configurationDefaults } from '../schema.js';
11
13
  import { toSiblingRows } from '../emit.js';
12
14
  import { gaps, parseRegistry } from '../registry-resolve/registry.js';
13
- import { ANSWER_PROVENANCES, renderCompleteDocument, renderIncompleteDocument, UNCONFIRMED_FIX, unconfirmedReason, } from './render.js';
15
+ import { ANSWER_PROVENANCES, confirmedBy, renderCompleteDocument, renderIncompleteDocument, unconfirmedReason, } from './render.js';
14
16
  import { COMPOSE_NAMES, NODE_LOCKFILE_NAMES, deriveInferredFacts, readDockerfileFacts } from './derive.js';
15
17
  import { productPackageManifest } from '../team-metadata.js';
16
18
  import { refusalOutcome as refusal } from '../refusal.js';
@@ -653,18 +655,21 @@ const QUESTION_WHY = {
653
655
  policies: 'declare one row per toolchain and lockfile with any build or test command the guard should allow',
654
656
  testData: 'answer with exactly { seeded_data: boolean, signed_in_users: boolean } to state whether this local setup needs seeded data and signed-in users; each false field records a precise not-declared decision, while true keeps the product-owned declaration work visible without claiming that data or authentication exists',
655
657
  composeFiles: 'list the Compose files, relative to the cockpit root, that run the product, such as ["compose.yml"]; cockpit runs them as written. A small app with no Compose file gets a starting one, which it then owns, from npx --no cockpit init <cockpit root> --scaffold-compose <app dir> --port <port>',
656
- machine: 'declare the machine floor the product needs from the container runtime: runtime_memory_mb, runtime_cpus, free_disk_mb and cold_start_seconds; these are product requirements a person states: cockpit never proposes them, because no product file records them and this machine\'s capacity is not what the product needs',
658
+ machine: 'declare the machine floor the product needs from the container runtime: runtime_memory_mb, runtime_cpus, free_disk_mb and cold_start_seconds; init proposes memory and CPUs from the Compose file\'s service limits, or from this machine\'s measured runtime where no limit records them; nothing records free disk or the cold start time, so a person states those, or the setup agent marks its choice "agent-default"',
657
659
  posture: `preflight refuses a runtime until its posture is declared: addressing is "port" (localhost:<port>) or "hostname"; authentication is "none" (no sign-in), "bypassed" (sign-in turned off locally) or "real"; posture_requirements names what depends on them, each tagged with the posture it needs. For a plain localhost stack with no sign-in, answer ${JSON.stringify({ addressing: 'port', authentication: 'none', posture_requirements: [{ requirement: 'leased-localhost-ports', addressing: 'port' }] })}`,
658
660
  testing: 'declare the product\'s test commands as [testing] stages and gates: a targeted gate agents run while iterating and a full gate they run before handing back shared changes; init proposes them from test entry points it finds on disk. Answer "none" when the product has no tests yet: no work can then be graded PASS, and doctor warns until gates are declared',
659
661
  guards: 'name the database hosts and live vendor API hosts of every non-local environment the product has (production, staging, shared dev or QA): cockpit runs only locally, so the guard denies agent commands that reach them; answer { "hosted_db_hosts": [], "vendor_endpoint_hosts": [] } to record that none exists',
660
- defaults: 'confirm the exact surfaced inferred and schema-default values before completing the document',
662
+ defaults: 'a person confirms the values the draft lists under its person heading (protected branches, guard allow and deny lists, approved MCP servers, resettable volumes, generated host paths, and anything a registry left to a guess); expectedSha256 covers only those, and the setup agent accepts the schema defaults and the cockpit\'s own layout, which bring-up proves',
661
663
  };
662
664
  /** How a decision comment attributes an answer, so a value the agent chose never reads as one a person stated. */
663
665
  const STATED_BY = {
664
666
  'operator-confirmed': 'the operator stated that',
665
667
  'agent-default': 'the setup agent assumed, as a default, that',
666
668
  unattributed: 'the answers file, without saying who chose it, states that',
669
+ derived: 'init read from the product files that',
667
670
  };
671
+ const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
672
+ const DERIVED_FIX = 'answer the value init proposes for this field under interview.outstanding, whose provenance kind is "derived"; otherwise mark the answer "human" once a person states it, or "agent-default" when you chose it';
668
673
  const noGuardHostsDecision = (provenance) => `${STATED_BY[provenance]} no non-local environment (production, staging, shared) has a database or live vendor host for the guard to deny`;
669
674
  const runnableWhy = (gaps) => `resolve the exact current runnable gaps: ${gaps.map(gap => `${gap.field} from ${gap.source}`).join('; ')}`;
670
675
  /**
@@ -698,7 +703,11 @@ function configurationQuestion(context, field, why, expectedSha256) {
698
703
  source: `configuration:${context.sourceObservationSha256}`,
699
704
  why, ...(expectedSha256 === undefined ? {} : { expectedSha256 }) };
700
705
  }
701
- function readConfigurationAnswer(answers, index, question) {
706
+ /**
707
+ * The answer to the current question. One marked derived must be exactly the value init read for
708
+ * the field from an exact source: the provenance is checked, never taken on the answers file's word.
709
+ */
710
+ function readConfigurationAnswer(answers, index, question, derived) {
702
711
  const answer = answers[index];
703
712
  if (!answer)
704
713
  return { kind: 'pending' };
@@ -708,7 +717,13 @@ function readConfigurationAnswer(answers, index, question) {
708
717
  const value = JSON.parse(answer.bytes);
709
718
  if (containsSensitiveField(value))
710
719
  return { kind: 'refusal', outcome: refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'operator answers cannot contain sensitive fields', question.field) };
711
- return { kind: 'answer', value, provenance: answer.provenance };
720
+ if (answer.provenance !== 'derived')
721
+ return { kind: 'answer', value, provenance: answer.provenance };
722
+ if (derived === undefined)
723
+ return { kind: 'refusal', outcome: refusal('DOCUMENT_CONFIGURATION_ANSWER_NOT_DERIVED', `the ${question.field} answer is marked derived, but init read no complete value for it from an exact source`, question.field, DERIVED_FIX) };
724
+ if (canonicalJson(value) !== canonicalJson(derived.value))
725
+ return { kind: 'refusal', outcome: refusal('DOCUMENT_CONFIGURATION_ANSWER_NOT_DERIVED', `the ${question.field} answer is marked derived, but it is not ${JSON.stringify(derived.value)}, the value init read from ${derived.source}`, question.field, DERIVED_FIX) };
726
+ return { kind: 'answer', value, provenance: answer.provenance, source: derived.source };
712
727
  }
713
728
  catch {
714
729
  return { kind: 'refusal', outcome: refusal(// The parser message would quote the operator's answer.
@@ -806,20 +821,29 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
806
821
  : input.estate && root ? relative(root, input.estate.root) || '.' : '.';
807
822
  const destination = input.output ? resolve(input.output) : null;
808
823
  let answerIndex = 0;
824
+ let inference;
809
825
  let siblingRows;
810
826
  let siblingAnswerRows = [];
811
- // Every answer a person did not state is listed in cockpit.toml, so it never reads as theirs.
827
+ // Every answer a person did not state is listed in cockpit.toml, so it never reads as theirs; one
828
+ // accepted as read names the file it was read from instead.
812
829
  const unconfirmed = [];
813
- const record = (field, provenance) => {
814
- if (provenance !== 'operator-confirmed')
815
- unconfirmed.push({ field, provenance });
830
+ const derived = [];
831
+ const record = (field, read) => {
832
+ if (read.provenance === 'derived')
833
+ derived.push({ field, source: read.source });
834
+ else if (read.provenance !== 'operator-confirmed')
835
+ unconfirmed.push({ field, provenance: read.provenance });
836
+ };
837
+ const derivedFact = (target) => {
838
+ const fact = inference?.facts.find(row => row.target === target && row.provenance.kind === 'derived');
839
+ return fact && { value: fact.value, source: fact.provenance.source };
816
840
  };
817
841
  // Always asked, with the directory name as the proposal: the name scopes Compose projects, state
818
842
  // and generated files, so renaming after sync is a migration, and setup agents that were never
819
843
  // asked renamed the cockpit by hand afterwards.
820
844
  const proposedName = cockpitName(root);
821
845
  const nameQuestion = configurationQuestion(context, 'cockpit.name', proposedName ? QUESTION_WHY.nameProposed : QUESTION_WHY.name);
822
- const nameRead = readConfigurationAnswer(configuration.answers, answerIndex, nameQuestion);
846
+ const nameRead = readConfigurationAnswer(configuration.answers, answerIndex, nameQuestion, proposedName === undefined ? undefined : { value: proposedName, source: 'the cockpit root directory name' });
823
847
  if (nameRead.kind === 'pending')
824
848
  return pendingConfiguration({ name: proposedName, defaults, siblings: detected,
825
849
  attributions: inferGreenfieldSiblingRoot(buildAttributions({ name: proposedName, defaults, siblings: detected }, root, defaultSource), siblingsRoot), gaps: [] }, destination, nameQuestion);
@@ -829,7 +853,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
829
853
  if (!parsedName.success)
830
854
  return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the cockpit name answer does not match the schema: ${issueSummary(parsedName.error)}`, nameQuestion.field);
831
855
  const name = parsedName.data.name;
832
- record('cockpit.name', nameRead.provenance);
856
+ record('cockpit.name', nameRead);
833
857
  answerIndex += 1;
834
858
  const detectedRows = toSiblingRows(detected);
835
859
  const needsSiblingAnswer = detected.length === 0 || gaps([...detected]).some(gap => gap.blocking);
@@ -854,7 +878,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
854
878
  return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'the operator-confirmed sibling declarations are invalid or change detected registry facts', question.field);
855
879
  }
856
880
  siblingRows = parsed.data;
857
- record('sibling', read.provenance);
881
+ record('sibling', read);
858
882
  answerIndex += 1;
859
883
  }
860
884
  else {
@@ -865,7 +889,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
865
889
  siblingAnswerRows = detectedRows;
866
890
  }
867
891
  // Read with the same inference init proposed from, so a defaulted toolchain names the lockfile it overrode.
868
- const inference = defaultSource === 'estate' && root ? await deriveInferredFacts({ root: resolve(root, siblingsRoot ?? '.'),
892
+ inference = defaultSource === 'estate' && root ? await deriveInferredFacts({ root: resolve(root, siblingsRoot ?? '.'),
869
893
  configurationRoot: root, repositories: siblingRows.map(row => ({ slug: row.slug, path: row.dir })) }) : undefined;
870
894
  if (estateRunnable.length > 0) {
871
895
  const dispositionSlugs = estateRunnable.map(row => row.slug);
@@ -917,11 +941,11 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
917
941
  if (configuration.classification === 'one-sibling'
918
942
  && [...runnableResolutions.values()].every(row => !row.runnable))
919
943
  return refusal('DOCUMENT_NO_RUNNABLE_PRODUCT', 'the operator-confirmed estate contains no runnable product', question.field);
920
- record('estate.runnable', read.provenance);
944
+ record('estate.runnable', read);
921
945
  answerIndex += 1;
922
946
  }
923
947
  const portQuestion = configurationQuestion(context, 'ports.bases', QUESTION_WHY.ports);
924
- const portRead = readConfigurationAnswer(configuration.answers, answerIndex, portQuestion);
948
+ const portRead = readConfigurationAnswer(configuration.answers, answerIndex, portQuestion, derivedFact('ports.bases'));
925
949
  const previewRows = documentedRows(siblingRows);
926
950
  const attributions = defaultAttributions(defaults, siblingAnswerRows, detected, root, defaultSource, siblingsRoot, inference);
927
951
  if (portRead.kind === 'pending')
@@ -932,7 +956,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
932
956
  const parsedPorts = CockpitConfig.shape.ports.safeParse({ ...defaults.ports, bases: portRead.value });
933
957
  if (!parsedPorts.success)
934
958
  return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed port bases do not match the schema: ${issueSummary(parsedPorts.error)}`, portQuestion.field);
935
- record('ports.bases', portRead.provenance);
959
+ record('ports.bases', portRead);
936
960
  answerIndex += 1;
937
961
  let environment = defaults.environment;
938
962
  let testData;
@@ -949,7 +973,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
949
973
  const parsedPolicies = InstallPolicy.array().max(256).safeParse(policyRead.value);
950
974
  if (!parsedPolicies.success)
951
975
  return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed install policies do not match the schema: ${issueSummary(parsedPolicies.error)}`, policyQuestion.field);
952
- record('environment.install_policies', policyRead.provenance);
976
+ record('environment.install_policies', policyRead);
953
977
  answerIndex += 1;
954
978
  if (registrySha256 === null) {
955
979
  const testDataQuestion = configurationQuestion(context, 'environment.test_data', QUESTION_WHY.testData);
@@ -967,11 +991,11 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
967
991
  return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', 'the local test-data answer must be exactly { seeded_data: boolean, signed_in_users: boolean }', testDataQuestion.field);
968
992
  testData = { seeded_data: value.seeded_data,
969
993
  signed_in_users: value.signed_in_users, provenance: testDataRead.provenance };
970
- record('environment.test_data', testDataRead.provenance);
994
+ record('environment.test_data', testDataRead);
971
995
  answerIndex += 1;
972
996
  }
973
997
  const composeQuestion = configurationQuestion(context, 'environment.compose_files', QUESTION_WHY.composeFiles);
974
- const composeRead = readConfigurationAnswer(configuration.answers, answerIndex, composeQuestion);
998
+ const composeRead = readConfigurationAnswer(configuration.answers, answerIndex, composeQuestion, derivedFact('environment.compose_files'));
975
999
  if (composeRead.kind === 'pending')
976
1000
  return pendingConfiguration({ name, defaults, siblings: previewRows,
977
1001
  attributions, gaps: [] }, destination, composeQuestion);
@@ -987,12 +1011,11 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
987
1011
  const defaultIndex = attributions.findIndex(row => row.target === 'environment.compose_files');
988
1012
  if (defaultIndex >= 0)
989
1013
  attributions.splice(defaultIndex, 1);
990
- record('environment.compose_files', composeRead.provenance);
1014
+ record('environment.compose_files', composeRead);
991
1015
  answerIndex += 1;
992
1016
  // Every runtime command refuses a declared runtime with no floor, so a configuration that
993
- // declares one is incomplete until the operator states it. Nothing proposes it: Compose
994
- // deploy.resources limits are per-container ceilings that most files omit, and docker info
995
- // reports this machine's capacity, not what the product needs.
1017
+ // declares one is incomplete until it is answered. The interview proposes memory and CPUs,
1018
+ // but free disk and the cold start time have no source, so the answer is never derived.
996
1019
  const machineQuestion = configurationQuestion(context, 'environment.machine', QUESTION_WHY.machine);
997
1020
  const machineRead = readConfigurationAnswer(configuration.answers, answerIndex, machineQuestion);
998
1021
  if (machineRead.kind === 'pending')
@@ -1004,7 +1027,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
1004
1027
  if (!parsedMachine.success)
1005
1028
  return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed machine floor does not match the schema: ${issueSummary(parsedMachine.error)}`, machineQuestion.field);
1006
1029
  environment = { ...environment, machine: parsedMachine.data };
1007
- record('environment.machine', machineRead.provenance);
1030
+ record('environment.machine', machineRead);
1008
1031
  answerIndex += 1;
1009
1032
  const postureQuestion = configurationQuestion(context, 'environment.posture', QUESTION_WHY.posture);
1010
1033
  const postureRead = readConfigurationAnswer(configuration.answers, answerIndex, postureQuestion);
@@ -1027,7 +1050,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
1027
1050
  if (postureFailures.length > 0)
1028
1051
  return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed posture would fail preflight: ${postureFailures.map(row => row.reason).join('; ')}`, postureQuestion.field);
1029
1052
  environment = parsedPosture.data;
1030
- record('environment.posture', postureRead.provenance);
1053
+ record('environment.posture', postureRead);
1031
1054
  answerIndex += 1;
1032
1055
  // Asked, never defaulted: a cockpit with no gate cannot grade work, and "none" is a decision
1033
1056
  // a person makes, not the absence of an answer.
@@ -1049,7 +1072,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
1049
1072
  ? `: ${issueSummary(parsedTesting.error)}` : ''}`, testingQuestion.field);
1050
1073
  testing = parsedTesting.data;
1051
1074
  }
1052
- record('testing', testingRead.provenance);
1075
+ record('testing', testingRead);
1053
1076
  answerIndex += 1;
1054
1077
  }
1055
1078
  // Left as schema defaults, both lists are empty and the guard denies nothing by host, which no
@@ -1069,7 +1092,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
1069
1092
  return refusal('DOCUMENT_CONFIGURATION_ANSWER_INVALID', `the operator-confirmed guard hosts must be exactly { hosted_db_hosts, vendor_endpoint_hosts }${parsedGuards
1070
1093
  ? `: ${issueSummary(parsedGuards.error)}` : ''}`, guardQuestion.field);
1071
1094
  const noGuardHosts = parsedGuards.data.hosted_db_hosts.length === 0 && parsedGuards.data.vendor_endpoint_hosts.length === 0;
1072
- record('guards', guardRead.provenance);
1095
+ record('guards', guardRead);
1073
1096
  answerIndex += 1;
1074
1097
  const candidate = CockpitConfig.safeParse({ ...(unconfirmed.length === 0 ? {} : { unconfirmed: unconfirmed.map(row => row.field) }),
1075
1098
  ...defaults, cockpit: { ...defaults.cockpit, name,
@@ -1083,7 +1106,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
1083
1106
  || configuration.classification === 'incomplete-multi-repository') && candidate.data.sibling.length < 2) {
1084
1107
  return refusal('DOCUMENT_CONFIGURATION_APPLICABILITY_INVALID', 'the sibling declarations do not match the current estate classification', 'sibling');
1085
1108
  }
1086
- const expectedSha256 = confirmationDigest(attributions);
1109
+ const expectedSha256 = confirmationDigest(attributions.filter(row => confirmedBy(row) === 'person'));
1087
1110
  const defaultsQuestion = configurationQuestion(context, 'configuration.defaults', QUESTION_WHY.defaults, expectedSha256);
1088
1111
  const defaultsRead = readConfigurationAnswer(configuration.answers, answerIndex, defaultsQuestion);
1089
1112
  if (defaultsRead.kind === 'pending')
@@ -1114,12 +1137,12 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
1114
1137
  : 'signed-in users: required but unresolved; declare seeded personas and browser authentication states before claiming signed-in testing'
1115
1138
  : `signed-in users: not declared because ${STATED_BY[testData.provenance]} this local setup does not need them`] : []),
1116
1139
  ];
1117
- const artifact = renderCompleteDocument(candidate.data, SCHEMA_DIRECTIVE, decisions, unconfirmed);
1140
+ const artifact = renderCompleteDocument(candidate.data, SCHEMA_DIRECTIVE, decisions, unconfirmed, derived);
1118
1141
  const diagnostics = diagnosticsFor(attributions, []);
1119
1142
  for (const row of unconfirmed)
1120
1143
  diagnostics.push({ severity: 'warning', code: 'DOCUMENT_ANSWER_UNCONFIRMED', target: row.field,
1121
1144
  message: `${unconfirmedReason(row)}, so cockpit.toml lists it under unconfirmed and doctor warns until a person confirms it`,
1122
- fix: UNCONFIRMED_FIX });
1145
+ fix: unconfirmedFix([row.field]) });
1123
1146
  if (noGuardHosts)
1124
1147
  diagnostics.push({ severity: 'info', code: 'DOCUMENT_GUARD_HOSTS_NONE', target: 'guards',
1125
1148
  message: `${noGuardHostsDecision(guardRead.provenance)}, so the guard denies no command by host`,
@@ -1129,7 +1152,7 @@ async function completeConfigurationDocument(input, root, registrySha256, detect
1129
1152
  message: 'no runnable product was observed, so this configuration declares no runtime environment and no Compose file: sync publishes the harness surfaces only, and up, logs and the other runtime commands refuse',
1130
1153
  fix: 'once a product runs, declare environment.compose_files and run npx --no cockpit sync --config <your cockpit.toml>' });
1131
1154
  return { kind: 'complete', artifact, destination, diagnostics,
1132
- result: { document: artifact, ...(unconfirmed.length === 0 ? {} : { unconfirmed: unconfirmed }) },
1155
+ result: { document: artifact, ...(unconfirmed.length === 0 ? {} : { unconfirmed }), ...(derived.length === 0 ? {} : { derived }) },
1133
1156
  status: 0, question: null };
1134
1157
  }
1135
1158
  export async function draftFromRegistry(input, estateGaps = [], estateRunnable = []) {