devflow-kit 2.5.0 → 3.0.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 (158) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +44 -19
  3. package/dist/agents/git.md +13 -15
  4. package/dist/cli/commands/ambient.js +160 -145
  5. package/dist/cli/commands/capture.js +29 -55
  6. package/dist/cli/commands/compliance.js +32 -61
  7. package/dist/cli/commands/context.js +17 -32
  8. package/dist/cli/commands/debug.js +65 -26
  9. package/dist/cli/commands/flags.js +3 -3
  10. package/dist/cli/commands/hud.js +34 -10
  11. package/dist/cli/commands/init-seed.js +40 -4
  12. package/dist/cli/commands/init.js +249 -271
  13. package/dist/cli/commands/install-report.js +10 -15
  14. package/dist/cli/commands/knowledge/index.js +1 -1
  15. package/dist/cli/commands/knowledge/toggle.js +11 -3
  16. package/dist/cli/commands/learning.js +52 -37
  17. package/dist/cli/commands/legacy-hooks.js +11 -14
  18. package/dist/cli/commands/memory.js +67 -78
  19. package/dist/cli/commands/proxy.js +23 -41
  20. package/dist/cli/commands/security.js +5 -13
  21. package/dist/cli/commands/skills.js +21 -3
  22. package/dist/cli/commands/tracker.js +100 -228
  23. package/dist/cli/commands/uninstall.js +343 -138
  24. package/dist/commands/bug-analysis.md +38 -12
  25. package/dist/commands/code-review.md +70 -21
  26. package/dist/commands/debug.md +37 -7
  27. package/dist/commands/dynamic-build.md +66 -17
  28. package/dist/commands/dynamic-plan.md +19 -8
  29. package/dist/commands/dynamic-profile.md +24 -10
  30. package/dist/commands/dynamic-tickets.md +22 -11
  31. package/dist/commands/explore.md +37 -7
  32. package/dist/commands/implement.md +96 -32
  33. package/dist/commands/plan.md +62 -19
  34. package/dist/commands/release.md +2 -2
  35. package/dist/commands/research.md +34 -8
  36. package/dist/commands/resolve.md +65 -17
  37. package/dist/commands/self-review.md +45 -9
  38. package/dist/core/compliance-compose.js +27 -27
  39. package/dist/core/evidence-policy.js +240 -24
  40. package/dist/core/feature-config.js +94 -25
  41. package/dist/core/feature-switch.js +1 -1
  42. package/dist/core/flags.js +30 -2
  43. package/dist/core/fs-atomic.js +27 -0
  44. package/dist/core/hook-log-dirs.js +104 -0
  45. package/dist/core/learning-tuning-config.js +5 -3
  46. package/dist/core/ledger-root.js +102 -0
  47. package/dist/core/manifest.js +6 -4
  48. package/dist/core/mds-variants.js +34 -97
  49. package/dist/core/migrations.js +49 -23
  50. package/dist/core/plugins.js +5 -4
  51. package/dist/core/project-paths.js +0 -17
  52. package/dist/core/same-location.js +25 -0
  53. package/dist/core/tracker.js +226 -139
  54. package/dist/hud/components/config-counts.js +15 -4
  55. package/dist/hud/components/learning-counts.js +14 -0
  56. package/dist/hud/config.js +2 -1
  57. package/dist/hud/cost-history.js +2 -4
  58. package/dist/hud/git.js +52 -7
  59. package/dist/hud/index.js +7 -9
  60. package/dist/skills/git/references/pr/check-merge-readiness.md +1 -1
  61. package/dist/skills/git/references/pr/ensure-pr-ready.md +1 -1
  62. package/dist/skills/git/references/pr/update-pr-evidence.md +1 -1
  63. package/dist/skills/git/references/tracker/_mcp.md +1 -1
  64. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +1 -1
  65. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +1 -1
  66. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +2 -2
  67. package/dist/skills/git/references/tracker/github/manage-debt.md +3 -3
  68. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +1 -1
  69. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +1 -1
  70. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +2 -2
  71. package/dist/skills/git/references/tracker/jira/manage-debt.md +1 -1
  72. package/dist/skills/git/references/tracker/jira/post-wave-report.md +1 -1
  73. package/dist/skills/git/references/tracker/jira/setup-task.md +1 -1
  74. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +1 -1
  75. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +1 -1
  76. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +2 -2
  77. package/dist/skills/git/references/tracker/linear/manage-debt.md +1 -1
  78. package/dist/skills/git/references/tracker/linear/post-wave-report.md +1 -1
  79. package/dist/skills/git/references/tracker/linear/setup-task.md +1 -1
  80. package/dist/targets/claude-code/claude-paths.js +59 -57
  81. package/dist/targets/claude-code/compliance-install.js +49 -65
  82. package/dist/targets/claude-code/hooks.js +108 -3
  83. package/dist/targets/claude-code/installer.js +30 -57
  84. package/dist/targets/claude-code/post-install.js +232 -139
  85. package/dist/targets/claude-code/tracker-install.js +38 -65
  86. package/package.json +5 -4
  87. package/src/assets/agents/code.md +4 -3
  88. package/src/assets/agents/design.md +1 -0
  89. package/src/assets/agents/git.mds +55 -57
  90. package/src/assets/agents/knowledge.md +2 -2
  91. package/src/assets/agents/review.md +3 -1
  92. package/src/assets/agents/tracker.md +37 -30
  93. package/src/assets/commands/_partials/_compliance.mds +19 -1
  94. package/src/assets/commands/_partials/_decisions.mds +15 -3
  95. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  96. package/src/assets/commands/_partials/_engine.mds +2 -2
  97. package/src/assets/commands/_partials/_evidence_policy.mds +3 -3
  98. package/src/assets/commands/_partials/_factory.mds +1 -1
  99. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  100. package/src/assets/commands/_partials/_plan_contract.mds +2 -2
  101. package/src/assets/commands/_partials/_preamble.mds +1 -1
  102. package/src/assets/commands/_partials/_publication.mds +6 -2
  103. package/src/assets/commands/_partials/_settings.mds +28 -0
  104. package/src/assets/commands/_partials/_ticket_template.mds +3 -3
  105. package/src/assets/commands/_partials/_tracker.mds +4 -4
  106. package/src/assets/commands/_partials/_wave.mds +4 -4
  107. package/src/assets/commands/bug-analysis.mds +19 -17
  108. package/src/assets/commands/code-review.mds +39 -33
  109. package/src/assets/commands/debug.mds +4 -5
  110. package/src/assets/commands/dynamic-build.mds +75 -53
  111. package/src/assets/commands/dynamic-plan.mds +20 -15
  112. package/src/assets/commands/dynamic-profile.mds +24 -11
  113. package/src/assets/commands/dynamic-tickets.mds +25 -20
  114. package/src/assets/commands/explore.mds +4 -5
  115. package/src/assets/commands/implement.mds +58 -45
  116. package/src/assets/commands/plan.mds +34 -29
  117. package/src/assets/commands/release.md +2 -2
  118. package/src/assets/commands/research.mds +11 -9
  119. package/src/assets/commands/resolve.mds +41 -39
  120. package/src/assets/commands/self-review.mds +24 -25
  121. package/src/assets/mds/git/_pr.mds +61 -61
  122. package/src/assets/mds/git/_references.mds +19 -19
  123. package/src/assets/mds/tracker/_common.mds +8 -8
  124. package/src/assets/mds/tracker/_github.mds +71 -71
  125. package/src/assets/mds/tracker/_jira.mds +74 -74
  126. package/src/assets/mds/tracker/_linear.mds +75 -75
  127. package/src/assets/mds/tracker/_mcp.mds +23 -17
  128. package/src/assets/scripts/hooks/background-memory-update +35 -19
  129. package/src/assets/scripts/hooks/capture-prompt +18 -12
  130. package/src/assets/scripts/hooks/capture-question +18 -12
  131. package/src/assets/scripts/hooks/capture-turn +27 -17
  132. package/src/assets/scripts/hooks/debug-trace +11 -6
  133. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  134. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  135. package/src/assets/scripts/hooks/ensure-root-gitignore +111 -36
  136. package/src/assets/scripts/hooks/git-marker +48 -0
  137. package/src/assets/scripts/hooks/json-helper.cjs +6 -1
  138. package/src/assets/scripts/hooks/lib/project-paths.cjs +0 -19
  139. package/src/assets/scripts/hooks/log-paths +80 -0
  140. package/src/assets/scripts/hooks/memory-worker +17 -15
  141. package/src/assets/scripts/hooks/pre-compact-memory +41 -16
  142. package/src/assets/scripts/hooks/queue-append +104 -30
  143. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  144. package/src/assets/scripts/hooks/session-start-context +289 -122
  145. package/src/assets/scripts/hooks/session-start-memory +35 -16
  146. package/src/assets/scripts/lib/project-config.cjs +633 -0
  147. package/src/assets/scripts/resolve-evidence-policy.cjs +300 -220
  148. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  149. package/src/assets/scripts/verify-evidence.cjs +1 -1
  150. package/src/assets/skills/compliance/SKILL.md +2 -2
  151. package/src/assets/skills/docs-framework/SKILL.md +6 -7
  152. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  153. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  154. package/src/assets/skills/git/references/github-api.md +9 -9
  155. package/src/assets/skills/git/references/patterns.md +1 -1
  156. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  157. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  158. package/src/targets/claude-code/templates/managed-settings.json +25 -9
