@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
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import {} from '../schema.js';
3
3
  import { sha256Hex } from '../sha256.js';
4
4
  import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, readdirSync, } from 'node:fs';
5
+ import { mkdir, open as openFile, rm } from 'node:fs/promises';
5
6
  import { basename, delimiter, dirname, isAbsolute, join, parse as parsePath, relative, resolve, sep } from 'node:path';
6
7
  import { isProxy } from 'node:util/types';
7
8
  import { readBoundedFile } from '../bounded-read.js';
@@ -18,21 +19,29 @@ import { isGuardSurfaceTarget } from '../surfaces/harness-registry.js';
18
19
  import { cockpitCommand } from '../fix-catalogue.js';
19
20
  import { stripDevelopmentMarkers } from '../surfaces/development-markers.js';
20
21
  import { decodeLifecycleRecord } from '../environment/application/lifecycle.js';
21
- import { resolveExecutablePath } from '../environment/executable.js';
22
+ import { selectLocalDockerDaemon } from '../environment/docker-daemon.js';
23
+ import { resolveDockerExecutable, resolveExecutablePath } from '../environment/executable.js';
24
+ import { discoverMachineStacks } from '../environment/reclaim.js';
22
25
  import { consumeWorkApplicationAuthorities, prepareWorkApplicationAuthorities, projectWorkApplicationAuthorities, } from '../environment/generated-application/authorities.js';
23
26
  import { prepareGeneratedEnvironmentCurrentness } from '../environment/generated-application/check.js';
24
27
  import { createLifecycleCommandAdapters } from '../environment/lifecycle-composition.js';
25
28
  import { createReadCommandAdapters } from '../environment/read-composition.js';
26
29
  import { deriveEntrypoints, observeRetainedPortFamily } from '../environment/ports.js';
27
30
  import { readState } from '../environment/state.js';
31
+ import { parseDiff } from '../testing/test-protection.js';
32
+ import { testFilePatterns, weakeningFindings } from '../testing/weakening.js';
28
33
  import { runGate } from '../testing/gates.js';
29
- import { parseCriteriaFile, parseWorkOrder, WORK_ORDER_ID } from './contract.js';
34
+ import { readSiblingCheckouts, missingServedCheckout, servedPath, servedSiblings } from '../environment/sibling-checkouts.js';
35
+ import { splitRepositoryPath } from '../repository-path.js';
36
+ import { parseCriteriaFile, parseWorkOrder, scopeMatcher, WORK_ORDER_ID } from './contract.js';
30
37
  import { createWorkStore, outputTail, WORK_RECORD_DIRECTORY } from './store.js';
31
38
  const issuedCompositions = new WeakSet();
32
39
  const MAX_DOCUMENT_BYTES = 8 * 1_048_576;
33
40
  const MAX_REPOSITORY_PATHS = 4_096;
34
41
  const MAX_REPOSITORY_FILE_BYTES = 8 * 1_048_576;
35
42
  const MAX_REPOSITORY_AGGREGATE_BYTES = 64 * 1_048_576;
43
+ const MAX_DECISION_RECORDS = 4_096;
44
+ const MAX_DECISION_RECORD_BYTES = 262_144;
36
45
  // What the grader is shown of one changed file, and of the whole change.
37
46
  const MAX_DIFF_BYTES = 65_536;
38
47
  const MAX_CHANGE_BYTES = 1_048_576;
@@ -200,6 +209,34 @@ function readBounded(root, relativePath, maximum = MAX_DOCUMENT_BYTES) {
200
209
  throw new RefusalError('contained file is missing');
201
210
  return readAnchoredLeaf(anchored, maximum, 'contained file');
202
211
  }
