devflow-kit 3.8.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +6 -6
  3. package/dist/agents/code.md +10 -10
  4. package/dist/agents/fix.md +7 -7
  5. package/dist/agents/git.md +2 -2
  6. package/dist/agents/harden.md +7 -6
  7. package/dist/agents/refactor.md +6 -6
  8. package/dist/agents/skim.md +1 -1
  9. package/dist/agents/triage.md +7 -0
  10. package/dist/cli/commands/ambient.js +10 -2
  11. package/dist/cli/commands/compliance-prompts.js +9 -4
  12. package/dist/cli/commands/compliance.js +171 -24
  13. package/dist/cli/commands/curation.js +79 -23
  14. package/dist/cli/commands/gate-level-prompts.js +39 -23
  15. package/dist/cli/commands/gates.js +6 -7
  16. package/dist/cli/commands/init.js +150 -145
  17. package/dist/cli/commands/install-report.js +34 -0
  18. package/dist/cli/commands/legacy-sweep.js +64 -0
  19. package/dist/cli/commands/memory.js +59 -12
  20. package/dist/cli/commands/proxy.js +6 -1
  21. package/dist/cli/commands/rules.js +29 -12
  22. package/dist/cli/commands/skills.js +42 -9
  23. package/dist/cli/commands/tracker.js +9 -3
  24. package/dist/cli/commands/uninstall.js +354 -60
  25. package/dist/commands/bug-analysis.md +71 -122
  26. package/dist/commands/code-review.md +11 -11
  27. package/dist/commands/debug.md +2 -2
  28. package/dist/commands/dynamic-build.md +25 -25
  29. package/dist/commands/dynamic-plan.md +2 -2
  30. package/dist/commands/dynamic-profile.md +2 -2
  31. package/dist/commands/dynamic-tickets.md +2 -2
  32. package/dist/commands/explore.md +2 -2
  33. package/dist/commands/implement.md +48 -34
  34. package/dist/commands/plan.md +2 -2
  35. package/dist/commands/release.md +3 -3
  36. package/dist/commands/research.md +2 -2
  37. package/dist/commands/resolve.md +50 -46
  38. package/dist/commands/self-review.md +10 -9
  39. package/dist/compliance-off/agents/git.md +120 -0
  40. package/dist/compliance-off/agents/triage.md +180 -0
  41. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  42. package/dist/compliance-off/commands/code-review.md +520 -0
  43. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  44. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  45. package/dist/compliance-off/commands/implement.md +736 -0
  46. package/dist/compliance-off/commands/plan.md +671 -0
  47. package/dist/compliance-off/commands/release.md +309 -0
  48. package/dist/compliance-off/commands/resolve.md +742 -0
  49. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  50. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  51. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  52. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  53. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  54. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  55. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  56. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  57. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  58. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  59. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  60. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  61. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  62. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  63. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  64. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  65. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  66. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  67. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  68. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  69. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  70. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  71. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  72. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  73. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  74. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  75. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  76. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  77. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  78. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  79. package/dist/core/agent-models.js +19 -4
  80. package/dist/core/area-pause-notice.js +44 -0
  81. package/dist/core/assets.js +126 -17
  82. package/dist/core/compliance-off-files.js +27 -0
  83. package/dist/core/curation-move-skip.js +4 -5
  84. package/dist/core/curation-queue-cleanup.js +6 -5
  85. package/dist/core/curation-store.js +1 -0
  86. package/dist/core/curation-variants.js +460 -112
  87. package/dist/core/evidence-policy.js +10 -8
  88. package/dist/core/flags.js +2 -2
  89. package/dist/core/installed-variants.js +63 -0
  90. package/dist/core/manifest.js +48 -5
  91. package/dist/core/mds-variants.js +700 -3
  92. package/dist/core/orphan-sweep.js +11 -2
  93. package/dist/core/plugins.js +69 -12
  94. package/dist/core/tracked-areas.js +138 -14
  95. package/dist/curation-compliance-off/agents/triage.md +176 -0
  96. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  97. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  98. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  99. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  100. package/dist/curation-compliance-off/commands/implement.md +689 -0
  101. package/dist/curation-compliance-off/commands/plan.md +631 -0
  102. package/dist/curation-compliance-off/commands/release.md +279 -0
  103. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  104. package/dist/curation-off/agents/code.md +10 -10
  105. package/dist/curation-off/agents/fix.md +7 -7
  106. package/dist/curation-off/agents/harden.md +7 -6
  107. package/dist/curation-off/agents/refactor.md +6 -6
  108. package/dist/curation-off/agents/triage.md +7 -0
  109. package/dist/curation-off/commands/bug-analysis.md +69 -119
  110. package/dist/curation-off/commands/code-review.md +11 -11
  111. package/dist/curation-off/commands/debug.md +2 -2
  112. package/dist/curation-off/commands/dynamic-build.md +25 -25
  113. package/dist/curation-off/commands/explore.md +2 -2
  114. package/dist/curation-off/commands/implement.md +48 -34
  115. package/dist/curation-off/commands/plan.md +2 -2
  116. package/dist/curation-off/commands/release.md +1 -1
  117. package/dist/curation-off/commands/resolve.md +50 -46
  118. package/dist/curation-off/commands/self-review.md +10 -9
  119. package/dist/hud/components/curation-counts.js +27 -0
  120. package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
  121. package/dist/skills/git/references/pr/post-review-summary.md +12 -11
  122. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  123. package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
  124. package/dist/skills/git/references/publication-gate.md +6 -7
  125. package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
  126. package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
  127. package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
  128. package/dist/targets/claude-code/behavior-install.js +23 -9
  129. package/dist/targets/claude-code/charter-render.js +374 -0
  130. package/dist/targets/claude-code/compliance-install.js +32 -12
  131. package/dist/targets/claude-code/compliance-off-files.js +245 -0
  132. package/dist/targets/claude-code/curation-install.js +570 -100
  133. package/dist/targets/claude-code/installer.js +467 -62
  134. package/dist/targets/claude-code/language-stamp.js +6 -0
  135. package/dist/targets/claude-code/linked-folder.js +182 -0
  136. package/dist/targets/claude-code/reference-converge.js +118 -0
  137. package/dist/targets/claude-code/tracker-install.js +21 -8
  138. package/dist/targets/claude-code/tracker-servers.js +20 -6
  139. package/dist/targets/claude-code/workflow-folder-install.js +231 -0
  140. package/dist/variant-axes.json +109 -0
  141. package/package.json +1 -1
  142. package/src/assets/agents/_partials/_code.mds +6 -6
  143. package/src/assets/agents/code.mds +4 -4
  144. package/src/assets/agents/evaluate.md +7 -13
  145. package/src/assets/agents/fix.mds +1 -1
  146. package/src/assets/agents/git.mds +34 -2
  147. package/src/assets/agents/harden.mds +7 -6
  148. package/src/assets/agents/skim.mds +1 -1
  149. package/src/assets/agents/test.md +21 -12
  150. package/src/assets/agents/triage.mds +9 -0
  151. package/src/assets/agents/validate.md +27 -34
  152. package/src/assets/commands/_partials/_compliance.mds +5 -3
  153. package/src/assets/commands/_partials/_decisions.mds +10 -8
  154. package/src/assets/commands/_partials/_engine.mds +9 -8
  155. package/src/assets/commands/_partials/_publication.mds +10 -1
  156. package/src/assets/commands/_partials/_settings.mds +2 -2
  157. package/src/assets/commands/bug-analysis.mds +80 -126
  158. package/src/assets/commands/code-review.mds +52 -9
  159. package/src/assets/commands/dynamic-build.mds +109 -15
  160. package/src/assets/commands/dynamic-tickets.mds +13 -0
  161. package/src/assets/commands/implement.mds +145 -33
  162. package/src/assets/commands/plan.mds +19 -0
  163. package/src/assets/commands/release.mds +50 -1
  164. package/src/assets/commands/resolve.mds +229 -44
  165. package/src/assets/commands/self-review.mds +8 -7
  166. package/src/assets/mds/git/_ops.mds +29 -1
  167. package/src/assets/mds/git/_ops_pr.mds +20 -10
  168. package/src/assets/mds/git/_ops_steps.mds +27 -0
  169. package/src/assets/mds/git/_pr.mds +15 -15
  170. package/src/assets/mds/git/_references.mds +12 -7
  171. package/src/assets/mds/tracker/_common.mds +4 -0
  172. package/src/assets/mds/tracker/_github.mds +43 -4
  173. package/src/assets/mds/tracker/_jira.mds +44 -4
  174. package/src/assets/mds/tracker/_linear.mds +44 -4
  175. package/src/assets/rules/quality.md +2 -0
  176. package/src/assets/scripts/ci-wait.cjs +493 -118
  177. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  178. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  179. package/src/assets/scripts/hooks/background-memory-update +79 -1
  180. package/src/assets/scripts/hooks/json-helper.cjs +44 -2
  181. package/src/assets/scripts/hooks/lib/curation-store.cjs +82 -20
  182. package/src/assets/scripts/hooks/lib/render-decisions.cjs +9 -1
  183. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +226 -33
  184. package/src/assets/scripts/hooks/memory-permission +10 -2
  185. package/src/assets/scripts/hooks/memory-worker +11 -0
  186. package/src/assets/scripts/hooks/pre-compact-memory +11 -0
  187. package/src/assets/scripts/hooks/preamble +18 -8
  188. package/src/assets/scripts/hooks/queue-append +218 -1
  189. package/src/assets/scripts/hooks/session-start-context +89 -37
  190. package/src/assets/scripts/hooks/session-start-memory +39 -0
  191. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  192. package/src/assets/scripts/pr-evidence.cjs +46 -4
  193. package/src/assets/scripts/resolve-settings.cjs +244 -30
  194. package/src/assets/scripts/verify-evidence.cjs +708 -124
  195. package/src/assets/skills/agent-references/SKILL.md +7 -1
  196. package/src/assets/skills/agent-references/references/commands/implement-levels.md +6 -4
  197. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  198. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  199. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +3 -2
  200. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  201. package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
  202. package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
  203. package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
  204. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  205. package/src/assets/skills/git/SKILL.md +3 -3
  206. package/src/assets/skills/git/references/github-api.md +2 -139
  207. package/src/assets/skills/git/references/github-posting.md +158 -0
  208. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  209. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  210. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  211. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -21,13 +21,26 @@
