devflow-kit 3.1.0 → 3.2.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 (86) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -1
  3. package/dist/cli/agents-view/render.js +69 -15
  4. package/dist/cli/agents-view/state.js +40 -14
  5. package/dist/cli/commands/agents.js +135 -45
  6. package/dist/cli/commands/init.js +128 -53
  7. package/dist/cli/commands/learning.js +36 -8
  8. package/dist/cli/commands/memory.js +35 -14
  9. package/dist/cli/commands/uninstall.js +163 -39
  10. package/dist/commands/code-review.md +0 -2
  11. package/dist/commands/debug.md +14 -11
  12. package/dist/commands/dynamic-build.md +33 -43
  13. package/dist/commands/dynamic-plan.md +8 -2
  14. package/dist/commands/explore.md +9 -3
  15. package/dist/commands/implement.md +20 -16
  16. package/dist/commands/plan.md +13 -9
  17. package/dist/commands/release.md +8 -2
  18. package/dist/commands/research.md +8 -2
  19. package/dist/commands/resolve.md +1 -3
  20. package/dist/commands/self-review.md +0 -2
  21. package/dist/core/agent-frontmatter.js +25 -0
  22. package/dist/core/agent-models.js +198 -36
  23. package/dist/core/agent-state.js +27 -5
  24. package/dist/core/assets.js +1 -1
  25. package/dist/core/feature-config.js +68 -10
  26. package/dist/core/flags.js +24 -0
  27. package/dist/core/learning-queue-cleanup.js +10 -11
  28. package/dist/core/linked-path.js +46 -0
  29. package/dist/core/plugins.js +9 -3
  30. package/dist/core/queue-drain.js +31 -0
  31. package/dist/hud/components/learning-counts.js +54 -8
  32. package/dist/skills/git/references/tracker/github/create-release.md +2 -2
  33. package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
  34. package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
  35. package/dist/targets/claude-code/installer.js +36 -9
  36. package/dist/targets/claude-code/post-install.js +128 -38
  37. package/package.json +1 -1
  38. package/src/assets/agents/code.md +14 -17
  39. package/src/assets/agents/design.md +2 -0
  40. package/src/assets/agents/diagnose.md +2 -0
  41. package/src/assets/agents/evaluate.md +4 -0
  42. package/src/assets/agents/knowledge.md +2 -0
  43. package/src/assets/agents/research.md +2 -0
  44. package/src/assets/agents/review.md +2 -0
  45. package/src/assets/agents/scrutinize.md +4 -0
  46. package/src/assets/agents/simplify.md +4 -0
  47. package/src/assets/agents/skim.md +3 -1
  48. package/src/assets/agents/synthesize.md +6 -0
  49. package/src/assets/agents/test.md +18 -10
  50. package/src/assets/agents/triage.md +2 -0
  51. package/src/assets/agents/validate.md +14 -10
  52. package/src/assets/commands/_partials/_engine.mds +15 -31
  53. package/src/assets/commands/_partials/_knowledge.mds +0 -2
  54. package/src/assets/commands/_partials/_tracker.mds +1 -1
  55. package/src/assets/commands/code-review.mds +0 -2
  56. package/src/assets/commands/debug.mds +13 -8
  57. package/src/assets/commands/dynamic-build.mds +17 -11
  58. package/src/assets/commands/dynamic-plan.mds +7 -1
  59. package/src/assets/commands/explore.mds +9 -1
  60. package/src/assets/commands/implement.mds +19 -13
  61. package/src/assets/commands/plan.mds +12 -8
  62. package/src/assets/commands/release.md +8 -2
  63. package/src/assets/commands/research.mds +8 -2
  64. package/src/assets/commands/resolve.mds +1 -1
  65. package/src/assets/mds/tracker/_github.mds +2 -2
  66. package/src/assets/mds/tracker/_jira.mds +2 -2
  67. package/src/assets/mds/tracker/_linear.mds +2 -2
  68. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +3 -2
  69. package/src/assets/scripts/hooks/background-memory-update +69 -11
  70. package/src/assets/scripts/hooks/capture-prompt +4 -3
  71. package/src/assets/scripts/hooks/capture-question +4 -3
  72. package/src/assets/scripts/hooks/capture-turn +4 -3
  73. package/src/assets/scripts/hooks/ensure-devflow-init +13 -1
  74. package/src/assets/scripts/hooks/ensure-root-gitignore +122 -10
  75. package/src/assets/scripts/hooks/git-marker +71 -0
  76. package/src/assets/scripts/hooks/json-helper.cjs +12 -145
  77. package/src/assets/scripts/hooks/json-parse +24 -129
  78. package/src/assets/scripts/hooks/lib/learning-store.cjs +169 -64
  79. package/src/assets/scripts/hooks/lib/render-decisions.cjs +1 -1
  80. package/src/assets/scripts/hooks/memory-worker +10 -0
  81. package/src/assets/scripts/hooks/pre-compact-memory +66 -14
  82. package/src/assets/scripts/hooks/preamble +9 -1
  83. package/src/assets/scripts/hooks/queue-append +53 -21
  84. package/src/assets/scripts/hooks/session-start-context +108 -29
  85. package/src/assets/scripts/hooks/session-start-memory +33 -11
  86. package/src/assets/skills/test-driven-development/SKILL.md +6 -4
@@ -14,14 +14,19 @@
14
14
  * devflow agents --list → tabular list (safe in non-TTY)
15
15
  * devflow agents --set <agent> --model <m> --effort <e>
16
16
  * devflow agents --reset [--yes]
17
+ *
18
+ * D-WORKER-AGENTS: `memory` is a background worker, not an agent, and is
19
+ * settable here as `--set memory` and as a row after the agents in `--list` and
20
+ * the TUI. Its state is always 'worker'; its values are held to the worker
21
+ * domain (Claude models and effort levels only) by validateWorkerValue.
17
22
  */