@@ -7,18 +7,19 @@
7
7
  * Avoids PF-009: per-artifact failures are warn-not-throw.
8
8
  * Avoids PF-015: enable/disable each converge BOTH artifacts unconditionally.
9
9
  * The evidence-policy lines (--status, and the --enable/--set suggestion) come
10
- * from src/core/evidence-policy.ts, the seam onto the package's own resolver;
11
- * the CLI prints .devflow/policy.json and never writes it (applies ADR-024).
10
+ * from src/core/evidence-policy.ts, the seam onto the package's own resolvers;
11
+ * the CLI prints the keys to add to .devflow/project.json and never writes it
12
+ * (applies ADR-024).
12
13
  */
13
14
  import { Command } from 'commander';
14
15
  import { promises as fs } from 'fs';
15
16
  import * as path from 'path';
16
17
  import * as p from '@clack/prompts';
17
18
  import color from 'picocolors';
18
- import { ALWAYS_PRESENT_REFS, COMPLIANCE_FRAMEWORKS, normalizeFrameworks, parseFrameworkList, } from '../../core/compliance.js';
19
+ import { COMPLIANCE_FRAMEWORKS, normalizeFrameworks, parseFrameworkList, } from '../../core/compliance.js';
19
20
  import { frameworkChoices, FRAMEWORK_SELECT_MESSAGE } from './compliance-prompts.js';
