devflow-kit 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +1 -1
  3. package/dist/agents/code.md +6 -6
  4. package/dist/agents/fix.md +6 -6
  5. package/dist/agents/harden.md +7 -6
  6. package/dist/agents/refactor.md +6 -6
  7. package/dist/cli/commands/ambient.js +10 -2
  8. package/dist/cli/commands/backup-session.js +131 -0
  9. package/dist/cli/commands/compliance-prompts.js +9 -4
  10. package/dist/cli/commands/compliance.js +184 -24
  11. package/dist/cli/commands/curation.js +23 -10
  12. package/dist/cli/commands/gate-level-prompts.js +39 -25
  13. package/dist/cli/commands/gates.js +5 -7
  14. package/dist/cli/commands/init.js +169 -184
  15. package/dist/cli/commands/install-report.js +129 -0
  16. package/dist/cli/commands/legacy-sweep.js +159 -0
  17. package/dist/cli/commands/proxy.js +6 -1
  18. package/dist/cli/commands/rules.js +75 -21
  19. package/dist/cli/commands/skills.js +42 -9
  20. package/dist/cli/commands/tracker.js +9 -3
  21. package/dist/cli/commands/uninstall.js +339 -75
  22. package/dist/commands/bug-analysis.md +69 -120
  23. package/dist/commands/dynamic-build.md +23 -23
  24. package/dist/commands/implement.md +46 -32
  25. package/dist/commands/release.md +1 -1
  26. package/dist/commands/resolve.md +35 -31
  27. package/dist/commands/self-review.md +8 -7
  28. package/dist/compliance-off/agents/git.md +120 -0
  29. package/dist/compliance-off/agents/triage.md +180 -0
  30. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  31. package/dist/compliance-off/commands/code-review.md +520 -0
  32. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  33. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  34. package/dist/compliance-off/commands/implement.md +736 -0
  35. package/dist/compliance-off/commands/plan.md +671 -0
  36. package/dist/compliance-off/commands/release.md +309 -0
  37. package/dist/compliance-off/commands/resolve.md +742 -0
  38. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  39. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  40. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  41. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  42. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  43. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  44. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  45. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  46. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  47. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  48. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  49. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  50. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  51. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  52. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  53. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  54. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  55. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  56. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  57. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  58. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  59. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  60. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  61. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  62. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  63. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  64. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  65. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  66. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  67. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  68. package/dist/core/agent-models.js +19 -4
  69. package/dist/core/assets.js +126 -17
  70. package/dist/core/compliance-off-files.js +27 -0
  71. package/dist/core/compliance.js +14 -0
  72. package/dist/core/curation-variants.js +460 -112
  73. package/dist/core/flags.js +2 -2
  74. package/dist/core/fs-atomic.js +5 -1
  75. package/dist/core/installed-variants.js +63 -0
  76. package/dist/core/manifest.js +48 -5
  77. package/dist/core/mds-variants.js +700 -3
  78. package/dist/core/orphan-sweep.js +29 -2
  79. package/dist/core/path-set.js +15 -0
  80. package/dist/core/plugins.js +68 -10
  81. package/dist/core/reference-sweep.js +42 -25
  82. package/dist/core/retired-shipped-names.js +278 -0
  83. package/dist/curation-compliance-off/agents/triage.md +176 -0
  84. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  85. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  86. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  87. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  88. package/dist/curation-compliance-off/commands/implement.md +689 -0
  89. package/dist/curation-compliance-off/commands/plan.md +631 -0
  90. package/dist/curation-compliance-off/commands/release.md +279 -0
  91. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  92. package/dist/curation-off/agents/code.md +6 -6
  93. package/dist/curation-off/agents/fix.md +6 -6
  94. package/dist/curation-off/agents/harden.md +7 -6
  95. package/dist/curation-off/agents/refactor.md +6 -6
  96. package/dist/curation-off/commands/bug-analysis.md +69 -119
  97. package/dist/curation-off/commands/dynamic-build.md +23 -23
  98. package/dist/curation-off/commands/implement.md +46 -32
  99. package/dist/curation-off/commands/release.md +1 -1
  100. package/dist/curation-off/commands/resolve.md +35 -31
  101. package/dist/curation-off/commands/self-review.md +8 -7
  102. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  103. package/dist/targets/claude-code/behavior-install.js +23 -9
  104. package/dist/targets/claude-code/charter-render.js +374 -0
  105. package/dist/targets/claude-code/compliance-install.js +62 -17
  106. package/dist/targets/claude-code/compliance-off-files.js +249 -0
  107. package/dist/targets/claude-code/curation-install.js +632 -117
  108. package/dist/targets/claude-code/folder-swap.js +142 -0
  109. package/dist/targets/claude-code/install-backup.js +780 -0
  110. package/dist/targets/claude-code/installer.js +795 -199
  111. package/dist/targets/claude-code/language-stamp.js +6 -0
  112. package/dist/targets/claude-code/legacy-skill-hashes.js +170 -0
  113. package/dist/targets/claude-code/linked-folder.js +176 -0
  114. package/dist/targets/claude-code/reference-converge.js +131 -0
  115. package/dist/targets/claude-code/rules-folder.js +34 -0
  116. package/dist/targets/claude-code/shipped-set.js +160 -0
  117. package/dist/targets/claude-code/tracker-install.js +21 -8
  118. package/dist/targets/claude-code/tracker-servers.js +20 -6
  119. package/dist/targets/claude-code/workflow-folder-install.js +251 -0
  120. package/dist/variant-axes.json +109 -0
  121. package/package.json +1 -1
  122. package/src/assets/agents/_partials/_code.mds +6 -6
  123. package/src/assets/agents/evaluate.md +1 -1
  124. package/src/assets/agents/git.mds +32 -0
  125. package/src/assets/agents/harden.mds +7 -6
  126. package/src/assets/agents/test.md +21 -12
  127. package/src/assets/agents/triage.mds +2 -0
  128. package/src/assets/agents/validate.md +10 -10
  129. package/src/assets/commands/_partials/_compliance.mds +5 -3
  130. package/src/assets/commands/_partials/_engine.mds +9 -8
  131. package/src/assets/commands/_partials/_publication.mds +10 -1
  132. package/src/assets/commands/bug-analysis.mds +80 -126
  133. package/src/assets/commands/code-review.mds +43 -0
  134. package/src/assets/commands/dynamic-build.mds +109 -15
  135. package/src/assets/commands/dynamic-tickets.mds +13 -0
  136. package/src/assets/commands/implement.mds +145 -33
  137. package/src/assets/commands/plan.mds +19 -0
  138. package/src/assets/commands/release.mds +50 -1
  139. package/src/assets/commands/resolve.mds +217 -32
  140. package/src/assets/commands/self-review.mds +8 -7
  141. package/src/assets/mds/git/_ops.mds +23 -0
  142. package/src/assets/mds/git/_ops_steps.mds +17 -0
  143. package/src/assets/mds/git/_pr.mds +1 -1
  144. package/src/assets/mds/git/_references.mds +6 -0
  145. package/src/assets/mds/tracker/_common.mds +4 -0
  146. package/src/assets/mds/tracker/_github.mds +42 -0
  147. package/src/assets/mds/tracker/_jira.mds +43 -0
  148. package/src/assets/mds/tracker/_linear.mds +43 -0
  149. package/src/assets/rules/quality.md +2 -0
  150. package/src/assets/scripts/ci-wait.cjs +204 -126
  151. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  152. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  153. package/src/assets/scripts/hooks/preamble +18 -8
  154. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  155. package/src/assets/scripts/pr-evidence.cjs +41 -2
  156. package/src/assets/scripts/verify-evidence.cjs +408 -56
  157. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  158. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  159. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  160. package/src/assets/skills/agent-references/references/validate/passed-alone.md +7 -7
  161. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  162. package/src/assets/skills/git/SKILL.md +3 -3
  163. package/src/assets/skills/git/references/github-api.md +2 -139
  164. package/src/assets/skills/git/references/github-posting.md +158 -0
  165. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  166. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  167. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  168. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Curation-variant splitter: one compiled prompt body in, its curation-on and
