devflow-kit 3.2.0 → 3.4.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 (188) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/dist/agents/code.md +330 -0
  4. package/{src/assets → dist}/agents/design.md +11 -1
  5. package/{src/assets → dist}/agents/diagnose.md +17 -13
  6. package/dist/agents/git.md +29 -56
  7. package/{src/assets → dist}/agents/knowledge.md +8 -5
  8. package/{src/assets → dist}/agents/research.md +21 -2
  9. package/{src/assets → dist}/agents/review.md +18 -7
  10. package/{src/assets → dist}/agents/scrutinize.md +35 -11
  11. package/dist/agents/skim.md +148 -0
  12. package/{src/assets → dist}/agents/triage.md +8 -2
  13. package/dist/cli/commands/init.js +62 -0
  14. package/dist/cli/commands/learning.js +63 -8
  15. package/dist/cli/commands/uninstall.js +42 -1
  16. package/dist/commands/bug-analysis.md +30 -8
  17. package/dist/commands/code-review.md +142 -61
  18. package/dist/commands/debug.md +15 -13
  19. package/dist/commands/dynamic-build.md +176 -130
  20. package/dist/commands/dynamic-plan.md +31 -19
  21. package/dist/commands/dynamic-profile.md +27 -13
  22. package/dist/commands/dynamic-tickets.md +28 -14
  23. package/dist/commands/explore.md +16 -14
  24. package/dist/commands/implement.md +163 -158
  25. package/dist/commands/plan.md +37 -24
  26. package/dist/commands/release.md +69 -4
  27. package/dist/commands/research.md +33 -11
  28. package/dist/commands/resolve.md +61 -47
  29. package/dist/commands/self-review.md +50 -32
  30. package/dist/core/agent-models.js +53 -7
  31. package/dist/core/assets.js +55 -10
  32. package/dist/core/claude-md-audit.js +190 -0
  33. package/dist/core/feature-switch.js +20 -1
  34. package/dist/core/flags.js +28 -0
  35. package/dist/core/fs-atomic.js +8 -3
  36. package/dist/core/learning-tuning-config.js +8 -0
  37. package/dist/core/learning-variants.js +213 -0
  38. package/dist/core/manifest.js +62 -0
  39. package/dist/core/mds-variants.js +38 -1
  40. package/dist/core/plugins.js +78 -11
  41. package/{src/assets → dist/learning-off}/agents/code.md +77 -28
  42. package/dist/learning-off/agents/design.md +119 -0
  43. package/dist/learning-off/agents/diagnose.md +210 -0
  44. package/dist/learning-off/agents/knowledge.md +90 -0
  45. package/dist/learning-off/agents/research.md +149 -0
  46. package/dist/learning-off/agents/review.md +228 -0
  47. package/dist/learning-off/agents/scrutinize.md +117 -0
  48. package/{src/assets → dist/learning-off}/agents/skim.md +5 -10
  49. package/dist/learning-off/agents/triage.md +163 -0
  50. package/dist/learning-off/commands/bug-analysis.md +420 -0
  51. package/dist/learning-off/commands/code-review.md +525 -0
  52. package/dist/learning-off/commands/debug.md +294 -0
  53. package/dist/learning-off/commands/dynamic-build.md +1255 -0
  54. package/dist/learning-off/commands/dynamic-plan.md +424 -0
  55. package/dist/learning-off/commands/dynamic-profile.md +214 -0
  56. package/dist/learning-off/commands/dynamic-tickets.md +632 -0
  57. package/dist/learning-off/commands/explore.md +210 -0
  58. package/dist/learning-off/commands/implement.md +808 -0
  59. package/dist/learning-off/commands/plan.md +664 -0
  60. package/dist/learning-off/commands/release.md +310 -0
  61. package/dist/learning-off/commands/research.md +222 -0
  62. package/dist/learning-off/commands/resolve.md +837 -0
  63. package/dist/learning-off/commands/self-review.md +266 -0
  64. package/dist/skills/git/references/tracker/_contract.md +33 -0
  65. package/dist/skills/git/references/tracker/github/fetch-issue.md +2 -0
  66. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +2 -0
  67. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +5 -1
  68. package/dist/skills/git/references/tracker/github/post-wave-report.md +2 -0
  69. package/dist/skills/git/references/tracker/github/setup-task.md +12 -0
  70. package/dist/skills/git/references/tracker/jira/associate-release.md +1 -1
  71. package/dist/skills/git/references/tracker/jira/fetch-issue.md +2 -0
  72. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +2 -0
  73. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +5 -1
  74. package/dist/skills/git/references/tracker/jira/post-wave-report.md +2 -0
  75. package/dist/skills/git/references/tracker/jira/setup-task.md +14 -2
  76. package/dist/skills/git/references/tracker/linear/associate-release.md +1 -1
  77. package/dist/skills/git/references/tracker/linear/fetch-issue.md +2 -0
  78. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +2 -0
  79. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +5 -1
  80. package/dist/skills/git/references/tracker/linear/post-wave-report.md +2 -0
  81. package/dist/skills/git/references/tracker/linear/setup-task.md +14 -2
  82. package/dist/targets/claude-code/installer.js +72 -36
  83. package/dist/targets/claude-code/language-stamp.js +185 -0
  84. package/dist/targets/claude-code/learning-install.js +489 -0
  85. package/package.json +1 -1
  86. package/src/assets/agents/code.mds +339 -0
  87. package/src/assets/agents/design.mds +149 -0
  88. package/src/assets/agents/diagnose.mds +225 -0
  89. package/src/assets/agents/evaluate.md +12 -25
  90. package/src/assets/agents/git.mds +29 -56
  91. package/src/assets/agents/knowledge.mds +125 -0
  92. package/src/assets/agents/learning.md +4 -6
  93. package/src/assets/agents/research.mds +176 -0
  94. package/src/assets/agents/review.mds +286 -0
  95. package/src/assets/agents/scrutinize.mds +132 -0
  96. package/src/assets/agents/simplify.md +20 -0
  97. package/src/assets/agents/skim.mds +161 -0
  98. package/src/assets/agents/synthesize.md +12 -0
  99. package/src/assets/agents/test.md +2 -2
  100. package/src/assets/agents/triage.mds +194 -0
  101. package/src/assets/agents/validate.md +15 -8
  102. package/src/assets/commands/_partials/_compliance.mds +5 -4
  103. package/src/assets/commands/_partials/_decisions.mds +31 -0
  104. package/src/assets/commands/_partials/_engine.mds +29 -24
  105. package/src/assets/commands/_partials/_knowledge.mds +26 -13
  106. package/src/assets/commands/_partials/_plan_contract.mds +1 -1
  107. package/src/assets/commands/_partials/_preamble.mds +33 -9
  108. package/src/assets/commands/_partials/_publication.mds +5 -4
  109. package/src/assets/commands/_partials/_settings.mds +13 -5
  110. package/src/assets/commands/_partials/_wave.mds +16 -6
  111. package/src/assets/commands/bug-analysis.mds +24 -2
  112. package/src/assets/commands/code-review.mds +148 -45
  113. package/src/assets/commands/debug.mds +17 -1
  114. package/src/assets/commands/dynamic-build.mds +144 -65
  115. package/src/assets/commands/dynamic-plan.mds +36 -6
  116. package/src/assets/commands/dynamic-profile.mds +9 -1
  117. package/src/assets/commands/dynamic-tickets.mds +16 -2
  118. package/src/assets/commands/explore.mds +27 -1
  119. package/src/assets/commands/implement.mds +171 -138
  120. package/src/assets/commands/plan.mds +47 -8
  121. package/src/assets/commands/{release.md → release.mds} +27 -24
  122. package/src/assets/commands/research.mds +28 -4
  123. package/src/assets/commands/resolve.mds +69 -17
  124. package/src/assets/commands/self-review.mds +43 -13
  125. package/src/assets/mds/tracker/_common.mds +1 -1
  126. package/src/assets/mds/tracker/_contract.mds +72 -0
  127. package/src/assets/mds/tracker/_github.mds +13 -2
  128. package/src/assets/mds/tracker/_jira.mds +17 -5
  129. package/src/assets/mds/tracker/_linear.mds +17 -5
  130. package/src/assets/mds/tracker/_mcp.mds +2 -2
  131. package/src/assets/mds/tracker/_steps.mds +97 -0
  132. package/src/assets/rules/context-economy.md +10 -0
  133. package/src/assets/rules/go.md +1 -0
  134. package/src/assets/rules/java.md +1 -0
  135. package/src/assets/rules/python.md +1 -0
  136. package/src/assets/rules/rust.md +1 -0
  137. package/src/assets/rules/typescript.md +1 -0
  138. package/src/assets/scripts/ci-wait.cjs +636 -0
  139. package/src/assets/scripts/claude-md-audit.cjs +611 -0
  140. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +2 -3
  141. package/src/assets/scripts/hooks/background-memory-update +290 -9
  142. package/src/assets/scripts/hooks/json-helper.cjs +13 -5
  143. package/src/assets/scripts/hooks/json-parse +34 -10
  144. package/src/assets/scripts/hooks/session-start-context +315 -7
  145. package/src/assets/scripts/release-trace.cjs +27 -10
  146. package/src/assets/skills/accessibility/SKILL.md +1 -1
  147. package/src/assets/skills/apply-decisions/SKILL.md +12 -82
  148. package/src/assets/skills/apply-feature-knowledge/SKILL.md +9 -43
  149. package/src/assets/skills/architecture/SKILL.md +1 -1
  150. package/src/assets/skills/boundary-validation/SKILL.md +1 -1
  151. package/src/assets/skills/complexity/SKILL.md +1 -1
  152. package/src/assets/skills/compliance/SKILL.md +1 -1
  153. package/src/assets/skills/consistency/SKILL.md +1 -1
  154. package/src/assets/skills/database/SKILL.md +1 -1
  155. package/src/assets/skills/dependencies/SKILL.md +1 -1
  156. package/src/assets/skills/dependency-research/SKILL.md +3 -6
  157. package/src/assets/skills/design-review/SKILL.md +1 -1
  158. package/src/assets/skills/docs-framework/SKILL.md +1 -1
  159. package/src/assets/skills/documentation/SKILL.md +1 -1
  160. package/src/assets/skills/feature-knowledge/SKILL.md +43 -12
  161. package/src/assets/skills/gap-analysis/SKILL.md +1 -1
  162. package/src/assets/skills/git/SKILL.md +1 -1
  163. package/src/assets/skills/go/SKILL.md +1 -1
  164. package/src/assets/skills/java/SKILL.md +1 -1
  165. package/src/assets/skills/patterns/SKILL.md +1 -1
  166. package/src/assets/skills/performance/SKILL.md +1 -1
  167. package/src/assets/skills/python/SKILL.md +1 -1
  168. package/src/assets/skills/qa/SKILL.md +1 -3
  169. package/src/assets/skills/quality-gates/SKILL.md +10 -13
  170. package/src/assets/skills/quality-gates/references/report-template.md +20 -20
  171. package/src/assets/skills/react/SKILL.md +1 -1
  172. package/src/assets/skills/regression/SKILL.md +1 -1
  173. package/src/assets/skills/reliability/SKILL.md +1 -1
  174. package/src/assets/skills/research-codebase/SKILL.md +1 -1
  175. package/src/assets/skills/research-competitor/SKILL.md +1 -1
  176. package/src/assets/skills/research-external/SKILL.md +1 -1
  177. package/src/assets/skills/research-technology/SKILL.md +1 -1
  178. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  179. package/src/assets/skills/rust/SKILL.md +1 -1
  180. package/src/assets/skills/security/SKILL.md +1 -1
  181. package/src/assets/skills/software-design/SKILL.md +1 -1
  182. package/src/assets/skills/test-driven-development/SKILL.md +9 -29
  183. package/src/assets/skills/testing/SKILL.md +1 -1
  184. package/src/assets/skills/typescript/SKILL.md +1 -1
  185. package/src/assets/skills/ui-design/SKILL.md +1 -1
  186. package/src/assets/skills/worktree-support/SKILL.md +3 -55
  187. package/src/assets/skills/worktree-support/references/discovery.md +48 -0
  188. package/src/assets/skills/worktree-support/references/roots.md +2 -2
