devflow-kit 2.3.0 → 2.5.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 (169) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +86 -18
  3. package/dist/agents/git.md +824 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/attribution-prompts.js +144 -0
  6. package/dist/cli/commands/compliance-prompts.js +7 -11
  7. package/dist/cli/commands/compliance.js +23 -1
  8. package/dist/cli/commands/init-seed.js +69 -30
  9. package/dist/cli/commands/init.js +551 -77
  10. package/dist/cli/commands/install-report.js +205 -0
  11. package/dist/cli/commands/knowledge/index.js +2 -2
  12. package/dist/cli/commands/knowledge/toggle.js +27 -37
  13. package/dist/cli/commands/learning.js +37 -30
  14. package/dist/cli/commands/memory.js +79 -69
  15. package/dist/cli/commands/prompt-io.js +38 -0
  16. package/dist/cli/commands/proxy.js +91 -31
  17. package/dist/cli/commands/security.js +76 -16
  18. package/dist/cli/commands/skills.js +53 -7
  19. package/dist/cli/commands/tracker-prompts.js +145 -0
  20. package/dist/cli/commands/tracker.js +405 -0
  21. package/dist/cli/commands/uninstall.js +233 -77
  22. package/dist/cli.js +2 -0
  23. package/dist/commands/bug-analysis.md +22 -4
  24. package/dist/commands/code-review.md +44 -15
  25. package/dist/commands/debug.md +20 -6
  26. package/dist/commands/dynamic-build.md +289 -67
  27. package/dist/commands/dynamic-plan.md +60 -21
  28. package/dist/commands/dynamic-profile.md +1 -1
  29. package/dist/commands/dynamic-tickets.md +58 -8
  30. package/dist/commands/explore.md +2 -2
  31. package/dist/commands/implement.md +241 -53
  32. package/dist/commands/plan.md +88 -17
  33. package/dist/commands/release.md +64 -17
  34. package/dist/commands/resolve.md +138 -58
  35. package/dist/commands/self-review.md +2 -2
  36. package/dist/core/agent-models.js +55 -12
  37. package/dist/core/assets.js +58 -2
  38. package/dist/core/evidence-policy.js +147 -0
  39. package/dist/core/feature-config.js +130 -64
  40. package/dist/core/feature-switch.js +112 -0
  41. package/dist/core/flags.js +149 -8
  42. package/dist/core/manifest.js +33 -7
  43. package/dist/core/mds-variants.js +861 -0
  44. package/dist/core/model-discovery.js +12 -1
  45. package/dist/core/plugins.js +357 -9
  46. package/dist/core/project-paths.js +1 -1
  47. package/dist/core/proxy-log.js +8 -4
  48. package/dist/core/proxy-state.js +100 -43
  49. package/dist/core/reference-sweep.js +136 -0
  50. package/dist/core/tracker.js +407 -0
  51. package/dist/skills/git/references/decision-markers.md +19 -0
  52. package/dist/skills/git/references/learn-conventions.md +56 -0
  53. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  54. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  55. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  56. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  57. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  58. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  59. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  60. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  61. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  62. package/dist/skills/git/references/publication-gate.md +13 -0
  63. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  64. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  65. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  66. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  67. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  68. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  69. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  70. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  71. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  72. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  73. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  74. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  75. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  76. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  77. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  78. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  79. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  80. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  81. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  82. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  83. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  84. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  85. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  86. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  87. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  88. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  89. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  90. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  91. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  92. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  93. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  94. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  95. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  96. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  97. package/dist/skills/git/references/trust-rule.md +7 -0
  98. package/dist/targets/claude-code/installer.js +1213 -31
  99. package/dist/targets/claude-code/legacy.js +5 -0
  100. package/dist/targets/claude-code/post-install.js +303 -104
  101. package/dist/targets/claude-code/tracker-install.js +161 -0
  102. package/package.json +4 -3
  103. package/src/assets/agents/code.md +42 -4
  104. package/src/assets/agents/design.md +1 -1
  105. package/src/assets/agents/git.mds +827 -0
  106. package/src/assets/agents/knowledge.md +1 -1
  107. package/src/assets/agents/learning.md +11 -0
  108. package/src/assets/agents/synthesize.md +1 -1
  109. package/src/assets/agents/test.md +16 -5
  110. package/src/assets/agents/tracker.md +467 -0
  111. package/src/assets/agents/validate.md +7 -5
  112. package/src/assets/commands/_partials/_engine.mds +11 -9
  113. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  114. package/src/assets/commands/_partials/_knowledge.mds +2 -2
  115. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  116. package/src/assets/commands/_partials/_preamble.mds +1 -1
  117. package/src/assets/commands/_partials/_publication.mds +3 -1
  118. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  119. package/src/assets/commands/_partials/_tracker.mds +18 -0
  120. package/src/assets/commands/_partials/_wave.mds +16 -10
  121. package/src/assets/commands/bug-analysis.mds +15 -5
  122. package/src/assets/commands/code-review.mds +34 -14
  123. package/src/assets/commands/debug.mds +11 -4
  124. package/src/assets/commands/dynamic-build.mds +227 -41
  125. package/src/assets/commands/dynamic-plan.mds +35 -13
  126. package/src/assets/commands/dynamic-tickets.mds +47 -5
  127. package/src/assets/commands/implement.mds +206 -52
  128. package/src/assets/commands/plan.mds +70 -17
  129. package/src/assets/commands/release.md +64 -17
  130. package/src/assets/commands/resolve.mds +126 -56
  131. package/src/assets/mds/git/_pr.mds +331 -0
  132. package/src/assets/mds/git/_references.mds +135 -0
  133. package/src/assets/mds/tracker/_common.mds +156 -0
  134. package/src/assets/mds/tracker/_github.mds +472 -0
  135. package/src/assets/mds/tracker/_jira.mds +407 -0
  136. package/src/assets/mds/tracker/_linear.mds +449 -0
  137. package/src/assets/mds/tracker/_mcp.mds +299 -0
  138. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  139. package/src/assets/scripts/hooks/background-memory-update +14 -9
  140. package/src/assets/scripts/hooks/capture-prompt +6 -2
  141. package/src/assets/scripts/hooks/capture-question +6 -2
  142. package/src/assets/scripts/hooks/capture-turn +6 -2
  143. package/src/assets/scripts/hooks/ensure-devflow-init +1 -1
  144. package/src/assets/scripts/hooks/ensure-proxy +64 -5
  145. package/src/assets/scripts/hooks/ensure-root-gitignore +161 -60
  146. package/src/assets/scripts/hooks/hook-log-init +3 -1
  147. package/src/assets/scripts/hooks/json-helper.cjs +223 -5
  148. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -1
  149. package/src/assets/scripts/hooks/memory-worker +15 -8
  150. package/src/assets/scripts/hooks/pre-compact-memory +12 -8
  151. package/src/assets/scripts/hooks/preamble +1 -4
  152. package/src/assets/scripts/hooks/queue-append +68 -24
  153. package/src/assets/scripts/hooks/session-start-context +355 -8
  154. package/src/assets/scripts/hooks/session-start-memory +12 -8
  155. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  156. package/src/assets/scripts/redact-secrets.cjs +490 -62
  157. package/src/assets/scripts/release-trace.cjs +1143 -0
  158. package/src/assets/scripts/resolve-evidence-policy.cjs +1065 -0
  159. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  160. package/src/assets/skills/compliance/SKILL.md +2 -0
  161. package/src/assets/skills/docs-framework/SKILL.md +5 -3
  162. package/src/assets/skills/git/SKILL.md +8 -82
  163. package/src/assets/skills/git/references/github-api.md +179 -141
  164. package/src/assets/skills/git/references/patterns.md +11 -12
  165. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  166. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  167. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  168. package/src/targets/claude-code/templates/settings.json +0 -4
  169. package/src/assets/agents/git.md +0 -938