3
- * curation-off variants out.
2
+ * Variant splitter: one compiled prompt body in, its variants out, on two marker
3
+ * axes: curation and compliance.
4
4
  *
5
- * Pure module, zero I/O. Every refusal is a Result; the exiting shell is
6
- * scripts/build-mds.ts, which renders these errors into the build's single exit.
5
+ * Pure module, zero I/O. Every refusal is a Result; the exiting shells are
6
+ * scripts/build-mds.ts, which renders the split errors into the build's single exit,
7
+ * and the installer, which refuses an install on a manifest error.
7
8
  *
8
9
  * D-LEARNING-VARIANTS: when curation is off on a machine, the decisions text is
9
10
  * ABSENT from every prompt it would have reached, not merely gated at run time.
@@ -11,16 +12,27 @@
11
12
  * the curation-on variant at today's path (dist/commands/x.md, dist/agents/x.md)
12
13
  * and the curation-off variant at dist/curation-off/{commands,agents}/x.md, only
13
14
  * for a host that has an arm. A prompt with no arm has one variant and no
14
- * curation-off file.
15
+ * curation-off file. The compliance axis works the same way: a body is split on
16
+ * both axes into four variants, keyed `on`, `curation-off`, `compliance-off` and
17
+ * `both-off`, and the build writes a variant only for a host that has an arm on
18
+ * each axis that variant turns off. The off variants go to dist/curation-off/,
19
+ * dist/compliance-off/ and dist/curation-compliance-off/, and the manifest
20
+ * dist/variant-axes.json records which axes each host carries an arm on. The install
21
+ * order of the trees per machine is owned by src/core/assets.ts, and the install refusal
22
+ * for a compliance-off machine by src/targets/claude-code/installer.ts.
15
23
  *
16
- * The arms are whole-line markers in the host or in a partial it expands:
24
+ * The arms are whole-line markers in the host or in a partial it expands. Each
25
+ * axis has the same three:
17
26
  *
18
- * <!-- curation:on --> opens an arm that only the curation-on variant keeps
19
- * <!-- curation:off --> opens an arm that only the curation-off variant keeps;
27
+ * <!-- curation:on --> opens an arm that only the curation-on variants keep
28
+ * <!-- curation:off --> opens an arm that only the curation-off variants keep;
20
29
  * directly after an on arm it is that arm's else
21
30
  * <!-- curation:end --> closes the arm
22
31
  *
23
- * A marker line matches CURATION_MARKER_RE. It may be indented and may sit inside
32
+ * and `<!-- compliance:on -->`, `<!-- compliance:off -->` and
33
+ * `<!-- compliance:end -->` do the same for the compliance variants.
34
+ *
35
+ * A marker line matches VARIANT_MARKER_RE. It may be indented and may sit inside
24
36
  * a fenced block, a list, a define body or an agent's second frontmatter block,
25
37
  * because MDS passes an HTML comment through untouched. The splitter runs on the
26
38
  * COMPILED body, after the MDS compile, so it adds no compileFile call, no import
@@ -28,6 +40,13 @@
28
40
  * marker line is removed whole, with its newline. Every other line is kept byte
29
41
  * for byte, so a blank line is content: put the blank line a paragraph needs
30
42
  * inside the arm that owns the paragraph, and the other variant gets none of it.
