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
@@ -0,0 +1,104 @@
1
+ import { promises as fs } from 'fs';
2
+ import * as path from 'path';
3
+ /**
4
+ * @file hook-log-dirs.ts
5
+ *
6
+ * The cap on the hooks' per-directory log folders.
7
+ *
8
+ * D-LOG-DIR-CAP: every hook logs under `~/.devflow/logs/<slug>/`, one directory
9
+ * per working directory a session ran in (`log-paths`' devflow_log_dir). Left
10
+ * uncapped they pile up — a machine was found holding 31k of them, most left by
11
+ * test runs in throwaway temp directories — so `devflow init` keeps the
12
+ * {@link MAX_HOOK_LOG_DIRS} most recently written and removes the rest, oldest
13
+ * first. A folder's recency is the newest mtime among the folder and the log
14
+ * files in it: a log that is only ever appended to leaves the folder's own mtime
15
+ * where the file's creation put it, so the folder mtime alone would rank the
16
+ * busiest project as the oldest.
17
+ *
18
+ * Every pass is bounded — at most {@link MAX_LOG_DIRS_SCANNED} folders read and
19
+ * {@link MAX_LOG_DIRS_PRUNED_PER_RUN} removed. The two bounds are equal, so one
20
+ * init clears every folder it scanned beyond the cap: a 31k backlog costs that
21
+ * init about seven seconds (roughly 2.2 s per 10,000 removals), and only a
22
+ * backlog larger than the scan bound is finished by the next init. The pass is
23
+ * the one-time cleanup of an existing backlog and the standing cap alike.
24
+ * Only directories are touched: files at the logs root (`proxy.log`) and symbolic
25
+ * links are left alone.
26
+ */
27
+ /** How many hook log folders survive a prune — the most recently written ones. */
28
+ export const MAX_HOOK_LOG_DIRS = 200;
29
+ /** The most folders one prune reads; beyond it the rest wait for a later run. */
30
+ export const MAX_LOG_DIRS_SCANNED = 100_000;
31
+ /** The most folders one prune removes — every scanned folder, so one run clears what it read. */
32
+ export const MAX_LOG_DIRS_PRUNED_PER_RUN = MAX_LOG_DIRS_SCANNED;
33
+ /** The most entries read inside one folder to find its newest log. */
34
+ const MAX_FILES_READ_PER_DIR = 64;
35
+ /** Folders stat'ed or removed at once. */
36
+ const IO_CHUNK = 64;
37
+ /** The newest mtime among `dir` and the entries in it; -Infinity when unreadable. */
38
+ async function recencyOf(dir) {
39
+ let newest;
40
+ try {
41
+ newest = (await fs.lstat(dir)).mtimeMs;
42
+ }
43
+ catch {
44
+ return -Infinity;
45
+ }
46
+ let names;
47
+ try {
48
+ names = (await fs.readdir(dir)).slice(0, MAX_FILES_READ_PER_DIR);
49
+ }
50
+ catch {
51
+ return newest;
52
+ }
53
+ const times = await Promise.all(names.map(name => fs.lstat(path.join(dir, name)).then(st => st.mtimeMs, () => -Infinity)));
54
+ return Math.max(newest, ...times);
55
+ }
56
+ /** Apply `fn` to every item, `IO_CHUNK` at a time. Bounded by `items.length`. */
57
+ async function inChunks(items, fn) {
58
+ const out = [];
59
+ for (let i = 0; i < items.length; i += IO_CHUNK) {
60
+ out.push(...await Promise.all(items.slice(i, i + IO_CHUNK).map(fn)));
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * Remove the oldest hook log folders under `logsDir` beyond the cap
66
+ * (D-LOG-DIR-CAP). Never throws: a missing logs directory is nothing to do, and
67
+ * a folder that cannot be removed is counted as still over the cap.
68
+ */
69
+ export async function pruneHookLogDirs(logsDir, opts = {}) {
70
+ const keep = opts.keep ?? MAX_HOOK_LOG_DIRS;
71
+ const maxRemovals = opts.maxRemovals ?? MAX_LOG_DIRS_PRUNED_PER_RUN;
72
+ const maxScanned = opts.maxScanned ?? MAX_LOG_DIRS_SCANNED;
73
+ if (!path.isAbsolute(logsDir) || path.basename(logsDir) !== 'logs') {
74
+ return { ok: false, error: `not a devflow logs directory: ${logsDir}` };
75
+ }
76
+ if (keep < 0 || maxRemovals < 0 || maxScanned < 0) {
77
+ return { ok: false, error: 'prune bounds must be non-negative' };
78
+ }
79
+ let entries;
80
+ try {
81
+ entries = await fs.readdir(logsDir, { withFileTypes: true });
82
+ }
83
+ catch (err) {
84
+ if (err.code === 'ENOENT')
85
+ return { ok: true, value: { removed: 0, overCap: 0 } };
86
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
87
+ }
88
+ const dirs = entries
89
+ .filter(e => e.isDirectory())
90
+ .slice(0, maxScanned)
91
+ .map(e => path.join(logsDir, e.name));
92
+ if (dirs.length <= keep)
93
+ return { ok: true, value: { removed: 0, overCap: 0 } };
94
+ const recencies = await inChunks(dirs, recencyOf);
95
+ const oldestFirst = dirs
96
+ .map((dir, i) => ({ dir, recency: recencies[i] }))
97
+ .sort((a, b) => a.recency - b.recency);
98
+ const overCap = oldestFirst.slice(0, oldestFirst.length - keep);
99
+ const batch = overCap.slice(0, maxRemovals);
100
+ const outcomes = await inChunks(batch, ({ dir }) => fs.rm(dir, { recursive: true, force: true }).then(() => true, () => false));
101
+ const removed = outcomes.filter(Boolean).length;
102
+ return { ok: true, value: { removed, overCap: overCap.length - removed } };
103
+ }
104
+ //# sourceMappingURL=hook-log-dirs.js.map
@@ -57,13 +57,15 @@ function readConfigFile(filePath) {
57
57
  * Priority (highest wins): project config → global config → defaults.
58
58
  *
59
59
  * - Global: `~/.devflow/learning.json`
60
- * - Project: `<cwd>/.devflow/learning/learning.json`
60
+ * - Project: `<ledgerRoot>/.devflow/learning/learning.json` — pass the ledger root
61
+ * (getLedgerRoot), where session-start-context reads it and `devflow learning
62
+ * --configure` writes it (D-LEDGER-MAIN-WORKTREE).
61
63
  *
62
64
  * Invalid JSON in either file is silently ignored and treated as absent.
63
65
  */
64
- export function loadLearningTuningConfig(cwd) {
66
+ export function loadLearningTuningConfig(ledgerRoot) {
65
67
  const globalConfigPath = path.join(getDevFlowDirectory(), 'learning.json');
66
- const projectConfigPath = getLearningTuningConfigPath(cwd);
68
+ const projectConfigPath = getLearningTuningConfigPath(ledgerRoot);
67
69
  let config = { ...DEFAULTS };
68
70
  const globalJson = readConfigFile(globalConfigPath);
69
71
  if (globalJson !== null) {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @file ledger-root.ts
3
+ *
4
+ * Where the learning ledger lives for a working directory — the TypeScript twin of
5
+ * `df_resolve_roots`' DF_LEDGER_ROOT in src/assets/scripts/hooks/resolve-project-root.
6
+ *
7
+ * D-LEDGER-MAIN-WORKTREE: the ledger is one per REPOSITORY, not per checkout. In a
8
+ * linked worktree the hooks queue and render into the main worktree's
9
+ * `.devflow/learning/`, so `devflow learning` and the HUD must read and clear that
10
+ * same directory — resolving the worktree's own toplevel (getGitRoot) would show an
11
+ * empty ledger and drain a queue nothing writes to. The rule, from ONE git call:
12
+ *
13
+ * git rev-parse --path-format=absolute --show-toplevel --git-common-dir
14
+ *
15
+ * - exactly two absolute lines, the common dir ends in `/.git`, `<main>/.devflow`
16
+ * is a directory and `<main>` is not HOME (physical paths, like the hooks'
17
+ * df_is_project_root) → `<main>`;
18
+ * - two absolute lines otherwise → the toplevel;
19
+ * - git before 2.31 echoes `--path-format=absolute` back as its own line → the
20
+ * toplevel it printed after it (the hooks fall back to the toplevel too);
21
+ * - git failed (not a work tree) → null: the caller keeps its non-git fallback;
22
+ * - any other output → getGitRoot(), the hooks' df_resolve_root fallback.
23
+ *
24
+ * Memory is NOT resolved here: working memory stays per checkout (getGitRoot).
25
+ * Parity with the shell helper is pinned by tests/core/ledger-root.test.ts, which
26
+ * runs df_resolve_roots on the same fixtures.
27
+ */
28
+ import { execFile } from 'child_process';
29
+ import { promises as fs } from 'fs';
30
+ import * as os from 'os';
31
+ import * as path from 'path';
32
+ import { getGitRoot } from './git.js';
33
+ /** The one git call, as argv (no shell). */
34
+ const LEDGER_ROOT_GIT_ARGS = ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'];
35
+ /** What git before 2.31 prints first: the flag it does not know, echoed back. */
36
+ const PATH_FORMAT_ECHO = '--path-format=absolute';
37
+ /** Classify the git call's stdout. Pure. */
38
+ export function parseLedgerRootOutput(stdout) {
39
+ const lines = stdout.replace(/\n$/, '').split('\n');
40
+ if (lines.length === 2 && lines.every(l => path.isAbsolute(l))) {
41
+ return { kind: 'roots', toplevel: lines[0], commonDir: lines[1] };
42
+ }
43
+ if (lines.length === 3 && lines[0] === PATH_FORMAT_ECHO && path.isAbsolute(lines[1])) {
44
+ return { kind: 'toplevel', toplevel: lines[1] };
45
+ }
46
+ return { kind: 'unrecognised' };
47
+ }
48
+ /** stdout of the one git call from `cwd`, or null when git failed. */
49
+ function runRevParse(cwd, timeoutMs) {
50
+ return new Promise((resolve) => {
51
+ execFile('git', LEDGER_ROOT_GIT_ARGS, { cwd, timeout: timeoutMs, encoding: 'utf-8' }, (err, stdout) => {
52
+ resolve(err ? null : stdout);
53
+ });
54
+ });
55
+ }
56
+ /** A path's physical location, or null when it cannot be resolved. */
57
+ async function physical(target) {
58
+ try {
59
+ return await fs.realpath(target);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /**
66
+ * Whether the main worktree `main` holds the ledger: it has a `.devflow/` directory
67
+ * and is not HOME. An unresolvable `main` is refused (the ledger stays in this
68
+ * checkout); an unresolvable HOME cannot equal it, so it refuses nothing — both as
69
+ * df_is_project_root decides.
70
+ */
71
+ async function mainHoldsLedger(main, home) {
72
+ const stat = await fs.stat(path.join(main, '.devflow')).catch(() => null);
73
+ if (!stat?.isDirectory())
74
+ return false;
75
+ const [mainReal, homeReal] = await Promise.all([physical(main), physical(home)]);
76
+ return mainReal !== null && mainReal !== homeReal;
77
+ }
78
+ /**
79
+ * The learning ledger root for `cwd` — the main worktree or this checkout's
80
+ * toplevel — or null outside a git work tree.
81
+ */
82
+ export async function getLedgerRoot(cwd = process.cwd(), options = {}) {
83
+ const stdout = await runRevParse(cwd, options.timeoutMs ?? 0);
84
+ if (stdout === null)
85
+ return null;
86
+ const parsed = parseLedgerRootOutput(stdout);
87
+ switch (parsed.kind) {
88
+ case 'toplevel':
89
+ return parsed.toplevel;
90
+ case 'unrecognised':
91
+ return getGitRoot(cwd);
92
+ case 'roots': {
93
+ if (!parsed.commonDir.endsWith('/.git'))
94
+ return parsed.toplevel;
95
+ const main = parsed.commonDir.slice(0, -'/.git'.length);
96
+ if (main && await mainHoldsLedger(main, options.home ?? os.homedir()))
97
+ return main;
98
+ return parsed.toplevel;
99
+ }
100
+ }
101
+ }
102
+ //# sourceMappingURL=ledger-root.js.map
@@ -75,7 +75,6 @@ export async function readManifest(devflowDir) {
75
75
  const features = data.features;
76
76
  if (!data.version ||
77
77
  !Array.isArray(data.plugins) ||
78
- !data.scope ||
79
78
  typeof features !== 'object' ||
80
79
  features === null ||
81
80
  typeof features.ambient !== 'boolean' ||
@@ -84,7 +83,7 @@ export async function readManifest(devflowDir) {
84
83
  typeof data.updatedAt !== 'string') {
85
84
  return null;
86
85
  }
87
- // D-FEATURES-ABSENT-ON (a sub-decision of D-FEATURES-MACHINE-WIDE,
86
+ // D-FEATURES-ABSENT-ON (a sub-decision of D-FEATURES-NARROW-ONLY,
88
87
  // src/core/feature-switch.ts): knowledge and learning are machine-wide
89
88
  // switches, and every runtime gate — queue_read_gates in the hooks,
90
89
  // isMachineFeatureOn in the CLI, the knowledge write-back prose gate — reads
@@ -135,7 +134,8 @@ export async function readManifest(devflowDir) {
135
134
  const manifest = {
136
135
  version: data.version,
137
136
  plugins: data.plugins,
138
- scope: data.scope,
137
+ // D-MANIFEST-SCOPE-PINNED: the on-disk value is not consulted.
138
+ scope: 'user',
139
139
  knownPlugins,
140
140
  features: {
141
141
  ambient: features.ambient,
@@ -194,7 +194,9 @@ export async function readManifest(devflowDir) {
194
194
  export async function writeManifest(devflowDir, data) {
195
195
  await fs.mkdir(devflowDir, { recursive: true });
196
196
  const manifestPath = path.join(devflowDir, 'manifest.json');
197
- await writeFileAtomicExclusive(manifestPath, JSON.stringify(data, null, 2) + '\n');
197
+ // D-MANIFEST-SCOPE-PINNED: every write records 'user', whatever the caller holds.
198
+ const pinned = { ...data, scope: 'user' };
199
+ await writeFileAtomicExclusive(manifestPath, JSON.stringify(pinned, null, 2) + '\n');
198
200
  }
199
201
  /**
200
202
  * Update a single feature field in the manifest. No-op when no manifest exists.
@@ -306,11 +306,10 @@ export const PR_HOST_OPS = [
306
306
  * from the module that writes there: it is a fact about where PR mechanics live
307
307
  * in the reference tree, not something the registry can work out.
308
308
  *
309
- * Not to be confused with {@link PR_HOST_TRACKER_SUBDIR} (`tracker/github`),
310
- * which is the TRACKER directory every install carries because PR hosting is on
311
- * GitHub. This one is the provider-independent `pr/` directory itself — it is
312
- * under no provider, and every install carries it for the same reason: a jira or
313
- * linear user still opens pull requests.
309
+ * It is the provider-independent `pr/` directory, a sibling of the `tracker/`
310
+ * tree rather than a directory inside it: it is under no provider, and every
311
+ * install carries it because a jira or linear user still opens pull requests on
312
+ * GitHub.
314
313
  */
315
314
  export const PR_HOST_DESTINATION_ROOT = 'pr';
316
315
  /**
@@ -418,20 +417,6 @@ export const VARIANT_MODULES = [
418
417
  export const MCP_BACKED_PROVIDER_SUBDIRS = ['tracker/jira', 'tracker/linear'];
419
418
  /** The destination directory every tracker provider module lands under. */
420
419
  export const TRACKER_DESTINATION_ROOT = 'tracker';
421
- /**
422
- * The tracker destination every install carries, whatever the user selected.
423
- *
424
- * Not a default and not a fallback: PR hosting stays on GitHub under every
425
- * issue-tracker provider, so a jira or linear user still runs `gh pr` mechanics
426
- * and still needs the GitHub tree reachable. It is the FLOOR of
427
- * {@link installedReferenceManifest}'s union.
428
- *
429
- * Stated rather than derived, because the fact is about where pull requests
430
- * live, not about anything the registry knows. A derivation from "the one
431
- * CLI-backed module" would read as a rule and silently promote the next
432
- * CLI-backed provider into everyone's install.
433
- */
434
- export const PR_HOST_TRACKER_SUBDIR = `${TRACKER_DESTINATION_ROOT}/github`;
435
420
  /**
436
421
  * The provider-independent tool-call contract document.
437
422
  *
@@ -668,93 +653,44 @@ export function expandVariants(modules = resolveVariantModules()) {
668
653
  * build's own refusal sinks use (scripts/build-mds.ts).
669
654
  */
670
655
  export function generatedReferenceManifest() {
671
- const expanded = expandVariants();
672
- if (!expanded.ok) {
673
- throw new Error(`Reference module registry does not expand — ${JSON.stringify(expanded.error)}. ` +
674
- `VARIANT_MODULES in src/core/mds-variants.ts is invalid.`);
675
- }
676
- return expanded.value.map(pair => pair.relPath);
656
+ return expandedManifest(resolveVariantModules());
677
657
  }
678
658
  /**
679
- * The references ONE install carries, for one resolved tracker provider — the
680
- * narrower manifest the overlay converges to.
681
- *
682
- * D-INSTALL-SET: the BUILD emits every provider ({@link generatedReferenceManifest},
683
- * 42 files) because the tarball must be able to serve any selection without a
684
- * rebuild. An INSTALL carries `{github} ∪ {selected provider}`:
685
- *
686
- * - the GitHub tree is the FLOOR under every provider, not an optional extra.
687
- * PR hosting stays on GitHub whatever the issue tracker is, so those
688
- * mechanics stay reachable for a jira or linear user;
689
- * - the cross-cutting documents (`subdir: ''`) are provider-independent and
690
- * always land;
691
- * - the PR-host tree ({@link PR_HOST_DESTINATION_ROOT}) is provider-independent
692
- * for the same reason the GitHub tree is a floor — pull requests, PR reviews
693
- * and PR checks stay on GitHub under every issue tracker — but it sits under
694
- * no provider directory, so it is named here rather than reached through the
695
- * provider union;
696
- * - a provider directory the user did not select is 11 files nothing they can
697
- * reach ever loads (applies ADR-003 — ship the end state, not every state).
698
- *
699
- * `tracker/_mcp.md` rides the same gate its GENERATION does
700
- * ({@link MCP_BACKED_PROVIDER_SUBDIRS}): it is the transport contract for
701
- * providers reached by tool call, and GitHub's mechanics are `gh` commands. One
702
- * predicate, asked of the selection here and of the registry in
703
- * {@link mcpContractIsGenerated}, so opening the gate and shipping the provider
704
- * stay the same edit.
705
- *
706
- * Derived from the registry rather than a provider table: a provider registered
707
- * with a `tracker/{id}` subdir is installable by construction, and a literal
708
- * here would be a second roster to keep in step with VARIANT_MODULES.
659
+ * The references ONE install carries — the manifest the overlay converges to.
660
+ *
661
+ * D-INSTALL-ALL-PROVIDERS: an install carries exactly what the build emits
662
+ * ({@link generatedReferenceManifest}): every provider's tree, the PR-host tree,
663
+ * the cross-cutting documents and `tracker/_mcp.md`, whatever the machine selected.
664
+ * The provider is no longer a property of the install. A repository selects its
665
+ * own tracker in its committed `.devflow/project.json`, so one machine meets more
666
+ * than one provider and a Git spawn must find the mechanics of whichever one the
667
+ * repository resolves — an install scoped to the machine's selection would leave
668
+ * every such repository degraded until someone re-ran init for a provider that is
669
+ * not theirs. The trees are INERT until a spawn resolves a provider and names
670
+ * one of its files, so carrying all of them costs disk, never context.
671
+ *
672
+ * The overlay still PRUNES everything under its converged subtrees this manifest
673
+ * does not name, so a retired generated document leaves on the next install.
709
674
  *
710
675
  * Asserts rather than degrades on a registry that does not expand, exactly as
711
676
  * its sibling does (design review M3): the registry is a compile-time constant,
712
- * so a refusal is a programming error rather than an install-time degradation —
713
- * no caller could sensibly continue, and every caller would otherwise carry the
714
- * same impossible branch.
715
- *
716
- * @param opts.provider - The resolved tracker provider id, used as the
717
- * `tracker/{id}` sub-directory key.
718
- * @param opts.modules - Registry to expand (defaults to the shipped one).
719
- * Injectable so both the refusal arm and a provider set this build does not
720
- * produce are provable without editing the registry.
677
+ * so a refusal is a programming error rather than an install-time degradation.
678
+ *
679
+ * @param opts.modules - Registry to expand (defaults to the shipped one, with the
680
+ * gated contract module resolved). Injectable so the refusal arm is provable
681
+ * without editing the registry.
721
682
  */
722
- export function installedReferenceManifest(opts) {
723
- const modules = opts.modules ?? resolveVariantModules();
683
+ export function installedReferenceManifest(opts = {}) {
684
+ return expandedManifest(opts.modules ?? resolveVariantModules());
685
+ }
686
+ /** Every relPath a registry expands to, or the assertion both manifests share. */
687
+ function expandedManifest(modules) {
724
688
  const expanded = expandVariants(modules);
725
689
  if (!expanded.ok) {
726
690
  throw new Error(`Reference module registry does not expand — ${JSON.stringify(expanded.error)}. ` +
727
691
  `VARIANT_MODULES in src/core/mds-variants.ts is invalid.`);
728
692
  }
729
- const providerSubdir = `${TRACKER_DESTINATION_ROOT}/${opts.provider}`;
730
- const wanted = new Set(['', PR_HOST_DESTINATION_ROOT, PR_HOST_TRACKER_SUBDIR, providerSubdir]);
731
- const installed = expanded.value
732
- .filter(pair => wanted.has(subdirOfRelPath(pair.relPath)))
733
- .map(pair => pair.relPath);
734
- const gated = MCP_BACKED_PROVIDER_SUBDIRS;
735
- if (gated.includes(providerSubdir)) {
736
- const contract = contractRelPath(expanded.value);
737
- if (contract !== undefined)
738
- installed.push(contract);
739
- }
740
- return installed;
741
- }
742
- /** The directory part of a manifest-relative path; `''` for a file at the root. */
743
- function subdirOfRelPath(relPath) {
744
- const cut = relPath.lastIndexOf('/');
745
- return cut < 0 ? '' : relPath.slice(0, cut);
746
- }
747
- /**
748
- * The tool-call contract's emitted path, as this registry expands it — read from
749
- * the expansion rather than composed from the module's fields, so the name can
750
- * only ever be the one the build actually writes.
751
- *
752
- * Takes the already-expanded pairs rather than re-expanding: the caller has
753
- * already validated the same registry expands cleanly, so a second call would
754
- * only duplicate that work and reintroduce a refusal branch that can never fire.
755
- */
756
- function contractRelPath(pairs) {
757
- return pairs.find(pair => pair.module === MCP_CONTRACT_MODULE.source)?.relPath;
693
+ return expanded.value.map(pair => pair.relPath);
758
694
  }
759
695
  // ---------------------------------------------------------------------------
760
696
  // Section splitting — which slice of a module's compiled body belongs to which op
@@ -792,8 +728,9 @@ export const VARIANT_SECTION_MARKER_RE = /^<!-- op: (_?[a-z0-9][a-z0-9._-]{0,63}
792
728
  *
793
729
  * empty-section is the third arm, and it exists because the other two cannot see
794
730
  * it: an op with a marker and no body compiles cleanly and emits a zero-byte
795
- * reference, which reads downstream as "mechanics unavailable" with no build
796
- * signal at all (the GAP-44 shape — omission is caught, emptiness is not).
731
+ * reference, which the agent then loads as an operation with no instructions,
732
+ * with no build signal at all (the GAP-44 shape — omission is caught, emptiness
733
+ * is not).
797
734
  *
798
735
  * Total on success, and immutable: the caller gets back a readonly array of its
799
736
  * OWN records, in its own order, each carrying its section. Nothing is looked up
@@ -10,10 +10,10 @@
10
10
  */
11
11
  import { promises as fs } from 'fs';
12
12
  import * as path from 'path';
13
- import * as os from 'os';
14
13
  import { writeFileAtomicExclusive } from './fs-atomic.js';
15
14
  import { getMemoryDir } from './project-paths.js';
16
15
  import { LEGACY_AGENT_KEYS, canonicaliseAgentKeys, parseAgentMappingEnvelope } from './agent-models.js';
16
+ import { migrateLegacyTrackerConventions } from './tracker.js';
17
17
  /**
18
18
  * D31: Registry pattern over scattered `if (!applied.includes(...))` conditionals.
19
19
  *
@@ -94,23 +94,48 @@ export const MIGRATIONS = [
94
94
  return { infos, warnings };
95
95
  },
96
96
  },
97
+ {
98
+ id: 'tracker-conventions-per-provider-v1',
99
+ description: 'Move ~/.devflow/tracker.md to ~/.devflow/tracker/{provider}.md, the provider its frontmatter names',
100
+ scope: 'global',
101
+ // D-TRACKER-PER-PROVIDER-CONVENTIONS: conventions became per provider, so the
102
+ // single machine-wide file moves to the file of the provider it was learned
103
+ // for. The move and every refusal to move are migrateLegacyTrackerConventions'
104
+ // (src/core/tracker.ts); this entry maps its outcome onto the runner's
105
+ // contract. A file it leaves in place is REPORTED, once, and the migration is
106
+ // marked applied — the file is user content and nothing a re-run could do
107
+ // differently. An I/O failure THROWS instead: a throwing migration is not
108
+ // marked applied, so the runner retries it on the next `devflow init` rather
109
+ // than recording a move that never happened (the retry-forever path the
110
+ // KNOWN ISSUE above describes is bounded here by what can fail — a rename and
111
+ // an rm inside ~/.devflow).
112
+ async run(ctx) {
113
+ const outcome = await migrateLegacyTrackerConventions(ctx.devflowDir);
114
+ switch (outcome.kind) {
115
+ case 'none':
116
+ return { infos: [], warnings: [] };
117
+ case 'moved':
118
+ return { infos: [`Moved the ${outcome.provider} tracker conventions to ${outcome.to}`], warnings: [] };
119
+ case 'kept':
120
+ return { infos: [], warnings: [`tracker-conventions-per-provider-v1: ${outcome.reason}`] };
121
+ case 'failed':
122
+ throw new Error(outcome.error);
123
+ default: {
124
+ const _exhaustive = outcome;
125
+ return _exhaustive;
126
+ }
127
+ }
128
+ },
129
+ },
97
130
  ];
98
131
  const MIGRATIONS_FILE = 'migrations.json';
99
132
  /**
100
- * D30: State lives at `~/.devflow/migrations.json` (scope-independent) rather
101
- * than the install manifest because:
102
- *
103
- * - The install manifest is scope-specific: user-scope manifests live at
104
- * `~/.devflow/manifest.json` while local-scope manifests live at
105
- * `.devflow/manifest.json` inside the repo. A migration that runs on user-scope
106
- * init wouldn't be recorded in a local-scope manifest, so the migration would
107
- * re-run on the next local-scope init.
108
- * - Migration state is machine-wide: once a global migration runs on a machine it
109
- * should never re-run regardless of which project or scope triggered devflow init.
110
- * - `~/.devflow/migrations.json` is always writable (home-dir location), whereas
111
- * local-scope devflowDir may be inside a read-only checkout.
133
+ * D30: State lives at `~/.devflow/migrations.json` rather than in the install
134
+ * manifest because migration state is machine-wide: once a global migration runs
135
+ * on a machine it should never re-run regardless of which project triggered
136
+ * devflow init.
112
137
  *
113
- * @param devflowDir - absolute path to `~/.devflow` (always the home-dir location)
138
+ * @param devflowDir - absolute path to the resolved machine root (`~/.devflow`)
114
139
  */
115
140
  export async function readAppliedMigrations(devflowDir) {
116
141
  const filePath = path.join(devflowDir, MIGRATIONS_FILE);
@@ -227,7 +252,7 @@ async function runGlobalMigration(migration, ctx) {
227
252
  * additional projects) can retry the failed projects.
228
253
  *
229
254
  * D37: runPerProjectMigration is unreachable in production — MIGRATIONS holds
230
- * only a global migration (`canonicalise-agent-keys-v1`). The vacuous-truth
255
+ * only global migrations. The vacuous-truth
231
256
  * analysis is preserved for correctness: if a per-project migration is ever
232
257
  * added, an empty discoveredProjects list marks it applied (empty-discovery-marks-applied
233
258
  * intended); the applied-set write is skipped only when newlyApplied is empty,
@@ -267,19 +292,20 @@ async function runPerProjectMigration(migration, ctx, discoveredProjects) {
267
292
  * Run all unapplied migrations from MIGRATIONS.
268
293
  *
269
294
  * D32: Always-run-unapplied semantics (no fresh-vs-upgrade branch).
270
- * MIGRATIONS currently holds one global migration (`canonicalise-agent-keys-v1`);
271
- * on a fresh machine the loop executes once and writes migrations.json. On
272
- * subsequent runs the ID is already in the applied set and the loop is a no-op.
295
+ * MIGRATIONS currently holds only global migrations; on a fresh machine each
296
+ * executes once and the applied set is written to migrations.json. On subsequent
297
+ * runs every ID is already in the applied set and the loop is a no-op.
273
298
  *
274
- * @param ctx - devflowDir (memoryDir and projectRoot filled per-project)
299
+ * @param ctx - devflowDir, the resolved machine root (`~/.devflow`) that also holds
300
+ * migrations.json; memoryDir and projectRoot are filled per-project
275
301
  * @param discoveredProjects - absolute paths to discovered Claude-enabled project roots
276
302
  * @param registryOverride - override MIGRATIONS for testing (defaults to module-level MIGRATIONS)
277
303
  */
278
304
  export async function runMigrations(ctx, discoveredProjects, registryOverride) {
279
305
  const registry = registryOverride ?? MIGRATIONS;
280
- // Always read from home-dir devflow location so state is machine-wide
281
- const homeDevflowDir = path.join(os.homedir(), '.devflow');
282
- const appliedArray = await readAppliedMigrations(homeDevflowDir);
306
+ // D-ONE-HOME: state lives in the caller's resolved machine root, never a
307
+ // second, independently derived home path (D30: machine-wide state).
308
+ const appliedArray = await readAppliedMigrations(ctx.devflowDir);
283
309
  // Convert to Set once for O(1) lookups throughout the loop (issue #9)
284
310
  const applied = new Set(appliedArray);
285
311
  const newlyApplied = [];
@@ -323,7 +349,7 @@ export async function runMigrations(ctx, discoveredProjects, registryOverride) {
323
349
  }
324
350
  // Write state once at end, accumulating all newly applied IDs (issue #5 — O(N²) → O(1))
325
351
  if (newlyApplied.length > 0) {
326
- await writeAppliedMigrations(homeDevflowDir, [...appliedArray, ...newlyApplied]);
352
+ await writeAppliedMigrations(ctx.devflowDir, [...appliedArray, ...newlyApplied]);
327
353
  }
328
354
  return { newlyApplied, failures, infos, warnings };
329
355
  }
@@ -492,7 +492,8 @@ export const FEATURE_OWNED_RULES = ['compliance'];
492
492
  * command-less plugin, so a reference to one is a reference to something the
493
493
  * user may deliberately not have. The referencing prompts are written to probe
494
494
  * first and proceed without it — `/code-review` checks
495
- * `~/.claude/skills/devflow:{focus}/SKILL.md` before spawning that focus, the
495
+ * `skills/devflow:{focus}/SKILL.md` under Claude Code's directory (`CLAUDE_CONFIG_DIR`
496
+ * when absolute, else `~/.claude` — D-CLAUDE-DIR-PROMPTS) before spawning that focus, the
496
497
  * Review and Code agents continue when the Skill invocation fails. Putting them
497
498
  * in a `requires` would reinstate the universal install for exactly the eight
498
499
  * skills the selection prompt exists to let a user decline (AC-25).
@@ -832,9 +833,9 @@ export const WORKFLOW_ORDER = [
832
833
  ];
833
834
  /**
834
835
  * Plugin names excluded from the init multiselect buckets.
835
- * These are always installed regardless of user selection:
836
+ * init adds them itself rather than offering them:
836
837
  * - devflow-core-skills (always installed, non-optional)
837
- * - devflow-ambient (always installed, non-optional)
838
+ * - devflow-ambient (installed iff ambient mode is on — D-AMBIENT-FOLLOWS-SWITCH)
838
839
  *
839
840
  * Invariant: EXCLUDED ∩ optional === ∅ — no optional plugin may be excluded from
840
841
  * the init UI without a re-init carry mechanism to preserve it across full reinstalls.
@@ -847,7 +848,7 @@ export const EXCLUDED = new Set(['devflow-core-skills', 'devflow-ambient']);
847
848
  *
848
849
  * Excluded from both buckets (not selectable at init):
849
850
  * - devflow-core-skills (always installed)
850
- * - devflow-ambient (always installed)
851
+ * - devflow-ambient (installed iff ambient mode is on)
851
852
  *
852
853
  * Pure function — does not mutate the input array; preserves DEVFLOW_PLUGINS
853
854
  * ordering within each bucket; deterministic; no I/O.
@@ -139,21 +139,4 @@ export function getResearchDir(projectRoot) {
139
139
  export function getHandoffPath(projectRoot, branchSlug) {
140
140
  return path.join(projectRoot, '.devflow', 'docs', `handoff-${branchSlug}.md`);
141
141
  }
142
- // ---------------------------------------------------------------------------
143
- // Gitignore entries (returned as string arrays for updateGitignore callers)
144
- // ---------------------------------------------------------------------------
145
- /**
146
- * The canonical list of generic gitignore entries Devflow adds to a project's
147
- * root .gitignore for LOCAL-scope installs. Currently just `.claude/`.
148
- *
149
- * `.devflow/` is intentionally NOT here: it is managed by ensureDevflowGitignore
150
- * (TS) / ensure-root-gitignore (hook), which write the feature-knowledge carve-out
151
- * for ALL scopes. Adding a bare `.devflow/` here would append a wholesale-ignore
152
- * line after the carve-out and re-bury it (last match wins in .gitignore).
153
- *
154
- * CJS mirror: src/assets/scripts/hooks/lib/project-paths.cjs getGitignoreEntries().
155
- */
156
- export function getGitignoreEntries() {
157
- return ['.claude/'];
158
- }
159
142
  //# sourceMappingURL=project-paths.js.map
@@ -0,0 +1,25 @@
1
+ import { promises as fs } from 'fs';
2
+ import * as path from 'path';
3
+ /**
4
+ * Whether two paths name one location — realpaths where they exist, so a symlinked
5
+ * HOME, or macOS's /var → /private/var temp tree, still matches. The shell hooks make
6
+ * the same physical comparison in git-marker's df_is_project_root (D-HOOKS-GIT-ONLY).
7
+ */
8
+ export async function isSameLocation(a, b) {
9
+ const canonical = (target) => fs.realpath(target).catch(() => path.resolve(target));
10
+ const [left, right] = await Promise.all([canonical(a), canonical(b)]);
11
+ return left === right;
12
+ }
13
+ /**
14
+ * D-INIT-NOT-HOME: the CLI half of D-HOOKS-GIT-ONLY. A git repository rooted at
15
+ * HOME (a dotfiles repo) is not a project: its `<root>/.devflow` is the machine
16
+ * root ~/.devflow, and its `.gitignore` and `.claudeignore` are the user's own
17
+ * home-directory files. So no command writes per-repository files there — the
18
+ * same rule the hooks' df_is_project_root applies — and `roots` is returned
19
+ * without any entry that is HOME.
20
+ */
21
+ export async function withoutHomeRoots(roots, homeDir) {
22
+ const verdicts = await Promise.all(roots.map(root => isSameLocation(root, homeDir)));
23
+ return roots.filter((_, i) => !verdicts[i]);
24
+ }
25
+ //# sourceMappingURL=same-location.js.map