@@ -7,6 +7,11 @@
7
7
  * Users who skip major versions should run uninstall + reinstall.
8
8
  *
9
9
  * Organized by era to make scanning for duplicates tractable.
10
+ *
11
+ * These lists are FROZEN deletion manifests for names that once shipped: the
12
+ * `*_V2` suffix is the frozen spelling of an era, not a version to bump, and a
13
+ * name is removed from a list only when its pruning window has passed — never
14
+ * renamed to match a current registry name (avoids PF-012).
10
15
  */
11
16
  /** Pre-v1.0.0: devflow- prefixed skill names from the original install scheme. */
12
17
  const LEGACY_SKILLS_PRE_V1 = [
@@ -1,5 +1,5 @@
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
4
  import * as os from 'os';
5
5
  import * as p from '@clack/prompts';
@@ -25,6 +25,28 @@ export function computeGitignoreAppend(existingContent, entries) {
25
25
  const existingLines = existingContent.split('\n').map(l => l.trim());
26
26
  return entries.filter(entry => !existingLines.includes(entry));
27
27
  }
28
+ /**
29
+ * Sentinel line whose presence means the current (v3-and-later) carve-out block is
30
+ * installed. Devflow-unique: no user writes `!.devflow/conventions.md` by hand.
31
+ */
32
+ const DEVFLOW_GITIGNORE_SENTINEL_V3 = '!.devflow/conventions.md';
33
+ /** Sentinel line whose presence means the v2 carve-out block is installed (no conventions.md line). */
34
+ const DEVFLOW_GITIGNORE_SENTINEL_V2 = '!.devflow/features/*/KNOWLEDGE.md';
35
+ /**
36
+ * The block's final line: ignore the devflow-managed `.claudeignore` file.
37
+ * NOT a sentinel — users legitimately author this line themselves.
38
+ */
39
+ const CLAUDEIGNORE_LINE = '.claudeignore';
40
+ /** A user's explicit un-ignore of `.claudeignore`; never overridden. */
41
+ const CLAUDEIGNORE_NEGATION = '!.claudeignore';
42
+ /**
43
+ * Re-includes the team-owned evidence policy file (D-GITIGNORE-V5). A COMPLETION
44
+ * line, never a presence sentinel: users may author it themselves, so its presence
45
+ * proves nothing about the devflow block (avoids PF-059). It sits after `.devflow/*`
46
+ * (which it overrides under last-match-wins) and before `.claudeignore`, so the
47
+ * block's final line stays `.claudeignore`.
48
+ */
49
+ const DEVFLOW_POLICY_LINE = '!.devflow/policy.json';
28
50
  /**
29
51
  * The shared .devflow/ gitignore block. Everything under .devflow/ is local
30
52
  * (memory, learning, docs, locks) EXCEPT:
@@ -32,6 +54,9 @@ export function computeGitignoreAppend(existingContent, entries) {
32
54
  * + committed (the Knowledge agent commits them at workflow end).
33
55
  * - conventions.md: naming-convention authority written by the Git learn-conventions
34
56
  * operation; GIT-TRACKED so the team shares a single naming source.
57
+ * - policy.json: the team-owned evidence policy, read from the default branch by
58
+ * resolve-evidence-policy.cjs; GIT-TRACKED so a team can commit it without `git add -f`.
59
+ * Devflow never writes it.
35
60
  *
36
61
  * Re-including files under an ignored tree needs a `dir/*` + `!dir/keep` pair at
37
62
  * each level — a bare `.devflow/` excludes the directory so git never descends and
@@ -39,65 +64,121 @@ export function computeGitignoreAppend(existingContent, entries) {
39
64
  *
40
65
  * Kept BYTE-IDENTICAL to the block emitted by src/assets/scripts/hooks/ensure-root-gitignore
41
66
  * 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
67
  */
45
- export const DEVFLOW_GITIGNORE_BLOCK = [
68
+ const DEVFLOW_GITIGNORE_BLOCK_LINES = [
46
69
  '# 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.',
70
+ '# Shared via git: feature knowledge bases under .devflow/features/ (index.md and',
71
+ '# every {slug}/KNOWLEDGE.md), .devflow/conventions.md (naming authority) and',
72
+ '# .devflow/policy.json (evidence policy). To stop sharing the first two, re-add',
73
+ '# `.devflow/features/` or `.devflow/conventions.md` to your own .gitignore.',
51
74
  '.devflow/*',
52
75
  '!.devflow/features/',
53
76
  '.devflow/features/*',
54
77
  '!.devflow/features/index.md',
55
78
  '!.devflow/features/*/',
56
79
  '.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';
80
+ DEVFLOW_GITIGNORE_SENTINEL_V2,
81
+ DEVFLOW_GITIGNORE_SENTINEL_V3,
82
+ DEVFLOW_POLICY_LINE,
83
+ CLAUDEIGNORE_LINE,
84
+ ];
85
+ /** The full carve-out block, `.claudeignore` line included. */
86
+ export const DEVFLOW_GITIGNORE_BLOCK = DEVFLOW_GITIGNORE_BLOCK_LINES.join('\n');
87
+ /**
88
+ * The carve-out block without its final `.claudeignore` line — emitted instead of
89
+ * the full block when the target .gitignore already carries a `.claudeignore` or
90
+ * `!.claudeignore` entry of the user's own.
91
+ */
92
+ export const DEVFLOW_GITIGNORE_BLOCK_WITHOUT_CLAUDEIGNORE = DEVFLOW_GITIGNORE_BLOCK_LINES.slice(0, -1).join('\n');
64
93
  /** The legacy wholesale comment our pre-carve-out writers emitted. */
65
94
  const LEGACY_DEVFLOW_COMMENT = '# Devflow runtime data (local by default; remove to share via git)';
66
95
  /**
67
96
  * 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`.
70
- *
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).
97
+ * `.devflow/` with the feature-knowledge + conventions.md + policy.json carve-out —
98
+ * or `null` when no change is needed. Idempotent: feeding its own output back returns `null`.
99
+ *
100
+ * D-GITIGNORE-V5: the block is detected ONLY by its own devflow-unique sentinel
101
+ * (`!.devflow/conventions.md`). `.claudeignore` and `!.devflow/policy.json` are
102
+ * COMPLETION lines — users legitimately author both themselves — so each is topped up
103
+ * when missing and never read as proof the block exists. A presence check on a
104
+ * user-authored line inverts both halves of the contract: projects that already carry
105
+ * that line are told the block is installed when it is not, and a user's
106
+ * `!.claudeignore` un-ignore is silently reversed by re-appending `.claudeignore`
107
+ * under last-match-wins (avoids PF-059).
108
+ *
109
+ * `hasClaudeignoreEntry` is true when some whole line, trimmed, is exactly
110
+ * `.claudeignore` OR `!.claudeignore`. Treating both forms as "present" both honours
111
+ * an un-ignore and makes every branch converge on re-run. `hasPolicyLine` is true when
112
+ * some whole line, trimmed, is exactly `!.devflow/policy.json`. The missing completion
113
+ * lines, in block order, are [policy line, `.claudeignore` (only when
114
+ * `!hasClaudeignoreEntry`)].
115
+ *
116
+ * 1. A `/.devflow/` line present → `null` (user opt-out; respect manual config).
117
+ * 2. v3 sentinel present → append the missing completion lines; `null` when none are
118
+ * missing. This is the v4→v5 upgrade: a v4 block gains only the policy line, after
119
+ * its `.claudeignore` line, and keeps its old comment.
120
+ * 3. v2 sentinel present, no v3 → append `!.devflow/conventions.md` followed by the
121
+ * missing completion lines.
122
+ * 4. Legacy bare `.devflow/` present → strip it (+ our old comment), then append the
123
+ * block; no block at all → append the block. The block is emitted MINUS its final
124
+ * `.claudeignore` line when `hasClaudeignoreEntry`. A user's own policy line is
125
+ * duplicated harmlessly here, and the re-run is a no-op.
126
+ * 5. Neither completion line is ever a sentinel. The marker file
127
+ * (`.devflow/.root-gitignore-configured-v5`) is a fast-path claim, never proof.
128
+ *
129
+ * Line matching is whole-line, whitespace-tolerant, exact text — never substring.
130
+ * Both append forms are mirrored byte-for-byte in the shell twin
131
+ * (src/assets/scripts/hooks/ensure-root-gitignore), which is what the cross-implementation
132
+ * parity table in tests/shell-hooks.test.ts pins.
76
133
  */
77
134
  export function computeDevflowGitignore(existingContent) {
78
135
  const lines = existingContent.split('\n');
79
136
  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/'))
137
+ const hasClaudeignoreEntry = trimmed.some(l => l === CLAUDEIGNORE_LINE || l === CLAUDEIGNORE_NEGATION);
138
+ const hasPolicyLine = trimmed.includes(DEVFLOW_POLICY_LINE);
139
+ /** The block-completing lines this file lacks, in block order. */
140
+ const missingCompletionLines = [
141
+ ...(hasPolicyLine ? [] : [DEVFLOW_POLICY_LINE]),
142
+ ...(hasClaudeignoreEntry ? [] : [CLAUDEIGNORE_LINE]),
143
+ ];
144
+ /**
145
+ * Continue an existing devflow block with the lines it is missing. One newline
146
+ * guard, no blank separator — the appended lines belong to the block above them.
147
+ */
148
+ const appendLines = (body, block) => `${body}${body.endsWith('\n') ? '' : '\n'}${block}\n`;
149
+ /**
150
+ * Start a new block after unrelated content: one blank separator line. Existing
151
+ * trailing newlines are preserved verbatim (no trimEnd, no blank-line dedupe) so
152
+ * the shell twin's `tail -c 1` guard produces the identical bytes.
153
+ */
154
+ const appendBlock = (body, block) => body.length === 0
155
+ ? `${block}\n`
156
+ : `${body}${body.endsWith('\n') ? '' : '\n'}\n${block}\n`;
157
+ // 1. User opt-out wins over every sentinel.
158
+ if (trimmed.includes('/.devflow/'))
83
159
  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).
160
+ // 2. Block installed (v3 and later) — top up only the completion lines it lacks.
161
+ if (trimmed.includes(DEVFLOW_GITIGNORE_SENTINEL_V3)) {
162
+ return missingCompletionLines.length === 0
163
+ ? null
164
+ : appendLines(existingContent, missingCompletionLines.join('\n'));
165
+ }
166
+ // 3. v2 block installed — append the lines it lacks, in block order.
86
167
  if (trimmed.includes(DEVFLOW_GITIGNORE_SENTINEL_V2)) {
87
- const sep = existingContent.endsWith('\n') ? '' : '\n';
88
- return `${existingContent}${sep}${DEVFLOW_GITIGNORE_SENTINEL_V3}\n`;
168
+ return appendLines(existingContent, [DEVFLOW_GITIGNORE_SENTINEL_V3, ...missingCompletionLines].join('\n'));
89
169
  }
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/')) {
170
+ // 4. No devflow block — install one, respecting any .claudeignore entry of the user's own.
171
+ const block = hasClaudeignoreEntry
172
+ ? DEVFLOW_GITIGNORE_BLOCK_WITHOUT_CLAUDEIGNORE
173
+ : DEVFLOW_GITIGNORE_BLOCK;
174
+ if (trimmed.includes('.devflow/')) {
94
175
  // Upgrade our legacy wholesale entry: drop the bare line + old comment, append block.
95
176
  const kept = lines
96
177
  .filter(l => l.trim() !== '.devflow/' && l.trim() !== LEGACY_DEVFLOW_COMMENT)
97
178
  .join('\n');
98
- return append(kept);
179
+ return appendBlock(kept, block);
99
180
  }
100
- return append(existingContent);
181
+ return appendBlock(existingContent, block);
101
182
  }
102
183
  /**
103
184
  * Merge Devflow deny entries into an existing settings JSON object.
@@ -531,11 +612,14 @@ export async function installManagedSettings(rootDir, verbose) {
531
612
  return false;
532
613
  }
533
614
  try {
534
- execSync(`sudo mkdir -p '${managedDir}'`, { stdio: 'inherit' });
535
- // Write via sudo tee to avoid shell quoting issues with the JSON content
615
+ execFileSync('sudo', ['mkdir', '-p', managedDir], { stdio: 'inherit' });
616
+ // Stage the JSON in a file and copy it with sudo. Every sudo call takes an
617
+ // argv (execFileSync, no shell), so no path is ever re-parsed by a shell — a
618
+ // package root under a home directory holding a quote cannot break or extend
619
+ // the root command.
536
620
  const tmpFile = path.join(rootDir, '.managed-settings-tmp.json');
537
621
  await fs.writeFile(tmpFile, content, 'utf-8');
538
- execSync(`sudo cp '${tmpFile}' '${managedPath}'`, { stdio: 'inherit' });
622
+ execFileSync('sudo', ['cp', tmpFile, managedPath], { stdio: 'inherit' });
539
623
  await fs.rm(tmpFile, { force: true });
540
624
  if (verbose) {
541
625
  p.log.success(`Managed settings written to ${managedPath} (via sudo)`);
@@ -557,14 +641,26 @@ export async function installManagedSettings(rootDir, verbose) {
557
641
  * 1. Try direct write/delete
558
642
  * 2. If EACCES and TTY, ask user before sudo
559
643
  * 3. Non-TTY: return false (caller logs preservation message)
644
+ *
645
+ * `managedPathOverride` exists so a test can point the removal at a temp file;
646
+ * production callers omit it and get the platform's system path. It is a
647
+ * parameter, deliberately not an environment variable: this function may run
648
+ * `sudo rm` / `sudo cp` on the path, and an env-selectable target would let
649
+ * whoever controls the environment aim a root write the user consented to for
650
+ * "managed settings" at any file.
560
651
  */
561
- export async function removeManagedSettings(rootDir, verbose) {
652
+ export async function removeManagedSettings(rootDir, verbose, managedPathOverride) {
562
653
  let managedPath;
563
- try {
564
- managedPath = getManagedSettingsPath();
654
+ if (managedPathOverride !== undefined) {
655
+ managedPath = managedPathOverride;
565
656
  }
566
- catch {
567
- return false;
657
+ else {
658
+ try {
659
+ managedPath = getManagedSettingsPath();
660
+ }
661
+ catch {
662
+ return false;
663
+ }
568
664
  }
569
665
  let existingContent;
570
666
  try {
@@ -639,12 +735,12 @@ export async function removeManagedSettings(rootDir, verbose) {
639
735
  }
640
736
  try {
641
737
  if (shouldDelete) {
642
- execSync(`sudo rm '${managedPath}'`, { stdio: 'inherit' });
738
+ execFileSync('sudo', ['rm', managedPath], { stdio: 'inherit' });
643
739
  }
644
740
  else {
645
741
  const tmpFile = path.join(rootDir, '.managed-settings-tmp.json');
646
742
  await fs.writeFile(tmpFile, updatedContent, 'utf-8');
647
- execSync(`sudo cp '${tmpFile}' '${managedPath}'`, { stdio: 'inherit' });
743
+ execFileSync('sudo', ['cp', tmpFile, managedPath], { stdio: 'inherit' });
648
744
  await fs.rm(tmpFile, { force: true });
649
745
  }
650
746
  if (verbose) {
@@ -712,10 +808,95 @@ export async function stripUserSecurityDenyList(settingsPath) {
712
808
  await writeFileAtomicExclusive(settingsPath, stripped);
713
809
  return { removed };
714
810
  }
811
+ /** True for a non-null, non-array object literal. */
812
+ function isPlainObject(value) {
813
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
814
+ }
815
+ /**
816
+ * Every `command` string carried by a hook matcher, tolerating foreign shapes.
817
+ * Returns an empty array for anything that is not `{ hooks: [{ command: string }] }`.
818
+ */
819
+ function hookCommandsOf(matcher) {
820
+ if (!isPlainObject(matcher) || !Array.isArray(matcher.hooks))
821
+ return [];
822
+ return matcher.hooks
823
+ .filter(isPlainObject)
824
+ .map((h) => h.command)
825
+ .filter((c) => typeof c === 'string');
826
+ }
827
+ /**
828
+ * Merge Devflow's template hook entries and top-level fields into an existing
829
+ * parsed settings object. Idempotent by exact command string — a hook that is
830
+ * already present is skipped. Sets `statusLine` only when the user has no existing
831
+ * value. Attribution is NOT injected here — managed by the flags pipeline (D27).
832
+ * Mutates `existing` in place.
833
+ *
834
+ * D-SETTINGS-1: merge strategy — never replace; only add devflow entries that are absent.
835
+ * Returns { changed: true } when any field was added.
836
+ *
837
+ * `existing` comes from a hand-editable file, so every branch is shape-guarded:
838
+ * a `hooks` value (or per-event value) that is not the expected object/array shape
839
+ * is left untouched rather than overwritten or thrown on (applies PF-023 — validate
840
+ * at the sink that mutates).
841
+ *
842
+ * Exported for testing.
843
+ */
844
+ export function mergeDevflowSettingsTemplate(existing, template) {
845
+ let changed = false;
846
+ // Merge hook entries — idempotent by exact command string
847
+ const tmplHooks = isPlainObject(template.hooks) ? template.hooks : {};
848
+ const existingHooksRaw = existing.hooks;
849
+ if (existingHooksRaw === undefined || isPlainObject(existingHooksRaw)) {
850
+ const existingHooks = existingHooksRaw ?? {};
851
+ let hooksChanged = false;
852
+ for (const [event, matchers] of Object.entries(tmplHooks)) {
853
+ if (!Array.isArray(matchers))
854
+ continue;
855
+ for (const matcher of matchers) {
856
+ const cmd = hookCommandsOf(matcher)[0];
857
+ if (!cmd)
858
+ continue;
859
+ const current = existingHooks[event];
860
+ if (current !== undefined && !Array.isArray(current))
861
+ break; // foreign shape — leave the event alone
862
+ const eventArr = current ?? [];
863
+ if (eventArr.some((m) => hookCommandsOf(m).includes(cmd)))
864
+ continue;
865
+ eventArr.push(matcher);
866
+ existingHooks[event] = eventArr;
867
+ hooksChanged = true;
868
+ }
869
+ }
870
+ // Attach only when something was actually added — never introduce an empty
871
+ // `hooks` key into a settings.json that had none.
872
+ if (hooksChanged) {
873
+ existing.hooks = existingHooks;
874
+ changed = true;
875
+ }
876
+ }
877
+ // Set statusLine only if the user has none
878
+ if (existing.statusLine === undefined && template.statusLine !== undefined) {
879
+ existing.statusLine = template.statusLine;
880
+ changed = true;
881
+ }
882
+ // attribution: owned exclusively by the flags pipeline (applyFlags/stripFlags, D27) — a second writer here would race it.
883
+ return { changed };
884
+ }
715
885
  /**
716
886
  * Install or update settings.json with Devflow configuration.
717
- * Prompts interactively in TTY mode when settings already exist.
718
- * In non-TTY mode, skips override (safe default).
887
+ *
888
+ * Strategy (D-SETTINGS-1: merge, never overwrite wholesale):
889
+ * - Fresh file: write the template directly.
890
+ * - Existing file: MERGE devflow hook entries and fields into the parsed object.
891
+ * Idempotent by exact command string — existing hooks are never duplicated or removed.
892
+ * Preserves every user key (env, permissions, apiKeyHelper, model, etc.) untouched.
893
+ * - Parse failure: warn and skip; file left byte-identical (never clobber a broken file).
894
+ *
895
+ * The merge is additive only, so it runs without a confirmation prompt: init is called
896
+ * from inside an active spinner (a prompt would render on top of it), and declining
897
+ * could not protect the file anyway — init's own settings pass rewrites the hook set
898
+ * unconditionally right afterwards. A prompt whose answer changes nothing is worse
899
+ * than no prompt.
719
900
  *
720
901
  * The deny list is handled by init's dedicated security step
721
902
  * (applyUserSecurityDenyList / installManagedSettings) after installSettings completes.
@@ -741,38 +922,30 @@ export async function installSettings(claudeDir, rootDir, devflowDir, verbose) {
741
922
  }
742
923
  return;
743
924
  }
744
- // Settings exist — check if they already have hooks
745
- let hasHooks = false;
925
+ // Settings exist — parse and merge (never overwrite wholesale)
926
+ let existingParsed;
746
927
  try {
747
- const existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
748
- hasHooks = !!existing.hooks;
928
+ existingParsed = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
749
929
  }
750
- catch { /* parse error = treat as no hooks */ }
751
- if (hasHooks) {
752
- // Settings already configured with hooks — nothing to do
930
+ catch {
931
+ // Parse failure — warn and skip; file left byte-identical (D-SETTINGS-1)
932
+ if (verbose) {
933
+ p.log.warn('settings.json could not be parsed — Devflow hooks not added. ' +
934
+ 'Fix the JSON manually and re-run devflow init.');
935
+ }
753
936
  return;
754
937
  }
755
- // Settings exist without hooks — prompt in TTY, warn in non-TTY
756
- if (process.stdin.isTTY) {
757
- const confirmed = await p.confirm({
758
- message: 'settings.json exists without hooks (Working Memory needs hooks). Override?',
759
- initialValue: true,
760
- });
761
- if (p.isCancel(confirmed)) {
762
- p.cancel('Installation cancelled.');
763
- process.exit(0);
764
- }
765
- if (confirmed) {
766
- await fs.writeFile(settingsPath, settingsContent, 'utf-8');
767
- p.log.success('Settings overridden');
768
- }
769
- else {
770
- p.log.info('Keeping existing settings');
771
- }
938
+ const templateParsed = JSON.parse(settingsContent);
939
+ // Merge template into existing (mutates existingParsed in place).
940
+ // If nothing needs to be added the write is skipped entirely.
941
+ const { changed } = mergeDevflowSettingsTemplate(existingParsed, templateParsed);
942
+ if (!changed) {
943
+ // Already fully configured — nothing to do
944
+ return;
772
945
  }
773
- else {
774
- p.log.warn('Settings exist without hooks. Working Memory requires hooks.');
775
- p.log.info('Re-run interactively to configure, or manually add hooks to settings.json');
946
+ await writeFileAtomicExclusive(settingsPath, JSON.stringify(existingParsed, null, 2) + '\n');
947
+ if (verbose) {
948
+ p.log.success('Settings updated with Devflow hooks and HUD');
776
949
  }
777
950
  }
778
951
  catch (error) {
@@ -872,45 +1045,73 @@ export async function updateGitignore(gitRoot, verbose) {
872
1045
  }
873
1046
  }
874
1047
  }
875
- /** Current carve-out marker version. Bump when the block format changes. */
876
- const GITIGNORE_MARKER_V3 = '.root-gitignore-configured-v3';
877
- /** Previous marker — removed when upgrading to v3. */
878
- const GITIGNORE_MARKER_V2 = '.root-gitignore-configured-v2';
1048
+ /**
1049
+ * Current carve-out marker version. Bump when the block format changes — together
1050
+ * with the shell twin's stamp (ensure-root-gitignore) and the ensure-devflow-init
1051
+ * fast path, in one commit (D-GITIGNORE-V5).
1052
+ */
1053
+ const GITIGNORE_MARKER_V5 = '.root-gitignore-configured-v5';
1054
+ /**
1055
+ * Earlier markers, the unversioned (v1) one included — every one is removed
1056
+ * whenever the project is v5-stamped, on the fast path too: an older devflow can
1057
+ * re-stamp one beside v5, and the shell twin drops the same four.
1058
+ */
1059
+ const LEGACY_GITIGNORE_MARKERS = [
1060
+ '.root-gitignore-configured-v4',
1061
+ '.root-gitignore-configured-v3',
1062
+ '.root-gitignore-configured-v2',
1063
+ '.root-gitignore-configured',
1064
+ ];
1065
+ /** Remove every legacy marker; an absent one is a no-op. Call only once v5 is stamped. */
1066
+ async function removeLegacyGitignoreMarkers(devflowDir) {
1067
+ for (const legacy of LEGACY_GITIGNORE_MARKERS) {
1068
+ try {
1069
+ await fs.rm(path.join(devflowDir, legacy), { force: true });
1070
+ }
1071
+ catch { /* ok if absent */ }
1072
+ }
1073
+ }
879
1074
  /**
880
1075
  * Deterministically ensure the project root .gitignore applies the `.devflow/`
881
- * carve-out (local by default, feature knowledge + conventions.md shared via git).
1076
+ * carve-out (local by default; feature knowledge, conventions.md and the evidence
1077
+ * policy shared via git).
882
1078
  *
883
1079
  * Manages ONLY `.devflow/` — never `.claude/` — because user-scope installs must
884
1080
  * not gitignore `.claude/`. This is the init-time counterpart to the always-on
885
- * src/assets/scripts/hooks/ensure-root-gitignore shell helper; both write the identical
886
- * DEVFLOW_GITIGNORE_BLOCK, so the two paths are byte-compatible and mutually
887
- * idempotent. Called unconditionally (independent of install scope and every
888
- * feature toggle) whenever a git root is known.
1081
+ * src/assets/scripts/hooks/ensure-root-gitignore shell helper; both resolve the same
1082
+ * shape for a given .gitignore — DEVFLOW_GITIGNORE_BLOCK, or
1083
+ * DEVFLOW_GITIGNORE_BLOCK_WITHOUT_CLAUDEIGNORE when the project owns that entry — and
1084
+ * emit identical bytes, so the two paths are byte-compatible and mutually idempotent.
1085
+ * Called unconditionally (independent of install scope and every feature toggle)
1086
+ * whenever a git root is known.
889
1087
  *
890
- * Uses a versioned marker file (`.devflow/.root-gitignore-configured-v3`) for fast-path
891
- * detection — the same pattern as the shell twin. Bumping the version forces existing
892
- * installs to re-run once and upgrade their block (v2→v3: adds conventions.md line).
1088
+ * Uses a versioned project-local marker file (`.devflow/.root-gitignore-configured-v5`)
1089
+ * for fast-path detection — the same pattern as the shell twin. The marker is a claim,
1090
+ * not proof, so even a marked install re-reads .gitignore and re-runs
1091
+ * computeDevflowGitignore; bumping the version forces a re-run once per install, which
1092
+ * is how a v4-marked project gains the policy line and is re-stamped v5.
893
1093
  *
894
- * Idempotent: already-v3 installs return immediately (marker fast-path). Errors are
895
- * swallowed (verbose-logged) — a gitignore write must never abort init.
1094
+ * Idempotent: computeDevflowGitignore returns null for a converged file, so a
1095
+ * marked install performs one read and no write. Errors are swallowed
1096
+ * (verbose-logged) — a gitignore write must never abort init.
896
1097
  */
897
1098
  export async function ensureDevflowGitignore(gitRoot, verbose) {
898
1099
  try {
899
1100
  const devflowDir = path.join(gitRoot, '.devflow');
900
- const markerV3 = path.join(devflowDir, GITIGNORE_MARKER_V3);
1101
+ const markerV5 = path.join(devflowDir, GITIGNORE_MARKER_V5);
901
1102
  const gitignorePath = path.join(gitRoot, '.gitignore');
902
- // Fast-path with verification: v3 marker normally means the block is installed,
1103
+ // Fast-path with verification: v5 marker normally means the block is installed,
903
1104
  // but the marker is a claim, not proof — a merge-conflict resolution may have
904
1105
  // dropped the block. Even when the marker exists, read .gitignore (one cheap
905
1106
  // read) and run computeDevflowGitignore; write only when it returns non-null.
906
- // Idempotent: sentinel present → computeDevflowGitignore returns null → no write.
907
- let v3Marked = false;
1107
+ // Idempotent: converged file → computeDevflowGitignore returns null → no write.
1108
+ let v5Marked = false;
908
1109
  try {
909
- await fs.access(markerV3);
910
- v3Marked = true;
1110
+ await fs.access(markerV5);
1111
+ v5Marked = true;
911
1112
  }
912
1113
  catch { /* absent */ }
913
- if (v3Marked) {
1114
+ if (v5Marked) {
914
1115
  let existingContent = '';
915
1116
  try {
916
1117
  existingContent = await fs.readFile(gitignorePath, 'utf-8');
@@ -920,9 +1121,10 @@ export async function ensureDevflowGitignore(gitRoot, verbose) {
920
1121
  if (healContent !== null) {
921
1122
  await fs.writeFile(gitignorePath, healContent, 'utf-8');
922
1123
  if (verbose) {
923
- p.log.success('.gitignore configured (.devflow/ local; feature knowledge + conventions shared)');
1124
+ p.log.success('.gitignore configured (.devflow/ local; feature knowledge + conventions + evidence policy shared)');
924
1125
  }
925
1126
  }
1127
+ await removeLegacyGitignoreMarkers(devflowDir);
926
1128
  return;
927
1129
  }
928
1130
  let gitignoreContent = '';
@@ -934,16 +1136,13 @@ export async function ensureDevflowGitignore(gitRoot, verbose) {
934
1136
  if (newContent !== null) {
935
1137
  await fs.writeFile(gitignorePath, newContent, 'utf-8');
936
1138
  if (verbose) {
937
- p.log.success('.gitignore configured (.devflow/ local; feature knowledge + conventions shared)');
1139
+ p.log.success('.gitignore configured (.devflow/ local; feature knowledge + conventions + evidence policy shared)');
938
1140
  }
939
1141
  }
940
- // Stamp v3 marker so subsequent runs fast-path; drop the legacy v2 marker.
1142
+ // Stamp v5 marker so subsequent runs fast-path; drop every legacy marker.
941
1143
  await fs.mkdir(devflowDir, { recursive: true });
942
- await fs.writeFile(markerV3, '', 'utf-8');
943
- try {
944
- await fs.rm(path.join(devflowDir, GITIGNORE_MARKER_V2), { force: true });
945
- }
946
- catch { /* ok if absent */ }
1144
+ await fs.writeFile(markerV5, '', 'utf-8');
1145
+ await removeLegacyGitignoreMarkers(devflowDir);
947
1146
  }
948
1147
  catch (error) {
949
1148
  if (verbose) {