20
21
  import { COMPLIANCE_SKILL_TOKENS } from '../../core/compliance-compose.js';
21
- import { evidencePolicyStatusLine, evidencePolicySuggestion, formatEvidencePolicyUnavailable, loadEvidencePolicyModule, } from '../../core/evidence-policy.js';
22
+ import { evidencePolicyStatusLine, evidencePolicySuggestion, formatEvidencePolicyUnavailable, loadEvidencePolicyModule, loadSettingsModule, repoComplianceStatusLines, } from '../../core/evidence-policy.js';
22
23
  import { readManifest, writeManifest } from '../../core/manifest.js';
23
24
  import { convergeFromManifest } from '../../targets/claude-code/compliance-install.js';
24
25
  import { validateRuleShadow, validateSkillShadow } from '../../targets/claude-code/installer.js';
@@ -65,7 +66,7 @@ export function resolveComplianceCliAction(current, action, setFrameworks) {
65
66
  messages: [
66
67
  {
67
68
  level: 'success',
68
- text: 'Compliance disabled — artifacts removed, frameworks remembered for re-enable',
69
+ text: 'Compliance disabled — rule removed, frameworks remembered for re-enable',
69
70
  },
70
71
  ],
71
72
  };
@@ -93,23 +94,17 @@ export function resolveComplianceCliAction(current, action, setFrameworks) {
93
94
  }
94
95
  }
95
96
  }
96
- // ── Drift classification ───────────────────────────────────────────────────────
97
+ // ── Manifest classification ────────────────────────────────────────────────────
97
98
  /**
98
- * Classify a list of manifest framework IDs that are not currently installed.
99
- * Separates valid (registry-known) IDs from invalid (unknown) IDs so the status
100
- * display can recommend the correct remediation for each class.
99
+ * The manifest framework IDs the registry does not know — a hand-edited or
100
+ * newer-devflow manifest. Every install drops them (normalizeFrameworks), so
101
+ * `--status` names them with the one remedy that removes them: `--set`.
101
102
  *
102
- * Called by the --status handler to compute drift between the manifest and
103
- * installed artifacts. Also exported to allow unit testing of the classification
104
- * logic in isolation.
103
+ * Installed reference files are no drift signal: every install carries all six
104
+ * (D-COMPLIANCE-INSTALL-ALWAYS), whatever the manifest selects.
105
105
  */
106
- export function classifyDriftMissing(manifestFrameworks, installedRefIds, registryIds) {
107
- const installedSet = new Set(installedRefIds);
108
- const missing = manifestFrameworks.filter(id => !installedSet.has(id));
109
- return {
110
- validMissing: missing.filter(id => registryIds.has(id)),
111
- invalidIds: missing.filter(id => !registryIds.has(id)),
112
- };
106
+ export function unknownFrameworkIds(manifestFrameworks, registryIds) {
107
+ return manifestFrameworks.filter(id => !registryIds.has(id));
113
108
  }
114
109
  // ── Status helpers ─────────────────────────────────────────────────────────────
115
110
  /** Returns true if the compliance skill dir exists at the install target. */
@@ -122,23 +117,6 @@ async function skillInstalled(claudeDir) {
122
117
  return false;
123
118
  }
124
119
  }
125
- /** Returns the set of installed framework reference IDs from the skill dir. */
126
- async function installedRefIds(claudeDir) {
127
- const refDir = path.join(claudeDir, 'skills', 'devflow:compliance', 'references');
128
- try {
129
- const entries = await fs.readdir(refDir);
130
- return entries
131
- .filter(e => e.endsWith('.md') && !ALWAYS_PRESENT_REFS.includes(e))
132
- .map(e => path.basename(e, '.md'))
133
- // S58: sanitize — keep only entries whose basename matches the expected id
134
- // shape (lowercase letters, digits, hyphens). Strips terminal-escape sequences
135
- // or path segments that could be injected via a crafted filename.
136
- .filter(id => /^[a-z0-9-]+$/.test(id));
137
- }
138
- catch {
139
- return [];
140
- }
141
- }
142
120
  /** Returns true if the compliance rule file exists at the install target. */