21
21
  * 5. Which files does the shipped registry produce, flattened into the manifest
22
22
  * an installer converges to? (generatedReferenceManifest — the one answer
23
23
  * that asserts instead of returning a Result; see the function for why.)
24
+ * The same question for a machine with compliance off is answered by
25
+ * installedReferenceManifest({ compliance: false }): the manifest minus the
26
+ * references of the posting operations (POSTING_OPS) and the publication gate.
27
+ * Two more questions serve the compliance-off references the build writes beside
28
+ * the compliance-on ones: which relative paths a compliance-off install omits
29
+ * (complianceOffOmittedRelPaths), and what each compliance arm of one operation's
30
+ * section keeps (splitSectionArms).
31
+ * A last group serves the workflow skill folders: which hosts make up a folder and
32
+ * which files each variant key holds (WORKFLOW_FOLDERS, workflowFolderFiles), and the
33
+ * build id that ties the files of one folder and key together (workflowBuildId,
34
+ * stampBuildId, unstampBuildId, checkFolderBuildIds, verifyFolderBuildId).
24
35
  * It still performs no I/O and no iteration over the filesystem.
25
36
  *
26
37
  * The `-variants` in the filename names the variant-expansion entry point below
27
38
  * (DR-16), which lives next to the validation it depends on.
28
39
  */
40
+ import { createHash } from 'crypto';
29
41
  import * as path from 'path';
30
42
  import { isContainedIn } from './paths.js';
43
+ import { COMPLIANCE_OFF_OUTPUT_DIR, OFF_OUTPUT_DIRS, machineVariantKey, splitVariants, } from './curation-variants.js';
31
44
  function Ok(value) {
32
45
  return { ok: true, value };
33
46
  }
@@ -101,13 +114,42 @@ export function validateContractOutputName(name) {
101
114
  return Ok(name);
102
115
  return Err(inner.error.kind === 'empty' ? { kind: 'empty' } : { ...inner.error, name });
103
116
  }
117
+ /**
118
+ * The one name the core host of a workflow skill folder may emit: the stem of
119
+ * `SKILL.md`, the file name a skill's entry point has by convention.
120
+ */
121
+ export const WORKFLOW_SKILL_ENTRY_NAME = 'SKILL';
122
+ /**
123
+ * Validate the emitted basename of a workflow skill folder's CORE host: exactly the
124
+ * literal {@link WORKFLOW_SKILL_ENTRY_NAME}.
125
+ *
126
+ * D-WORKFLOW-SKILL-NAME: a THIRD name rule, beside validateOutputName and
127
+ * validateContractOutputName, and OUTPUT_NAME_RE stays as it is. The literal is
128
+ * uppercase, which the ordinary rule refuses on purpose for every command, agent and
129
+ * reference, so relaxing the shared rule would have admitted `Implement.md` as a
130
+ * command name too, across all four build destinations, to buy a name only this one
131
+ * file needs. A closed set of one is a literal comparison, not a pattern.
132
+ *
133
+ * Every other name is refused with the ordinary rule's diagnosis where it has one
134
+ * (empty, traversal, separator), so those refusals cannot drift apart from their
135
+ * originals; a name the ordinary rule accepts is still refused here, as an
136
+ * invalid-charset name, because only the literal is allowed. The refusal reports the
137
+ * name AS WRITTEN.
138
+ */
139
+ export function validateSkillOutputName(name) {
140
+ if (name === WORKFLOW_SKILL_ENTRY_NAME)
141
+ return Ok(name);
142
+ const ordinary = validateOutputName(name);
143
+ return Err(ordinary.ok ? { kind: 'invalid-charset', name } : ordinary.error);
144
+ }
104
145
  /**
105
146
  * The only directories the MDS build may write into, each tagged with the host
106
147
  * variant it selects.
107
148
  *
108
149
  * `dist/commands` holds compiled slash commands; `dist/agents` holds agents
109
150
  * compiled from generator hosts; `dist/skills/git/references` holds the generated
110
- * `devflow:git` skill references. Adding an entry here is the single place a new
151
+ * `devflow:git` skill references; `dist/workflows` holds the compiled workflow skill
152
+ * folders. Adding an entry here is the single place a new
111
153
  * build destination becomes legal — and `satisfies` forces that entry to declare
112
154
  * a HostVariant, so no destination can arrive without saying how it is treated.
113
155
  */