@@ -28,15 +28,20 @@ import { promises as fs } from 'fs';
28
28
  *
29
29
  * @param filePath - Absolute path to the target file.
30
30
  * @param data - UTF-8 encoded content to write.
31
+ * @param createMode - Permission bits the temp file is CREATED with (masked by the
32
+ * umask), so a file that must be owner-only is never readable by others, not even
33
+ * between the write and the rename. Omitted, the umask default applies. Either way
34
+ * an existing target's mode wins (below).
31
35
  */
32
- export async function writeFileAtomicExclusive(filePath, data) {
36
+ export async function writeFileAtomicExclusive(filePath, data, createMode) {
33
37
  // PID-scope the tmp name so concurrent writers from different processes
34
38
  // (e.g., two Claude Code sessions) never collide on the same .tmp path.
35
39
  // mirrors proxy-log.ts rotation at src/core/proxy-log.ts which PID-scopes
36
40
  // for the same reason.
37
41
  const tmp = `${filePath}.tmp.${process.pid}`;
42
+ const options = { encoding: 'utf-8', flag: 'wx', ...(createMode === undefined ? {} : { mode: createMode }) };
38
43
  try {
39
- await fs.writeFile(tmp, data, { encoding: 'utf-8', flag: 'wx' });
44
+ await fs.writeFile(tmp, data, options);
40
45
  }
41
46
  catch (err) {
42
47
  if (err.code !== 'EEXIST')
@@ -48,7 +53,7 @@ export async function writeFileAtomicExclusive(filePath, data) {
48
53
  await fs.unlink(tmp);
49
54
  }
50
55
  catch { /* race — already removed */ }
51
- await fs.writeFile(tmp, data, { encoding: 'utf-8', flag: 'wx' });
56
+ await fs.writeFile(tmp, data, options);
52
57
  }
53
58
  // Preserve the target's permission mode across the atomic replace.
54
59
  // A user who hardened the target (e.g. settings.json → 0600 to protect
@@ -6,6 +6,14 @@ const VALID_MODELS = new Set(['opus', 'sonnet', 'haiku']);
6
6
  function isValidModel(value) {
7
7
  return typeof value === 'string' && VALID_MODELS.has(value);
8
8
  }
9
+ /**
10
+ * D-LEARNING-SHIPPED-ROW: the default model is the model the Learning agent ships
11
+ * with, the `model:` line of src/assets/agents/learning.md. With no layer
12
+ * configuring a model, the spawn directive names none and that line decides, so a
13
+ * different default here would describe a run that never happens.
14
+ * tests/decisions/config.test.ts holds this value equal to the Learning row of the
15
+ * agent-config table, which a change of the shipped tier edits with the frontmatter.
16
+ */
9
17
  const DEFAULTS = {
10
18
  model: 'opus',
11
19
  debug: false,
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Learning-variant splitter: one compiled prompt body in, its learning-on and
3
+ * learning-off variants out.
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.
7
+ *
8
+ * D-LEARNING-VARIANTS: when learning is off on a machine, the decisions text is
9
+ * ABSENT from every prompt it would have reached, not merely gated at run time.
10
+ * The build therefore emits two variants of a prompt that carries learning text:
11
+ * the learning-on variant at today's path (dist/commands/x.md, dist/agents/x.md)
12
+ * and the learning-off variant at dist/learning-off/{commands,agents}/x.md, only
13
+ * for a host that has an arm. A prompt with no arm has one variant and no
14
+ * learning-off file.
15
+ *
16
+ * The arms are whole-line markers in the host or in a partial it expands:
17
+ *
18
+ * <!-- learning:on --> opens an arm that only the learning-on variant keeps
19
+ * <!-- learning:off --> opens an arm that only the learning-off variant keeps;
20
+ * directly after an on arm it is that arm's else
21
+ * <!-- learning:end --> closes the arm
22
+ *
23
+ * A marker line matches LEARNING_MARKER_RE. It may be indented and may sit inside
24
+ * a fenced block, a list, a define body or an agent's second frontmatter block,
25
+ * because MDS passes an HTML comment through untouched. The splitter runs on the
26
+ * COMPILED body, after the MDS compile, so it adds no compileFile call, no import
27
+ * into an MDS module and nothing against the per-module compile-time guard. A
28
+ * marker line is removed whole, with its newline. Every other line is kept byte
29
+ * for byte, so a blank line is content: put the blank line a paragraph needs
30
+ * inside the arm that owns the paragraph, and the other variant gets none of it.
31
+ *
32
+ * The markers are INTERIM. MDS 0.4.4 emits an `@if` inside a code fence or on an
33
+ * indented line as literal text (its lexer requires column 0), and the arms have
34
+ * to sit exactly there: inside fenced spawn blocks, scripts, list items and
35
+ * frontmatter. They are replaced by native conditionals once mdscript ships
36
+ * fence- and indent-aware `@if` (dean0x/mdscript#452). That swap, with the
37
+ * @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 learning:`, `off` becomes `@else:` and `end` becomes `@end`. A standalone
40
+ * `off` arm, with no on arm before it, becomes an `@if learning:` with an empty
41
+ * body followed by `@else:`.
42
+ *
43
+ * What the splitter refuses, each as a LearningSplitError 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;
46
+ * - an arm still open at the end of the body;
47
+ * - an empty arm (no line with content between its marker and the next);
48
+ * - a stray marker: a line that names `<!-- learning:` but is not a whole-line
49
+ * marker, which would otherwise ship as prompt text.
50
+ *
51
+ * Scope guarantee: this module answers one question (what are the two variants of
52
+ * this body?) and one cheap one for the build to ask of a body that must carry no
53
+ * arms (containsLearningMarker). It decides nothing about WHICH text belongs in an
54
+ * arm: that is authoring, and the guards over the built trees hold it.
55
+ */
56
+ function Ok(value) {
57
+ return { ok: true, value };
58
+ }
59
+ function Err(error) {
60
+ return { ok: false, error };
61
+ }
62
+ /**
63
+ * Repo-relative root of the learning-off variants.
64
+ *
65
+ * Exported because the build's orphan prune names this directory even when no
66
+ * host carries an arm, which is exactly the case where every file in it is an
67
+ * orphan, so it cannot be derived from the plan.
68
+ */
69
+ export const LEARNING_OFF_OUTPUT_DIR = 'dist/learning-off';
70
+ /**
71
+ * POSIX repo-relative path of the learning-off variant of an output file.
72
+ *
73
+ * `fileName` is the already-validated basename the learning-on variant is written
74
+ * under (`implement.md`), so the two variants of one host always share a name.
75
+ */
76
+ export function learningOffRelPath(kind, fileName) {
77
+ return `${LEARNING_OFF_OUTPUT_DIR}/${kind}/${fileName}`;
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // Markers
81
+ // ---------------------------------------------------------------------------
82
+ /**
83
+ * A whole-line learning marker. The group captures `on`, `off` or `end`.
84
+ *
85
+ * Anchored at both ends with no nested quantifier, so a match is linear in the
86
+ * line length. Leading and trailing space or tab is allowed; anything else on the
87
+ * line is not a marker.
88
+ */
89
+ export const LEARNING_MARKER_RE = /^[ \t]*<!-- learning:(on|off|end) -->[ \t]*$/;
90
+ /**
91
+ * The fragment that makes a line a marker ATTEMPT: loose on spacing and case so a
92
+ * malformed marker is caught rather than shipped. Every valid marker contains it.
93
+ */
94
+ const MARKER_FRAGMENT_RE = /<!--\s*learning\s*:/i;
95
+ /** True when the text names a learning marker anywhere, valid or not. */
96
+ export function containsLearningMarker(text) {
97
+ return MARKER_FRAGMENT_RE.test(text);
98
+ }
99
+ function markerOf(line) {
100
+ // A CRLF body carries the carriage return on the line; it is not part of the marker.
101
+ const bare = line.endsWith('\r') ? line.slice(0, -1) : line;
102
+ const match = LEARNING_MARKER_RE.exec(bare);
103
+ return match === null ? null : match[1];
104
+ }
105
+ /** The longest stretch of a stray line a refusal quotes. */
106
+ const STRAY_QUOTE_MAX = 80;
107
+ /**
108
+ * Split a compiled body into its learning-on and learning-off variants.
109
+ *
110
+ * Total and pure: the same input gives the same output, and a refusal is a
111
+ * Result. The loop is one pass over the body's lines, so its bound is the body's
112
+ * own length.
113
+ *
114
+ * An arm needs a line with content in it. An arm that holds only blank lines is
115
+ * refused as empty, because it would add blank lines to one variant and nothing to
116
+ * the other, which is a mistake rather than a design.
117
+ */
118
+ export function splitLearningVariants(body) {
119
+ const lines = body.split('\n');
120
+ const on = [];
121
+ const off = [];
122
+ let hasArms = false;
123
+ // The open arm, if any: which one, the line of the marker that opened it, and
124
+ // whether it has held a line with content yet.
125
+ let arm = null;
126
+ let armOpenedAt = 0;
127
+ let armHasContent = false;
128
+ for (let index = 0; index < lines.length; index++) {
129
+ const line = lines[index];
130
+ const lineNo = index + 1;
131
+ const marker = markerOf(line);
132
+ if (marker === null) {
133
+ if (MARKER_FRAGMENT_RE.test(line)) {
134
+ return Err({ kind: 'stray-marker', line: lineNo, text: line.trim().slice(0, STRAY_QUOTE_MAX) });
135
+ }
136
+ if (arm !== 'off')
137
+ on.push(line);
138
+ if (arm !== 'on')
139
+ off.push(line);
140
+ if (arm !== null && line.trim() !== '')
141
+ armHasContent = true;
142
+ continue;
143
+ }
144
+ hasArms = true;
145
+ switch (marker) {
146
+ case 'on':
147
+ if (arm !== null)
148
+ return Err({ kind: 'nested-arm', line: lineNo, arm, openedAt: armOpenedAt });
149
+ arm = 'on';
150
+ armOpenedAt = lineNo;
151
+ armHasContent = false;
152
+ break;
153
+ case 'off':
154
+ if (arm === 'off')
155
+ return Err({ kind: 'repeated-off', line: lineNo, openedAt: armOpenedAt });
156
+ // Directly after an on arm this is its else: the on arm ends here.
157
+ if (arm === 'on' && !armHasContent) {
158
+ return Err({ kind: 'empty-arm', arm, openedAt: armOpenedAt, closedAt: lineNo });
159
+ }
160
+ arm = 'off';
161
+ armOpenedAt = lineNo;
162
+ armHasContent = false;
163
+ break;
164
+ case 'end':
165
+ if (arm === null)
166
+ return Err({ kind: 'unopened-end', line: lineNo });
167
+ if (!armHasContent) {
168
+ return Err({ kind: 'empty-arm', arm, openedAt: armOpenedAt, closedAt: lineNo });
169
+ }
170
+ arm = null;
171
+ break;
172
+ default: {
173
+ const unhandled = marker;
174
+ return Err({ kind: 'stray-marker', line: lineNo, text: String(unhandled) });
175
+ }
176
+ }
177
+ }
178
+ if (arm !== null)
179
+ return Err({ kind: 'unclosed-arm', arm, openedAt: armOpenedAt });
180
+ return Ok({ on: on.join('\n'), off: off.join('\n'), hasArms });
181
+ }
182
+ /**
183
+ * Render a refusal as one line a person can act on.
184
+ *
185
+ * One arm per kind with a `never` default, so a kind added to the union cannot
186
+ * fall through into a message that does not describe it. Shared by the build and
187
+ * the tests, so the text asserted is the text shipped.
188
+ */
189
+ export function describeLearningSplitError(error) {
190
+ switch (error.kind) {
191
+ case 'nested-arm':
192
+ return (`line ${error.line}: a learning:on marker inside the ${error.arm} arm opened at line ` +
193
+ `${error.openedAt} — arms do not nest; close it with learning:end first`);
194
+ case 'repeated-off':
195
+ return (`line ${error.line}: a second learning:off marker inside the off arm opened at line ` +
196
+ `${error.openedAt} — an arm has at most one else`);
197
+ case 'unopened-end':
198
+ return `line ${error.line}: a learning:end marker with no open arm`;
199
+ case 'unclosed-arm':
200
+ return `the ${error.arm} arm opened at line ${error.openedAt} is never closed with learning:end`;
201
+ case 'empty-arm':
202
+ return (`lines ${error.openedAt}-${error.closedAt}: the ${error.arm} arm holds no line with content — ` +
203
+ `delete the arm or put its text inside it`);
204
+ case 'stray-marker':
205
+ return (`line ${error.line}: "${error.text}" names a learning marker but is not one — a marker ` +
206
+ `is a whole line, exactly <!-- learning:on -->, <!-- learning:off --> or <!-- learning:end -->`);
207
+ default: {
208
+ const unhandled = error;
209
+ return `unhandled learning split error ${JSON.stringify(unhandled)}`;
210
+ }
211
+ }
212
+ }
213
+ //# sourceMappingURL=learning-variants.js.map
@@ -213,6 +213,68 @@ export async function syncManifestFeature(devflowDir, key, value) {
213
213
  manifest.updatedAt = new Date().toISOString();
214
214
  await writeManifest(devflowDir, manifest);
215
215
  }
216
+ /**
217
+ * Drop `names` from a RAW parsed manifest's `plugins` list. Pure — returns a new
218
+ * object and never mutates its input. Null when there is nothing to write: the
219
+ * value is not a manifest-shaped object, or none of `names` is listed.
220
+ *
221
+ * Only `plugins` and `updatedAt` change; every other key is carried verbatim,
222
+ * `knownPlugins` included. That snapshot records which registry plugins the last
223
+ * install knew, and init adopts a registry plugin that is in neither list as new
224
+ * (`resolveSeedPlugins`), so dropping a removed plugin from it would make the next
225
+ * re-init install it again. Going through readManifest()/writeManifest() instead
226
+ * would drop every key ManifestData does not model (one a newer devflow wrote,
227
+ * say) and persist that reader's unrelated heals as a side effect of a removal.
228
+ * Entries that are not strings are neither matched nor dropped.
229
+ */
230
+ export function withoutManifestPlugins(rawManifest, names, now) {
231
+ if (typeof rawManifest !== 'object' || rawManifest === null || Array.isArray(rawManifest))
232
+ return null;
233
+ const record = rawManifest;
234
+ if (!Array.isArray(record.plugins))
235
+ return null;
236
+ const doomed = new Set(names);
237
+ const kept = record.plugins.filter(entry => !(typeof entry === 'string' && doomed.has(entry)));
238
+ if (kept.length === record.plugins.length)
239
+ return null;
240
+ return { ...record, plugins: kept, updatedAt: now };
241
+ }
242
+ /**
243
+ * D-UNINSTALL-DROPS-PLUGIN: record a selective uninstall in `<devflowDir>/manifest.json`.
244
+ *
245
+ * The manifest is the install record init seeds from (a re-init keeps the prior
246
+ * selection), so a plugin `devflow uninstall --plugin` removed but the manifest
247
+ * still lists would come back on the next plain `devflow init`. This is the one
248
+ * writer that takes a name off that list (atomic temp + rename).
249
+ *
250
+ * A quiet no-op, not an error, when there is nothing to record: no manifest (a
251
+ * pre-manifest install), one that is not valid JSON or not manifest-shaped, or
252
+ * none of `names` listed — the file is left byte-identical. Never throws: a failed
253
+ * write comes back as `{ ok: false }` for the caller to report.
254
+ */
255
+ export async function removeManifestPlugins(devflowDir, names) {
256
+ const manifestPath = path.join(devflowDir, 'manifest.json');
257
+ let raw;
258
+ try {
259
+ raw = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
260
+ }
261
+ catch {
262
+ // ENOENT, EACCES or SyntaxError — no usable manifest, so nothing to record.
263
+ return { ok: true, removed: [] };
264
+ }
265
+ const next = withoutManifestPlugins(raw, names, new Date().toISOString());
266
+ if (next === null)
267
+ return { ok: true, removed: [] };
268
+ try {
269
+ await writeFileAtomicExclusive(manifestPath, JSON.stringify(next, null, 2) + '\n');
270
+ }
271
+ catch (error) {
272
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
273
+ }
274
+ const doomed = new Set(names);
275
+ const before = raw.plugins;
276
+ return { ok: true, removed: before.filter((entry) => typeof entry === 'string' && doomed.has(entry)) };
277
+ }
216
278
  /**
217
279
  * Merge new plugins into existing plugin list (union, no duplicates).
218
280
  * Preserves order: existing plugins first, then new ones appended.
@@ -120,6 +120,13 @@ export function validateContractOutputName(name) {
120
120
  * from here keeps the table below the only place a destination is spelled.
121
121
  */
122
122
  export const AGENTS_OUTPUT_DIR = 'dist/agents';
123
+ /**
124
+ * Repo-relative destination for `commands` hosts.
125
+ *
126
+ * Exported for the same reason as AGENTS_OUTPUT_DIR: the orphan prune names the
127
+ * directory even when no command host is planned.
128
+ */
129
+ export const COMMANDS_OUTPUT_DIR = 'dist/commands';
123
130
  /**
124
131
  * The bare (unprefixed) skill that OWNS the generated references.
125
132
  *
@@ -153,7 +160,7 @@ export const SKILL_REFS_SKILL_NAME = 'git';
153
160
  */
154
161
  export const SKILL_REFS_OUTPUT_DIR = `dist/skills/${SKILL_REFS_SKILL_NAME}/references`;
155
162
  const ALLOWED_OUTPUT_DIRS = [
156
- { dir: 'dist/commands', variant: 'commands' },
163
+ { dir: COMMANDS_OUTPUT_DIR, variant: 'commands' },
157
164
  { dir: AGENTS_OUTPUT_DIR, variant: 'agents' },
158
165
  { dir: SKILL_REFS_OUTPUT_DIR, variant: 'skill-refs' },
159
166
  ];
@@ -363,6 +370,30 @@ export const GIT_CROSS_CUTTING_DOCS = [
363
370
  * Registering a provider whose `subdir` is one of MCP_BACKED_PROVIDER_SUBDIRS is
364
371
  * also what opens the generation gate on the tool-call contract; see
365
372
  * {@link mcpContractIsGenerated}. There is no second edit and no flag.
373
+ *
374
+ * D-TRACKER-CONTRACT-ON-DEMAND. The last entry, `_contract.mds`, is the first
375
+ * UNGATED `kind: 'contract'` module (the tool-call contract of
376
+ * {@link MCP_CONTRACT_MODULE} is gated, so it is not in this array). It emits
377
+ * `tracker/_contract.md`: the provider resolution and the tracker input contract
378
+ * that used to sit in the always-loaded Git agent, where every spawn paid for
379
+ * them although most spawns run a PR-host operation that never touches the
380
+ * issue tracker. A spawn now reads the file once, only when it runs a tracker
381
+ * operation, before its first tracker step. The agent's retained
382
+ * `## Loading the mechanics` section is the one line that names it; it keeps
383
+ * the PR-mechanics load rule and the merged step order, which a PR-only spawn
384
+ * needs and which this file cannot instruct a spawn to load. It is ungated for
385
+ * the reason the registry's other provider-independent files are: every install
386
+ * carries every provider, GitHub included, so no registered provider is the
387
+ * condition it depends on.
388
+ *
389
+ * The budget clause: the file is a per-SPAWN term of each tracker row (GitHub,
390
+ * Jira, Linear), charged the way the tool-call contract is (`contractTerm` in
391
+ * tests/tracker/budget-model.ts) and added in `providerLoadedSet`. It is not part
392
+ * of `ownLoadForProvider`, of the PR-host row or of the per-operation PR-host
393
+ * cap. ensure-pr-ready is both a PR-host and a tracker operation, so charging it
394
+ * the contract there would put it over a lower-only cap; its full cost,
395
+ * contract included, is priced through the tracker rows, where it is already a
396
+ * candidate.
366
397
  */
367
398
  export const VARIANT_MODULES = [
368
399
  {
@@ -395,6 +426,12 @@ export const VARIANT_MODULES = [
395
426
  kind: 'named',
396
427
  ops: GIT_CROSS_CUTTING_DOCS,
397
428
  },
429
+ {
430
+ source: 'src/assets/mds/tracker/_contract.mds',
431
+ subdir: 'tracker',
432
+ kind: 'contract',
433
+ ops: ['_contract'],
434
+ },
398
435
  ];
399
436
  // ---------------------------------------------------------------------------
400
437
  // The tool-call contract module, and the gate on its generation
@@ -64,7 +64,13 @@ export const DEVFLOW_PLUGINS = [
64
64
  'security',
65
65
  'worktree-support',
66
66
  ],
67
- rules: ['security', 'engineering', 'quality', 'reliability'],
67
+ // D-CONTEXT-ECONOMY-RULE: reading discipline is a core rule, not a skill, because it
68
+ // must reach every session and every spawned agent without an activation step. It
69
+ // tells the model to list a large file's headings and read ranges, and to count
70
+ // search matches before printing them. Its body stays near 400 characters (ceiling
71
+ // 450) and names no search binary, since the rule is loaded into every session;
72
+ // tests/rules.test.ts enforces the length, the directives and this registration.
73
+ rules: ['security', 'engineering', 'quality', 'reliability', 'context-economy'],
68
74
  },
69
75
  {
70
76
  name: 'devflow-plan',
@@ -244,7 +250,10 @@ export const DEVFLOW_PLUGINS = [
244
250
  commands: ['/release'],
245
251
  agents: ['git', 'validate'],
246
252
  skills: ['git', 'worktree-support'],
247
- requires: ['testing'],
253
+ // D-PRELOAD-TRIM: nothing in this plugin's corpus reads `testing` (Validate
254
+ // preloads only `worktree-support`), so the closure guard's reverse arm holds
255
+ // this list empty.
256
+ requires: [],
248
257
  rules: [],
249
258
  },
250
259
  {
@@ -253,7 +262,9 @@ export const DEVFLOW_PLUGINS = [
253
262
  commands: ['/self-review'],
254
263
  agents: ['simplify', 'scrutinize', 'validate', 'knowledge'],
255
264
  skills: ['quality-gates', 'software-design', 'worktree-support', 'feature-knowledge', 'apply-feature-knowledge'],
256
- requires: ['apply-decisions', 'testing'],
265
+ // D-PRELOAD-TRIM: no agent, command or skill in this plugin's corpus names
266
+ // `testing` (Validate preloads only `worktree-support`), so it is not required here.
267
+ requires: ['apply-decisions'],
257
268
  rules: [],
258
269
  },
259
270
  {
@@ -490,19 +501,52 @@ export const FEATURE_OWNED_SKILLS = ['compliance'];
490
501
  * (guarded by plugins.test.ts FEATURE_OWNED constants describe block).
491
502
  */
492
503
  export const FEATURE_OWNED_RULES = ['compliance'];
504
+ /**
505
+ * Skills a learning-off machine does not install.
506
+ *
507
+ * D-LEARNING-VARIANT-INSTALL: the learning-off variants of the commands and
508
+ * agents (D-LEARNING-VARIANTS) carry no decisions text and preload no
509
+ * apply-decisions, so the skill has no reader on a machine with learning off. It
510
+ * stays OWNED and REQUIRED by its plugins in the registry — the closure guard
511
+ * reasons over the learning-on variant, the superset — and the install drops it
512
+ * on top of the selection: `installViaFileCopy` skips it when `learning` is false
513
+ * and `convergeLearningVariants` installs or removes it when the switch flips.
514
+ *
515
+ * This is a machine-switch condition, not a deselection: the plan
516
+ * ({@link resolveSkillInstallPlan}) is untouched, so a learning-off machine never
517
+ * reports the skill as "removed because no selected plugin requires it" nor its
518
+ * shadow as inactive for a plugin that is not selected.
519
+ *
520
+ * Used by:
521
+ * - installer.ts installViaFileCopy: omits these from the install loop when learning is off
522
+ * - learning-install.ts convergeLearningVariants: installs or removes the directory
523
+ * - tests: independent literal ['apply-decisions'] (avoids the EXCLUDED-as-oracle trap)
524
+ */
525
+ export const LEARNING_GATED_SKILLS = ['apply-decisions'];
526
+ /**
527
+ * A skills map with the learning-gated skills left out when learning is off.
528
+ *
529
+ * Pure: the same map instance comes back when learning is on, a copy otherwise.
530
+ */
531
+ export function omitLearningGatedSkills(skillsMap, learning) {
532
+ if (learning)
533
+ return skillsMap;
534
+ const gated = LEARNING_GATED_SKILLS;
535
+ return new Map([...skillsMap].filter(([skill]) => !gated.includes(skill)));
536
+ }
493
537
  // ── Skill-closure boundaries ──────────────────────────────────────────────────
494
538
  /**
495
539
  * Skills that are REFERENCED but never REQUIRED — the presence-gated set.
496
540
  *
497
541
  * D-PRESENCE-GATED: every language/ecosystem skill ships with an optional,
498
542
  * command-less plugin, so a reference to one is a reference to something the
499
- * user may deliberately not have. The referencing prompts are written to probe
500
- * first and proceed without it — `/code-review` checks
501
- * `skills/devflow:{focus}/SKILL.md` under Claude Code's directory (`CLAUDE_CONFIG_DIR`
502
- * when absolute, else `~/.claude` — D-CLAUDE-DIR-PROMPTS) before spawning that focus, the
503
- * Review and Code agents continue when the Skill invocation fails. Putting them
504
- * in a `requires` would reinstate the universal install for exactly the eight
505
- * skills the selection prompt exists to let a user decline (AC-25).
543
+ * user may deliberately not have. The referencing prompts are written to
544
+ * proceed without it: `/code-review` spawns a language focus only when the
545
+ * installer stamped it into the command (D-LANGUAGE-FOCUS-STAMP,
546
+ * {@link installedLanguageFocuses}), and the Review and Code agents continue
547
+ * when the Skill invocation fails. Putting them in a `requires` would reinstate
548
+ * the universal install for exactly the eight skills the selection prompt
549
+ * exists to let a user decline (AC-25).
506
550
  *
507
551
  * DERIVED from the registry rather than hand-listed: a ninth language plugin is
508
552
  * presence-gated by being declared, with no second roster to remember. Guarded
@@ -540,7 +584,7 @@ export const TEMPLATE_SKILL_REFS = [
540
584
  },
541
585
  {
542
586
  literal: 'devflow:{FOCUS}',
543
- site: 'src/assets/agents/review.md (the Review agent loading its own focus skill)',
587
+ site: 'src/assets/agents/review.mds (the Review agent loading its own focus skill)',
544
588
  why: 'receiving half of the same substitution — the agent is told which focus it is, not which skill exists',
545
589
  },
546
590
  ];
@@ -728,6 +772,29 @@ export function buildScopedSkillsMap(plugins) {
728
772
  }
729
773
  return skillsMap;
730
774
  }
775
+ /**
776
+ * The language focuses an install selection makes available — the list the
777
+ * installer stamps into the installed /code-review command.
778
+ *
779
+ * D-LANGUAGE-FOCUS-STAMP: the language gate moved from a run-time `test -f`
780
+ * probe of Claude Code's directory to this list, computed once at install time.
781
+ * Pure plumbing and nothing more: the registry's {@link PRESENCE_GATED_SKILLS}
782
+ * filtered to the skills the selection's closure installs
783
+ * ({@link buildScopedSkillsMap}), in registry order. Which focuses a diff gets
784
+ * stays a prompt rule that the orchestrator executes; no code here, or anywhere
785
+ * in the installer, classifies a diff or chooses a focus.
786
+ *
787
+ * The selection is the EFFECTIVE one (`FileCopyOptions.effectivePlugins`), never
788
+ * the plugins one run copies: `--plugin=X` installs X alone while the machine's
789
+ * selection is the manifest's plugins plus X, and the stamp has to describe what
790
+ * is on disk. Selective uninstall passes the plugins that remain.
791
+ *
792
+ * @param effectivePlugins - The plugins whose skill closure is, or stays, installed.
793
+ */
794
+ export function installedLanguageFocuses(effectivePlugins) {
795
+ const installed = buildScopedSkillsMap(effectivePlugins);
796
+ return PRESENCE_GATED_SKILLS.filter(skill => installed.has(skill));
797
+ }
731
798
  /**
732
799
  * Build a skills map over the WHOLE registry.
733
800
  *