devflow-kit 3.3.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 (138) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/agents/code.md +330 -0
  3. package/{src/assets → dist}/agents/design.md +1 -1
  4. package/{src/assets → dist}/agents/diagnose.md +1 -2
  5. package/dist/agents/git.md +29 -56
  6. package/{src/assets → dist}/agents/knowledge.md +4 -3
  7. package/{src/assets → dist}/agents/research.md +2 -2
  8. package/{src/assets → dist}/agents/review.md +8 -7
  9. package/{src/assets → dist}/agents/scrutinize.md +1 -1
  10. package/dist/agents/skim.md +148 -0
  11. package/{src/assets → dist}/agents/triage.md +1 -1
  12. package/dist/cli/commands/init.js +62 -0
  13. package/dist/cli/commands/learning.js +38 -3
  14. package/dist/cli/commands/uninstall.js +42 -1
  15. package/dist/commands/bug-analysis.md +30 -8
  16. package/dist/commands/code-review.md +141 -60
  17. package/dist/commands/debug.md +14 -12
  18. package/dist/commands/dynamic-build.md +37 -38
  19. package/dist/commands/dynamic-plan.md +30 -18
  20. package/dist/commands/dynamic-profile.md +27 -13
  21. package/dist/commands/dynamic-tickets.md +28 -14
  22. package/dist/commands/explore.md +15 -13
  23. package/dist/commands/implement.md +33 -28
  24. package/dist/commands/plan.md +37 -24
  25. package/dist/commands/release.md +69 -4
  26. package/dist/commands/research.md +33 -11
  27. package/dist/commands/resolve.md +35 -32
  28. package/dist/commands/self-review.md +36 -23
  29. package/dist/core/agent-models.js +43 -0
  30. package/dist/core/assets.js +55 -10
  31. package/dist/core/claude-md-audit.js +190 -0
  32. package/dist/core/feature-switch.js +20 -1
  33. package/dist/core/flags.js +28 -0
  34. package/dist/core/fs-atomic.js +8 -3
  35. package/dist/core/learning-variants.js +213 -0
  36. package/dist/core/manifest.js +62 -0
  37. package/dist/core/mds-variants.js +38 -1
  38. package/dist/core/plugins.js +71 -9
  39. package/{src/assets → dist/learning-off}/agents/code.md +6 -10
  40. package/dist/learning-off/agents/design.md +119 -0
  41. package/dist/learning-off/agents/diagnose.md +210 -0
  42. package/dist/learning-off/agents/knowledge.md +90 -0
  43. package/dist/learning-off/agents/research.md +149 -0
  44. package/dist/learning-off/agents/review.md +228 -0
  45. package/dist/learning-off/agents/scrutinize.md +117 -0
  46. package/{src/assets → dist/learning-off}/agents/skim.md +1 -8
  47. package/dist/learning-off/agents/triage.md +163 -0
  48. package/dist/learning-off/commands/bug-analysis.md +420 -0
  49. package/dist/learning-off/commands/code-review.md +525 -0
  50. package/dist/learning-off/commands/debug.md +294 -0
  51. package/dist/learning-off/commands/dynamic-build.md +1255 -0
  52. package/dist/learning-off/commands/dynamic-plan.md +424 -0
  53. package/dist/learning-off/commands/dynamic-profile.md +214 -0
  54. package/dist/learning-off/commands/dynamic-tickets.md +632 -0
  55. package/dist/learning-off/commands/explore.md +210 -0
  56. package/dist/learning-off/commands/implement.md +808 -0
  57. package/dist/learning-off/commands/plan.md +664 -0
  58. package/dist/learning-off/commands/release.md +310 -0
  59. package/dist/learning-off/commands/research.md +222 -0
  60. package/dist/learning-off/commands/resolve.md +837 -0
  61. package/dist/learning-off/commands/self-review.md +266 -0
  62. package/dist/skills/git/references/tracker/_contract.md +33 -0
  63. package/dist/skills/git/references/tracker/github/fetch-issue.md +2 -0
  64. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +2 -0
  65. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +4 -0
  66. package/dist/skills/git/references/tracker/github/post-wave-report.md +2 -0
  67. package/dist/skills/git/references/tracker/github/setup-task.md +12 -0
  68. package/dist/skills/git/references/tracker/jira/associate-release.md +1 -1
  69. package/dist/skills/git/references/tracker/jira/fetch-issue.md +2 -0
  70. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +2 -0
  71. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +4 -0
  72. package/dist/skills/git/references/tracker/jira/post-wave-report.md +2 -0
  73. package/dist/skills/git/references/tracker/jira/setup-task.md +14 -2
  74. package/dist/skills/git/references/tracker/linear/associate-release.md +1 -1
  75. package/dist/skills/git/references/tracker/linear/fetch-issue.md +2 -0
  76. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +2 -0
  77. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +4 -0
  78. package/dist/skills/git/references/tracker/linear/post-wave-report.md +2 -0
  79. package/dist/skills/git/references/tracker/linear/setup-task.md +14 -2
  80. package/dist/targets/claude-code/installer.js +72 -36
  81. package/dist/targets/claude-code/language-stamp.js +185 -0
  82. package/dist/targets/claude-code/learning-install.js +489 -0
  83. package/package.json +1 -1
  84. package/src/assets/agents/code.mds +339 -0
  85. package/src/assets/agents/design.mds +149 -0
  86. package/src/assets/agents/diagnose.mds +225 -0
  87. package/src/assets/agents/evaluate.md +1 -3
  88. package/src/assets/agents/git.mds +29 -56
  89. package/src/assets/agents/knowledge.mds +125 -0
  90. package/src/assets/agents/research.mds +176 -0
  91. package/src/assets/agents/review.mds +286 -0
  92. package/src/assets/agents/scrutinize.mds +132 -0
  93. package/src/assets/agents/skim.mds +161 -0
  94. package/src/assets/agents/triage.mds +194 -0
  95. package/src/assets/agents/validate.md +8 -6
  96. package/src/assets/commands/_partials/_compliance.mds +5 -4
  97. package/src/assets/commands/_partials/_decisions.mds +31 -0
  98. package/src/assets/commands/_partials/_engine.mds +9 -1
  99. package/src/assets/commands/_partials/_knowledge.mds +25 -12
  100. package/src/assets/commands/_partials/_preamble.mds +33 -9
  101. package/src/assets/commands/_partials/_publication.mds +5 -4
  102. package/src/assets/commands/_partials/_settings.mds +13 -5
  103. package/src/assets/commands/_partials/_wave.mds +8 -0
  104. package/src/assets/commands/bug-analysis.mds +24 -2
  105. package/src/assets/commands/code-review.mds +147 -44
  106. package/src/assets/commands/debug.mds +17 -1
  107. package/src/assets/commands/dynamic-build.mds +33 -2
  108. package/src/assets/commands/dynamic-plan.mds +36 -6
  109. package/src/assets/commands/dynamic-profile.mds +9 -1
  110. package/src/assets/commands/dynamic-tickets.mds +16 -2
  111. package/src/assets/commands/explore.mds +27 -1
  112. package/src/assets/commands/implement.mds +41 -8
  113. package/src/assets/commands/plan.mds +47 -8
  114. package/src/assets/commands/{release.md → release.mds} +27 -24
  115. package/src/assets/commands/research.mds +28 -4
  116. package/src/assets/commands/resolve.mds +43 -2
  117. package/src/assets/commands/self-review.mds +30 -5
  118. package/src/assets/mds/tracker/_contract.mds +72 -0
  119. package/src/assets/mds/tracker/_github.mds +13 -2
  120. package/src/assets/mds/tracker/_jira.mds +17 -5
  121. package/src/assets/mds/tracker/_linear.mds +17 -5
  122. package/src/assets/mds/tracker/_mcp.mds +2 -2
  123. package/src/assets/mds/tracker/_steps.mds +97 -0
  124. package/src/assets/rules/context-economy.md +10 -0
  125. package/src/assets/rules/go.md +1 -0
  126. package/src/assets/rules/java.md +1 -0
  127. package/src/assets/rules/python.md +1 -0
  128. package/src/assets/rules/rust.md +1 -0
  129. package/src/assets/rules/typescript.md +1 -0
  130. package/src/assets/scripts/claude-md-audit.cjs +611 -0
  131. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +1 -2
  132. package/src/assets/scripts/hooks/json-helper.cjs +13 -5
  133. package/src/assets/scripts/hooks/json-parse +34 -10
  134. package/src/assets/scripts/hooks/session-start-context +315 -7
  135. package/src/assets/skills/apply-decisions/SKILL.md +1 -1
  136. package/src/assets/skills/apply-feature-knowledge/SKILL.md +5 -5
  137. package/src/assets/skills/feature-knowledge/SKILL.md +43 -12
  138. package/src/assets/skills/quality-gates/SKILL.md +1 -1
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The CLI's view of the CLAUDE.md import audit — a typed seam onto the package's
3
+ * own `claude-md-audit.cjs`, never a second implementation of it.
4
+ *
5
+ * D-CLAUDE-MD-IMPORT-AUDIT: the logic (import grammar, bounds, thresholds, display
6
+ * text, stamp format) lives once, in src/assets/scripts/claude-md-audit.cjs, because
7
+ * the SessionStart hook reaches only `~/.devflow/scripts/`, which `composeScripts`
8
+ * fills by copying `src/assets/scripts/` verbatim. This module is the typed facade
9
+ * `devflow init` calls, loaded with the evidence-policy seam's `loadScript` against
10
+ * an explicit surface map (D-POLICY-CJS-SEAM). The interfaces below are TRANSCRIBED
11
+ * from the script's JSDoc and are this side's only shape authority; the module is
12
+ * required from `scriptsDir()` (the package's own copy), never from the installed
13
+ * `~/.devflow/scripts`, which may be older than this CLI.
14
+ *
15
+ * Nothing here throws: a load failure, a script that throws and a stamp that cannot
16
+ * be written are `Result` errors, and init prints at most one degraded line for them
17
+ * and keeps its exit code.
18
+ *
19
+ * D-AUDIT-STAMP (the caller's half): the audit writes nothing; this module writes the
20
+ * stamp after the install, only under an existing machine root (a command never
21
+ * creates `~/.devflow` for it), only through a sibling temp file renamed into place
22
+ * ({@link writeFileAtomicExclusive}), and never through a link. Because
23
+ * `writeFileAtomicExclusive` does not refuse a symlink at the target, the write does
24
+ * its own `lstat` regular-file check first (AC-437): a stamp path that is a symbolic
25
+ * link or any other non-regular file is refused and nothing is written.
26
+ */
27
+ import { promises as fs } from 'fs';
28
+ import { join } from 'path';
29
+ import { scriptsDir } from './assets.js';
30
+ import { writeFileAtomicExclusive } from './fs-atomic.js';
31
+ import { loadScript, } from './evidence-policy.js';
32
+ /** The script, relative to src/assets/scripts/ (and ~/.devflow/scripts/). */
33
+ export const CLAUDE_MD_AUDIT_SCRIPT_NAME = 'claude-md-audit.cjs';
34
+ /** The stamp's basename under the machine root (`~/.devflow`). */
35
+ export const CLAUDE_MD_AUDIT_STAMP_FILE = '.claude-md-audit';
36
+ /**
37
+ * The prefix of the stamp's sibling temp file, `<stamp>.tmp.<pid>`: the one name
38
+ * the hook and {@link writeFileAtomicExclusive} both create, so a crash between the
39
+ * write and the rename leaves a name uninstall can sweep.
40
+ */
41
+ export const CLAUDE_MD_AUDIT_STAMP_TMP_PREFIX = `${CLAUDE_MD_AUDIT_STAMP_FILE}.tmp.`;
42
+ /** The stamp is created owner-only, the mode the hook's `umask 077` write gives it. */
43
+ const STAMP_CREATE_MODE = 0o600;
44
+ /** Every key of ClaudeMdAuditModule and the runtime kind the loader requires of it. */
45
+ export const CLAUDE_MD_AUDIT_MODULE_SURFACE = Object.freeze({
46
+ FILE_THRESHOLD_BYTES: 'number',
47
+ CHAIN_THRESHOLD_BYTES: 'number',
48
+ MAX_HOPS: 'number',
49
+ MAX_PATHS_PER_ROOT: 'number',
50
+ SCAN_BYTES: 'number',
51
+ MAX_BYTES_READ: 'number',
52
+ MAX_STAMP_BYTES: 'number',
53
+ MAX_KEYS: 'number',
54
+ SKIP_FILE_BYTES: 'number',
55
+ NOT_SHOWN: 'string',
56
+ HOOK_MAGIC: 'string',
57
+ HOOK_END: 'string',
58
+ extractImports: 'function',
59
+ isDisplayable: 'function',
60
+ isRecordable: 'function',
61
+ formatFinding: 'function',
62
+ findingKey: 'function',
63
+ parseStamp: 'function',
64
+ renderStamp: 'function',
65
+ readStampFile: 'function',
66
+ audit: 'function',
67
+ main: 'function',
68
+ });
69
+ /**
70
+ * Load the audit script from `dir` (default: the package's own scripts directory)
71
+ * and shape-check its surface. Never throws.
72
+ */
73
+ export function loadClaudeMdAuditModule(dir = scriptsDir()) {
74
+ return loadScript(join(dir, CLAUDE_MD_AUDIT_SCRIPT_NAME), CLAUDE_MD_AUDIT_MODULE_SURFACE);
75
+ }
76
+ // ── Roots ──────────────────────────────────────────────────────────────────────
77
+ /**
78
+ * The gated root set, in the order the hook passes it: `CLAUDE.md` in the Claude
79
+ * config directory, then (only for a git project that is not HOME — the caller
80
+ * passes `null` otherwise) the project's `CLAUDE.md`, `.claude/CLAUDE.md` and
81
+ * `CLAUDE.local.md` at its toplevel. Ancestor directories, lazily loaded
82
+ * subdirectory files and `rules/*.md` are not roots.
83
+ */
84
+ export function claudeMdAuditRoots(claudeDir, projectRoot) {
85
+ const roots = [join(claudeDir, 'CLAUDE.md')];
86
+ if (projectRoot !== null) {
87
+ roots.push(join(projectRoot, 'CLAUDE.md'), join(projectRoot, '.claude', 'CLAUDE.md'), join(projectRoot, 'CLAUDE.local.md'));
88
+ }
89
+ return roots;
90
+ }
91
+ /** The stamp's text when it is a regular file of at most MAX_STAMP_BYTES, else null (read as absent). */
92
+ export async function readClaudeMdAuditStamp(devflowDir, maxBytes) {
93
+ const stampPath = join(devflowDir, CLAUDE_MD_AUDIT_STAMP_FILE);
94
+ try {
95
+ const st = await fs.lstat(stampPath);
96
+ if (!st.isFile() || st.size > maxBytes)
97
+ return null;
98
+ return await fs.readFile(stampPath, 'utf-8');
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ /**
105
+ * Run the audit over the gated roots. Reads only; the stamp is written by
106
+ * {@link writeClaudeMdAuditStamp}. Never throws.
107
+ */
108
+ export async function runClaudeMdAudit(opts) {
109
+ const loaded = loadClaudeMdAuditModule(opts.scriptsDir);
110
+ if (!loaded.ok)
111
+ return { ok: false, error: { kind: 'load', detail: loaded.error } };
112
+ const audit = loaded.value;
113
+ try {
114
+ const stampText = await readClaudeMdAuditStamp(opts.devflowDir, audit.MAX_STAMP_BYTES);
115
+ const prior = stampText === null ? [] : audit.parseStamp(stampText).keys;
116
+ const roots = claudeMdAuditRoots(opts.claudeDir, opts.projectRoot);
117
+ if (opts.showAll !== true)
118
+ return { ok: true, value: audit.audit({ roots, home: opts.home, keys: prior }) };
119
+ const result = audit.audit({ roots, home: opts.home, keys: [] });
120
+ const keys = [...prior.filter(key => !result.shown.includes(key)), ...result.shown].slice(-audit.MAX_KEYS);
121
+ return { ok: true, value: { ...result, stamp: audit.renderStamp({ roots, examined: result.examined, keys }) } };
122
+ }
123
+ catch (err) {
124
+ return { ok: false, error: { kind: 'failed', detail: err instanceof Error ? err.message : String(err) } };
125
+ }
126
+ }
127
+ /**
128
+ * Run the audit and record its stamp (D-AUDIT-STAMP): what `devflow init` does after the
129
+ * install. Every finding over a threshold is displayed (`showAll`), as init is a deliberate
130
+ * command. The stamp carries the examined paths and every key the audit displayed, so the
131
+ * first SessionStart afterwards repeats nothing it was just shown. A stamp that cannot be
132
+ * written (no machine root, a link in the way, a full disk) costs a repeated message at
133
+ * most, never the audit's result, so that failure is not reported. Never throws.
134
+ */
135
+ export async function auditAndRecordClaudeMd(opts) {
136
+ const run = await runClaudeMdAudit({ ...opts, showAll: true });
137
+ if (run.ok)
138
+ await writeClaudeMdAuditStamp(opts.devflowDir, run.value.stamp);
139
+ return run;
140
+ }
141
+ /**
142
+ * The display lines of an audit result, as ONE note body, or null when nothing is
143
+ * flagged. The single formatter both init paths (Recommended and Advanced) use; the
144
+ * lines themselves are produced by the script's own formatter, the one the hook
145
+ * shows in its systemMessage.
146
+ */
147
+ export function formatClaudeMdAuditNote(result) {
148
+ return result.lines.length === 0 ? null : result.lines.join('\n');
149
+ }
150
+ /** The one degraded line printed when the audit could not run. */
151
+ export function formatClaudeMdAuditUnavailable(error) {
152
+ if (error.kind === 'load') {
153
+ return error.detail.kind === 'not-found'
154
+ ? 'CLAUDE.md import audit unavailable: audit script not found — reinstall devflow-kit'
155
+ : 'CLAUDE.md import audit unavailable: audit script failed to load — reinstall devflow-kit';
156
+ }
157
+ return 'CLAUDE.md import audit unavailable: the audit failed';
158
+ }
159
+ /**
160
+ * Write the stamp text to `<devflowDir>/.claude-md-audit` (AC-437, D-AUDIT-STAMP).
161
+ *
162
+ * Refuses, writing nothing: a machine root that does not exist; a stamp path that is
163
+ * a symbolic link, a directory, a FIFO or any other non-regular file (an `lstat`
164
+ * check made here, because the atomic writer renames over the target without
165
+ * looking at what it is). Otherwise the text lands in a sibling temp file renamed
166
+ * into place. Never throws.
167
+ */
168
+ export async function writeClaudeMdAuditStamp(devflowDir, text) {
169
+ try {
170
+ const root = await fs.stat(devflowDir).catch(() => null);
171
+ if (root === null || !root.isDirectory())
172
+ return { ok: false, error: { kind: 'no-machine-root' } };
173
+ const stampPath = join(devflowDir, CLAUDE_MD_AUDIT_STAMP_FILE);
174
+ const existing = await fs.lstat(stampPath).catch((err) => {
175
+ if (err.code === 'ENOENT')
176
+ return null;
177
+ throw err;
178
+ });
179
+ if (existing !== null && !existing.isFile()) {
180
+ return { ok: false, error: { kind: 'refused', detail: existing.isSymbolicLink() ? 'the stamp path is a symbolic link' : 'the stamp path is not a regular file' } };
181
+ }
182
+ // Owner-only, as the hook writes it: the stamp names the user's project paths.
183
+ await writeFileAtomicExclusive(stampPath, text, STAMP_CREATE_MODE);
184
+ return { ok: true, value: undefined };
185
+ }
186
+ catch (err) {
187
+ return { ok: false, error: { kind: 'write-failed', detail: err instanceof Error ? err.message : String(err) } };
188
+ }
189
+ }
190
+ //# sourceMappingURL=claude-md-audit.js.map
@@ -80,6 +80,16 @@ export function setMachineFeature(rawManifest, feature, enabled, now) {
80
80
  updatedAt: now,
81
81
  };
82
82
  }
83
+ /**
84
+ * Whether a RAW parsed manifest already records `feature` as exactly `enabled`:
85
+ * `features.<feature>` is that boolean. Pure. Stricter than
86
+ * {@link isMachineFeatureOn} on purpose — an absent key, a non-boolean value and
87
+ * a legacy key all read as a state without being recorded as it, so writing the
88
+ * explicit boolean is still a change.
89
+ */
90
+ function isMachineFeatureRecorded(rawManifest, feature, enabled) {
91
+ return isJsonObject(rawManifest) && isJsonObject(rawManifest.features) && rawManifest.features[feature] === enabled;
92
+ }
83
93
  async function readRawManifest(devflowDir) {
84
94
  try {
85
95
  return JSON.parse(await fs.readFile(path.join(devflowDir, 'manifest.json'), 'utf-8'));
@@ -101,9 +111,18 @@ export async function readMachineFeature(devflowDir, feature) {
101
111
  * temp + rename). Refuses with `not-installed` when there is no manifest to
102
112
  * write into — a manifest is created by `devflow init`, never by a toggle,
103
113
  * because a bare `{features: {...}}` file is not a manifest any reader accepts.
114
+ *
115
+ * D-NOOP-TOGGLE: a toggle to the value the manifest already records writes
116
+ * nothing. The only bytes such a write could change are `updatedAt`, and that
117
+ * field means "the manifest's content last changed", so a no-op must not move
118
+ * it. This is the one write point of `devflow memory|learning|knowledge
119
+ * --enable/--disable`, so the rule holds for all three at once.
104
120
  */
105
121
  export async function writeMachineFeature(devflowDir, feature, enabled) {
106
- const next = setMachineFeature(await readRawManifest(devflowDir), feature, enabled, new Date().toISOString());
122
+ const raw = await readRawManifest(devflowDir);
123
+ if (isMachineFeatureRecorded(raw, feature, enabled))
124
+ return { ok: true, value: undefined };
125
+ const next = setMachineFeature(raw, feature, enabled, new Date().toISOString());
107
126
  if (next === null)
108
127
  return { ok: false, error: 'not-installed' };
109
128
  await writeFileAtomicExclusive(path.join(devflowDir, 'manifest.json'), JSON.stringify(next, null, 2) + '\n');
@@ -423,6 +423,34 @@ export const FLAG_REGISTRY = [
423
423
  integer: true,
424
424
  upstreamDefault: 600000,
425
425
  },
426
+ {
427
+ // D-AUTO-COMPACT-WINDOW-OPT-IN: opt-in and unset by default (undefined → manifest null →
428
+ // key never written). devflow writes CLAUDE_CODE_AUTO_COMPACT_WINDOW only when the user sets
429
+ // this flag. Init applies a seeded record without asking, which is why the default is neutral
430
+ // and `recommended` stays false: a smaller window compacts sooner, and a compaction
431
+ // mid-command is exactly what the SessionStart resume directive (D-COMPACT-RESUME-DIRECTIVE)
432
+ // recovers from, so the flag is not recommended until a forced mid-/implement /compact has
433
+ // been seen to resume at the phase after the last finished one (plan §9, a follow-up gate).
434
+ // The percent-based auto-compact override is a different variable that devflow deliberately
435
+ // never writes or names; tests/guards/no-autocompact-pct-override.test.ts keeps it out of src/.
436
+ // The env name was confirmed present in Claude Code 2.1.296, where it takes precedence over
437
+ // the in-app setting, and an in-app message names `auto` or 100k–1M (docs/reference/
438
+ // claude-code-flags-probe.md). No upstreamDefault: the default window and the grammar of the
439
+ // variable's own value were not observed, so the description states only devflow's own
440
+ // accepted range, 100000–1000000.
441
+ id: 'auto-compact-window',
442
+ label: 'Auto-compact window',
443
+ description: 'Context window size in tokens at which Claude Code auto-compacts',
444
+ hint: 'Sets the auto-compact window (100000-1000000); unset keeps the default',
445
+ blurb: 'auto-compact window size',
446
+ kind: 'number',
447
+ target: { type: 'env', key: 'CLAUDE_CODE_AUTO_COMPACT_WINDOW' },
448
+ recommended: false,
449
+ defaultValue: undefined,
450
+ min: 100000, // devflow sanity bound
451
+ max: 1000000, // devflow sanity bound
452
+ integer: true,
453
+ },
426
454
  {
427
455
  // Writes as { command: value } per Claude Code spellcheck setting shape.
428
456
  id: 'spellcheck',
@@ -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
@@ -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