212
+ // Node has no openat, so a write cannot name its file relative to a directory descriptor. Every ancestor is
213
+ // anchored by descriptor instead, the leaf is opened without following a link, and nothing is written until
214
+ // the opened file is shown to be the single-link regular file at the confined name, under ancestors that are
215
+ // still the anchored directories: one swapped for a link while the file was opened fails that check.
216
+ async function openContainedForWrite(root, relativePath, flags) {
217
+ // No ancestor's contents are bound: the write itself changes the directory that holds the file.
218
+ const anchored = anchoredReadPath(root, relativePath, false, join(root, relativePath));
219
+ if (!anchored)
220
+ throw new RefusalError('contained path is missing');
221
+ try {
222
+ const handle = await openFile(anchored.target, flags | constants.O_WRONLY | constants.O_NOFOLLOW, 0o644);
223
+ try {
224
+ const opened = await handle.stat({ bigint: true });
225
+ const named = lstatSync(anchored.target, { bigint: true, throwIfNoEntry: false });
226
+ if (!opened.isFile() || opened.nlink !== 1n || opened.dev !== named?.dev || opened.ino !== named.ino || !readAnchorsCurrent(anchored.anchors)) {
227
+ throw new RefusalError('contained file or an ancestor changed while it was opened for writing; nothing was written');
228
+ }
229
+ return handle;
230
+ }
231
+ catch (error) {
232
+ await handle.close();
233
+ throw error;
234
+ }
235
+ }
236
+ finally {
237
+ closeReadAnchors(anchored.anchors);
238
+ }
239
+ }
203
240
  function readExactFile(path, maximum) {
204
241
  const anchored = anchoredAbsoluteReadPath(path);
205
242
  try {
@@ -395,11 +432,10 @@ function excludedRepositoryPath(path, excluded) {
395
432
  ? path === candidate.slice(0, -1) || path.startsWith(candidate)
396
433
  : path === candidate);
397
434
  }
398
- function repositoryEnvironmentInstancePaths(repositoryRoot, siblingsRoot, siblings) {
435
+ function repositoryEnvironmentInstancePaths(repositoryRoot, siblings) {
399
436
  const paths = new Set();
400
437
  for (const sibling of siblings) {
401
- const siblingRoot = resolve(siblingsRoot, sibling.dir);
402
- const prefix = relative(repositoryRoot, siblingRoot);
438
+ const prefix = relative(repositoryRoot, sibling.root);
403
439
  if (isAbsolute(prefix) || prefix === '..' || prefix.startsWith(`..${sep}`))
404
440
  continue;
405
441
  for (const file of sibling.environmentFiles)
@@ -549,12 +585,13 @@ async function observeRepositorySubject(repository, excluded, directories, sensi
549
585
  export function isWorkApplicationComposition(value) {
550
586
  return value !== null && typeof value === 'object' && issuedCompositions.has(value);
551
587
  }
552
- function createWorkApplicationComposition(config, authorities) {
588
+ function createWorkApplicationComposition(config, authorities, served) {
553
589
  if (!config || isProxy(config) || !boundedName(config.cockpitName) || !isAbsolute(config.cockpitRoot)
554
590
  || realpathSync(config.cockpitRoot) !== config.cockpitRoot || dirname(config.source.path) !== config.cockpitRoot
555
591
  || typeof config.siblingsRoot !== 'string' || !config.siblingsRoot || config.siblingsRoot.includes('\0')
556
592
  || !Array.isArray(config.siblings) || config.siblings.some(row => !boundedName(row?.slug) || typeof row?.dir !== 'string'
557
593
  || isAbsolute(row.dir) || row.dir !== '.' && row.dir.split(/[\\/]/).some((part) => !part || part === '.' || part === '..'))
594
+ || config.siblings.some(row => !isAbsolute(served.get(row.slug)?.served ?? ''))
558
595
  || !authorities || isProxy(authorities))
559
596
  throw new RefusalError('work application composition requires admitted source-bound configuration');
560
597
  const root = config.cockpitRoot;
@@ -564,19 +601,21 @@ function createWorkApplicationComposition(config, authorities) {
564
601
  const directories = authorities.executableDirectories ?? DEFAULT_EXECUTABLE_DIRECTORIES;
565
602
  const browserAuthenticationTargets = new Set((config.browser?.authentication.states ?? []).map(row => resolve(root, row.path)));
566
603
  // The cockpit's own repository is `.`; a sibling is named by its slug, and its paths read `<slug>:path`.
567
- // A sibling checked out at the cockpit root is that same repository, under the cockpit's key.
568
- const repositories = [{ key: '.', identity: config.cockpitName, root }, ...config.siblings.flatMap(row => resolve(siblingsRoot, row.dir) === root ? []
569
- : [{ key: row.slug, identity: `siblings/${row.slug}`, root: resolve(siblingsRoot, row.dir) }])]
570
- .map(repository => ({ ...repository, sensitivePaths: repositoryEnvironmentInstancePaths(repository.root, siblingsRoot, config.siblings) }));
571
- const keyOf = (key) => config.siblings.some(row => row.slug === key && resolve(siblingsRoot, row.dir) === root) ? '.' : key;
604
+ // A sibling is read where it is served, which `cockpit use` may have moved to a worktree, so a grade
605
+ // judges the code the stack runs. One served at the cockpit root is that same repository, under the cockpit's key.
606
+ const servedRoot = (slug) => servedPath(served, slug);
607
+ const siblingRoots = config.siblings.map(row => ({ root: servedRoot(row.slug), environmentFiles: row.environmentFiles }));
608
+ const repositories = [{ key: '.', identity: config.cockpitName, root }, ...config.siblings.flatMap(row => servedRoot(row.slug) === root ? []
609
+ : [{ key: row.slug, identity: `siblings/${row.slug}`, root: servedRoot(row.slug) }])]
610
+ .map(repository => ({ ...repository, sensitivePaths: repositoryEnvironmentInstancePaths(repository.root, siblingRoots) }));
611
+ const keyOf = (key) => config.siblings.some(row => row.slug === key && servedRoot(row.slug) === root) ? '.' : key;
572
612
  const qualified = (key, path) => key === '.' ? path : `${key}:${path}`;
573
613
  const located = (reference) => {
574
- const colon = reference.indexOf(':');
575
- const key = colon < 0 ? '.' : keyOf(reference.slice(0, colon));
614
+ const { slug, path } = splitRepositoryPath(reference);
615
+ const key = slug === undefined ? '.' : keyOf(slug);
576
616
  const repository = repositories.find(row => row.key === key);
577
617
  if (!repository)
578
618
  throw new RefusalError(`${reference} names no declared sibling ${key}`);
579
- const path = colon < 0 ? reference : reference.slice(colon + 1);
580
619
  admitRepositoryPath(repository.root, repository.identity, path, repository.sensitivePaths, browserAuthenticationTargets);
581
620
  return { repository, path };
582
621
  };
@@ -591,6 +630,100 @@ function createWorkApplicationComposition(config, authorities) {
591
630
  throw error;
592
631
  }
593
632
  };
633
+ const readDocsFile = async (reference) => {
634
+ const { repository, path } = located(reference);
635
+ try {
636
+ return readBounded(repository.root, path, MAX_DECISION_RECORD_BYTES).toString('utf8');
637
+ }
638
+ catch (error) {
639
+ if (hasErrorCode(error, 'ENOENT', 'ENOTDIR'))
640
+ return undefined;
641
+ throw error;
642
+ }
643
+ };
644
+ const decisionRecords = async () => {
645
+ const declared = config.docs?.decisions;
646
+ if (!declared)
647
+ return new Map();
648
+ const { repository, path } = located(declared);
649
+ let names;
650
+ try {
651
+ names = readdirSync(contained(repository.root, path, true));
652
+ }
653
+ catch (error) {
654
+ if (hasErrorCode(error, 'ENOENT'))
655
+ return new Map();
656
+ throw error;
657
+ }
658
+ const records = names.filter(name => name.endsWith('.md') && lstatSync(join(repository.root, path, name)).isFile()).sort();
659
+ if (records.length > MAX_DECISION_RECORDS)
660
+ throw new RefusalError(`[docs] decisions ${declared} holds more than ${MAX_DECISION_RECORDS} records, more than cockpit reads`);
661
+ return new Map(records.map(name => [`${declared}/${name}`, readBounded(repository.root, `${path}/${name}`, MAX_DECISION_RECORD_BYTES).toString('utf8')]));
662
+ };
663
+ // The only files cockpit writes outside .cockpit/: a decision record it scaffolds or marks superseded, and the index line.
664
+ // Each is written through a descriptor opened by openContainedForWrite; a replacement is written in place, as a
665
+ // rename would name its target by path again.
666
+ const writeContained = async (repository, path, flags, text, replace = false) => {
667
+ const handle = await openContainedForWrite(repository.root, path, flags);
668
+ try {
669
+ if (replace)
670
+ await handle.truncate(0);
671
+ await handle.writeFile(text);
672
+ }
673
+ finally {
674
+ await handle.close();
675
+ }
676
+ };
677
+ // A write that fails part way puts back what it changed, so a rerun does not number a second record or mark the old one twice.
678
+ const writeDecisionRecords = async (writes, indexLine) => {
679
+ const undo = [];
680
+ try {
681
+ for (const { reference, text, create } of writes) {
682
+ const { repository, path } = located(reference);
683
+ await mkdir(dirname(contained(repository.root, path, true)), { recursive: true });
684
+ const target = contained(repository.root, path, true);
685
+ if (create) {
686
+ try {
687
+ await writeContained(repository, path, constants.O_CREAT | constants.O_EXCL, text);
688
+ }
689
+ catch (error) {
690
+ if (hasErrorCode(error, 'EEXIST'))
691
+ throw new RefusalError(`${reference} already exists; rerun work decide, which takes the next free name`);
692
+ throw error;
693
+ }
694
+ undo.push(() => rm(target, { force: true }));
695
+ continue;
696
+ }
697
+ const previous = readBounded(repository.root, path, MAX_DECISION_RECORD_BYTES).toString('utf8');
698
+ // Queued before the write: an in-place write that fails after truncating has already changed the record.
699
+ undo.push(() => writeContained(repository, path, 0, previous, true));
700
+ await writeContained(repository, path, 0, text, true);
701
+ }
702
+ if (indexLine === undefined)
703
+ return;
704
+ if (!config.docs?.decisionsIndex)
705
+ throw new RefusalError('an index line was given but [docs] decisions_index is not declared');
706
+ const index = located(config.docs.decisionsIndex);
707
+ const current = readBounded(index.repository.root, index.path, MAX_DECISION_RECORD_BYTES).toString('utf8');
708
+ await writeContained(index.repository, index.path, constants.O_APPEND, `${current === '' || current.endsWith('\n') ? '' : '\n'}${indexLine}\n`);
709
+ }
710
+ catch (error) {
711
+ // Every step is tried, so one that fails does not leave the files after it changed.
712
+ const failed = [];
713
+ for (const step of undo.reverse()) {
714
+ try {
715
+ await step();
716
+ }
717
+ catch (undoError) {
718
+ failed.push(errorCause(undoError));
719
+ }
720
+ }
721
+ if (failed.length)
722
+ throw new RefusalError(`${errorCause(error)}; putting back the decision records also failed (${failed.join('; ')}); `
723
+ + 'compare the [docs] decisions directory with git diff and restore what changed with git restore');
724
+ throw error;
725
+ }
726
+ };
594
727
  const git = async (repository, args) => {
595
728
  const observed = await executeBounded(repository.root, ['git', ...args], directories);
596
729
  if (observed.kind !== 'executed' || observed.exitCode !== 0 || observed.truncated)
@@ -621,12 +754,12 @@ function createWorkApplicationComposition(config, authorities) {
621
754
  throw error;
622
755
  }
623
756
  };
624
- const browser = async (kind, role) => {
757
+ const browser = async (kind, role, route) => {
625
758
  if (!config.browser)
626
759
  throw new RefusalError('browser application projection is unavailable');
627
760
  const authority = await resolveBrowser();
628
761
  return { runtime: authority.runtime, client: authority.client, policy: authority.policy, kind,
629
- ...(authority.state ? { state: authority.state } : {}), ...(role ? { role } : {}) };
762
+ ...(authority.state ? { state: authority.state } : {}), ...(role ? { role } : {}), ...(route ? { route } : {}) };
630
763
  };
631
764
  const gate = async (request) => {
632
765
  if (!authorities.testing || !config.testing)
@@ -690,9 +823,10 @@ function createWorkApplicationComposition(config, authorities) {
690
823
  throw new RefusalError(`${repository.identity} at ${repository.root} has no commit yet; commit once there`);
691
824
  return [repository.key, head];
692
825
  })));
826
+ const untrackedFiles = async () => new Map(await Promise.all(repositories.map(async (repository) => [repository.key, (await git(repository, ['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean)])));
693
827
  // What changed since the base: committed, staged, unstaged and untracked files, each with its diff
694
828
  // or new content. A credential-shaped or declared-instance path is listed but its content is withheld.
695
- const changes = async (base) => {
829
+ const changesWith = async (base, untrackedPaths) => {
696
830
  const files = [];
697
831
  let budget = MAX_CHANGE_BYTES;
698
832
  // What sync generates is cockpit's, not the order's: the paths the ownership manifest lists.
@@ -716,7 +850,7 @@ function createWorkApplicationComposition(config, authorities) {
716
850
  const rows = [];
717
851
  for (let index = 0; index + 1 < tracked.length; index += 2)
718
852
  rows.push([tracked[index], tracked[index + 1], false]);
719
- for (const path of (await git(repository, ['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean))
853
+ for (const path of untrackedPaths.get(repository.key))
720
854
  rows.push(['A', path, true]);
721
855
  for (const [status, path, untracked] of rows) {
722
856
  if (repository.key === '.' && (excludedRepositoryPath(path, COCKPIT_WRITTEN) || generated.has(path)))
@@ -741,6 +875,40 @@ function createWorkApplicationComposition(config, authorities) {
741
875
  }
742
876
  return files;
743
877
  };
878
+ // Test weakening since the base, per checkout: committed, staged and unstaged edits of tracked files,
879
+ // and new untracked test files, which a diff against the base does not list.
880
+ const weakeningWith = async (base, untracked) => {
881
+ const patterns = testFilePatterns(config.testing?.testFiles);
882
+ const isTest = scopeMatcher(patterns.tests);
883
+ const perRepository = await Promise.all(repositories.map(async (repository) => {
884
+ const from = base[repository.key] ?? 'HEAD';
885
+ const diff = await git(repository, ['-c', 'core.quotePath=true', 'diff', '--no-ext-diff', '--no-textconv', '--no-color', '--text', '--find-renames', '--unified=0', from, '--']);
886
+ const files = parseDiff(diff);
887
+ for (const path of untracked.get(repository.key)) {
888
+ if (!isTest(path))
889
+ continue;
890
+ let content;
891
+ try {
892
+ admitRepositoryPath(repository.root, repository.identity, path, repository.sensitivePaths, browserAuthenticationTargets);
893
+ content = readBounded(repository.root, path, MAX_DIFF_BYTES).toString('utf8');
894
+ }
895
+ catch (error) {
896
+ if (error instanceof RefusalError)
897
+ continue;
898
+ throw error;
899
+ }
900
+ files.push({ old: '/dev/null', next: path, hunk: true, removed: false, removedLines: [], renamed: false, deleted: false, added: content.split('\n') });
901
+ }
902
+ return weakeningFindings(files, patterns).map(row => ({ ...row, repository: repository.key }));
903
+ }));
904
+ return { patterns, findings: perRepository.flat() };
905
+ };
906
+ const testWeakening = async (base) => weakeningWith(base, await untrackedFiles());
907
+ const review = async (base) => {
908
+ const untracked = await untrackedFiles();
909
+ const [changed, weakening] = await Promise.all([changesWith(base, untracked), weakeningWith(base, untracked)]);
910
+ return { changes: changed, weakening };
911
+ };
744
912
  const gradeIndependently = async (subject) => {
745
913
  let evaluator;
746
914
  try {
@@ -764,13 +932,29 @@ function createWorkApplicationComposition(config, authorities) {
764
932
  return { refusal: { code: ENVIRONMENT_CODES.EVALUATOR_ANSWER_INVALID, message: read.problem, fix: 'rerun the command; if the answer is refused again, list another evaluator first in work.evaluators' } };
765
933
  return { criteria: read.criteria, evaluator: { tool: result.tool, fallbacks: result.fallbacks } };
766
934
  };
935
+ const containers = async () => {
936
+ const env = authorities.env;
937
+ const docker = await resolveDockerExecutable(env);
938
+ if (docker.state !== 'available')
939
+ return { state: 'no-docker', reason: docker.reason };
940
+ const daemon = await selectLocalDockerDaemon(env, 30_000, executeProbe, { command: 'work recover',
941
+ localOnly: 'a lock holder can be looked for only among the containers on this machine' });
942
+ if (daemon.kind === 'refusal')
943
+ return { state: 'unavailable', reason: daemon.diagnostic.message };
944
+ const listed = await discoverMachineStacks({ executable: docker.value, context: daemon.dockerContext, ...env.DOCKER_HOST === undefined ? {} : { host: env.DOCKER_HOST },
945
+ cwd: root, env: { PATH: env.PATH ?? '' }, timeoutMs: 30_000 }, executeProbe);
946
+ if (!listed)
947
+ return { state: 'unavailable', reason: `the containers cockpit labels could not be listed on Docker context ${daemon.dockerContext}; start Docker, or select the local context that runs cockpit stacks` };
948
+ return { state: 'listed', context: daemon.dockerContext, containers: listed.resources.filter(row => row.kind === 'container').map(({ id, name, running }) => ({ id, name, running })) };
949
+ };
767
950
  const composition = {
768
951
  policy: config.policy, caps: { wip: config.wipCap, attempts: config.attemptCap }, env: authorities.env,
769
952
  store: createWorkStore(root), recordedPolicy: () => readRecordedPolicy(root), orderIds, readOrder,
770
953
  readCriteriaFile: async (path) => parseCriteriaFile(readBounded(root, path, 65_536)),
771
954
  sourceDigests: async (order) => Object.fromEntries(order.criteria.flatMap(row => row.source ? [row.source] : [])
772
955
  .map(source => [source, sha256Hex(readSource(source))])),
773
- heads, runCheck, changes, evaluate: gradeIndependently, browser, gate,
956
+ heads, runCheck, evaluate: gradeIndependently, testWeakening, review, browser, gate,
957
+ ...config.docs ? { docs: config.docs } : {}, decisionRecords, readDocsFile, writeDecisionRecords, containers,
774
958
  };
775
959
  issuedCompositions.add(composition);
776
960
  return composition;
@@ -970,14 +1154,22 @@ export async function createConfiguredWorkApplicationComposition(input, options
970
1154
  namespace: browserAuthority.namespace, stackId: loaded.result.stackId, ownerId: record.value.holderId,
971
1155
  } } : {}) };
972
1156
  };
973
- const configuredSiblingsRoot = resolve(config.cockpitRoot, config.siblingsRoot);
1157
+ // Work orders and gates read each sibling where `cockpit use` serves it, as the stack does.
1158
+ const recorded = await readSiblingCheckouts(config.cockpitRoot);
1159
+ if (recorded.kind !== 'complete')
1160
+ return { kind: 'refusal', reason: `${recorded.diagnostic.code}: ${recorded.diagnostic.message}; ${recorded.diagnostic.fix}` };
1161
+ const served = servedSiblings(config.cockpitRoot, { cockpit: { siblings_root: config.siblingsRoot }, sibling: config.siblings }, recorded.checkouts);
1162
+ const gone = await missingServedCheckout(served);
1163
+ if (gone)
1164
+ return { kind: 'refusal', reason: `${gone.code}: ${gone.message}; ${gone.fix}` };
1165
+ const siblingRoots = config.siblings.map(row => ({ root: servedPath(served, row.slug), environmentFiles: row.environmentFiles }));
974
1166
  const gateBrowserAuthenticationTargets = new Set((config.browser?.authentication.states ?? []).map(row => resolve(config.cockpitRoot, row.path)));
975
1167
  const gateRepositories = [
976
1168
  { identity: config.cockpitName, root: config.cockpitRoot,
977
- sensitivePaths: repositoryEnvironmentInstancePaths(config.cockpitRoot, configuredSiblingsRoot, config.siblings),
1169
+ sensitivePaths: repositoryEnvironmentInstancePaths(config.cockpitRoot, siblingRoots),
978
1170
  forbiddenTargets: gateBrowserAuthenticationTargets },
979
- ...config.siblings.map(row => ({ identity: `siblings/${row.slug}`, root: resolve(configuredSiblingsRoot, row.dir),
980
- sensitivePaths: repositoryEnvironmentInstancePaths(resolve(configuredSiblingsRoot, row.dir), configuredSiblingsRoot, config.siblings),
1171
+ ...siblingRoots.map((row, index) => ({ identity: `siblings/${config.siblings[index].slug}`, root: row.root,
1172
+ sensitivePaths: repositoryEnvironmentInstancePaths(row.root, siblingRoots),
981
1173
  forbiddenTargets: gateBrowserAuthenticationTargets })),
982
1174
  ];
983
1175
  const reads = createReadCommandAdapters({ configPath: captured.configPath, env: captured.env,
@@ -1001,7 +1193,7 @@ export async function createConfiguredWorkApplicationComposition(input, options
1001
1193
  ...(config.browser ? { resolveBrowser } : {}),
1002
1194
  testing: { registry: authority.testing.registry,
1003
1195
  dependencies: productGateDependencies(config.cockpitRoot, config.source, gateRepositories, declaredEnvironment, executableDirectories), runtime: gateRuntime },
1004
- });
1196
+ }, served);
1005
1197
  return { kind: 'complete', composition };
1006
1198
  }
1007
1199
  catch (error) {
@@ -34,7 +34,7 @@ export type ParsedFile<T> = Readonly<{
34
34
  /** Parses `workorders/<id>.md`; the id the file declares must be the name it is stored under. */
35
35
  export declare function parseWorkOrder(bytes: Uint8Array, fileId: string): ParsedFile<WorkOrder>;
36
36
  /** The quick-path file `cockpit grade --criteria` reads: front-matter with criteria, and optionally scope and gates. */
37
- export declare const CriteriaFile: z.ZodObject<{
37
+ declare const CriteriaFile: z.ZodObject<{
38
38
  criteria: z.ZodArray<z.ZodObject<{
39
39
  id: z.ZodString;
40
40
  text: z.ZodString;
@@ -51,11 +51,27 @@ export declare const CriteriaFile: z.ZodObject<{
51
51
  export declare function parseCriteriaFile(bytes: Uint8Array): ParsedFile<z.infer<typeof CriteriaFile>>;
52
52
  /** The digest an acceptance binds: the contract, each cited source's bytes, and the base commits. */
53
53
  export declare function workOrderDigest(order: WorkOrder, sources: Readonly<Record<string, string>>, base: Readonly<Record<string, string>>): string;
54
+ /** One part of a contract an amendment changed in a way that may lower the bar, as it read before and after. */
55
+ export type AmendmentReview = Readonly<{
56
+ field: string;
57
+ before: string;
58
+ after: string;
59
+ }>;
60
+ type ContractAmendment = Readonly<{
61
+ removed: readonly string[];
62
+ review: readonly AmendmentReview[];
63
+ }>;
54
64
  /**
55
- * The accepted criteria a changed contract removes or rewrites. Adding a criterion strengthens an
56
- * order; dropping one, or changing what one says or how it is checked, weakens it, and that is never
57
- * an agent's decision.
65
+ * How a changed contract moves the bar. Dropping an accepted criterion or gate lowers it in every
66
+ * reading, so it is `removed`. A rewritten criterion, a widened scope, a dropped or reworded non-goal
67
+ * or a new objective may lower it or only clarify it: no byte comparison can tell which, so each is
68
+ * returned for review with its text before and after. Adding a criterion or gate, narrowing scope and
69
+ * adding a non-goal only raise the bar.
58
70
  */
59
- export declare function criteriaWeakening(accepted: WorkOrder, changed: WorkOrder): readonly string[];
60
- export declare function scopeMatches(scope: readonly string[], path: string): boolean;
71
+ export declare function contractAmendment(accepted: WorkOrder, changed: WorkOrder): ContractAmendment;
72
+ /** The review fields a `checks` grade enforces: the criteria's checks and the scope. Objective and non-goals bind only where a model grades. */
73
+ export declare const checksBindsField: (field: string) => boolean;
74
+ /** Whether a path falls in `scope`, with each glob compiled once for the many paths a caller tests. */
75
+ export declare function scopeMatcher(scope: readonly string[]): (path: string) => boolean;
76
+ export declare const scopeMatches: (scope: readonly string[], path: string) => boolean;
61
77
  export {};
@@ -5,13 +5,12 @@
5
5
  import { createHash } from 'node:crypto';
6
6
  import { parseDocument } from 'yaml';
7
7
  import { z } from 'zod';
8
+ import { REPOSITORY_PATH } from '../repository-path.js';
8
9
  // One inert word, never a path or an option: the CLI, the guard and the store each admit it before it names a file.
9
10
  export const WORK_ORDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
11
  const WORK_ORDER_LIMITS = Object.freeze({ bytes: 65_536, criteria: 12, scope: 64, textBytes: 2_048, arguments: 32, argumentBytes: 2_048 });
11
12
  const PRINTABLE = /^[^\x00-\x08\x0b-\x1f\x7f]*$/;
12
13
  const text = z.string().trim().min(1).refine(value => Buffer.byteLength(value) <= WORK_ORDER_LIMITS.textBytes && PRINTABLE.test(value), `at most ${WORK_ORDER_LIMITS.textBytes} bytes without control characters`);
13
- // `path` in the cockpit's own repository, or `<sibling slug>:path` in a sibling; never absolute or climbing.
14
- const REPOSITORY_PATH = /^(?:[a-z0-9][a-z0-9-]{0,63}:)?(?![/])(?!.*(?:^|[/:])\.\.(?:\/|$))[A-Za-z0-9._@+\-/*?]+$/;
15
14
  const repositoryPath = z.string().max(1_024).regex(REPOSITORY_PATH, 'a relative path, optionally <sibling slug>:path, without .. or a leading /');
16
15
  const sourcePath = repositoryPath.refine(value => !/[*?]/.test(value), 'a source names one file, without wildcards');
17
16
  const argument = z.string().min(1).refine(value => Buffer.byteLength(value) <= WORK_ORDER_LIMITS.argumentBytes && !/[\x00-\x1f\x7f]/.test(value), 'a bounded argument without control characters');
@@ -77,7 +76,7 @@ export function parseWorkOrder(bytes, fileId) {
77
76
  return { ok: true, value: parsed.data };
78
77
  }
79
78
  /** The quick-path file `cockpit grade --criteria` reads: front-matter with criteria, and optionally scope and gates. */
80
- export const CriteriaFile = z.object({ criteria: Criteria, scope: z.array(repositoryPath).max(WORK_ORDER_LIMITS.scope).default([]),
79
+ const CriteriaFile = z.object({ criteria: Criteria, scope: z.array(repositoryPath).max(WORK_ORDER_LIMITS.scope).default([]),
81
80
  gates: z.array(z.string().regex(/^[a-z][a-z0-9-]{0,63}$/)).max(16).default([]), objective: text.optional() }).strict();
82
81
  export function parseCriteriaFile(bytes) {
83
82
  const read = frontMatter(bytes);
@@ -97,26 +96,45 @@ function canonical(value) {
97
96
  export function workOrderDigest(order, sources, base) {
98
97
  return createHash('sha256').update(JSON.stringify(canonical({ order, sources, base }))).digest('hex');
99
98
  }
99
+ const criterionText = (row) => `${row.text}${row.check ? ` [check: ${JSON.stringify(row.check)}]` : ''}${row.source ? ` [source: ${row.source}]` : ''}`;
100
100
  /**
101
- * The accepted criteria a changed contract removes or rewrites. Adding a criterion strengthens an
102
- * order; dropping one, or changing what one says or how it is checked, weakens it, and that is never
103
- * an agent's decision.
101
+ * How a changed contract moves the bar. Dropping an accepted criterion or gate lowers it in every
102
+ * reading, so it is `removed`. A rewritten criterion, a widened scope, a dropped or reworded non-goal
103
+ * or a new objective may lower it or only clarify it: no byte comparison can tell which, so each is
104
+ * returned for review with its text before and after. Adding a criterion or gate, narrowing scope and
105
+ * adding a non-goal only raise the bar.
104
106
  */
105
- export function criteriaWeakening(accepted, changed) {
106
- return accepted.criteria.flatMap(before => {
107
+ export function contractAmendment(accepted, changed) {
108
+ const removed = [...accepted.criteria.filter(before => !changed.criteria.some(row => row.id === before.id)).map(row => `${row.id} is removed`),
109
+ ...accepted.gates.filter(gate => !changed.gates.includes(gate)).map(gate => `gate ${gate} is removed`)];
110
+ const review = accepted.criteria.flatMap(before => {
107
111
  const after = changed.criteria.find(row => row.id === before.id);
108
- if (!after)
109
- return [`${before.id} is removed`];
110
- return JSON.stringify(canonical(after)) === JSON.stringify(canonical(before)) ? [] : [`${before.id} is rewritten`];
112
+ return after && JSON.stringify(canonical(after)) !== JSON.stringify(canonical(before))
113
+ ? [{ field: `criteria.${before.id}`, before: criterionText(before), after: criterionText(after) }] : [];
111
114
  });
115
+ if (changed.scope.some(entry => !accepted.scope.includes(entry)))
116
+ review.push({ field: 'scope', before: accepted.scope.join(', '), after: changed.scope.join(', ') });
117
+ if (accepted.non_goals.some(goal => !changed.non_goals.includes(goal)))
118
+ review.push({ field: 'non_goals', before: accepted.non_goals.join('; '), after: changed.non_goals.join('; ') });
119
+ if (accepted.objective !== changed.objective)
120
+ review.push({ field: 'objective', before: accepted.objective, after: changed.objective });
121
+ return { removed, review };
112
122
  }
123
+ /** The review fields a `checks` grade enforces: the criteria's checks and the scope. Objective and non-goals bind only where a model grades. */
124
+ export const checksBindsField = (field) => field === 'scope' || field.startsWith('criteria.');
113
125
  // `**` crosses directories, `*` and `?` stay within one; a plain entry also covers what is below it.
114
- export function scopeMatches(scope, path) {
115
- return scope.some(entry => {
116
- if (!/[*?]/.test(entry))
117
- return path === entry || path.startsWith(`${entry.replace(/\/$/, '')}/`);
126
+ /** Whether a path falls in `scope`, with each glob compiled once for the many paths a caller tests. */
127
+ export function scopeMatcher(scope) {
128
+ const tests = scope.map((entry) => {
129
+ if (!/[*?]/.test(entry)) {
130
+ const directory = `${entry.replace(/\/$/, '')}/`;
131
+ return path => path === entry || path.startsWith(directory);
132
+ }
118
133
  const pattern = entry.split(/(\*\*\/?|\*|\?)/).map(part => part === '**' || part === '**/' ? '(?:.*/)?' + (part === '**' ? '.*' : '')
119
134
  : part === '*' ? '[^/]*' : part === '?' ? '[^/]' : part.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('');
120
- return new RegExp(`^${pattern}$`).test(path);
135
+ const compiled = new RegExp(`^${pattern}$`);
136
+ return path => compiled.test(path);
121
137
  });
138
+ return path => tests.some(test => test(path));
122
139
  }
140
+ export const scopeMatches = (scope, path) => scopeMatcher(scope)(path);
@@ -0,0 +1,23 @@
1
+ type RecordName = Readonly<{
2
+ name: string;
3
+ number?: string;
4
+ }>;
5
+ /** The next record's file name: the next free number, at least four digits wide, unless the directory names records by date. */
6
+ export declare function nextRecordName(existing: readonly string[], title: string, today: string): RecordName;
7
+ /** A single-line title, safe inside a heading and a Markdown link label. */
8
+ export declare function recordTitle(text: string): string;
9
+ export declare function scaffoldRecord(input: Readonly<{
10
+ title: string;
11
+ number?: string;
12
+ workOrder: string;
13
+ supersedes?: string;
14
+ }>): string;
15
+ export declare const holdsPlaceholders: (text: string) => boolean;
16
+ /** The work orders a record cites, from a `Work order:` line or a `work_order:` front-matter field. */
17
+ export declare function citedOrders(text: string): readonly string[];
18
+ /**
19
+ * Whether a record names a path inside `scope`. A path-like word is one with a slash; a record kept
20
+ * in a sibling names that sibling's paths without its prefix, so `prefix` qualifies them.
21
+ */
22
+ export declare function namesScope(text: string, scope: readonly string[], prefix: string): boolean;
23
+ export {};
@@ -0,0 +1,55 @@
1
+ // Owns the decision records a work order cites, read and written as text: how a new record is named,
2
+ // the template cockpit scaffolds, whether a record still holds that template's placeholders, which
3
+ // order it cites, and which paths it names. The integrity of the whole directory (duplicate numbers,
4
+ // an index that misses a file) is the docs check's; this module reads only what an order needs.
5
+ import { isDatedRecordName, recordNumber } from '../docs/decision-record.js';
6
+ import { splitRepositoryPath } from '../repository-path.js';
7
+ import { scopeMatcher, WORK_ORDER_ID } from './contract.js';
8
+ // Every placeholder the template writes carries this, so a record nobody filled in is detectable.
9
+ const PLACEHOLDER = 'cockpit: replace this';
10
+ /** The next record's file name: the next free number, at least four digits wide, unless the directory names records by date. */
11
+ export function nextRecordName(existing, title, today) {
12
+ const records = existing.filter(name => name.endsWith('.md'));
13
+ const numbers = records.flatMap(name => recordNumber(name) ?? []);
14
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64).replace(/-+$/, '') || 'decision';
15
+ if (!numbers.length && records.some(isDatedRecordName)) {
16
+ // Two decisions with one title on one day would otherwise share a name.
17
+ let name = `${today}-${slug}.md`;
18
+ for (let suffix = 2; records.includes(name); suffix++)
19
+ name = `${today}-${slug}-${suffix}.md`;
20
+ return { name };
21
+ }
22
+ const next = numbers.reduce((max, value) => Math.max(max, Number(value)), 0) + 1;
23
+ const number = String(next).padStart(Math.max(4, ...numbers.map(value => value.length)), '0');
24
+ return { name: `${number}-${slug}.md`, number };
25
+ }
26
+ /** A single-line title, safe inside a heading and a Markdown link label. */
27
+ export function recordTitle(text) {
28
+ return text.replace(/\s+/g, ' ').trim().replace(/[[\]]/g, '');
29
+ }
30
+ export function scaffoldRecord(input) {
31
+ return [`# ${input.number ? `${input.number}. ` : ''}${input.title}`, '', 'Status: accepted', `Work order: ${input.workOrder}`,
32
+ ...input.supersedes ? [`Supersedes: ${input.supersedes}`] : [], '',
33
+ '## Context', '', `<!-- ${PLACEHOLDER} with what made a decision necessary. -->`, '',
34
+ '## Decision', '', `<!-- ${PLACEHOLDER} with what was decided. -->`, '',
35
+ '## Consequences', '', `<!-- ${PLACEHOLDER} with what becomes easier or harder because of it. -->`, ''].join('\n');
36
+ }
37
+ export const holdsPlaceholders = (text) => text.includes(PLACEHOLDER);
38
+ /** The work orders a record cites, from a `Work order:` line or a `work_order:` front-matter field. */
39
+ export function citedOrders(text) {
40
+ return [...text.matchAll(/^\**work[ _-]order\**:\**[ \t]*(.+)$/gim)]
41
+ .flatMap(match => match[1].split(/[\s,`]+/)).filter(id => id && WORK_ORDER_ID.test(id));
42
+ }
43
+ /**
44
+ * Whether a record names a path inside `scope`. A path-like word is one with a slash; a record kept
45
+ * in a sibling names that sibling's paths without its prefix, so `prefix` qualifies them.
46
+ */
47
+ export function namesScope(text, scope, prefix) {
48
+ const inScope = scopeMatcher(scope);
49
+ for (const match of text.matchAll(/(?:^|[\s`(<"'[])((?:[a-z0-9][a-z0-9-]{0,63}:)?[A-Za-z0-9._@+-]+(?:\/[A-Za-z0-9._@+-]*)+)/g)) {
50
+ const path = match[1].replace(/^\.\//, '').replace(/[.,;:]+$/, '');
51
+ if (inScope(splitRepositoryPath(path).slug === undefined ? `${prefix}${path}` : path))
52
+ return true;
53
+ }
54
+ return false;
55
+ }