@@ -159,10 +201,46 @@ export const SKILL_REFS_SKILL_NAME = 'git';
159
201
  * no reference module is planned — the same reason AGENTS_OUTPUT_DIR is exported.
160
202
  */
161
203
  export const SKILL_REFS_OUTPUT_DIR = `dist/skills/${SKILL_REFS_SKILL_NAME}/references`;
204
+ /**
205
+ * Repo-relative root of the compliance-off references: the one source root a
206
+ * compliance-off install overlays from.
207
+ *
208
+ * It sits inside the compliance-off prompt tree (COMPLIANCE_OFF_OUTPUT_DIR), beside
209
+ * `commands/` and `agents/`, and mirrors SKILL_REFS_OUTPUT_DIR below that tree. It is
210
+ * deliberately NOT an entry of the allowlist below: the build writes it as a second
211
+ * output of a reference module, so no host can declare it as its `output-dir:`.
212
+ *
213
+ * The tree is complete, not a patch over the compliance-on one: it holds every file of
214
+ * `installedReferenceManifest({ compliance: false })`, a byte copy of the compliance-on
215
+ * file where the module has no compliance arm there. An installer that reads this root
216
+ * therefore never has to fall through to SKILL_REFS_OUTPUT_DIR (AC-50).
217
+ */
218
+ export const COMPLIANCE_OFF_SKILL_REFS_OUTPUT_DIR = `${COMPLIANCE_OFF_OUTPUT_DIR}/skills/${SKILL_REFS_SKILL_NAME}/references`;
219
+ /**
220
+ * The kind folder the workflow skill folders sit in: `dist/workflows`, and
221
+ * `<variant tree>/workflows` for every other variant key, as `commands` and `agents`
222
+ * are the kind folders of the prompts.
223
+ */
224
+ export const WORKFLOW_FOLDERS_KIND = 'workflows';
225
+ /**
226
+ * Repo-relative destination for `workflow-skill` hosts: the root that holds one folder
227
+ * per registered workflow skill, `dist/workflows/<folder>/…`.
228
+ *
229
+ * D-WORKFLOW-FOLDER-BUILD: a host declares this ROOT, never its folder. The folder and
230
+ * the file inside it come from the registry WORKFLOW_FOLDERS, for the reason a
231
+ * reference module's operations do: the names are a typed roster, not frontmatter.
232
+ * The off variants of a folder sit under the trees of the other variant keys
233
+ * (workflowFoldersRootRelPath), none of which is a destination a host may declare.
234
+ *
235
+ * Exported because the build's orphan prune names this tree even when no folder is
236
+ * registered, which is exactly the case where every folder in it is an orphan.
237
+ */
238
+ export const WORKFLOW_FOLDERS_OUTPUT_DIR = `dist/${WORKFLOW_FOLDERS_KIND}`;
162
239
  const ALLOWED_OUTPUT_DIRS = [
163
240
  { dir: COMMANDS_OUTPUT_DIR, variant: 'commands' },
164
241
  { dir: AGENTS_OUTPUT_DIR, variant: 'agents' },
165
242
  { dir: SKILL_REFS_OUTPUT_DIR, variant: 'skill-refs' },
243
+ { dir: WORKFLOW_FOLDERS_OUTPUT_DIR, variant: 'workflow-skill' },
166
244
  ];
167
245
  /**
168
246
  * The allowlisted directory names, in declaration order, for error rendering.
@@ -325,6 +403,33 @@ export const PR_HOST_OPS = [
325
403
  * GitHub.
326
404
  */
327
405
  export const PR_HOST_DESTINATION_ROOT = 'pr';
406
+ /**
407
+ * The eight Git operations that post to GitHub or to a tracker: the one roster of
408
+ * them. Posting steps are a compile-time variant keyed on the machine compliance
409
+ * switch alone, and this list says which reference files that variant drops.
410
+ *
411
+ * A compliance-off install carries no reference file of any of them: four under
412
+ * `pr/` and, for each of the three providers, four under `tracker/{provider}/`, which
413
+ * is 16 files. `check-merge-readiness` is deliberately absent: it reads state and
414
+ * posts nothing. The names are checked against the registry twice, once at compile
415
+ * time here (each is a PR-host or a tracker operation) and once at run time by
416
+ * {@link checkPostingOps}, because the manifest that omits them must fail closed: a
417
+ * name that matched no file would leave a posting reference in a compliance-off
418
+ * install without a signal.
419
+ *
420
+ * Tests take this list from here and never retype it
421
+ * (POSTING_OPERATIONS in tests/fixtures/mds-manifest.ts).
422
+ */
423
+ export const POSTING_OPS = [
424
+ 'post-review-summary',
425
+ 'resolve-review-threads',
426
+ 'post-resolution-summary',
427
+ 'update-pr-evidence',
428
+ 'manage-debt',
429
+ 'post-wave-report',
430
+ 'backlink-shipped-issues',
431
+ 'associate-release',
432
+ ];
328
433
  /**
329
434
  * The cross-cutting `devflow:git` reference documents — provider-independent, so
330
435
  * they land at the root of the references directory rather than under
@@ -360,6 +465,13 @@ export const GIT_CROSS_CUTTING_DOCS = [
360
465
  'publication-gate',
361
466
  'trust-rule',
362
467
  ];
468
+ /**
469
+ * The cross-cutting documents a compliance-off install omits besides the references
470
+ * of {@link POSTING_OPS}: `publication-gate` holds the D10 step order and is named
471
+ * from the two summary operations only, so once those are gone nothing loads it.
472
+ * A subset of {@link GIT_CROSS_CUTTING_DOCS}, checked at compile time.
473
+ */
474
+ export const COMPLIANCE_OFF_OMITTED_DOCS = ['publication-gate'];
363
475
  /**
364
476
  * The Git operations whose one operation reference is a cross-cutting document
365
477
  * rather than a per-op file in `pr/` or `tracker/{provider}/` (D-OP-ONE-REF).
@@ -632,6 +744,10 @@ export function deferredReferenceModuleSources(modules = VARIANT_MODULES) {
632
744
  * VariantModuleKind for why a count proves nothing about a named document set.
633
745
  */
