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
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Two halves, deliberately in one module:
5
5
  * - The DOMAIN half (registry, TrackerProvider, parseTrackerId,
6
- * normalizeTrackerFeature, path derivation) is pure — zero I/O.
6
+ * normalizeTrackerFeature, path derivation, the frontmatter parser) is
7
+ * pure — zero I/O.
7
8
  * - The LIFECYCLE half (rearmTrackerInference, applyTrackerSentinel,
8
- * renameStaleTrackerConventions) owns the three ~/.devflow tracker files.
9
+ * migrateLegacyTrackerConventions) owns the ~/.devflow tracker files.
9
10
  * It sits here rather than in a target adapter because ~/.devflow is
10
11
  * devflow-global, not Claude-Code-specific — the same reason manifest.ts's
11
12
  * read/write live in src/core/ (applies ADR-013).
@@ -14,15 +15,16 @@
14
15
  * fallible path returns a Result, so callers own their own error rendering
15
16
  * and a try/finally in a caller is never skipped.
16
17
  *
17
- * D-TRACKER-OWNER [DR-22][DR-10]: the attempt counter and the presence sentinel
18
- * have exactly ONE owner each — `rearmTrackerInference` and
18
+ * D-TRACKER-OWNER [DR-22][DR-10]: the attempt counters and the machine provider
19
+ * sentinel have exactly ONE owner each — `rearmTrackerInference` and
19
20
  * `applyTrackerSentinel` — and every command that touches one goes through it.
20
21
  * The sentinel is converged by `devflow init` and `devflow tracker --set`; the
21
- * counter is re-armed by those two and by `devflow tracker --status`, which is
22
- * the command a capped user reaches for (D-F). A bare "also delete this file"
23
- * appended to an eleven-row edit list in a 2,100-line init.ts is the same
24
- * policy expressed twice with no owner; these functions are the owner. Never
25
- * inline an `fs.rm` at a call site.
22
+ * counters are re-armed by those two and by `devflow tracker --status`, which
23
+ * is the command a capped user reaches for (D-F). The conventions files are the
24
+ * Tracker agent's to write and the user's to edit: no command moves, renames or
25
+ * deletes one, and the single legacy file is moved once, by the migration that
26
+ * calls `migrateLegacyTrackerConventions`. Never inline an `fs.rm` at a call
27
+ * site.
26
28
  */
27
29
  import { promises as fs } from 'fs';
28
30
  import * as path from 'path';
@@ -57,42 +59,49 @@ export const TRACKER_PROVIDER_IDS = TRACKER_PROVIDERS.map(p => p.id);
57
59
  * Existing installs and every GitHub user land here, silently.
58
60
  */
59
61
  export const DEFAULT_TRACKER_PROVIDER = 'github';
60
- /**
61
- * The manifest key path, as ONE shared constant.
62
- *
63
- * Both readers must agree on this literal: the TypeScript reader
64
- * (`readManifest().features.tracker.provider`) and the shell reader
65
- * (`json_field_file "$devflowDir/manifest.json" "features.tracker.provider"`,
66
- * whose jq and node backends both split the dotted path and walk it). A second
67
- * spelling in a shell script is exactly the drift this constant prevents.
68
- */
69
- export const TRACKER_PROVIDER_KEY_PATH = 'features.tracker.provider';
70
62
  // ---------------------------------------------------------------------------
71
63
  // Artifact basenames — one spelling for every TypeScript reader
72
64
  //
73
65
  // NOT the only spelling in the repository, and a rename that assumes it is will
74
- // miss three places these names are hardcoded (PF-013): the SessionStart hook's
66
+ // miss the places these names are hardcoded (PF-013): the SessionStart hook's
75
67
  // Section 3 (shell) and the Tracker agent's prompt (prose), neither of which can
76
- // import from here, and uninstall.ts's install-artifact list, which spells the
77
- // fixed ~/.devflow entries as literals the way its siblings do. Each is
78
- // cross-pinned against these constants by tests — shell-hooks, tracker-agent,
79
- // uninstall-logic and core/tracker — so the spellings cannot drift silently, but
80
- // they do have to move together.
68
+ // import from here. Each is cross-pinned against these constants by tests —
69
+ // shell-hooks-tracker, tracker-agent, uninstall-logic and core/tracker — so the
70
+ // spellings cannot drift silently, but they do have to move together.
81
71
  //
