devflow-kit 2.4.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 (213) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.md +111 -18
  3. package/dist/agents/git.md +822 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/ambient.js +160 -145
  6. package/dist/cli/commands/attribution-prompts.js +1 -1
  7. package/dist/cli/commands/capture.js +29 -55
  8. package/dist/cli/commands/compliance-prompts.js +1 -1
  9. package/dist/cli/commands/compliance.js +48 -55
  10. package/dist/cli/commands/context.js +17 -32
  11. package/dist/cli/commands/debug.js +65 -26
  12. package/dist/cli/commands/flags.js +3 -3
  13. package/dist/cli/commands/hud.js +34 -10
  14. package/dist/cli/commands/init-seed.js +61 -27
  15. package/dist/cli/commands/init.js +649 -240
  16. package/dist/cli/commands/install-report.js +200 -0
  17. package/dist/cli/commands/knowledge/index.js +2 -2
  18. package/dist/cli/commands/knowledge/toggle.js +35 -37
  19. package/dist/cli/commands/learning.js +79 -57
  20. package/dist/cli/commands/legacy-hooks.js +11 -14
  21. package/dist/cli/commands/memory.js +134 -135
  22. package/dist/cli/commands/prompt-io.js +4 -4
  23. package/dist/cli/commands/proxy.js +23 -41
  24. package/dist/cli/commands/security.js +81 -29
  25. package/dist/cli/commands/skills.js +71 -7
  26. package/dist/cli/commands/tracker-prompts.js +145 -0
  27. package/dist/cli/commands/tracker.js +277 -0
  28. package/dist/cli/commands/uninstall.js +520 -169
  29. package/dist/cli.js +2 -0
  30. package/dist/commands/bug-analysis.md +58 -14
  31. package/dist/commands/code-review.md +110 -32
  32. package/dist/commands/debug.md +55 -11
  33. package/dist/commands/dynamic-build.md +344 -73
  34. package/dist/commands/dynamic-plan.md +77 -27
  35. package/dist/commands/dynamic-profile.md +25 -11
  36. package/dist/commands/dynamic-tickets.md +76 -15
  37. package/dist/commands/explore.md +37 -7
  38. package/dist/commands/implement.md +314 -62
  39. package/dist/commands/plan.md +146 -32
  40. package/dist/commands/release.md +64 -17
  41. package/dist/commands/research.md +34 -8
  42. package/dist/commands/resolve.md +196 -68
  43. package/dist/commands/self-review.md +45 -9
  44. package/dist/core/agent-models.js +55 -12
  45. package/dist/core/assets.js +58 -2
  46. package/dist/core/compliance-compose.js +27 -27
  47. package/dist/core/evidence-policy.js +363 -0
  48. package/dist/core/feature-config.js +200 -65
  49. package/dist/core/feature-switch.js +112 -0
  50. package/dist/core/flags.js +34 -6
  51. package/dist/core/fs-atomic.js +27 -0
  52. package/dist/core/hook-log-dirs.js +104 -0
  53. package/dist/core/learning-tuning-config.js +5 -3
  54. package/dist/core/ledger-root.js +102 -0
  55. package/dist/core/manifest.js +38 -10
  56. package/dist/core/mds-variants.js +798 -0
  57. package/dist/core/migrations.js +49 -23
  58. package/dist/core/model-discovery.js +12 -1
  59. package/dist/core/plugins.js +361 -12
  60. package/dist/core/project-paths.js +1 -18
  61. package/dist/core/proxy-log.js +8 -6
  62. package/dist/core/proxy-state.js +11 -8
  63. package/dist/core/reference-sweep.js +136 -0
  64. package/dist/core/same-location.js +25 -0
  65. package/dist/core/tracker.js +494 -0
  66. package/dist/hud/components/config-counts.js +15 -4
  67. package/dist/hud/components/learning-counts.js +14 -0
  68. package/dist/hud/config.js +2 -1
  69. package/dist/hud/cost-history.js +2 -4
  70. package/dist/hud/git.js +52 -7
  71. package/dist/hud/index.js +7 -9
  72. package/dist/skills/git/references/decision-markers.md +19 -0
  73. package/dist/skills/git/references/learn-conventions.md +56 -0
  74. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  75. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  76. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  77. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  78. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  79. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  80. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  81. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  82. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  83. package/dist/skills/git/references/publication-gate.md +13 -0
  84. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  85. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  87. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  88. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  89. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  90. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  91. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  92. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  93. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  94. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  95. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  96. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  97. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  98. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  99. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  100. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  101. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  102. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  103. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  104. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  105. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  106. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  107. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  108. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  109. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  110. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  111. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  112. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  113. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  114. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  115. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  116. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  117. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  118. package/dist/skills/git/references/trust-rule.md +7 -0
  119. package/dist/targets/claude-code/claude-paths.js +59 -57
  120. package/dist/targets/claude-code/compliance-install.js +49 -65
  121. package/dist/targets/claude-code/hooks.js +108 -3
  122. package/dist/targets/claude-code/installer.js +1187 -32
  123. package/dist/targets/claude-code/legacy.js +5 -0
  124. package/dist/targets/claude-code/post-install.js +366 -151
  125. package/dist/targets/claude-code/tracker-install.js +134 -0
  126. package/package.json +8 -6
  127. package/src/assets/agents/code.md +45 -6
  128. package/src/assets/agents/design.md +2 -1
  129. package/src/assets/agents/git.mds +825 -0
  130. package/src/assets/agents/knowledge.md +3 -3
  131. package/src/assets/agents/learning.md +11 -0
  132. package/src/assets/agents/review.md +3 -1
  133. package/src/assets/agents/synthesize.md +1 -1
  134. package/src/assets/agents/test.md +16 -5
  135. package/src/assets/agents/tracker.md +474 -0
  136. package/src/assets/agents/validate.md +7 -5
  137. package/src/assets/commands/_partials/_compliance.mds +19 -1
  138. package/src/assets/commands/_partials/_decisions.mds +15 -3
  139. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  140. package/src/assets/commands/_partials/_engine.mds +13 -11
  141. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  142. package/src/assets/commands/_partials/_factory.mds +1 -1
  143. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  144. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  145. package/src/assets/commands/_partials/_preamble.mds +2 -2
  146. package/src/assets/commands/_partials/_publication.mds +8 -2
  147. package/src/assets/commands/_partials/_settings.mds +28 -0
  148. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  149. package/src/assets/commands/_partials/_tracker.mds +18 -0
  150. package/src/assets/commands/_partials/_wave.mds +16 -10
  151. package/src/assets/commands/bug-analysis.mds +31 -19
  152. package/src/assets/commands/code-review.mds +67 -41
  153. package/src/assets/commands/debug.mds +13 -7
  154. package/src/assets/commands/dynamic-build.mds +274 -66
  155. package/src/assets/commands/dynamic-plan.mds +50 -23
  156. package/src/assets/commands/dynamic-profile.mds +24 -11
  157. package/src/assets/commands/dynamic-tickets.mds +63 -16
  158. package/src/assets/commands/explore.mds +4 -5
  159. package/src/assets/commands/implement.mds +234 -67
  160. package/src/assets/commands/plan.mds +91 -33
  161. package/src/assets/commands/release.md +64 -17
  162. package/src/assets/commands/research.mds +11 -9
  163. package/src/assets/commands/resolve.mds +150 -78
  164. package/src/assets/commands/self-review.mds +24 -25
  165. package/src/assets/mds/git/_pr.mds +331 -0
  166. package/src/assets/mds/git/_references.mds +135 -0
  167. package/src/assets/mds/tracker/_common.mds +156 -0
  168. package/src/assets/mds/tracker/_github.mds +472 -0
  169. package/src/assets/mds/tracker/_jira.mds +407 -0
  170. package/src/assets/mds/tracker/_linear.mds +449 -0
  171. package/src/assets/mds/tracker/_mcp.mds +305 -0
  172. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  173. package/src/assets/scripts/hooks/background-memory-update +40 -19
  174. package/src/assets/scripts/hooks/capture-prompt +18 -8
  175. package/src/assets/scripts/hooks/capture-question +18 -8
  176. package/src/assets/scripts/hooks/capture-turn +27 -13
  177. package/src/assets/scripts/hooks/debug-trace +11 -6
  178. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  179. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  180. package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
  181. package/src/assets/scripts/hooks/git-marker +48 -0
  182. package/src/assets/scripts/hooks/hook-log-init +3 -1
  183. package/src/assets/scripts/hooks/json-helper.cjs +228 -5
  184. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
  185. package/src/assets/scripts/hooks/log-paths +80 -0
  186. package/src/assets/scripts/hooks/memory-worker +22 -13
  187. package/src/assets/scripts/hooks/pre-compact-memory +44 -15
  188. package/src/assets/scripts/hooks/preamble +1 -4
  189. package/src/assets/scripts/hooks/queue-append +146 -28
  190. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  191. package/src/assets/scripts/hooks/session-start-context +534 -20
  192. package/src/assets/scripts/hooks/session-start-memory +38 -15
  193. package/src/assets/scripts/lib/project-config.cjs +633 -0
  194. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  195. package/src/assets/scripts/redact-secrets.cjs +490 -62
  196. package/src/assets/scripts/release-trace.cjs +1143 -0
  197. package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
  198. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  199. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  200. package/src/assets/skills/compliance/SKILL.md +4 -2
  201. package/src/assets/skills/docs-framework/SKILL.md +11 -10
  202. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  203. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  204. package/src/assets/skills/git/SKILL.md +8 -78
  205. package/src/assets/skills/git/references/github-api.md +179 -141
  206. package/src/assets/skills/git/references/patterns.md +11 -6
  207. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  208. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  209. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  210. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  211. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  212. package/src/targets/claude-code/templates/managed-settings.json +25 -9
  213. package/src/assets/agents/git.md +0 -938