634
746
  export const MIN_VARIANT_PAIRS = 8;
747
+ /** POSIX path, relative to SKILL_REFS_OUTPUT_DIR, of the file a module writes for one of its names. */
748
+ function referenceRelPath(mod, op) {
749
+ return mod.subdir === '' ? `${op}.md` : `${mod.subdir}/${op}.md`;
750
+ }
635
751
  /**
636
752
  * Expand reference modules into the flat `(module, op)` pair list the build
637
753
  * writes.
@@ -693,7 +809,7 @@ export function expandVariants(modules = resolveVariantModules()) {
693
809
  if (!nameResult.ok) {
694
810
  return Err({ kind: 'invalid-op-name', module: mod.source, op, cause: nameResult.error });
695
811
  }
696
- const relPath = mod.subdir === '' ? `${op}.md` : `${mod.subdir}/${op}.md`;
812
+ const relPath = referenceRelPath(mod, op);
697
813
  const claimants = claimedBy.get(relPath);
698
814
  if (claimants === undefined) {
699
815
  claimedBy.set(relPath, [mod.source]);
@@ -755,12 +871,29 @@ export function generatedReferenceManifest() {
755
871
  * its sibling does (design review M3): the registry is a compile-time constant,
756
872
  * so a refusal is a programming error rather than an install-time degradation.
757
873
  *
874
+ * `compliance: false` is the install of a machine with compliance off: the same
875
+ * manifest without the references of the posting operations ({@link POSTING_OPS},
876
+ * 16 files) and without the publication gate document, which only those operations
877
+ * load. Any other value, and an absent option, is today's manifest. The omission
878
+ * fails closed: a posting name the registry does not carry asserts, because a name
879
+ * that matched no file would leave a posting reference in a compliance-off install.
880
+ *
758
881
  * @param opts.modules - Registry to expand (defaults to the shipped one, with the
759
882
  * gated contract module resolved). Injectable so the refusal arm is provable
760
883
  * without editing the registry.
884
+ * @param opts.compliance - `false` for the compliance-off install; the default is on.
761
885
  */
762
886
  export function installedReferenceManifest(opts = {}) {
763
- return expandedManifest(opts.modules ?? resolveVariantModules());
887
+ const modules = opts.modules ?? resolveVariantModules();
888
+ const manifest = expandedManifest(modules);
889
+ if (opts.compliance !== false)
890
+ return manifest;
891
+ const omitted = complianceOffOmittedRelPaths(modules);
892
+ if (!omitted.ok) {
893
+ throw new Error(`${describePostingOpsError(omitted.error)}. POSTING_OPS and VARIANT_MODULES in ` +
894
+ `src/core/mds-variants.ts must agree.`);
895
+ }
896
+ return manifest.filter(rel => !omitted.value.has(rel));
764
897
  }
765
898
  /** Every relPath a registry expands to, or the assertion both manifests share. */
766
899
  function expandedManifest(modules) {
@@ -771,6 +904,94 @@ function expandedManifest(modules) {
771
904
  }
772
905
  return expanded.value.map(pair => pair.relPath);
773
906
  }
907
+ /**
908
+ * Check a roster of posting operations against the registry: every name must be an
909
+ * operation of a fan-out module, and none may be listed twice.
910
+ *
911
+ * A named document is not an operation, so a document name is refused here: that
912
+ * keeps the posting roster from growing into a second list of documents to omit
913
+ * (those are {@link COMPLIANCE_OFF_OMITTED_DOCS}). The loop runs once over the roster
914
+ * given, so its bound is the roster's own length.
915
+ *
916
+ * @param ops - The roster to check (the shipped one is {@link POSTING_OPS}).
917
+ * @param modules - The registry to check it against.
918
+ */
919
+ export function checkPostingOps(ops, modules) {
920
+ const registered = new Set(modules.filter(mod => mod.kind === 'fanout').flatMap(mod => mod.ops));
921
+ const seen = new Set();
922
+ for (const op of ops) {
923
+ if (seen.has(op))
924
+ return Err({ kind: 'duplicate-posting-op', op });
925
+ seen.add(op);
926
+ if (!registered.has(op))
927
+ return Err({ kind: 'unregistered-posting-op', op });
928
+ }
929
+ return Ok(ops);
930
+ }
931
+ /** Render a posting-roster refusal as one line, with a `never` default so a new kind cannot fall through. */
932
+ export function describePostingOpsError(error) {
933
+ switch (error.kind) {
934
+ case 'unregistered-posting-op':
935
+ return (`posting operation '${error.op}' is not an operation of any fan-out reference module ` +
936
+ `in VARIANT_MODULES`);
937
+ case 'duplicate-posting-op':
938
+ return `posting operation '${error.op}' is listed twice in the posting roster`;
939
+ default: {
940
+ const unhandled = error;
941
+ return `unhandled posting roster refusal ${JSON.stringify(unhandled)}`;
942
+ }
943
+ }
944
+ }
945
+ /**
946
+ * Does a compliance-off install omit the reference this module writes for `op`?
947
+ *
948
+ * A fan-out module's file is omitted when `op` is a posting operation; a named
949
+ * document is omitted when it is in {@link COMPLIANCE_OFF_OMITTED_DOCS}; the contract
950
+ * document is never omitted. The `never` default makes a fourth module kind answer
951
+ * the question here rather than inherit one of these answers.
952
+ */
953
+ function omittedWhenComplianceOff(kind, op, postingOps) {
954
+ switch (kind) {
955
+ case 'fanout':
956
+ return postingOps.includes(op);
957
+ case 'named':
958
+ return COMPLIANCE_OFF_OMITTED_DOCS.includes(op);
959
+ case 'contract':
960
+ return false;
961
+ default: {
962
+ // A kind added to VariantModuleKind stops compiling at the next line. If one
963
+ // reached run time anyway it fails closed: its reference is left out.
964
+ const unhandled = kind;
965
+ void unhandled;
966
+ return true;
967
+ }
968
+ }
969
+ }
970
+ /**
971
+ * The relative paths (under the references root) a compliance-off install omits, for
972
+ * a registry: every file of a posting operation, and the omitted documents.
973
+ *
974
+ * The one owner of the omission. The compliance-off manifest filters by it, and the
975
+ * build decides from it which references get a compliance-off copy, so the manifest an
976
+ * installer converges to and the tree the build writes cannot drift apart.
977
+ *
978
+ * @param modules - The registry to read.
979
+ * @param postingOps - The posting roster (defaults to {@link POSTING_OPS}); injectable
980
+ * so the refusal of a bogus roster is provable without editing the shipped one.
981
+ */
982
+ export function complianceOffOmittedRelPaths(modules, postingOps = POSTING_OPS) {
983
+ const checked = checkPostingOps(postingOps, modules);
984
+ if (!checked.ok)
985
+ return Err(checked.error);
986
+ const omitted = new Set();
987
+ for (const mod of modules) {
988
+ for (const op of mod.ops) {
989
+ if (omittedWhenComplianceOff(mod.kind, op, postingOps))
990
+ omitted.add(referenceRelPath(mod, op));
991
+ }
992
+ }
993
+ return Ok(omitted);
994
+ }
774
995
  // ---------------------------------------------------------------------------
