devflow-kit 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/dist/agents/git.md +2 -2
  4. package/dist/cli/agents-view/index.js +1 -1
  5. package/dist/cli/agents-view/render.js +71 -17
  6. package/dist/cli/agents-view/state.js +42 -16
  7. package/dist/cli/agents-view/terminal.js +5 -5
  8. package/dist/cli/commands/agents.js +142 -51
  9. package/dist/cli/commands/ambient.js +1 -1
  10. package/dist/cli/commands/attribution-prompts.js +8 -8
  11. package/dist/cli/commands/capture.js +1 -1
  12. package/dist/cli/commands/compliance-prompts.js +8 -8
  13. package/dist/cli/commands/compliance.js +8 -7
  14. package/dist/cli/commands/flags.js +33 -31
  15. package/dist/cli/commands/hud.js +1 -1
  16. package/dist/cli/commands/init-seed.js +9 -9
  17. package/dist/cli/commands/init.js +162 -85
  18. package/dist/cli/commands/install-report.js +10 -10
  19. package/dist/cli/commands/learning.js +302 -136
  20. package/dist/cli/commands/memory.js +36 -15
  21. package/dist/cli/commands/proxy.js +23 -23
  22. package/dist/cli/commands/rules.js +6 -5
  23. package/dist/cli/commands/tracker-prompts.js +6 -6
  24. package/dist/cli/commands/tracker.js +9 -9
  25. package/dist/cli/commands/uninstall.js +183 -59
  26. package/dist/cli/flags-view/render.js +5 -5
  27. package/dist/cli/flags-view/state.js +9 -9
  28. package/dist/cli/flags-view/terminal.js +4 -4
  29. package/dist/cli/tui/cells.js +1 -1
  30. package/dist/cli/tui/terminal.js +6 -6
  31. package/dist/commands/code-review.md +0 -2
  32. package/dist/commands/debug.md +14 -11
  33. package/dist/commands/dynamic-build.md +51 -47
  34. package/dist/commands/dynamic-plan.md +27 -7
  35. package/dist/commands/dynamic-profile.md +17 -3
  36. package/dist/commands/dynamic-tickets.md +18 -4
  37. package/dist/commands/explore.md +9 -3
  38. package/dist/commands/implement.md +20 -16
  39. package/dist/commands/plan.md +13 -9
  40. package/dist/commands/release.md +23 -3
  41. package/dist/commands/research.md +9 -3
  42. package/dist/commands/resolve.md +9 -12
  43. package/dist/commands/self-review.md +0 -2
  44. package/dist/core/agent-frontmatter.js +28 -3
  45. package/dist/core/agent-models.js +204 -42
  46. package/dist/core/agent-state.js +28 -6
  47. package/dist/core/ansi.js +2 -2
  48. package/dist/core/assets.js +1 -1
  49. package/dist/core/cache.js +7 -8
  50. package/dist/core/codex-auth-inspect.js +4 -4
  51. package/dist/core/compliance-compose.js +3 -3
  52. package/dist/core/compliance.js +3 -4
  53. package/dist/core/evidence-policy.js +14 -13
  54. package/dist/core/external-models.js +1 -1
  55. package/dist/core/feature-config.js +71 -13
  56. package/dist/core/feature-switch.js +3 -3
  57. package/dist/core/flags.js +49 -25
  58. package/dist/core/fs-atomic.js +6 -7
  59. package/dist/core/learning-queue-cleanup.js +16 -81
  60. package/dist/core/learning-store.js +61 -0
  61. package/dist/core/linked-path.js +46 -0
  62. package/dist/core/manifest.js +5 -5
  63. package/dist/core/mds-variants.js +13 -13
  64. package/dist/core/model-discovery.js +8 -8
  65. package/dist/core/observations.js +17 -101
  66. package/dist/core/orphan-sweep.js +4 -4
  67. package/dist/core/plugins.js +13 -8
  68. package/dist/core/project-paths.js +9 -13
  69. package/dist/core/proxy-log.js +8 -8
  70. package/dist/core/proxy-state.js +3 -3
  71. package/dist/core/queue-drain.js +31 -0
  72. package/dist/core/reference-sweep.js +6 -6
  73. package/dist/core/teammate-mode-cleanup.js +1 -1
  74. package/dist/core/tracker.js +14 -14
  75. package/dist/hud/colors.js +2 -2
  76. package/dist/hud/components/learning-counts.js +54 -22
  77. package/dist/hud/components/version-badge.js +1 -1
  78. package/dist/skills/git/references/pr/resolve-review-threads.md +2 -2
  79. package/dist/skills/git/references/tracker/github/create-release.md +2 -2
  80. package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
  81. package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
  82. package/dist/targets/claude-code/compliance-install.js +17 -15
  83. package/dist/targets/claude-code/hooks.js +2 -2
  84. package/dist/targets/claude-code/installer.js +59 -32
  85. package/dist/targets/claude-code/legacy.js +1 -1
  86. package/dist/targets/claude-code/post-install.js +135 -45
  87. package/dist/targets/claude-code/tracker-install.js +2 -2
  88. package/package.json +1 -1
  89. package/src/assets/agents/code.md +15 -21
  90. package/src/assets/agents/design.md +4 -2
  91. package/src/assets/agents/diagnose.md +3 -1
  92. package/src/assets/agents/evaluate.md +4 -0
  93. package/src/assets/agents/git.mds +2 -2
  94. package/src/assets/agents/knowledge.md +5 -3
  95. package/src/assets/agents/learning.md +281 -196
  96. package/src/assets/agents/research.md +3 -1
  97. package/src/assets/agents/review.md +5 -3
  98. package/src/assets/agents/scrutinize.md +5 -1
  99. package/src/assets/agents/simplify.md +4 -0
  100. package/src/assets/agents/skim.md +4 -2
  101. package/src/assets/agents/synthesize.md +6 -0
  102. package/src/assets/agents/test.md +18 -10
  103. package/src/assets/agents/triage.md +11 -9
  104. package/src/assets/agents/validate.md +14 -10
  105. package/src/assets/commands/_partials/_decisions.mds +8 -3
  106. package/src/assets/commands/_partials/_docs_root.mds +3 -3
  107. package/src/assets/commands/_partials/_engine.mds +16 -32
  108. package/src/assets/commands/_partials/_knowledge.mds +0 -2
  109. package/src/assets/commands/_partials/_preamble.mds +6 -2
  110. package/src/assets/commands/_partials/_settings.mds +2 -2
  111. package/src/assets/commands/_partials/_tracker.mds +1 -1
  112. package/src/assets/commands/code-review.mds +0 -2
  113. package/src/assets/commands/debug.mds +13 -8
  114. package/src/assets/commands/dynamic-build.mds +18 -12
  115. package/src/assets/commands/dynamic-plan.mds +10 -4
  116. package/src/assets/commands/dynamic-profile.mds +1 -1
  117. package/src/assets/commands/dynamic-tickets.mds +2 -2
  118. package/src/assets/commands/explore.mds +9 -1
  119. package/src/assets/commands/implement.mds +19 -13
  120. package/src/assets/commands/plan.mds +12 -8
  121. package/src/assets/commands/release.md +23 -3
  122. package/src/assets/commands/research.mds +9 -3
  123. package/src/assets/commands/resolve.mds +9 -10
  124. package/src/assets/mds/git/_pr.mds +3 -3
  125. package/src/assets/mds/tracker/_common.mds +1 -1
  126. package/src/assets/mds/tracker/_github.mds +3 -3
  127. package/src/assets/mds/tracker/_jira.mds +3 -3
  128. package/src/assets/mds/tracker/_linear.mds +3 -3
  129. package/src/assets/mds/tracker/_mcp.mds +6 -5
  130. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -2
  131. package/src/assets/scripts/hooks/background-memory-update +97 -33
  132. package/src/assets/scripts/hooks/capture-prompt +4 -3
  133. package/src/assets/scripts/hooks/capture-question +4 -3
  134. package/src/assets/scripts/hooks/capture-turn +5 -20
  135. package/src/assets/scripts/hooks/ensure-devflow-init +14 -2
  136. package/src/assets/scripts/hooks/ensure-proxy +5 -6
  137. package/src/assets/scripts/hooks/ensure-root-gitignore +123 -11
  138. package/src/assets/scripts/hooks/git-marker +71 -0
  139. package/src/assets/scripts/hooks/is-hex-sha +1 -1
  140. package/src/assets/scripts/hooks/json-helper.cjs +345 -944
  141. package/src/assets/scripts/hooks/json-parse +25 -129
  142. package/src/assets/scripts/hooks/lib/decisions-format.cjs +205 -156
  143. package/src/assets/scripts/hooks/lib/learning-store.cjs +3207 -0
  144. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +7 -5
  145. package/src/assets/scripts/hooks/lib/project-paths.cjs +13 -19
  146. package/src/assets/scripts/hooks/lib/render-decisions.cjs +253 -226
  147. package/src/assets/scripts/hooks/memory-worker +10 -0
  148. package/src/assets/scripts/hooks/pre-compact-memory +66 -14
  149. package/src/assets/scripts/hooks/preamble +9 -1
  150. package/src/assets/scripts/hooks/queue-append +55 -23
  151. package/src/assets/scripts/hooks/resolve-project-root +3 -4
  152. package/src/assets/scripts/hooks/session-start-context +146 -45
  153. package/src/assets/scripts/hooks/session-start-memory +33 -11
  154. package/src/assets/scripts/lib/project-config.cjs +2 -2
  155. package/src/assets/scripts/pr-evidence.cjs +3 -3
  156. package/src/assets/scripts/redact-secrets.cjs +20 -20
  157. package/src/assets/scripts/release-trace.cjs +1 -1
  158. package/src/assets/scripts/resolve-evidence-policy.cjs +3 -3
  159. package/src/assets/scripts/resolve-settings.cjs +3 -3
  160. package/src/assets/scripts/verify-evidence.cjs +2 -2
  161. package/src/assets/skills/apply-decisions/SKILL.md +37 -17
  162. package/src/assets/skills/docs-framework/SKILL.md +2 -2
  163. package/src/assets/skills/feature-knowledge/SKILL.md +6 -5
  164. package/src/assets/skills/test-driven-development/SKILL.md +6 -4
  165. package/dist/core/observation-io.js +0 -50
  166. package/src/assets/scripts/hooks/decisions-usage-scan.cjs +0 -131