143
121
  async function ruleInstalled(claudeDir) {
144
122
  try {
@@ -180,7 +158,7 @@ async function skillShadowState(devflowDir) {
180
158
  export const complianceCommand = new Command('compliance')
181
159
  .description('Enable, disable, or configure the compliance feature')
182
160
  .option('--enable', 'Enable compliance (restores previously selected frameworks)')
183
- .option('--disable', 'Disable compliance (artifacts removed; frameworks remembered for re-enable)')
161
+ .option('--disable', 'Disable compliance (rule removed; frameworks remembered for re-enable)')
184
162
  .option('--status', 'Show compliance state: manifest, installed artifacts, shadow presence, and the evidence policy for the current repository')
185
163
  .option('--set <list>', 'Set active frameworks (comma-separated IDs); enables compliance. Use --set "" for zero frameworks (generic controls only)')
186
164
  .action(async (options) => {
@@ -221,24 +199,22 @@ export const complianceCommand = new Command('compliance')
221
199
  ? current.frameworks.join(', ')
222
200
  : color.dim('none declared');
223
201
  const rulesEnabled = manifest.features.rules;
224
- const [skillOk, refIds, ruleOk, isRuleShadowed, skillShadow] = await Promise.all([
202
+ const [skillOk, ruleOk, isRuleShadowed, skillShadow] = await Promise.all([
225
203
  skillInstalled(claudeDir),
226
- installedRefIds(claudeDir),
227
204
  ruleInstalled(claudeDir),
228
205
  ruleShadowed(devflowDir),
229
206
  skillShadowState(devflowDir),
230
207
  ]);
231
- // Detect framework drift: manifest says X, installed refs say Y.
232
- // Invalid IDs (not in the registry) are reported separately from valid-but-missing
233
- // IDs so the suggested remediation is correct: --enable can reconcile valid IDs,
234
- // but only --set can remove IDs that are not in the registry.
235
- const registrySet = new Set(COMPLIANCE_FRAMEWORKS.map(fw => fw.id));
236
- const manifestSet = new Set(current.frameworks);
237
- const driftInstalled = refIds.filter(id => !manifestSet.has(id));
238
- const { validMissing, invalidIds } = classifyDriftMissing(current.frameworks, refIds, registrySet);
208
+ const invalidIds = unknownFrameworkIds(current.frameworks, new Set(COMPLIANCE_FRAMEWORKS.map(fw => fw.id)));
209
+ // The repository's own declaration (.devflow/project.json) and, while the
210
+ // retired policy file is in the working tree, the hint to migrate it. Both come from
211
+ // the local settings resolver — one git call, no network — and add nothing
212
+ // when the repository declares nothing (the block is then unchanged).
213
+ const repoLines = repoComplianceStatusLines(loadSettingsModule(), { dir: process.cwd() });
239
214
  const lines = [
240
215
  `State: ${enabledLabel}`,
241
216
  `Frameworks: ${fwLabel}`,
217
+ ...repoLines,
242
218
  '',
243
219
  `Skill: ${skillOk ? color.green('installed') : color.dim('not installed')}` +
244
220
  (skillShadow === 'composition-skipped'
@@ -254,20 +230,11 @@ export const complianceCommand = new Command('compliance')
254
230
  '',
255
231
  // The repository in cwd, resolved by the package's own resolver with the
256
232
  // compliance state already read above (D-POLICY-CJS-SEAM). Bounded: at most
257
- // two `gh` calls, each with a timeout, so offline degrades to a flagged line.
233
+ // three `gh` calls, each with a timeout, so offline degrades to a flagged line.
258
234
  evidencePolicyStatusLine(loadEvidencePolicyModule(), { dir: process.cwd(), compliance: current }),
259
235
  ];
260
- if (driftInstalled.length > 0 || validMissing.length > 0 || invalidIds.length > 0) {
236
+ if (invalidIds.length > 0) {
261
237
  lines.push('');
262
- if (driftInstalled.length > 0 || validMissing.length > 0) {
263
- lines.push(color.yellow('Artifact drift detected (run devflow compliance --enable to reconcile):'));
264
- if (driftInstalled.length > 0) {
265
- lines.push(` Installed not in manifest: ${driftInstalled.join(', ')}`);
266
- }
267
- if (validMissing.length > 0) {
268
- lines.push(` In manifest but not installed: ${validMissing.join(', ')}`);
269
- }
270
- }
271
238
  for (const id of invalidIds) {
272
239
  lines.push(color.red(` unknown framework id in manifest (ignored): ${id} — remove with --set`));
273
240
  }
@@ -342,15 +309,19 @@ export const complianceCommand = new Command('compliance')
342
309
  p.log.info(color.dim('Note: compliance rule withheld (rules disabled) — ' +
343
310
  'run `devflow rules --enable` to install the stamped rule'));
344
311
  }
345
- // Suggest the team policy file compliance now implies. Printed, never written:
346
- // .devflow/policy.json is team-owned (D-POLICY-NO-WRITE, applies ADR-024).
312
+ // Suggest the team file compliance now implies. Printed, never written:
313
+ // .devflow/project.json is team-owned (D-POLICY-NO-WRITE, applies ADR-024).
347
314
  if (resolved.nextState.enabled) {
348
315
  const policyModule = loadEvidencePolicyModule();
316
+ const settingsModule = loadSettingsModule();
349
317
  if (!policyModule.ok) {
350
318
  p.log.warn(formatEvidencePolicyUnavailable(policyModule.error));
351
319
  }
320
+ else if (!settingsModule.ok) {
321
+ p.log.warn(formatEvidencePolicyUnavailable(settingsModule.error));
322
+ }
352
323
  else {
353
- const suggestion = evidencePolicySuggestion(resolved.nextState, policyModule.value);
324
+ const suggestion = evidencePolicySuggestion(resolved.nextState, policyModule.value, settingsModule.value);
354
325
  if (suggestion !== null)
355
326
  p.note(suggestion, 'Evidence policy');
356
327
  }
@@ -1,9 +1,16 @@
1
- import * as path from 'path';
1
+ import { devflowHookOwner, hasHook, removeHooks, runHookCommand, } from '../../targets/claude-code/hooks.js';
2
2
  // ─── Context hook utilities ────────────────────────────────────────────────
3
3
  //
4
4
  // The session-start-context hook is always-on (registered unconditionally by
5
5
  // init, removed by uninstall). It has internal sentinel awareness per feature.
6
6
  const CONTEXT_HOOK_MARKER = 'session-start-context';
7
+ /**
8
+ * D-EXACT-HOOK-OWNER: the context hook is devflow's only when its command ends in
9
+ * `/scripts/hooks/run-hook session-start-context` (hooks.ts), under any directory.
10
+ * It has been registered through run-hook since it first shipped, so there is no
11
+ * legacy form to recognise.
12
+ */
13
+ const isContextHook = devflowHookOwner([CONTEXT_HOOK_MARKER]);
7
14
  /**
8
15
  * Add the session-start-context hook to SessionStart in settings JSON.
9
16
  * Idempotent — returns unchanged JSON if hook already present.
@@ -13,46 +20,24 @@ export function addContextHook(settingsJson, devflowDir) {
13
20
  return settingsJson;
14
21
  }
15
22
  const settings = JSON.parse(settingsJson);
16
- if (!settings.hooks) {
17
- settings.hooks = {};
18
- }
19
- const hookCommand = path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ` ${CONTEXT_HOOK_MARKER}`;
20
- const newEntry = {
21
- hooks: [
22
- {
23
- type: 'command',
24
- command: hookCommand,
25
- timeout: 10,
26
- },
27
- ],
28
- };
29
- if (!settings.hooks.SessionStart) {
30
- settings.hooks.SessionStart = [];
31
- }
32
- settings.hooks.SessionStart.push(newEntry);
23
+ settings.hooks ??= {};
24
+ settings.hooks.SessionStart ??= [];
25
+ settings.hooks.SessionStart.push({
26
+ hooks: [{ type: 'command', command: runHookCommand(devflowDir, CONTEXT_HOOK_MARKER), timeout: 10 }],
27
+ });
33
28
  return JSON.stringify(settings, null, 2) + '\n';
34
29
  }
35
30
  /**
36
31
  * Remove the session-start-context hook from settings JSON.
37
32
  * Idempotent — returns unchanged JSON if hook not present.
38
- * Preserves all other SessionStart hooks.
33
+ * Removes the single hook, so the other hooks of its matcher group and every
34
+ * other SessionStart group stay in place (D-EXACT-HOOK-OWNER).
39
35
  */
40
36
  export function removeContextHook(settingsJson) {
41
37
  const settings = JSON.parse(settingsJson);
42
- if (!settings.hooks?.SessionStart) {
38
+ if (!removeHooks(settings, 'SessionStart', isContextHook)) {
43
39
  return settingsJson;
44
40
  }
45
- const before = settings.hooks.SessionStart.length;
46
- settings.hooks.SessionStart = settings.hooks.SessionStart.filter((matcher) => !matcher.hooks.some((h) => h.command.includes(CONTEXT_HOOK_MARKER)));
47
- if (settings.hooks.SessionStart.length === before) {
48
- return settingsJson;
49
- }
50
- if (settings.hooks.SessionStart.length === 0) {
51
- delete settings.hooks.SessionStart;
52
- }
53
- if (Object.keys(settings.hooks).length === 0) {
54
- delete settings.hooks;
55
- }
56
41
  return JSON.stringify(settings, null, 2) + '\n';
57
42
  }
58
43
  /**
@@ -61,6 +46,6 @@ export function removeContextHook(settingsJson) {
61
46
  */
62
47
  export function hasContextHook(input) {
63
48
  const settings = typeof input === 'string' ? JSON.parse(input) : input;
64
- return settings.hooks?.SessionStart?.some((matcher) => matcher.hooks.some((h) => h.command.includes(CONTEXT_HOOK_MARKER))) ?? false;
49
+ return hasHook(settings, 'SessionStart', isContextHook);
65
50
  }
66
51
  //# sourceMappingURL=context.js.map
@@ -4,17 +4,47 @@ import * as path from 'path';
4
4
  import * as p from '@clack/prompts';
5
5
  import color from 'picocolors';
6
6
  import { getClaudeDirectory, getHomeDirectory } from '../../targets/claude-code/claude-paths.js';
7
- // ─── Pure functions — no I/O, fully testable ─────────────────────────────────
7
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
8
+ function isPlainObject(value) {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
10
+ }
11
+ /**
12
+ * The settings object and its `env` (undefined when absent), or why not.
13
+ *
14
+ * D-DEBUG-ENV-OBJECT: Claude Code reads `env` as an object of variables. A
15
+ * present `env` that is anything else — an array, a string, `null` — is
16
+ * rejected, never repaired and never written through: a key set on an array is
17
+ * dropped by JSON.stringify, so the command would report success and write
18
+ * nothing, and replacing the value would discard what the user wrote.
19
+ */
20
+ function parseSettingsEnv(settingsJson) {
21
+ let settings;
22
+ try {
23
+ settings = JSON.parse(settingsJson);
24
+ }
25
+ catch {
26
+ return { ok: false, error: { kind: 'malformed' } };
27
+ }
28
+ if (!isPlainObject(settings))
29
+ return { ok: false, error: { kind: 'malformed' } };
30
+ if (!Object.prototype.hasOwnProperty.call(settings, 'env'))
31
+ return { ok: true, settings, env: undefined };
32
+ const env = settings.env;
33
+ if (!isPlainObject(env))
34
+ return { ok: false, error: { kind: 'env-not-object' } };
35
+ return { ok: true, settings, env };
36
+ }
8
37
  /**
9
38
  * Apply DEVFLOW_HOOK_DEBUG=1 to a settings JSON string.
10
39
  * Returns a new serialized settings string. Does not mutate.
11
40
  * Follows the applyFlags pattern from flags.ts.
12
41
  */
13
42
  export function applyDebugTrace(settingsJson) {
14
- const settings = JSON.parse(settingsJson);
15
- settings.env ??= {};
16
- settings.env.DEVFLOW_HOOK_DEBUG = '1';
17
- return JSON.stringify(settings, null, 2) + '\n';
43
+ const parsed = parseSettingsEnv(settingsJson);
44
+ if (!parsed.ok)
45
+ return parsed;
46
+ const next = { ...parsed.settings, env: { ...parsed.env, DEVFLOW_HOOK_DEBUG: '1' } };
47
+ return { ok: true, value: JSON.stringify(next, null, 2) + '\n' };
18
48
  }
19
49
  /**
20
50
  * Remove DEVFLOW_HOOK_DEBUG from a settings JSON string.
@@ -23,15 +53,28 @@ export function applyDebugTrace(settingsJson) {
23
53
  * Follows the stripFlags pattern from flags.ts.
24
54
  */
25
55
  export function stripDebugTrace(settingsJson) {
26
- const settings = JSON.parse(settingsJson);
27
- const env = settings.env;
28
- if (env) {
29
- delete env.DEVFLOW_HOOK_DEBUG;
30
- if (Object.keys(env).length === 0) {
31
- delete settings.env;
56
+ const parsed = parseSettingsEnv(settingsJson);
57
+ if (!parsed.ok)
58
+ return parsed;
59
+ if (parsed.env === undefined)
60
+ return { ok: true, value: JSON.stringify(parsed.settings, null, 2) + '\n' };
61
+ const without = (obj, key) => Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key));
62
+ const env = without(parsed.env, 'DEVFLOW_HOOK_DEBUG');
63
+ const next = Object.keys(env).length === 0 ? without(parsed.settings, 'env') : { ...parsed.settings, env };
64
+ return { ok: true, value: JSON.stringify(next, null, 2) + '\n' };
65
+ }
66
+ /** The message a rejected settings file gets. Pure. */
67
+ export function describeDebugSettingsError(error) {
68
+ switch (error.kind) {
69
+ case 'malformed':
70
+ return 'settings.json is malformed — fix it before modifying env vars';
71
+ case 'env-not-object':
72
+ return 'settings.json has an "env" that is not an object — fix it before modifying env vars';
73
+ default: {
74
+ const exhaustive = error;
75
+ return exhaustive;
32
76
  }
33
77
  }
34
- return JSON.stringify(settings, null, 2) + '\n';
35
78
  }
36
79
  /**
37
80
  * Read the debug tracing state from a settings JSON string.
@@ -90,29 +133,25 @@ export const debugCommand = new Command('debug')
90
133
  settingsJson = '{}';
91
134
  }
92
135
  if (options.enable) {
93
- let updated;
94
- try {
95
- updated = applyDebugTrace(settingsJson);
96
- }
97
- catch {
98
- p.log.error('settings.json is malformed — fix it before modifying env vars');
136
+ const updated = applyDebugTrace(settingsJson);
137
+ if (!updated.ok) {
138
+ p.log.error(describeDebugSettingsError(updated.error));
139
+ process.exitCode = 1;
99
140
  return;
100
141
  }
101
- await fs.writeFile(settingsPath, updated, 'utf-8');
142
+ await writeSettingsFileAtomic(settingsPath, updated.value);
102
143
  p.log.success('Hook debug tracing enabled');
103
144
  p.log.info(color.dim('Remember to disable after debugging: devflow debug --disable'));
104
145
  return;
105
146
  }
106
147
  if (options.disable) {
107
- let updated;
108
- try {
109
- updated = stripDebugTrace(settingsJson);
110
- }
111
- catch {
112
- p.log.error('settings.json is malformed — fix it before modifying env vars');
148
+ const updated = stripDebugTrace(settingsJson);
149
+ if (!updated.ok) {
150
+ p.log.error(describeDebugSettingsError(updated.error));
151
+ process.exitCode = 1;
113
152
  return;
114
153
  }
115
- await fs.writeFile(settingsPath, updated, 'utf-8');
154
+ await writeSettingsFileAtomic(settingsPath, updated.value);
116
155
  p.log.success('Hook debug tracing disabled');
117
156
  return;
118
157
  }
@@ -23,7 +23,7 @@ import color from 'picocolors';
23
23
  import { getClaudeDirectory, getDevFlowDirectory, } from '../../targets/claude-code/claude-paths.js';
24
24
  import { FLAG_REGISTRY, findFlag, convergeFlagsIntoSettings, parseFlagValueInput, formatFlagValue, effectiveDisplay, neutralValueOf, describeFlagKind, expectedInputFor, } from '../../core/flags.js';
25
25
  import { readManifest, writeManifest } from '../../core/manifest.js';
26
- import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
26
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
27
27
  import { sanitizeCell } from '../tui/cells.js';
28
28
  // Static imports for pure view-state helpers — no TTY machinery (applies PF-017).
29
29
  // runFlagsTui stays lazily imported in handleBare to keep TTY module out of
@@ -98,7 +98,7 @@ manifest, opts = { viewModeExplicit: false }) {
98
98
  // Settings write — independent error path (avoids PF-015 fan-out).
99
99
  const settingsPath = path.join(claudeDir, 'settings.json');
100
100
  try {
101
- await writeFileAtomicExclusive(settingsPath, updatedSettings);
101
+ await writeSettingsFileAtomic(settingsPath, updatedSettings);
102
102
  }
103
103
  catch (err) {
104
104
  p.log.error(`Failed to write settings.json: ${err instanceof Error ? err.message : String(err)}`);
@@ -456,7 +456,7 @@ async function handleBare(claudeDir, devflowDir) {
456
456
  // The read captured before runFlagsTui is a stale snapshot by the time the
457
457
  // user saves — any concurrent writer (proxy enable, devflow agents, Claude
458
458
  // Code /config) that ran during the session would be silently overwritten by
459
- // the atomic rename in writeFileAtomicExclusive. Re-reading rebases the flag
459
+ // the atomic rename in writeSettingsFileAtomic. Re-reading rebases the flag
460
460
  // write onto current content and ensures convergeFlagsIntoSettings sees the
461
461
  // fresh viewMode (applies PF-022 — file state, not config state, is reality).
462
462
  const freshSettings = await readSettingsSafe(path.join(claudeDir, 'settings.json'));
@@ -5,12 +5,13 @@ import * as p from '@clack/prompts';
5
5
  import color from 'picocolors';
6
6
  import { getClaudeDirectory, getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
7
7
  import { syncManifestFeature } from '../../core/manifest.js';
8
- import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
8
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
9
9
  import { HUD_COMPONENTS, loadConfig, saveConfig, } from '../../hud/config.js';
10
10
  /**
11
11
  * Add the HUD statusLine to settings JSON.
12
12
  * Idempotent — returns unchanged JSON if HUD already set.
13
- * Upgrades legacy statusline.sh to hud.sh automatically.
13
+ * Upgrades devflow's legacy statusline.sh to hud.sh automatically; a statusLine
14
+ * that is not devflow's is returned unchanged (D-HUD-EXACT-OWNER).
14
15
  */
15
16
  export function addHudStatusLine(settingsJson, devflowDir) {
16
17
  const settings = JSON.parse(settingsJson);
@@ -54,16 +55,39 @@ export function hasHudStatusLine(settingsJson) {
54
55
  return false;
55
56
  return isDevFlowStatusLine(settings.statusLine);
56
57
  }
58
+ /**
59
+ * The statusLine command endings devflow has ever written: the HUD, and the
60
+ * pre-HUD `statusline.sh` it replaced.
61
+ */
62
+ const DEVFLOW_STATUSLINE_SUFFIXES = [
63
+ '/.devflow/scripts/hud.sh',
64
+ '/.devflow/scripts/statusline.sh',
65
+ ];
57
66
  /**
58
67
  * Check if an existing statusLine belongs to Devflow (HUD or legacy statusline).
59
- * Matches paths containing 'hud.sh', 'statusline.sh', or a '/devflow/' directory segment.
68
+ *
69
+ * D-HUD-EXACT-OWNER: a statusLine is devflow's only when its command ends in
70
+ * `/.devflow/scripts/hud.sh` or the legacy `/.devflow/scripts/statusline.sh`, under
71
+ * any parent directory — so installs from the custom-directory and local-scope era
72
+ * are still recognised. A bare `statusline.sh` (the Claude Code docs' own example,
73
+ * `~/.claude/statusline.sh`) or a path that merely contains a `devflow` segment is
74
+ * the user's (applies ADR-024: remove or replace only what devflow provably wrote).
75
+ * Backslashes are read as slashes so a Windows install is matched the same way. A
76
+ * hand-edited command that is not a string is the user's, as in `endsWithAny`.
77
+ *
78
+ * Every caller converges through this one predicate: `addHudStatusLine` (init's
79
+ * settings pass with the HUD on, `init --hud-only`, `hud --enable`),
80
+ * `removeHudStatusLine` (init's settings pass with `--no-hud`, `hud --disable`,
81
+ * uninstall's `runCleanupPhase`), and `hasHudStatusLine` / `hasNonDevFlowStatusLine`
82
+ * (`hud --enable`, `hud --status`).
60
83
  */
61
84
  function isDevFlowStatusLine(statusLine) {
62
- const cmd = statusLine.command ?? '';
63
- return (cmd.includes('hud.sh') ||
64
- cmd.includes('statusline.sh') ||
65
- cmd.includes('/devflow/') ||
66
- cmd.includes('\\devflow\\'));
85
+ // Parsed from a hand-editable settings.json, so the declared type is not a guarantee.
86
+ const raw = statusLine.command;
87
+ if (typeof raw !== 'string')
88
+ return false;
89
+ const cmd = raw.trim().replace(/\\/g, '/');
90
+ return DEVFLOW_STATUSLINE_SUFFIXES.some((suffix) => cmd.endsWith(suffix));
67
91
  }
68
92
  /**
69
93
  * Check if an existing statusLine belongs to a non-Devflow tool.
@@ -171,7 +195,7 @@ export function createHudCommand() {
171
195
  }
172
196
  }
173
197
  const updated = addHudStatusLine(settingsContent, devflowDir);
174
- await writeFileAtomicExclusive(settingsPath, updated);
198
+ await writeSettingsFileAtomic(settingsPath, updated);
175
199
  }
176
200
  // Always update config and sync manifest — removing the already-enabled
177
201
  // early-return makes --enable self-healing symmetric with --disable.
@@ -209,7 +233,7 @@ export function createHudCommand() {
209
233
  const settingsContent = await fs.readFile(settingsPath, 'utf-8');
210
234
  const updated = removeHudStatusLine(settingsContent);
211
235
  if (updated !== settingsContent) {
212
- await writeFileAtomicExclusive(settingsPath, updated);
236
+ await writeSettingsFileAtomic(settingsPath, updated);
213
237
  statusLineRemoved = true;
214
238
  }
215
239
  }
@@ -32,9 +32,9 @@ export const FEATURE_DEFAULTS = {
32
32
  * Resolve feature booleans for the init seed: every feature comes from
33
33
  * manifest.features, with registry defaults when the manifest is absent.
34
34
  *
35
- * D-FEATURES-MACHINE-WIDE (src/core/feature-switch.ts): memory, learning and
36
- * knowledge are machine-wide, recorded in the manifest alone, so the seed takes
37
- * no per-repo input at all. Seeding from whatever repo init happens to run in
35
+ * D-FEATURES-NARROW-ONLY (src/core/feature-switch.ts): the machine switches for
36
+ * memory, learning and knowledge are recorded in the manifest alone (a repository
37
+ * only narrows them at read time), so the seed takes no per-repo input at all. Seeding from whatever repo init happens to run in
38
38
  * would flip the switch for EVERY repo as a side effect — a stale per-repo
39
39
  * `true` would silently re-enable a feature the user turned off. ADR-014's
40
40
  * state-aware re-init preserves the prior machine-wide choice, the manifest's.
@@ -116,7 +116,7 @@ export function resolveSeedFlags(manifestFlags, registry = FLAG_REGISTRY) {
116
116
  * - Otherwise → split + adopt newly-added non-optional selectable plugins
117
117
  * whose name is ∉ knownPlugins and ∉ manifestPlugins
118
118
  *
119
- * Always-installed plugins (devflow-core-skills, devflow-ambient) are filtered
119
+ * The plugins init adds itself (devflow-core-skills, devflow-ambient) are filtered
120
120
  * out by partitionSelectablePlugins and never appear in the returned buckets.
121
121
  */
122
122
  export function resolveSeedPlugins(manifestPlugins, knownPlugins, allPlugins) {
@@ -157,6 +157,42 @@ export function resolveSeedPlugins(manifestPlugins, knownPlugins, allPlugins) {
157
157
  }
158
158
  return { workflowPlugins, languagePlugins };
159
159
  }
160
+ /**
161
+ * The plugins an init run installs, from the selection and the ambient switch.
162
+ *
163
+ * `selectedPlugins` is empty when nothing picked a selection (a fresh
164
+ * non-interactive install), and then every non-optional plugin is installed.
165
+ * `devflow-core-skills` is always added; `devflow-ambient` is added exactly
166
+ * when ambient mode is on.
167
+ *
168
+ * D-AMBIENT-FOLLOWS-SWITCH: `devflow-ambient` is installed iff ambient mode is
169
+ * on, on EVERY path. It is not a selectable plugin (`EXCLUDED` keeps it out of
170
+ * both multiselect buckets and out of the re-init seed, see
171
+ * {@link resolveSeedPlugins}); since #150 the ambient switch is what includes
172
+ * it, and #150's own test plan reads "`init --no-ambient` — confirm ambient
173
+ * plugin NOT installed". The fresh default branch used to take every
174
+ * non-optional plugin, ambient included, so a first `init --no-ambient`
175
+ * recorded `devflow-ambient` in the manifest and the re-init — seeded without
176
+ * it — dropped it: the first install was the wrong one, not the re-init
177
+ * (#388 AC-3). The default branch therefore leaves ambient out and lets the
178
+ * switch below add it, exactly as it does for an explicit selection.
179
+ *
180
+ * Pure function — no I/O; returns a new array, never mutates its inputs.
181
+ */
182
+ export function resolvePluginsToInstall(selectedPlugins, ambientEnabled, allPlugins) {
183
+ let pluginsToInstall = selectedPlugins.length > 0
184
+ ? allPlugins.filter(p => selectedPlugins.includes(p.name))
185
+ : allPlugins.filter(p => !p.optional && p.name !== 'devflow-ambient');
186
+ const coreSkillsPlugin = allPlugins.find(p => p.name === 'devflow-core-skills');
187
+ if (pluginsToInstall.length > 0 && coreSkillsPlugin && !pluginsToInstall.includes(coreSkillsPlugin)) {
188
+ pluginsToInstall = [coreSkillsPlugin, ...pluginsToInstall];
189
+ }
190
+ const ambientPlugin = allPlugins.find(p => p.name === 'devflow-ambient');
191
+ if (ambientEnabled && ambientPlugin && !pluginsToInstall.includes(ambientPlugin)) {
192
+ pluginsToInstall = [...pluginsToInstall, ambientPlugin];
193
+ }
194
+ return pluginsToInstall;
195
+ }
160
196
  /**
161
197
  * Extract the attribution suppression state from a settings JSON string.
162
198
  *