82
- // Two sets are the exception, and deliberately so, because neither is a fixed
83
- // list uninstall could keep in step by hand. The conventions backups are
84
- // one-per-provider, so uninstall imports TRACKER_CONVENTIONS_BACKUP_NAMES rather
85
- // than listing them: a literal list would fall behind the registry the day a
86
- // fourth provider lands. The staging files are one-per-invocation under a mktemp
87
- // name, so uninstall imports TRACKER_STAGED_PREFIX and resolves it against disk.
88
- // Either spelled by hand leaves a file behind that no uninstall list accounts
89
- // for, in a directory the run reports as swept.
72
+ // Two families are one-per-provider and are DERIVED from the registry rather
73
+ // than spelled out — the conventions files under TRACKER_CONVENTIONS_DIR and the
74
+ // attempt counters (TRACKER_ATTEMPTS_NAMES) — so a fourth provider is covered the
75
+ // day it joins the registry. The staging files are one-per-invocation under a
76
+ // mktemp name, so uninstall imports TRACKER_STAGED_PREFIX and resolves it against
77
+ // disk.
90
78
  // ---------------------------------------------------------------------------
91
- /** `~/.devflow/tracker.md` — the inferred conventions file (USER CONTENT on uninstall). */
92
- export const TRACKER_CONVENTIONS_FILE = 'tracker.md';
93
- /** `~/.devflow/.tracker.attempts` — inference attempt counter (install artifact). */
94
- export const TRACKER_ATTEMPTS_FILE = '.tracker.attempts';
95
- /** `~/.devflow/.tracker.enabled` — zero-byte presence sentinel (install artifact). */
79
+ /**
80
+ * `~/.devflow/tracker/` — one inferred conventions file per provider
81
+ * (USER CONTENT on uninstall).
82
+ *
83
+ * D-TRACKER-PER-PROVIDER-CONVENTIONS: conventions are learned per PROVIDER, not
84
+ * per machine. A repository may select its own tracker in its committed
85
+ * `.devflow/project.json`, so one machine can meet more than one provider, and a
86
+ * single machine-wide file would be silently authoritative for whichever provider
87
+ * did not write it. One file per provider means a provider change moves nothing
88
+ * aside: the other provider's conventions stay where they are, correct for the
89
+ * repositories that use it.
90
+ */
91
+ export const TRACKER_CONVENTIONS_DIR = 'tracker';
92
+ /**
93
+ * `~/.devflow/tracker.md` — the single machine-wide conventions file releases
94
+ * before per-provider conventions wrote. The `tracker-conventions-per-provider-v1`
95
+ * migration moves it to its provider's file; one it cannot place (no provider in
96
+ * its frontmatter, or that provider's file already exists) stays, as USER CONTENT.
97
+ */
98
+ export const TRACKER_LEGACY_CONVENTIONS_FILE = 'tracker.md';
99
+ /** `~/.devflow/.tracker.attempts` — the single counter those releases kept; the migration removes it. */
100
+ export const TRACKER_LEGACY_ATTEMPTS_FILE = '.tracker.attempts';
101
+ /**
102
+ * `~/.devflow/.tracker.enabled` — the machine provider sentinel (install artifact).
103
+ * Holds the provider NAME on one line; absent for github (see applyTrackerSentinel).
104
+ */
96
105
  export const TRACKER_ENABLED_FILE = '.tracker.enabled';
97
106
  /** `~/.devflow/.tracker.processing` — the Tracker agent's atomic claim (install artifact). */
98
107
  export const TRACKER_CLAIM_FILE = '.tracker.processing';
@@ -114,6 +123,21 @@ export const TRACKER_CLAIM_FILE = '.tracker.processing';
114
123
  * spellings together.
115
124
  */
116
125
  export const TRACKER_STAGED_PREFIX = '.tracker-staged.';