18
23
  import { Command } from 'commander';
19
24
  import * as path from 'path';
20
25
  import * as p from '@clack/prompts';
21
26
  import color from 'picocolors';
22
- import { EFFORT_LEVELS, LEGACY_AGENT_KEYS, readAgentMapping, saveAgentMapping, reapplyAgentMapping, loadShippedDefaults, readInstalledAgentNames, } from '../../core/agent-models.js';
27
+ import { EFFORT_INHERIT, EFFORT_LEVELS, LEGACY_AGENT_KEYS, WORKER_AGENTS, agentOnlyMapping, isEffortLevel, isWorkerAgent, readAgentMapping, saveAgentMapping, reapplyAgentMapping, loadShippedAgentDefaults, readInstalledAgentNames, validateWorkerValue, } from '../../core/agent-models.js';
23
28
  import { CLAUDE_MODEL_ALIASES, isDormantExternalModel, } from '../../core/external-models.js';
24
- import { classifyAgentState, AGENT_STATE_LABELS, } from '../../core/agent-state.js';
29
+ import { classifyAgentState, formatEffortDisplay, AGENT_STATE_LABELS, } from '../../core/agent-state.js';
25
30
  import { isValidModelName } from '../../core/agent-frontmatter.js';
26
31
  import { isProxyEnabled } from '../../core/proxy-state.js';
27
32
  import { getAllAgentNames } from '../../core/plugins.js';
