devflow-kit 3.5.0 → 3.7.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 (146) hide show
  1. package/CHANGELOG.md +178 -1
  2. package/README.md +10 -6
  3. package/dist/agents/code.md +7 -11
  4. package/dist/agents/{diagnose.md → debug.md} +4 -4
  5. package/dist/agents/{code-fix.md → fix.md} +7 -8
  6. package/dist/agents/{scrutinize.md → harden.md} +6 -6
  7. package/dist/agents/{code-edit.md → refactor.md} +8 -9
  8. package/dist/agents/review.md +2 -0
  9. package/dist/cli/agents-view/render.js +1 -1
  10. package/dist/cli/commands/gates.js +276 -0
  11. package/dist/cli/commands/init-seed.js +9 -1
  12. package/dist/cli/commands/init.js +34 -11
  13. package/dist/cli/commands/learning.js +12 -12
  14. package/dist/cli/commands/legacy-hooks.js +1 -1
  15. package/dist/cli/commands/memory.js +18 -0
  16. package/dist/cli/commands/uninstall.js +111 -5
  17. package/dist/cli.js +2 -0
  18. package/dist/commands/bug-analysis.md +21 -17
  19. package/dist/commands/code-review.md +65 -66
  20. package/dist/commands/debug.md +17 -17
  21. package/dist/commands/dynamic-build.md +104 -116
  22. package/dist/commands/dynamic-plan.md +13 -11
  23. package/dist/commands/dynamic-profile.md +9 -7
  24. package/dist/commands/dynamic-tickets.md +13 -11
  25. package/dist/commands/explore.md +15 -15
  26. package/dist/commands/implement.md +80 -154
  27. package/dist/commands/plan.md +34 -66
  28. package/dist/commands/release.md +11 -7
  29. package/dist/commands/research.md +14 -10
  30. package/dist/commands/resolve.md +44 -88
  31. package/dist/commands/self-review.md +44 -44
  32. package/dist/core/agent-models.js +47 -11
  33. package/dist/core/{learning-tuning-config.js → curate-tuning-config.js} +14 -14
  34. package/dist/core/echo-value.js +42 -0
  35. package/dist/core/evidence-policy.js +17 -5
  36. package/dist/core/gate-level.js +213 -0
  37. package/dist/core/learning-queue-cleanup.js +1 -1
  38. package/dist/core/manifest.js +26 -10
  39. package/dist/core/migrations.js +63 -44
  40. package/dist/core/model-discovery.js +7 -1
  41. package/dist/core/plugins.js +14 -12
  42. package/dist/core/project-paths.js +3 -3
  43. package/dist/core/tracker.js +5 -33
  44. package/dist/hud/codex-usage.js +247 -0
  45. package/dist/hud/components/codex-quota.js +37 -0
  46. package/dist/hud/components/usage-quota.js +1 -1
  47. package/dist/hud/config.js +1 -0
  48. package/dist/hud/index.js +16 -3
  49. package/dist/hud/render.js +3 -1
  50. package/dist/learning-off/agents/code.md +7 -11
  51. package/dist/learning-off/agents/{diagnose.md → debug.md} +4 -4
  52. package/dist/learning-off/agents/{code-fix.md → fix.md} +7 -8
  53. package/dist/learning-off/agents/{scrutinize.md → harden.md} +6 -6
  54. package/dist/learning-off/agents/{code-edit.md → refactor.md} +8 -9
  55. package/dist/learning-off/agents/review.md +2 -0
  56. package/dist/learning-off/commands/bug-analysis.md +9 -9
  57. package/dist/learning-off/commands/code-review.md +54 -59
  58. package/dist/learning-off/commands/debug.md +6 -10
  59. package/dist/learning-off/commands/dynamic-build.md +98 -112
  60. package/dist/learning-off/commands/dynamic-plan.md +5 -5
  61. package/dist/learning-off/commands/dynamic-profile.md +1 -1
  62. package/dist/learning-off/commands/dynamic-tickets.md +5 -5
  63. package/dist/learning-off/commands/explore.md +4 -8
  64. package/dist/learning-off/commands/implement.md +68 -146
  65. package/dist/learning-off/commands/plan.md +23 -58
  66. package/dist/learning-off/commands/release.md +4 -2
  67. package/dist/learning-off/commands/research.md +2 -2
  68. package/dist/learning-off/commands/resolve.md +33 -81
  69. package/dist/learning-off/commands/self-review.md +32 -36
  70. package/dist/skills/git/references/pr/check-merge-readiness.md +2 -0
  71. package/dist/skills/git/references/pr/update-pr-evidence.md +4 -6
  72. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +7 -9
  73. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +7 -15
  74. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +7 -15
  75. package/dist/targets/claude-code/installer.js +117 -84
  76. package/dist/targets/claude-code/learning-install.js +15 -3
  77. package/dist/targets/claude-code/tracker-servers.js +1 -1
  78. package/package.json +1 -1
  79. package/src/assets/agents/_partials/_code.mds +7 -8
  80. package/src/assets/agents/code.mds +4 -7
  81. package/src/assets/agents/{learning.md → curate.md} +8 -10
  82. package/src/assets/agents/{diagnose.mds → debug.mds} +4 -4
  83. package/src/assets/agents/evaluate.md +4 -4
  84. package/src/assets/agents/{code-fix.mds → fix.mds} +5 -5
  85. package/src/assets/agents/{scrutinize.mds → harden.mds} +6 -6
  86. package/src/assets/agents/{simplify.md → polish.md} +4 -4
  87. package/src/assets/agents/{code-edit.mds → refactor.mds} +6 -6
  88. package/src/assets/agents/review.mds +2 -0
  89. package/src/assets/agents/synthesize.md +2 -2
  90. package/src/assets/agents/test.md +2 -2
  91. package/src/assets/commands/_partials/_compliance.mds +1 -1
  92. package/src/assets/commands/_partials/_decisions.mds +26 -6
  93. package/src/assets/commands/_partials/_engine.mds +15 -15
  94. package/src/assets/commands/_partials/_evidence_policy.mds +0 -17
  95. package/src/assets/commands/_partials/_knowledge.mds +14 -9
  96. package/src/assets/commands/_partials/_preamble.mds +4 -2
  97. package/src/assets/commands/_partials/_roster.mds +4 -4
  98. package/src/assets/commands/_partials/_settings.mds +2 -2
  99. package/src/assets/commands/_partials/_wave.mds +10 -17
  100. package/src/assets/commands/bug-analysis.mds +7 -7
  101. package/src/assets/commands/code-review.mds +51 -56
  102. package/src/assets/commands/debug.mds +2 -2
  103. package/src/assets/commands/dynamic-build.mds +67 -74
  104. package/src/assets/commands/implement.mds +65 -127
  105. package/src/assets/commands/plan.mds +21 -61
  106. package/src/assets/commands/release.mds +5 -1
  107. package/src/assets/commands/resolve.mds +28 -76
  108. package/src/assets/commands/self-review.mds +27 -27
  109. package/src/assets/mds/git/_ops_steps.mds +3 -3
  110. package/src/assets/mds/git/_pr.mds +6 -6
  111. package/src/assets/mds/tracker/_common.mds +23 -12
  112. package/src/assets/mds/tracker/_github.mds +2 -9
  113. package/src/assets/mds/tracker/_jira.mds +2 -15
  114. package/src/assets/mds/tracker/_linear.mds +2 -15
  115. package/src/assets/scripts/ci-wait.cjs +22 -4
  116. package/src/assets/scripts/claude-md-audit.cjs +66 -51
  117. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -3
  118. package/src/assets/scripts/hooks/background-memory-update +404 -95
  119. package/src/assets/scripts/hooks/capture-prompt +1 -1
  120. package/src/assets/scripts/hooks/capture-question +1 -1
  121. package/src/assets/scripts/hooks/capture-turn +1 -1
  122. package/src/assets/scripts/hooks/json-helper.cjs +13 -2
  123. package/src/assets/scripts/hooks/json-parse +18 -3
  124. package/src/assets/scripts/hooks/lib/learning-store.cjs +1 -1
  125. package/src/assets/scripts/hooks/lib/project-paths.cjs +4 -4
  126. package/src/assets/scripts/hooks/memory-permission +358 -0
  127. package/src/assets/scripts/hooks/preamble +1 -1
  128. package/src/assets/scripts/hooks/session-start-context +60 -47
  129. package/src/assets/scripts/hooks/session-start-memory +26 -5
  130. package/src/assets/scripts/lib/project-config.cjs +19 -4
  131. package/src/assets/scripts/resolve-settings.cjs +200 -32
  132. package/src/assets/scripts/verify-evidence.cjs +726 -9
  133. package/src/assets/skills/agent-references/SKILL.md +14 -6
  134. package/src/assets/skills/agent-references/references/commands/code-review-levels.md +19 -0
  135. package/src/assets/skills/agent-references/references/commands/implement-evidence-asks.md +35 -0
  136. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +15 -0
  137. package/src/assets/skills/agent-references/references/commands/plan-levels.md +40 -0
  138. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +1 -1
  139. package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -22
  140. package/src/assets/skills/agent-references/references/synthesize/review.md +11 -11
  141. package/src/assets/skills/apply-decisions/SKILL.md +6 -4
  142. package/src/assets/skills/docs-framework/SKILL.md +3 -3
  143. package/src/assets/skills/qa/SKILL.md +1 -1
  144. package/src/assets/skills/quality-gates/SKILL.md +7 -7
  145. package/src/assets/skills/quality-gates/references/report-template.md +7 -7
  146. /package/src/assets/skills/agent-references/references/{learning → curate}/maintain.md +0 -0