43
+ * A line goes into a variant only when every arm open at that line matches the
44
+ * variant, so a body with no compliance marker splits on the curation axis to the
45
+ * same bytes it always did, and the compliance state of the variant changes nothing.
46
+ *
47
+ * One axis may nest fully inside an arm of the other. Two arms of one axis never
48
+ * nest, and arms of two axes never overlap: at most one arm per axis is open, and
49
+ * the arm opened last is the one that closes first.
31
50
  *
32
51
  * The markers are INTERIM. MDS 0.4.4 emits an `@if` inside a code fence or on an
33
52
  * indented line as literal text (its lexer requires column 0), and the arms have
@@ -35,28 +54,38 @@
35
54
  * frontmatter. They are replaced by native conditionals once mdscript ships
36
55
  * fence- and indent-aware `@if` (dean0x/mdscript#452). That swap, with the
37
56
  * @mdscript/mds upgrade it rides on proven byte-identical in dist/ and this module
38
- * deleted, is dean0x/devflow#439. The mapping is one to one: `on` becomes
39
- * `@if curation:`, `off` becomes `@else:` and `end` becomes `@end`. A standalone
40
- * `off` arm, with no on arm before it, becomes an `@if curation:` with an empty
41
- * body followed by `@else:`.
42
- *
43
- * What the splitter refuses, each as a CurationSplitError with the source line:
44
- * - nesting: an `on` marker while an arm is open, or a second `off` in one arm;
45
- * - an `end` with no open arm;
57
+ * deleted, is dean0x/devflow#439; the compliance axis rides the same swap
58
+ * (dean0x/devflow#648). The mapping is one to one, per axis: `<!-- curation:on -->`
59
+ * becomes `@if curation:` and `<!-- compliance:on -->` becomes `@if compliance:`,
60
+ * `off` becomes `@else:` and `end` becomes `@end`. A standalone `off` arm, with no
61
+ * on arm before it, becomes an `@if <axis>:` with an empty body followed by
62
+ * `@else:`. One axis nested inside the other becomes one `@if` inside the other,
63
+ * and an interleaving, which no `@if` nesting expresses, is a refusal here.
64
+ *
65
+ * What the splitter refuses, each as a VariantSplitError with the source line:
66
+ * - nesting: an `on` marker while an arm of its axis is open, or a second `off`
67
+ * in one arm;
68
+ * - an interleaving: an `off` or `end` marker for an arm while an arm of the other
69
+ * axis, opened after it, is still open;
70
+ * - an `end` with no open arm of its axis;
46
71
  * - an arm still open at the end of the body;
47
72
  * - an empty arm (no line with content between its marker and the next);
48
73
  * - a retired marker: a line that names the pre-rename spelling of the marker, in
49
74
  * any spacing or case, which would otherwise ship as prompt text;
50
- * - a stray marker: a line that names `<!-- curation:` but is not a whole-line
51
- * marker, which would otherwise ship as prompt text.
75
+ * - a stray marker: a line that names `<!-- curation:` or `<!-- compliance:` but
76
+ * is not a whole-line marker, which would otherwise ship as prompt text.
52
77
  *
53
78
  * The retired spelling is never accepted as an alias: the build fails on it, so a
54
79
  * host that still carries it cannot ship a prompt with both variants silently equal.
55
80
  *
56
- * Scope guarantee: this module answers one question (what are the two variants of
57
- * this body?) and one cheap one for the build to ask of a body that must carry no
58
- * arms (containsCurationMarker). It decides nothing about WHICH text belongs in an
59
- * arm: that is authoring, and the guards over the built trees hold it.
81
+ * Scope guarantee: this module answers one question (what are the variants of this
82
+ * body, and where do they land?) and a cheap one per axis for the build to ask of a
83
+ * body that must carry no arms (containsCurationMarker, containsComplianceMarker). It
84
+ * also renders the variant manifest the build writes beside the trees
85
+ * (renderVariantManifest) and parses it for the installer (parseVariantManifest), so the
86
+ * build and the installer share one layout. It decides
87
+ * nothing about WHICH text belongs in an arm: that is authoring, and the guards over
88
+ * the built trees hold it.
60
89
  */
61
90
  function Ok(value) {
62
91
  return { ok: true, value };
@@ -72,6 +101,20 @@ function Err(error) {
72
101
  * orphan, so it cannot be derived from the plan.
73
102
  */
74
103
  export const CURATION_OFF_OUTPUT_DIR = 'dist/curation-off';
104
+ /**
105
+ * Repo-relative root of the compliance-off variants: curation on, compliance off.
106
+ *
107
+ * Exported for the same reason as CURATION_OFF_OUTPUT_DIR: the orphan prune names it
108
+ * even when no host carries a compliance arm.
109
+ */
110
+ export const COMPLIANCE_OFF_OUTPUT_DIR = 'dist/compliance-off';
111
+ /**
112
+ * Repo-relative root of the both-off variants: curation off and compliance off.
113
+ *
114
+ * A sibling of the two single-axis trees, never inside one, so the prune of one
115
+ * tree cannot reach the files of another.
116
+ */
117
+ export const BOTH_OFF_OUTPUT_DIR = 'dist/curation-compliance-off';
75
118
  /**
76
119
  * Repo-relative root of the retired off tree, from before the rename.
77
120
  *
@@ -81,124 +124,249 @@ export const CURATION_OFF_OUTPUT_DIR = 'dist/curation-off';
81
124
  */
82
125
  export const LEGACY_OFF_OUTPUT_DIR = 'dist/learning-off';
83
126
  /**
84
- * POSIX repo-relative path of the curation-off variant of an output file.
127
+ * The tree each off variant is written under. Total over OffVariantKey, so a new key cannot go without a tree.
128
+ *
129
+ * Exported so the build derives its orphan prune from this map: every off key has its tree swept.
130
+ */
131
+ export const OFF_OUTPUT_DIRS = {
132
+ 'curation-off': CURATION_OFF_OUTPUT_DIR,
133
+ 'compliance-off': COMPLIANCE_OFF_OUTPUT_DIR,
134
+ 'both-off': BOTH_OFF_OUTPUT_DIR,
135
+ };
136
+ /**
137
+ * POSIX repo-relative path of an off variant of an output file: the kind's folder
138
+ * under the tree of the variant key.
85
139
  *
86
- * `fileName` is the already-validated basename the curation-on variant is written
87
- * under (`implement.md`), so the two variants of one host always share a name.
140
+ * `fileName` is the already-validated basename the `on` variant is written under,
141
+ * so every variant of one host shares a name.
88
142
  */
89
- export function curationOffRelPath(kind, fileName) {
90
- return `${CURATION_OFF_OUTPUT_DIR}/${kind}/${fileName}`;
143
+ export function variantOffRelPath(key, kind, fileName) {
144
+ return `${OFF_OUTPUT_DIRS[key]}/${kind}/${fileName}`;
91
145
  }
92
146
  // ---------------------------------------------------------------------------
93
147
  // Markers
94
148
  // ---------------------------------------------------------------------------
149
+ /** The two marker axes. Curation is listed first: it is the older axis and the first one a stray line is tested against. */
150
+ export const VARIANT_AXES = ['curation', 'compliance'];
151
+ /** Every switch on: the machine whose prompts come from the normal compiled files. */
152
+ export const ALL_SWITCHES_ON = Object.freeze({ curation: true, compliance: true });
95
153
  /**
96
- * A whole-line curation marker. The group captures `on`, `off` or `end`.
154
+ * A whole-line marker of either axis. Group 1 captures the axis; group 2 captures
155
+ * `on`, `off` or `end`.
97
156
  *
98
157
  * Anchored at both ends with no nested quantifier, so a match is linear in the
99
158
  * line length. Leading and trailing space or tab is allowed; anything else on the
100
159
  * line is not a marker.
101
160
  */
102
- export const CURATION_MARKER_RE = /^[ \t]*<!-- curation:(on|off|end) -->[ \t]*$/;
161
+ export const VARIANT_MARKER_RE = /^[ \t]*<!-- (curation|compliance):(on|off|end) -->[ \t]*$/;
103
162
  /**
104
- * The fragment that makes a line a marker ATTEMPT: loose on spacing and case so a
105
- * malformed marker is caught rather than shipped. Every valid marker contains it.
163
+ * The fragment that makes a line a marker ATTEMPT on one axis: loose on spacing and
164
+ * case so a malformed marker is caught rather than shipped. Every valid marker of
165
+ * the axis contains it. None is global, so `test` keeps no state between lines.
106
166
  */
107
- const MARKER_FRAGMENT_RE = /<!--\s*curation\s*:/i;
167
+ const MARKER_FRAGMENT_RES = {
168
+ curation: /<!--\s*curation\s*:/i,
169
+ compliance: /<!--\s*compliance\s*:/i,
170
+ };
108
171
  /**
109
172
  * The same attempt test for the retired spelling of the marker. No spelling of it is
110
173
  * valid, so a line that matches is always refused.
111
174
  */
112
175
  const LEGACY_MARKER_FRAGMENT_RE = /<!--\s*learning\s*:/i;
113
- /** True when the text names a marker anywhere, in the current or the retired spelling, valid or not. */
176
+ /** True when the text names a curation marker anywhere, in the current or the retired spelling, valid or not. */
114
177
  export function containsCurationMarker(text) {
115
- return MARKER_FRAGMENT_RE.test(text) || LEGACY_MARKER_FRAGMENT_RE.test(text);
178
+ return MARKER_FRAGMENT_RES.curation.test(text) || LEGACY_MARKER_FRAGMENT_RE.test(text);
179
+ }
180
+ /** True when the text names a compliance marker anywhere, valid or not. */
181
+ export function containsComplianceMarker(text) {
182
+ return MARKER_FRAGMENT_RES.compliance.test(text);
183
+ }
184
+ /** True when the text names a marker of either axis, or the retired spelling, valid or not. */
185
+ export function containsVariantMarker(text) {
186
+ return containsCurationMarker(text) || containsComplianceMarker(text);
116
187
  }
117
188
  function markerOf(line) {
118
189
  // A CRLF body carries the carriage return on the line; it is not part of the marker.
119
190
  const bare = line.endsWith('\r') ? line.slice(0, -1) : line;
120
- const match = CURATION_MARKER_RE.exec(bare);
121
- return match === null ? null : match[1];
191
+ const match = VARIANT_MARKER_RE.exec(bare);
192
+ return match === null ? null : { axis: match[1], word: match[2] };
193
+ }
194
+ /** The axis whose marker the line attempts, or null when it attempts none. */
195
+ function attemptedAxisOf(line) {
196
+ for (const axis of VARIANT_AXES) {
197
+ if (MARKER_FRAGMENT_RES[axis].test(line))
198
+ return axis;
199
+ }
200
+ return null;
201
+ }
202
+ /** The four variants of a body, named by the axes they turn off. */
203
+ export const VARIANT_KEYS = ['on', 'curation-off', 'compliance-off', 'both-off'];
204
+ /** Every variant that turns an axis off, in VARIANT_KEYS order. */
205
+ export const OFF_VARIANT_KEYS = VARIANT_KEYS.filter((key) => key !== 'on');
206
+ /** The arm each axis keeps in each variant. */
207
+ const VARIANT_STATES = {
208
+ on: { curation: 'on', compliance: 'on' },
209
+ 'curation-off': { curation: 'off', compliance: 'on' },
210
+ 'compliance-off': { curation: 'on', compliance: 'off' },
211
+ 'both-off': { curation: 'off', compliance: 'off' },
212
+ };
213
+ /**
214
+ * The variants the build writes for a body, `on` first.
215
+ *
216
+ * A variant is written only for a host that has an arm on each axis the variant turns
217
+ * off: `curation-off` when the body carries a curation arm, `compliance-off` when it
218
+ * carries a compliance arm, and `both-off` when it carries both. A body with no arm
219
+ * has the one variant, because every key would give the same bytes.
220
+ */
221
+ export function emittedVariantKeys(hasArms) {
222
+ const keys = ['on'];
223
+ if (hasArms.curation)
224
+ keys.push('curation-off');
225
+ if (hasArms.compliance)
226
+ keys.push('compliance-off');
227
+ if (hasArms.curation && hasArms.compliance)
228
+ keys.push('both-off');
229
+ return keys;
122
230
  }
123
231
  /** The longest stretch of a stray line a refusal quotes. */
124
232
  const STRAY_QUOTE_MAX = 80;
125
233
  /**
126
- * Split a compiled body into its curation-on and curation-off variants.
234
+ * Handle a line that is not a marker: refuse it when it attempts one (the retired
235
+ * spelling first, then a current one), else keep it in every variant that agrees with
236
+ * every open arm and mark each open arm as holding content when the line is not blank.
237
+ *
238
+ * Reads no module state. It changes only the `kept` and `open` accumulators of the
239
+ * split that passed them in.
240
+ */
241
+ function takeContentLine(line, lineNo, kept, open) {
242
+ if (LEGACY_MARKER_FRAGMENT_RE.test(line)) {
243
+ return { kind: 'legacy-marker', line: lineNo, text: line.trim().slice(0, STRAY_QUOTE_MAX) };
244
+ }
245
+ const attempted = attemptedAxisOf(line);
246
+ if (attempted !== null) {
247
+ return { kind: 'stray-marker', axis: attempted, line: lineNo, text: line.trim().slice(0, STRAY_QUOTE_MAX) };
248
+ }
249
+ for (const key of VARIANT_KEYS) {
250
+ if (open.every(arm => arm.arm === VARIANT_STATES[key][arm.axis]))
251
+ kept[key].push(line);
252
+ }
253
+ if (line.trim() !== '') {
254
+ for (const arm of open)
255
+ arm.hasContent = true;
256
+ }
257
+ return null;
258
+ }
259
+ /**
260
+ * The refusal for a marker that would close `current`, the open arm of its axis (an
261
+ * `off` that ends an on arm, or an `end`), or null when the arm may close. An open
262
+ * arm of the other axis is checked first, because it must close before this one;
263
+ * an arm that held no content is checked second.
264
+ */
265
+ function checkClose(axis, word, lineNo, current, inner) {
266
+ if (inner !== null) {
267
+ return {
268
+ kind: 'interleaved-arm', axis, marker: word, line: lineNo, openedAt: current.openedAt,
269
+ innerAxis: inner.axis, innerOpenedAt: inner.openedAt,
270
+ };
271
+ }
272
+ if (!current.hasContent) {
273
+ return { kind: 'empty-arm', axis, arm: current.arm, openedAt: current.openedAt, closedAt: lineNo };
274
+ }
275
+ return null;
276
+ }
277
+ /**
278
+ * Apply one marker to the open arms: open an arm, turn an on arm to its else, or close
279
+ * an arm. Returns the refusal when the marker is not allowed there, and null after it
280
+ * changed `open`.
281
+ *
282
+ * Reads no module state. It changes only the `open` stack of the split that passed it in.
283
+ */
284
+ function applyMarker(marker, lineNo, open) {
285
+ const { axis } = marker;
286
+ const at = open.findIndex(arm => arm.axis === axis);
287
+ const current = at === -1 ? null : open[at];
288
+ // The arm of the other axis opened after this axis's arm, if any: the one that must close first.
289
+ const inner = at !== -1 && at + 1 < open.length ? open[at + 1] : null;
290
+ switch (marker.word) {
291
+ case 'on':
292
+ if (current !== null) {
293
+ return { kind: 'nested-arm', axis, line: lineNo, arm: current.arm, openedAt: current.openedAt };
294
+ }
295
+ open.push({ axis, arm: 'on', openedAt: lineNo, hasContent: false });
296
+ return null;
297
+ case 'off': {
298
+ if (current === null) {
299
+ open.push({ axis, arm: 'off', openedAt: lineNo, hasContent: false });
300
+ return null;
301
+ }
302
+ if (current.arm === 'off') {
303
+ return { kind: 'repeated-off', axis, line: lineNo, openedAt: current.openedAt };
304
+ }
305
+ // Directly after an on arm this is its else: the on arm ends here.
306
+ const refused = checkClose(axis, 'off', lineNo, current, inner);
307
+ if (refused !== null)
308
+ return refused;
309
+ open[at] = { axis, arm: 'off', openedAt: lineNo, hasContent: false };
310
+ return null;
311
+ }
312
+ case 'end': {
313
+ if (current === null)
314
+ return { kind: 'unopened-end', axis, line: lineNo };
315
+ const refused = checkClose(axis, 'end', lineNo, current, inner);
316
+ if (refused !== null)
317
+ return refused;
318
+ open.pop();
319
+ return null;
320
+ }
321
+ default: {
322
+ const unhandled = marker.word;
323
+ return { kind: 'stray-marker', axis, line: lineNo, text: String(unhandled) };
324
+ }
325
+ }
326
+ }
327
+ /**
328
+ * Split a compiled body into its four variants.
127
329
  *
128
330
  * Total and pure: the same input gives the same output, and a refusal is a
129
331
  * Result. The loop is one pass over the body's lines, so its bound is the body's
130
- * own length.
332
+ * own length. The open arms are a stack, outermost first. It holds at most one arm
333
+ * per axis, because a second `on` of an axis is refused, so it never holds more
334
+ * arms than there are axes.
131
335
  *
132
- * An arm needs a line with content in it. An arm that holds only blank lines is
133
- * refused as empty, because it would add blank lines to one variant and nothing to
134
- * the other, which is a mistake rather than a design.
336
+ * An arm needs a line with content in it, and a line inside an inner arm is also
337
+ * inside every arm around it. An arm that holds only blank lines is refused as
338
+ * empty, because it would add blank lines to one variant and nothing to the
339
+ * other, which is a mistake rather than a design.
135
340
  */
136
- export function splitCurationVariants(body) {
341
+ export function splitVariants(body) {
137
342
  const lines = body.split('\n');
138
- const on = [];
139
- const off = [];
140
- let hasArms = false;
141
- // The open arm, if any: which one, the line of the marker that opened it, and
142
- // whether it has held a line with content yet.
143
- let arm = null;
144
- let armOpenedAt = 0;
145
- let armHasContent = false;
343
+ const kept = { on: [], 'curation-off': [], 'compliance-off': [], 'both-off': [] };
344
+ const seen = { curation: false, compliance: false };
345
+ const open = [];
146
346
  for (let index = 0; index < lines.length; index++) {
147
347
  const line = lines[index];
148
348
  const lineNo = index + 1;
149
349
  const marker = markerOf(line);
150
- if (marker === null) {
151
- if (LEGACY_MARKER_FRAGMENT_RE.test(line)) {
152
- return Err({ kind: 'legacy-marker', line: lineNo, text: line.trim().slice(0, STRAY_QUOTE_MAX) });
153
- }
154
- if (MARKER_FRAGMENT_RE.test(line)) {
155
- return Err({ kind: 'stray-marker', line: lineNo, text: line.trim().slice(0, STRAY_QUOTE_MAX) });
156
- }
157
- if (arm !== 'off')
158
- on.push(line);
159
- if (arm !== 'on')
160
- off.push(line);
161
- if (arm !== null && line.trim() !== '')
162
- armHasContent = true;
163
- continue;
164
- }
165
- hasArms = true;
166
- switch (marker) {
167
- case 'on':
168
- if (arm !== null)
169
- return Err({ kind: 'nested-arm', line: lineNo, arm, openedAt: armOpenedAt });
170
- arm = 'on';
171
- armOpenedAt = lineNo;
172
- armHasContent = false;
173
- break;
174
- case 'off':
175
- if (arm === 'off')
176
- return Err({ kind: 'repeated-off', line: lineNo, openedAt: armOpenedAt });
177
- // Directly after an on arm this is its else: the on arm ends here.
178
- if (arm === 'on' && !armHasContent) {
179
- return Err({ kind: 'empty-arm', arm, openedAt: armOpenedAt, closedAt: lineNo });
180
- }
181
- arm = 'off';
182
- armOpenedAt = lineNo;
183
- armHasContent = false;
184
- break;
185
- case 'end':
186
- if (arm === null)
187
- return Err({ kind: 'unopened-end', line: lineNo });
188
- if (!armHasContent) {
189
- return Err({ kind: 'empty-arm', arm, openedAt: armOpenedAt, closedAt: lineNo });
190
- }
191
- arm = null;
192
- break;
193
- default: {
194
- const unhandled = marker;
195
- return Err({ kind: 'stray-marker', line: lineNo, text: String(unhandled) });
196
- }
197
- }
350
+ if (marker !== null)
351
+ seen[marker.axis] = true;
352
+ const refused = marker === null ? takeContentLine(line, lineNo, kept, open) : applyMarker(marker, lineNo, open);
353
+ if (refused !== null)
354
+ return Err(refused);
198
355
  }
199
- if (arm !== null)
200
- return Err({ kind: 'unclosed-arm', arm, openedAt: armOpenedAt });
201
- return Ok({ on: on.join('\n'), off: off.join('\n'), hasArms });
356
+ // The arm opened last is the one an `end` would close first.
357
+ const unclosed = open.length > 0 ? open[open.length - 1] : null;
358
+ if (unclosed !== null) {
359
+ return Err({ kind: 'unclosed-arm', axis: unclosed.axis, arm: unclosed.arm, openedAt: unclosed.openedAt });
360
+ }
361
+ return Ok({
362
+ bodies: {
363
+ on: kept.on.join('\n'),
364
+ 'curation-off': kept['curation-off'].join('\n'),
365
+ 'compliance-off': kept['compliance-off'].join('\n'),
366
+ 'both-off': kept['both-off'].join('\n'),
367
+ },
368
+ hasArms: seen,
369
+ });
202
370
  }
203
371
  /**
204
372
  * Render a refusal as one line a person can act on.
@@ -207,31 +375,211 @@ export function splitCurationVariants(body) {
207
375
  * fall through into a message that does not describe it. Shared by the build and
208
376
  * the tests, so the text asserted is the text shipped.
209
377
  */
210
- export function describeCurationSplitError(error) {
378
+ export function describeVariantSplitError(error) {
211
379
  switch (error.kind) {
212
380
  case 'nested-arm':
213
- return (`line ${error.line}: a curation:on marker inside the ${error.arm} arm opened at line ` +
214
- `${error.openedAt} — arms do not nest; close it with curation:end first`);
381
+ return (`line ${error.line}: a ${error.axis}:on marker inside the ${error.arm} arm opened at line ` +
382
+ `${error.openedAt} — arms do not nest; close it with ${error.axis}:end first`);
383
+ case 'interleaved-arm':
384
+ return (`line ${error.line}: a ${error.axis}:${error.marker} marker ends the ${error.axis} arm opened at line ` +
385
+ `${error.openedAt} while the ${error.innerAxis} arm opened at line ${error.innerOpenedAt} is still ` +
386
+ `open — arms of two axes may nest but not overlap; close the ${error.innerAxis} arm with ` +
387
+ `${error.innerAxis}:end first`);
215
388
  case 'repeated-off':
216
- return (`line ${error.line}: a second curation:off marker inside the off arm opened at line ` +
389
+ return (`line ${error.line}: a second ${error.axis}:off marker inside the off arm opened at line ` +
217
390
  `${error.openedAt} — an arm has at most one else`);
218
391
  case 'unopened-end':
219
- return `line ${error.line}: a curation:end marker with no open arm`;
392
+ return `line ${error.line}: a ${error.axis}:end marker with no open arm`;
220
393
  case 'unclosed-arm':
221
- return `the ${error.arm} arm opened at line ${error.openedAt} is never closed with curation:end`;
394
+ return `the ${error.arm} arm opened at line ${error.openedAt} is never closed with ${error.axis}:end`;
222
395
  case 'empty-arm':
223
- return (`lines ${error.openedAt}-${error.closedAt}: the ${error.arm} arm holds no line with content — ` +
396
+ return (`lines ${error.openedAt}-${error.closedAt}: the ${error.axis}:${error.arm} arm holds no line with content — ` +
224
397
  `delete the arm or put its text inside it`);
225
398
  case 'legacy-marker':
226
399
  return (`line ${error.line}: "${error.text}" uses the retired learning marker — rename it to ` +
227
400
  `<!-- curation:on -->, <!-- curation:off --> or <!-- curation:end -->`);
228
401
  case 'stray-marker':
229
- return (`line ${error.line}: "${error.text}" names a curation marker but is not one — a marker ` +
230
- `is a whole line, exactly <!-- curation:on -->, <!-- curation:off --> or <!-- curation:end -->`);
402
+ return (`line ${error.line}: "${error.text}" names a ${error.axis} marker but is not one — a marker ` +
403
+ `is a whole line, exactly <!-- ${error.axis}:on -->, <!-- ${error.axis}:off --> or <!-- ${error.axis}:end -->`);
404
+ default: {
405
+ const unhandled = error;
406
+ return `unhandled variant split error ${JSON.stringify(unhandled)}`;
407
+ }
408
+ }
409
+ }
410
+ // ---------------------------------------------------------------------------
411
+ // The variant manifest
412
+ // ---------------------------------------------------------------------------
413
+ /**
414
+ * Repo-relative path of the variant manifest, written beside the variant trees.
415
+ *
416
+ * The installer reads it to learn which compiled prompts carry a curation arm and
417
+ * which carry a compliance arm, so it never has to guess from the files it finds.
418
+ */
419
+ export const VARIANT_MANIFEST_REL_PATH = 'dist/variant-axes.json';
420
+ /** The manifest format version. A change to the shape of the file raises it. */
421
+ export const VARIANT_MANIFEST_VERSION = 1;
422
+ /** The manifest key of a host: the same `<kind>/<name>` spelling the host rosters use. */
423
+ function manifestKeyOf(host) {
424
+ return `${host.kind}/${host.name}`;
425
+ }
426
+ /** Order by UTF-16 code unit, so the order does not depend on the locale of the machine that builds. */
427
+ function byCodeUnit(a, b) {
428
+ if (a < b)
429
+ return -1;
430
+ return a > b ? 1 : 0;
431
+ }
432
+ /**
433
+ * Render the manifest of a build as JSON text.
434
+ *
435
+ * Total and pure, and deterministic: the hosts are sorted by key, each host lists
436
+ * its axes in the VARIANT_AXES order whatever order its input object holds them in,
437
+ * and the text ends in a newline, so the same roster always gives the same bytes. A
438
+ * kind and name that appear twice are refused, because the two hosts would write one
439
+ * file and one of the entries would be wrong.
440
+ */
441
+ export function renderVariantManifest(hosts) {
442
+ const entries = new Map();
443
+ for (const host of hosts) {
444
+ const key = manifestKeyOf(host);
445
+ if (entries.has(key))
446
+ return Err({ kind: 'duplicate-host', host: key });
447
+ entries.set(key, { curation: host.axes.curation, compliance: host.axes.compliance });
448
+ }
449
+ const sorted = {};
450
+ for (const key of [...entries.keys()].sort(byCodeUnit)) {
451
+ const axes = entries.get(key);
452
+ if (axes !== undefined)
453
+ sorted[key] = axes;
454
+ }
455
+ return Ok(`${JSON.stringify({ version: VARIANT_MANIFEST_VERSION, hosts: sorted }, null, 2)}\n`);
456
+ }
457
+ /** Render a manifest refusal as one line a person can act on. */
458
+ export function describeVariantManifestError(error) {
459
+ switch (error.kind) {
460
+ case 'duplicate-host':
461
+ return `two hosts write ${error.host}.md — a variant manifest names each compiled prompt once`;
462
+ default: {
463
+ const unhandled = error.kind;
464
+ return `unhandled variant manifest error ${String(unhandled)}`;
465
+ }
466
+ }
467
+ }
468
+ /** The most hosts a manifest may name. The shipped roster is named in tests/fixtures/mds-manifest.ts; the bound keeps a corrupt file from driving an unbounded loop. */
469
+ export const MAX_VARIANT_MANIFEST_HOSTS = 1024;
470
+ /** A manifest key: a prompt kind, a slash and an output basename, the same charset the build validates names against. */
471
+ const MANIFEST_HOST_KEY_RE = /^(commands|agents)\/([a-z0-9][a-z0-9._-]{0,63})$/;
472
+ function isPlainRecord(value) {
473
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
474
+ }
475
+ /**
476
+ * Parse the text of a variant manifest into its hosts, in file order.
477
+ *
478
+ * The parse boundary of the file the build wrote: it trusts nothing in the text. The
479
+ * version must be the one this module renders, the host map must be a plain object
480
+ * within MAX_VARIANT_MANIFEST_HOSTS, every key must be `<kind>/<name>` with a known kind
481
+ * and a valid basename (so a key can never name a path outside a variant tree), and both
482
+ * axes of every host must be booleans. The first fault is the refusal. Pure and total:
483
+ * the JSON parse is the only call that can throw and it is caught here.
484
+ */
485
+ export function parseVariantManifest(text) {
486
+ let parsed;
487
+ try {
488
+ parsed = JSON.parse(text);
489
+ }
490
+ catch {
491
+ return Err({ kind: 'not-json' });
492
+ }
493
+ if (!isPlainRecord(parsed))
494
+ return Err({ kind: 'not-an-object' });
495
+ if (parsed.version !== VARIANT_MANIFEST_VERSION)
496
+ return Err({ kind: 'unsupported-version' });
497
+ const rawHosts = parsed.hosts;
498
+ if (!isPlainRecord(rawHosts))
499
+ return Err({ kind: 'bad-hosts' });
500
+ const entries = Object.entries(rawHosts);
501
+ if (entries.length > MAX_VARIANT_MANIFEST_HOSTS)
502
+ return Err({ kind: 'too-many-hosts', count: entries.length });
503
+ const hosts = [];
504
+ for (const [key, value] of entries) {
505
+ const match = MANIFEST_HOST_KEY_RE.exec(key);
506
+ if (match === null)
507
+ return Err({ kind: 'bad-host-key', key });
508
+ if (!isPlainRecord(value) || typeof value.curation !== 'boolean' || typeof value.compliance !== 'boolean') {
509
+ return Err({ kind: 'bad-host-axes', host: key });
510
+ }
511
+ hosts.push({
512
+ kind: match[1],
513
+ name: match[2],
514
+ axes: { curation: value.curation, compliance: value.compliance },
515
+ });
516
+ }
517
+ return Ok(hosts);
518
+ }
519
+ /** Render a manifest parse refusal as one line a person can act on. */
520
+ export function describeVariantManifestParseError(error) {
521
+ switch (error.kind) {
522
+ case 'not-json':
523
+ return 'the text is not JSON';
524
+ case 'not-an-object':
525
+ return 'the top level is not an object';
526
+ case 'unsupported-version':
527
+ return `the version is not ${VARIANT_MANIFEST_VERSION}`;
528
+ case 'bad-hosts':
529
+ return '"hosts" is missing or is not an object';
530
+ case 'too-many-hosts':
531
+ return `${error.count} hosts exceed the bound of ${MAX_VARIANT_MANIFEST_HOSTS}`;
532
+ case 'bad-host-key':
533
+ return `the host key "${error.key}" is not <commands|agents>/<name>`;
534
+ case 'bad-host-axes':
535
+ return `the host ${error.host} does not carry a boolean "curation" and a boolean "compliance"`;
231
536
  default: {
232
537
  const unhandled = error;
233
- return `unhandled curation split error ${JSON.stringify(unhandled)}`;
538
+ return `unhandled variant manifest parse error ${JSON.stringify(unhandled)}`;
234
539
  }
235
540
  }
236
541
  }
542
+ /**
543
+ * The variant a machine with these switches installs for a host with these arms.
544
+ *
545
+ * An axis counts as off for a host only when the machine has it off AND the host carries
546
+ * an arm on it, because a host with no arm on an axis has one body on that axis. The
547
+ * result is therefore always a variant the build wrote for the host (see
548
+ * emittedVariantKeys): a host with both arms on a both-off machine gets `both-off`, a host
549
+ * with only a compliance arm gets `compliance-off`, never the curation-off file it does not
550
+ * have and never the on file.
551
+ */
552
+ export function machineVariantKey(machine, hasArms) {
553
+ const curationOff = !machine.curation && hasArms.curation;
554
+ const complianceOff = !machine.compliance && hasArms.compliance;
555
+ if (curationOff && complianceOff)
556
+ return 'both-off';
557
+ if (curationOff)
558
+ return 'curation-off';
559
+ return complianceOff ? 'compliance-off' : 'on';
560
+ }
561
+ /**
562
+ * The repo-relative off files a machine must find for the hosts that carry a compliance
563
+ * arm, in manifest order. Empty when compliance is on: that machine reads no off file for
564
+ * the axis.
565
+ *
566
+ * A compliance-armed host has no safe fall-through on a machine with compliance off: the
567
+ * normal file, and on a both-off machine the curation-off file, both carry the compliance
568
+ * text the machine has switched off. A host with no compliance arm is not listed: its
569
+ * normal (or curation-off) file is the right one.
570
+ */
571
+ export function requiredComplianceVariantFiles(machine, hosts) {
572
+ if (machine.compliance)
573
+ return [];
574
+ const files = [];
575
+ for (const host of hosts) {
576
+ if (!host.axes.compliance)
577
+ continue;
578
+ const key = machineVariantKey(machine, host.axes);
579
+ if (key === 'on')
580
+ continue;
581
+ files.push(variantOffRelPath(key, host.kind, `${host.name}.md`));
582
+ }
583
+ return files;
584
+ }
237
585
  //# sourceMappingURL=curation-variants.js.map