775
996
  // Section splitting — which slice of a module's compiled body belongs to which op
776
997
  // ---------------------------------------------------------------------------
@@ -874,4 +1095,480 @@ export function splitVariantSections(body, entries) {
874
1095
  }
875
1096
  return Ok(out);
876
1097
  }
1098
+ /** A variant body as a reference file holds it: trimmed, one trailing newline, or empty when it has no text. */
1099
+ function settleSectionBody(body) {
1100
+ const trimmed = body.trim();
1101
+ return trimmed === '' ? '' : `${trimmed}\n`;
1102
+ }
1103
+ /**
1104
+ * Split one operation's section on its compliance arms.
1105
+ *
1106
+ * The caller has already split the module into operation sections
1107
+ * (splitVariantSections), so an arm that runs across two sections is an unclosed arm
1108
+ * in the first and an unopened end in the second, and each is refused here with the
1109
+ * operation it is in. Each variant is settled the way splitVariantSections settles a
1110
+ * section, so a section with no arm gives both variants its own bytes.
1111
+ *
1112
+ * Curation is outside this split: the splitter keeps the curation-on text in both
1113
+ * bodies, and the build refuses a curation marker in a reference module before it
1114
+ * gets here.
1115
+ *
1116
+ * Total and pure: a refusal is a Result.
1117
+ *
1118
+ * @param op - The operation, for the refusal.
1119
+ * @param content - The settled section from splitVariantSections.
1120
+ * @param opts.complianceOffWritten - Whether the build writes a compliance-off file
1121
+ * for this operation. A variant that is written must have text; an operation the
1122
+ * compliance-off install omits writes none, and its compliance-off body may be empty.
1123
+ */
1124
+ export function splitSectionArms(op, content, opts) {
1125
+ const split = splitVariants(content);
1126
+ if (!split.ok)
1127
+ return Err({ kind: 'arm-split', op, error: split.error });
1128
+ const on = settleSectionBody(split.value.bodies.on);
1129
+ const complianceOff = settleSectionBody(split.value.bodies['compliance-off']);
1130
+ if (on === '')
1131
+ return Err({ kind: 'empty-variant', op, variant: 'on' });
1132
+ if (opts.complianceOffWritten && complianceOff === '') {
1133
+ return Err({ kind: 'empty-variant', op, variant: 'compliance-off' });
1134
+ }
1135
+ return Ok({ on, complianceOff });
1136
+ }
1137
+ // ---------------------------------------------------------------------------
1138
+ // Workflow skill folders: the registry, the files of each variant key, the build id
1139
+ // ---------------------------------------------------------------------------
1140
+ /** The compiled name of a folder's core host: the skill's entry file. */
1141
+ export const WORKFLOW_SKILL_FILE = `${WORKFLOW_SKILL_ENTRY_NAME}.md`;
1142
+ /**
1143
+ * The subfolder of reference files that sit outside the build id: they are read on
1144
+ * demand by link, not as part of the skill's own text, and they ship byte-equal to
1145
+ * their compiled body.
1146
+ */
1147
+ export const WORKFLOW_REFERENCES_DIR = 'references';
1148
+ /**
1149
+ * The workflow skill folders this build compiles.
1150
+ *
1151
+ * D-WORKFLOW-FOLDER-BUILD: the set is empty, and the build layer ships with it empty.
1152
+ * No host in src/assets becomes a folder until a folder is registered here, so every
1153
+ * file the build wrote before is byte-identical. A host that declares the workflow
1154
+ * root without a row here is refused, which is what keeps a folder from appearing by
1155
+ * accident. The registry is a typed roster rather than frontmatter for the reason the
1156
+ * git reference modules' roster is: the folder and file names are decided in one
1157
+ * reviewed place.
1158
+ */
1159
+ export const WORKFLOW_FOLDERS = [];
1160
+ /** Every host of a folder, the core first, then the references in registry order. */
1161
+ export function workflowFolderHosts(folder) {
1162
+ return [
1163
+ { source: folder.core, file: WORKFLOW_SKILL_FILE, role: 'core', omittedWhenComplianceOff: false },
1164
+ ...folder.references.map((ref) => ({
1165
+ source: ref.source,
1166
+ file: ref.file,
1167
+ role: 'reference',
1168
+ omittedWhenComplianceOff: ref.omittedWhenComplianceOff === true,
1169
+ })),
1170
+ ];
1171
+ }
1172
+ /** The host a repo-relative source path is registered as, and its folder; null when no folder lists it. */
1173
+ export function findWorkflowFolderHost(folders, source) {
1174
+ for (const folder of folders) {
1175
+ const host = workflowFolderHosts(folder).find(h => h.source === source);
1176
+ if (host !== undefined)
1177
+ return { folder, host };
1178
+ }
1179
+ return null;
1180
+ }
1181
+ /**
1182
+ * A repo-relative POSIX `.mds` path that stays inside the repo: no backslash, no
1183
+ * leading slash, no dot segment, spelled canonically.
1184
+ */
1185
+ function isRepoRelativeMdsSource(source) {
1186
+ return source.endsWith('.mds')
1187
+ && !source.includes('\\')
1188
+ && !path.posix.isAbsolute(source)
1189
+ && !source.startsWith('../')
1190
+ && path.posix.normalize(source) === source;
1191
+ }
1192
+ /**
1193
+ * Validate the compiled name of a reference file: `<name>.md` or `references/<name>.md`,
1194
+ * with the ordinary output-name rule on the stem. `SKILL` is refused by that rule (it is
1195
+ * uppercase), so the entry file belongs to the core host alone.
1196
+ */
1197
+ function validateWorkflowReferenceFile(file) {
1198
+ const prefix = `${WORKFLOW_REFERENCES_DIR}/`;
1199
+ const bare = file.startsWith(prefix) ? file.slice(prefix.length) : file;
1200
+ if (!bare.endsWith('.md'))
1201
+ return Err({ kind: 'invalid-charset', name: file });
1202
+ const stem = validateOutputName(bare.slice(0, -'.md'.length));
1203
+ if (stem.ok)
1204
+ return Ok(file);
1205
+ return Err(stem.error.kind === 'empty' ? { kind: 'empty' } : { ...stem.error, name: file });
1206
+ }
1207
+ /**
1208
+ * Check a registry: every folder and file name is safe to join onto a build
1209
+ * destination, every source is a repo-relative `.mds` path, and no folder, source or
1210
+ * file is listed twice.
1211
+ *
1212
+ * Total and pure. Each loop is bounded by the registry it is given.
1213
+ */
1214
+ export function checkWorkflowFolders(folders) {
1215
+ const folderNames = new Set();
1216
+ const sources = new Set();
1217
+ for (const folder of folders) {
1218
+ const named = validateOutputName(folder.name);
1219
+ if (!named.ok)
1220
+ return Err({ kind: 'bad-folder-name', folder: folder.name, error: named.error });
1221
+ if (folderNames.has(folder.name))
1222
+ return Err({ kind: 'duplicate-folder', folder: folder.name });
1223
+ folderNames.add(folder.name);
1224
+ const files = new Set();
1225
+ for (const host of workflowFolderHosts(folder)) {
1226
+ if (!isRepoRelativeMdsSource(host.source)) {
1227
+ return Err({ kind: 'bad-source', folder: folder.name, source: host.source });
1228
+ }
1229
+ if (sources.has(host.source))
1230
+ return Err({ kind: 'duplicate-source', source: host.source });
1231
+ sources.add(host.source);
1232
+ if (host.role === 'reference') {
1233
+ const file = validateWorkflowReferenceFile(host.file);
1234
+ if (!file.ok)
1235
+ return Err({ kind: 'bad-file', folder: folder.name, file: host.file, error: file.error });
1236
+ }
1237
+ if (files.has(host.file))
1238
+ return Err({ kind: 'duplicate-file', folder: folder.name, file: host.file });
1239
+ files.add(host.file);
1240
+ }
1241
+ }
1242
+ return Ok(folders);
1243
+ }
1244
+ function isRecord(value) {
1245
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1246
+ }
1247
+ function parseWorkflowReference(value, at) {
1248
+ if (!isRecord(value))
1249
+ return Err({ kind: 'malformed', at });
1250
+ const { source, file, omittedWhenComplianceOff } = value;
1251
+ if (typeof source !== 'string')
1252
+ return Err({ kind: 'malformed', at: `${at}.source` });
1253
+ if (typeof file !== 'string')
1254
+ return Err({ kind: 'malformed', at: `${at}.file` });
1255
+ if (omittedWhenComplianceOff !== undefined && typeof omittedWhenComplianceOff !== 'boolean') {
1256
+ return Err({ kind: 'malformed', at: `${at}.omittedWhenComplianceOff` });
1257
+ }
1258
+ return Ok(omittedWhenComplianceOff === undefined ? { source, file } : { source, file, omittedWhenComplianceOff });
1259
+ }
1260
+ /**
1261
+ * Parse a registry from untrusted data (JSON that crossed a process boundary) and
1262
+ * check it: the shape first, then {@link checkWorkflowFolders}. Pure; a refusal
1263
+ * names the path of the first malformed field.
1264
+ */
1265
+ export function parseWorkflowFolders(value) {
1266
+ if (!Array.isArray(value))
1267
+ return Err({ kind: 'malformed', at: '$' });
1268
+ const folders = [];
1269
+ for (const [index, entry] of value.entries()) {
1270
+ const at = `$[${index}]`;
1271
+ if (!isRecord(entry))
1272
+ return Err({ kind: 'malformed', at });
1273
+ const { name, core, references } = entry;
1274
+ if (typeof name !== 'string')
1275
+ return Err({ kind: 'malformed', at: `${at}.name` });
1276
+ if (typeof core !== 'string')
1277
+ return Err({ kind: 'malformed', at: `${at}.core` });
1278
+ if (!Array.isArray(references))
1279
+ return Err({ kind: 'malformed', at: `${at}.references` });
1280
+ const parsed = [];
1281
+ for (const [refIndex, ref] of references.entries()) {
1282
+ const result = parseWorkflowReference(ref, `${at}.references[${refIndex}]`);
1283
+ if (!result.ok)
1284
+ return Err(result.error);
1285
+ parsed.push(result.value);
1286
+ }
1287
+ folders.push({ name, core, references: parsed });
1288
+ }
1289
+ return checkWorkflowFolders(folders);
1290
+ }
1291
+ /** Render a registry refusal as one line a person can act on. */
1292
+ export function describeWorkflowFolderError(error) {
1293
+ switch (error.kind) {
1294
+ case 'malformed':
1295
+ return `the workflow folder registry is malformed at ${error.at}`;
1296
+ case 'bad-folder-name':
1297
+ return `workflow folder name '${error.folder}' is refused (${error.error.kind})`;
1298
+ case 'duplicate-folder':
1299
+ return `workflow folder '${error.folder}' is registered twice`;
1300
+ case 'bad-source':
1301
+ return `workflow folder '${error.folder}' lists '${error.source}', which is not a repo-relative .mds path`;
1302
+ case 'duplicate-source':
1303
+ return `the source '${error.source}' is listed twice in the workflow folder registry`;
1304
+ case 'bad-file':
1305
+ return `workflow folder '${error.folder}' names the file '${error.file}', which is refused (${error.error.kind})`;
1306
+ case 'duplicate-file':
1307
+ return `workflow folder '${error.folder}' names the file '${error.file}' twice`;
1308
+ default: {
1309
+ const unhandled = error;
1310
+ return `unhandled workflow folder refusal ${JSON.stringify(unhandled)}`;
1311
+ }
1312
+ }
1313
+ }
1314
+ /** A folder is complete for every key, so the key a machine reads is the one for its switches alone. */
1315
+ const FOLDER_ARMS = { curation: true, compliance: true };
1316
+ /**
1317
+ * The variant key of the folder a machine with these switches reads.
1318
+ *
1319
+ * Unlike a prompt, a folder is complete under every key (a byte copy of the on text
1320
+ * where no arm applies), so the key follows the switches alone and never depends on
1321
+ * which arms a file carries. The mapping is machineVariantKey's, with every arm assumed
1322
+ * present, so the key table has one owner.
1323
+ */
1324
+ export function workflowFolderKey(switches) {
1325
+ return machineVariantKey(switches, FOLDER_ARMS);
1326
+ }
1327
+ /** The keys whose folder omits the compliance-gated files: the ones that turn compliance off. */
1328
+ const COMPLIANCE_OFF_KEYS = new Set([true, false].map(curation => workflowFolderKey({ curation, compliance: false })));
1329
+ /**
1330
+ * The files one variant key's folder holds, in registry order, the core first.
1331
+ *
1332
+ * The one owner of "which files does this key hold": the build plans a file's
1333
+ * destination as absent exactly when it is not listed here, and checks each key's
1334
+ * folder against this list. A key that lacks a file of its own list fails the build;
1335
+ * a file the key's list leaves out is never required.
1336
+ */
1337
+ export function workflowFolderFiles(folder, key) {
1338
+ const complianceOff = COMPLIANCE_OFF_KEYS.has(key);
1339
+ return workflowFolderHosts(folder)
1340
+ .filter(host => !(complianceOff && host.omittedWhenComplianceOff))
1341
+ .map(host => host.file);
1342
+ }
1343
+ /** Repo-relative POSIX root of the folders of one variant key: `dist/workflows`, or `<variant tree>/workflows`. */
1344
+ export function workflowFoldersRootRelPath(key) {
1345
+ return key === 'on' ? WORKFLOW_FOLDERS_OUTPUT_DIR : `${OFF_OUTPUT_DIRS[key]}/${WORKFLOW_FOLDERS_KIND}`;
1346
+ }
1347
+ /** Repo-relative POSIX path of one file of one folder under one variant key. */
1348
+ export function workflowFolderRelPath(key, folder, file) {
1349
+ return `${workflowFoldersRootRelPath(key)}/${folder}/${file}`;
1350
+ }
1351
+ /** Whether a file of a folder carries the build id. The reference files are outside it. */
1352
+ export function isStampedWorkflowFile(file) {
1353
+ return !file.startsWith(`${WORKFLOW_REFERENCES_DIR}/`);
1354
+ }
1355
+ /** Length of the build id, in hex digits. */
1356
+ export const BUILD_ID_LENGTH = 12;
1357
+ /** The stamp as a heading carries it: a suffix on the first heading line. */
1358
+ const BUILD_STAMP_SUFFIX_RE = new RegExp(` \\[build ([0-9a-f]{${BUILD_ID_LENGTH}})\\]$`);
1359
+ function byCodeUnit(a, b) {
1360
+ return a < b ? -1 : a > b ? 1 : 0;
1361
+ }
1362
+ /**
1363
+ * The build id of one folder under one variant key: the first {@link BUILD_ID_LENGTH}
1364
+ * hex digits of the SHA-256 over the folder's compiled bodies as they stand BEFORE the
1365
+ * stamp, sorted by file name.
1366
+ *
1367
+ * D-BUILD-ID: one id per folder and key, and nothing else goes into it: no timestamp,
1368
+ * no per-file hash. The same sources therefore give the same id on every machine and
1369
+ * every run, and any change to any file of the folder moves it. The digest covers
1370
+ * every file of the key's folder, the reference files included, so a changed reference
1371
+ * moves the id of its folder; only the STAMP skips them (isStampedWorkflowFile), which
1372
+ * keeps those files byte-equal to their compiled bodies.
1373
+ *
1374
+ * The id can be recomputed from a stamped folder. The stamp drops the blanks that end
1375
+ * the first heading line, so a stamped file enters the digest in that same form
1376
+ * ({@link stampReadyBody}) and {@link unstampBuildId} gives back the exact text that was
1377
+ * hashed. A body whose heading ends in blanks and one whose heading does not therefore
1378
+ * share an id, as they share their stamped text.
1379
+ *
1380
+ * Each file enters the digest as its name, its length in bytes, then its body, each
1381
+ * on a line of its own. The length makes the framing injective: two different sets of
1382
+ * files cannot produce one byte stream by moving text across a file boundary.
1383
+ */
1384
+ export function workflowBuildId(files) {
1385
+ const hash = createHash('sha256');
1386
+ for (const { file, body } of [...files].sort((a, b) => byCodeUnit(a.file, b.file))) {
1387
+ const hashed = isStampedWorkflowFile(file) ? stampReadyBody(body) : body;
1388
+ hash.update(`${file}\n${Buffer.byteLength(hashed, 'utf8')}\n`);
1389
+ hash.update(hashed, 'utf8');
1390
+ hash.update('\n');
1391
+ }
1392
+ return hash.digest('hex').slice(0, BUILD_ID_LENGTH);
1393
+ }
1394
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
1395
+ const HEADING_RE = /^#{1,6}[ \t]+\S/;
1396
+ /**
1397
+ * Find the first ATX heading line, skipping a leading frontmatter block and every
1398
+ * fenced code block, so a `# comment` in either is never mistaken for it. One pass over
1399
+ * the lines, so the bound is the text's own length.
1400
+ */
1401
+ function locateFirstHeading(text) {
1402
+ const lines = text.split('\n');
1403
+ let inFrontmatter = lines[0]?.replace(/\r$/, '') === '---';
1404
+ let fence = null;
1405
+ let offset = 0;
1406
+ for (const [index, raw] of lines.entries()) {
1407
+ const line = raw.replace(/\r$/, '');
1408
+ const start = offset;
1409
+ offset += raw.length + 1;
1410
+ if (inFrontmatter) {
1411
+ if (index > 0 && line === '---')
1412
+ inFrontmatter = false;
1413
+ continue;
1414
+ }
1415
+ const opened = FENCE_RE.exec(line);
1416
+ if (opened !== null) {
1417
+ const marker = opened[1];
1418
+ if (fence === null)
1419
+ fence = marker;
1420
+ else if (marker[0] === fence[0] && marker.length >= fence.length)
1421
+ fence = null;
1422
+ continue;
1423
+ }
1424
+ if (fence === null && HEADING_RE.test(line))
1425
+ return { start, end: start + line.length };
1426
+ }
1427
+ return null;
1428
+ }
1429
+ /** The offset where the text of a heading line ends: its end, less the blanks that close it. */
1430
+ function headingTextEnd(text, heading) {
1431
+ return heading.start + text.slice(heading.start, heading.end).replace(/[ \t]+$/, '').length;
1432
+ }
1433
+ /**
1434
+ * A compiled body in the form the stamp leaves it, before the suffix: the blanks that
1435
+ * end the first heading line dropped. A body with no heading is returned as it is.
1436
+ * {@link stampBuildId} writes the suffix after this text and {@link unstampBuildId}
1437
+ * returns this text, which is what lets the build id be recomputed from a stamped file.
1438
+ */
1439
+ function stampReadyBody(body) {
1440
+ const heading = locateFirstHeading(body);
1441
+ if (heading === null)
1442
+ return body;
1443
+ return `${body.slice(0, headingTextEnd(body, heading))}${body.slice(heading.end)}`;
1444
+ }
1445
+ /**
1446
+ * Stamp a build id into the first heading line of a compiled body, as the suffix
1447
+ * ` [build <id>]`.
1448
+ *
1449
+ * D-BUILD-ID: the heading is where a reader of the file sees the id first, and a
1450
+ * suffix leaves the heading text, and every anchor made from it, as written. A body
1451
+ * with no heading has nowhere to carry the id and is refused, and so is a heading that
1452
+ * already carries a stamp, which would otherwise hide a stale one behind the new one.
1453
+ * The blanks that end the heading line are dropped, so the suffix follows the text
1454
+ * directly; {@link unstampBuildId} undoes the stamp up to that one change.
1455
+ */
1456
+ export function stampBuildId(body, id) {
1457
+ const heading = locateFirstHeading(body);
1458
+ if (heading === null)
1459
+ return Err({ kind: 'no-heading' });
1460
+ const existing = BUILD_STAMP_SUFFIX_RE.exec(body.slice(heading.start, heading.end));
1461
+ if (existing !== null)
1462
+ return Err({ kind: 'already-stamped', id: existing[1] });
1463
+ return Ok(`${body.slice(0, headingTextEnd(body, heading))} [build ${id}]${body.slice(heading.end)}`);
1464
+ }
1465
+ /** Read the build id out of the first heading line of a stamped file. */
1466
+ export function readBuildId(text) {
1467
+ const heading = locateFirstHeading(text);
1468
+ if (heading === null)
1469
+ return Err({ kind: 'no-heading' });
1470
+ const stamp = BUILD_STAMP_SUFFIX_RE.exec(text.slice(heading.start, heading.end));
1471
+ return stamp === null ? Err({ kind: 'no-stamp' }) : Ok(stamp[1]);
1472
+ }
1473
+ /**
1474
+ * Take the stamp off a stamped file: the text without the suffix on its first heading
1475
+ * line, and the id the suffix carried.
1476
+ *
1477
+ * The inverse of {@link stampBuildId} on a body whose heading does not end in blanks
1478
+ * (a heading that does comes back without them, which is the form the build id is taken
1479
+ * over). Only the first heading is read, so a stamp-like text elsewhere in the file stays.
1480
+ */
1481
+ export function unstampBuildId(text) {
1482
+ const heading = locateFirstHeading(text);
1483
+ if (heading === null)
1484
+ return Err({ kind: 'no-heading' });
1485
+ const stamp = BUILD_STAMP_SUFFIX_RE.exec(text.slice(heading.start, heading.end));
1486
+ if (stamp === null)
1487
+ return Err({ kind: 'no-stamp' });
1488
+ return Ok({ body: `${text.slice(0, heading.end - stamp[0].length)}${text.slice(heading.end)}`, id: stamp[1] });
1489
+ }
1490
+ /**
1491
+ * The shared-id check of a folder: every stamped file of one folder and key carries the
1492
+ * same build id. Returns that id. It reads the stamps and nothing else; it does not
1493
+ * recompute the id from the files, and {@link verifyFolderBuildId} does.
1494
+ *
1495
+ * The reference files are outside it. A file with no heading or no stamp is refused by
1496
+ * name, and two ids are refused with every file's id listed, so a folder that mixes
1497
+ * files of two builds (an install that stopped half-way, a stale file copied in) stops
1498
+ * the check instead of passing as one skill.
1499
+ */
1500
+ export function checkFolderBuildIds(files) {
1501
+ const ids = [];
1502
+ for (const { file, body } of files) {
1503
+ if (!isStampedWorkflowFile(file))
1504
+ continue;
1505
+ const read = readBuildId(body);
1506
+ if (!read.ok)
1507
+ return Err({ kind: 'unstamped', file, reason: read.error.kind });
1508
+ ids.push({ file, id: read.value });
1509
+ }
1510
+ if (ids.length === 0)
1511
+ return Err({ kind: 'no-stamped-file' });
1512
+ const first = ids[0].id;
1513
+ return ids.every(entry => entry.id === first) ? Ok(first) : Err({ kind: 'mixed-ids', ids });
1514
+ }
1515
+ /**
1516
+ * The check a reader of a folder makes: the shared-id check ({@link checkFolderBuildIds}),
1517
+ * then the id recomputed over the unstamped stamped files and the reference bodies as
1518
+ * they stand, which must be the id the stamps carry. Returns that id.
1519
+ *
1520
+ * Every file of the key's folder must be in `files`, because the id covers them all. A
1521
+ * folder with one stamped file, or a reference file of another build, no longer passes
1522
+ * on the stamps alone: its files hash to another id. The check holds for a folder as
1523
+ * the build wrote it. It does not hold against someone who edits a file and the stamp
1524
+ * together, since the id is a plain digest and not a signature.
1525
+ */
1526
+ export function verifyFolderBuildId(files) {
1527
+ const shared = checkFolderBuildIds(files);
1528
+ if (!shared.ok)
1529
+ return shared;
1530
+ const unstamped = [];
1531
+ for (const { file, body } of files) {
1532
+ if (!isStampedWorkflowFile(file)) {
1533
+ unstamped.push({ file, body });
1534
+ continue;
1535
+ }
1536
+ const read = unstampBuildId(body);
1537
+ if (!read.ok)
1538
+ return Err({ kind: 'unstamped', file, reason: read.error.kind });
1539
+ unstamped.push({ file, body: read.value.body });
1540
+ }
1541
+ const recomputed = workflowBuildId(unstamped);
1542
+ return recomputed === shared.value ? Ok(shared.value) : Err({ kind: 'id-mismatch', stamped: shared.value, recomputed });
1543
+ }
1544
+ /** Render a stamp refusal as one line a person can act on. */
1545
+ export function describeBuildStampError(error) {
1546
+ switch (error.kind) {
1547
+ case 'no-heading':
1548
+ return 'it has no heading line to carry the build id; open the file with a heading';
1549
+ case 'already-stamped':
1550
+ return `its first heading already carries a build stamp (${error.id}); the build writes the stamp itself`;
1551
+ default: {
1552
+ const unhandled = error;
1553
+ return `unhandled build stamp refusal ${JSON.stringify(unhandled)}`;
1554
+ }
1555
+ }
1556
+ }
1557
+ /** Render a build id check refusal as one line a person can act on. */
1558
+ export function describeBuildIdCheckError(error) {
1559
+ switch (error.kind) {
1560
+ case 'no-stamped-file':
1561
+ return 'the folder holds no file that carries a build id';
1562
+ case 'unstamped':
1563
+ return `${error.file} has no build id (${error.reason})`;
1564
+ case 'mixed-ids':
1565
+ return `the folder mixes build ids: ${error.ids.map(entry => `${entry.file} ${entry.id}`).join(', ')}`;
1566
+ case 'id-mismatch':
1567
+ return `the files of the folder hash to build id ${error.recomputed}, but the stamps carry ${error.stamped}; a file is from another build or was changed`;
1568
+ default: {
1569
+ const unhandled = error;
1570
+ return `unhandled build id refusal ${JSON.stringify(unhandled)}`;
1571
+ }
1572
+ }
1573
+ }
877
1574
  //# sourceMappingURL=mds-variants.js.map