@@ -142,38 +142,56 @@ export function validateWorkerValue(field, value) {
142
142
  // Key migration
143
143
  // ---------------------------------------------------------------------------
144
144
  /**
145
- * Maps old agent keys (form A slugs) to their canonical replacement keys.
145
+ * Maps every retired agent key to the live slug that replaces it. Two renames feed it:
146
146
  *
147
- * 13 agents renamed from noun form (coder, reviewer, …) to action-verb form
148
- * (code, review, …). The canonicalise-agent-keys-v1 migration in migrations.ts
149
- * calls canonicaliseAgentKeys() to rewrite ~/.devflow/agent-models.json on the
150
- * user's first init after upgrading.
147
+ * - The 2.0.0 action-verb rename (coder, reviewer, …) and the 1.x names that rename
148
+ * retired (scrutinizer, simplifier, bug-analyzer).
149
+ * - The rename of six agents to the names that state their job (code-fix, code-edit,
150
+ * scrutinize, simplify, diagnose, learning).
151
+ *
152
+ * SINGLE HOP: every value is a live slug and no value is itself a key. A 1.x name
153
+ * therefore points straight at its final slug (scrutinizer → harden, never
154
+ * scrutinizer → scrutinize → harden), so one pass of canonicaliseAgentKeys() lands
155
+ * every key. The two canonicalise-agent-keys migrations in migrations.ts call it to
156
+ * rewrite ~/.devflow/agent-models.json; readAgentMapping() calls it on every read.
151
157
  *
152
158
  * PROTOTYPE SAFETY: Object.create(null) prevents prototype-pollution.
153
159
  * Every lookup MUST use Object.hasOwn — never key in LEGACY_AGENT_KEYS or
154
160
  * direct bracket access. `--set constructor` would otherwise return a function
155
161
  * that flows into path.join (the object-literal lookup on argv trap).
156
162
  *
163
+ * ORDER: entries are declared oldest first. canonicaliseAgentKeys() reads that order
164
+ * when two keys meet at one slug (scrutinizer and scrutinize both mean harden): the
165
+ * entry declared later is the more recent name and wins.
166
+ *
157
167
  * DORMANCY: entries here are stored whether or not the key rename has been
158
168
  * applied to the file yet. canonicaliseAgentKeys() is idempotent, so
159
169
  * double-execution from concurrent sessions is a harmless no-op — the second
160
170
  * run finds the file already migrated and makes zero writes.
161
171
  */
162
172
  export const LEGACY_AGENT_KEYS = Object.assign(Object.create(null), {
163
- // Phase 4 — agent-action-verbs rename (old slug → new slug)
173
+ // 1.x names (noun form), retargeted to the live slug of their final rename.
174
+ 'scrutinizer': 'harden',
175
+ 'simplifier': 'polish',
176
+ 'bug-analyzer': 'debug',
177
+ // 2.0.0 action-verb rename (old slug → new slug).
164
178
  'coder': 'code',
165
179
  'designer': 'design',
166
180
  'evaluator': 'evaluate',
167
181
  'researcher': 'research',
168
182
  'reviewer': 'review',
169
- 'scrutinizer': 'scrutinize',
170
- 'simplifier': 'simplify',
171
183
  'skimmer': 'skim',
172
184
  'synthesizer': 'synthesize',
173
185
  'tester': 'test',
174
186
  'triager': 'triage',
175
187
  'validator': 'validate',
176
- 'bug-analyzer': 'diagnose',
188
+ // Rename of six agents to the names that state their job.
189
+ 'code-fix': 'fix',
190
+ 'code-edit': 'refactor',
191
+ 'scrutinize': 'harden',
192
+ 'simplify': 'polish',
193
+ 'diagnose': 'debug',
194
+ 'learning': 'curate',
177
195
  });
178
196
  /**
179
197
  * Canonicalise agent keys in a raw agents map by applying LEGACY_AGENT_KEYS.
@@ -184,6 +202,10 @@ export const LEGACY_AGENT_KEYS = Object.assign(Object.create(null), {
184
202
  *
185
203
  * Collision detection is against the ORIGINAL map so results are order-independent.
186
204
  *
205
+ * Two legacy keys that name one agent (scrutinizer and scrutinize both mean harden) meet
206
+ * at one slug. The key declared later in LEGACY_AGENT_KEYS, the more recent name, wins;
207
+ * the other is dropped. The pick reads the declaration order, never the file order.
208
+ *
187
209
  * The function is pure and idempotent: calling it twice on the same input
188
210
  * produces the same result as calling it once (idempotent under concurrent execution).
189
211
  *
@@ -208,6 +230,19 @@ export function canonicaliseAgentKeys(rawAgents, onWarning) {
208
230
  }
209
231
  // Snapshot the original keys for collision detection (order-independent result).
210
232
  const originalKeys = new Set(Object.keys(rawAgents));
233
+ // When several legacy keys in the file name one agent, the one declared latest in
234
+ // LEGACY_AGENT_KEYS is the one that is renamed; the others are dropped below.
235
+ const declared = Object.keys(LEGACY_AGENT_KEYS);
236
+ const winnerByTarget = new Map();
237
+ for (const oldKey of originalKeys) {
238
+ if (oldKey === '__proto__' || !Object.hasOwn(LEGACY_AGENT_KEYS, oldKey))
239
+ continue;
240
+ const target = LEGACY_AGENT_KEYS[oldKey];
241
+ const held = winnerByTarget.get(target);
242
+ if (held === undefined || declared.indexOf(oldKey) > declared.indexOf(held)) {
243
+ winnerByTarget.set(target, oldKey);
244
+ }
245
+ }
211
246
  const result = { ...rawAgents };
212
247
  let didMutate = false;
213
248
  for (const oldKey of originalKeys) {
@@ -237,8 +272,9 @@ export function canonicaliseAgentKeys(rawAgents, onWarning) {
237
272
  didMutate = true;
238
273
  continue;
239
274
  }
240
- if (originalKeys.has(newKey)) {
241
- // Collision: new (canonical) key already present → new wins, old dropped.
275
+ if (originalKeys.has(newKey) || winnerByTarget.get(newKey) !== oldKey) {
276
+ // Collision: new (canonical) key already present, or a more recent legacy key
277
+ // names the same agent → that value wins, old dropped.
242
278
  // Warning is surfaced by the caller's structured dropped-block (e.g. migrations.ts)
243
279
  // to avoid printing the same collision twice in two different vocabularies (F8).
244
280
  dropped.push(oldKey);
@@ -1,17 +1,17 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { getDevFlowDirectory } from '../targets/claude-code/claude-paths.js';
4
- import { getLearningTuningConfigPath } from './project-paths.js';
4
+ import { getCurateTuningConfigPath } from './project-paths.js';
5
5
  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
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
10
+ * D-LEARNING-SHIPPED-ROW: the default model is the model the Curate agent ships
11
+ * with, the `model:` line of src/assets/agents/curate.md. With no layer
12
12
  * configuring a model, the spawn directive names none and that line decides, so a
13
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
14
+ * tests/decisions/config.test.ts holds this value equal to the Curate row of the
15
15
  * agent-config table, which a change of the shipped tier edits with the frontmatter.
16
16
  */
17
17
  const DEFAULTS = {
@@ -19,12 +19,12 @@ const DEFAULTS = {
19
19
  debug: false,
20
20
  };
21
21
  /**
22
- * Apply a single JSON config layer onto a LearningTuningConfig, returning a new object.
22
+ * Apply a single JSON config layer onto a CurateTuningConfig, returning a new object.
23
23
  * Skips fields with wrong types. Swallows parse errors — callers see defaults.
24
24
  * Unknown fields (e.g. an old config still on disk with extra knobs) are
25
25
  * silently ignored, not an error.
26
26
  */
27
- export function applyLearningTuningConfigLayer(config, json) {
27
+ export function applyCurateTuningConfigLayer(config, json) {
28
28
  try {
29
29
  const parsed = JSON.parse(json);
30
30
  // Object-shape guard mirrors coerceConfig in feature-config.ts: a JSON
@@ -34,7 +34,7 @@ export function applyLearningTuningConfigLayer(config, json) {
34
34
  }
35
35
  const raw = parsed;
36
36
  return {
37
- // Only accept a model value that belongs to the closed LearningModelAlias domain.
37
+ // Only accept a model value that belongs to the closed CurateModelAlias domain.
38
38
  model: isValidModel(raw.model) ? raw.model : config.model,
39
39
  debug: typeof raw.debug === 'boolean' ? raw.debug : config.debug,
40
40
  };
@@ -54,13 +54,13 @@ function readConfigFile(filePath) {
54
54
  catch (err) {
55
55
  if (err.code !== 'ENOENT') {
56
56
  // Warn but don't crash — callers fall back to defaults.
57
- console.warn(`[learning-tuning-config] warning: could not read ${filePath}: ${err.message}`);
57
+ console.warn(`[curate-tuning-config] warning: could not read ${filePath}: ${err.message}`);
58
58
  }
59
59
  return null;
60
60
  }
61
61
  }
62
62
  /**
63
- * Load and merge learning agent tuning configuration.
63
+ * Load and merge curate agent tuning configuration.
64
64
  *
65
65
  * Priority (highest wins): project config → global config → defaults.
66
66
  *
@@ -71,18 +71,18 @@ function readConfigFile(filePath) {
71
71
  *
72
72
  * Invalid JSON in either file is silently ignored and treated as absent.
73
73
  */
74
- export function loadLearningTuningConfig(ledgerRoot) {
74
+ export function loadCurateTuningConfig(ledgerRoot) {
75
75
  const globalConfigPath = path.join(getDevFlowDirectory(), 'learning.json');
76
- const projectConfigPath = getLearningTuningConfigPath(ledgerRoot);
76
+ const projectConfigPath = getCurateTuningConfigPath(ledgerRoot);
77
77
  let config = { ...DEFAULTS };
78
78
  const globalJson = readConfigFile(globalConfigPath);
79
79
  if (globalJson !== null) {
80
- config = applyLearningTuningConfigLayer(config, globalJson);
80
+ config = applyCurateTuningConfigLayer(config, globalJson);
81
81
  }
82
82
  const projectJson = readConfigFile(projectConfigPath);
83
83
  if (projectJson !== null) {
84
- config = applyLearningTuningConfigLayer(config, projectJson);
84
+ config = applyCurateTuningConfigLayer(config, projectJson);
85
85
  }
86
86
  return config;
87
87
  }
88
- //# sourceMappingURL=learning-tuning-config.js.map
88
+ //# sourceMappingURL=curate-tuning-config.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Render an untrusted value for a terminal message.
3
+ *
4
+ * A neutral home for the sanitizer that every parser echoing a rejected value
5
+ * shares (the tracker provider and the gate level today), so no parser imports
6
+ * another feature's module just to reach it.
7
+ */
8
+ /** Longest rejected value echoed back to the terminal, in CODE POINTS. */
9
+ const MAX_ECHOED_VALUE = 40;
10
+ /**
11
+ * Render an untrusted token for a terminal message.
12
+ *
13
+ * Rejected values are echoed so the user can see what they typed, so they are
14
+ * third-party input at a display sink: control characters (terminal escapes,
15
+ * BEL, newlines) are replaced and the value is truncated.
16
+ *
17
+ * Takes `unknown`, and a non-string renders as its TYPE: the never-throws
18
+ * contract of the callers has to hold for what reaches this sink, not
19
+ * only for what the signature says — a caller reads hand-editable
20
+ * files, and a caller-side guard is one edit from being gone. Naming the type
21
+ * also keeps the render total, where `String()` would hand control to a
22
+ * caller-supplied `toString` — itself both a throw path and an echo path this
23
+ * function exists to close.
24
+ */
25
+ export function describeEchoedValue(raw) {
26
+ if (typeof raw !== 'string')
27
+ return `<${raw === null ? 'null' : typeof raw}>`;
28
+ // The class is written with ESCAPES, never literal control bytes. A raw NUL
29
+ // makes grep classify this whole file as binary — it prints "Binary file
30
+ // matches" and skips the lines — so every grep-based sweep over src/core/
31
+ // silently stops covering the file while still exiting 0. Pinned by
32
+ // tests/guards/no-control-bytes.test.ts.
33
+ // eslint-disable-next-line no-control-regex
34
+ const stripped = raw.replace(/[\x00-\x1f\x7f]/g, '?');
35
+ // Cut by CODE POINT. `slice` cuts UTF-16 code units, so a cut landing inside an
36
+ // astral character (emoji, CJK ext-B) emits the lone surrogate half of it.
37
+ const points = [...stripped];
38
+ return points.length > MAX_ECHOED_VALUE
39
+ ? `${points.slice(0, MAX_ECHOED_VALUE).join('')}…`
40
+ : stripped;
41
+ }
42
+ //# sourceMappingURL=echo-value.js.map
@@ -331,15 +331,27 @@ export function repoComplianceStatusLines(loaded, opts) {
331
331
  * tracked by git, or null (D-PERSONAL-UNTRACKED). The resolver ignores such a file
332
332
  * and says so on stderr, but prompts run it with stderr discarded, so a status
333
333
  * command is where the user sees why their personal settings have no effect.
334
+ *
335
+ * Takes the ONE resolved result, so a caller that already holds it adds no second
336
+ * resolve (each resolve forks git).
337
+ */
338
+ export function trackedConfigWarning(settings) {
339
+ if (!settings.personalTracked)
340
+ return null;
341
+ const file = settingsSourceFile('personal');
342
+ return `Git tracks an entry at or under ${file}, so devflow ignores that file. It holds personal settings. ` +
343
+ 'Find the entry in the output of: git ls-files. Untrack it with: git rm --cached <entry>. ' +
344
+ 'Then check the file contents, or delete the file. ' +
345
+ 'Untracking leaves the committed bytes on disk as your personal file.';
346
+ }
347
+ /**
348
+ * {@link trackedConfigWarning} for a caller that holds the loaded module and not a
349
+ * resolved result: it resolves once and returns null when the module is unusable.
334
350
  */
335
351
  export function personalConfigTrackedWarning(loaded, opts) {
336
352
  if (!loaded.ok)
337
353
  return null;
338
- if (!loaded.value.resolveSettings(opts).personalTracked)
339
- return null;
340
- const file = settingsSourceFile('personal');
341
- return `${file} is tracked by git, so devflow ignores it — it holds personal settings. ` +
342
- `Untrack it with: git rm --cached ${file}`;
354
+ return trackedConfigWarning(loaded.value.resolveSettings(opts));
343
355
  }
344
356
  /** The policies the hint maps, in the order it prints them. */
345
357
  const HINT_POLICIES = ['standard', 'required'];
@@ -0,0 +1,213 @@
1
+ import { promises as fs, constants as fsConstants } from 'fs';
2
+ import * as path from 'path';
3
+ import { writeFileAtomicExclusive } from './fs-atomic.js';
4
+ import { hasRequiredManifestFields } from './manifest.js';
5
+ import { describeEchoedValue } from './echo-value.js';
6
+ /**
7
+ * The gate level: how much checking a command applies. One of three words, from
8
+ * most to least checking.
9
+ *
10
+ * `features.gateLevel` in `~/.devflow/manifest.json` is the MACHINE default, and
11
+ * `gateLevel` in a repository's personal `.devflow/config.json` overrides it.
12
+ * Neither is a feature switch: a switch can only be narrowed by a repository
13
+ * layer, but a level may be raised or lowered by the personal file. The settings
14
+ * resolver (`resolve-settings.cjs`) folds both into the `GATE_LEVEL=` field of its
15
+ * settings line; this module owns the closed set, the parser and the machine
16
+ * layer's raw reader and writer. No code here chooses a level from a task or a
17
+ * diff.
18
+ *
19
+ * A present value that is not one of the three words is MALFORMED. A reader reads
20
+ * it as `full` and leaves it on disk as written: no read and no unrelated write
21
+ * heals it to absent, which would lower the level without a word.
22
+ */
23
+ export const GATE_LEVELS = Object.freeze(['full', 'standard', 'lean']);
24
+ /** The level when neither the personal file nor the manifest records one. */
25
+ export const DEFAULT_GATE_LEVEL = 'standard';
26
+ /**
27
+ * Largest manifest read, in bytes. Pinned by a parity test to the resolver
28
+ * library's MAX_MANIFEST_BYTES, so the CLI and a prompt never disagree about a
29
+ * manifest that is too large to read.
30
+ */
31
+ export const MAX_MANIFEST_BYTES = 1048576;
32
+ const VALID_LEVELS_LIST = GATE_LEVELS.join(', ');
33
+ function isPlainObject(value) {
34
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
35
+ }
36
+ function isGateLevel(value) {
37
+ return typeof value === 'string' && GATE_LEVELS.includes(value);
38
+ }
39
+ /**
40
+ * Strict boundary parser for a gate level typed on the command line.
41
+ *
42
+ * REJECT, NEVER REPAIR: membership is byte-exact. No trim, no case folding, no
43
+ * alias. `Lean` and `lean ` are errors, not `lean`. The message names every valid
44
+ * level and echoes the offending value only in the sanitized form of
45
+ * {@link describeEchoedValue}.
46
+ */
47
+ export function parseGateLevel(value) {
48
+ if (isGateLevel(value))
49
+ return { ok: true, value };
50
+ if (value === undefined || value === '') {
51
+ return { ok: false, error: `Missing gate level. Valid levels: ${VALID_LEVELS_LIST}` };
52
+ }
53
+ return {
54
+ ok: false,
55
+ error: `Unknown gate level: "${describeEchoedValue(value)}". Valid levels: ${VALID_LEVELS_LIST}`,
56
+ };
57
+ }
58
+ /**
59
+ * The machine layer's level in a RAW parsed manifest. Pure and total.
60
+ *
61
+ * null no level is recorded: not an object, `features` not an object, or
62
+ * no own `gateLevel` key
63
+ * 'invalid' a `gateLevel` key is present and is not one of the three words
64
+ * a level the recorded level
65
+ *
66
+ * Deliberately NOT built on readManifest(): that reader rejects a manifest
67
+ * missing a required field and heal-writes, and a read-only status must do
68
+ * neither. The resolver script's `machineGateLevel` is the CommonJS twin; a
69
+ * table test holds the two to the same rows.
70
+ */
71
+ export function machineGateLevel(raw) {
72
+ if (!isPlainObject(raw))
73
+ return null;
74
+ const features = raw.features;
75
+ if (!isPlainObject(features))
76
+ return null;
77
+ if (!Object.hasOwn(features, 'gateLevel'))
78
+ return null;
79
+ const value = features.gateLevel;
80
+ return isGateLevel(value) ? value : 'invalid';
81
+ }
82
+ /**
83
+ * Set `features.gateLevel` in a RAW parsed manifest. Pure — returns a new object
84
+ * and never mutates its input. Null when the top level or `features` is not a
85
+ * plain object (there is no `features` record to write into).
86
+ *
87
+ * Only `features.gateLevel` and `updatedAt` change; every other key is carried
88
+ * verbatim, including keys this build does not model.
89
+ */
90
+ export function setMachineGateLevel(raw, level, now) {
91
+ if (!isPlainObject(raw) || !isPlainObject(raw.features))
92
+ return null;
93
+ return {
94
+ ...raw,
95
+ features: { ...raw.features, gateLevel: level },
96
+ updatedAt: now,
97
+ };
98
+ }
99
+ const ABSENT = Object.freeze({ kind: 'absent' });
100
+ const CORRUPT = Object.freeze({ kind: 'corrupt' });
101
+ function startsWithBom(bytes) {
102
+ return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
103
+ }
104
+ /** Bytes of the manifest, or why not: one bounded read of a regular file, followed through links. */
105
+ async function readManifestBytes(manifestPath) {
106
+ try {
107
+ let st;
108
+ try {
109
+ st = await fs.stat(manifestPath);
110
+ }
111
+ catch (err) {
112
+ const code = err.code;
113
+ return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'corrupt';
114
+ }
115
+ if (!st.isFile() || st.size > MAX_MANIFEST_BYTES)
116
+ return 'corrupt';
117
+ // O_NONBLOCK: a FIFO swapped in after the stat cannot block the open.
118
+ const handle = await fs.open(manifestPath, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK || 0));
119
+ try {
120
+ const opened = await handle.stat();
121
+ if (!opened.isFile() || opened.size > MAX_MANIFEST_BYTES)
122
+ return 'corrupt';
123
+ // One byte past the stat'd size: a file that grew between the stat and the
124
+ // read is refused rather than truncated into something that parses. Each
125
+ // pass reads at least one byte or stops, so the loop is bounded by the buffer.
126
+ const buf = Buffer.alloc(opened.size + 1);
127
+ let total = 0;
128
+ for (let pass = 0; pass < buf.length && total < buf.length; pass++) {
129
+ const { bytesRead } = await handle.read(buf, total, buf.length - total, null);
130
+ if (bytesRead === 0)
131
+ break;
132
+ total += bytesRead;
133
+ }
134
+ return total > opened.size ? 'corrupt' : buf.subarray(0, total);
135
+ }
136
+ finally {
137
+ await handle.close().catch(() => undefined);
138
+ }
139
+ }
140
+ catch {
141
+ return 'corrupt';
142
+ }
143
+ }
144
+ /**
145
+ * Read `<devflowDir>/manifest.json` raw, for the gate level. Never throws, never
146
+ * exits and never writes: unlike readManifest() it heals nothing, models no key
147
+ * and keeps every key it does not name.
148
+ *
149
+ * It follows the resolver library's rules, so the CLI and a prompt see the same
150
+ * file: the path is followed through links, a missing path is absent, and every
151
+ * other failure is corrupt. Bytes decode as UTF-8 with a fatal error, which also
152
+ * strips a leading BOM, and then parse as JSON, so the level is read past a BOM
153
+ * as it is in the resolver. A file with a BOM is still `rejected`, because
154
+ * readManifest() parses the text as read and refuses it.
155
+ */
156
+ export async function readGateManifest(devflowDir) {
157
+ const bytes = await readManifestBytes(path.join(devflowDir, 'manifest.json'));
158
+ if (bytes === 'absent')
159
+ return ABSENT;
160
+ if (bytes === 'corrupt')
161
+ return CORRUPT;
162
+ let parsed;
163
+ try {
164
+ parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
165
+ }
166
+ catch {
167
+ return CORRUPT;
168
+ }
169
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.features))
170
+ return CORRUPT;
171
+ const readable = !startsWithBom(bytes) && hasRequiredManifestFields(parsed);
172
+ return { kind: readable ? 'object' : 'rejected', raw: parsed };
173
+ }
174
+ /**
175
+ * Write the machine default to `<devflowDir>/manifest.json` (atomic temp +
176
+ * rename). Never throws. Refuses with `invalid-level`, before any I/O, a level
177
+ * that is not one of the three words, because the type is no check at run time.
178
+ * Refuses with `not-installed` when there is no manifest to write into — a
179
+ * manifest is created by `devflow init`, never by this write.
180
+ *
181
+ * Only `features.gateLevel` and `updatedAt` change. A write to the level the
182
+ * manifest already records changes nothing, `updatedAt` included, because that
183
+ * field means "the manifest's content last changed". A manifest that
184
+ * readManifest() would reject for another key gets the same write as one it
185
+ * accepts: its level is valid, and the fault is not this write's to fix.
186
+ *
187
+ * `now` is injected so a test fixes the timestamp.
188
+ */
189
+ export async function writeMachineGateLevel(devflowDir, level, now) {
190
+ if (!isGateLevel(level))
191
+ return { kind: 'invalid-level' };
192
+ const manifestPath = path.join(devflowDir, 'manifest.json');
193
+ const read = await readGateManifest(devflowDir);
194
+ if (read.kind === 'absent')
195
+ return { kind: 'not-installed' };
196
+ if (read.kind === 'corrupt')
197
+ return { kind: 'unreadable', path: manifestPath };
198
+ const features = read.raw.features;
199
+ if (isPlainObject(features) && features.gateLevel === level)
200
+ return { kind: 'unchanged' };
201
+ try {
202
+ const next = setMachineGateLevel(read.raw, level, now());
203
+ if (next === null)
204
+ return { kind: 'unreadable', path: manifestPath };
205
+ await writeFileAtomicExclusive(manifestPath, JSON.stringify(next, null, 2) + '\n');
206
+ return { kind: 'written' };
207
+ }
208
+ catch (err) {
209
+ const code = err?.code;
210
+ return { kind: 'write-failed', path: manifestPath, reason: typeof code === 'string' ? code : 'unknown error' };
211
+ }
212
+ }
213
+ //# sourceMappingURL=gate-level.js.map
@@ -11,7 +11,7 @@ import { drainQueueFiles } from './queue-drain.js';
11
11
  /**
12
12
  * Drain the learning (decisions-detection) pending-turns queue, its claimed
13
13
  * batch and the claim's owner file so stale turns don't process later — used by
14
- * both `--clear` and `--disable`. A mid-run Learning agent whose claimed batch
14
+ * both `--clear` and `--disable`. A mid-run Curate agent whose claimed batch
15
15
  * vanishes aborts without changes, which is the desired outcome in both cases.
16
16
  * Refused, deleting nothing, when `.devflow` or `.devflow/learning` under `gitRoot`
17
17
  * is a symbolic link (D-CLI-NO-SYMLINK). ENOENT-tolerant; other errors propagate.
@@ -51,6 +51,27 @@ function parseManifestFlags(features, knownFlags) {
51
51
  // Case C: missing/malformed → empty record
52
52
  return { flags: {}, legacy: false };
53
53
  }
54
+ /**
55
+ * Whether a parsed manifest object carries every field {@link readManifest}
56
+ * requires: a version, a plugin list, a `features` object with boolean `ambient`
57
+ * and `memory`, and string `installedAt` and `updatedAt`. Pure. A manifest that
58
+ * fails it reads as "no prior install".
59
+ *
60
+ * Exported so a raw reader can tell a manifest this reader rejects from one it
61
+ * accepts without calling it: readManifest heal-writes, and a read-only status
62
+ * must not.
63
+ */
64
+ export function hasRequiredManifestFields(data) {
65
+ const features = data.features;
66
+ return !(!data.version ||
67
+ !Array.isArray(data.plugins) ||
68
+ typeof features !== 'object' ||
69
+ features === null ||
70
+ typeof features.ambient !== 'boolean' ||
71
+ typeof features.memory !== 'boolean' ||
72
+ typeof data.installedAt !== 'string' ||
73
+ typeof data.updatedAt !== 'string');
74
+ }
54
75
  /**
55
76
  * Read and parse the manifest file. Returns null if missing or corrupt.
56
77
  *
@@ -75,17 +96,9 @@ export async function readManifest(devflowDir, opts) {
75
96
  try {
76
97
  const content = await fs.readFile(manifestPath, 'utf-8');
77
98
  const data = JSON.parse(content);
78
- const features = data.features;
79
- if (!data.version ||
80
- !Array.isArray(data.plugins) ||
81
- typeof features !== 'object' ||
82
- features === null ||
83
- typeof features.ambient !== 'boolean' ||
84
- typeof features.memory !== 'boolean' ||
85
- typeof data.installedAt !== 'string' ||
86
- typeof data.updatedAt !== 'string') {
99
+ if (!hasRequiredManifestFields(data))
87
100
  return null;
88
- }
101
+ const features = data.features;
89
102
  // D-FEATURES-ABSENT-ON (a sub-decision of D-FEATURES-NARROW-ONLY,
90
103
  // src/core/feature-switch.ts): knowledge and learning are machine-wide
91
104
  // switches, and every runtime gate — queue_read_gates in the hooks,
@@ -172,6 +185,9 @@ export async function readManifest(devflowDir, opts) {
172
185
  // self-heal behaviour, and a different condition from a per-repo config
173
186
  // value outside the domain (which does emit `unknown tracker provider`).
174
187
  tracker: normalizeTrackerFeature(features.tracker, opts?.onWarning),
188
+ // The machine gate level, raw (see the field's doc comment): kept when the
189
+ // key is present, a stored `null` included, and left out when it is absent.
190
+ ...(Object.hasOwn(features, 'gateLevel') ? { gateLevel: features.gateLevel } : {}),
175
191
  },
176
192
  installedAt: data.installedAt,
177
193
  updatedAt: data.updatedAt,