@@ -1,11 +1,9 @@
1
1
  import { promises as fs, writeFileSync, unlinkSync } from 'fs';
2
- import { execSync } from 'child_process';
2
+ import { execFileSync } from 'child_process';
3
3
  import * as path from 'path';
4
- import * as os from 'os';
5
4
  import * as p from '@clack/prompts';
6
5
  import { getManagedSettingsPath } from './claude-paths.js';
7
- import { getGitignoreEntries, getDocsDir } from '../../core/project-paths.js';
8
- import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
6
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
9
7
  function isNodeSystemError(error) {
10
8
  return (error instanceof Error &&
11
9
  'code' in error &&
@@ -13,18 +11,48 @@ function isNodeSystemError(error) {
13
11
  }
14
12
  /**
15
13
  * Replace ${DEVFLOW_DIR} placeholders in a settings template.
14
+ *
15
+ * D-ONE-HOME: the settings template's install-time placeholder is the one
16
+ * surviving spelling of that name. It is a template token substituted here with
17
+ * the machine root (always ~/.devflow) — not an environment variable, and no
18
+ * runtime reader resolves it (tests/guards/one-home.test.ts pins both sites).
16
19
  */
17
20
  export function substituteSettingsTemplate(template, devflowDir) {
18
21
  return template.replace(/\$\{DEVFLOW_DIR\}/g, devflowDir);
19
22
  }
20
23
  /**
21
- * Compute which entries need appending to a .gitignore file.
22
- * Returns only entries not already present.
24
+ * Sentinel line whose presence means the current (v3-and-later) carve-out block is
25
+ * installed. Devflow-unique: no user writes `!.devflow/conventions.md` by hand.
23
26
  */
24
- export function computeGitignoreAppend(existingContent, entries) {
25
- const existingLines = existingContent.split('\n').map(l => l.trim());
26
- return entries.filter(entry => !existingLines.includes(entry));
27
- }
27
+ const DEVFLOW_GITIGNORE_SENTINEL_V3 = '!.devflow/conventions.md';
28
+ /** Sentinel line whose presence means the v2 carve-out block is installed (no conventions.md line). */
29
+ const DEVFLOW_GITIGNORE_SENTINEL_V2 = '!.devflow/features/*/KNOWLEDGE.md';
30
+ /**
31
+ * The block's final line: ignore the devflow-managed `.claudeignore` file.
32
+ * NOT a sentinel — users legitimately author this line themselves.
33
+ */
34
+ const CLAUDEIGNORE_LINE = '.claudeignore';
35
+ /** A user's explicit un-ignore of `.claudeignore`; never overridden. */
36
+ const CLAUDEIGNORE_NEGATION = '!.claudeignore';
37
+ /**
38
+ * Re-includes the retired evidence-policy file (D-GITIGNORE-V5,
39
+ * D-POLICY-JSON-RETIRED). A COMPLETION line, never a presence sentinel: users may author it themselves, so its presence
40
+ * proves nothing about the devflow block (avoids PF-059). It sits after `.devflow/*`
41
+ * (which it overrides under last-match-wins) and before `.claudeignore`, so the
42
+ * block's final line stays `.claudeignore`.
43
+ */
44
+ const DEVFLOW_POLICY_LINE = '!.devflow/policy.json';
45
+ /**
46
+ * Re-includes the team-committed project settings file (D-GITIGNORE-V6). The same
47
+ * contract as the policy line: a COMPLETION line, never a presence sentinel — a
48
+ * user may author it before devflow ever runs, so its presence proves nothing about
49
+ * the block (avoids PF-059). It sits after the policy line and before `.claudeignore`,
50
+ * so a v5 block, which ends in `.claudeignore`, gains it just before that line
51
+ * (D-GITIGNORE-IN-BLOCK, computeDevflowGitignore). Without it
52
+ * `.devflow/*` ignores `.devflow/project.json`, and a team could only commit it with
53
+ * `git add -f`. Devflow never writes the file itself (ADR-024).
54
+ */
55
+ const DEVFLOW_PROJECT_LINE = '!.devflow/project.json';
28
56
  /**
29
57
  * The shared .devflow/ gitignore block. Everything under .devflow/ is local
30
58
  * (memory, learning, docs, locks) EXCEPT:
@@ -32,6 +60,14 @@ export function computeGitignoreAppend(existingContent, entries) {
32
60
  * + committed (the Knowledge agent commits them at workflow end).
33
61
  * - conventions.md: naming-convention authority written by the Git learn-conventions
34
62
  * operation; GIT-TRACKED so the team shares a single naming source.
63
+ * - policy.json: the retired evidence-policy file. resolve-evidence-policy.cjs never
64
+ * parses it, but where project.json has no `evidence` its presence holds the
65
+ * repository at `required` (D-POLICY-JSON-RETIRED); GIT-TRACKED so a team's
66
+ * committed copy stays shared until its value moves into project.json. Devflow
67
+ * never writes it.
68
+ * - project.json: the team-committed settings (evidence, compliance, tracker, review
69
+ * publication, narrow-only feature switches) both resolvers read; GIT-TRACKED for
70
+ * the same reason (D-GITIGNORE-V6). Devflow never writes it.
35
71
  *
36
72
  * Re-including files under an ignored tree needs a `dir/*` + `!dir/keep` pair at
37
73
  * each level — a bare `.devflow/` excludes the directory so git never descends and
@@ -39,70 +75,192 @@ export function computeGitignoreAppend(existingContent, entries) {
39
75
  *
40
76
  * Kept BYTE-IDENTICAL to the block emitted by src/assets/scripts/hooks/ensure-root-gitignore
41
77
  * so the init-time path and the always-on hook path produce the same file.
42
- *
43
- * D-GITIGNORE-V3: v3 of the carve-out block (adds !.devflow/conventions.md).
44
78
  */
45
- export const DEVFLOW_GITIGNORE_BLOCK = [
79
+ const DEVFLOW_GITIGNORE_BLOCK_LINES = [
46
80
  '# Devflow runtime data — local by default (memory, learning, docs, locks).',
47
- '# Two exceptions are shared via git: feature knowledge bases under .devflow/features/',
48
- '# (index.md and every {slug}/KNOWLEDGE.md) and .devflow/conventions.md (naming',
49
- '# authority). To stop sharing, re-add `.devflow/features/` or `.devflow/conventions.md`',
50
- '# to your own .gitignore.',
81
+ '# Shared via git: feature knowledge bases under .devflow/features/ (index.md and',
82
+ '# every {slug}/KNOWLEDGE.md), .devflow/conventions.md (naming authority),',
83
+ '# .devflow/policy.json (retired; presence only) and .devflow/project.json (team settings).',
84
+ '# To stop sharing the first two, re-add `.devflow/features/` or',
85
+ '# `.devflow/conventions.md` to your own .gitignore.',
51
86
  '.devflow/*',
52
87
  '!.devflow/features/',
53
88
  '.devflow/features/*',
54
89
  '!.devflow/features/index.md',
55
90
  '!.devflow/features/*/',
56
91
  '.devflow/features/*/*',
57
- '!.devflow/features/*/KNOWLEDGE.md',
58
- '!.devflow/conventions.md',
59
- ].join('\n');
60
- /** Sentinel line whose presence means the v3 carve-out block is installed. */
61
- const DEVFLOW_GITIGNORE_SENTINEL_V3 = '!.devflow/conventions.md';
62
- /** Sentinel line whose presence means the v2 carve-out block is installed (no conventions.md line). */
63
- const DEVFLOW_GITIGNORE_SENTINEL_V2 = '!.devflow/features/*/KNOWLEDGE.md';
92
+ DEVFLOW_GITIGNORE_SENTINEL_V2,
93
+ DEVFLOW_GITIGNORE_SENTINEL_V3,
94
+ DEVFLOW_POLICY_LINE,
95
+ DEVFLOW_PROJECT_LINE,
96
+ CLAUDEIGNORE_LINE,
97
+ ];
98
+ /** The full carve-out block, `.claudeignore` line included. */
99
+ export const DEVFLOW_GITIGNORE_BLOCK = DEVFLOW_GITIGNORE_BLOCK_LINES.join('\n');
100
+ /**
101
+ * The entries directly under a repository's `.devflow/` that the team shares
102
+ * through git (a trailing `/` marks a directory): the feature knowledge bases,
103
+ * the naming conventions, the retired evidence-policy file and the committed
104
+ * project settings.
105
+ *
106
+ * D-UNINSTALL-CARVE-OUT: uninstall's project-data step never deletes these — a
107
+ * confirmed removal takes everything else under `.devflow/` and keeps them
108
+ * byte-identical, because an uncommitted edit to a tracked file is not
109
+ * recoverable from git. Kept next to the gitignore block it mirrors: every path
110
+ * the block re-includes must appear here (pinned by tests/uninstall-logic.test.ts).
111
+ */
112
+ export const DEVFLOW_TRACKED_PATHS = Object.freeze([
113
+ 'features/',
114
+ 'conventions.md',
115
+ 'policy.json',
116
+ 'project.json',
117
+ ]);
118
+ /**
119
+ * The carve-out block without its final `.claudeignore` line — emitted instead of
120
+ * the full block when the target .gitignore already carries a `.claudeignore` or
121
+ * `!.claudeignore` entry of the user's own.
122
+ */
123
+ export const DEVFLOW_GITIGNORE_BLOCK_WITHOUT_CLAUDEIGNORE = DEVFLOW_GITIGNORE_BLOCK_LINES.slice(0, -1).join('\n');
64
124
  /** The legacy wholesale comment our pre-carve-out writers emitted. */
65
125
  const LEGACY_DEVFLOW_COMMENT = '# Devflow runtime data (local by default; remove to share via git)';
126
+ /**
127
+ * The most lines a top-up run extends below its anchor — one per line the run may
128
+ * hold (the sentinel, the policy line, the project line). The shell twin's loop has
129
+ * the same bound.
130
+ */
131
+ const BLOCK_RUN_MAX = 3;
66
132
  /**
67
133
  * PURE: given existing .gitignore content, return the content that ignores
68
- * `.devflow/` with the feature-knowledge + conventions.md carve-out — or `null`
69
- * when no change is needed. Idempotent: feeding its own output back returns `null`.
134
+ * `.devflow/` with the feature-knowledge + conventions.md + policy.json + project.json
135
+ * carve-out — or `null` when no change is needed. Idempotent: feeding its own output
136
+ * back returns `null`.
137
+ *
138
+ * D-GITIGNORE-V5 / D-GITIGNORE-V6: the block is detected ONLY by its own
139
+ * devflow-unique sentinel (`!.devflow/conventions.md`). `.claudeignore`,
140
+ * `!.devflow/policy.json` and `!.devflow/project.json` are COMPLETION lines — users
141
+ * legitimately author all three themselves — so each is topped up when missing and
142
+ * never read as proof the block exists. A presence check on a
143
+ * user-authored line inverts both halves of the contract: projects that already carry
144
+ * that line are told the block is installed when it is not, and a user's
145
+ * `!.claudeignore` un-ignore is silently reversed by re-appending `.claudeignore`
146
+ * under last-match-wins (avoids PF-059).
147
+ *
148
+ * `hasClaudeignoreEntry` is true when some whole line, trimmed, is exactly
149
+ * `.claudeignore` OR `!.claudeignore`. Treating both forms as "present" both honours
150
+ * an un-ignore and makes every branch converge on re-run. `hasPolicyLine` and
151
+ * `hasProjectLine` are true when some whole line, trimmed, is exactly
152
+ * `!.devflow/policy.json` / `!.devflow/project.json`. The missing completion lines, in
153
+ * block order, are [policy line, project line, `.claudeignore` (only when
154
+ * `!hasClaudeignoreEntry`)].
70
155
  *
71
- * - v3 sentinel already present → `null` (already at current format).
72
- * - User-authored `/.devflow/` (leading slash) present → `null` (respect manual config).
73
- * - v2 sentinel present but not v3 → UPGRADE: append just `!.devflow/conventions.md`.
74
- * - Legacy bare `.devflow/` present → strip it (+ our old comment), append the full block.
75
- * - Otherwise → append the full block (or the block alone when content is empty).
156
+ * 1. A `/.devflow/` line present → `null` (user opt-out; respect manual config).
157
+ * 2. v3 sentinel present → insert the missing completion lines into the block;
158
+ * `null` when none are missing. This is the v4→v6 and v5→v6 upgrade: a v5 block
159
+ * gains only the project line, just before its `.claudeignore` line, and a v4
160
+ * block the policy and project lines, each keeping its old comment.
161
+ * 3. v2 sentinel present, no v3 → insert `!.devflow/conventions.md` followed by the
162
+ * missing completion lines right after the v2 sentinel.
163
+ * 4. Legacy bare `.devflow/` present → strip it (+ our old comment), then append the
164
+ * block; no block at all → append the block. The block is emitted MINUS its final
165
+ * `.claudeignore` line when `hasClaudeignoreEntry`. A user's own policy or project
166
+ * line is duplicated harmlessly here, and the re-run is a no-op.
167
+ * 5. No completion line is ever a sentinel. The marker file
168
+ * (`.devflow/.root-gitignore-configured-v6`) is a fast-path claim, never proof.
169
+ *
170
+ * D-GITIGNORE-IN-BLOCK: lines topped up into an existing block (2 and 3) go INSIDE
171
+ * it, where a fresh block holds them — never at the end of the file. gitignore is
172
+ * last-match-wins, so a `!.devflow/project.json` appended after a user's own later
173
+ * `.devflow/project.json` would silently override their re-ignore. The missing
174
+ * lines are inserted as one run, in block order, after the first sentinel line and
175
+ * the lines right after it that a fresh block places before the first missing line
176
+ * (blockRunBefore; at most three). Every other byte of the file is kept.
177
+ *
178
+ * Line matching is whole-line, whitespace-tolerant, exact text — never substring.
179
+ * The insert and the append form are mirrored byte-for-byte in the shell twin
180
+ * (src/assets/scripts/hooks/ensure-root-gitignore), which is what the cross-implementation
181
+ * parity table in tests/shell-hooks.test.ts pins.
76
182
  */
77
183
  export function computeDevflowGitignore(existingContent) {
78
184
  const lines = existingContent.split('\n');
79
185
  const trimmed = lines.map(l => l.trim());
80
- if (trimmed.includes(DEVFLOW_GITIGNORE_SENTINEL_V3))
81
- return null;
82
- if (trimmed.some(l => l === '/.devflow/'))
186
+ const hasClaudeignoreEntry = trimmed.some(l => l === CLAUDEIGNORE_LINE || l === CLAUDEIGNORE_NEGATION);
187
+ const hasPolicyLine = trimmed.includes(DEVFLOW_POLICY_LINE);
188
+ const hasProjectLine = trimmed.includes(DEVFLOW_PROJECT_LINE);
189
+ /** The block-completing lines this file lacks, in block order. */
190
+ const missingCompletionLines = [
191
+ ...(hasPolicyLine ? [] : [DEVFLOW_POLICY_LINE]),
192
+ ...(hasProjectLine ? [] : [DEVFLOW_PROJECT_LINE]),
193
+ ...(hasClaudeignoreEntry ? [] : [CLAUDEIGNORE_LINE]),
194
+ ];
195
+ /**
196
+ * The block lines a top-up run follows: the v3 sentinel, then whichever of the
197
+ * policy and project lines a fresh block places before the first missing line.
198
+ * Mirrors `_ERG_RUN_RE` in the shell twin.
199
+ */
200
+ const blockRunBefore = !hasPolicyLine
201
+ ? [DEVFLOW_GITIGNORE_SENTINEL_V3]
202
+ : !hasProjectLine
203
+ ? [DEVFLOW_GITIGNORE_SENTINEL_V3, DEVFLOW_POLICY_LINE]
204
+ : [DEVFLOW_GITIGNORE_SENTINEL_V3, DEVFLOW_POLICY_LINE, DEVFLOW_PROJECT_LINE];
205
+ /**
206
+ * Insert `inserted` into an existing devflow block: after the first line whose
207
+ * trimmed text is `anchor`, and after up to BLOCK_RUN_MAX lines right below it
208
+ * whose trimmed text is in `run`. Every other byte is kept. A run that ends on a
209
+ * last line with no newline gets one first, so the inserted lines never fuse onto
210
+ * it. Mirrors `_erg_insert_in_block` in the shell twin (D-GITIGNORE-IN-BLOCK).
211
+ */
212
+ const insertInBlock = (anchor, run, inserted) => {
213
+ let end = trimmed.indexOf(anchor);
214
+ for (let k = 0; k < BLOCK_RUN_MAX && end + 1 < lines.length && run.includes(trimmed[end + 1]); k++) {
215
+ end++;
216
+ }
217
+ if (end === lines.length - 1)
218
+ return `${existingContent}\n${inserted.join('\n')}\n`;
219
+ return [...lines.slice(0, end + 1), ...inserted, ...lines.slice(end + 1)].join('\n');
220
+ };
221
+ /**
222
+ * Start a new block after unrelated content: one blank separator line. Existing
223
+ * trailing newlines are preserved verbatim (no trimEnd, no blank-line dedupe) so
224
+ * the shell twin's `tail -c 1` guard produces the identical bytes.
225
+ */
226
+ const appendBlock = (body, block) => body.length === 0
227
+ ? `${block}\n`
228
+ : `${body}${body.endsWith('\n') ? '' : '\n'}\n${block}\n`;
229
+ // 1. User opt-out wins over every sentinel.
230
+ if (trimmed.includes('/.devflow/'))
83
231
  return null;
84
- // v2→v3 upgrade: v2 sentinel present, v3 sentinel absent → append the missing line only.
85
- // Preserve existing trailing newlines byte-for-byte (matches shell twin's tail -c 1 guard).
232
+ // 2. Block installed (v3 and later) — top up only the completion lines it lacks.
233
+ if (trimmed.includes(DEVFLOW_GITIGNORE_SENTINEL_V3)) {
234
+ return missingCompletionLines.length === 0
235
+ ? null
236
+ : insertInBlock(DEVFLOW_GITIGNORE_SENTINEL_V3, blockRunBefore, missingCompletionLines);
237
+ }
238
+ // 3. v2 block installed — insert the lines it lacks, in block order, right after
239
+ // its sentinel, the last carve-out line it has.
86
240
  if (trimmed.includes(DEVFLOW_GITIGNORE_SENTINEL_V2)) {
87
- const sep = existingContent.endsWith('\n') ? '' : '\n';
88
- return `${existingContent}${sep}${DEVFLOW_GITIGNORE_SENTINEL_V3}\n`;
241
+ return insertInBlock(DEVFLOW_GITIGNORE_SENTINEL_V2, [], [DEVFLOW_GITIGNORE_SENTINEL_V3, ...missingCompletionLines]);
89
242
  }
90
- const append = (body) => body.trimEnd()
91
- ? `${body.trimEnd()}\n\n${DEVFLOW_GITIGNORE_BLOCK}\n`
92
- : `${DEVFLOW_GITIGNORE_BLOCK}\n`;
93
- if (trimmed.some(l => l === '.devflow/')) {
243
+ // 4. No devflow block — install one, respecting any .claudeignore entry of the user's own.
244
+ const block = hasClaudeignoreEntry
245
+ ? DEVFLOW_GITIGNORE_BLOCK_WITHOUT_CLAUDEIGNORE
246
+ : DEVFLOW_GITIGNORE_BLOCK;
247
+ if (trimmed.includes('.devflow/')) {
94
248
  // Upgrade our legacy wholesale entry: drop the bare line + old comment, append block.
95
249
  const kept = lines
96
250
  .filter(l => l.trim() !== '.devflow/' && l.trim() !== LEGACY_DEVFLOW_COMMENT)
97
251
  .join('\n');
98
- return append(kept);
252
+ return appendBlock(kept, block);
99
253
  }
100
- return append(existingContent);
254
+ return appendBlock(existingContent, block);
101
255
  }
102
256
  /**
103
257
  * Merge Devflow deny entries into an existing settings JSON object.
104
- * Preserves existing entries (including allow and sibling keys), deduplicates,
105
- * and returns the merged JSON string with trailing newline.
258
+ * Preserves existing entries (including allow and sibling keys) except those named
259
+ * in `retired`, deduplicates, and returns the merged JSON string with trailing newline.
260
+ *
261
+ * `retired` is how an install converges an older one: pass retiredDenyEntries(template)
262
+ * so entries Devflow once shipped and has since dropped do not linger. An entry that
263
+ * `newDenyEntries` carries is never dropped, whatever `retired` says.
106
264
  *
107
265
  * PURE + idempotent: calling with the same inputs always yields byte-equal output.
108
266
  * Non-array `deny` (e.g. a string, null) is treated as empty — neither throws nor spreads chars.
@@ -110,22 +268,33 @@ export function computeDevflowGitignore(existingContent) {
110
268
  * @throws {SyntaxError} on malformed JSON — callers must pre-validate (e.g. via detectDenyState)
111
269
  * or wrap in try/catch.
112
270
  */
113
- export function mergeDenyList(existingJson, newDenyEntries) {
271
+ export function mergeDenyList(existingJson, newDenyEntries, retired = new Set()) {
114
272
  const existing = JSON.parse(existingJson);
115
273
  const rawDeny = existing.permissions?.deny;
116
274
  const currentDeny = Array.isArray(rawDeny) ? rawDeny : [];
117
- const merged = [...new Set([...currentDeny, ...newDenyEntries])];
275
+ const kept = currentDeny.filter(e => !retired.has(e));
276
+ const merged = [...new Set([...kept, ...newDenyEntries])];
118
277
  existing.permissions = { ...(existing.permissions ?? {}), deny: merged };
119
278
  return JSON.stringify(existing, null, 2) + '\n';
120
279
  }
121
280
  /**
122
281
  * Historical superset of every deny entry Devflow has ever shipped.
123
- * Append every future entry here; never remove entries.
124
- * Used by stripUserDenyList to identify Devflow-managed entries in legacy installs.
282
+ * Append every future entry here; never remove entries — a retired template entry
283
+ * stays here so removal (stripUserDenyList, removeManagedSettings) and install
284
+ * convergence (retiredDenyEntries) still recognise it in an older install.
125
285
  *
126
286
  * Load-time assertion below verifies this is a superset of the current template.
127
287
  */
128
288
  // D-SECURITY-01: frozen at module load — any future template entry must appear here too.
289
+ // D-SECURITY-02 (#399): the nine v1 piped rules (`Bash(curl * | bash*)` and kin) are
290
+ // RETIRED — kept here, dropped from the template. Claude Code splits a Bash command at
291
+ // `|` (and `&&`, `||`, `;`, `|&`, `&`, newlines) and matches every rule against each
292
+ // subcommand alone, so a rule holding ` | ` can never match anything. The exact
293
+ // shell-on-stdin denies in the v2 batch (`Bash(bash)`, `Bash(sh -s *)`, ...) match the
294
+ // shell subcommand of such a pipeline instead.
295
+ // Only entries a release actually shipped belong here: removal and install convergence
296
+ // strip every entry this set names that the template does not, so a rule Devflow never
297
+ // shipped would be taken from a user who wrote it (ADR-024, prove-you-wrote-it).
129
298
  export const DEVFLOW_HISTORICAL_DENY = Object.freeze(new Set([
130
299
  // v1 batch — 154 entries shipped in src/targets/claude-code/templates/managed-settings.json
131
300
  'Bash(rm -rf /*)',
@@ -282,7 +451,54 @@ export const DEVFLOW_HISTORICAL_DENY = Object.freeze(new Set([
282
451
  'Read(/etc/shadow)',
283
452
  'Read(/etc/sudoers)',
284
453
  'Read(/etc/passwd)',
454
+ // v2 batch (#399) — 25 template entries: a shell reading its script from stdin,
455
+ // `zsh -c` beside the v1 `sh -c`/`bash -c`, OrbStack VM control, docker
456
+ // pull/delete/prune and whole-disk or privileged runs.
457
+ 'Bash(bash)',
458
+ 'Bash(sh)',
459
+ 'Bash(zsh)',
460
+ 'Bash(bash - *)',
461
+ 'Bash(sh - *)',
462
+ 'Bash(zsh - *)',
463
+ 'Bash(bash -s *)',
464
+ 'Bash(sh -s *)',
465
+ 'Bash(zsh -s *)',
466
+ 'Bash(zsh -c *)',
467
+ 'Bash(docker run*--privileged*)',
468
+ 'Bash(docker run*-v /:*)',
469
+ 'Bash(docker run*--volume /:*)',
470
+ 'Bash(docker run*--volume=/:*)',
471
+ 'Bash(docker pull *)',
472
+ 'Bash(docker image pull *)',
473
+ 'Bash(docker rm *)',
474
+ 'Bash(docker container rm *)',
475
+ 'Bash(docker rmi *)',
476
+ 'Bash(docker image rm *)',
477
+ 'Bash(docker volume rm *)',
478
+ 'Bash(docker*prune*)',
479
+ 'Bash(orb *)',
480
+ 'Bash(orbctl *)',
481
+ 'Bash(open *OrbStack*)',
285
482
  ]));
483
+ /**
484
+ * The Devflow deny entries an older install may carry that the current template no
485
+ * longer ships: DEVFLOW_HISTORICAL_DENY minus the template. PURE.
486
+ *
487
+ * An empty template (loadTemplateDenyEntries' failure value) retires nothing — an
488
+ * unreadable template must never read as "Devflow dropped every entry it ever shipped".
489
+ *
490
+ * Accepted trade-off (ADR-024): a deny entry is a bare string, so a user who typed a
491
+ * retired entry themselves is indistinguishable from Devflow's copy and loses it on the
492
+ * next install, exactly as `security --disable` and uninstall already strip every
493
+ * historical entry. Retire an entry only when losing a user's identical copy is
494
+ * harmless; the #399 piped rules qualify because none could ever match (D-SECURITY-02).
495
+ */
496
+ export function retiredDenyEntries(templateEntries) {
497
+ if (templateEntries.length === 0)
498
+ return new Set();
499
+ const current = new Set(templateEntries);
500
+ return new Set([...DEVFLOW_HISTORICAL_DENY].filter(e => !current.has(e)));
501
+ }
286
502
  /**
287
503
  * Assert that DEVFLOW_HISTORICAL_DENY is a superset of the provided template entries.
288
504
  * Throws if any template entry is missing from the historical set.
@@ -458,8 +674,8 @@ export function resolveSecurityAction(flag, manifestMode, detected, isTTY) {
458
674
  }
459
675
  /**
460
676
  * Load the deny entry array from the managed-settings.json template.
461
- * Canonical single-source helper used by installManagedSettings, removeManagedSettings,
462
- * init.ts's security step, and security.ts's --enable/--disable paths.
677
+ * Canonical single-source helper used by installManagedSettings, init.ts's security
678
+ * step, and security.ts's --enable path. Removal keys on DEVFLOW_HISTORICAL_DENY instead.
463
679
  *
464
680
  * Defensive read: treats file as `Record<string, unknown>`, guards with Array.isArray,
465
681
  * coerces each element to string. Returns [] on any read or parse failure (never throws).
@@ -503,7 +719,7 @@ export async function installManagedSettings(rootDir, verbose) {
503
719
  let content;
504
720
  try {
505
721
  const existing = await fs.readFile(managedPath, 'utf-8');
506
- content = mergeDenyList(existing, newDenyEntries);
722
+ content = mergeDenyList(existing, newDenyEntries, retiredDenyEntries(newDenyEntries));
507
723
  }
508
724
  catch {
509
725
  // File doesn't exist — use template as-is
@@ -531,11 +747,14 @@ export async function installManagedSettings(rootDir, verbose) {
531
747
  return false;
532
748
  }
533
749
  try {
534
- execSync(`sudo mkdir -p '${managedDir}'`, { stdio: 'inherit' });
535
- // Write via sudo tee to avoid shell quoting issues with the JSON content
750
+ execFileSync('sudo', ['mkdir', '-p', managedDir], { stdio: 'inherit' });
751
+ // Stage the JSON in a file and copy it with sudo. Every sudo call takes an
752
+ // argv (execFileSync, no shell), so no path is ever re-parsed by a shell — a
753
+ // package root under a home directory holding a quote cannot break or extend
754
+ // the root command.
536
755
  const tmpFile = path.join(rootDir, '.managed-settings-tmp.json');
537
756
  await fs.writeFile(tmpFile, content, 'utf-8');
538
- execSync(`sudo cp '${tmpFile}' '${managedPath}'`, { stdio: 'inherit' });
757
+ execFileSync('sudo', ['cp', tmpFile, managedPath], { stdio: 'inherit' });
539
758
  await fs.rm(tmpFile, { force: true });
540
759
  if (verbose) {
541
760
  p.log.success(`Managed settings written to ${managedPath} (via sudo)`);
@@ -557,14 +776,26 @@ export async function installManagedSettings(rootDir, verbose) {
557
776
  * 1. Try direct write/delete
558
777
  * 2. If EACCES and TTY, ask user before sudo
559
778
  * 3. Non-TTY: return false (caller logs preservation message)
779
+ *
780
+ * `managedPathOverride` exists so a test can point the removal at a temp file;
781
+ * production callers omit it and get the platform's system path. It is a
782
+ * parameter, deliberately not an environment variable: this function may run
783
+ * `sudo rm` / `sudo cp` on the path, and an env-selectable target would let
784
+ * whoever controls the environment aim a root write the user consented to for
785
+ * "managed settings" at any file.
560
786
  */
561
- export async function removeManagedSettings(rootDir, verbose) {
787
+ export async function removeManagedSettings(rootDir, verbose, managedPathOverride) {
562
788
  let managedPath;
563
- try {
564
- managedPath = getManagedSettingsPath();
789
+ if (managedPathOverride !== undefined) {
790
+ managedPath = managedPathOverride;
565
791
  }
566
- catch {
567
- return false;
792
+ else {
793
+ try {
794
+ managedPath = getManagedSettingsPath();
795
+ }
796
+ catch {
797
+ return false;
798
+ }
568
799
  }
569
800
  let existingContent;
570
801
  try {
@@ -573,15 +804,11 @@ export async function removeManagedSettings(rootDir, verbose) {
573
804
  catch {
574
805
  return false; // File doesn't exist
575
806
  }
576
- // Load our deny entries to identify which to remove
577
- const devflowDenyEntries = await loadTemplateDenyEntries(rootDir);
578
- if (devflowDenyEntries.length === 0) {
579
- return false;
580
- }
807
+ // Key on every entry Devflow has ever shipped, not the current template: an install
808
+ // from an older release carries entries the template has since retired (D-SECURITY-02).
581
809
  const existing = JSON.parse(existingContent);
582
810
  const currentDeny = existing.permissions?.deny ?? [];
583
- const devflowSet = new Set(devflowDenyEntries);
584
- const remaining = currentDeny.filter(entry => !devflowSet.has(entry));
811
+ const remaining = currentDeny.filter(entry => !DEVFLOW_HISTORICAL_DENY.has(entry));
585
812
  // Determine the target action: delete file entirely or write updated content
586
813
  let shouldDelete = false;
587
814
  let updatedContent = null;
@@ -639,12 +866,12 @@ export async function removeManagedSettings(rootDir, verbose) {
639
866
  }
640
867
  try {
641
868
  if (shouldDelete) {
642
- execSync(`sudo rm '${managedPath}'`, { stdio: 'inherit' });
869
+ execFileSync('sudo', ['rm', managedPath], { stdio: 'inherit' });
643
870
  }
644
871
  else {
645
872
  const tmpFile = path.join(rootDir, '.managed-settings-tmp.json');
646
873
  await fs.writeFile(tmpFile, updatedContent, 'utf-8');
647
- execSync(`sudo cp '${tmpFile}' '${managedPath}'`, { stdio: 'inherit' });
874
+ execFileSync('sudo', ['cp', tmpFile, managedPath], { stdio: 'inherit' });
648
875
  await fs.rm(tmpFile, { force: true });
649
876
  }
650
877
  if (verbose) {
@@ -675,8 +902,8 @@ export async function applyUserSecurityDenyList(settingsPath, currentTemplateDen
675
902
  catch {
676
903
  existing = '{}';
677
904
  }
678
- const merged = mergeDenyList(existing, currentTemplateDeny);
679
- await writeFileAtomicExclusive(settingsPath, merged);
905
+ const merged = mergeDenyList(existing, currentTemplateDeny, retiredDenyEntries(currentTemplateDeny));
906
+ await writeSettingsFileAtomic(settingsPath, merged);
680
907
  return merged;
681
908
  }
682
909
  /**
@@ -684,7 +911,7 @@ export async function applyUserSecurityDenyList(settingsPath, currentTemplateDen
684
911
  * Colocated with applyUserSecurityDenyList — the remove-side counterpart.
685
912
  *
686
913
  * Sequence: read → stripUserDenyList → guard (stripped !== existing) →
687
- * writeFileAtomicExclusive → return { removed }.
914
+ * writeSettingsFileAtomic → return { removed }.
688
915
  * Atomic write (temp+rename) upholds the never-truncate-on-crash invariant.
689
916
  * ENOENT is swallowed (file absent = nothing to strip). Other errors propagate.
690
917
  *
@@ -709,7 +936,7 @@ export async function stripUserSecurityDenyList(settingsPath) {
709
936
  if (stripped === existing) {
710
937
  return null;
711
938
  }
712
- await writeFileAtomicExclusive(settingsPath, stripped);
939
+ await writeSettingsFileAtomic(settingsPath, stripped);
713
940
  return { removed };
714
941
  }
715
942
  /** True for a non-null, non-array object literal. */
@@ -820,7 +1047,7 @@ export async function installSettings(claudeDir, rootDir, devflowDir, verbose) {
820
1047
  settingsExists = false;
821
1048
  }
822
1049
  if (!settingsExists) {
823
- await fs.writeFile(settingsPath, settingsContent, 'utf-8');
1050
+ await writeSettingsFileAtomic(settingsPath, settingsContent);
824
1051
  if (verbose) {
825
1052
  p.log.success('Settings configured');
826
1053
  }
@@ -847,7 +1074,7 @@ export async function installSettings(claudeDir, rootDir, devflowDir, verbose) {
847
1074
  // Already fully configured — nothing to do
848
1075
  return;
849
1076
  }
850
- await writeFileAtomicExclusive(settingsPath, JSON.stringify(existingParsed, null, 2) + '\n');
1077
+ await writeSettingsFileAtomic(settingsPath, JSON.stringify(existingParsed, null, 2) + '\n');
851
1078
  if (verbose) {
852
1079
  p.log.success('Settings updated with Devflow hooks and HUD');
853
1080
  }
@@ -885,11 +1112,12 @@ export async function installClaudeignore(gitRoot, rootDir, verbose) {
885
1112
  }
886
1113
  /**
887
1114
  * Discover git repository roots from Claude's project history.
888
- * Parses ~/.claude/history.jsonl for unique project paths that are valid git repos.
889
- * @param homeDir - Override home directory (dependency injection for tests)
1115
+ * Parses `<claudeDir>/history.jsonl` for unique project paths that are valid git repos.
1116
+ * @param claudeDir - The Claude Code directory whose history is read — the caller
1117
+ * passes getClaudeDirectory() (D-CLAUDE-CONFIG-DIR), tests a sandbox.
890
1118
  */
891
- export async function discoverProjectGitRoots(homeDir) {
892
- const historyPath = path.join(homeDir ?? os.homedir(), '.claude', 'history.jsonl');
1119
+ export async function discoverProjectGitRoots(claudeDir) {
1120
+ const historyPath = path.join(claudeDir, 'history.jsonl');
893
1121
  let content;
894
1122
  try {
895
1123
  content = await fs.readFile(historyPath, 'utf-8');
@@ -921,73 +1149,79 @@ export async function discoverProjectGitRoots(homeDir) {
921
1149
  return gitRoots.sort();
922
1150
  }
923
1151
  /**
924
- * Update .gitignore with Devflow entries (for local scope installs).
1152
+ * Current carve-out marker version. Bump when the block format changes — together
1153
+ * with the shell twin's stamp (ensure-root-gitignore) and the ensure-devflow-init
1154
+ * fast path, in one commit.
1155
+ *
1156
+ * D-GITIGNORE-V6 (#392): v6 adds the `!.devflow/project.json` completion line. A
1157
+ * v5-stamped project misses the v6 fast path once, gains that line (and nothing else
1158
+ * — its comment and every other line stay byte-identical) and is re-stamped v6, so
1159
+ * a team can commit `.devflow/project.json` without `git add -f`. v2–v5 inputs all
1160
+ * converge on the same completion order: policy, project, `.claudeignore`.
925
1161
  */
926
- export async function updateGitignore(gitRoot, verbose) {
927
- try {
928
- const gitignorePath = path.join(gitRoot, '.gitignore');
929
- const entriesToAdd = getGitignoreEntries();
930
- let gitignoreContent = '';
1162
+ const GITIGNORE_MARKER_V6 = '.root-gitignore-configured-v6';
1163
+ /**
1164
+ * Earlier markers, the unversioned (v1) one included — every one is removed
1165
+ * whenever the project is v6-stamped, on the fast path too: an older devflow can
1166
+ * re-stamp one beside v6, and the shell twin drops the same five.
1167
+ */
1168
+ const LEGACY_GITIGNORE_MARKERS = [
1169
+ '.root-gitignore-configured-v5',
1170
+ '.root-gitignore-configured-v4',
1171
+ '.root-gitignore-configured-v3',
1172
+ '.root-gitignore-configured-v2',
1173
+ '.root-gitignore-configured',
1174
+ ];
1175
+ /** Remove every legacy marker; an absent one is a no-op. Call only once v6 is stamped. */
1176
+ async function removeLegacyGitignoreMarkers(devflowDir) {
1177
+ for (const legacy of LEGACY_GITIGNORE_MARKERS) {
931
1178
  try {
932
- gitignoreContent = await fs.readFile(gitignorePath, 'utf-8');
933
- }
934
- catch { /* doesn't exist */ }
935
- const linesToAdd = computeGitignoreAppend(gitignoreContent, entriesToAdd);
936
- if (linesToAdd.length > 0) {
937
- const newContent = gitignoreContent
938
- ? `${gitignoreContent.trimEnd()}\n\n# Devflow local installation\n${linesToAdd.join('\n')}\n`
939
- : `# Devflow local installation\n${linesToAdd.join('\n')}\n`;
940
- await fs.writeFile(gitignorePath, newContent, 'utf-8');
941
- if (verbose) {
942
- p.log.success('.gitignore updated');
943
- }
944
- }
945
- }
946
- catch (error) {
947
- if (verbose) {
948
- p.log.warn(`Could not update .gitignore: ${error instanceof Error ? error.message : error}`);
1179
+ await fs.rm(path.join(devflowDir, legacy), { force: true });
949
1180
  }
1181
+ catch { /* ok if absent */ }
950
1182
  }
951
1183
  }
952
- /** Current carve-out marker version. Bump when the block format changes. */
953
- const GITIGNORE_MARKER_V3 = '.root-gitignore-configured-v3';
954
- /** Previous marker — removed when upgrading to v3. */
955
- const GITIGNORE_MARKER_V2 = '.root-gitignore-configured-v2';
956
1184
  /**
957
1185
  * Deterministically ensure the project root .gitignore applies the `.devflow/`
958
- * carve-out (local by default, feature knowledge + conventions.md shared via git).
1186
+ * carve-out (local by default; feature knowledge, conventions.md, the evidence
1187
+ * policy and the project settings shared via git).
959
1188
  *
960
- * Manages ONLY `.devflow/` — never `.claude/` — because user-scope installs must
961
- * not gitignore `.claude/`. This is the init-time counterpart to the always-on
962
- * src/assets/scripts/hooks/ensure-root-gitignore shell helper; both write the identical
963
- * DEVFLOW_GITIGNORE_BLOCK, so the two paths are byte-compatible and mutually
964
- * idempotent. Called unconditionally (independent of install scope and every
965
- * feature toggle) whenever a git root is known.
1189
+ * Manages ONLY `.devflow/` — never `.claude/` — because a project's `.claude/`
1190
+ * is its own to share or ignore. This is the init-time counterpart to the always-on
1191
+ * src/assets/scripts/hooks/ensure-root-gitignore shell helper; both resolve the same
1192
+ * shape for a given .gitignore — DEVFLOW_GITIGNORE_BLOCK, or
1193
+ * DEVFLOW_GITIGNORE_BLOCK_WITHOUT_CLAUDEIGNORE when the project owns that entry — and
1194
+ * emit identical bytes, so the two paths are byte-compatible and mutually idempotent.
1195
+ * Called unconditionally (independent of every feature toggle) whenever a git
1196
+ * root is known.
966
1197
  *
967
- * Uses a versioned marker file (`.devflow/.root-gitignore-configured-v3`) for fast-path
968
- * detection — the same pattern as the shell twin. Bumping the version forces existing
969
- * installs to re-run once and upgrade their block (v2→v3: adds conventions.md line).
1198
+ * Uses a versioned project-local marker file (`.devflow/.root-gitignore-configured-v6`)
1199
+ * for fast-path detection — the same pattern as the shell twin. The marker is a claim,
1200
+ * not proof, so even a marked install re-reads .gitignore and re-runs
1201
+ * computeDevflowGitignore; bumping the version forces a re-run once per install, which
1202
+ * is how a v5-marked project gains the project line and is re-stamped v6.
970
1203
  *
971
- * Idempotent: already-v3 installs return immediately (marker fast-path). Errors are
972
- * swallowed (verbose-logged) — a gitignore write must never abort init.
1204
+ * Idempotent: computeDevflowGitignore returns null for a converged file, so a
1205
+ * marked install performs one read and no write. Errors are swallowed
1206
+ * (verbose-logged) — a gitignore write must never abort init.
973
1207
  */
974
1208
  export async function ensureDevflowGitignore(gitRoot, verbose) {
975
1209
  try {
976
1210
  const devflowDir = path.join(gitRoot, '.devflow');
977
- const markerV3 = path.join(devflowDir, GITIGNORE_MARKER_V3);
1211
+ const markerV6 = path.join(devflowDir, GITIGNORE_MARKER_V6);
978
1212
  const gitignorePath = path.join(gitRoot, '.gitignore');
979
- // Fast-path with verification: v3 marker normally means the block is installed,
1213
+ // Fast-path with verification: v6 marker normally means the block is installed,
980
1214
  // but the marker is a claim, not proof — a merge-conflict resolution may have
981
1215
  // dropped the block. Even when the marker exists, read .gitignore (one cheap
982
1216
  // read) and run computeDevflowGitignore; write only when it returns non-null.
983
- // Idempotent: sentinel present → computeDevflowGitignore returns null → no write.
984
- let v3Marked = false;
1217
+ // Idempotent: converged file → computeDevflowGitignore returns null → no write.
1218
+ let v6Marked = false;
985
1219
  try {
986
- await fs.access(markerV3);
987
- v3Marked = true;
1220
+ await fs.access(markerV6);
1221
+ v6Marked = true;
988
1222
  }
989
1223
  catch { /* absent */ }
990
- if (v3Marked) {
1224
+ if (v6Marked) {
991
1225
  let existingContent = '';
992
1226
  try {
993
1227
  existingContent = await fs.readFile(gitignorePath, 'utf-8');
@@ -997,9 +1231,10 @@ export async function ensureDevflowGitignore(gitRoot, verbose) {
997
1231
  if (healContent !== null) {
998
1232
  await fs.writeFile(gitignorePath, healContent, 'utf-8');
999
1233
  if (verbose) {
1000
- p.log.success('.gitignore configured (.devflow/ local; feature knowledge + conventions shared)');
1234
+ p.log.success('.gitignore configured (.devflow/ local; feature knowledge + conventions + retired policy.json + project settings shared)');
1001
1235
  }
1002
1236
  }
1237
+ await removeLegacyGitignoreMarkers(devflowDir);
1003
1238
  return;
1004
1239
  }
1005
1240
  let gitignoreContent = '';
@@ -1011,16 +1246,13 @@ export async function ensureDevflowGitignore(gitRoot, verbose) {
1011
1246
  if (newContent !== null) {
1012
1247
  await fs.writeFile(gitignorePath, newContent, 'utf-8');
1013
1248
  if (verbose) {
1014
- p.log.success('.gitignore configured (.devflow/ local; feature knowledge + conventions shared)');
1249
+ p.log.success('.gitignore configured (.devflow/ local; feature knowledge + conventions + retired policy.json + project settings shared)');
1015
1250
  }
1016
1251
  }
1017
- // Stamp v3 marker so subsequent runs fast-path; drop the legacy v2 marker.
1252
+ // Stamp v6 marker so subsequent runs fast-path; drop every legacy marker.
1018
1253
  await fs.mkdir(devflowDir, { recursive: true });
1019
- await fs.writeFile(markerV3, '', 'utf-8');
1020
- try {
1021
- await fs.rm(path.join(devflowDir, GITIGNORE_MARKER_V2), { force: true });
1022
- }
1023
- catch { /* ok if absent */ }
1254
+ await fs.writeFile(markerV6, '', 'utf-8');
1255
+ await removeLegacyGitignoreMarkers(devflowDir);
1024
1256
  }
1025
1257
  catch (error) {
1026
1258
  if (verbose) {
@@ -1028,21 +1260,4 @@ export async function ensureDevflowGitignore(gitRoot, verbose) {
1028
1260
  }
1029
1261
  }
1030
1262
  }
1031
- /**
1032
- * Create .devflow/docs/ directory structure for Devflow artifacts.
1033
- */
1034
- export async function createDocsStructure(verbose) {
1035
- const docsDir = getDocsDir(process.cwd());
1036
- try {
1037
- await Promise.all([
1038
- fs.mkdir(path.join(docsDir, 'status', 'compact'), { recursive: true }),
1039
- fs.mkdir(path.join(docsDir, 'reviews'), { recursive: true }),
1040
- fs.mkdir(path.join(docsDir, 'releases'), { recursive: true }),
1041
- ]);
1042
- if (verbose) {
1043
- p.log.success('.devflow/docs/ structure ready');
1044
- }
1045
- }
1046
- catch { /* may already exist */ }
1047
- }
1048
1263
  //# sourceMappingURL=post-install.js.map