@@ -47,16 +52,28 @@ function Err(error) {
47
52
  * This preserves the configure-first-then-enable provisioning flow and keeps
48
53
  * `--set` at zero subprocess cost (AC-P9: cache-only, 0 spawns).
49
54
  *
55
+ * D-WORKER-AGENTS: when `agentName` is a worker (`memory`), model and effort are
56
+ * held to the worker value domain instead — validateWorkerValue, the function
57
+ * readAgentMapping applies on read — with no catalog lookup: a worker runs
58
+ * outside any session, so an external model, `inherit` as a model and `inherit`
59
+ * as an effort are all rejected.
60
+ *
61
+ * D-SHIPPED-EFFORT: for an agent, `--effort inherit` is accepted. It stores the
62
+ * sentinel that drops the agent's effort line; `--effort default` deletes the
63
+ * key so the shipped effort applies again.
64
+ *
50
65
  * Returns Err when:
51
66
  * - neither model nor effort is provided
52
67
  * - model is unknown AND catalog is known
53
- * - effort is unknown (not in EFFORT_LEVELS ∪ 'default')
68
+ * - effort is unknown (not in EFFORT_LEVELS ∪ 'default' ∪ 'inherit')
69
+ * - agentName is a worker and a value is outside the worker domain
54
70
  */
55
- export function validateSetArgs(args, catalog = { known: false }) {
71
+ export function validateSetArgs(args, catalog = { known: false }, agentName) {
56
72
  const { model, effort } = args;
57
73
  if (model === undefined && effort === undefined) {
58
74
  return Err('Specify at least one of --model or --effort');
59
75
  }
76
+ const worker = agentName !== undefined && isWorkerAgent(agentName);
60
77
  if (model !== undefined) {
61
78
  // Charset gate: applied at every trust boundary regardless of catalog state.
62
79
  // Rejects injection payloads (newlines, YAML metacharacters) before they can
@@ -66,7 +83,12 @@ export function validateSetArgs(args, catalog = { known: false }) {
66
83
  return Err(`Invalid model name "${model}". Model names must start with an alphanumeric character ` +
67
84
  `and contain only alphanumeric, dot, underscore, or hyphen characters (max 64 chars).`);
68
85
  }
69
- if (catalog.known) {
86
+ if (worker) {
87
+ const check = validateWorkerValue('model', model);
88
+ if (!check.ok)
89
+ return Err(check.error);
90
+ }
91
+ else if (catalog.known) {
70
92
  // Full validation against the discovered catalog.
71
93
  const valid = ['default', ...CLAUDE_MODEL_ALIASES, ...catalog.selectableNames];
72
94
  if (!valid.includes(model)) {
@@ -77,9 +99,16 @@ export function validateSetArgs(args, catalog = { known: false }) {
77
99
  // warning fires at agents.ts call site if the proxy is off.
78
100
  }
79
101
  if (effort !== undefined) {
80
- const valid = ['default', ...EFFORT_LEVELS];
81
- if (!valid.includes(effort)) {
82
- return Err(`Unknown effort "${effort}". Valid: ${valid.join(', ')}`);
102
+ if (worker) {
103
+ const check = validateWorkerValue('effort', effort);
104
+ if (!check.ok)
105
+ return Err(check.error);
106
+ }
107
+ else {
108
+ const valid = ['default', ...EFFORT_LEVELS, EFFORT_INHERIT];
109
+ if (!valid.includes(effort)) {
110
+ return Err(`Unknown effort "${effort}". Valid: ${valid.join(', ')}`);
111
+ }
83
112
  }
84
113
  }
85
114
  return Ok(args);
@@ -106,10 +135,9 @@ export function applySetMapping(mapping, agentName, args) {
106
135
  if (args.effort === 'default') {
107
136
  delete existing.effort;
108
137
  }
109
- else {
110
- // Sound narrowing: validateSetArgs already confirmed args.effort is a valid
111
- // EffortLevel before this function is called. SetArgs.effort is string to keep
112
- // the CLI entry type permissive; the assertion here is not a compensating cast.
138
+ else if (isEffortLevel(args.effort) || args.effort === EFFORT_INHERIT) {
139
+ // SetArgs.effort is a string to keep the CLI entry type permissive;
140
+ // validateSetArgs has already confirmed it is a level or the inherit sentinel.
113
141
  existing.effort = args.effort;
114
142
  }
115
143
  }
@@ -133,23 +161,62 @@ export async function buildListRows(input) {
133
161
  const entry = mapping.agents[name];
134
162
  const configured = entry?.model ?? 'default';
135
163
  const effort = entry?.effort ?? 'default';
136
- const defaultModel = shippedDefaults[name] ?? 'unknown';
164
+ const defaultModel = shippedDefaults[name]?.model ?? 'unknown';
165
+ const shippedEffort = shippedDefaults[name]?.effort;
137
166
  const installed = installedNames.has(name);
138
167
  // inRegistry is always true here — agentNames comes from the registry.
139
168
  const state = classifyAgentState({ configured, proxyEnabled, installed, inRegistry: true });
140
- return { name, defaultModel, configured, effort, state };
169
+ return { name, defaultModel, configured, effort, shippedEffort, state };
141
170
  });
142
171
  return rows;
143
172
  }
173
+ /**
174
+ * Build the list rows of the background workers (D-WORKER-AGENTS), which follow
175
+ * the agent rows in `--list`.
176
+ *
177
+ * A worker has no installed agent file, so its row never consults the install
178
+ * directory: its state is always 'worker', never classified (it can be neither
179
+ * not installed nor dormant — validateSetArgs rejects an external model for a
180
+ * worker). DEFAULT is the model WORKER_AGENTS ships and EFFORT shows the shipped
181
+ * effort while unconfigured, the same convention as an agent row.
182
+ *
183
+ * Pure function, no I/O.
184
+ */
185
+ export function buildWorkerListRows(mapping) {
186
+ return Object.entries(WORKER_AGENTS).map(([name, shipped]) => {
187
+ const entry = mapping.agents[name];
188
+ return {
189
+ name,
190
+ defaultModel: shipped.model,
191
+ configured: entry?.model ?? 'default',
192
+ effort: entry?.effort ?? 'default',
193
+ shippedEffort: shipped.effort,
194
+ state: 'worker',
195
+ };
196
+ });
197
+ }
144
198
  // ---------------------------------------------------------------------------
145
199
  // --list output formatting
146
200
  // ---------------------------------------------------------------------------
201
+ /**
202
+ * Width of the EFFORT column: the longest `default (<level>)` an unconfigured row
203
+ * can show, derived from EFFORT_LEVELS so a new level cannot truncate the cell.
204
+ */
205
+ const EFFORT_COLUMN_WIDTH = Math.max(...EFFORT_LEVELS.map(level => formatEffortDisplay('default', level).length), EFFORT_INHERIT.length);
206
+ /** Narrowest the DEFAULT column of `--list` renders; it widens to fit a longer shipped default. */
207
+ const DEFAULT_COLUMN_MIN_WIDTH = 10;
208
+ /** Narrowest the CONFIGURED column of `--list` renders; it widens to fit a longer configured model. */
209
+ const CONFIGURED_COLUMN_MIN_WIDTH = 16;
147
210
  export function formatListOutput(rows, proxyEnabled) {
148
211
  const lines = [];
149
212
  const AGENT_W = 20;
150
- const DEFAULT_W = 10;
151
- const CONFIGURED_W = 16;
152
- const EFFORT_W = 12;
213
+ // DEFAULT and CONFIGURED grow to the longest value in their column, so a full
214
+ // model identifier (the memory worker ships claude-sonnet-5-5; a user maps
215
+ // claude-sonnet-4-6) is shown whole, not cut to a fixed width and read as a
216
+ // different model.
217
+ const DEFAULT_W = Math.max(DEFAULT_COLUMN_MIN_WIDTH, ...rows.map(row => stripAnsi(row.defaultModel).length));
218
+ const CONFIGURED_W = Math.max(CONFIGURED_COLUMN_MIN_WIDTH, ...rows.map(row => stripAnsi(row.configured).length));
219
+ const EFFORT_W = EFFORT_COLUMN_WIDTH;
153
220
  // Header
154
221
  lines.push([
155
222
  color.gray('AGENT'.padEnd(AGENT_W)),
@@ -176,6 +243,9 @@ export function formatListOutput(rows, proxyEnabled) {
176
243
  case 'unknown':
177
244
  stateStr = color.dim(AGENT_STATE_LABELS['unknown']);
178
245
  break;
246
+ case 'worker':
247
+ stateStr = color.dim(AGENT_STATE_LABELS['worker']);
248
+ break;
179
249
  default: {
180
250
  const _ = row.state;
181
251
  void _;
@@ -188,15 +258,18 @@ export function formatListOutput(rows, proxyEnabled) {
188
258
  stripAnsi(row.name).padEnd(AGENT_W).slice(0, AGENT_W),
189
259
  stripAnsi(row.defaultModel).padEnd(DEFAULT_W).slice(0, DEFAULT_W),
190
260
  stripAnsi(row.configured).padEnd(CONFIGURED_W).slice(0, CONFIGURED_W),
191
- stripAnsi(row.effort).padEnd(EFFORT_W).slice(0, EFFORT_W),
261
+ stripAnsi(formatEffortDisplay(row.effort, row.shippedEffort)).padEnd(EFFORT_W).slice(0, EFFORT_W),
192
262
  stateStr,
193
263
  ].join(' '));
194
264
  }
195
- const installed = rows.filter(r => r.state !== 'not-installed').length;
196
- const configured = rows.filter(r => r.configured !== 'default' || r.effort !== 'default').length;
265
+ // The footer counts agent rows only: a worker row is neither installed nor
266
+ // not installed, and configuring it changes no agent (D-WORKER-AGENTS).
267
+ const agentRows = rows.filter(r => r.state !== 'worker');
268
+ const installed = agentRows.filter(r => r.state !== 'not-installed').length;
269
+ const configured = agentRows.filter(r => r.configured !== 'default' || r.effort !== 'default').length;
197
270
  const proxyLabel = proxyEnabled ? color.green('enabled') : color.yellow('disabled');
198
271
  lines.push('');
199
- lines.push(`${installed}/${rows.length} installed · ${configured} configured · proxy: ${proxyLabel}`);
272
+ lines.push(`${installed}/${agentRows.length} installed · ${configured} configured · proxy: ${proxyLabel}`);
200
273
  return lines.join('\n');
201
274
  }
202
275
  // ---------------------------------------------------------------------------
@@ -241,7 +314,8 @@ async function buildTuiState(agentNames, mapping, shippedDefaults, proxyEnabled,
241
314
  const entry = mapping.agents[name];
242
315
  return buildRow({
243
316
  name,
244
- shippedDefault: shippedDefaults[name] ?? 'unknown',
317
+ shippedDefault: shippedDefaults[name]?.model ?? 'unknown',
318
+ shippedEffort: shippedDefaults[name]?.effort,
245
319
  savedModel: entry?.model,
246
320
  savedEffort: entry?.effort,
247
321
  proxyEnabled,
@@ -251,12 +325,31 @@ async function buildTuiState(agentNames, mapping, shippedDefaults, proxyEnabled,
251
325
  inRegistry: true,
252
326
  });
253
327
  });
254
- // Orphan rows: keys in agent-models.json not present in the registry.
328
+ // Worker rows (D-WORKER-AGENTS) follow the agents. A worker has no installed
329
+ // file and takes only the worker value domain, so it is built with its own
330
+ // shipped defaults and never classified as an orphan (state 'unknown').
331
+ for (const [workerName, shipped] of Object.entries(WORKER_AGENTS)) {
332
+ const entry = mapping.agents[workerName];
333
+ rows.push(buildRow({
334
+ name: workerName,
335
+ shippedDefault: shipped.model,
336
+ shippedEffort: shipped.effort,
337
+ savedModel: entry?.model,
338
+ savedEffort: entry?.effort,
339
+ proxyEnabled,
340
+ installed: false,
341
+ inRegistry: false,
342
+ worker: true,
343
+ }));
344
+ }
345
+ // Orphan rows: agent keys in agent-models.json not present in the registry.
255
346
  // Appended at the end so they are visually separated from known agents.
256
- for (const orphanKey of Object.keys(mapping.agents)) {
347
+ // agentOnlyMapping leaves out the worker keys, which have their own rows above.
348
+ const agentEntries = agentOnlyMapping(mapping);
349
+ for (const orphanKey of Object.keys(agentEntries)) {
257
350
  if (registrySet.has(orphanKey))
258
351
  continue;
259
- const entry = mapping.agents[orphanKey];
352
+ const entry = agentEntries[orphanKey];
260
353
  rows.push(buildRow({
261
354
  name: orphanKey,
262
355
  shippedDefault: 'unknown',
@@ -352,7 +445,8 @@ export const agentsCommand = new Command('agents')
352
445
  .option('--list', 'List all agents with their current configuration')
353
446
  .option('--set <agent>', 'Set model/effort for a specific agent')
354
447
  .option('--model <model>', 'Model to assign (use with --set)')
355
- .option('--effort <level>', 'Effort level to assign (use with --set)')
448
+ .option('--effort <level>', `Effort level to assign (use with --set): ${EFFORT_LEVELS.join(', ')}, ` +
449
+ `default (use the shipped effort), or ${EFFORT_INHERIT} (drop the effort line, follow the session)`)
356
450
  .option('--reset', 'Clear all agent customisations and restore defaults')
357
451
  .option('--yes', 'Skip confirmation prompt (use with --reset)')
358
452
  .action(async (options) => {
@@ -376,20 +470,23 @@ export const agentsCommand = new Command('agents')
376
470
  // An agent with no shipped default renders a blank DEFAULT column and can
377
471
  // never be reverted off an external model; surface the gap rather than
378
472
  // letting the table imply the agent simply ships without one.
379
- const shippedDefaults = await loadShippedDefaults(undefined, {
473
+ const shippedDefaults = await loadShippedAgentDefaults(undefined, {
380
474
  onWarning: (msg) => p.log.warn(msg),
381
475
  });
382
- // ── --list ──────────────────────────────────────────────────────────────
383
- if (options.list) {
384
- const agentNames = getAllAgentNames().sort();
385
- const rows = await buildListRows({
386
- agentNames,
476
+ // Agent rows in registry order, then the worker rows (D-WORKER-AGENTS).
477
+ const loadListRows = async () => {
478
+ const agentRows = await buildListRows({
479
+ agentNames: getAllAgentNames().sort(),
387
480
  mapping,
388
481
  installDir,
389
482
  shippedDefaults,
390
483
  proxyEnabled,
391
484
  });
392
- process.stdout.write(formatListOutput(rows, proxyEnabled) + '\n');
485
+ return [...agentRows, ...buildWorkerListRows(mapping)];
486
+ };
487
+ // ── --list ──────────────────────────────────────────────────────────────
488
+ if (options.list) {
489
+ process.stdout.write(formatListOutput(await loadListRows(), proxyEnabled) + '\n');
393
490
  return;
394
491
  }
395
492
  // ── --reset ─────────────────────────────────────────────────────────────
@@ -447,10 +544,11 @@ export const agentsCommand = new Command('agents')
447
544
  p.log.info(`Agent '${agentName}' has been renamed to '${canonical}' — using canonical name.`);
448
545
  agentName = canonical;
449
546
  }
450
- // Validate agent name
547
+ // Validate agent name. D-WORKER-AGENTS: the workers are settable too, so a
548
+ // first `--set memory` is accepted although nothing in the mapping names it yet.
451
549
  const knownAgents = getAllAgentNames();
452
550
  const knownMapping = Object.keys(mapping.agents);
453
- const allKnown = new Set([...knownAgents, ...knownMapping]);
551
+ const allKnown = new Set([...knownAgents, ...Object.keys(WORKER_AGENTS), ...knownMapping]);
454
552
  if (!allKnown.has(agentName)) {
455
553
  p.log.error(`Unknown agent "${agentName}". Valid: ${[...allKnown].sort().join(', ')}`);
456
554
  process.exitCode = 1;
@@ -461,7 +559,7 @@ export const agentsCommand = new Command('agents')
461
559
  // dormancy warning below fires if needed). This preserves the
462
560
  // configure-first-then-enable provisioning flow.
463
561
  const setCatalog = getExternalModelsCached(cacheDir);
464
- const validation = validateSetArgs({ model: options.model, effort: options.effort }, setCatalog);
562
+ const validation = validateSetArgs({ model: options.model, effort: options.effort }, setCatalog, agentName);
465
563
  if (!validation.ok) {
466
564
  p.log.error(validation.error);
467
565
  process.exitCode = 1;
@@ -510,15 +608,7 @@ export const agentsCommand = new Command('agents')
510
608
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
511
609
  if (!isInteractive) {
512
610
  // Non-TTY: print list and exit 1 with note
513
- const agentNames = getAllAgentNames().sort();
514
- const rows = await buildListRows({
515
- agentNames,
516
- mapping,
517
- installDir,
518
- shippedDefaults,
519
- proxyEnabled,
520
- });
521
- process.stdout.write(formatListOutput(rows, proxyEnabled) + '\n');
611
+ process.stdout.write(formatListOutput(await loadListRows(), proxyEnabled) + '\n');
522
612
  process.stderr.write('Note: interactive view requires a terminal. Use --list for non-TTY output.\n');
523
613
  process.exitCode = 1;
524
614
  return;
@@ -12,7 +12,7 @@ import { getLedgerRoot } from '../../core/ledger-root.js';
12
12
  import { installViaFileCopy, composeScripts } from '../../targets/claude-code/installer.js';
13
13
  import { formatOverlaySummary, formatSkillScopeSummary, formatTrackerAssetSummary, isPluginListUnchanged } from './install-report.js';
14
14
  import { convergeTrackerArtifacts } from '../../targets/claude-code/tracker-install.js';
15
- import { installSettings, installManagedSettings, installClaudeignore, discoverProjectGitRoots, ensureDevflowGitignore, applyUserSecurityDenyList, detectDenyState, resolveSecurityAction, assertHistoricalDenySuperset, loadTemplateDenyEntries, stripUserSecurityDenyList, } from '../../targets/claude-code/post-install.js';
15
+ import { installSettings, installManagedSettings, installClaudeignore, hasClaudeignore, discoverProjectGitRoots, ensureDevflowGitignore, applyUserSecurityDenyList, detectDenyState, resolveSecurityAction, assertHistoricalDenySuperset, loadTemplateDenyEntries, stripUserSecurityDenyList, } from '../../targets/claude-code/post-install.js';
16
16
  import { DEVFLOW_PLUGINS, LEGACY_COMMAND_NAMES, LEGACY_RULE_NAMES, buildAssetMaps, buildScopedSkillsMap, buildRulesMap, partitionSelectablePlugins, WORKFLOW_ORDER, parsePluginSelection, resolveFeatureRedirect } from '../../core/plugins.js';
17
17
  import { LEGACY_SKILL_NAMES } from '../../targets/claude-code/legacy.js';
18
18
  import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafeDelete } from '../../core/safe-delete.js';
@@ -22,7 +22,7 @@ import { convergeMemoryHooks, drainMemoryQueue } from './memory.js';
22
22
  import { addCaptureHooks, removeCaptureHooks } from './capture.js';
23
23
  import { removeDreamHook } from './legacy-hooks.js';
24
24
  import { addProxyHooks, removeProxyHooks, applyProxyEnv, stripProxyEnv, runProxyPreflight, buildRealPreflightDeps } from './proxy.js';
25
- import { reapplyAgentMapping, readAgentMapping } from '../../core/agent-models.js';
25
+ import { reapplyAgentMapping, readAgentMapping, hasAgentMappingEntries } from '../../core/agent-models.js';
26
26
  import { readProxyState, writeProxyState, buildProxyState, buildRoutingConfigJson, DEFAULT_PROXY_PORT, proxyJsonExists } from '../../core/proxy-state.js';
27
27
  import { stripDevflowTeammateModeFromJson } from '../../core/teammate-mode-cleanup.js';
28
28
  // Settings/HookMatcher types used by hook utilities — each in their own module
@@ -34,6 +34,7 @@ import { addContextHook, removeContextHook, hasContextHook } from './context.js'
34
34
  import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
35
35
  import { writeManagedConfig, readConfigIfPresent, DEFAULT_CONFIG } from '../../core/feature-config.js';
36
36
  import { drainLearningQueue } from '../../core/learning-queue-cleanup.js';
37
+ import { formatRefusedDrain } from '../../core/queue-drain.js';
37
38
  import { removeManagedDenyList, describeManagedDenyRemoval } from './security.js';
38
39
  import { resolveInitSeed, applyCliToggles, resolveResetGatedInputs, resolvePluginsToInstall } from './init-seed.js';
39
40
  import { parseFrameworkList, normalizeFrameworks } from '../../core/compliance.js';
@@ -55,7 +56,7 @@ import { getPackageRoot } from '../../core/paths.js';
55
56
  /**
56
57
  * D32/D35: Orchestrates the init-level migration-runner seam.
57
58
  *
58
- * Computes the project list with the D37 fallback rule:
59
+ * Computes the project list with the D37 fallback rule ({@link projectRoots}):
59
60
  * 1. Use discoveredProjects when non-empty.
60
61
  * 2. Fall back to [gitRoot] when discoveredProjects is empty and gitRoot is set.
61
62
  * 3. Run with no per-project targets when both are absent (global-only; per-project
@@ -70,8 +71,7 @@ import { getPackageRoot } from '../../core/paths.js';
70
71
  * this helper testable without real filesystem migration state.
71
72
  */
72
73
  export async function runMigrationsWithFallback(discoveredProjects, gitRoot, devflowDir, logger, verbose, runner) {
73
- const projectsForMigration = discoveredProjects.length > 0 ? discoveredProjects : (gitRoot ? [gitRoot] : []);
74
- const migrationResult = await runner({ devflowDir }, projectsForMigration);
74
+ const migrationResult = await runner({ devflowDir }, projectRoots(discoveredProjects, gitRoot));
75
75
  reportMigrationResult(migrationResult, logger, verbose);
76
76
  return migrationResult;
77
77
  }
@@ -119,6 +119,9 @@ export function formatSweepSummary(report) {
119
119
  function logSummaryLines(lines) {
120
120
  for (const line of lines) {
121
121
  switch (line.level) {
122
+ case 'success':
123
+ p.log.success(line.message);
124
+ break;
122
125
  case 'info':
123
126
  p.log.info(line.message);
124
127
  break;
@@ -144,6 +147,81 @@ export function classifySafeDeleteState(installedVersion, currentVersion) {
144
147
  return 'outdated';
145
148
  return 'missing';
146
149
  }
150
+ /**
151
+ * The project roots init writes per-project files into and migrates (D37): every
152
+ * project discovered from Claude's history, else the current repository, else
153
+ * none. Pure.
154
+ */
155
+ export function projectRoots(discoveredProjects, gitRoot) {
156
+ if (discoveredProjects.length > 0)
157
+ return [...discoveredProjects];
158
+ return gitRoot ? [gitRoot] : [];
159
+ }
160
+ /**
161
+ * The Recommended summary's `.claudeignore` row.
162
+ *
163
+ * D-INIT-REAL-OUTCOME: init's summary and status lines state what this run does,
164
+ * decided from the state it acts on rather than printed whatever happens, and
165
+ * point only at a step the user can still take. The summary prints before the
166
+ * install runs, so this row is decided from whether each targeted project
167
+ * already holds a `.claudeignore` (hasClaudeignore, which matches the install's
168
+ * exclusive create): one without it gets one, so the row says `created`; when
169
+ * all have one, `already present`; when the run targets no project, `skipped`.
170
+ * formatSafeDeleteStatus applies the same rule to safe-delete.
171
+ *
172
+ * Pure function.
173
+ *
174
+ * @param present - for each project the run targets, whether it already holds a
175
+ * `.claudeignore`; empty when the run targets none.
176
+ */
177
+ export function resolveClaudeignoreOutcome(present) {
178
+ if (present.length === 0)
179
+ return 'skipped';
180
+ return present.every(Boolean) ? 'already present' : 'created';
181
+ }
182
+ /** The Recommended summary's safe-delete row for what the run does to the block; a run that leaves it alone adds none. */
183
+ const SAFE_DELETE_SUMMARY_ROW = {
184
+ install: 'Safe delete: installed',
185
+ upgrade: 'Safe delete: upgraded',
186
+ skip: '',
187
+ };
188
+ /**
189
+ * What init says about safe-delete once the install has run (D-INIT-REAL-OUTCOME).
190
+ *
191
+ * The outcome comes first: the block this run installed or upgraded, or the
192
+ * current block it found in place. Failing an outcome, the one step left to the
193
+ * user is installing the platform's trash command, which init cannot do for
194
+ * them. A non-interactive run always takes the Recommended path, which installs
195
+ * or upgrades the block itself, so its outcome is all there is to report.
196
+ *
197
+ * Pure function — returns lines, logs nothing.
198
+ *
199
+ * @param state - The profile's block before this run, or null when no profile was
200
+ * checked (the trash command is missing, or the shell has no profile init writes).
201
+ */
202
+ export function formatSafeDeleteStatus(input) {
203
+ const { profilePath, info } = input;
204
+ if (profilePath !== null) {
205
+ const restart = { level: 'info', message: 'Restart your shell or run: ' + color.cyan(`source ${profilePath}`) };
206
+ if (input.action === 'install')
207
+ return [{ level: 'success', message: `Safe-delete installed to ${color.dim(profilePath)}` }, restart];
208
+ if (input.action === 'upgrade')
209
+ return [{ level: 'success', message: `Safe-delete upgraded in ${color.dim(profilePath)}` }, restart];
210
+ if (input.state === 'current')
211
+ return [{ level: 'info', message: `Safe-delete already configured in ${color.dim(profilePath)}` }];
212
+ }
213
+ if (input.available || info.installHint === null)
214
+ return [];
215
+ if (!input.interactive) {
216
+ return [{ level: 'info', message: `Protect against accidental ${color.red('rm -rf')}: ${color.cyan(info.installHint)}` }];
217
+ }
218
+ if (profilePath === null)
219
+ return [];
220
+ return [
221
+ { level: 'info', message: `Install ${color.cyan(info.command ?? 'trash')} first: ${color.dim(info.installHint)}` },
222
+ { level: 'info', message: `Then re-run ${color.cyan('devflow init')} to auto-configure safe-delete.` },
223
+ ];
224
+ }
147
225
  export { addContextHook, removeContextHook, hasContextHook };
148
226
  /**
149
227
  * Combine workflow and language selections into a single plugin list.
@@ -344,14 +422,25 @@ export async function persistManifestThenConvergeTracker(opts) {
344
422
  * D-LEDGER-MAIN-WORKTREE: each queue drains where the hooks write it — memory at
345
423
  * this checkout's toplevel (`gitRoot`), learning at the ledger root (`ledgerRoot`,
346
424
  * getLedgerRoot), which in a linked worktree is the main checkout.
425
+ *
426
+ * Returns one warning for each drain refused because a folder on the way to its
427
+ * queue is a symbolic link (D-CLI-NO-SYMLINK); the caller prints them.
347
428
  */
348
429
  export async function drainDisabledFeatureQueues(opts, io = { drainMemoryQueue, drainLearningQueue }) {
349
430
  if (!opts.manifestWritten)
350
- return;
351
- if (!opts.memoryEnabled && opts.gitRoot !== null)
352
- await io.drainMemoryQueue(opts.gitRoot);
353
- if (!opts.learningEnabled && opts.ledgerRoot !== null)
354
- await io.drainLearningQueue(opts.ledgerRoot);
431
+ return [];
432
+ const refused = [];
433
+ if (!opts.memoryEnabled && opts.gitRoot !== null) {
434
+ const memory = await io.drainMemoryQueue(opts.gitRoot);
435
+ if (!memory.drained)
436
+ refused.push(formatRefusedDrain('memory', memory.linkedFolder));
437
+ }
438
+ if (!opts.learningEnabled && opts.ledgerRoot !== null) {
439
+ const learning = await io.drainLearningQueue(opts.ledgerRoot);
440
+ if (!learning.drained)
441
+ refused.push(formatRefusedDrain('learning', learning.linkedFolder));
442
+ }
443
+ return refused;
355
444
  }
356
445
  /**
357
446
  * The manifest `devflow init --hud-only` writes. Pure — never mutates `existing`.
@@ -822,6 +911,8 @@ export const initCommand = new Command('init')
822
911
  let claudeignoreEnabled = !!gitRoot;
823
912
  let discoveredProjects = [];
824
913
  let safeDeleteAction = 'skip';
914
+ // The profile's block before this run; null when no profile was checked.
915
+ let safeDeleteState = null;
825
916
  let safeDeleteBlock = null;
826
917
  // Security mode is resolved from flag + manifest + detected reality via resolveSecurityAction.
827
918
  // The final value is written to the manifest and consumed by the dedicated security step.
@@ -950,14 +1041,17 @@ export const initCommand = new Command('init')
950
1041
  ]);
951
1042
  discoveredProjects = discoveredResult;
952
1043
  if (needsVersionCheck) {
953
- const state = classifySafeDeleteState(installedVersionResult, SAFE_DELETE_BLOCK_VERSION);
954
- if (state === 'current')
1044
+ safeDeleteState = classifySafeDeleteState(installedVersionResult, SAFE_DELETE_BLOCK_VERSION);
1045
+ if (safeDeleteState === 'current')
955
1046
  safeDeleteAction = 'skip';
956
- else if (state === 'outdated')
1047
+ else if (safeDeleteState === 'outdated')
957
1048
  safeDeleteAction = 'upgrade';
958
1049
  else
959
1050
  safeDeleteAction = 'install';
960
1051
  }
1052
+ // D-INIT-REAL-OUTCOME: the summary prints before the install runs, so its
1053
+ // .claudeignore row comes from what each project the install targets holds.
1054
+ const claudeignorePresent = await Promise.all((claudeignoreEnabled ? projectRoots(discoveredProjects, gitRoot) : []).map(hasClaudeignore));
961
1055
  // Print summary
962
1056
  const defaultFlagCount = countActiveFlags(enabledFlags);
963
1057
  const complianceSummary = formatComplianceSummary(complianceEnabled, complianceFrameworks);
@@ -976,8 +1070,8 @@ export const initCommand = new Command('init')
976
1070
  `Tracker: ${formatTrackerSummary(trackerProvider)}`,
977
1071
  `View mode: ${readViewMode(enabledFlags)}`,
978
1072
  `Claude Code flags: ${defaultFlagCount} configured`,
979
- `${claudeignoreEnabled ? '.claudeignore: created' : ''}`,
980
- `${safeDeleteAction !== 'skip' ? 'Safe delete: installed' : ''}`,
1073
+ `.claudeignore: ${resolveClaudeignoreOutcome(claudeignorePresent)}`,
1074
+ SAFE_DELETE_SUMMARY_ROW[safeDeleteAction],
981
1075
  ].filter(l => l.trim()).join('\n');
982
1076
  p.note(summaryLines + `\n\nCustomize later: ${color.cyan('devflow init --advanced')}`, 'Recommended settings applied');
983
1077
  }
@@ -1271,11 +1365,11 @@ export const initCommand = new Command('init')
1271
1365
  safeDeleteBlock = generateSafeDeleteBlock(shell, process.platform, trashCmd);
1272
1366
  if (safeDeleteBlock) {
1273
1367
  const installedVersion = await getInstalledVersion(profilePath);
1274
- const state = classifySafeDeleteState(installedVersion, SAFE_DELETE_BLOCK_VERSION);
1275
- if (state === 'current') {
1368
+ safeDeleteState = classifySafeDeleteState(installedVersion, SAFE_DELETE_BLOCK_VERSION);
1369
+ if (safeDeleteState === 'current') {
1276
1370
  safeDeleteAction = 'skip';
1277
1371
  }
1278
- else if (state === 'outdated') {
1372
+ else if (safeDeleteState === 'outdated') {
1279
1373
  safeDeleteAction = 'upgrade';
1280
1374
  }
1281
1375
  else {
@@ -1727,7 +1821,9 @@ export const initCommand = new Command('init')
1727
1821
  {
1728
1822
  const agentInstallDir = path.join(claudeDir, 'agents', 'devflow');
1729
1823
  const preCheckMapping = await readAgentMapping(devflowDir);
1730
- const hasMappingEntries = preCheckMapping.ok && Object.keys(preCheckMapping.value.agents).length > 0;
1824
+ // hasAgentMappingEntries counts agent entries only: an agents.memory-only
1825
+ // mapping names no installed file, so it keeps this gate closed (D-WORKER-AGENTS).
1826
+ const hasMappingEntries = preCheckMapping.ok && hasAgentMappingEntries(preCheckMapping.value);
1731
1827
  if (hasMappingEntries || proxyEnabled) {
1732
1828
  const reapplyResult = await reapplyAgentMapping({
1733
1829
  proxyEnabled,
@@ -1856,10 +1952,10 @@ export const initCommand = new Command('init')
1856
1952
  // Configure HUD
1857
1953
  const existingHud = loadHudConfig();
1858
1954
  saveHudConfig({ enabled: hudEnabled, detail: existingHud.detail });
1859
- // File extras
1955
+ // File extras — into the projects the Recommended summary's .claudeignore row checked.
1860
1956
  if (claudeignoreEnabled) {
1957
+ const results = await Promise.all(projectRoots(discoveredProjects, gitRoot).map(root => installClaudeignore(root, rootDir, verbose)));
1861
1958
  if (discoveredProjects.length > 0) {
1862
- const results = await Promise.all(discoveredProjects.map(root => installClaudeignore(root, rootDir, verbose)));
1863
1959
  const created = results.filter(Boolean).length;
1864
1960
  if (created > 0) {
1865
1961
  p.log.success(`.claudeignore created in ${created} project(s)`);
@@ -1868,9 +1964,6 @@ export const initCommand = new Command('init')
1868
1964
  p.log.info(`.claudeignore already exists in all ${discoveredProjects.length} project(s)`);
1869
1965
  }
1870
1966
  }
1871
- else if (gitRoot) {
1872
- await installClaudeignore(gitRoot, rootDir, verbose);
1873
- }
1874
1967
  }
1875
1968
  // Deterministically ensure .devflow/ is gitignored at the repo root — independent
1876
1969
  // of every feature toggle. The always-on ensure-root-gitignore
@@ -2062,34 +2155,14 @@ export const initCommand = new Command('init')
2062
2155
  p.note(commandsNote, 'Available commands');
2063
2156
  }
2064
2157
  // Safe-delete status messages (after spinner)
2065
- if (process.stdin.isTTY && profilePath) {
2066
- if (safeDeleteAction === 'install') {
2067
- p.log.success(`Safe-delete installed to ${color.dim(profilePath)}`);
2068
- p.log.info('Restart your shell or run: ' + color.cyan(`source ${profilePath}`));
2069
- }
2070
- else if (safeDeleteAction === 'upgrade') {
2071
- p.log.success(`Safe-delete upgraded in ${color.dim(profilePath)}`);
2072
- p.log.info('Restart your shell or run: ' + color.cyan(`source ${profilePath}`));
2073
- }
2074
- else if (safeDeleteAvailable && safeDeleteBlock) {
2075
- const installedVersion = await getInstalledVersion(profilePath);
2076
- if (classifySafeDeleteState(installedVersion, SAFE_DELETE_BLOCK_VERSION) === 'current') {
2077
- p.log.info(`Safe-delete already configured in ${color.dim(profilePath)}`);
2078
- }
2079
- }
2080
- else if (!safeDeleteAvailable && safeDeleteInfo.installHint) {
2081
- p.log.info(`Install ${color.cyan(safeDeleteInfo.command ?? 'trash')} first: ${color.dim(safeDeleteInfo.installHint)}`);
2082
- p.log.info(`Then re-run ${color.cyan('devflow init')} to auto-configure safe-delete.`);
2083
- }
2084
- }
2085
- else if (!process.stdin.isTTY) {
2086
- if (safeDeleteAvailable && safeDeleteInfo.command) {
2087
- p.log.info(`Safe-delete available (${safeDeleteInfo.command}). Run interactively to auto-install.`);
2088
- }
2089
- else if (safeDeleteInfo.installHint) {
2090
- p.log.info(`Protect against accidental ${color.red('rm -rf')}: ${color.cyan(safeDeleteInfo.installHint)}`);
2091
- }
2092
- }
2158
+ logSummaryLines(formatSafeDeleteStatus({
2159
+ interactive: process.stdin.isTTY === true,
2160
+ action: safeDeleteAction,
2161
+ state: safeDeleteState,
2162
+ available: safeDeleteAvailable,
2163
+ profilePath,
2164
+ info: safeDeleteInfo,
2165
+ }));
2093
2166
  // Verbose mode: show details
2094
2167
  if (verbose) {
2095
2168
  const pluginsList = pluginsToInstall
@@ -2157,13 +2230,15 @@ export const initCommand = new Command('init')
2157
2230
  p.log.info(msg.text);
2158
2231
  }
2159
2232
  // Only now that the machine-wide switch is on disk (D-INIT-DRAIN-AFTER-SWITCH).
2160
- await drainDisabledFeatureQueues({
2233
+ const refusedDrains = await drainDisabledFeatureQueues({
2161
2234
  gitRoot,
2162
2235
  ledgerRoot: learningEnabled || gitRoot === null ? null : await getLedgerRoot(),
2163
2236
  memoryEnabled,
2164
2237
  learningEnabled,
2165
2238
  manifestWritten: trackerLifecycle.manifestWritten,
2166
2239
  });
2240
+ for (const line of refusedDrains)
2241
+ p.log.warn(line);
2167
2242
  // The hooks' per-directory log folders, capped (D-LOG-DIR-CAP): one pass
2168
2243
  // clears every folder it scans beyond the cap; only a backlog beyond the
2169
2244
  // scan bound (MAX_LOG_DIRS_SCANNED, 100,000) waits for the next init.