126
+ /**
127
+ * `.tracker.{provider}.attempts` — one provider's inference attempt counter
128
+ * (install artifact).
129
+ *
130
+ * Per provider, so one provider whose tracker connection is broken spends only
131
+ * its own attempts: a machine that meets jira in one repository and linear in
132
+ * another must not have the broken one cap the working one. The claim file stays
133
+ * global — one Tracker agent runs at a time on a machine, whichever provider it
134
+ * is learning.
135
+ */
136
+ export function trackerAttemptsName(provider) {
137
+ return `.tracker.${provider}.attempts`;
138
+ }
139
+ /** Every provider's attempt-counter basename, in registry order — what a re-arm clears and uninstall removes. */
140
+ export const TRACKER_ATTEMPTS_NAMES = TRACKER_PROVIDER_IDS.map(id => trackerAttemptsName(id));
117
141
  /**
118
142
  * How many background inference attempts a machine gets before the SessionStart
119
143
  * hook stops emitting the setup directive.
@@ -165,7 +189,7 @@ export function isTrackerProvider(value) {
165
189
  * Takes `unknown`, and a non-string renders as its TYPE: the module's
166
190
  * never-throws contract (PF-014) has to hold for what reaches this sink, not
167
191
  * only for what the signature says does — `devflow tracker --status` reads
168
- * `tracker.md`'s hand-editable frontmatter, and a caller-side guard is one edit
192
+ * a conventions file's hand-editable frontmatter, and a caller-side guard is one edit
169
193
  * from being gone. Naming the type also keeps the render total, where `String()`
170
194
  * would hand control to a caller-supplied `toString` — itself both a throw path
171
195
  * and an echo path this function exists to close.
@@ -240,68 +264,110 @@ export function normalizeTrackerFeature(raw) {
240
264
  // ---------------------------------------------------------------------------
241
265
  // Exported functions — path derivation (pure)
242
266
  // ---------------------------------------------------------------------------
243
- /** `{devflowDir}/tracker.md` — the inferred conventions file. */
244
- export function trackerConventionsPath(devflowDir) {
245
- return path.join(devflowDir, TRACKER_CONVENTIONS_FILE);
267
+ /** `{devflowDir}/tracker` — the directory holding one conventions file per provider. */
268
+ export function trackerConventionsDir(devflowDir) {
269
+ return path.join(devflowDir, TRACKER_CONVENTIONS_DIR);
270
+ }
271
+ /**
272
+ * `{devflowDir}/tracker/{provider}.md` — one provider's inferred conventions.
273
+ *
274
+ * The provider is a registry id (the type admits nothing else), so the segment it
275
+ * contributes is one of three fixed words, never user input joined into a path.
276
+ */
277
+ export function trackerConventionsPath(devflowDir, provider) {
278
+ return path.join(trackerConventionsDir(devflowDir), `${provider}.md`);
246
279
  }
247
- /** `{devflowDir}/.tracker.attempts` — the inference attempt counter. */
248
- export function trackerAttemptsPath(devflowDir) {
249
- return path.join(devflowDir, TRACKER_ATTEMPTS_FILE);
280
+ /** `{devflowDir}/.tracker.{provider}.attempts` — one provider's inference attempt counter. */
281
+ export function trackerAttemptsPath(devflowDir, provider) {
282
+ return path.join(devflowDir, trackerAttemptsName(provider));
250
283
  }
251
- /** `{devflowDir}/.tracker.enabled` — the zero-byte presence sentinel. */
284
+ /** `{devflowDir}/.tracker.enabled` — the machine provider sentinel. */
252
285
  export function trackerEnabledSentinelPath(devflowDir) {
253
286
  return path.join(devflowDir, TRACKER_ENABLED_FILE);
254
287
  }
288
+ /** How many leading lines of a conventions file are scanned for frontmatter. */
289
+ const FRONTMATTER_SCAN_LINES = 40;
255
290
  /**
256
- * `tracker.md.{provider}.bak` — the basename a stale conventions file lands under.
291
+ * Parse the leading frontmatter of a conventions file's head.
257
292
  *
258
- * EXPORTED deliberately, not by oversight, and so is
259
- * {@link trackerConventionsBackupPath} below. The `.bak` filename is a
260
- * user-visible contract: `renameStaleTrackerConventions` writes it, `devflow
261
- * tracker --status` and the uninstall user-content list both reason about it, and
262
- * tests/core/tracker.test.ts pins it. Un-exporting would force the pin to
263
- * re-derive the name from a template beside the real one, which is the
264
- * shadow-reimplementation a guard is worth nothing without (PF-018).
293
+ * The one parser, shared by `devflow tracker --status` and the
294
+ * per-provider migration, so the two can never disagree about which provider a
295
+ * file names. Scans at most {@link FRONTMATTER_SCAN_LINES} lines; the first
296
+ * occurrence of each key wins.
265
297
  */
266
- export function trackerConventionsBackupName(previous) {
267
- return `${TRACKER_CONVENTIONS_FILE}.${previous}.bak`;
268
- }
269
- /** `{devflowDir}/tracker.md.{provider}.bak` — where a stale conventions file lands. */
270
- export function trackerConventionsBackupPath(devflowDir, previous) {
271
- return path.join(devflowDir, trackerConventionsBackupName(previous));
298
+ export function parseTrackerFrontmatter(head) {
299
+ const lines = head.split('\n', FRONTMATTER_SCAN_LINES);
300
+ if (lines[0]?.trim() !== '---')
301
+ return { hasFrontmatter: false };
302
+ let provider;
303
+ let inferredFrom;
304
+ for (const line of lines.slice(1)) {
305
+ if (line.trim() === '---')
306
+ break;
307
+ const match = /^([A-Za-z-]+):\s*(.*)$/.exec(line);
308
+ if (match === null)
309
+ continue;
310
+ if (match[1] === 'provider' && provider === undefined)
311
+ provider = match[2].trim();
312
+ if (match[1] === 'inferred-from' && inferredFrom === undefined)
313
+ inferredFrom = match[2].trim();
314
+ }
315
+ return { hasFrontmatter: true, provider, inferredFrom };
272
316
  }
273
317
  /**
274
- * Every backup basename a provider change can leave behind, in registry order.
275
- *
276
- * D-TRACKER-BACKUP-SET [OD-15]: a backup holds exactly what `tracker.md` held —
277
- * the user's inferred site and project key — so uninstall classifies the whole
278
- * set as USER CONTENT beside `tracker.md`, never as install artifacts (@D8 in
279
- * src/cli/commands/uninstall.ts keeps the two lists disjoint).
318
+ * How many leading BYTES of a conventions file any reader takes — the bound the
319
+ * Tracker agent writes to and the Git agent loads. A line cap alone bounds the
320
+ * SCAN, not the read: the file's size is not devflow's to assume (avoids PF-023:
321
+ * a bound is only real at the sink).
322
+ */
323
+ export const TRACKER_CONVENTIONS_READ_BYTES = 8000;
324
+ /**
325
+ * Read at most `limit` bytes from the head of a REGULAR file.
280
326
  *
281
- * Derived from `TRACKER_PROVIDER_IDS` rather than spelled out, so a fourth
282
- * provider is classified the moment it joins the registry instead of leaving a
283
- * file that survives an uninstall reporting `~/.devflow` swept. `github` is in
284
- * the set: a hand-written `tracker.md` is moved aside on a github→jira change
285
- * too, and `renameStaleTrackerConventions` takes `previous` from the whole
286
- * domain.
327
+ * `undefined` for an absent, unreadable or non-regular path — a FIFO or a device
328
+ * is refused before it is opened, so a hostile entry can never block a read.
329
+ * Follows a symlink to read what it names (a reader cares what the conventions
330
+ * SAY); it never moves or writes through one. Never throws (PF-014).
287
331
  */
288
- export const TRACKER_CONVENTIONS_BACKUP_NAMES = TRACKER_PROVIDER_IDS.map(id => trackerConventionsBackupName(id));
332
+ export async function readBoundedHead(filePath, limit) {
333
+ let handle;
334
+ try {
335
+ if (!(await fs.stat(filePath)).isFile())
336
+ return undefined;
337
+ handle = await fs.open(filePath, 'r');
338
+ const buffer = Buffer.alloc(limit);
339
+ const { bytesRead } = await handle.read(buffer, 0, limit, 0);
340
+ return buffer.subarray(0, bytesRead).toString('utf-8');
341
+ }
342
+ catch {
343
+ return undefined;
344
+ }
345
+ finally {
346
+ await handle?.close().catch(() => undefined);
347
+ }
348
+ }
289
349
  // ---------------------------------------------------------------------------
290
350
  // Exported functions — file lifecycle
291
351
  // ---------------------------------------------------------------------------
292
352
  /**
293
- * Re-arm background convention inference by removing the attempt counter.
353
+ * Re-arm background convention inference by removing every provider's attempt
354
+ * counter.
294
355
  *
295
356
  * The documented re-arm path (OD-14 / D-F): `devflow init` AND
296
- * `devflow tracker --set/--status` both re-arm, so a user whose tracker MCP
297
- * server was broken for five sessions is not stuck at the cap forever.
357
+ * `devflow tracker --set/--status` all re-arm, so a user whose tracker server
358
+ * was broken for five sessions is not stuck at the cap forever. Every provider's
359
+ * counter, not only the machine's: a repository's committed project.json can
360
+ * select a provider the machine never did, and its counter is the one a user in
361
+ * that repository is capped on.
298
362
  *
299
- * Idempotent when the counter is absent; never throws (PF-014). `fs.rm` with
363
+ * Idempotent when a counter is absent; never throws (PF-014). `fs.rm` with
300
364
  * `force` treats an absent file — and an absent parent directory — as success.
301
365
  */
302
366
  export async function rearmTrackerInference(devflowDir) {
303
367
  try {
304
- await fs.rm(trackerAttemptsPath(devflowDir), { force: true });
368
+ for (const name of TRACKER_ATTEMPTS_NAMES) {
369
+ await fs.rm(path.join(devflowDir, name), { force: true });
370
+ }
305
371
  return { ok: true, value: undefined };
306
372
  }
307
373
  catch (err) {
@@ -309,13 +375,15 @@ export async function rearmTrackerInference(devflowDir) {
309
375
  }
310
376
  }
311
377
  /**
312
- * Converge the `.tracker.enabled` presence sentinel to the resolved provider.
378
+ * Converge the `.tracker.enabled` sentinel to the machine provider.
313
379
  *
314
- * [DR-10] Written (zero bytes) whenever the resolved provider is NOT github, and
315
- * removed when it is. This is the SessionStart hook's cheap gate: without it,
316
- * every GitHub user's every session would fall through to a manifest read — one
317
- * `jq` (or `node`) fork per session, forever, for 100% of users on the default
318
- * provider. With it the GitHub path is one `stat` and zero forks.
380
+ * [DR-10] Holds the provider NAME, one line, whenever the machine provider is NOT
381
+ * github, and is removed when it is. The SessionStart hook reads it with the
382
+ * `read` builtin, so the machine provider costs no fork: a GitHub user pays one
383
+ * `stat`, and a jira machine whose conventions are already learned pays a stat,
384
+ * a builtin read and a second stat — zero forks either way. A name rather than a
385
+ * bare presence marker, so the hook never has to open the manifest to learn which
386
+ * provider it is gating.
319
387
  *
320
388
  * Converges unconditionally in both directions (avoids PF-015): a provider
321
389
  * flipped back to github removes the sentinel in the same call shape that wrote
@@ -329,7 +397,7 @@ export async function applyTrackerSentinel(devflowDir, provider) {
329
397
  }
330
398
  else {
331
399
  await fs.mkdir(devflowDir, { recursive: true });
332
- await fs.writeFile(sentinel, '', 'utf-8');
400
+ await fs.writeFile(sentinel, `${provider}\n`, 'utf-8');
333
401
  }
334
402
  return { ok: true, value: undefined };
335
403
  }
@@ -338,70 +406,89 @@ export async function applyTrackerSentinel(devflowDir, provider) {
338
406
  }
339
407
  }
340
408
  /**
341
- * Move a now-stale `~/.devflow/tracker.md` aside when the provider changes.
409
+ * Move the pre-per-provider `~/.devflow/tracker.md` to the provider file its
410
+ * frontmatter names (D-TRACKER-PER-PROVIDER-CONVENTIONS), once.
342
411
  *
343
- * The writer's repair. A conventions file inferred for one
344
- * provider is silently authoritative for the next one unless it is moved aside,
345
- * and the reader half (the provider-mismatch guard) then has nothing to disagree
346
- * with. Landing it at `tracker.md.{old}.bak` keeps the user's inferred content
347
- * recoverable while the next session re-arms inference for the new provider.
412
+ * - no legacy file → `none`
413
+ * - frontmatter names a registry provider,
414
+ * and that provider's file does not exist → `moved`, by `rename(2)`
415
+ * - no provider it can name, or the target
416
+ * already exists → `kept`: the file stays where it is,
417
+ * user content, with one reason
418
+ * - any I/O failure → `failed`
348
419
  *
349
- * Every step REPORTS: `devflow init` must never abort on a feature-state change
350
- * (PF-009's isolation posture), so both callers render a warning and carry on.
420
+ * `rename(2)` moves a SYMLINK itself, never the file it points at, so a
421
+ * conventions file kept in a dotfiles repository stays there and only the link
422
+ * moves. A link's target is resolved relative to the link's directory, so a
423
+ * RELATIVE link would resolve from one level deeper and name a different path —
424
+ * the conventions would silently vanish behind a dangling link that also blocks
425
+ * the Tracker agent's create-exclusive write. A relative link is therefore `kept`,
426
+ * with its reason; an absolute one moves unaffected. rename also replaces an
427
+ * existing destination without a word, which is why the target is probed first
428
+ * and a present one is never overwritten: that file holds conventions a user may
429
+ * have corrected by hand, and it is the provider's own.
351
430
  *
352
- * D-TRACKER-BACKUP-EXCLUSIVE [OD-15]: the move is `link` then `unlink`, never
353
- * `rename`. `rename(2)` replaces an existing destination without a word, so
354
- * jira→github→jira→github destroyed the first `tracker.md.jira.bak` while init
355
- * printed a line that reads as preservation — and a `.bak` holds exactly what
356
- * `tracker.md` holds, which is the hand-correctable content uninstall classifies
357
- * as user content. `link(2)` fails with EEXIST instead, so a second transition
358
- * for one provider keeps BOTH copies and says which one blocked the move; the
359
- * user resolves it by moving one aside, and the next run completes the change.
360
- * Numbering the backups was the alternative and was rejected: it accumulates
361
- * without bound and puts names in `~/.devflow` that
362
- * `TRACKER_CONVENTIONS_BACKUP_NAMES` cannot enumerate, leaving files no uninstall
363
- * list accounts for. Hard links in this directory are already load-bearing — the
364
- * Tracker agent places `tracker.md` itself with `ln` for the same
365
- * create-exclusive property.
431
+ * D-TRACKER-MIGRATE-WINDOW: the probe and the rename are two calls, so a Tracker
432
+ * agent that writes the same provider's file between them is replaced by the
433
+ * legacy one. Accepted: the window is one syscall wide, only `devflow init` opens
434
+ * it, and both files hold that provider's conventions. link-then-unlink would
435
+ * close it but follows a symlink on macOS, fails where hard links are
436
+ * unsupported, and can leave both names behind.
366
437
  *
367
- * A provider change with no file on disk, and an unchanged provider, are both
368
- * `{kind:'none'}` — a transition is a change plus a file.
438
+ * The legacy single attempt counter is removed on every run that reaches the end:
439
+ * it carries no user content, the per-provider counters replace it, and a file
440
+ * nothing reads is one no uninstall list would account for.
369
441
  */
370
- export async function renameStaleTrackerConventions(devflowDir, previous, resolved) {
371
- if (previous === undefined || previous === resolved)
372
- return { kind: 'none' };
373
- const from = trackerConventionsPath(devflowDir);
374
- const to = trackerConventionsBackupPath(devflowDir, previous);
442
+ export async function migrateLegacyTrackerConventions(devflowDir) {
443
+ const from = path.join(devflowDir, TRACKER_LEGACY_CONVENTIONS_FILE);
375
444
  try {
376
- await fs.link(from, to);
377
- }
378
- catch (err) {
379
- switch (errnoCode(err)) {
380
- // Nothing to move aside — the common case on a provider change with no
381
- // prior inference run.
382
- case 'ENOENT':
445
+ await fs.rm(path.join(devflowDir, TRACKER_LEGACY_ATTEMPTS_FILE), { force: true });
446
+ let isLink;
447
+ try {
448
+ isLink = (await fs.lstat(from)).isSymbolicLink();
449
+ }
450
+ catch (err) {
451
+ if (errnoCode(err) === 'ENOENT')
383
452
  return { kind: 'none' };
384
- case 'EEXIST':
385
- return {
386
- kind: 'failed',
387
- error: `Kept the existing ${to} — moving ${from} aside would have destroyed it. ` +
388
- `Move or delete one of the two, then re-run to finish the provider change.`,
389
- };
390
- default:
391
- return { kind: 'failed', error: `Could not move the stale tracker.md aside: ${errorMessage(err)}` };
453
+ throw err;
392
454
  }
393
- }
394
- try {
395
- await fs.unlink(from);
455
+ if (isLink && !path.isAbsolute(await fs.readlink(from))) {
456
+ return {
457
+ kind: 'kept',
458
+ reason: `${from} is a symbolic link with a relative target, which would no longer resolve from ` +
459
+ `${trackerConventionsDir(devflowDir)}, so it was left in place — re-create it there by hand.`,
460
+ };
461
+ }
462
+ const head = await readBoundedHead(from, TRACKER_CONVENTIONS_READ_BYTES);
463
+ if (head === undefined) {
464
+ return { kind: 'kept', reason: `${from} is not a readable regular file, so it was left in place.` };
465
+ }
466
+ const named = parseTrackerFrontmatter(head).provider;
467
+ if (!isTrackerProvider(named)) {
468
+ return {
469
+ kind: 'kept',
470
+ reason: `${from} names no tracker provider in its frontmatter, so it was left in place — ` +
471
+ `move it to ${trackerConventionsDir(devflowDir)}/{provider}.md by hand if it is still wanted.`,
472
+ };
473
+ }
474
+ const to = trackerConventionsPath(devflowDir, named);
475
+ try {
476
+ await fs.lstat(to);
477
+ return {
478
+ kind: 'kept',
479
+ reason: `${from} was left in place — ${to} already exists and is never overwritten.`,
480
+ };
481
+ }
482
+ catch (err) {
483
+ if (errnoCode(err) !== 'ENOENT')
484
+ throw err;
485
+ }
486
+ await fs.mkdir(trackerConventionsDir(devflowDir), { recursive: true });
487
+ await fs.rename(from, to);
488
+ return { kind: 'moved', from, to, provider: named };
396
489
  }
397
490
  catch (err) {
398
- // The backup exists and holds the content; only the stale name is still
399
- // there, so the reader's mismatch guard still fires and nothing was lost.
400
- return {
401
- kind: 'failed',
402
- error: `Copied the stale conventions to ${to} but could not remove ${from}: ${errorMessage(err)}`,
403
- };
491
+ return { kind: 'failed', error: `Could not move ${from} to its provider's file: ${errorMessage(err)}` };
404
492
  }
405
- return { kind: 'renamed', from, to, previous };
406
493
  }
407
494
  //# sourceMappingURL=tracker.js.map
@@ -2,14 +2,26 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { dim } from '../colors.js';
5
+ /**
6
+ * The Claude Code directory: an absolute `CLAUDE_CONFIG_DIR`, else `~/.claude` —
7
+ * the rule `getClaudeDirectory()` applies (D-CLAUDE-CONFIG-DIR in
8
+ * src/targets/claude-code/claude-paths.ts), restated here so the HUD's copied
9
+ * import closure stays within src/hud and src/core. A relative value is ignored,
10
+ * not resolved against the session cwd, so the HUD counts what devflow installed.
11
+ */
12
+ function userClaudeDir() {
13
+ const configured = process.env.CLAUDE_CONFIG_DIR;
14
+ if (configured !== undefined && configured !== '' && path.isAbsolute(configured))
15
+ return configured;
16
+ return path.join(process.env.HOME || homedir(), '.claude');
17
+ }
5
18
  function countClaudeMdFiles(cwd) {
6
19
  let count = 0;
7
20
  // Check project CLAUDE.md
8
21
  if (fs.existsSync(path.join(cwd, 'CLAUDE.md')))
9
22
  count++;
10
23
  // Check user CLAUDE.md
11
- const claudeDir = process.env.CLAUDE_CONFIG_DIR ||
12
- path.join(process.env.HOME || homedir(), '.claude');
24
+ const claudeDir = userClaudeDir();
13
25
  if (fs.existsSync(path.join(claudeDir, 'CLAUDE.md')))
14
26
  count++;
15
27
  return count;
@@ -39,8 +51,7 @@ function countFromSettings(settingsPath) {
39
51
  * Exported for use by the main HUD entry point.
40
52
  */
41
53
  export function gatherConfigCounts(cwd) {
42
- const claudeDir = process.env.CLAUDE_CONFIG_DIR ||
43
- path.join(process.env.HOME || homedir(), '.claude');
54
+ const claudeDir = userClaudeDir();
44
55
  const claudeMdFiles = countClaudeMdFiles(cwd);
45
56
  // Count rules (.md/.mdc files in .claude/rules)
46
57
  let rules = 0;
@@ -1,6 +1,9 @@
1
1
  import * as fs from 'node:fs';
2
2
  import { dim } from '../colors.js';
3
3
  import { getDecisionsLedgerPath } from '../../core/project-paths.js';
4
+ import { getLedgerRoot } from '../../core/ledger-root.js';
5
+ /** The HUD's per-git-command budget (src/hud/git.ts GIT_TIMEOUT). */
6
+ const LEDGER_ROOT_TIMEOUT_MS = 1000;
4
7
  /**
5
8
  * @devflow-design-decision D309
6
9
  * Counts come from decisions-ledger.jsonl (the render source of truth), NOT
@@ -65,6 +68,17 @@ export function gatherLearningCounts(cwd) {
65
68
  }
66
69
  return parsedAny ? counts : null;
67
70
  }
71
+ /**
72
+ * Count the ledger the hooks write for a session started in `cwd`: the ledger
73
+ * root (getLedgerRoot — the main checkout in a linked worktree, the repository
74
+ * root from a subdirectory), or `cwd` itself outside a git work tree. One git
75
+ * call, bounded by the HUD's per-command budget; run it alongside the git
76
+ * status gather, not after it (D-LEDGER-MAIN-WORKTREE).
77
+ */
78
+ export async function gatherLedgerLearningCounts(cwd, options = {}) {
79
+ const root = await getLedgerRoot(cwd, { ...options, timeoutMs: LEDGER_ROOT_TIMEOUT_MS });
80
+ return gatherLearningCounts(root ?? cwd);
81
+ }
68
82
  /**
69
83
  * HUD component: decisions/pitfalls counts.
70
84
  * Shows how many active ADR/PF entries the project has accumulated.
@@ -23,7 +23,8 @@ export const HUD_COMPONENTS = [
23
23
  'learningCounts',
24
24
  ];
25
25
  export function getConfigPath() {
26
- const devflowDir = process.env.DEVFLOW_DIR || path.join(process.env.HOME || homedir(), '.devflow');
26
+ // D-ONE-HOME: always $HOME/.devflow — no environment variable relocates it.
27
+ const devflowDir = path.join(process.env.HOME || homedir(), '.devflow');
27
28
  return path.join(devflowDir, 'hud.json');
28
29
  }
29
30
  export function loadConfig() {
@@ -24,12 +24,10 @@ function isSessionEntry(value) {
24
24
  let sessionsDirCreated = false;
25
25
  let cachedAggregation = null;
26
26
  /**
27
- * Returns the paths used for cost storage.
28
- * Respects DEVFLOW_DIR env for testability.
27
+ * Returns the paths used for cost storage, under $HOME/.devflow (D-ONE-HOME).
29
28
  */
30
29
  export function getCostFilePaths() {
31
- const devflowDir = process.env.DEVFLOW_DIR ||
32
- path.join(process.env.HOME || homedir(), '.devflow');
30
+ const devflowDir = path.join(process.env.HOME || homedir(), '.devflow');
33
31
  const sessionsDir = path.join(devflowDir, 'costs', 'sessions');
34
32
  const archivePath = path.join(devflowDir, 'costs', 'archive.jsonl');
35
33
  return { sessionsDir, archivePath };