@@ -3,7 +3,7 @@
3
3
  * `resolve-evidence-policy.cjs`, never a second implementation of it.
4
4
  *
5
5
  * D-POLICY-CJS-SEAM: the resolver is plain CommonJS under src/assets/scripts/,
6
- * outside every tsconfig (PF-043, PF-069), so the interfaces below are
6
+ * outside every tsconfig, so the interfaces below are
7
7
  * TRANSCRIBED from its JSDoc typedefs and are the only shape authority on this
8
8
  * side — open those typedefs before changing anything here. The module is loaded
9
9
  * with `require()` from `scriptsDir()`, which resolves under the package root both
@@ -20,7 +20,7 @@
20
20
  * `lib/project-config.cjs` (loadProjectConfigLib), so the CLI judges a config
21
21
  * file's bytes exactly as the resolvers do.
22
22
  *
23
- * D-POLICY-NO-WRITE (applies ADR-024): `.devflow/project.json` is team-owned, and
23
+ * D-POLICY-NO-WRITE: `.devflow/project.json` is team-owned, and
24
24
  * devflow never writes or replaces a shared file it cannot prove it wrote. This
25
25
  * module therefore imports no fs API; the CLI only PRINTS the bytes a team may
26
26
  * choose to commit (`evidencePolicySuggestion`, and the migration lines of
@@ -101,23 +101,24 @@ function surfaceMismatches(value, surface) {
101
101
  * require() one package script and shape-check it against `surface`. Never
102
102
  * throws: a missing file is `not-found`; a module that throws on load or lacks a
103
103
  * surface key is `unusable`. The caller's type parameter is justified by the
104
- * surface check, which `satisfies` ties to the interface's keys.
104
+ * surface check, which `satisfies` ties to the interface's keys. Exported for
105
+ * src/core/learning-store.ts, which loads the learning store the same way.
105
106
  */
106
- function loadScript(file, surface) {
107
+ export function loadScript(scriptPath, surface) {
107
108
  let loaded;
108
109
  try {
109
- loaded = createRequire(import.meta.url)(file);
110
+ loaded = createRequire(import.meta.url)(scriptPath);
110
111
  }
111
112
  catch (err) {
112
113
  const code = err.code;
113
114
  if (code === 'MODULE_NOT_FOUND')
114
- return { ok: false, error: { kind: 'not-found', path: file } };
115
+ return { ok: false, error: { kind: 'not-found', path: scriptPath } };
115
116
  const detail = err instanceof Error ? err.message : String(err);
116
- return { ok: false, error: { kind: 'unusable', path: file, detail } };
117
+ return { ok: false, error: { kind: 'unusable', path: scriptPath, detail } };
117
118
  }
118
119
  const mismatches = surfaceMismatches(loaded, surface);
119
120
  if (mismatches.length > 0) {
120
- return { ok: false, error: { kind: 'unusable', path: file, detail: `missing or mistyped: ${mismatches.join(', ')}` } };
121
+ return { ok: false, error: { kind: 'unusable', path: scriptPath, detail: `missing or mistyped: ${mismatches.join(', ')}` } };
121
122
  }
122
123
  return { ok: true, value: loaded };
123
124
  }
@@ -167,10 +168,10 @@ export function formatEvidencePolicyUnavailable(error) {
167
168
  /**
168
169
  * The `compliance --status` line: the resolved policy for `opts.dir`, or the
169
170
  * unavailable line when the loader failed — that line is the whole handling
170
- * (ADR-028). The caller passes the compliance state it already read, so the
171
- * manifest is never read twice. `resolve()` makes at most three `gh` calls and
172
- * bounds every subprocess with a timeout, so an offline machine degrades to a
173
- * flagged result rather than a hang.
171
+ * (only a damaged package fails the load). The caller passes the compliance
172
+ * state it already read, so the manifest is never read twice. `resolve()` makes
173
+ * at most three `gh` calls and bounds every subprocess with a timeout, so an
174
+ * offline machine degrades to a flagged result rather than a hang.
174
175
  */
175
176
  export function evidencePolicyStatusLine(loaded, opts) {
176
177
  if (!loaded.ok)
@@ -198,7 +199,7 @@ function suggestedFrameworks(complianceState) {
198
199
  * count); `null` otherwise. The bytes come
199
200
  * from the settings resolver's `serializeProjectSuggestion`, which returns them
200
201
  * only when they read back through the shared parser as exactly what was asked.
201
- * Nothing is written (D-POLICY-NO-WRITE, applies ADR-024).
202
+ * Nothing is written (D-POLICY-NO-WRITE).
202
203
  */
203
204
  export function evidencePolicySuggestion(complianceState, policy, settings) {
204
205
  if (policy.complianceDefault(complianceState) !== 'required')
@@ -9,7 +9,7 @@
9
9
  * in src/core/model-discovery.ts. The TUI picker and --set validation use the
10
10
  * ExternalModelCatalog returned by those functions.
11
11
  *
12
- * applies ADR-013: pure core-layer module, no Claude Code adapter concerns.
12
+ * Pure core-layer module, no Claude Code adapter concerns.
13
13
  *
14
14
  * NOTE: the internal routing runtime package name must NEVER appear in
15
15
  * user-visible strings, CLI output, or error messages. User-facing vocabulary:
@@ -1,5 +1,6 @@
1
1
  import * as path from 'path';
2
2
  import { promises as fs } from 'fs';
3
+ import { firstSymbolicLink } from './linked-path.js';
3
4
  import { getFeatureConfigPath } from './project-paths.js';
4
5
  import { parseTrackerId } from './tracker.js';
5
6
  import { loadProjectConfigLib } from './evidence-policy.js';
@@ -14,7 +15,7 @@ import { loadProjectConfigLib } from './evidence-policy.js';
14
15
  * carrying it would leave a `learning: false` in the file that no longer does
15
16
  * what it says. `decisions` is the pre-rename spelling of `learning`;
16
17
  * `autoCommit` is inert. `features` is deliberately NOT here — it is a live key,
17
- * carried like any other unmanaged key (avoids PF-071).
18
+ * carried like any other unmanaged key.
18
19
  */
19
20
  const RETIRED_CONFIG_KEYS = new Set([
20
21
  'memory', 'learning', 'knowledge', 'decisions', 'autoCommit',
@@ -77,7 +78,7 @@ function coerceConfig(parsed) {
77
78
  if (!isJsonObject(parsed))
78
79
  return null;
79
80
  const p = parsed;
80
- // Coerce reviewPublication: any invalid or absent value → 'auto' (self-heal, ADR-014 idiom).
81
+ // Coerce reviewPublication: any invalid or absent value → 'auto' (self-heal).
81
82
  const rp = p.reviewPublication;
82
83
  const reviewPublication = rp === 'auto' || rp === 'full' || rp === 'off' ? rp : 'auto';
83
84
  // The per-repo tracker override is carried through VERBATIM — never coerced,
@@ -169,23 +170,70 @@ async function readConfigBody(projectRoot, lib = loadProjectConfigLib()) {
169
170
  }
170
171
  return classifyConfigBytes(read.bytes, lib.value);
171
172
  }
173
+ /** A thrown value's message, or the value itself when it is not an Error. */
174
+ function messageOf(err) {
175
+ return err instanceof Error ? err.message : String(err);
176
+ }
172
177
  /**
173
- * Serialise a config body to a project's config file.
178
+ * Serialise a config body to a project's config file. Never throws.
174
179
  * Creates the .devflow/ directory if missing.
175
180
  * Uses an atomic temp+rename pattern to prevent partial reads under concurrent writes.
181
+ * The copy is created only where nothing stands ('wx'), so an entry a repository
182
+ * planted at its name — a symbolic link among them — is never written through
183
+ * (D-CLI-NO-SYMLINK) and, not being this run's, is left where it is; the write then
184
+ * fails and the Result says so. Once this run has created the copy, a write, close or
185
+ * rename that fails removes it again, so a failed write leaves nothing beside the
186
+ * config; the Result names the failure, and the copy too when it could not be removed.
176
187
  */
177
188
  async function writeConfigBody(projectRoot, body) {
178
189
  const configPath = getFeatureConfigPath(projectRoot);
179
- await fs.mkdir(path.join(projectRoot, '.devflow'), { recursive: true });
180
190
  const tmpPath = configPath + '.tmp.' + process.pid;
181
- await fs.writeFile(tmpPath, JSON.stringify(body, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
182
- await fs.rename(tmpPath, configPath);
191
+ let text;
192
+ let copy;
193
+ try {
194
+ text = JSON.stringify(body, null, 2) + '\n';
195
+ await fs.mkdir(path.join(projectRoot, '.devflow'), { recursive: true });
196
+ copy = await fs.open(tmpPath, 'wx', 0o600);
197
+ }
198
+ catch (err) {
199
+ return { ok: false, detail: messageOf(err) };
200
+ }
201
+ try {
202
+ try {
203
+ // The open handle is fs.writeFile's destination and the text its data: the same
204
+ // write as copy.writeFile(text), in the form a path-traversal scan reads right,
205
+ // since it takes a writeFile's first argument for a path.
206
+ await fs.writeFile(copy, text, 'utf-8');
207
+ }
208
+ finally {
209
+ await copy.close();
210
+ }
211
+ await fs.rename(tmpPath, configPath);
212
+ return { ok: true };
213
+ }
214
+ catch (err) {
215
+ return { ok: false, detail: await removeCopy(tmpPath, messageOf(err)) };
216
+ }
217
+ }
218
+ /**
219
+ * Remove the copy a failed config write created, and say why the write failed:
220
+ * `detail`, followed by the removal's own failure when the copy stays. A copy that
221
+ * is already gone counts as removed (`force`).
222
+ */
223
+ async function removeCopy(tmpPath, detail) {
224
+ try {
225
+ await fs.rm(tmpPath, { force: true });
226
+ return detail;
227
+ }
228
+ catch (err) {
229
+ return `${detail}; its copy ${tmpPath} could not be removed: ${messageOf(err)}`;
230
+ }
183
231
  }
184
232
  /**
185
233
  * Merge devflow's managed keys over the config body the file already holds.
186
234
  * Pure — returns a new object and never mutates `existing`.
187
235
  *
188
- * D-CONFIG-PRESERVE-UNMANAGED (avoids PF-071): `.devflow/config.json` is a
236
+ * D-CONFIG-PRESERVE-UNMANAGED: `.devflow/config.json` is a
189
237
  * user-editable file that devflow only PARTLY owns. The managed keys come from
190
238
  * `managed`; every other key comes from the file, verbatim and by key presence
191
239
  * — the hand-written per-repo `tracker` override (whose invalid values must
@@ -227,21 +275,31 @@ export function mergeManagedConfig(existing, managed) {
227
275
  * change. Acceptable because init is a single-threaded, user-initiated command
228
276
  * and the window is milliseconds on a local filesystem; the file swap itself is
229
277
  * atomic (temp + rename), so a reader never sees a partial file.
278
+ *
279
+ * D-CLI-NO-SYMLINK (firstSymbolicLink): a `.devflow` that is a symbolic link is
280
+ * left alone, and the Result says so; the file is neither read nor written there.
230
281
  */
231
282
  export async function writeManagedConfig(projectRoot, managed, lib = loadProjectConfigLib()) {
232
283
  const configPath = getFeatureConfigPath(projectRoot);
284
+ let linked;
285
+ try {
286
+ linked = await firstSymbolicLink([path.dirname(configPath)]);
287
+ }
288
+ catch (err) {
289
+ return { ok: false, error: { kind: 'unreadable', path: configPath, detail: messageOf(err) } };
290
+ }
291
+ if (linked !== null) {
292
+ return { ok: false, error: { kind: 'unreadable', path: configPath, detail: `${linked} is a symbolic link, and devflow writes nothing through one` } };
293
+ }
233
294
  const existing = await readConfigBody(projectRoot, lib);
234
295
  if (existing.kind === 'malformed')
235
296
  return { ok: false, error: { kind: 'malformed', path: configPath } };
236
297
  if (existing.kind === 'unreadable') {
237
298
  return { ok: false, error: { kind: 'unreadable', path: configPath, detail: existing.detail } };
238
299
  }
239
- try {
240
- await writeConfigBody(projectRoot, mergeManagedConfig(objectOf(existing), managed));
241
- }
242
- catch (err) {
243
- return { ok: false, error: { kind: 'write-failed', path: configPath, detail: err instanceof Error ? err.message : String(err) } };
244
- }
300
+ const written = await writeConfigBody(projectRoot, mergeManagedConfig(objectOf(existing), managed));
301
+ if (!written.ok)
302
+ return { ok: false, error: { kind: 'write-failed', path: configPath, detail: written.detail } };
245
303
  return { ok: true };
246
304
  }
247
305
  /**
@@ -6,7 +6,7 @@ function isJsonObject(value) {
6
6
  }
7
7
  /**
8
8
  * The pre-rename key each machine feature was stored under, where one exists:
9
- * `learning` was `decisions` (ADR-011) and `knowledge` was `kb`. A manifest no
9
+ * `learning` was `decisions` and `knowledge` was `kb`. A manifest no
10
10
  * command has rewritten since the rename can still hold only the legacy key.
11
11
  * `memory` was never renamed.
12
12
  */
@@ -19,7 +19,7 @@ const LEGACY_KEYS = {
19
19
  *
20
20
  * Only an explicit boolean `false` switches a feature off. A missing key, a
21
21
  * non-boolean value, or anything that is not a manifest-shaped object reads as
22
- * ON — fail-open (ADR-028), and the exact rule `queue_read_gates` applies in the
22
+ * ON — fail-open, and the exact rule `queue_read_gates` applies in the
23
23
  * shell hooks, so the CLI's status and the runtime never disagree about the
24
24
  * same file.
25
25
  *
@@ -37,7 +37,7 @@ const LEGACY_KEYS = {
37
37
  * precedence exactly, so `devflow knowledge --status` reports a `kb: false`
38
38
  * as disabled. queue_read_gates never reads knowledge, so there is no shell
39
39
  * mirror. The knowledge write-back prose gate deliberately does not learn the
40
- * legacy key (ADR-028: no prompt text for a state only an un-upgraded install
40
+ * legacy key (no prompt text for a state only an un-upgraded install
41
41
  * can hold); readManifest rewrites `kb` to `knowledge` on the next CLI run
42
42
  * that loads the manifest, after which that gate reads the healed key.
43
43
  *
@@ -7,7 +7,7 @@
7
7
  * D14: Typed registry — flags carry kind (boolean|enum|number|string), target
8
8
  * (env|setting), and per-kind defaultValue. Neutral values delete their target
9
9
  * key; active values write the appropriate payload. Number 0 is ACTIVE. Sink
10
- * validation via coerceFlagValue (applies PF-023: validate at the convergence
10
+ * validation via coerceFlagValue (validate at the convergence
11
11
  * point every caller reaches). applyFlags(settingsJson, FlagsRecord) is the
12
12
  * sole API; init.ts works directly with FlagsRecord (no legacy string[] bridge).
13
13
  */
@@ -139,7 +139,7 @@ export const FLAG_REGISTRY = [
139
139
  {
140
140
  // Devflow fan-outs routinely exceed the upstream default of 20.
141
141
  // Set to 40 by default so parallel Code/Review/Research waves don't
142
- // silently queue. upstreamDefault recorded for display. (applies PF-023 bounds)
142
+ // silently queue. upstreamDefault recorded for display.
143
143
  id: 'max-concurrent-subagents',
144
144
  label: 'Max concurrent subagents',
145
145
  description: 'Maximum number of subagents Claude Code will spawn concurrently',
@@ -150,7 +150,7 @@ export const FLAG_REGISTRY = [
150
150
  recommended: true,
151
151
  defaultValue: 40,
152
152
  min: 1,
153
- max: 100, // devflow sanity bound (applies PF-023)
153
+ max: 100, // devflow sanity bound
154
154
  integer: true,
155
155
  upstreamDefault: 20,
156
156
  },
@@ -341,7 +341,7 @@ export const FLAG_REGISTRY = [
341
341
  // ── Valued flags (number/enum/string) ────────────────────────────────────
342
342
  {
343
343
  // Domain: unset by default; set only when users want a non-default spawn depth.
344
- // upstreamDefault: 3 (recorded for display). PF-023 bounds: max 10.
344
+ // upstreamDefault: 3 (recorded for display). Sanity bound: max 10.
345
345
  id: 'subagent-spawn-depth',
346
346
  label: 'Max subagent spawn depth',
347
347
  description: 'Maximum depth of nested subagent spawning',
@@ -352,7 +352,7 @@ export const FLAG_REGISTRY = [
352
352
  recommended: false,
353
353
  defaultValue: undefined,
354
354
  min: 1,
355
- max: 10, // devflow sanity bound (applies PF-023)
355
+ max: 10, // devflow sanity bound
356
356
  integer: true,
357
357
  upstreamDefault: 3,
358
358
  },
@@ -384,7 +384,7 @@ export const FLAG_REGISTRY = [
384
384
  },
385
385
  {
386
386
  // Upstream default: 30 min. 0 = disabled (still ACTIVE — written to env).
387
- // PF-023 bounds: max 1440 (24h). min 0 (0 = off, explicit value not neutral).
387
+ // Sanity bounds: max 1440 (24h). min 0 (0 = off, explicit value not neutral).
388
388
  id: 'goal-checkin-minutes',
389
389
  label: 'Goal check-in interval',
390
390
  description: 'Interval in minutes for Claude to check in on task goals',
@@ -395,10 +395,34 @@ export const FLAG_REGISTRY = [
395
395
  recommended: false,
396
396
  defaultValue: undefined,
397
397
  min: 0, // 0 = off (ACTIVE, not neutral — written as "0")
398
- max: 1440, // devflow sanity bound: 24 hours (applies PF-023)
398
+ max: 1440, // devflow sanity bound: 24 hours
399
399
  integer: true,
400
400
  upstreamDefault: 30,
401
401
  },
402
+ {
403
+ // D-FOREGROUND-RUN: every agent runs builds and tests in the foreground under an explicit
404
+ // Bash `timeout` whose ceiling is Claude Code's 600000 ms, or this variable when set. A
405
+ // suite that cannot be split under that ceiling is reported BLOCKED with the remedy
406
+ // `devflow flags --set bash-max-timeout-ms=<ms>`, so this flag is that remedy.
407
+ // The env name was confirmed against Claude Code 2.1.294 (docs/reference/claude-code-flags-probe.md):
408
+ // the binary reads BASH_MAX_TIMEOUT_MS, ignores a non-positive or NaN value, and takes the
409
+ // larger of it and the default timeout. Neutral by default (undefined → manifest null →
410
+ // key deleted). min 600000 = upstream default, so the flag only ever raises the ceiling;
411
+ // max 7200000 (2 h) is the devflow sanity bound.
412
+ id: 'bash-max-timeout-ms',
413
+ label: 'Bash max timeout',
414
+ description: 'Ceiling in milliseconds for a foreground Bash command timeout',
415
+ hint: 'Raises the Bash timeout ceiling past 600000 ms for long-running suites',
416
+ blurb: 'Bash timeout ceiling',
417
+ kind: 'number',
418
+ target: { type: 'env', key: 'BASH_MAX_TIMEOUT_MS' },
419
+ recommended: false,
420
+ defaultValue: undefined,
421
+ min: 600000, // upstream default ceiling: lower values would only shrink it
422
+ max: 7200000, // devflow sanity bound: 2 hours
423
+ integer: true,
424
+ upstreamDefault: 600000,
425
+ },
402
426
  {
403
427
  // Writes as { command: value } per Claude Code spellcheck setting shape.
404
428
  id: 'spellcheck',
@@ -411,7 +435,7 @@ export const FLAG_REGISTRY = [
411
435
  recommended: false,
412
436
  defaultValue: undefined,
413
437
  wrapKey: 'command',
414
- maxLength: 256, // devflow sanity bound (applies PF-023)
438
+ maxLength: 256, // devflow sanity bound
415
439
  },
416
440
  {
417
441
  // view-mode folded into the registry; neutralValue 'default' deletes the viewMode key.
@@ -474,7 +498,7 @@ export function isNeutral(flag, value) {
474
498
  /**
475
499
  * Map a record value to a TUI value.
476
500
  *
477
- * viewMode GLUE RULE (PF-017 one-shared-definition corollary): the mapping lives here,
501
+ * viewMode GLUE RULE (one shared definition): the mapping lives here,
478
502
  * next to neutralValueOf — the definition it depends on — not across a module boundary.
479
503
  * enum with neutralValue: neutralValue → null in TUI (null is the TUI representation
480
504
  * of "use the default"; the key is deleted when persisted).
@@ -494,7 +518,7 @@ export function recordToTui(flag, v) {
494
518
  /**
495
519
  * Map a TUI value back to a record value.
496
520
  *
497
- * viewMode GLUE RULE (PF-017 one-shared-definition corollary): inverse of recordToTui,
521
+ * viewMode GLUE RULE (one shared definition): inverse of recordToTui,
498
522
  * co-located with that function so the round-trip contract is auditable in one place.
499
523
  * enum with neutralValue: null → neutralValue (e.g. 'default').
500
524
  * All other values pass through unchanged.
@@ -509,7 +533,7 @@ export function tuiToRecord(flag, v) {
509
533
  }
510
534
  /**
511
535
  * Validate and coerce `raw` to a safe value for `flag` at the sink.
512
- * Returns null when the value is invalid (hostile-value defence — applies PF-023).
536
+ * Returns null when the value is invalid (hostile-value defence).
513
537
  *
514
538
  * Number invariants: finite, within [min, max], integer when required.
515
539
  * String invariants: within maxLength, no control characters.
@@ -567,7 +591,7 @@ export function coerceFlagValue(flag, raw) {
567
591
  * Parse a CLI text input to a FlagsRecordValue.
568
592
  * 'unset' (literal) → null for any flag.
569
593
  *
570
- * Number branch uses strict decimal grammar (applies PF-023 — invariant at the sink
594
+ * Number branch uses strict decimal grammar (invariant at the sink
571
595
  * every caller reaches, not per-caller): rejects empty, padded, hex, exponent,
572
596
  * and leading-zero forms. Equivalent to the TUI's strict parsing so both entry
573
597
  * points share one grammar.
@@ -749,7 +773,7 @@ export function readViewMode(record) {
749
773
  * Sanitize a FlagsRecord by coercing each known flag's value through
750
774
  * coerceFlagValue.
751
775
  *
752
- * Known flag IDs (applies ADR-014 key-presence semantics):
776
+ * Known flag IDs (key-presence semantics):
753
777
  * - explicit null input → kept as null (deliberately unset)
754
778
  * - valid non-null input → kept as coerced value
755
779
  * - invalid non-null input → KEY DROPPED (absent = adopt default on next init,
@@ -758,7 +782,7 @@ export function readViewMode(record) {
758
782
  * Unknown flag IDs (forward-compat):
759
783
  * - primitive values (boolean, number, string, null) → kept as-is
760
784
  * - non-primitive values (objects, arrays) → DROPPED to avoid laundering
761
- * untrusted shapes into FlagsRecordValue (applies PF-023)
785
+ * untrusted shapes into FlagsRecordValue
762
786
  *
763
787
  * D39: `__proto__`, `constructor`, `prototype` are always skipped.
764
788
  */
@@ -823,7 +847,7 @@ export function getDefaultFlagsRecord() {
823
847
  * Migrate a legacy (string-array) enabled-flags manifest to a typed FlagsRecord.
824
848
  * Called by manifest.ts self-healing when it encounters an old string-array manifest.
825
849
  *
826
- * Contract (applies ADR-014 transition semantics):
850
+ * Contract:
827
851
  * - knownIds defined → knownSet = knownIds ∪ enabledIds
828
852
  * - knownIds undefined → knownSet = full current registry ∪ enabledIds
829
853
  * (pre-knownFlags manifests: all flags known, so adopt-nothing is expressed
@@ -884,7 +908,7 @@ export function settingValueHoldsManagedShape(flag, value) {
884
908
  return isDeepStrictEqual(value, guard);
885
909
  }
886
910
  /**
887
- * D-ATTR-GUARD single-source predicate (consistency-01 / ADR-024).
911
+ * D-ATTR-GUARD single-source predicate (consistency-01).
888
912
  *
889
913
  * Returns true when the settings.json string contains a value at the flag's
890
914
  * target key that equals the flag's managed shape (`settingDeleteGuard`).
@@ -926,7 +950,7 @@ export function settingHoldsManagedShape(settingsJson, flagId) {
926
950
  *
927
951
  * Delegates to `settingValueHoldsManagedShape` — the single equality oracle for
928
952
  * managed-shape comparisons — so there is exactly one `isDeepStrictEqual` call
929
- * across the entire apply/strip pipeline (ADR-024 mechanism 3).
953
+ * across the entire apply/strip pipeline.
930
954
  *
931
955
  * Single-source invariant: both the disable path (applyFlags neutral branch) and
932
956
  * the uninstall path (stripFlags) collapse to this predicate.
@@ -980,7 +1004,7 @@ function buildPayload(flag, value) {
980
1004
  * Apply a FlagsRecord to a settings JSON string.
981
1005
  *
982
1006
  * - Unknown flag IDs are skipped (forward-compatible with future flags).
983
- * - `coerceFlagValue` is called at the sink before applying (applies PF-023).
1007
+ * - `coerceFlagValue` is called at the sink before applying.
984
1008
  * - Neutral values delete their target key.
985
1009
  * - Env payloads for number flags are stringified ('40', never 40).
986
1010
  * - Setting payloads for string flags with wrapKey are shaped ({ command: v }).
@@ -988,7 +1012,7 @@ function buildPayload(flag, value) {
988
1012
  * - `__proto__`, `constructor`, `prototype` keys are silently skipped.
989
1013
  */
990
1014
  export function applyFlags(settingsJson, flags) {
991
- // REL-M2 sink guard (applies PF-023): a non-plain-object root (null, array, scalar)
1015
+ // REL-M2 sink guard: a non-plain-object root (null, array, scalar)
992
1016
  // would cause a silent no-op or a confusing TypeError deep inside the loop.
993
1017
  // Throw early with a clear message so every caller path is self-guarding.
994
1018
  const root = JSON.parse(settingsJson);
@@ -1003,7 +1027,7 @@ export function applyFlags(settingsJson, flags) {
1003
1027
  const flag = FLAG_REGISTRY_MAP.get(id);
1004
1028
  if (!flag)
1005
1029
  continue; // unknown id — skip for forward compat
1006
- // Coerce at the sink (applies PF-023: validate at the convergence point)
1030
+ // Coerce at the sink (validate at the convergence point)
1007
1031
  const safe = coerceFlagValue(flag, value);
1008
1032
  if (isNeutral(flag, safe)) {
1009
1033
  // Neutral → delete the target key
@@ -1045,7 +1069,7 @@ export function applyFlags(settingsJson, flags) {
1045
1069
  * Cleans up empty env object. Strip-then-apply idempotence preserved (INV-1).
1046
1070
  */
1047
1071
  export function stripFlags(settingsJson) {
1048
- // REL-M2 sink guard (applies PF-023): mirror of applyFlags — throw early on a
1072
+ // REL-M2 sink guard: mirror of applyFlags — throw early on a
1049
1073
  // non-plain-object root so every caller path is self-guarding.
1050
1074
  const root = JSON.parse(settingsJson);
1051
1075
  if (root === null || typeof root !== 'object' || Array.isArray(root)) {
@@ -1122,7 +1146,7 @@ export function resolveFinalViewMode(current, selected, explicit) {
1122
1146
  // ─── Fold-before-strip pipeline ───────────────────────────────────────────────
1123
1147
  /**
1124
1148
  * Fold-before-strip pipeline — the single authoritative entry point for all
1125
- * settings.json mutation paths (applies PF-015, PF-017, ADR-014).
1149
+ * settings.json mutation paths.
1126
1150
  *
1127
1151
  * Both `init.ts` and `persistFlagConfig` (flags.ts) MUST call this instead of
1128
1152
  * invoking `stripFlags` + `applyFlags` directly; the invariant lives in the
@@ -1143,7 +1167,7 @@ export function resolveFinalViewMode(current, selected, explicit) {
1143
1167
  * A flag is "claimed" when it is present and non-null in the claimed set.
1144
1168
  * Claimed: record value wins (devflow previously set this value).
1145
1169
  * Unclaimed: fold from settings — if the user has a value in settings.json,
1146
- * adopt it into the record (ADR-014 adoption, devflow takes ownership).
1170
+ * adopt it into the record (devflow takes ownership).
1147
1171
  *
1148
1172
  * Boolean flags: never folded — on/off is always record-driven.
1149
1173
  *
@@ -1166,7 +1190,7 @@ export function resolveFinalViewMode(current, selected, explicit) {
1166
1190
  */
1167
1191
  export function convergeFlagsIntoSettings(settingsJson, record, opts) {
1168
1192
  // ── Step 1: fold view-mode (must read pre-strip) ──────────────────────────
1169
- // PF-015: resolveExistingViewMode reads the viewMode key. stripFlags removes
1193
+ // resolveExistingViewMode reads the viewMode key. stripFlags removes
1170
1194
  // it as part of the view-mode registry entry. Reading after strip silently
1171
1195
  // reverts an externally-set /focus.
1172
1196
  const folded = {
@@ -1229,7 +1253,7 @@ export function convergeFlagsIntoSettings(settingsJson, record, opts) {
1229
1253
  }
1230
1254
  }
1231
1255
  // ── Step 2b: adopt guarded boolean settings before the strip ─────────────
1232
- // D-ATTR-ADOPT (PF-050 / ADR-024): a delete guard is evidence about the VALUE,
1256
+ // D-ATTR-ADOPT: a delete guard is evidence about the VALUE,
1233
1257
  // never about the record — a pre-existing on-disk key whose value matches the
1234
1258
  // managed shape means devflow wrote it, so adopt it into the record now, before
1235
1259
  // stripFlags can unconditionally remove it on the next line.
@@ -5,11 +5,10 @@ import { promises as fs } from 'fs';
5
5
  * D34: Canonical atomic-write helper for the TypeScript CLI surface.
6
6
  *
7
7
  * Call sites: used by the CLI's exclusive-write call sites (migrations, init, post-install,
8
- * uninstall, security, ambient, memory, HUD, observation I/O).
9
- * The CJS counterpart (`writeExclusive` in `src/assets/scripts/hooks/json-helper.cjs` and
10
- * `src/assets/scripts/hooks/decisions-usage-scan.cjs`) intentionally remains a separate
11
- * implementation — same semantics, different module system. Any change to the
12
- * retry logic here MUST be mirrored in both CJS files.
8
+ * uninstall, security, ambient, memory, HUD).
9
+ * The CJS counterpart (`writeExclusive` in `src/assets/scripts/hooks/lib/learning-store.cjs`)
10
+ * intentionally remains a separate implementation — same semantics, different module
11
+ * system. Any change to the retry logic here MUST be mirrored there.
13
12
  */
14
13
  /**
15
14
  * Atomically write `filePath` by writing to a sibling `.tmp` then renaming.
@@ -34,7 +33,7 @@ export async function writeFileAtomicExclusive(filePath, data) {
34
33
  // PID-scope the tmp name so concurrent writers from different processes
35
34
  // (e.g., two Claude Code sessions) never collide on the same .tmp path.
36
35
  // mirrors proxy-log.ts rotation at src/core/proxy-log.ts which PID-scopes
37
- // for the same reason. avoids PF-011.
36
+ // for the same reason.
38
37
  const tmp = `${filePath}.tmp.${process.pid}`;
39
38
  try {
40
39
  await fs.writeFile(tmp, data, { encoding: 'utf-8', flag: 'wx' });
@@ -58,7 +57,7 @@ export async function writeFileAtomicExclusive(filePath, data) {
58
57
  //
59
58
  // Non-fatal path: if stat fails (ENOENT → fresh file, or any other I/O
60
59
  // error), skip chmod and keep the umask default — the write must still
61
- // complete correctly (avoids PF-009 failure-isolation principle).
60
+ // complete correctly.
62
61
  try {
63
62
  const { mode } = await fs.stat(filePath);
64
63
  // mode includes file-type bits; mask to permission bits only for chmod.
@@ -1,92 +1,27 @@
1
1
  /**
2
2
  * @file learning-queue-cleanup.ts
3
3
  *
4
- * Shared cleanup helpers for `.devflow/learning/`. Imported solely by
5
- * `src/cli/commands/learning.ts`:
6
- * - `sweepLegacyDreamMarkers` — used by `devflow learning --reset`
7
- * - `drainLearningQueue` — used by `devflow learning --clear` / `--disable`
4
+ * `drainLearningQueue`, the learning queue drain shared by `devflow learning
5
+ * --clear` and `--disable` (src/cli/commands/learning.ts) and by the drain
6
+ * `devflow init` runs when learning is switched off (src/cli/commands/init.ts).
8
7
  */
9
- import { promises as fs } from 'fs';
10
8
  import * as path from 'path';
11
- import { getLearningPendingTurnsPath, getLearningPendingTurnsProcessingPath, } from './project-paths.js';
12
- // ---------------------------------------------------------------------------
13
- // Legacy marker-pipeline sweep
14
- // ---------------------------------------------------------------------------
15
- /** Fixed-name stamps left by the retired dream marker pipeline. */
16
- const LEGACY_FIXED_STAMPS = ['.decisions-runs-today', '.curation-last', '.processor-spawned-at'];
17
- /** Per-session marker variants: (decisions|curation).*.{json,processing,retries,failed} */
18
- function isLegacyPerSessionMarker(name) {
19
- return ((name.startsWith('decisions.') || name.startsWith('curation.')) &&
20
- (name.endsWith('.json') || name.endsWith('.processing') || name.endsWith('.retries') || name.endsWith('.failed')));
21
- }
22
- /**
23
- * Sweep legacy marker-pipeline files from a `.devflow/learning/` directory:
24
- * the fixed-name stamps above, plus per-session `decisions.*`/`curation.*`
25
- * markers. Never touches `learning.json` or the live
26
- * `.pending-turns.jsonl`/`.pending-turns.processing` queue files.
27
- *
28
- * ENOENT-idempotent (missing learning dir or already-removed files are not
29
- * errors). Non-ENOENT errors are rethrown — callers that need best-effort
30
- * semantics (e.g. `--reset`, which must still finish releasing its lock)
31
- * should wrap the call in their own try/catch.
32
- *
33
- * @returns number of files removed
34
- */
35
- export async function sweepLegacyDreamMarkers(learningDir) {
36
- let removed = 0;
37
- for (const name of LEGACY_FIXED_STAMPS) {
38
- try {
39
- await fs.unlink(path.join(learningDir, name));
40
- removed++;
41
- }
42
- catch (err) {
43
- const code = err.code;
44
- if (code !== 'ENOENT')
45
- throw err;
46
- }
47
- }
48
- try {
49
- const entries = await fs.readdir(learningDir);
50
- for (const entry of entries) {
51
- if (isLegacyPerSessionMarker(entry)) {
52
- try {
53
- await fs.unlink(path.join(learningDir, entry));
54
- removed++;
55
- }
56
- catch (err) {
57
- const code = err.code;
58
- if (code !== 'ENOENT')
59
- throw err;
60
- }
61
- }
62
- }
63
- }
64
- catch (err) {
65
- const code = err.code;
66
- if (code !== 'ENOENT')
67
- throw err;
68
- }
69
- return removed;
70
- }
71
- // ---------------------------------------------------------------------------
72
- // Live queue drain
73
- // ---------------------------------------------------------------------------
9
+ import { getLearningClaimOwnerPath, getLearningDir, getLearningPendingTurnsPath, getLearningPendingTurnsProcessingPath, } from './project-paths.js';
10
+ import { drainQueueFiles } from './queue-drain.js';
74
11
  /**
75
- * Drain the learning (decisions-detection) pending-turns queue so stale turns
76
- * don't process later — used by both `--clear` and `--disable`. A mid-run
77
- * Learning agent whose claimed batch vanishes aborts without changes, which is
78
- * the desired outcome in both cases. ENOENT-tolerant; other errors propagate.
12
+ * Drain the learning (decisions-detection) pending-turns queue, its claimed
13
+ * batch and the claim's owner file so stale turns don't process later — used by
14
+ * both `--clear` and `--disable`. A mid-run Learning agent whose claimed batch
15
+ * vanishes aborts without changes, which is the desired outcome in both cases.
16
+ * Refused, deleting nothing, when `.devflow` or `.devflow/learning` under `gitRoot`
17
+ * is a symbolic link (D-CLI-NO-SYMLINK). ENOENT-tolerant; other errors propagate.
79
18
  */
80
19
  export async function drainLearningQueue(gitRoot) {
81
- await Promise.all([
82
- fs.unlink(getLearningPendingTurnsPath(gitRoot)).catch((e) => {
83
- if (e.code !== 'ENOENT')
84
- throw e;
85
- }),
86
- fs.unlink(getLearningPendingTurnsProcessingPath(gitRoot)).catch((e) => {
87
- if (e.code !== 'ENOENT')
88
- throw e;
89
- }),
20
+ const learningDir = getLearningDir(gitRoot);
21
+ return drainQueueFiles([path.dirname(learningDir), learningDir], [
22
+ getLearningPendingTurnsPath(gitRoot),
23
+ getLearningPendingTurnsProcessingPath(gitRoot),
24
+ getLearningClaimOwnerPath(gitRoot),
90
25
  ]);
91
26
  }
92
27
  //# sourceMappingURL=learning-queue-cleanup.js.map