devflow-kit 2.5.0 → 3.0.1

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 +82 -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 +246 -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
@@ -1,63 +1,47 @@
1
1
  import { Command } from 'commander';
2
2
  import { promises as fs } from 'fs';
3
3
  import * as path from 'path';
4
- import * as os from 'os';
5
4
  import * as p from '@clack/prompts';
6
5
  import color from 'picocolors';
7
6
  import { getClaudeDirectory, getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
8
7
  import { syncManifestFeature } from '../../core/manifest.js';
9
- import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
8
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
9
+ import { HOOKS_DIR_SUFFIX, endsWithAny, ensureHook, hasHook, removeHooks, runHookCommand, runHookSuffix, } from '../../targets/claude-code/hooks.js';
10
10
  const PREAMBLE_HOOK_MARKER = 'preamble';
11
- const LEGACY_HOOK_MARKER = 'ambient-prompt';
12
- /** Stale marker from previous installs — cleaned on disable/re-enable */
13
- const CLASSIFICATION_HOOK_MARKER = 'session-start-classification';
14
11
  /** SessionStart orchestrator charter hook — presence-gated by ambient toggle */
15
12
  const ORCHESTRATOR_HOOK_MARKER = 'session-start-orchestrator';
13
+ /**
14
+ * The command endings of each ambient hook devflow has ever registered.
15
+ *
16
+ * D-AMBIENT-EXACT-HOOK — the ambient instance of D-EXACT-HOOK-OWNER (hooks.ts): a
17
+ * hook is devflow's when its command ENDS in one of these, under any directory — so
18
+ * installs made under a custom or repo-local devflow directory are still recognised —
19
+ * and never because it merely contains a marker word: a user's
20
+ * `~/bin/preamble-logger.sh` or `echo preamble` is theirs (applies ADR-024). The
21
+ * legacy forms are the pre-preamble `ambient-prompt` hook (first a bare
22
+ * `ambient-prompt.sh`, then through `run-hook`) and the retired
23
+ * `session-start-classification` hook, both still swept on enable and disable.
24
+ */
25
+ const AMBIENT_HOOK_SUFFIXES = {
26
+ preamble: [runHookSuffix(PREAMBLE_HOOK_MARKER)],
27
+ legacyPrompt: [runHookSuffix('ambient-prompt'), `${HOOKS_DIR_SUFFIX}ambient-prompt.sh`],
28
+ classification: [runHookSuffix('session-start-classification')],
29
+ orchestrator: [runHookSuffix(ORCHESTRATOR_HOOK_MARKER)],
30
+ };
31
+ const isPreamble = endsWithAny(AMBIENT_HOOK_SUFFIXES.preamble);
32
+ const isLegacy = endsWithAny(AMBIENT_HOOK_SUFFIXES.legacyPrompt);
33
+ const isAmbient = endsWithAny([...AMBIENT_HOOK_SUFFIXES.preamble, ...AMBIENT_HOOK_SUFFIXES.legacyPrompt]);
34
+ const isClassification = endsWithAny(AMBIENT_HOOK_SUFFIXES.classification);
35
+ const isOrchestrator = endsWithAny(AMBIENT_HOOK_SUFFIXES.orchestrator);
16
36
  /**
17
37
  * Path where the legacy commands rule was installed.
18
38
  * The commands rule was removed — this path now exists only to purge the
19
39
  * legacy file from prior installs. Managed by ambient.ts directly (not the
20
40
  * plugin rules system), so only ambient enable/disable/init paths clean it up.
41
+ * Resolved under the Claude Code directory (D-CLAUDE-CONFIG-DIR), where the
42
+ * legacy install wrote it.
21
43
  */
22
- export const COMMANDS_RULE_PATH = path.join(os.homedir(), '.claude', 'rules', 'devflow', 'commands.md');
23
- /** Filter hook entries from a parsed Settings object for a given event. Returns true if any were removed. */
24
- function filterHookEntries(settings, eventName, shouldRemove) {
25
- if (!settings.hooks?.[eventName])
26
- return false;
27
- const before = settings.hooks[eventName].length;
28
- settings.hooks[eventName] = settings.hooks[eventName].filter((matcher) => !shouldRemove(matcher));
29
- if (settings.hooks[eventName].length === before)
30
- return false;
31
- if (settings.hooks[eventName].length === 0) {
32
- delete settings.hooks[eventName];
33
- }
34
- if (Object.keys(settings.hooks).length === 0) {
35
- delete settings.hooks;
36
- }
37
- return true;
38
- }
39
- /** Add a hook entry for an event if the marker is not already present. Returns true when an entry was added. */
40
- function ensureHook(settings, eventName, marker, entry) {
41
- if (settings.hooks?.[eventName]?.some((m) => m.hooks.some((h) => h.command.includes(marker)))) {
42
- return false;
43
- }
44
- settings.hooks ??= {};
45
- settings.hooks[eventName] ??= [];
46
- settings.hooks[eventName].push(entry);
47
- return true;
48
- }
49
- function isLegacy(matcher) {
50
- return matcher.hooks.some((h) => h.command.includes(LEGACY_HOOK_MARKER));
51
- }
52
- function isAmbient(matcher) {
53
- return matcher.hooks.some((h) => h.command.includes(PREAMBLE_HOOK_MARKER) || h.command.includes(LEGACY_HOOK_MARKER));
54
- }
55
- function isClassification(matcher) {
56
- return matcher.hooks.some((h) => h.command.includes(CLASSIFICATION_HOOK_MARKER));
57
- }
58
- function isOrchestrator(matcher) {
59
- return matcher.hooks.some((h) => h.command.includes(ORCHESTRATOR_HOOK_MARKER));
60
- }
44
+ export const COMMANDS_RULE_PATH = path.join(getClaudeDirectory(), 'rules', 'devflow', 'commands.md');
61
45
  /**
62
46
  * Remove the legacy commands awareness rule file left by prior installs.
63
47
  * Idempotent — no-op if the file does not exist.
@@ -75,23 +59,44 @@ export async function removeLegacyCommandsRule() {
75
59
  // filesystem. Neither should abort the caller's primary operation.
76
60
  }
77
61
  }
62
+ /**
63
+ * A predicate matching devflow's own hook (`isOurs`) registered with any command
64
+ * other than `canonical` — i.e. under a directory other than the one `canonical` names.
65
+ */
66
+ function isMisdirected(isOurs, canonical) {
67
+ return (hook) => isOurs(hook) && (hook.command ?? '').trim() !== canonical;
68
+ }
78
69
  /**
79
70
  * Add the ambient hooks (preamble UserPromptSubmit + session-start-orchestrator SessionStart)
80
71
  * and remove any legacy commands rule. Removes any legacy `ambient-prompt` hook first.
81
72
  * Idempotent — each hook is checked before adding so enable repairs partial states.
82
73
  * Legacy rule purge runs unconditionally to ensure stale files are always cleaned up.
74
+ *
75
+ * D-AMBIENT-CANONICAL-DIR: enable converges on `devflowDir`. A preamble or
76
+ * orchestrator hook devflow registered under another directory (an earlier enable
77
+ * that inferred its directory from a user's Stop hook, or a retired custom
78
+ * directory) is removed and the hook re-registered at `devflowDir`. Only hooks
79
+ * matched exactly (D-AMBIENT-EXACT-HOOK) are touched, one hook at a time, so the
80
+ * user's hooks and their matcher-group siblings keep their places.
83
81
  */
84
82
  export async function addAmbientHook(settingsJson, devflowDir) {
85
83
  const settings = JSON.parse(settingsJson);
86
- const removedLegacy = filterHookEntries(settings, 'UserPromptSubmit', isLegacy);
84
+ const preambleCommand = runHookCommand(devflowDir, PREAMBLE_HOOK_MARKER);
85
+ const orchestratorCommand = runHookCommand(devflowDir, ORCHESTRATOR_HOOK_MARKER);
86
+ const removedLegacy = removeHooks(settings, 'UserPromptSubmit', isLegacy);
87
87
  // Sweep stale classification hook from prior installs — symmetric with removeAmbientHook
88
- const removedClassification = filterHookEntries(settings, 'SessionStart', isClassification);
89
- const addedPreamble = ensureHook(settings, 'UserPromptSubmit', PREAMBLE_HOOK_MARKER, { hooks: [{ type: 'command', command: path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ' preamble', timeout: 5 }] });
90
- const addedOrchestrator = ensureHook(settings, 'SessionStart', ORCHESTRATOR_HOOK_MARKER, { hooks: [{ type: 'command', command: path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ' session-start-orchestrator', timeout: 10 }] });
88
+ const removedClassification = removeHooks(settings, 'SessionStart', isClassification);
89
+ const removedMisdirected = [
90
+ removeHooks(settings, 'UserPromptSubmit', isMisdirected(isPreamble, preambleCommand)),
91
+ removeHooks(settings, 'SessionStart', isMisdirected(isOrchestrator, orchestratorCommand)),
92
+ ].some(Boolean);
93
+ const addedPreamble = ensureHook(settings, 'UserPromptSubmit', isPreamble, { hooks: [{ type: 'command', command: preambleCommand, timeout: 5 }] });
94
+ const addedOrchestrator = ensureHook(settings, 'SessionStart', isOrchestrator, { hooks: [{ type: 'command', command: orchestratorCommand, timeout: 10 }] });
91
95
  // Purge legacy commands rule (runs before early-return so stale files are always removed)
92
96
  await removeLegacyCommandsRule();
93
- if (!removedLegacy && !removedClassification && !addedPreamble && !addedOrchestrator)
97
+ if (!removedLegacy && !removedClassification && !removedMisdirected && !addedPreamble && !addedOrchestrator) {
94
98
  return settingsJson;
99
+ }
95
100
  return JSON.stringify(settings, null, 2) + '\n';
96
101
  }
97
102
  /**
@@ -99,22 +104,38 @@ export async function addAmbientHook(settingsJson, devflowDir) {
99
104
  * Removes preamble + legacy from UserPromptSubmit.
100
105
  * Removes session-start-orchestrator from SessionStart.
101
106
  * Also removes stale SessionStart classification hook from previous installs.
102
- * Purges legacy COMMANDS_RULE_PATH if present (runs before early-return).
107
+ * Purges legacy COMMANDS_RULE_PATH if present (runs before early-return), unless
108
+ * `options.purgeLegacyRule` is false — a legacy repo-local uninstall edits a repo's
109
+ * settings and must not touch the user's Claude directory (D-LEGACY-LOCAL-CLEANUP).
103
110
  * Idempotent — returns unchanged JSON if no ambient hooks were present.
104
111
  * Preserves other hooks. Cleans empty arrays/objects.
105
112
  */
106
- export async function removeAmbientHook(settingsJson) {
113
+ export async function removeAmbientHook(settingsJson, options = {}) {
107
114
  const settings = JSON.parse(settingsJson);
108
- const removedPrompt = filterHookEntries(settings, 'UserPromptSubmit', isAmbient);
109
- const removedOrchestrator = filterHookEntries(settings, 'SessionStart', isOrchestrator);
115
+ const removedPrompt = removeHooks(settings, 'UserPromptSubmit', isAmbient);
116
+ const removedOrchestrator = removeHooks(settings, 'SessionStart', isOrchestrator);
110
117
  // Clean up stale classification hooks from previous installs (no longer registered)
111
- const removedClassification = filterHookEntries(settings, 'SessionStart', isClassification);
118
+ const removedClassification = removeHooks(settings, 'SessionStart', isClassification);
112
119
  // Purge legacy commands rule (runs before early-return so stale files are always removed)
113
- await removeLegacyCommandsRule();
120
+ if (options.purgeLegacyRule !== false)
121
+ await removeLegacyCommandsRule();
114
122
  if (!removedPrompt && !removedOrchestrator && !removedClassification)
115
123
  return settingsJson;
116
124
  return JSON.stringify(settings, null, 2) + '\n';
117
125
  }
126
+ /**
127
+ * Converge the ambient hooks in a settings JSON string to `enabled`.
128
+ *
129
+ * The one ambient transform `devflow init` applies inside its single settings
130
+ * read-modify-write pass: always remove-then-add, which upgrades a legacy
131
+ * `ambient-prompt` hook and re-points devflow's hooks at `devflowDir`. Both halves
132
+ * match hooks exactly (D-AMBIENT-EXACT-HOOK), so a user's own hooks — and their
133
+ * siblings in a shared matcher group — come through byte-identical.
134
+ */
135
+ export async function convergeAmbientHooks(settingsJson, enabled, devflowDir) {
136
+ const cleaned = await removeAmbientHook(settingsJson);
137
+ return enabled ? addAmbientHook(cleaned, devflowDir) : cleaned;
138
+ }
118
139
  /**
119
140
  * Check if the ambient hook (legacy or current) is registered in settings JSON or parsed Settings object.
120
141
  * Preamble-authoritative: returns true iff the UserPromptSubmit preamble hook is present.
@@ -122,110 +143,104 @@ export async function removeAmbientHook(settingsJson) {
122
143
  */
123
144
  export function hasAmbientHook(input) {
124
145
  const settings = typeof input === 'string' ? JSON.parse(input) : input;
125
- return settings.hooks?.UserPromptSubmit?.some((matcher) => matcher.hooks.some((h) => h.command.includes(PREAMBLE_HOOK_MARKER) || h.command.includes(LEGACY_HOOK_MARKER))) ?? false;
146
+ return hasHook(settings, 'UserPromptSubmit', isAmbient);
126
147
  }
127
148
  /**
128
149
  * Check if the orchestrator SessionStart hook is present in settings JSON or parsed Settings object.
129
150
  */
130
151
  function hasOrchestratorHook(input) {
131
152
  const settings = typeof input === 'string' ? JSON.parse(input) : input;
132
- return settings.hooks?.SessionStart?.some((matcher) => matcher.hooks.some((h) => h.command.includes(ORCHESTRATOR_HOOK_MARKER))) ?? false;
153
+ return hasHook(settings, 'SessionStart', isOrchestrator);
133
154
  }
134
- export const ambientCommand = new Command('ambient')
135
- .description('Enable or disable ambient mode (orchestrator charter + plan handoff)')
136
- .option('--enable', 'Register ambient mode hooks')
137
- .option('--disable', 'Remove ambient mode hooks')
138
- .option('--status', 'Check if ambient mode is enabled')
139
- .action(async (options) => {
140
- const hasFlag = options.enable || options.disable || options.status;
141
- if (!hasFlag) {
142
- p.intro(color.bgMagenta(color.white(' Ambient Mode ')));
143
- p.note(`${color.cyan('devflow ambient --enable')} Register orchestrator hooks\n` +
144
- `${color.cyan('devflow ambient --disable')} Remove orchestrator hooks\n` +
145
- `${color.cyan('devflow ambient --status')} Check current state`, 'Usage');
146
- return;
147
- }
148
- const claudeDir = getClaudeDirectory();
149
- const settingsPath = path.join(claudeDir, 'settings.json');
150
- let settingsContent;
151
- try {
152
- settingsContent = await fs.readFile(settingsPath, 'utf-8');
153
- }
154
- catch (err) {
155
- if (err.code !== 'ENOENT')
156
- throw err;
157
- if (options.status) {
158
- p.log.info('Ambient mode: disabled (no settings.json found)');
155
+ /**
156
+ * Build a fresh Commander Command for the `ambient` subcommand.
157
+ * Exported for tests that need per-test isolation (Commander keeps parsed option
158
+ * values on the instance, so a reused command leaks options between runs).
159
+ */
160
+ export function createAmbientCommand() {
161
+ return new Command('ambient')
162
+ .description('Enable or disable ambient mode (orchestrator charter + plan handoff)')
163
+ .option('--enable', 'Register ambient mode hooks')
164
+ .option('--disable', 'Remove ambient mode hooks')
165
+ .option('--status', 'Check if ambient mode is enabled')
166
+ .action(async (options) => {
167
+ const hasFlag = options.enable || options.disable || options.status;
168
+ if (!hasFlag) {
169
+ p.intro(color.bgMagenta(color.white(' Ambient Mode ')));
170
+ p.note(`${color.cyan('devflow ambient --enable')} Register orchestrator hooks\n` +
171
+ `${color.cyan('devflow ambient --disable')} Remove orchestrator hooks\n` +
172
+ `${color.cyan('devflow ambient --status')} Check current state`, 'Usage');
159
173
  return;
160
174
  }
161
- // Create minimal settings.json
162
- settingsContent = '{}';
163
- }
164
- // Parse settings once, guarded against corrupt files.
165
- // hasAmbientHook / hasOrchestratorHook accept Settings directly to avoid re-parsing.
166
- let parsedSettings;
167
- try {
168
- parsedSettings = JSON.parse(settingsContent);
169
- }
170
- catch (err) {
171
- p.log.error(`Could not parse settings.json: ${err.message}`);
172
- return;
173
- }
174
- if (options.status) {
175
- const enabled = hasAmbientHook(parsedSettings);
176
- const hasOrchestrator = hasOrchestratorHook(parsedSettings);
177
- const repairHint = enabled !== hasOrchestrator
178
- ? ` ${color.dim('(partial — run devflow ambient --enable to repair)')}`
179
- : '';
180
- if (enabled) {
181
- p.log.info(`Ambient mode: ${color.green('enabled')}${repairHint}`);
175
+ const claudeDir = getClaudeDirectory();
176
+ const settingsPath = path.join(claudeDir, 'settings.json');
177
+ let settingsContent;
178
+ try {
179
+ settingsContent = await fs.readFile(settingsPath, 'utf-8');
182
180
  }
183
- else {
184
- p.log.info(`Ambient mode: ${color.dim('disabled')}${repairHint}`);
185
- }
186
- return;
187
- }
188
- // Resolve devflow scripts directory.
189
- // Primary: getDevFlowDirectory() — purpose-built, not coupled to hook path layout.
190
- // Fallback: infer from Stop hook command path (legacy installs where getDevFlowDirectory
191
- // may not yet reflect the correct location).
192
- let devflowDir = getDevFlowDirectory();
193
- try {
194
- const stopHook = parsedSettings.hooks?.Stop?.[0]?.hooks?.[0]?.command;
195
- if (stopHook) {
196
- const hookBinary = stopHook.split(' ')[0];
197
- const inferred = path.resolve(hookBinary, '..', '..', '..');
198
- // Only use inferred path when it differs from the canonical default —
199
- // this handles legacy installs where the hook was installed to a non-standard location.
200
- if (inferred !== devflowDir) {
201
- devflowDir = inferred;
181
+ catch (err) {
182
+ if (err.code !== 'ENOENT')
183
+ throw err;
184
+ if (options.status) {
185
+ p.log.info('Ambient mode: disabled (no settings.json found)');
186
+ return;
202
187
  }
188
+ // Create minimal settings.json
189
+ settingsContent = '{}';
203
190
  }
204
- }
205
- catch (err) {
206
- p.log.warn(`Could not resolve devflow directory from Stop hook: ${err.message}`);
207
- }
208
- if (options.enable) {
209
- const updated = await addAmbientHook(settingsContent, devflowDir);
210
- if (updated === settingsContent) {
211
- // Both hooks already present — addAmbientHook purges any legacy rule anyway
212
- p.log.info('Ambient mode already enabled');
191
+ // Parse settings once, guarded against corrupt files.
192
+ // hasAmbientHook / hasOrchestratorHook accept Settings directly to avoid re-parsing.
193
+ let parsedSettings;
194
+ try {
195
+ parsedSettings = JSON.parse(settingsContent);
196
+ }
197
+ catch (err) {
198
+ p.log.error(`Could not parse settings.json: ${err.message}`);
213
199
  return;
214
200
  }
215
- await writeFileAtomicExclusive(settingsPath, updated);
216
- await syncManifestFeature(getDevFlowDirectory(), 'ambient', true);
217
- p.log.success('Ambient mode enabled — orchestrator hooks registered');
218
- p.log.info(color.dim('Charter at session start, reminder per prompt, plan handoffs auto-run devflow:implement (git repos only)'));
219
- }
220
- if (options.disable) {
221
- const updated = await removeAmbientHook(settingsContent);
222
- if (updated === settingsContent) {
223
- p.log.info('Ambient mode already disabled');
201
+ if (options.status) {
202
+ const enabled = hasAmbientHook(parsedSettings);
203
+ const hasOrchestrator = hasOrchestratorHook(parsedSettings);
204
+ const repairHint = enabled !== hasOrchestrator
205
+ ? ` ${color.dim('(partial — run devflow ambient --enable to repair)')}`
206
+ : '';
207
+ if (enabled) {
208
+ p.log.info(`Ambient mode: ${color.green('enabled')}${repairHint}`);
209
+ }
210
+ else {
211
+ p.log.info(`Ambient mode: ${color.dim('disabled')}${repairHint}`);
212
+ }
224
213
  return;
225
214
  }
226
- await writeFileAtomicExclusive(settingsPath, updated);
227
- await syncManifestFeature(getDevFlowDirectory(), 'ambient', false);
228
- p.log.success('Ambient mode disabled — hooks removed');
229
- }
230
- });
215
+ // D-AMBIENT-CANONICAL-DIR: the hooks always point at the canonical devflow
216
+ // directory, where init installs run-hook. Never infer it from settings.json:
217
+ // the first Stop hook is whichever hook the user listed first (a notification
218
+ // sound, say), and a path derived from it names a run-hook that does not
219
+ // exist, so every prompt would fail.
220
+ const devflowDir = getDevFlowDirectory();
221
+ if (options.enable) {
222
+ const updated = await addAmbientHook(settingsContent, devflowDir);
223
+ if (updated === settingsContent) {
224
+ // Both hooks already present — addAmbientHook purges any legacy rule anyway
225
+ p.log.info('Ambient mode already enabled');
226
+ return;
227
+ }
228
+ await writeSettingsFileAtomic(settingsPath, updated);
229
+ await syncManifestFeature(devflowDir, 'ambient', true);
230
+ p.log.success('Ambient mode enabled — orchestrator hooks registered');
231
+ p.log.info(color.dim('Charter at session start, reminder per prompt, plan handoffs auto-run devflow:implement (git repos only)'));
232
+ }
233
+ if (options.disable) {
234
+ const updated = await removeAmbientHook(settingsContent);
235
+ if (updated === settingsContent) {
236
+ p.log.info('Ambient mode already disabled');
237
+ return;
238
+ }
239
+ await writeSettingsFileAtomic(settingsPath, updated);
240
+ await syncManifestFeature(devflowDir, 'ambient', false);
241
+ p.log.success('Ambient mode disabled — hooks removed');
242
+ }
243
+ });
244
+ }
245
+ export const ambientCommand = createAmbientCommand();
231
246
  //# sourceMappingURL=ambient.js.map
@@ -1,4 +1,4 @@
1
- import * as path from 'path';
1
+ import { devflowHookOwner, ensureHook, hasHook, removeHooks, runHookCommand, } from '../../targets/claude-code/hooks.js';
2
2
  // ─── Capture hook utilities ────────────────────────────────────────────────
3
3
  //
4
4
  // The capture bundle (capture-prompt, capture-turn, capture-question) is
@@ -9,19 +9,20 @@ import * as path from 'path';
9
9
  // queue-append's queue_read_gates). Follows the context.ts add/remove/has
10
10
  // pattern rather than memory.ts's toggle pattern.
11
11
  //
12
- // IMPORTANT — Stop-array ordering contract: capture-turn MUST be registered
13
- // BEFORE memory-worker in the Stop hook array (see memory.ts). Settings.json
14
- // hook arrays run in array order, and memory-worker's throttle/spawn decision
15
- // assumes the current turn has already been appended to the queue by
16
- // capture-turn earlier in the same Stop event (append-before-spawn). This
17
- // module only ever pushes capture-turn; callers (init.ts) must register the
18
- // capture bundle before the memory bundle to preserve this ordering.
12
+ // Stop-event concurrency contract: Claude Code runs the hooks of one event in
13
+ // parallel, so array position in settings.json orders nothing at run time —
14
+ // memory-worker can spawn background-memory-update before capture-turn has
15
+ // appended this turn's assistant row. The worker tolerates that: a queue that
16
+ // holds only user rows is left in place and the LLM run skipped
17
+ // (D-QUEUE-NO-ORPHAN-DELETE in background-memory-update), so the next run
18
+ // takes the whole turn. init.ts still registers the capture bundle before the
19
+ // memory bundle, which keeps settings.json stable across re-inits.
19
20
  const CAPTURE_PROMPT_MARKER = 'capture-prompt';
20
21
  const CAPTURE_TURN_MARKER = 'capture-turn';
21
22
  const CAPTURE_QUESTION_MARKER = 'capture-question';
22
23
  const CAPTURE_QUESTION_MATCHER = 'AskUserQuestion';
23
24
  /**
24
- * Map of hook event type → filename marker for the capture hooks.
25
+ * Map of hook event type → run-hook marker for the capture hooks.
25
26
  * Three hooks total: UserPromptSubmit, Stop, PostToolUse (matcher-scoped).
26
27
  */
27
28
  const CAPTURE_HOOK_CONFIG = {
@@ -29,6 +30,15 @@ const CAPTURE_HOOK_CONFIG = {
29
30
  Stop: CAPTURE_TURN_MARKER,
30
31
  PostToolUse: CAPTURE_QUESTION_MARKER,
31
32
  };
33
+ /**
34
+ * D-EXACT-HOOK-OWNER: a capture hook is devflow's only when its command ends in
35
+ * `/scripts/hooks/run-hook <marker>` (hooks.ts), under any directory. The capture
36
+ * hooks have been registered through run-hook since they first shipped, so there
37
+ * is no legacy form to recognise.
38
+ */
39
+ function isCaptureHook(marker) {
40
+ return devflowHookOwner([marker]);
41
+ }
32
42
  /**
33
43
  * Add all 3 capture hooks (UserPromptSubmit, Stop, PostToolUse) to settings JSON.
34
44
  * Idempotent — skips hooks that already exist. Returns unchanged JSON if all 3 present.
@@ -40,31 +50,14 @@ export function addCaptureHooks(settingsJson, devflowDir) {
40
50
  if (hasCaptureHooks(settings)) {
41
51
  return settingsJson;
42
52
  }
43
- if (!settings.hooks) {
44
- settings.hooks = {};
45
- }
46
53
  for (const [hookType, marker] of Object.entries(CAPTURE_HOOK_CONFIG)) {
47
- const existing = settings.hooks[hookType] ?? [];
48
- const alreadyPresent = existing.some((matcher) => matcher.hooks.some((h) => h.command.includes(marker)));
49
- if (!alreadyPresent) {
50
- const hookCommand = path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ` ${marker}`;
51
- const newEntry = {
52
- hooks: [
53
- {
54
- type: 'command',
55
- command: hookCommand,
56
- timeout: 10,
57
- },
58
- ],
59
- };
60
- if (hookType === 'PostToolUse') {
61
- newEntry.matcher = CAPTURE_QUESTION_MATCHER;
62
- }
63
- if (!settings.hooks[hookType]) {
64
- settings.hooks[hookType] = [];
65
- }
66
- settings.hooks[hookType].push(newEntry);
54
+ const newEntry = {
55
+ hooks: [{ type: 'command', command: runHookCommand(devflowDir, marker), timeout: 10 }],
56
+ };
57
+ if (hookType === 'PostToolUse') {
58
+ newEntry.matcher = CAPTURE_QUESTION_MATCHER;
67
59
  }
60
+ ensureHook(settings, hookType, isCaptureHook(marker), newEntry);
68
61
  }
69
62
  return JSON.stringify(settings, null, 2) + '\n';
70
63
  }
@@ -81,25 +74,11 @@ export function removeCaptureHooks(input) {
81
74
  // happened. String input is returned byte-identical (no re-formatting).
82
75
  const settingsJson = typeof input === 'string' ? input : JSON.stringify(input, null, 2) + '\n';
83
76
  const settings = typeof input === 'string' ? JSON.parse(input) : structuredClone(input);
84
- if (!settings.hooks) {
85
- return settingsJson;
86
- }
87
77
  let changed = false;
88
78
  for (const [hookType, marker] of Object.entries(CAPTURE_HOOK_CONFIG)) {
89
- if (!settings.hooks[hookType]) {
90
- continue;
91
- }
92
- const before = settings.hooks[hookType].length;
93
- settings.hooks[hookType] = settings.hooks[hookType].filter((matcher) => !matcher.hooks.some((h) => h.command.includes(marker)));
94
- if (settings.hooks[hookType].length !== before) {
95
- changed = true;
96
- }
97
- if (settings.hooks[hookType].length === 0) {
98
- delete settings.hooks[hookType];
99
- }
100
- }
101
- if (settings.hooks && Object.keys(settings.hooks).length === 0) {
102
- delete settings.hooks;
79
+ // Evaluate every removal — never short-circuit (PF-015).
80
+ const removed = removeHooks(settings, hookType, isCaptureHook(marker));
81
+ changed = changed || removed;
103
82
  }
104
83
  if (!changed) {
105
84
  return settingsJson;
@@ -118,15 +97,10 @@ export function hasCaptureHooks(input) {
118
97
  */
119
98
  export function countCaptureHooks(input) {
120
99
  const settings = typeof input === 'string' ? JSON.parse(input) : input;
121
- if (!settings.hooks) {
122
- return 0;
123
- }
124
100
  let count = 0;
125
101
  for (const [hookType, marker] of Object.entries(CAPTURE_HOOK_CONFIG)) {
126
- const matchers = settings.hooks[hookType] ?? [];
127
- if (matchers.some((matcher) => matcher.hooks.some((h) => h.command.includes(marker)))) {
102
+ if (hasHook(settings, hookType, isCaptureHook(marker)))
128
103
  count++;
129
- }
130
104
  }
131
105
  return count;
132
106
  }